Compare commits
230
Commits
34eae37ba4
...
a8144e9acf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8144e9acf | ||
|
|
f9bea768f4 | ||
|
|
2a893e1a26 | ||
|
|
1e3bd7a8c0 | ||
|
|
52f5059159 | ||
|
|
4e009c7769 | ||
|
|
168122311a | ||
|
|
7c242b934c | ||
|
|
002e042d44 | ||
|
|
a7522d29bb | ||
|
|
86bb254334 | ||
|
|
7dc888eeae | ||
|
|
108bfae960 | ||
|
|
f0c799d5b4 | ||
|
|
ec5d8b18d8 | ||
|
|
7acaf74e10 | ||
|
|
46e49626b9 | ||
|
|
5a6780be2b | ||
|
|
bc63784752 | ||
|
|
6b1b180f66 | ||
|
|
d542038d36 | ||
|
|
e4e2a9d9c2 | ||
|
|
f607f7a930 | ||
|
|
3bd356c1f6 | ||
|
|
83801bad46 | ||
|
|
acab70c556 | ||
|
|
6b8241a356 | ||
|
|
cceb4def1e | ||
|
|
62869fc26a | ||
|
|
8b2273c6de | ||
|
|
2a18d33c23 | ||
|
|
74614f0e7c | ||
|
|
1404eda72b | ||
|
|
cac3f0b5ed | ||
|
|
e9c0000f5f | ||
|
|
1edf27e040 | ||
|
|
b5a44a1e56 | ||
|
|
34a30fb58d | ||
|
|
dda06ae351 | ||
|
|
008c0839cd | ||
|
|
a1e83aded7 | ||
|
|
0c39711432 | ||
|
|
c1f3898056 | ||
|
|
8a7a658260 | ||
|
|
37fb6e2f4e | ||
|
|
8a6c578a76 | ||
|
|
5684d5fe09 | ||
|
|
51148d71ee | ||
|
|
d2c94396aa | ||
|
|
07e1adcbba | ||
|
|
137689c5c1 | ||
|
|
d77c64e2ce | ||
|
|
94c2a8ffb2 | ||
|
|
1676eaa328 | ||
|
|
6d7d365892 | ||
|
|
fd9e5fc689 | ||
|
|
2f9fa22a56 | ||
|
|
39aaa62008 | ||
|
|
220abb10a1 | ||
|
|
14ba958513 | ||
|
|
0e42cbe4c8 | ||
|
|
9769e5b543 | ||
|
|
101eb9b614 | ||
|
|
bdb9dfca55 | ||
|
|
b1acd830d2 | ||
|
|
8f49fe4902 | ||
|
|
e346fa1adf | ||
|
|
55d81a6c53 | ||
|
|
db21715cf9 | ||
|
|
dce3eb94c2 | ||
|
|
1190ee9735 | ||
|
|
eba0f1f8a1 | ||
|
|
298ada79b7 | ||
|
|
7b27f6e3e2 | ||
|
|
02a16a6422 | ||
|
|
6b6683a026 | ||
|
|
45a9f525e4 | ||
|
|
75ecbce1fc | ||
|
|
4604d4c67a | ||
|
|
16102825b4 | ||
|
|
2d80b6248c | ||
|
|
0e0a584d85 | ||
|
|
32a96fb5b2 | ||
|
|
b41141fa8f | ||
|
|
0678fd195a | ||
|
|
fb7efe76f6 | ||
|
|
9e70dca97c | ||
|
|
e7d72be574 | ||
|
|
3981eb53f8 | ||
|
|
ca26b0660a | ||
|
|
5d90f3317f | ||
|
|
37e287c6c7 | ||
|
|
3daa8dfae8 | ||
|
|
7320ac8d01 | ||
|
|
96dbdb8050 | ||
|
|
ac955c5692 | ||
|
|
5731868a20 | ||
|
|
b1d363b33c | ||
|
|
fbe37e0f63 | ||
|
|
1e0a7a0dd8 | ||
|
|
bb977f2614 | ||
|
|
209334d609 | ||
|
|
f55ef1ffb9 | ||
|
|
ba7cd19564 | ||
|
|
80e177228f | ||
|
|
51d0188cc5 | ||
|
|
5eb8390921 | ||
|
|
09dd59e9aa | ||
|
|
5eef5f3bd3 | ||
|
|
9fe6698d17 | ||
|
|
79efe1d6a8 | ||
|
|
dd594080c3 | ||
|
|
5f411ddaf7 | ||
|
|
b9a82655bd | ||
|
|
bd722334d3 | ||
|
|
977fddfbff | ||
|
|
e2535cc7c3 | ||
|
|
52b624d3c8 | ||
|
|
4115960a42 | ||
|
|
4fd82a0952 | ||
|
|
4ae673b6d1 | ||
|
|
94d0589638 | ||
|
|
0f4e3d7110 | ||
|
|
d1273171a6 | ||
|
|
947d945827 | ||
|
|
41fa4ad0b0 | ||
|
|
2fc8c90d9f | ||
|
|
946d9ae3b9 | ||
|
|
f917b71960 | ||
|
|
ecf1e51915 | ||
|
|
052277bb56 | ||
|
|
7e167ea238 | ||
|
|
688d388551 | ||
|
|
012a499f74 | ||
|
|
14c97d7c19 | ||
|
|
9454d4a27a | ||
|
|
5c3d5f98a2 | ||
|
|
fd767f5c29 | ||
|
|
fd7384e472 | ||
|
|
3d812ae5ec | ||
|
|
5bafac3de8 | ||
|
|
319255c54b | ||
|
|
c3174bacb9 | ||
|
|
1cbbaab88e | ||
|
|
72e4488a45 | ||
|
|
246d5a0dc3 | ||
|
|
55972c3e9b | ||
|
|
b18f22bc94 | ||
|
|
6512c7ba2c | ||
|
|
27cc7e74a8 | ||
|
|
cb87c3a37d | ||
|
|
f1f3e00c97 | ||
|
|
6af0a86e2f | ||
|
|
4dcb33034a | ||
|
|
aae5a7ec1a | ||
|
|
300f5c2f4f | ||
|
|
05fc5cac22 | ||
|
|
cd999a8d5b | ||
|
|
69a4dc69f9 | ||
|
|
ffa95a9f51 | ||
|
|
29fd1f91fa | ||
|
|
0b9b4d8f64 | ||
|
|
5c9488d30b | ||
|
|
0a38ba5ef0 | ||
|
|
8fdf4f8014 | ||
|
|
bc62206ad6 | ||
|
|
4b28a4d1b4 | ||
|
|
b76af14d00 | ||
|
|
63ca24867c | ||
|
|
f9022a3ac4 | ||
|
|
fc708d15c5 | ||
|
|
a1040bbb71 | ||
|
|
b769580389 | ||
|
|
e6ceb5a3b3 | ||
|
|
d4c730213f | ||
|
|
f866e5fb14 | ||
|
|
b6adee5651 | ||
|
|
73b179a28d | ||
|
|
da2df110b1 | ||
|
|
0efa443429 | ||
|
|
93db691583 | ||
|
|
a35e870aad | ||
|
|
2c80efcd77 | ||
|
|
25380bac96 | ||
|
|
c090d1fce8 | ||
|
|
8e45d2c4ee | ||
|
|
2b0a775db0 | ||
|
|
01996fff7c | ||
|
|
5709e20a20 | ||
|
|
c0074da1d2 | ||
|
|
e2a4654edb | ||
|
|
6caa1d523f | ||
|
|
c74a059afb | ||
|
|
0bd40354bc | ||
|
|
e8e86e1a1d | ||
|
|
5de1f82e27 | ||
|
|
4e52ed7e77 | ||
|
|
59fb021c58 | ||
|
|
1f8f4a005f | ||
|
|
940624a2d6 | ||
|
|
e2de75c3b6 | ||
|
|
cb85af67b5 | ||
|
|
e9d04a10e5 | ||
|
|
66cd96a303 | ||
|
|
ed47b26b5c | ||
|
|
0aea8b0150 | ||
|
|
ddeb371632 | ||
|
|
a8828c16f3 | ||
|
|
2772ecb688 | ||
|
|
f09cae12b0 | ||
|
|
fd02b6f4ac | ||
|
|
e32b09e515 | ||
|
|
d682621645 | ||
|
|
37e7fd9946 | ||
|
|
8257ffeb09 | ||
|
|
1a40e53bdd | ||
|
|
423ed52537 | ||
|
|
701d5269e4 | ||
|
|
b0f59bd7f4 | ||
|
|
1e3db39722 | ||
|
|
b8d544d71d | ||
|
|
483de22b7d | ||
|
|
6999af0e5f | ||
|
|
140e9487af | ||
|
|
27354eb008 | ||
|
|
dca916c1bd | ||
|
|
8e6963f462 | ||
|
|
05f7627930 | ||
|
|
ad274c7101 | ||
|
|
95ba197591 |
No files matched your search
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "Vibe-Trading",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "20"
|
||||
}
|
||||
},
|
||||
"forwardPorts": [
|
||||
8899,
|
||||
5899
|
||||
],
|
||||
"portsAttributes": {
|
||||
"8899": {
|
||||
"label": "Vibe-Trading API",
|
||||
"onAutoForward": "notify",
|
||||
"visibility": "private"
|
||||
},
|
||||
"5899": {
|
||||
"label": "Vibe-Trading Frontend",
|
||||
"onAutoForward": "notify",
|
||||
"visibility": "private"
|
||||
}
|
||||
},
|
||||
"postCreateCommand": "python -m pip install --upgrade pip && python -m pip install -e . && cd frontend && npm install",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode"
|
||||
],
|
||||
"settings": {
|
||||
"python.defaultInterpreterPath": "/usr/local/bin/python"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
-17
@@ -1,28 +1,50 @@
|
||||
# Version control & CI
|
||||
.git/
|
||||
.github/
|
||||
|
||||
# IDE & editor
|
||||
.claude/
|
||||
.vscode/
|
||||
.editorconfig
|
||||
|
||||
# Python artifacts
|
||||
.venv/
|
||||
agent/.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
frontend/tsconfig.tsbuildinfo
|
||||
.env
|
||||
agent/.env
|
||||
agent/vibe_trading.egg-info/
|
||||
agent/vibe_trading_ai.egg-info/
|
||||
agent/__pycache__/
|
||||
|
||||
# Runtime data (generated at runtime, not in repo)
|
||||
data/
|
||||
agent/runs/
|
||||
agent/sessions/
|
||||
agent/.swarm/runs/
|
||||
agent/.ui_runtime/
|
||||
agent/.tasks/
|
||||
agent/vibe_trading.egg-info/
|
||||
.git/
|
||||
.github/
|
||||
.claude/
|
||||
.vscode/
|
||||
data/*.duckdb
|
||||
data/*.duckdb.wal
|
||||
data/.duckdb_tmp/
|
||||
data/parquet/
|
||||
data/backup/
|
||||
data/minutes/
|
||||
data/*.log
|
||||
|
||||
# Env files (mounted via docker-compose env_file)
|
||||
.env
|
||||
agent/.env
|
||||
|
||||
# Frontend build (built inside Docker, don't copy local)
|
||||
frontend/dist/
|
||||
frontend/node_modules/
|
||||
frontend/tsconfig.tsbuildinfo
|
||||
|
||||
# Tests & docs (not needed in production image)
|
||||
agent/tests/
|
||||
docs/
|
||||
assets/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Project docs
|
||||
CLAUDE.md
|
||||
CONTRIBUTING.md
|
||||
*.md
|
||||
!README.md
|
||||
!LICENSE
|
||||
@@ -0,0 +1,77 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior
|
||||
title: "[Bug] "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting! Please fill out the sections below.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What happened? What did you expect to happen?
|
||||
placeholder: |
|
||||
**What happened:**
|
||||
The backtest command crashed with a KeyError.
|
||||
|
||||
**What I expected:**
|
||||
The backtest should complete and show results.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: How can we reproduce this?
|
||||
placeholder: |
|
||||
1. Run `vibe-trading run -p "Backtest BTC-USDT MACD strategy"`
|
||||
2. Wait for backtest to complete
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Error Logs
|
||||
description: Paste any error messages or stack traces
|
||||
render: shell
|
||||
|
||||
- type: dropdown
|
||||
id: interface
|
||||
attributes:
|
||||
label: Interface
|
||||
description: How are you using Vibe-Trading?
|
||||
options:
|
||||
- CLI (vibe-trading)
|
||||
- Web UI (vibe-trading serve)
|
||||
- MCP Plugin
|
||||
- Docker
|
||||
- Python API
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: provider
|
||||
attributes:
|
||||
label: LLM Provider
|
||||
description: Which provider are you using?
|
||||
placeholder: "e.g., openrouter, deepseek, ollama"
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: "Run `pip show vibe-trading-ai` to check"
|
||||
placeholder: "e.g., 0.1.5"
|
||||
|
||||
- type: input
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: OS and Python version
|
||||
placeholder: "e.g., macOS 15 / Python 3.11"
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Discord Community
|
||||
url: https://discord.gg/2vDYc2w5
|
||||
about: Ask questions, share strategies, get help
|
||||
- name: Documentation
|
||||
url: https://github.com/HKUDS/Vibe-Trading#-quick-started
|
||||
about: Check the README for setup and usage guides
|
||||
@@ -0,0 +1,62 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement
|
||||
title: "[Feature] "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for your idea! Please describe what you'd like.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem or Motivation
|
||||
description: What problem does this solve? Why do you need it?
|
||||
placeholder: |
|
||||
I often need to compare multiple strategies side by side,
|
||||
but currently I have to run them one at a time.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: How would you like it to work?
|
||||
placeholder: |
|
||||
Add a `/compare` command that takes multiple run IDs
|
||||
and shows a side-by-side performance table.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Area
|
||||
description: Which part of the project does this affect?
|
||||
options:
|
||||
- Skills (new finance skill)
|
||||
- Backtest (engines, loaders, metrics)
|
||||
- Swarm (new preset or workflow)
|
||||
- CLI / TUI
|
||||
- Web UI
|
||||
- MCP Plugin
|
||||
- Data Sources
|
||||
- Documentation
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Any other approaches you've thought about?
|
||||
|
||||
- type: checkboxes
|
||||
id: contribution
|
||||
attributes:
|
||||
label: Contribution
|
||||
options:
|
||||
- label: I'm willing to submit a PR for this feature
|
||||
@@ -0,0 +1,28 @@
|
||||
## Summary
|
||||
|
||||
<!-- What does this PR do? 1-3 bullet points. -->
|
||||
|
||||
-
|
||||
|
||||
## Why
|
||||
|
||||
<!-- What problem does it solve? Link related issues with "Closes #123". -->
|
||||
|
||||
## Changes
|
||||
|
||||
<!-- List key changes. For new skills/presets, describe what they cover. -->
|
||||
|
||||
-
|
||||
|
||||
## Test Plan
|
||||
|
||||
- [ ] Existing tests pass (`pytest --ignore=agent/tests/e2e_backtest --tb=short -q`)
|
||||
- [ ] New tests added (if applicable)
|
||||
- [ ] Tested manually (describe below)
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] No changes to protected areas (`src/agent/`, `src/session/`, `src/providers/`) without prior discussion
|
||||
- [ ] No hardcoded values (API keys, file paths, magic numbers)
|
||||
- [ ] Code follows [CONTRIBUTING.md](../CONTRIBUTING.md) guidelines
|
||||
- [ ] Documentation updated (if user-facing change)
|
||||
@@ -2,12 +2,12 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -20,18 +20,24 @@ jobs:
|
||||
cache: pip
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -e .
|
||||
run: pip install -e ".[dev]"
|
||||
|
||||
- name: Syntax check
|
||||
run: |
|
||||
cd agent
|
||||
python -m py_compile cli.py
|
||||
python -m compileall -q cli
|
||||
python -m py_compile api_server.py
|
||||
python -m py_compile mcp_server.py
|
||||
python -m py_compile src/agent/loop.py
|
||||
python -m py_compile src/tools/__init__.py
|
||||
python -m py_compile backtest/runner.py
|
||||
|
||||
- name: Run tests
|
||||
# test_e2e_harness_v2.py is gated by VIBE_TRADING_RUN_LIVE_E2E
|
||||
# (real-LLM only); list --ignore here so CI's intent is explicit and
|
||||
# the suite never silently switches state on env presence.
|
||||
run: pytest --ignore=agent/tests/e2e_backtest --ignore=agent/tests/test_e2e_harness_v2.py --tb=short -q
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Deploy Wiki
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "wiki/**"
|
||||
- ".github/workflows/wiki-deploy.yml"
|
||||
|
||||
concurrency:
|
||||
group: wiki-deploy
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to Cloudflare Pages
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy wiki
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
command: pages deploy wiki --project-name=vibetrading-wiki --branch=main
|
||||
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Wiki
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "wiki/**"
|
||||
- ".github/workflows/wiki.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "wiki/**"
|
||||
- ".github/workflows/wiki.yml"
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Check JavaScript syntax
|
||||
run: |
|
||||
node --check wiki/main.js
|
||||
node --check wiki/theme.js
|
||||
node --check wiki/docs/content.js
|
||||
node --check wiki/docs/main.js
|
||||
|
||||
- name: Check locale JSON
|
||||
run: python -m json.tool wiki/locales/en.json >/dev/null
|
||||
|
||||
- name: Check static site files
|
||||
run: |
|
||||
test -f wiki/index.html
|
||||
test -f wiki/home/index.html
|
||||
test -f wiki/docs/index.html
|
||||
test -f wiki/tutorials/index.html
|
||||
test -f wiki/alpha-library/index.html
|
||||
test -f wiki/research-lab/index.html
|
||||
test -f wiki/_redirects
|
||||
test -f wiki/_headers
|
||||
test -f wiki/assets/icon.png
|
||||
! grep -R "localhost\\|nanobot" wiki --exclude=README.md
|
||||
+44
-2
@@ -1,4 +1,4 @@
|
||||
.venv/
|
||||
.venv/
|
||||
agent/.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
@@ -16,10 +16,21 @@ agent/sessions/
|
||||
agent/runs/
|
||||
agent/.ui_runtime/
|
||||
agent/.tasks/
|
||||
agent/uploads/
|
||||
agent/tests/*_result.json
|
||||
.ruff_cache/
|
||||
frontend/dist/
|
||||
frontend/tsconfig.tsbuildinfo
|
||||
wiki/node_modules/
|
||||
wiki/dist/
|
||||
wiki/.astro/
|
||||
wiki/.vite/
|
||||
wiki/.wrangler/
|
||||
wiki/.cache/
|
||||
.vibe-dev/
|
||||
.vibe-record/
|
||||
.cache/
|
||||
.remotion/
|
||||
|
||||
# DuckDB data cache
|
||||
data/*.duckdb
|
||||
@@ -27,13 +38,44 @@ data/*.duckdb.wal
|
||||
data/.duckdb_tmp/
|
||||
data/parquet/
|
||||
data/backup/
|
||||
|
||||
# Internal docs (plans, specs)
|
||||
docs/
|
||||
!wiki/docs/
|
||||
!wiki/docs/**
|
||||
docs/screenshots/agent-run.png
|
||||
docs/screenshots/boot.png
|
||||
data/minutes/
|
||||
data/overnight*.log
|
||||
data/*.log
|
||||
data/test_write.txt
|
||||
agent/.pytest_tmp/
|
||||
agent/tests/e2e_swarm_results.json
|
||||
agent/vibe_trading.egg-info/
|
||||
agent/vibe_trading_ai.egg-info/
|
||||
agent/tests/e2e_backtest/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Local demo videos (too large for git; link to external host instead)
|
||||
assets/*.mp4
|
||||
|
||||
# Local demo / promo production workspace
|
||||
demo/
|
||||
|
||||
# Manual E2E smoke tests — require live API keys (OpenRouter / yfinance),
|
||||
# would break CI if committed. Kept local; run explicitly when validating
|
||||
# a release.
|
||||
agent/tests/e2e_*.py
|
||||
agent/tests/test_e2e_*.py
|
||||
|
||||
# Local working notes
|
||||
NOTES.md
|
||||
|
||||
# Agent tool aliases (auto-generated mirror of CLAUDE.md)
|
||||
AGENTS.md
|
||||
|
||||
.DS_Store
|
||||
|
||||
# Alpha Zoo wiki artifacts (generated, not source)
|
||||
wiki/alpha-library/manifest.json
|
||||
wiki/alpha-library/content/
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Vibe-Trading are documented in this file.
|
||||
This project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### UI
|
||||
- Redesigned the interactive CLI startup and run-state visuals with a large
|
||||
figlet-style banner plus a Claude Code-style activity rail using `●`, `│`,
|
||||
and `·` glyphs. Non-TTY prompt runs now fall back to plain text output.
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
## [0.1.8] — 2026-05-17
|
||||
|
||||
### Added — Alpha Zoo (450+ pre-built quant alphas)
|
||||
- `agent/src/factors/` — base operators (`rank`, `scale`, `ts_*`, `delta`,
|
||||
`decay_linear`, `signed_power`, `safe_div`, market-aware `vwap`) and a
|
||||
registry that AST-extracts metadata from each alpha module without
|
||||
importing it. Lookahead is enforced at the operator level
|
||||
(`delta(d>=1)`), and registry sanity checks reject `+/-inf` and
|
||||
outputs that are more than 95 % NaN.
|
||||
- 4 zoos shipping 452 alphas total:
|
||||
- **qlib158** (154 alphas) — port of Microsoft Qlib's `Alpha158`
|
||||
feature handler under Apache-2.0, with pinned commit SHA per file.
|
||||
- **alpha101** (101 alphas) — implementation of Kakushadze (2015)
|
||||
*"101 Formulaic Alphas"* (arXiv:1601.00991), written from the paper
|
||||
appendix; the relevant trademarked string is intentionally absent.
|
||||
- **gtja191** (191 alphas) — implementation of Guotai Junan's 2014
|
||||
*"191 Short-period Trading Alpha Factors"* research report.
|
||||
- **academic** (6 factors) — Fama-French 5 + Carhart momentum, shipped
|
||||
as honest price-based proxies (not the canonical FF series).
|
||||
- `vibe-trading alpha {list,show,bench,compare,export-manifest}` CLI
|
||||
subcommand. `show` and `export-manifest` enforce path-traversal guards.
|
||||
- New agent tools: `AlphaZooTool` (browse) and `AlphaBenchTool`
|
||||
(orchestrator with Jinja2 autoescape + strict CSP HTML report).
|
||||
- `ZooSignalEngine.from_zoo(...)` — composite multi-factor signal engine
|
||||
with cross-sectional standardisation, weighting, and optional top-N /
|
||||
bottom-N long-short conversion.
|
||||
- `wiki/scripts/build_alpha_library.py` — Alpha Library renderer.
|
||||
Reads `manifest.json` produced by `vibe-trading alpha export-manifest`
|
||||
and emits 452 per-alpha HTML pages plus 4 per-zoo overviews, each with
|
||||
`script-src 'none'` CSP. The landing page hydrates per-zoo counts
|
||||
from `content/index.json`.
|
||||
- New blog post: *"Which of the 191 GTJA alphas still work in 2026?"*
|
||||
with aggregate IC statistics, theme breakdown, and the top alphas
|
||||
that survive eight years of out-of-sample data.
|
||||
|
||||
### Added — Web UI for Alpha Zoo
|
||||
- New page at `/alpha-zoo` in the Vite + React frontend with three
|
||||
views: browse (4 zoo cards + filter bar + paginated table), detail
|
||||
(formula, metadata, collapsible source code), and bench-runner
|
||||
(form → SSE-streamed progress + Alive/Reversed/Dead stat cards +
|
||||
Top-5-by-IR table + by-theme breakdown chart). "Alpha Zoo" nav
|
||||
entry added to the layout.
|
||||
- Four new REST routes in the FastAPI server:
|
||||
- `GET /alpha/list` — filterable alpha catalogue
|
||||
- `GET /alpha/{alpha_id}` — meta + source code
|
||||
- `POST /alpha/bench` — kicks off a background bench job and
|
||||
returns a `job_id`
|
||||
- `GET /alpha/bench/{job_id}/stream` — Server-Sent Events with
|
||||
`progress`, `result`, `done`, and `error` event types. In-memory
|
||||
job state with a 1-hour TTL; no Redis/Celery dependency.
|
||||
- Bench math is refactored into `agent/src/factors/bench_runner.py`
|
||||
so the CLI driver (`agent/scripts/w4a_run_benches.py`) and the new
|
||||
API worker share a single implementation.
|
||||
|
||||
### Added — Safety floor
|
||||
- `agent/tests/factors/test_alpha_purity.py` — AST allowlist scan over
|
||||
every `zoo/**/*.py` module (whitelist: pandas, numpy, scipy.\*,
|
||||
`src.factors.base`, `__future__`, `typing`, `math`, `dataclasses`;
|
||||
banned: `os`, `sys`, `subprocess`, `socket`, `urllib`, `requests`,
|
||||
`httpx`, `pathlib`, `Path`, `open`, `eval`, `exec`, `compile`,
|
||||
`__import__`, and `getattr(_, "__*")`).
|
||||
- `agent/tests/factors/test_lookahead.py` — sentinel future-row
|
||||
injection on a 300-row synthetic panel; corrupting rows after the
|
||||
probe must leave the probe value unchanged within 1e-9.
|
||||
- `tools/ci_grep_gates.sh` — CI gate that rejects `yaml.load(` without
|
||||
`safe_load`, any trademarked-name leak in shipped artifacts, and any
|
||||
per-stock-code data leak in `wiki/**/*.{json,csv,html}`.
|
||||
- `agent/tests/factors/conftest.py` — opt-in `pytest-socket` integration
|
||||
that hard-fails any test attempting outbound network during the
|
||||
factors test suite.
|
||||
|
||||
### Added — Community governance
|
||||
- `CONTRIBUTING.md` — Developer Certificate of Origin sign-off
|
||||
requirement and a contributor checklist for new alpha PRs (purity,
|
||||
lookahead, `__alpha_meta__` shape, LaTeX-matches-code, per-zoo
|
||||
LICENSE.md, DCO).
|
||||
- `NOTICE` (repo root) — Apache-2.0 attribution for Qlib and a
|
||||
declaration that the bundled formulas from Kakushadze, GTJA, and the
|
||||
academic baselines are mathematical content (paper prose, tables, and
|
||||
figures are not reproduced here).
|
||||
- Per-zoo `LICENSE.md` for each of `qlib158/`, `alpha101/`, `gtja191/`,
|
||||
and `academic/`, plus an upstream `NOTICE` for `qlib158/`.
|
||||
|
||||
### Changed
|
||||
- `agent/src/tools/factor_analysis_tool.py` extracted its IC/IR and
|
||||
layered-backtest helpers to `agent/src/factors/factor_analysis_core.py`
|
||||
so the new `alpha_bench_tool` reuses the same maths. Public tool
|
||||
signature is unchanged; `_compute_ic_series` and `_compute_group_equity`
|
||||
remain importable as backward-compatible aliases.
|
||||
- `agent/cli.py` grew by 7 lines to register the `alpha` subcommand;
|
||||
all handler logic lives in `agent/src/factors/cli_handlers.py`.
|
||||
- Packaging: `pyproject.toml` now ships `zoo/**/*.yaml`, `zoo/**/*.md`,
|
||||
and `zoo/**/NOTICE` as package data; `MANIFEST.in` recursively
|
||||
includes `agent/src/factors`.
|
||||
|
||||
### Known limitations
|
||||
- The `btc-usdt` universe is single-asset; cross-sectional IC requires
|
||||
≥2 instruments, so the bundled `alpha101_btc` bench run returns
|
||||
alive/reversed/dead = 0/0/0 by construction. Use a multi-symbol crypto
|
||||
basket (e.g. BTC + ETH + SOL + the top-N perpetuals) for meaningful
|
||||
cross-sectional results; a curated `crypto-majors` universe is planned
|
||||
for 0.2.
|
||||
|
||||
### Internal
|
||||
- `wiki/alpha-library/manifest.json` and `wiki/alpha-library/content/`
|
||||
are generated artifacts and gitignored. Run
|
||||
`vibe-trading alpha export-manifest --out wiki/alpha-library/manifest.json
|
||||
--force` followed by `python wiki/scripts/build_alpha_library.py` to
|
||||
regenerate the static site.
|
||||
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socioeconomic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement via
|
||||
[GitHub Issues](https://github.com/HKUDS/Vibe-Trading/issues).
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
# Contributing to Vibe-Trading
|
||||
|
||||
Vibe-Trading is a natural-language finance research AI agent (FastAPI + ReAct
|
||||
agent backend, Vite+React frontend, vectorized daily and options backtesting).
|
||||
This guide covers contribution governance: the Developer Certificate of Origin
|
||||
(DCO) sign-off requirement, the reviewer checklist for Alpha Zoo factor
|
||||
contributions, and the quickstart for adding a new alpha.
|
||||
|
||||
For general project setup (`pip install -e ".[dev]"`, dev servers,
|
||||
`pytest --ignore=agent/tests/e2e_backtest`), see the README. For bug reports
|
||||
and feature requests, use the GitHub issue templates.
|
||||
|
||||
## Developer Certificate of Origin (DCO)
|
||||
|
||||
Every commit in a community pull request MUST carry a `Signed-off-by:`
|
||||
trailer. We do not require a CLA — the DCO is a lightweight per-commit
|
||||
attestation that you wrote the code or have the right to submit it under
|
||||
the project's MIT license. Maintainer-direct commits to `main` are not
|
||||
subject to the trailer requirement (the maintainer's authorship is
|
||||
already attested by the commit's author field), but community PRs are.
|
||||
|
||||
Sign your commits with `-s`:
|
||||
|
||||
```bash
|
||||
git commit -s -m "feat(factors): add gtja191 alpha 042"
|
||||
```
|
||||
|
||||
This appends a trailer like:
|
||||
|
||||
```
|
||||
Signed-off-by: Your Name <you@example.com>
|
||||
```
|
||||
|
||||
PRs without a `Signed-off-by:` on every commit will be asked to rebase and
|
||||
resign. To fix an unsigned series, run
|
||||
`git rebase --signoff <base-branch>` and force-push the branch.
|
||||
|
||||
### DCO 1.1 (full text)
|
||||
|
||||
```
|
||||
Developer Certificate of Origin
|
||||
Version 1.1
|
||||
|
||||
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
|
||||
1 Letterman Drive
|
||||
Suite D4700
|
||||
San Francisco, CA, 94129
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this
|
||||
license document, but changing it is not allowed.
|
||||
|
||||
|
||||
Developer's Certificate of Origin 1.1
|
||||
|
||||
By making a contribution to this project, I certify that:
|
||||
|
||||
(a) The contribution was created in whole or in part by me and I
|
||||
have the right to submit it under the open source license
|
||||
indicated in the file; or
|
||||
|
||||
(b) The contribution is based upon previous work that, to the best
|
||||
of my knowledge, is covered under an appropriate open source
|
||||
license and I have the right under that license to submit that
|
||||
work with modifications, whether created in whole or in part
|
||||
by me, under the same open source license (unless I am
|
||||
permitted to submit under a different license), as indicated
|
||||
in the file; or
|
||||
|
||||
(c) The contribution was provided directly to me by some other
|
||||
person who certified (a), (b) or (c) and I have not modified
|
||||
it.
|
||||
|
||||
(d) I understand and agree that this project and the contribution
|
||||
are public and that a record of the contribution (including all
|
||||
personal information I submit with it, including my sign-off) is
|
||||
maintained indefinitely and may be redistributed consistent with
|
||||
this project or the open source license(s) involved.
|
||||
```
|
||||
|
||||
## Alpha PR Reviewer Checklist
|
||||
|
||||
This checklist applies to any PR adding or modifying files under
|
||||
`agent/src/factors/zoo/**/*.py`. Reviewers MUST verify every box before
|
||||
merging. Authors are strongly encouraged to self-check first.
|
||||
|
||||
- [ ] **Purity gate**: file passes `pytest agent/tests/factors/test_alpha_purity.py`.
|
||||
The AST scan rejects any imports outside the allowlist
|
||||
(`pandas`, `numpy`, `scipy.*`, `src.factors.base`, `__future__`, `typing`,
|
||||
`math`, `dataclasses`) and any reference to forbidden names: `os`,
|
||||
`subprocess`, `socket`, `urllib`, `requests`, `httpx`, `pathlib`, `Path`,
|
||||
`eval`, `exec`, `compile`, `__import__`, bare `open`, or `getattr` with a
|
||||
second argument beginning with `"__"`.
|
||||
- [ ] **Lookahead gate**: file passes `pytest agent/tests/factors/test_lookahead.py`.
|
||||
No negative shifts, no forward leakage. `delta(df, d)` must have `d >= 1`.
|
||||
- [ ] **`__alpha_meta__` present** with all required pydantic-validated fields:
|
||||
`id`, `theme`, `formula_latex`, `columns_required`, `universe`, `frequency`,
|
||||
`decay_horizon`, `min_warmup_bars`. Optional: `nickname`, `extras_required`,
|
||||
`requires_sector`, `notes`.
|
||||
- [ ] **`compute(panel)` contract**: returns a DataFrame with the same shape
|
||||
as `panel["close"]`, NaN preserved at warmup / missing-data positions,
|
||||
no `+/-inf` values.
|
||||
- [ ] **LaTeX matches code**: the `formula_latex` in `__alpha_meta__` and the
|
||||
formula in the module docstring describe what `compute()` actually does.
|
||||
- [ ] **Per-zoo `LICENSE.md` updated** to cite the source paper / report and
|
||||
to state that formulas are mathematical facts, not subject to copyright,
|
||||
and that prose / tables / figures from papers and reports are not
|
||||
reproduced in this repo. Do NOT frame the bundled formulas using US
|
||||
affirmative-defense terminology — that is a litigation posture, not a
|
||||
license grant. State the mathematical-facts rationale instead.
|
||||
- [ ] **Apache-2 attribution**: if the alpha is adapted from an Apache-2.0
|
||||
upstream (e.g. Microsoft Qlib), the file MUST carry a header of the form
|
||||
`# Adapted from <repo>@<commit-sha>:<path> (Apache-2.0). Copyright (c) <holder>.`
|
||||
- [ ] **DCO**: every commit in the PR carries `Signed-off-by:`.
|
||||
|
||||
## Adding a New Alpha (Quickstart)
|
||||
|
||||
1. Pick the target zoo directory under `agent/src/factors/zoo/` (e.g.
|
||||
`gtja191/`, `alpha101/`, `qlib158/`, `academic/`).
|
||||
2. Create `<alpha_id_short>.py` in that directory. Define `__alpha_meta__`
|
||||
(must satisfy the pydantic `AlphaMeta` schema in
|
||||
`agent/src/factors/registry.py`) and a pure `compute(panel)` function
|
||||
that imports only from `src.factors.base` plus the allowlisted stdlib /
|
||||
numpy / pandas / scipy.
|
||||
3. Run the purity and lookahead gates locally:
|
||||
```bash
|
||||
pytest agent/tests/factors/test_alpha_purity.py agent/tests/factors/test_lookahead.py -q
|
||||
```
|
||||
4. (Optional but recommended) Run a quick bench:
|
||||
```bash
|
||||
vibe-trading alpha bench --zoo <zoo_id> --universe csi300 --period 2020-2025
|
||||
```
|
||||
5. Open a PR. Every commit must include `Signed-off-by:` (use
|
||||
`git commit -s`). Reviewers will walk the checklist above.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Format with `black`; lint with `ruff` (config in `pyproject.toml`).
|
||||
- Type-annotate all public function and method signatures.
|
||||
- Google-style docstrings (`Args:` / `Returns:` / `Raises:`).
|
||||
- Keep files under 400 lines where practical, 800 hard cap.
|
||||
- No hardcoded paths, secrets, or URLs — config via `.env`, YAML, or
|
||||
module-level constants.
|
||||
- Delete unused code rather than commenting it out.
|
||||
|
||||
## Attribution
|
||||
|
||||
Do NOT add `Co-Authored-By:` trailers or AI-assistant attribution lines to
|
||||
commit messages or PR descriptions. The DCO sign-off is the only required
|
||||
trailer; keep commit metadata clean.
|
||||
|
||||
By contributing, you agree that your contributions are licensed under the
|
||||
project's MIT license (see `LICENSE`).
|
||||
+13
-1
@@ -14,6 +14,12 @@ RUN npm run build
|
||||
# ============================================================================
|
||||
FROM python:3.11-slim AS runtime
|
||||
|
||||
LABEL org.opencontainers.image.title="Vibe-Trading" \
|
||||
org.opencontainers.image.description="Natural-language finance research AI agent with backtesting" \
|
||||
org.opencontainers.image.version="0.1.7" \
|
||||
org.opencontainers.image.source="https://github.com/HKUDS/Vibe-Trading" \
|
||||
org.opencontainers.image.licenses="MIT"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# System deps
|
||||
@@ -28,7 +34,6 @@ RUN pip install --no-cache-dir -r agent/requirements.txt
|
||||
# Copy project
|
||||
COPY pyproject.toml LICENSE README.md ./
|
||||
COPY agent/ agent/
|
||||
COPY data/ data/
|
||||
|
||||
# Copy built frontend
|
||||
COPY --from=frontend-build /app/frontend/dist frontend/dist
|
||||
@@ -36,6 +41,13 @@ COPY --from=frontend-build /app/frontend/dist frontend/dist
|
||||
# Install CLI entrypoint
|
||||
RUN pip install --no-cache-dir -e .
|
||||
|
||||
# Runtime should not run as root. Keep writable app data directories owned by
|
||||
# the service user so named Docker volumes inherit usable permissions.
|
||||
RUN useradd --create-home --shell /usr/sbin/nologin vibe \
|
||||
&& mkdir -p agent/runs agent/sessions agent/uploads agent/.swarm/runs \
|
||||
&& chown -R vibe:vibe /app
|
||||
USER vibe
|
||||
|
||||
# Default port
|
||||
EXPOSE 8899
|
||||
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
include LICENSE
|
||||
include NOTICE
|
||||
include README.md
|
||||
include README_zh.md
|
||||
include README_ja.md
|
||||
include README_ko.md
|
||||
include README_ar.md
|
||||
include pyproject.toml
|
||||
include Dockerfile
|
||||
include docker-compose.yml
|
||||
|
||||
# Developer entry-point template
|
||||
include agent/.env.example
|
||||
|
||||
# Alpha Zoo factor metadata and per-zoo LICENSE / NOTICE files
|
||||
recursive-include agent/src/factors *.yaml *.md
|
||||
|
||||
# Tests shipped so dev installs can run `pytest` against the package.
|
||||
# Files prefixed with `_` are local debug scripts kept untracked — keep
|
||||
# them out of the sdist even if present in the working tree.
|
||||
recursive-include agent/tests *.py
|
||||
recursive-exclude agent/tests _*.py
|
||||
|
||||
# Strip generated / VCS / editor noise
|
||||
global-exclude __pycache__
|
||||
global-exclude *.py[cod]
|
||||
global-exclude *.so
|
||||
global-exclude .DS_Store
|
||||
global-exclude .pytest_cache
|
||||
global-exclude *.egg-info
|
||||
@@ -0,0 +1,22 @@
|
||||
Vibe-Trading
|
||||
Copyright 2026 HKUDS contributors.
|
||||
|
||||
This product includes software developed at HKUDS (https://github.com/HKUDS).
|
||||
|
||||
This product bundles Microsoft Qlib feature definitions under the Apache 2.0
|
||||
license. See agent/src/factors/zoo/qlib158/NOTICE and LICENSE.md for the
|
||||
upstream NOTICE and attribution.
|
||||
|
||||
Mathematical formulas from the following sources are reimplemented in this
|
||||
repository as factual mathematical content (formulas are not copyrightable):
|
||||
|
||||
- Kakushadze, Z. (2015) "101 Formulaic Alphas" (arXiv:1601.00991)
|
||||
See agent/src/factors/zoo/alpha101/LICENSE.md
|
||||
- Guotai Junan Securities (2014) "191 Short-period Trading Alpha Factors"
|
||||
See agent/src/factors/zoo/gtja191/LICENSE.md
|
||||
- Fama-French five-factor model, Carhart momentum, Hou-Xue-Zhang q-factor
|
||||
See agent/src/factors/zoo/academic/LICENSE.md
|
||||
|
||||
Prose, tables, and figures from the source papers and reports are NOT
|
||||
reproduced in this repository. Only the mathematical formulas — which are
|
||||
factual content and not subject to copyright — are reimplemented here.
|
||||
+947
@@ -0,0 +1,947 @@
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | <a href="README_zh.md">中文</a> | <a href="README_ja.md">日本語</a> | <a href="README_ko.md">한국어</a> | <b>العربية</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/icon.png" width="120" alt="شعار Vibe-Trading"/>
|
||||
</p>
|
||||
|
||||
<h1 align="center">Vibe-Trading: وكيل التداول الشخصي الخاص بك</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>أمر واحد يمنح وكيلك قدرات تداول شاملة</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Python-3.11%2B-3776AB?style=flat&logo=python&logoColor=white" alt="Python">
|
||||
<img src="https://img.shields.io/badge/Backend-FastAPI-009688?style=flat" alt="FastAPI">
|
||||
<img src="https://img.shields.io/badge/Frontend-React%2019-61DAFB?style=flat&logo=react&logoColor=white" alt="React">
|
||||
<a href="https://pypi.org/project/vibe-trading-ai/"><img src="https://img.shields.io/pypi/v/vibe-trading-ai?style=flat&logo=pypi&logoColor=white" alt="PyPI"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow?style=flat" alt="License"></a>
|
||||
<br>
|
||||
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/Feishu-Group-E9DBFC?style=flat-square&logo=feishu&logoColor=white" alt="Feishu"></a>
|
||||
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat-square&logo=wechat&logoColor=white" alt="WeChat"></a>
|
||||
<a href="https://discord.gg/2vDYc2w5"><img src="https://img.shields.io/badge/Discord-Join-7289DA?style=flat-square&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://vibetrading.wiki/">الموقع</a> ·
|
||||
<a href="https://vibetrading.wiki/docs/">الوثائق</a> ·
|
||||
<a href="#-الأخبار">الأخبار</a> ·
|
||||
<a href="#-الميزات-الرئيسية">الميزات</a> ·
|
||||
<a href="#-حساب-الظل">حساب الظل</a> ·
|
||||
<a href="#-العرض-التوضيحي">العرض التوضيحي</a> ·
|
||||
<a href="#-البدء-السريع">البدء السريع</a> ·
|
||||
<a href="#-أمثلة">أمثلة</a> ·
|
||||
<a href="#-خادم-api">API / MCP</a> ·
|
||||
<a href="#-خارطة-الطريق">خارطة الطريق</a> ·
|
||||
<a href="#المساهمة">المساهمة</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#-البدء-السريع"><img src="assets/pip-install.svg" height="45" alt="pip install vibe-trading-ai"></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 📰 الأخبار
|
||||
|
||||
- **2026-05-31** 🔌 **بنية وسطاء تعتمد أولاً على connectors (IBKR + Robinhood)**: يبدأ الوصول إلى التداول الآن من connector profile قابل للاختيار، لا من مداخل منفصلة للوسيط أو live. أوامر `vibe-trading connector list/use/check/account/positions/orders/quote/history` وأدوات MCP `trading_*` تشترك في نفس profile المحدد، حيث تكون paper/live مجرد خاصية ضمن connector. يمكن استخدام IBKR فوراً عبر profile محلي للقراءة فقط من TWS / IB Gateway، بينما يُزرع مسار MCP الرسمي البعيد لـ IBKR كتحقق OAuth بنطاق `mcp.read` إلى أن تتوفر أسماء أدوات قراءة مستقرة. يظل Robinhood Agentic Trading هو connector MCP حيّاً ومحدوداً خلف OAuth وmandate مُلتزم وorder guard وسجل تدقيق وhalt فوري.
|
||||
- **2026-05-30** 🧰 **جولة متانة — backtest وagent loop وsession**: تمرّ الآن signal engines المولّدة بواسطة LLM بتحقق مسبق من الواجهة قبل الإنشاء، فتلتقط مبكراً الأخطاء الشائعة مثل self-import الدائري، وغياب `generate()`، ووسائط `__init__` بلا قيم افتراضية، ونوع الإرجاع الخاطئ، وتُرجِع أخطاء JSON قابلة للتنفيذ بدل traceback خام ([#149](https://github.com/HKUDS/Vibe-Trading/pull/149))؛ ومتابعةٌ لاحقة توجّه أخطاء تحقق AST على مستوى المصدر عبر نفس مغلّف JSON النظيف. لم يعد agent loop يستنزف الخمسين تكراراً ليصل إلى حالة `failed` بلا أي مخرجات — فهو يحاكي أسلوب swarm worker المُجرَّب: يحقن wrap-up nudge عند 80% من ميزانية التكرار ويُسقط تعريفات الأدوات في التكرار الأخير لفرض إجابة نصية ([#148](https://github.com/HKUDS/Vibe-Trading/pull/148))، مع حارس يجعله يُطلَق في المنتصف فقط كي لا يزيح سياق research-goal. كتابة رسائل الجلسة تجري الآن `flush + fsync` بعد كل append حتى تنجو ردود الـ AI الباهظة من تعطّل أثناء الكتابة، ويتخطّى مسار القراءة أسطر JSONL التالفة (مع تسجيل أول 200 حرف للاسترداد) بدل إعطاء 500 لنقطة `/messages` كاملة ([#147](https://github.com/HKUDS/Vibe-Trading/pull/147)). كما أصلح محرّر الإدخال في الويب معالجة Enter مع IME بحيث لا يؤدي Enter لتأكيد التركيب إلى إرسال في منتصف الكلمة ([#146](https://github.com/HKUDS/Vibe-Trading/pull/146)).
|
||||
- **2026-05-29** 🔐 **دعم Robinhood Agentic Trading (اختياري، استقلالية محدودة)**: أُضيف دعم Robinhood Agentic Trading (MCP عن بُعد، OAuth). مُعطَّل وللقراءة فقط افتراضياً؛ ويتداول الوكيل تلقائياً فقط ضمن mandate يلتزم به المستخدم (الرموز / حجم الأمر / التعرّض / الرافعة / الحد اليومي)، مع kill switch فوري على مستوى الملفات، وتصفية استباقية للمراكز، وانتهاء صلاحية تلقائي لـ mandate، وسجل تدقيق كامل، و runner مستقل دائم. لا حفظ للأموال ولا تشغيل لمنصة تداول — الوسيط يحتفظ بالأموال وينفّذ، ونحن ننقل النية فقط. تجريبي / الاستخدام على مسؤوليتك.
|
||||
<details>
|
||||
<summary>أخبار سابقة</summary>
|
||||
|
||||
- **2026-05-28** 🧪 **سلامة Swarm + بوّابة alpha صارمة + MCP لعمّال swarm**: يحجب Swarm DAG الآن المهام المتفرعة عندما تفشل المهمة الأعلى ([#145](https://github.com/HKUDS/Vibe-Trading/pull/145)). دالة `run_bench_strict()` الجديدة تضيف فوق بوّابة IC تحكماً عشوائياً بنفس universe + قسمة train/test OOS لاصطياد العوامل التي تتبع beta السوق فقط ([#143](https://github.com/HKUDS/Vibe-Trading/pull/143)، شكراً @Soli22de). يستطيع عمّال Swarm الآن استدعاء أدوات من خوادم MCP خارجية يضبطها المشغل، مع تثبيت حدود الثقة باختبارات مخصصة ([#142](https://github.com/HKUDS/Vibe-Trading/pull/142)، شكراً @shadowinlife).
|
||||
- **2026-05-27** 📊 **مصدر بيانات A-share عبر mootdx + تحسين الإخراج**: محمّل `mootdx` الجديد يتحدث بروتوكول 通达信 TCP الأصلي لبيانات OHLCV لأسهم A (بدون مصادقة، بدون قيود معدل لكل IP، يومي + intraday مع pagination تراجع بـ 25 صفحة)، ويُدرج بين tushare وakshare في سلسلة fallback ([#107](https://github.com/HKUDS/Vibe-Trading/issues/107)). محمّل CCXT يقرأ الآن `HTTP_PROXY/HTTPS_PROXY/ALL_PROXY` ليعمل جلب بيانات Binance/OKX العامة من الشبكات المقيدة ([#126](https://github.com/HKUDS/Vibe-Trading/pull/126)، شكراً @ruok808). عرض الإجابة النهائية أزال أيضاً فواصل `---` الأفقية القبيحة بعرض كامل على CLI وWeb: يحث system prompt الآن agent على استخدام جداول markdown وعناوين `##`، يجرّد CLI renderer أسطر HR المستقلة كدفاع متعمق، ويخفي chat bubble أي `<hr>` ينفذ عبر ([#139](https://github.com/HKUDS/Vibe-Trading/issues/139)، شكراً @sdwxm188).
|
||||
- **2026-05-26** ✅ **إغلاق دورة حياة Research Goal**: أصبح Goal mode يعمل كمنفّذ مهام حقيقي: إنشاء goal من Web UI ينشئ الجلسة أو يربطها ويرسل kickoff turn فوراً؛ يمكن متابعة active goals وتعديلها وإلغاؤها وإكمالها عبر Web/API/CLI/MCP؛ ويتقدم agent loop من لقطة goal الحالية (criteria وevidence وclaims وopen items) بدلاً من الاعتماد على prompt الأصلي فقط. عندما تكون criteria covered لكن goal لا يزال active، ينتقل النظام إلى audit/status update بدلاً من التوقف الصامت، مع تغطية انحدارية عبر backend وCLI وMCP وfrontend events.
|
||||
|
||||
- **2026-05-25** 🧼 **واجهة Chat أنظف + سير composer**: أصبحت واجهة Web UI تترك التركيز للمدخل التالي: انتقلت أوضاع upload وswarm وresearch-goal إلى قائمة `+` في composer بدلاً من لوحات عائمة تقاطع المحادثة. يظهر السياق النشط فوق حقل الإدخال كشرائح compact، ولا تتوسع تفاصيل goal إلا inline عند النقر على الشريحة. أزيلت طبقة i18n المخصصة القديمة لصالح نصوص إنجليزية مباشرة، وتظهر بطاقة Full Report فقط للتشغيلات ذات تقرير فعلي، كما أصبح تشغيل التطوير المحلي وتقارير الحالة أكثر ثباتاً لاختبارات browser smoke.
|
||||
- **2026-05-24** 🎯 **Research Goal runtime**: أضيفت طبقة Research Goal مرتبطة بالجلسة عبر backend وCLI وAPI/MCP وSSE وWeb UI. تحفظ الأهداف claims وacceptance criteria وevidence rows وbudgets وcompletion policy؛ تستطيع agent tools إنشاء الأهداف وإضافة evidence؛ أصبح `/goal` مدخل CLI؛ تعرض REST/MCP لقطات goal وكتابات evidence؛ وتحافظ SSE على حداثة حالة chat clients. أغلقت إصلاحات audit اللاحقة مسارات verified evidence، ومنعت live-trading risk tiers عبر agent tools، وربطت goals المنشأة من CLI بالمنعطفات اللاحقة، ونظفت goal ledger عند حذف الجلسة، ووصلت replay-all، وأصلحت race في frontend snapshot بين الجلسات.
|
||||
- **2026-05-23** 🖥️ **تحديث CLI التفاعلي**: تفتح واجهة الطرفية الآن ببانر Vibe-Trading أكبر، وفاصل prompt أوضح، وملخص للدورة السابقة، وتوقيت بعد التشغيل، ومسار نشاط بأسلوب Claude Code لعمل الوكيل الحي. تُعرض استدعاءات الأدوات، وجلب الويب/البيانات، وأفعال نمط shell، وإجابات Markdown، وجداول pipe كسجل أكثر قابلية للقراءة، بينما تحافظ تشغيلات pipe أو non-TTY على إخراج نصي مناسب للأتمتة. أصبحت لقطات CLI المولدة artifacts محلية بدلاً من ملفات docs ملتزم بها، مما يبقي المستودع أخف.
|
||||
- **2026-05-22** 🧭 **استعادة Swarm + إبقاء MCP حياً**: أصبحت حالة Swarm تُصالح من ملفات المهام الحية عند كل قراءة، لذلك تستعيد عروض API/MCP/SSE/list التشغيلات التي تعطلت أو صارت stale بدلاً من عرض لقطة `running` للأبد. يرسل `run_swarm` نبضات MCP progress أثناء polling، مع إطار أول ثابت `swarm_started run_id=<id>` كي يستطيع العملاء استعادة المقبض بعد سقوط النقل؛ كما يصدر worker نبضات خلال LLM streaming وgrounding fetch وتنفيذ الأدوات. يستخدم stale-run reaper عتبات خاصة بكل run ويستنتج الحالة النهائية من حالات المهام. لم يعد `SwarmTool` يلغي team ما زال يعمل لمجرد انتهاء wait budget، ويمكن لعملاء MCP استدعاء `reap_stale_runs()` للتنظيف الصريح. حدّثت دفعة DX اليوم أيضاً النماذج الافتراضية للمزودين، وواءمت فحص CI syntax مع حزمة `agent/cli/` الجديدة. تغطي 22 اختباراً انحدارياً جديداً hydration، واستعادة الحالات النهائية، وجمع التشغيلات stale، وإيقاع keepalive، وتحمل env parsing، وربط heartbeat؛ ومجموعة swarm/MCP الكاملة عند 169 passed و4 skipped.
|
||||
- **2026-05-21** 🧱 **إعادة هيكلة حزمة CLI**: تقسيم `agent/cli.py` (3216 سطراً) إلى حزمة `agent/cli/` — واجهة تفاعلية، موجّه slash، مكوّنات Rich، وطبقة `_legacy.py` تحافظ على كل الأوامر الفرعية وتعيد تصدير كل الرموز العامة فتبقى `cli.cmd_*` / `cli._INIT_ENV_PATH` / `cli.Confirm` كما هي. Middleware جديد في FastAPI يخدم قشرة SPA عند فتح `/runs/{id}` أو `/correlation` مباشرة من المتصفح، مع نفس التضييق في بروكسي Vite للتطوير. توحيد سلسلة الإصدار عبر `cli/_version.py` (إنهاء الانحراف بين `--version` والبانر)، استعادة `python -m cli` عبر `__main__.py`، وتضييق بوابة chat بحيث تصل `chat --help` / `chat extra` إلى argparse القديم بدلاً من ابتلاع REPL لها.
|
||||
- **2026-05-20** 🔬 **Hypothesis Registry CLI**: استكمال جانب CLI لـ Hypothesis Registry الذي شُحن backend فقط في 2026-05-16. يُخرج `vibe-trading hypothesis list` جدول Rich أو JSON (مع فلتر `--status` و`--limit`)؛ يعرض `show <id>` لوحة تفاصيل تتضمن run cards المرتبطة؛ يقلب `invalidate <id> --note "..."` الحالة إلى `rejected` ويُبقي ملاحظات الإبطال السابقة عند حذف `--note`. متغير البيئة `VIBE_TRADING_HYPOTHESES_PATH` ما زال مدعوماً، مع إضافة `--path` لكل استدعاء. تغطي 22 اختباراً جديداً الربط، إخراج JSON، فلتر الحالة، الحد، أخطاء معرّف مفقود، وثبات الملاحظات.
|
||||
- **2026-05-19** ✨ **تغذية راجعة حيّة للأدوات + إلغاء سلس**: لم تعد الأدوات الطويلة (backtests، PDF كبيرة، عمّال swarm) تبدو متجمدة. كل استدعاء أداة يُصدر الآن نبضة قلب كل 3 ثوانٍ، بالإضافة إلى تقدّم مرحلي مهيكل — يُظهر `run_backtest` علامات الأطوار (`validate` / `simulate` / `finalize`)، ويُحدّث `read_document` عدّاد كل صفحة على PDF أو كل ورقة على Excel، ويُعلِم `read_url` بمرحلتي `fetch` / `parse`. تعرض لوحة Rich Live في CLI دوّاراً Unicode وشريط تقدّم ASCII وETA، وتُكدّس حتى 3 أدوات متوازية مفهرسة بالاسم. تضيف الواجهة الأمامية مكوّن `ToolProgressIndicator` جديد مع تجميع rAF، وARIA `role="status"` + `<progress>` أصلي مخفي لقارئات الشاشة، وSVG `ProgressRing` حتمي عندما يكون المجموع معروفاً. أول `Ctrl+C` أثناء تشغيل CLI يستدعي الآن `agent.cancel()` للخروج السلس (تكتمل الخطوة الحالية وتُغلق التتبعات بنظافة)، والثاني خلال ثانيتين يفرض الإنهاء. تم استخراج عناصر أساسية قابلة لإعادة الاستخدام: `ProgressBar.tsx` و`lib/tools.ts` (تعيين i18n لأسماء الأدوات المشترك).
|
||||
- **2026-05-18** 🧹 **تنظيف + إصلاح 3 أخطاء كامنة**: لم يعد `CompositeEngine` يوجّه رموز العقود الآجلة الصينية بدون لاحقة (مثل `RB2410`) إلى `GlobalFuturesEngine` بشكل خاطئ — انتقل `_is_china_futures` إلى وحدة `_market_hooks` المشتركة مع تطبيع حالة جدول المنتجات + حارس لبورصة غير صينية، وأُضيفت 9 حالات اختبار انحدار. تحفظ فهارس FTS5 للجلسات الآن الطوابع الزمنية، فيمكن لبحث الجلسات الفرز بالتاريخ، ونفس التغيير أصلح مسار إعادة الإدراج الذي كان يستبدل `started_at` بساعة الحائط في كل مرة. أُضيف `/alpha` المفقود إلى بروكسي تطوير Vite، فتُحلّ صفحة AlphaZoo الآن على `npm run dev`. تم تقييد `tests/test_e2e_harness_v2.py` (مجموعة e2e بـ LLM حقيقي) خلف `VIBE_TRADING_RUN_LIVE_E2E=1` كي لا تغيّر CI شكلها بناءً على وجود مفتاح البيئة. أُضيفت إلى ruff قاعدة `per-file-ignores` لمكتبة المعاملات (الضوضاء F401 من 3783 إلى 0)، وفُعِّلت `noUnusedLocals` / `noUnusedParameters` في tsconfig الواجهة كحواجز انحدار، وحُذف 76 سطراً من نموذج `vw = vwap(...)` غير المستخدم في ملفات `gtja191`. الصافي **-918 سطراً**.
|
||||
- **2026-05-17** 🧬 **Alpha Zoo v1 (0.1.8)**: 452 ألفا كمّي جاهز عبر 4 zoos — `qlib158` (ميزات Alpha158 من Microsoft Qlib، إسناد Apache-2.0)، `alpha101` (إعادة تنفيذ "101 Formulaic Alphas" من Kakushadze بناءً على ورقة arXiv:1601.00991)، `gtja191` (تقرير بحث Guotai Junan 2014 لعوامل تداول قصيرة الأجل)، `academic` (Fama-French 5 + Carhart momentum كـ proxy قائم على الأسعار). سطر أوامر واحد للـ bench على أي universe: `vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025`. تتضمن بوابة AST للنقاء، اختبار حماية lookahead، عزل الشبكة عبر `pytest-socket`، LICENSE.md لكل zoo، وسير عمل توقيع DCO لمساهمات المجتمع. تقديم Alpha Library تلقائياً على [vibetrading.wiki/alpha-library/](https://vibetrading.wiki/alpha-library/)، مع منشور Research Lab [Which of the 191 GTJA alphas still work in 2026?](https://vibetrading.wiki/research-lab/posts/alpha-191-in-2026.html).
|
||||
- **2026-05-16** 🧪 **تحديث عمود البحث**: أضيف backend Hypothesis Registry مع `create_hypothesis` و`update_hypothesis` و`link_backtest` و`search_hypotheses`. تضيف قارئات المحتوى الخارجي الآن `security_warnings` تحذيرية فقط، وانتقل ماسح Shadow Account من calendar-phase stub القديم إلى تقييم حتمي لميزات OHLCV.
|
||||
- **2026-05-15** 🪪 تعرض صفحة تفاصيل الـ run الآن بطاقة Trust Layer run card إلى جانب المقاييس والمخرجات، لتكمل الجانب الواجهي من عمل `run_card.json` الذي هبط في 2026-05-12. كما تم تعزيز `PersistentMemory.add()` على مسارات الطول والأسماء الفارغة أو التي تحتوي على فراغات فقط وبايتات التحكم C0/C1 ضمن فرز #108/#109/#110 ([#112](https://github.com/HKUDS/Vibe-Trading/pull/112)، شكراً @Teerapat-Vatpitak).
|
||||
- **2026-05-14** 🌐 أصبح الويكي العام متاحاً على [vibetrading.wiki](https://vibetrading.wiki/) مع أقسام docs وtutorials وResearch Lab وAlpha Library، ويُنشر عبر Cloudflare Pages. أصبحت الذاكرة الدائمة أيضاً قابلة للفحص من سطر الأوامر عبر `vibe-trading memory list/show/search/forget` ([#102](https://github.com/HKUDS/Vibe-Trading/pull/102)، شكراً @Teerapat-Vatpitak)، كما يدعم توليد الرموز وslugs للذاكرة الآن التايلاندية والعربية والعبرية والنص السيريلي ([#104](https://github.com/HKUDS/Vibe-Trading/pull/104)).
|
||||
|
||||
- **2026-05-13** 🧭 أصبحت تشغيلات السرب تؤسس عمل الوكلاء على بيانات سوق مجلوبة مسبقاً، مع تقارير محفوظة أنظف ([#93](https://github.com/HKUDS/Vibe-Trading/pull/93)، [#84](https://github.com/HKUDS/Vibe-Trading/pull/84)).
|
||||
- **2026-05-12** 🧾 أصبحت الاختبارات الرجعية تنتج `run_card.json` و`run_card.md` إلى جانب المخرجات لدعم تشغيلات بحثية قابلة لإعادة الإنتاج.
|
||||
- **2026-05-11** 🧭 **Memory slugs، ومحاسبة السرب، وفحص CLI المسبق**: أصبحت الذاكرة الدائمة تحفظ أحرف CJK عند توليد slugs للملفات، مما يمنع اصطدامات أسماء صامتة لملاحظات الصينية/اليابانية/الكورية ([#95](https://github.com/HKUDS/Vibe-Trading/pull/95)، شكراً @voidborne-d). تفضل مجاميع تشغيل السرب الآن استخدام استهلاك الرموز المبلغ من المزود مع الإبقاء على التقدير الاحتياطي الحالي ([#94](https://github.com/HKUDS/Vibe-Trading/pull/94)، شكراً @Teerapat-Vatpitak)، كما حصلت واجهة تشغيل CLI على فحص بدء مبكر للمشكلات البيئية الشائعة ([#96](https://github.com/HKUDS/Vibe-Trading/pull/96)، شكراً @ykykj).
|
||||
- **2026-05-10** 🧱 **حواجز انحدار وبيانات تشغيل وصفية**: أصبح استدعاء الذاكرة يتعامل مع الشرطات السفلية كحدود رموز، لذلك تطابق ذكريات `snake_case` مثل `mcp_wiring_test` استعلامات طبيعية مثل "mcp wiring" ([#87](https://github.com/HKUDS/Vibe-Trading/pull/87)، شكراً @hp083625). يملك خادم MCP الآن اختبار smoke عبر subprocess يغطي initialize → `tools/list` → `tools/call` لحماية مسار التعطل في أول استدعاء ([#86](https://github.com/HKUDS/Vibe-Trading/pull/86))، كما وصلت تحسينات منخفضة المخاطر لاختبارات مسارات Windows، ومعالجة استثناءات API best-effort، والتحقق من allowed-root في `run_dir` للاختبار الرجعي، وبيانات provider/model في SwarmRun ([#88](https://github.com/HKUDS/Vibe-Trading/pull/88)، [#90](https://github.com/HKUDS/Vibe-Trading/pull/90)، [#91](https://github.com/HKUDS/Vibe-Trading/pull/91)، [#92](https://github.com/HKUDS/Vibe-Trading/pull/92)، شكراً @Teerapat-Vatpitak).
|
||||
- **2026-05-09** 🛡️ **تعزيز مسارات API واستقرار خادم MCP**: تتحقق مسارات run/session في API الآن من معرفات المسار قبل البحث، وترفض المعاملات المشوهة التي تحتوي على أسطر جديدة مع تثبيت السلوك في مجموعة اختبارات auth/security ([#80](https://github.com/HKUDS/Vibe-Trading/pull/80)، شكراً @SJoon99). يسخن خادم MCP سجل الأدوات على الخيط الرئيسي قبل خدمة `tools/call` لتجنب تعطل أول استدعاء في اكتشاف الأدوات الكسول ([#85](https://github.com/HKUDS/Vibe-Trading/pull/85)، شكراً @Teerapat-Vatpitak). كما يحترم Vite dev proxy المتغير `VITE_API_URL` لأهداف الخلفية غير الافتراضية ([#82](https://github.com/HKUDS/Vibe-Trading/pull/82)، شكراً @voidborne-d).
|
||||
- **2026-05-08** 🧾 **حقول قوائم Tushare داخل المرشحات**: تستطيع اختبارات أسهم A اليومية الآن طلب حقول قوائم مالية آمنة زمنياً عبر `fundamental_fields`، بحيث يمكن لمحركات الإشارات الفرز على أعمدة مثل `income_total_revenue` و`income_n_income` و`balancesheet_total_hldr_eqy_exc_min_int` و`fina_indicator_roe` بعد تواريخ الإعلان/الإفصاح ([#76](https://github.com/HKUDS/Vibe-Trading/pull/76)، شكراً @mrbob-git). ويجعل التعزيز اللاحق طلب حقول القوائم الصريح يفشل سريعاً إذا تعذر تشغيل إثراء Tushare، بدلاً من الرجوع بصمت إلى أشرطة الأسعار الخام ([#77](https://github.com/HKUDS/Vibe-Trading/pull/77)).
|
||||
- **2026-05-07** 📈 **أساسيات Tushare وفرز المجتمع**: أضيف عقد `TushareFundamentalProvider` بنمط point-in-time لتدفقات البحث الأساسي، مع تغطية انحدار لمسار متغير البيئة `TUSHARE_TOKEN` في المشروع ([#74](https://github.com/HKUDS/Vibe-Trading/pull/74)). كما أوضح فرز المجتمع أن Vibe-Trading يركز حالياً على لغة واجهة واحدة لتسريع التكرار، ويتجنب تبعيات بحث زائدة ما دام `web_search` المدعوم من DuckDuckGo مضمناً، ويتعامل مع النشر المستضاف غير الرسمي كمكان غير موثوق لمفاتيح API أو رموز مصادر البيانات.
|
||||
- **2026-05-06** 🚀 **إصدار v0.1.7** ([Release notes](https://github.com/HKUDS/Vibe-Trading/releases/tag/v0.1.7)، `pip install -U vibe-trading-ai`): نُشر تعزيز حدود الأمان على PyPI وClawHub، ويغطي افتراضات أكثر أماناً للـ API/القراءة/الرفع/الملفات/URL/الكود المولد/أدوات shell/Docker مع إبقاء تدفقات CLI/Web UI المحلية سهلة. تشمل الدورة أيضاً Web UI Settings، وخريطة ارتباط حرارية، وOpenAI Codex OAuth، ومرشح A-share pre-ST، وتحسين CLI التفاعلي، وفحص swarm presets، وتحليل التوزيعات، وصقل سير التطوير، ورفع حدود أمان تبعيات بناء الواجهة. شكراً لمساهمي 0.1.7 وlemi9090 (S2W) على التحقق الأمني المنسق.
|
||||
- **2026-05-05** 🛡️ **متابعة حدود الأمان**: استكمال تعزيز الأمان حول CORS origins الصريحة، ومؤشرات بيانات الاعتماد في Settings، وقراءة عناوين الويب، وتوليد كود Shadow Account، مع اختبارات انحدار لكل مسار. تبقى تدفقات CLI/Web UI على localhost كما هي؛ وعلى عمليات النشر البعيدة استخدام `API_AUTH_KEY` وorigins موثوقة صريحة.
|
||||
- **2026-05-04** 🖥️ **تجربة CLI تفاعلية وتنظيف CI**: يعرض الوضع التفاعلي الآن شريط حالة سفلياً مباشراً يبين provider/model ومدة الجلسة وكمون آخر تشغيل وإحصاءات استدعاءات الأدوات، مع تصفح سجل الأوامر وتحرير المؤشر بمفاتيح الأسهم عبر `prompt_toolkit` ([#69](https://github.com/HKUDS/Vibe-Trading/pull/69)). يعود CLI إلى Rich prompts عند غياب `prompt_toolkit` أو TTY. كما وُئمت توقعات مسارات CI مع صندوق استيراد الملفات المعزز وحل `/tmp` عبر المنصات، فعاد main إلى الأخضر ([`bb67dc7`](https://github.com/HKUDS/Vibe-Trading/commit/bb67dc7cfcc11553c57d8962bee56381dca43758)).
|
||||
- **2026-05-03** 🛡️ **تصحيح تعزيز الأمان**: يشدد مصادقة API الافتراضية للنشر غير المحلي، ويحمي قراءات run/session/swarm الحساسة، ويقيد حدود الرفع وقراءة الملفات المحلية، ويقيد أدوات shell بحسب نقطة الدخول، ويتحقق من تحميل الاستراتيجيات المولدة قبل الاستيراد، ويشغل صورة Docker كمستخدم غير root مع منفذ localhost فقط افتراضياً. تبقى تدفقات CLI وWeb UI المحلية سهلة؛ وعلى نشر API/Web البعيد ضبط `API_AUTH_KEY`.
|
||||
- **2026-05-02** 🧭 **تحليل التوزيعات وخارطة طريق أوضح**: أضيفت مهارة `dividend-analysis` لأسهم الدخل، واستدامة التوزيعات، ونموها، وعائد المساهمين، وآليات ex-dividend، وفحص مصائد العائد، مع تثبيتها باختبارات انحدار للمهارات المضمنة. تركز خارطة الطريق العامة الآن على Research Autopilot وData Bridge وOptions Lab وPortfolio Studio وAlpha Zoo وResearch Delivery وTrust Layer ومشاركة Community.
|
||||
- **2026-05-01** 🔥 **خريطة ارتباط حرارية وOpenAI Codex OAuth ومرشح A-share pre-ST**: لوحة/API ارتباط جديدة تحسب ارتباطات العوائد المتحركة وتعرض خريطة حرارية ECharts لتحليل المحافظ والرموز ([#64](https://github.com/HKUDS/Vibe-Trading/pull/64)). يدعم مزود OpenAI Codex الآن ChatGPT OAuth عبر `vibe-trading provider login openai-codex` مع بيانات Settings واختبارات انحدار للمحول ([#65](https://github.com/HKUDS/Vibe-Trading/pull/65)). أضيفت وعُززت مهارة `ashare-pre-st-filter` لفحص مخاطر ST/*ST في أسهم A، مع فلترة صلة عقوبات Sina حتى لا تضخم إشارات حسابات الأوراق المالية عدادات E2 ([#63](https://github.com/HKUDS/Vibe-Trading/pull/63)).
|
||||
- **2026-04-30** ⚙️ **Web UI Settings وتعزيز validation CLI**: صفحة Settings جديدة لمزود/نموذج LLM، وbase URL، وreasoning effort، وبيانات اعتماد مصادر البيانات، مدعومة بواجهات settings API محلية/محمية وببيانات مزودين قابلة للتكوين ([#57](https://github.com/HKUDS/Vibe-Trading/pull/57)). كما تعزز `python -m backtest.validation <run_dir>` حتى تفشل المدخلات الناقصة أو الفارغة أو المشوهة أو غير الموجودة أو غير الدليل برسائل واضحة قبل بدء التحقق ([#60](https://github.com/HKUDS/Vibe-Trading/pull/60)).
|
||||
- **2026-04-28** 🚀 **إصدار v0.1.6** (`pip install -U vibe-trading-ai`): إصلاح إرجاع `vibe-trading --swarm-presets` فارغاً بعد `pip install` / `uv tool install` ([#55](https://github.com/HKUDS/Vibe-Trading/issues/55))، حيث أصبحت ملفات preset YAML مضمنة داخل حزمة `src.swarm` ومثبتة بستة اختبارات انحدار. كما أصبح محمل AKShare يوجه ETFs مثل `510300.SH` والفوركس مثل `USDCNH` إلى النقاط الصحيحة مع fallback registry معزز. يجمع الإصدار كل ما بعد v0.1.5: لوحة مقارنة معيارية، بث `/upload` وحدود الحجم، محمل Futu (HK + A-share)، مهارة تصدير vnpy، تعزيز أمني، وتحميل واجهة كسول من 688KB إلى 262KB.
|
||||
- **2026-04-27** 📊 **لوحة مقارنة معيارية وأمان الرفع**: مخرجات الاختبار الرجعي تتضمن الآن لوحة مقارنة معيارية (ticker / benchmark return / excess return / information ratio) مع حل عبر yfinance لـ SPY وCSI 300 وغيرها ([#48](https://github.com/HKUDS/Vibe-Trading/issues/48)). كما تبث `/upload` جسم الطلب في أجزاء 1 MB وتتوقف بعد `MAX_UPLOAD_SIZE`، مما يحد الذاكرة تحت العملاء الضخمين/المشوهين ([#53](https://github.com/HKUDS/Vibe-Trading/pull/53))، ومثبتة بأربعة اختبارات انحدار.
|
||||
- **2026-04-22** 🛡️ **تعزيز وتكاملات جديدة**: فرض احتواء المسارات في `safe_path` وصندوق أدوات journal/shadow، وإرسال `.env.example` / الاختبارات / ملفات Docker في sdist عبر `MANIFEST.in`، وتصغير الحزمة الأولية للواجهة من 688KB إلى 262KB عبر التحميل الكسول على مستوى المسارات. إضافة محمل Futu لأسهم HK وA-share ([#47](https://github.com/HKUDS/Vibe-Trading/pull/47)) ومهارة تصدير vnpy CtaTemplate ([#46](https://github.com/HKUDS/Vibe-Trading/pull/46)).
|
||||
- **2026-04-21** 🛡️ **مساحة العمل والوثائق**: تطبيع `run_dir` النسبي إلى دليل التشغيل النشط ([#43](https://github.com/HKUDS/Vibe-Trading/pull/43)). أمثلة استخدام README ([#45](https://github.com/HKUDS/Vibe-Trading/pull/45)).
|
||||
- **2026-04-20** 🔌 **Reasoning وSwarm**: الحفاظ على `reasoning_content` عبر جميع مسارات `ChatOpenAI`، لتعمل أفكار Kimi / DeepSeek / Qwen من البداية للنهاية ([#39](https://github.com/HKUDS/Vibe-Trading/issues/39)). بث Swarm وإيقاف Ctrl+C نظيف ([#42](https://github.com/HKUDS/Vibe-Trading/issues/42)).
|
||||
- **2026-04-19** 📦 **v0.1.5**: النشر إلى PyPI وClawHub. رفع حد `python-multipart` لسد CVE، وربط 5 أدوات MCP جديدة (`analyze_trade_journal` + 4 أدوات shadow-account)، وإصلاح سجل `pattern_recognition` → `pattern`، ومطابقة تبعيات Docker، ومزامنة بيان SKILL (22 أداة MCP / 71 مهارة).
|
||||
- **2026-04-18** 👥 **Shadow Account**: استخرج قواعد استراتيجيتك من سجل وسيط → اختبر الظل عبر الأسواق → تقرير HTML/PDF من 8 أقسام يوضح ما تتركه على الطاولة (خرق القواعد، الخروج المبكر، الإشارات الفائتة، الصفقات المضادة). 4 أدوات جديدة، ومهارة واحدة، و32 أداة إجمالاً. أمثلة Trade Journal + Shadow Account موجودة الآن في شاشة ترحيب Web UI.
|
||||
- **2026-04-17** 📊 **محلل سجل التداول وقارئ ملفات شامل**: ارفع صادرات الوسطاء (同花顺/东财/富途/generic CSV) → ملف تداول تلقائي (أيام الاحتفاظ، معدل الربح، نسبة PnL، التراجع) + 4 تشخيصات سلوكية (disposition effect، الإفراط في التداول، مطاردة الزخم، anchoring). أصبح `read_document` يوجه PDF وWord وExcel وPowerPoint والصور (OCR) و40+ صيغة نصية خلف استدعاء موحد.
|
||||
- **2026-04-16** 🧠 **Agent Harness**: ذاكرة دائمة عبر الجلسات، بحث جلسات FTS5، مهارات ذاتية التطور (CRUD كامل)، ضغط سياق بخمس طبقات، وتجميع أدوات القراءة/الكتابة. 27 أداة، و107 اختبارات جديدة.
|
||||
- **2026-04-15** 🤖 **Z.ai + MiniMax**: مزود Z.ai ([#35](https://github.com/HKUDS/Vibe-Trading/pull/35))، وإصلاح temperature في MiniMax وتحديث النموذج ([#33](https://github.com/HKUDS/Vibe-Trading/pull/33)). 13 مزوداً.
|
||||
- **2026-04-14** 🔧 **استقرار MCP**: إصلاح خطأ `Connection closed` في أداة الاختبار الرجعي على نقل stdio ([#32](https://github.com/HKUDS/Vibe-Trading/pull/32)).
|
||||
- **2026-04-13** 🌐 **اختبار رجعي مركب عبر الأسواق**: محرك `CompositeEngine` جديد يختبر محافظ مختلطة الأسواق (مثل أسهم A + crypto) بمجمع رأس مال مشترك وقواعد لكل سوق. كما أصلح fallback لمتغيرات قالب السرب ومهلة الواجهة.
|
||||
- **2026-04-12** 🌍 **تصدير متعدد المنصات**: يصدر `/pine` الاستراتيجيات إلى TradingView (Pine Script v6)، وTDX (通达信/同花顺/东方财富)، وMetaTrader 5 (MQL5) بأمر واحد.
|
||||
- **2026-04-11** 🛡️ **الموثوقية وتجربة المطور**: إعداد `.env` عبر `vibe-trading init` ([#19](https://github.com/HKUDS/Vibe-Trading/pull/19))، وفحوصات مسبقة، وfallback لمصادر البيانات وقت التشغيل، ومحرك اختبار رجعي معزز. README متعدد اللغات ([#21](https://github.com/HKUDS/Vibe-Trading/pull/21)).
|
||||
- **2026-04-10** 📦 **v0.1.4**: إصلاح Docker ([#8](https://github.com/HKUDS/Vibe-Trading/issues/8))، وأداة MCP `web_search`، و12 مزود LLM، وتبعيات `akshare`/`ccxt`. النشر إلى PyPI وClawHub.
|
||||
- **2026-04-09** 📊 **الموجة الثانية للاختبار الرجعي**: محركات ChinaFutures وGlobalFutures وForex وOptions v2. تحقق Monte Carlo وBootstrap CI وWalk-Forward.
|
||||
- **2026-04-08** 🔧 **اختبار رجعي متعدد الأسواق** مع قواعد لكل سوق، وتصدير Pine Script v6، و5 مصادر بيانات مع fallback تلقائي.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## ✨ الميزات الرئيسية
|
||||
|
||||
<div align="center">
|
||||
<table align="center" width="94%" style="width:94%; margin-left:auto; margin-right:auto;">
|
||||
<tr>
|
||||
<td align="center" width="50%" valign="top">
|
||||
<img src="assets/feature-self-improving-trading-agent.png" height="130" alt="وكيل تداول ذاتي التحسن"/><br>
|
||||
<h3>🔍 وكيل تداول ذاتي التحسن</h3>
|
||||
<div align="left">
|
||||
• بحث سوقي باللغة الطبيعية<br>
|
||||
• مسودات استراتيجيات وتحليل ملفات/ويب<br>
|
||||
• تدفقات عمل مدعومة بالذاكرة
|
||||
</div>
|
||||
</td>
|
||||
<td align="center" width="50%" valign="top">
|
||||
<img src="assets/feature-multi-agent-trading-teams.png" height="130" alt="فرق تداول متعددة الوكلاء"/><br>
|
||||
<h3>🐝 فرق تداول متعددة الوكلاء</h3>
|
||||
<div align="left">
|
||||
• فرق استثمار وكمّ وكريبتو ومخاطر<br>
|
||||
• تقدم مباشر وتقارير محفوظة<br>
|
||||
• وكلاء مؤسسون على بيانات سوق مجلوبة
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="50%" valign="top">
|
||||
<img src="assets/feature-cross-market-data-backtesting.png" height="130" alt="بيانات واختبارات رجعية عبر الأسواق"/><br>
|
||||
<h3>📊 بيانات واختبارات رجعية عبر الأسواق</h3>
|
||||
<div align="left">
|
||||
• أسهم A/HK/US، وكريبتو، وعقود آجلة، وفوركس<br>
|
||||
• fallback للبيانات واختبارات مركبة<br>
|
||||
• بيانات PIT، وتحقيق، وبطاقات تشغيل
|
||||
</div>
|
||||
</td>
|
||||
<td align="center" width="50%" valign="top">
|
||||
<img src="assets/feature-shadow-account.png" height="130" alt="Shadow Account"/><br>
|
||||
<h3>👥 Shadow Account</h3>
|
||||
<div align="left">
|
||||
• تشخيص سلوكي لسجلات الوسطاء<br>
|
||||
• مقارنات Shadow Account قائمة على القواعد<br>
|
||||
• تقارير تدقيق وكود استراتيجية قابلان للتصدير
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## 💡 ما هو Vibe-Trading؟
|
||||
|
||||
Vibe-Trading مساحة عمل بحثية مفتوحة المصدر تحول الأسئلة المالية إلى تحليل قابل للتشغيل. يربط المطالبات باللغة الطبيعية بمحملات بيانات السوق، وتوليد الاستراتيجيات، ومحركات الاختبار الرجعي، والتقارير، والتصدير، وذاكرة البحث الدائمة.
|
||||
|
||||
صُمم للبحث والمحاكاة والاختبار الرجعي — وعند اختيارك، يتيح أيضاً تداولاً مستقلاً عبر وسيط تُصرّح به بنفسك (مثل Robinhood Agentic Trading). لا يحتفظ بأي أموال، ولا يتداول أبداً خارج الحدود التي تضعها، ويمكنك إيقافه فوراً.
|
||||
|
||||
---
|
||||
|
||||
## ✨ ما الذي يمكنك فعله؟
|
||||
|
||||
| المهمة | الناتج |
|
||||
|------|--------|
|
||||
| **طرح سؤال تداول** | بحث سوقي باستخدام الأدوات والبيانات والمستندات وسياق جلسة قابل لإعادة الاستخدام. |
|
||||
| **اختبار فكرة استراتيجية رجعياً** | كود استراتيجية، ومقاييس، وسياق معياري، ومخرجات تحقق، وبطاقات تشغيل. |
|
||||
| **مراجعة صفقاتك الخاصة** | قراءة سجلات الوسطاء، وتشخيص السلوك، واستخراج القواعد، ومقارنات Shadow Account. |
|
||||
| **تحسين الأبحاث المتكررة** | الذاكرة الدائمة والمهارات القابلة للتحرير تحول الروتينات المفيدة إلى تدفقات قابلة لإعادة الاستخدام. |
|
||||
| **تشغيل فرق محللين** | مراجعات بحث متعددة الوكلاء لتدفقات الاستثمار والكم والكريبتو والماكرو والمخاطر. |
|
||||
| **إنتاج مخرجات قابلة للاستخدام** | تقارير، وTradingView Pine Script، وTDX، وMetaTrader 5، وأدوات MCP، وجلسات بحث لاحقة. |
|
||||
| **bench ألفا zoo جاهزة** | تشغيل IC + IR + تصنيف alive/reversed/dead عبر 452 ألفا (Qlib 158 + Kakushadze 101 + GTJA 191 + FF5 + Carhart) بسطر أوامر واحد على universe الخاص بك. |
|
||||
|
||||
---
|
||||
|
||||
## ⚡ مثال سريع
|
||||
|
||||
```bash
|
||||
pip install vibe-trading-ai
|
||||
|
||||
# بحث بلغة طبيعية
|
||||
vibe-trading run -p "Backtest a BTC-USDT 20/50 moving-average strategy for 2024, summarize return and drawdown, then export the report"
|
||||
|
||||
# bench لـ alpha zoo جاهز بسطر واحد
|
||||
vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025 --top 20
|
||||
```
|
||||
|
||||
```bash
|
||||
vibe-trading --upload trades_export.csv
|
||||
vibe-trading run -p "Analyze my trading behavior, extract my shadow strategy, and compare it with my actual trades"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 👥 حساب الظل
|
||||
|
||||
ينطلق Shadow Account من سجلات تداولك أنت، لا من قالب استراتيجية عام.
|
||||
|
||||
ارفع تصديراً من وسيطك، ودع الوكيل يلخص سلوكك، ثم قارن مسار التداول الحقيقي باستراتيجية ظل قائمة على قواعد.
|
||||
|
||||
| الخطوة | ناتج الوكيل |
|
||||
|------|--------------|
|
||||
| **1. قراءة سجلك** | يقرأ صادرات الوسطاء من 同花顺 و东方财富 و富途 وصيغ CSV العامة. |
|
||||
| **2. بناء ملف سلوكك** | أيام الاحتفاظ، ومعدل الربح، ونسبة PnL، والتراجع، وdisposition effect، والإفراط في التداول، ومطاردة الزخم، وفحوصات anchoring. |
|
||||
| **3. استخراج قواعدك** | يحول أنماط الدخول/الخروج المتكررة إلى ملف استراتيجية صريح بدلاً من ملخص ضبابي. |
|
||||
| **4. تشغيل الظل** | يختبر القواعد المستخرجة رجعياً ويبرز خرق القواعد، والخروج المبكر، والإشارات الفائتة، ومسارات التداول البديلة. |
|
||||
| **5. تسليم التقرير** | ينتج تقرير HTML/PDF يمكن فحصه أو أرشفته أو تحسينه في جلسة لاحقة. |
|
||||
|
||||
```bash
|
||||
vibe-trading --upload trades_export.csv
|
||||
vibe-trading run -p "Analyze my trading behavior, extract my shadow strategy, and compare it with my actual trades"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 سير البحث
|
||||
|
||||
تتبع أغلب التشغيلات مسار أدلة واحداً: توجيه الطلب، تحميل سياق السوق المناسب، تنفيذ الأدوات، التحقق من المخرجات، وإبقاء المخرجات قابلة للفحص.
|
||||
|
||||
| الطبقة | ما يحدث |
|
||||
|-------|--------------|
|
||||
| **Plan** | يختار المهارات المالية والأدوات ومصادر البيانات وإعداد السرب الملائمة عند الحاجة. |
|
||||
| **Ground** | يجلب أسهم A، وأسهم HK/US، والكريبتو، والعقود الآجلة، والفوركس، والمستندات، أو سياق الويب عبر المحملات المتاحة. |
|
||||
| **Execute** | يولد كود استراتيجية قابل للاختبار، ويشغل الأدوات، ويستخدم محرك الاختبار الرجعي أو سير التحليل المناسب. |
|
||||
| **Validate** | يضيف المقاييس، والمقارنة المعيارية، وMonte Carlo، وBootstrap، وWalk-Forward، وبطاقات التشغيل، والتحذيرات عند اللزوم. |
|
||||
| **Deliver** | يعيد التقارير والمخرجات وآثار الأدوات والتصديرات إلى TradingView وTDX وMetaTrader 5 وعملاء MCP أو جلسات لاحقة. |
|
||||
|
||||
---
|
||||
|
||||
## 🔩 القدرات التفصيلية
|
||||
|
||||
القوائم التفصيلية مطوية أدناه حتى يبقى README سهل القراءة. افتحها عندما تريد فحص اللبنات المتاحة.
|
||||
|
||||
<details>
|
||||
<summary><b>مكتبة المهارات المالية</b> <sub>75 مهارة عبر 8 فئات</sub></summary>
|
||||
|
||||
- 📊 75 مهارة مالية متخصصة منظمة في 8 فئات
|
||||
- 🌐 تغطية كاملة من الأسواق التقليدية إلى الكريبتو وDeFi
|
||||
- 🔬 قدرات شاملة من مصادر البيانات إلى البحث الكمي
|
||||
|
||||
| الفئة | المهارات | أمثلة |
|
||||
|----------|--------|----------|
|
||||
| Data Source | 7 | `data-routing`, `tushare`, `yfinance`, `okx-market`, `akshare`, `mootdx`, `ccxt` |
|
||||
| Strategy | 17 | `strategy-generate`, `cross-market-strategy`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
|
||||
| Analysis | 17 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis`, `dividend-analysis` |
|
||||
| Asset Class | 9 | `options-strategy`, `options-advanced`, `convertible-bond`, `etf-analysis`, `asset-allocation`, `sector-rotation` |
|
||||
| Crypto | 7 | `perp-funding-basis`, `liquidation-heatmap`, `stablecoin-flow`, `defi-yield`, `onchain-analysis` |
|
||||
| Flow | 7 | `hk-connect-flow`, `us-etf-flow`, `edgar-sec-filings`, `financial-statement`, `adr-hshare` |
|
||||
| Tool | 11 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader`, `vnpy-export`, `alpha-zoo` |
|
||||
| Risk Analysis | 1 | `ashare-pre-st-filter` |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>فرق تداول جاهزة</b> <sub>29 إعداد سرب مسبق</sub></summary>
|
||||
|
||||
- 🏢 29 فريق وكلاء جاهزاً للاستخدام
|
||||
- ⚡ تدفقات مالية مهيأة مسبقاً
|
||||
- 🎯 إعدادات للاستثمار والتداول وإدارة المخاطر
|
||||
|
||||
| الإعداد | سير العمل |
|
||||
|--------|----------|
|
||||
| `investment_committee` | مناظرة صعود/هبوط → مراجعة مخاطر → قرار مدير المحفظة النهائي |
|
||||
| `global_equities_desk` | باحث أسهم A + HK/US + كريبتو → استراتيجي عالمي |
|
||||
| `crypto_trading_desk` | تمويل/أساس + تصفية + تدفق → مدير مخاطر |
|
||||
| `earnings_research_desk` | أساسيات + مراجعات + خيارات → استراتيجي أرباح |
|
||||
| `macro_rates_fx_desk` | أسعار فائدة + FX + سلع → مدير محفظة ماكرو |
|
||||
| `quant_strategy_desk` | فرز + بحث عوامل → اختبار رجعي → تدقيق مخاطر |
|
||||
| `technical_analysis_panel` | TA كلاسيكي + Ichimoku + harmonic + Elliott + SMC → إجماع |
|
||||
| `risk_committee` | تراجع + مخاطر ذيل + مراجعة نظام → اعتماد |
|
||||
| `global_allocation_committee` | أسهم A + كريبتو + HK/US → تخصيص عبر الأسواق |
|
||||
|
||||
<sub>بالإضافة إلى أكثر من 20 إعداداً متخصصاً آخر — شغل vibe-trading --swarm-presets لاستكشافها كلها.
|
||||
|
||||
</sub>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Alpha Zoo</b> <sub>452 ألفا كمّي جاهز عبر 4 zoos</sub></summary>
|
||||
|
||||
- 🧬 452 ألفا cross-sectional، مع منع lookahead على طبقة العوامل (operators)
|
||||
- 📈 IC + IR + تصنيف alive/reversed/dead بأمر CLI واحد
|
||||
- 🔬 بوابة نقاء AST + اختبار حماية lookahead بـ 300 صف + قاطع شبكة عبر `pytest-socket`
|
||||
- 📦 إسناد Apache-2 لـ Qlib؛ ملف `LICENSE.md` لكل zoo يصرّح بأن الصيغ محتوى رياضي
|
||||
- 🤝 سير عمل توقيع Developer Certificate of Origin (DCO) لمساهمات المجتمع
|
||||
|
||||
| Zoo | العدد | المصدر | الرخصة |
|
||||
|-----|-------|--------|--------|
|
||||
| **qlib158** | 154 | Microsoft Qlib `Alpha158` (Apache-2.0، مثبّت على commit) | Apache-2.0 |
|
||||
| **alpha101** | 101 | Kakushadze (2015)، "101 Formulaic Alphas"، arXiv:1601.00991 | الصيغ محتوى رياضي |
|
||||
| **gtja191** | 191 | Guotai Junan (2014)، "191 Short-period Trading Alpha Factors" | الصيغ محتوى رياضي |
|
||||
| **academic** | 6 | Fama-French 5 + Carhart momentum (proxy قائم على الأسعار) | أدبيات أكاديمية عامة |
|
||||
|
||||
شغّل `vibe-trading alpha list` للتصفح، و`vibe-trading alpha show <id>` للحصول على الصيغ + المصدر، و`vibe-trading alpha bench --zoo X --universe Y --period Z` لتقييم zoo كاملة.
|
||||
|
||||
</details>
|
||||
|
||||
## 🎬 العرض التوضيحي
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
https://github.com/user-attachments/assets/4e4dcb80-7358-4b9a-92f0-1e29612e6e86
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
https://github.com/user-attachments/assets/3754a414-c3ee-464f-b1e8-78e1a74fbd30
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" align="center"><sub>☝️ اختبار رجعي باللغة الطبيعية ومناظرة سرب متعدد الوكلاء — Web UI + CLI</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 🚀 البدء السريع
|
||||
|
||||
### تثبيت بسطر واحد (PyPI)
|
||||
|
||||
```bash
|
||||
pip install vibe-trading-ai
|
||||
```
|
||||
|
||||
ثم شغل أول مهمة بحثية:
|
||||
|
||||
```bash
|
||||
vibe-trading init
|
||||
vibe-trading run -p "Backtest a BTC-USDT 20/50 moving-average strategy for 2024 and summarize return and drawdown"
|
||||
```
|
||||
|
||||
> **اسم الحزمة مقابل الأوامر:** حزمة PyPI هي `vibe-trading-ai`. بعد التثبيت تحصل على ثلاثة أوامر:
|
||||
>
|
||||
> | الأمر | الغرض |
|
||||
> |---------|---------|
|
||||
> | `vibe-trading` | CLI / TUI تفاعلي |
|
||||
> | `vibe-trading serve` | تشغيل خادم ويب FastAPI |
|
||||
> | `vibe-trading-mcp` | بدء خادم MCP (لـ Claude Desktop وOpenClaw وCursor وغيرها) |
|
||||
|
||||
```bash
|
||||
vibe-trading init # interactive .env setup
|
||||
vibe-trading # launch CLI
|
||||
vibe-trading serve --port 8899 # launch web UI
|
||||
vibe-trading-mcp # start MCP server (stdio)
|
||||
```
|
||||
|
||||
### أو اختر مساراً
|
||||
|
||||
| المسار | الأنسب لـ | الوقت |
|
||||
|------|----------|------|
|
||||
| **A. Docker** | التجربة الآن، دون إعداد محلي | دقيقتان |
|
||||
| **B. تثبيت محلي** | التطوير والوصول الكامل إلى CLI | 5 دقائق |
|
||||
| **C. MCP plugin** | وصله بوكيلك الحالي | 3 دقائق |
|
||||
| **D. ClawHub** | أمر واحد دون استنساخ | دقيقة واحدة |
|
||||
|
||||
### المتطلبات المسبقة
|
||||
|
||||
- **مفتاح API لنموذج LLM** من أي مزود مدعوم — أو التشغيل محلياً عبر **Ollama** (لا يحتاج مفتاحاً)
|
||||
- **Python 3.11+** للمسار B
|
||||
- **Docker** للمسار A
|
||||
- يمكن استخدام OpenAI Codex أيضاً عبر ChatGPT OAuth: اضبط `LANGCHAIN_PROVIDER=openai-codex`، ثم شغل `vibe-trading provider login openai-codex`. هذا لا يستخدم `OPENAI_API_KEY`.
|
||||
|
||||
> **مزودو LLM المدعومون:** OpenRouter, OpenAI, DeepSeek, Gemini, Groq, DashScope/Qwen, Zhipu, Moonshot/Kimi, MiniMax, Xiaomi MIMO, Z.ai, Ollama (local). راجع `.env.example` للإعداد.
|
||||
|
||||
> **نصيحة:** تعمل كل الأسواق دون مفاتيح API بفضل fallback التلقائي. yfinance (HK/US)، وOKX (crypto)، وmootdx (أسهم A، اتصال TCP مباشر بدون قيود IP)، وAKShare (A-shares, US, HK, futures, forex) كلها مجانية. رمز Tushare اختياري — mootdx هو الـ fallback الموصى به لأسهم A بدون رمز، بينما يوفر AKShare احتياطياً أوسع تغطية.
|
||||
|
||||
### المسار A: Docker (دون إعداد)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/Vibe-Trading.git
|
||||
cd Vibe-Trading
|
||||
cp agent/.env.example agent/.env
|
||||
# Edit agent/.env — uncomment your LLM provider and set API key
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
افتح `http://localhost:8899`. الخلفية والواجهة الأمامية داخل حاوية واحدة.
|
||||
|
||||
ينشر Docker الخلفية على `127.0.0.1:8899` افتراضياً ويشغل التطبيق كمستخدم حاوية غير root. إذا كنت تقصد تعريض API خارج جهازك، فاضبط `API_AUTH_KEY` قوياً وأرسل `Authorization: Bearer <key>` من العملاء.
|
||||
|
||||
### المسار B: التثبيت المحلي
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/Vibe-Trading.git
|
||||
cd Vibe-Trading
|
||||
python -m venv .venv
|
||||
|
||||
# Activate
|
||||
source .venv/bin/activate # Linux / macOS
|
||||
# .venv\Scripts\Activate.ps1 # Windows PowerShell
|
||||
|
||||
pip install -e .
|
||||
cp agent/.env.example agent/.env # Edit — set your LLM provider API key
|
||||
vibe-trading # Launch interactive TUI
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>تشغيل واجهة الويب (اختياري)</b></summary>
|
||||
|
||||
```bash
|
||||
# Terminal 1: API server
|
||||
vibe-trading serve --port 8899
|
||||
|
||||
# Terminal 2: Frontend dev server
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
افتح `http://localhost:5899`. تمرر الواجهة الأمامية استدعاءات API إلى `localhost:8899`.
|
||||
|
||||
**وضع الإنتاج (خادم واحد):**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build && cd ..
|
||||
vibe-trading serve --port 8899 # FastAPI serves dist/ as static files
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### المسار C: MCP plugin
|
||||
|
||||
راجع قسم [MCP Plugin](#-mcp-plugin) أدناه.
|
||||
|
||||
### المسار D: ClawHub (أمر واحد)
|
||||
|
||||
```bash
|
||||
npx clawhub@latest install vibe-trading --force
|
||||
```
|
||||
|
||||
تُنزل المهارة وإعداد MCP إلى مجلد مهارات وكيلك. راجع [تثبيت ClawHub](#-mcp-plugin) للتفاصيل.
|
||||
|
||||
---
|
||||
|
||||
## 🧠 متغيرات البيئة
|
||||
|
||||
انسخ `agent/.env.example` إلى `agent/.env` وأزل التعليق عن كتلة المزود التي تريدها. يحتاج كل مزود إلى 3-4 متغيرات:
|
||||
|
||||
| المتغير | مطلوب | الوصف |
|
||||
|----------|:--------:|-------------|
|
||||
| `LANGCHAIN_PROVIDER` | نعم | اسم المزود (`openrouter`, `deepseek`, `groq`, `ollama`, إلخ) |
|
||||
| `<PROVIDER>_API_KEY` | نعم* | مفتاح API (`OPENROUTER_API_KEY`, `DEEPSEEK_API_KEY`, إلخ) |
|
||||
| `<PROVIDER>_BASE_URL` | نعم | عنوان URL لنقطة نهاية API |
|
||||
| `LANGCHAIN_MODEL_NAME` | نعم | اسم النموذج (مثل `deepseek-v4-pro`) |
|
||||
| `TUSHARE_TOKEN` | لا | رمز Tushare Pro لبيانات أسهم A (يرجع إلى AKShare عند الحاجة) |
|
||||
| `TIMEOUT_SECONDS` | لا | مهلة استدعاء LLM، الافتراضي 120s |
|
||||
| `API_AUTH_KEY` | موصى به للنشر الشبكي | Bearer token مطلوب عندما يكون API قابلاً للوصول من عملاء غير محليين |
|
||||
| `VIBE_TRADING_ENABLE_SHELL_TOOLS` | لا | تفعيل صريح للأدوات القادرة على shell في نشر API/MCP-SSE البعيد |
|
||||
| `VIBE_TRADING_ALLOWED_FILE_ROOTS` | لا | جذور إضافية مفصولة بفواصل لاستيراد المستندات وسجلات الوسطاء |
|
||||
| `VIBE_TRADING_ALLOWED_RUN_ROOTS` | لا | جذور إضافية مفصولة بفواصل لأدلة تشغيل الكود المولد |
|
||||
|
||||
<sub>* لا يحتاج Ollama إلى مفتاح API. يستخدم OpenAI Codex ChatGPT OAuth ويخزن الرموز عبر `oauth-cli-kit`، لا داخل `agent/.env`.</sub>
|
||||
|
||||
**بيانات مجانية (دون مفتاح):** أسهم A عبر AKShare، وأسهم HK/US عبر yfinance، والكريبتو عبر OKX، وأكثر من 100 بورصة كريبتو عبر CCXT. يختار النظام تلقائياً أفضل مصدر متاح لكل سوق.
|
||||
|
||||
### 🎯 النماذج الموصى بها
|
||||
|
||||
Vibe-Trading وكيل كثيف الأدوات؛ المهارات والاختبارات الرجعية والذاكرة والأسراب كلها تمر عبر استدعاءات أدوات. اختيار النموذج يحدد مباشرة هل سيستخدم الوكيل أدواته أم سيصطنع إجابات من بيانات التدريب.
|
||||
|
||||
| المستوى | أمثلة | متى تستخدمه |
|
||||
|------|----------|-------------|
|
||||
| **الأفضل** | `anthropic/claude-opus-4.7`, `anthropic/claude-sonnet-4.6`, `openai/gpt-5.5-pro`, `google/gemini-3.5-flash` | أسراب معقدة (3+ وكلاء)، جلسات بحث طويلة، تحليل بمستوى ورقة علمية |
|
||||
| **النقطة المثلى** (افتراضي) | `deepseek-v4-pro`, `deepseek/deepseek-v4-pro`, `x-ai/grok-4.20`, `z-ai/glm-5.1`, `moonshotai/kimi-k2.6`, `qwen/qwen3-max-thinking` | الاستخدام اليومي، tool-calling موثوق بنحو عُشر التكلفة |
|
||||
| **تجنبها لاستخدام الوكيل** | `*-nano`, `*-flash-lite`, `*-coder-next`, small / distilled variants | tool-calling غير موثوق؛ سيبدو الوكيل وكأنه "يجيب من الذاكرة" بدلاً من تحميل المهارات أو تشغيل الاختبارات الرجعية |
|
||||
|
||||
يأتي `agent/.env.example` افتراضياً مع DeepSeek official API + `deepseek-v4-pro`; ويمكن لمستخدمي OpenRouter استخدام `deepseek/deepseek-v4-pro`.
|
||||
|
||||
---
|
||||
|
||||
## 🖥 مرجع CLI
|
||||
|
||||
```bash
|
||||
vibe-trading # interactive TUI
|
||||
vibe-trading run -p "..." # single run
|
||||
vibe-trading serve # API server
|
||||
vibe-trading alpha list # استعرض 452 ألفا جاهز؛ متاح show / bench / compare / export-manifest
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>أوامر الشرطة المائلة داخل TUI</b></summary>
|
||||
|
||||
| الأمر | الوصف |
|
||||
|---------|-------------|
|
||||
| `/help` | عرض كل الأوامر |
|
||||
| `/skills` | عرض كل المهارات المالية الـ 77 |
|
||||
| `/swarm` | عرض إعدادات فرق السرب الـ 29 |
|
||||
| `/swarm run <preset> [vars_json]` | تشغيل فريق سرب مع بث مباشر |
|
||||
| `/swarm list` | سجل تشغيلات السرب |
|
||||
| `/swarm show <run_id>` | تفاصيل تشغيل السرب |
|
||||
| `/swarm cancel <run_id>` | إلغاء سرب قيد التشغيل |
|
||||
| `/list` | التشغيلات الأخيرة |
|
||||
| `/show <run_id>` | تفاصيل التشغيل + المقاييس |
|
||||
| `/code <run_id>` | كود الاستراتيجية المولدة |
|
||||
| `/pine <run_id>` | تصدير المؤشرات (TradingView + TDX + MT5) |
|
||||
| `/trace <run_id>` | إعادة تشغيل التنفيذ كاملة |
|
||||
| `/continue <run_id> <prompt>` | متابعة تشغيل بتعليمات جديدة |
|
||||
| `/sessions` | عرض جلسات الدردشة |
|
||||
| `/settings` | عرض إعدادات التشغيل |
|
||||
| `/clear` | مسح الشاشة |
|
||||
| `/quit` | خروج |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>تشغيل واحد والخيارات</b></summary>
|
||||
|
||||
```bash
|
||||
vibe-trading run -p "Backtest BTC-USDT MACD strategy, last 30 days"
|
||||
vibe-trading run -p "Analyze AAPL momentum" --json
|
||||
vibe-trading run -f strategy.txt
|
||||
echo "Backtest 000001.SZ RSI" | vibe-trading run
|
||||
```
|
||||
|
||||
```bash
|
||||
vibe-trading -p "your prompt"
|
||||
vibe-trading --skills
|
||||
vibe-trading --swarm-presets
|
||||
vibe-trading --swarm-run investment_committee '{"topic":"BTC outlook"}'
|
||||
vibe-trading --list
|
||||
vibe-trading --show <run_id>
|
||||
vibe-trading --code <run_id>
|
||||
vibe-trading --pine <run_id> # Export indicators (TradingView + TDX + MT5)
|
||||
vibe-trading --trace <run_id>
|
||||
vibe-trading --continue <run_id> "refine the strategy"
|
||||
vibe-trading --upload report.pdf
|
||||
```
|
||||
|
||||
```bash
|
||||
vibe-trading alpha list --zoo gtja191 --limit 10
|
||||
vibe-trading alpha show gtja191_171
|
||||
vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025 --top 20
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 💡 أمثلة
|
||||
|
||||
### الاستراتيجيات والاختبار الرجعي
|
||||
|
||||
```bash
|
||||
# Moving average crossover on US equities
|
||||
vibe-trading run -p "Backtest a 20/50-day moving average crossover on AAPL for the past year, show Sharpe ratio and max drawdown"
|
||||
|
||||
# RSI mean-reversion on crypto
|
||||
vibe-trading run -p "Test RSI(14) mean-reversion on BTC-USDT: buy below 30, sell above 70, last 6 months"
|
||||
|
||||
# Multi-factor strategy on A-shares
|
||||
vibe-trading run -p "Backtest a momentum + value + quality multi-factor strategy on CSI 300 constituents over 2 years"
|
||||
|
||||
# After backtesting, export to TradingView / TDX / MetaTrader 5
|
||||
vibe-trading --pine <run_id>
|
||||
```
|
||||
|
||||
**bench ألفا zoo جاهز بسطر واحد**:
|
||||
```bash
|
||||
vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025 --top 20
|
||||
```
|
||||
|
||||
**استعرض الكتالوج** وافحص ألفا مفردة:
|
||||
```bash
|
||||
vibe-trading alpha list --zoo gtja191 --theme reversal --limit 10
|
||||
vibe-trading alpha show gtja191_171
|
||||
```
|
||||
|
||||
**ركّب إشارة متعدد العوامل** من ألفات zoo (Python):
|
||||
```python
|
||||
from src.skills.multi_factor.zoo_signal_engine import ZooSignalEngine
|
||||
engine = ZooSignalEngine.from_zoo(["gtja191_171", "gtja191_111", "gtja191_163"])
|
||||
panel = ... # your wide OHLCV panel
|
||||
signal = engine.compute_signal(panel)
|
||||
```
|
||||
|
||||
### بحث السوق
|
||||
|
||||
```bash
|
||||
# Equity deep-dive
|
||||
vibe-trading run -p "Research NVDA: earnings trend, analyst consensus, option flow, and key risks for next quarter"
|
||||
|
||||
# Macro analysis
|
||||
vibe-trading run -p "Analyze the current Fed rate path, USD strength, and impact on EM equities and gold"
|
||||
|
||||
# Crypto on-chain
|
||||
vibe-trading run -p "Deep dive BTC on-chain: whale flows, exchange balances, miner activity, and funding rates"
|
||||
```
|
||||
|
||||
### تدفقات السرب
|
||||
|
||||
```bash
|
||||
# Bull/bear debate on a stock
|
||||
vibe-trading --swarm-run investment_committee '{"topic": "Is TSLA a buy at current levels?"}'
|
||||
|
||||
# Quant strategy from screening to backtest
|
||||
vibe-trading --swarm-run quant_strategy_desk '{"universe": "S&P 500", "horizon": "3 months"}'
|
||||
|
||||
# Crypto desk: funding + liquidation + flow → risk manager
|
||||
vibe-trading --swarm-run crypto_trading_desk '{"asset": "ETH-USDT", "timeframe": "1w"}'
|
||||
|
||||
# Global macro portfolio allocation
|
||||
vibe-trading --swarm-run macro_rates_fx_desk '{"focus": "Fed pivot impact on EM bonds"}'
|
||||
```
|
||||
|
||||
### ذاكرة عبر الجلسات
|
||||
|
||||
```bash
|
||||
# Save your preferences once
|
||||
vibe-trading run -p "Remember: I prefer RSI-based strategies, max 10% drawdown, hold period 5–20 days"
|
||||
|
||||
# The agent recalls them in future sessions automatically
|
||||
vibe-trading run -p "Build a crypto strategy that fits my risk profile"
|
||||
```
|
||||
|
||||
### رفع المستندات وتحليلها
|
||||
|
||||
```bash
|
||||
# Analyze a broker export or earnings report
|
||||
vibe-trading --upload trades_export.csv
|
||||
vibe-trading run -p "Profile my trading behavior and identify any biases"
|
||||
|
||||
vibe-trading --upload NVDA_Q1_earnings.pdf
|
||||
vibe-trading run -p "Summarize the key risks and beats/misses from this earnings report"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 خادم API
|
||||
|
||||
```bash
|
||||
vibe-trading serve --port 8899
|
||||
```
|
||||
|
||||
| الطريقة | نقطة النهاية | الوصف |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/runs` | عرض التشغيلات |
|
||||
| `GET` | `/runs/{run_id}` | تفاصيل التشغيل |
|
||||
| `GET` | `/runs/{run_id}/pine` | تصدير مؤشرات متعدد المنصات |
|
||||
| `POST` | `/sessions` | إنشاء جلسة |
|
||||
| `POST` | `/sessions/{id}/messages` | إرسال رسالة |
|
||||
| `GET` | `/sessions/{id}/events` | بث أحداث SSE |
|
||||
| `POST` | `/upload` | رفع PDF/ملف |
|
||||
| `GET` | `/swarm/presets` | عرض إعدادات السرب |
|
||||
| `POST` | `/swarm/runs` | بدء تشغيل سرب |
|
||||
| `GET` | `/swarm/runs/{id}/events` | بث SSE للسرب |
|
||||
| `GET` | `/alpha/list` | قائمة ألفات مع تصفية حسب zoo/theme/universe |
|
||||
| `GET` | `/alpha/{alpha_id}` | بيانات وصفية + الكود المصدري للألفا |
|
||||
| `POST` | `/alpha/bench` | بدء مهمة bench (يعيد `job_id`) |
|
||||
| `GET` | `/alpha/bench/{job_id}/stream` | تدفق تقدّم SSE |
|
||||
| `GET` | `/settings/llm` | قراءة إعدادات LLM في Web UI |
|
||||
| `PUT` | `/settings/llm` | تحديث إعدادات LLM المحلية |
|
||||
| `GET` | `/settings/data-sources` | قراءة إعدادات مصادر البيانات المحلية |
|
||||
| `PUT` | `/settings/data-sources` | تحديث إعدادات مصادر البيانات المحلية |
|
||||
|
||||
توثيق تفاعلي: `http://localhost:8899/docs`
|
||||
|
||||
### الإعدادات الأمنية الافتراضية
|
||||
|
||||
للتطوير على localhost، يبقي `vibe-trading serve` سير المتصفح بسيطاً. لأي عميل غير محلي، تتطلب نقاط API الحساسة `API_AUTH_KEY`؛ استخدم `Authorization: Bearer <key>` لطلبات JSON/الرفع. تتعامل Web UI مع تدفقات Browser EventSource بعد إدخال المفتاح نفسه مرة واحدة في Settings.
|
||||
|
||||
تتوفر الأدوات القادرة على shell للـ CLI المحلي وتدفقات localhost الموثوقة، لكنها لا تُعرض لجلسات API البعيدة ما لم تضبط صراحة `VIBE_TRADING_ENABLE_SHELL_TOOLS=1`. قارئات المستندات والسجلات محدودة افتراضياً بجذور الرفع/الاستيراد؛ ضع الملفات تحت `agent/uploads` أو `agent/runs` أو `./uploads` أو `./data` أو `~/.vibe-trading/uploads` أو `~/.vibe-trading/imports`، أو أضف دليلاً مخصصاً عبر `VIBE_TRADING_ALLOWED_FILE_ROOTS`.
|
||||
|
||||
### إعدادات Web UI
|
||||
|
||||
تتيح صفحة Settings في Web UI للمستخدمين المحليين تحديث مزود/نموذج LLM، وbase URL، ومعلمات التوليد، وreasoning effort، وبيانات اعتماد السوق الاختيارية مثل رمز Tushare. تُحفظ الإعدادات في `agent/.env`؛ وتُحمّل قيم المزودين الافتراضية من `agent/src/providers/llm_providers.json`.
|
||||
|
||||
قراءات Settings بلا آثار جانبية: لا تنشئ `GET /settings/llm` ولا `GET /settings/data-sources` ملف `agent/.env`، ولا تعيدان إلا مسارات نسبية للمشروع. قد تكشف قراءات وكتابات Settings حالة بيانات الاعتماد أو تحدث بيانات الاعتماد/بيئة التشغيل، لذلك تتطلب `API_AUTH_KEY` عند ضبطه. إذا كان `API_AUTH_KEY` غير مضبوط في وضع التطوير، فلا يقبل الوصول إلى Settings إلا من عملاء loopback.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 MCP Plugin
|
||||
|
||||
يعرض Vibe-Trading 22 أداة MCP لأي عميل متوافق مع MCP. يعمل كعملية stdio فرعية، دون إعداد خادم. **21 من أصل 22 أداة تعمل دون أي مفاتيح API** (HK/US/crypto). يحتاج `run_swarm` وحده إلى مفتاح LLM.
|
||||
|
||||
<details>
|
||||
<summary><b>Claude Desktop</b></summary>
|
||||
|
||||
أضف إلى `claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"vibe-trading": {
|
||||
"command": "vibe-trading-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>OpenClaw</b></summary>
|
||||
|
||||
أضف إلى `~/.openclaw/config.yaml`:
|
||||
|
||||
```yaml
|
||||
skills:
|
||||
- name: vibe-trading
|
||||
command: vibe-trading-mcp
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Cursor / Windsurf / عملاء MCP آخرون</b></summary>
|
||||
|
||||
```bash
|
||||
vibe-trading-mcp # stdio (default)
|
||||
vibe-trading-mcp --transport sse # SSE for web clients
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**أدوات MCP المعروضة (22):** `list_skills`, `load_skill`, `backtest`, `factor_analysis`, `analyze_options`, `pattern_recognition`, `get_market_data`, `web_search`, `read_url`, `read_document`, `read_file`, `write_file`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `list_swarm_presets`, `run_swarm`, `get_swarm_status`, `get_run_result`, `list_runs`.
|
||||
|
||||
<details>
|
||||
<summary><b>التثبيت من ClawHub (أمر واحد)</b></summary>
|
||||
|
||||
```bash
|
||||
npx clawhub@latest install vibe-trading --force
|
||||
```
|
||||
|
||||
> `--force` مطلوب لأن المهارة تشير إلى واجهات API خارجية، مما يطلق فحص VirusTotal الآلي. الكود مفتوح المصدر بالكامل وآمن للفحص.
|
||||
|
||||
ينزل هذا المهارة وإعداد MCP إلى مجلد مهارات وكيلك. لا حاجة للاستنساخ.
|
||||
|
||||
تصفح على ClawHub: [clawhub.ai/skills/vibe-trading](https://clawhub.ai/skills/vibe-trading)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>OpenSpace — مهارات ذاتية التطور</b></summary>
|
||||
|
||||
كل المهارات المالية الـ 77 منشورة على [open-space.cloud](https://open-space.cloud) وتتطور ذاتياً عبر محرك التطور الذاتي في OpenSpace.
|
||||
|
||||
للاستخدام مع OpenSpace، أضف خادمي MCP إلى إعداد وكيلك:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"openspace": {
|
||||
"command": "openspace-mcp",
|
||||
"toolTimeout": 600,
|
||||
"env": {
|
||||
"OPENSPACE_HOST_SKILL_DIRS": "/path/to/vibe-trading/agent/src/skills",
|
||||
"OPENSPACE_WORKSPACE": "/path/to/OpenSpace"
|
||||
}
|
||||
},
|
||||
"vibe-trading": {
|
||||
"command": "vibe-trading-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
سيكتشف OpenSpace كل المهارات الـ 77 تلقائياً، مما يتيح auto-fix وauto-improve والمشاركة المجتمعية. ابحث عن مهارات Vibe-Trading عبر `search_skills("finance backtest")` في أي وكيل متصل بـ OpenSpace.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 📁 هيكل المشروع
|
||||
|
||||
<details>
|
||||
<summary><b>انقر للتوسيع</b></summary>
|
||||
|
||||
```
|
||||
Vibe-Trading/
|
||||
├── agent/ # Backend (Python)
|
||||
│ ├── cli/ # CLI package — interactive TUI + subcommands
|
||||
│ ├── api_server.py # FastAPI server — runs, sessions, upload, swarm, SSE
|
||||
│ ├── mcp_server.py # MCP server — 35 tools for OpenClaw / Claude Desktop
|
||||
│ │
|
||||
│ ├── src/
|
||||
│ │ ├── agent/ # ReAct agent core
|
||||
│ │ │ ├── loop.py # 5-layer compression + read/write tool batching
|
||||
│ │ │ ├── context.py # system prompt + auto-recall from persistent memory
|
||||
│ │ │ ├── skills.py # skill loader (77 bundled + user-created via CRUD)
|
||||
│ │ │ ├── tools.py # tool base class + registry
|
||||
│ │ │ ├── memory.py # lightweight workspace state per run
|
||||
│ │ │ ├── frontmatter.py # shared YAML frontmatter parser
|
||||
│ │ │ └── trace.py # execution trace writer
|
||||
│ │ │
|
||||
│ │ ├── memory/ # Cross-session persistent memory
|
||||
│ │ │ └── persistent.py # file-based memory (~/.vibe-trading/memory/)
|
||||
│ │ │
|
||||
│ │ ├── tools/ # 31 auto-discovered agent tools
|
||||
│ │ │ ├── backtest_tool.py # run backtests
|
||||
│ │ │ ├── remember_tool.py # cross-session memory (save/recall/forget)
|
||||
│ │ │ ├── skill_writer_tool.py # skill CRUD (save/patch/delete/file)
|
||||
│ │ │ ├── session_search_tool.py # FTS5 cross-session search
|
||||
│ │ │ ├── swarm_tool.py # launch swarm teams
|
||||
│ │ │ ├── web_search_tool.py # DuckDuckGo web search
|
||||
│ │ │ └── ... # bash, file I/O, factor analysis, options, alpha browser + bench, etc.
|
||||
│ │ │
|
||||
│ │ ├── factors/ # Alpha Zoo — 452 ألفا عبر 4 zoos
|
||||
│ │ │ ├── base.py # 19 عاملاً (rank/scale/ts_*/delta/decay_linear/safe_div/vwap)
|
||||
│ │ │ ├── registry.py # تحميل بيانات وصفية AST فقط + حساب كسول + بوابات سلامة
|
||||
│ │ │ ├── bench_runner.py # IC + تصنيف alive/reversed/dead
|
||||
│ │ │ └── zoo/ # qlib158 (154) + alpha101 (101) + gtja191 (191) + academic (6)
|
||||
│ │ │
|
||||
│ │ ├── api/ # وحدات مسارات FastAPI
|
||||
│ │ │ └── alpha_routes.py # /alpha/list, /alpha/{id}, /alpha/bench, SSE stream
|
||||
│ │ │
|
||||
│ │ ├── skills/ # 77 finance skills in 8 categories (SKILL.md each)
|
||||
│ │ ├── swarm/ # Swarm DAG execution engine
|
||||
│ │ │ └── presets/ # 29 swarm preset YAML definitions
|
||||
│ │ ├── session/ # Multi-turn chat + FTS5 session search
|
||||
│ │ └── providers/ # LLM provider abstraction
|
||||
│ │
|
||||
│ └── backtest/ # Backtest engines
|
||||
│ ├── engines/ # 7 engines + composite cross-market engine + options_portfolio
|
||||
│ ├── loaders/ # 7 sources: tushare, okx, yfinance, akshare, mootdx, ccxt, futu
|
||||
│ │ ├── base.py # DataLoader Protocol
|
||||
│ │ └── registry.py # Registry + auto-fallback chains
|
||||
│ └── optimizers/ # MVO, equal vol, max div, risk parity
|
||||
│
|
||||
├── frontend/ # Web UI (React 19 + Vite + TypeScript)
|
||||
│ └── src/
|
||||
│ ├── pages/ # Home, Agent, AlphaZoo, RunDetail, Compare, Correlation, Settings
|
||||
│ ├── components/ # chat, charts, layout
|
||||
│ └── stores/ # Zustand state management
|
||||
│
|
||||
├── Dockerfile # Multi-stage build
|
||||
├── docker-compose.yml # One-command deploy
|
||||
├── pyproject.toml # Package config + CLI entrypoint
|
||||
├── tools/ # Repo-level CI helpers
|
||||
│ └── ci_grep_gates.sh # rejects yaml.load / trademark / per-stock-data leaks
|
||||
└── LICENSE # MIT
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🏛 النظام البيئي
|
||||
|
||||
Vibe-Trading جزء من نظام وكلاء **[HKUDS](https://github.com/HKUDS)**:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/HKUDS/nanobot"><b>NanoBot</b></a><br>
|
||||
<sub>Ultra-Lightweight Personal AI Assistant</sub>
|
||||
</td>
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/HKUDS/AI-Trader"><b>AI-Trader</b></a><br>
|
||||
<sub>Agent-Native Signal & Copy Trading Platform</sub>
|
||||
</td>
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/HKUDS/CLI-Anything"><b>CLI-Anything</b></a><br>
|
||||
<sub>Making All Software Agent-Native</sub>
|
||||
</td>
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/HKUDS/OpenSpace"><b>OpenSpace</b></a><br>
|
||||
<sub>Self-Evolving AI Agent Skills</sub>
|
||||
</td>
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/HKUDS/ClawTeam"><b>ClawTeam</b></a><br>
|
||||
<sub>Agent Swarm Intelligence</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## 🗺 خارطة الطريق
|
||||
|
||||
> نشحن على مراحل. تنتقل العناصر إلى [Issues](https://github.com/HKUDS/Vibe-Trading/issues) عندما يبدأ العمل.
|
||||
|
||||
| المرحلة | الميزة | الحالة |
|
||||
|-------|---------|--------|
|
||||
| **Trust Layer** | بطاقات تشغيل قابلة لإعادة الإنتاج تُنتج وتظهر في Run Detail؛ يضيف v1 آثار الأدوات والاستشهادات | v0 شُحن |
|
||||
| **Hypothesis Registry** | فرضيات بحثية دائمة مع حالة lifecycle ومصادر بيانات ومهارات وروابط run-card وملاحظات إبطال | Backend MVP شُحن |
|
||||
| **Research Autopilot** | حلقة بحث يدوية أولاً: فرضية → اختبار رجعي حتمي → تقرير أدلة | التالي |
|
||||
| **Data Bridge** | أحضر بياناتك: موصلات CSV/Parquet/SQL محلية مع schema mapping | مخطط |
|
||||
| **Options Lab** | سطح تقلب، ولوحة Greeks، ومستكشف payoff/scenario | مخطط |
|
||||
| **Portfolio Studio** | أشعة مخاطر، وقيود، ومحسن يراعي الدوران، وملاحظات إعادة توازن | مخطط |
|
||||
| **Alpha Zoo** | 452 ألفا كمّي جاهز عبر 4 zoos (Qlib 158 + Kakushadze 101 + GTJA 191 + FF5 + Carhart)، سطر أوامر واحد للـ bench، تكامل agent، وواجهة Web | **تم الإطلاق 0.1.8** |
|
||||
| **Research Delivery** | موجزات مجدولة إلى Slack / Telegram / قنوات شبيهة بالبريد | مخطط |
|
||||
| **Community** | مهارات وإعدادات مسبقة وبطاقات استراتيجية قابلة للمشاركة | قيد الاستكشاف |
|
||||
|
||||
---
|
||||
|
||||
## المساهمة
|
||||
|
||||
نرحب بالمساهمات! راجع [CONTRIBUTING.md](CONTRIBUTING.md) للإرشادات.
|
||||
|
||||
**المشكلات الجيدة للمبتدئين** موسومة بـ [`good first issue`](https://github.com/HKUDS/Vibe-Trading/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) — اختر واحدة وابدأ.
|
||||
|
||||
هل تريد المساهمة بشيء أكبر؟ راجع [خارطة الطريق](#-خارطة-الطريق) أعلاه وافتح issue للنقاش قبل البدء.
|
||||
|
||||
---
|
||||
|
||||
## المساهمون
|
||||
|
||||
شكراً لكل من ساهم في Vibe-Trading!
|
||||
|
||||
مساهمو واعتمادات دورة v0.1.8 الأخيرة:
|
||||
|
||||
- @GTC2080 / TaoMu — Web UI Settings وواجهات إعداد provider/data-source (#57)
|
||||
- @BigNounce90 — تعزيز validation CLI لمدخل `run_dir` في الاختبار الرجعي (#60)
|
||||
- @shadowinlife — مهارة A-share pre-ST filter (#63)
|
||||
- @MB-Ndhlovu — لوحة خريطة الارتباط الحرارية وإصلاحات المراجعة (#64, #66)
|
||||
- @ykykj — خيار مزود OpenAI Codex OAuth (#65)
|
||||
- @RuifengFu — شريط حالة CLI التفاعلي وتحرير prompt (#69)
|
||||
- @SiMinus — أمر فحص swarm preset (#73)
|
||||
- @warren618 / Haozhe Wu — تعزيز الأمان، وتكامل الإصدار، والوثائق، وDocker، والتغليف، وسير التطوير المحلي
|
||||
- lemi9090 (S2W) — بحث أمني منسق، وتحقق، ودعم الإفصاح
|
||||
|
||||
<a href="https://github.com/HKUDS/Vibe-Trading/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=HKUDS/Vibe-Trading" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## إخلاء المسؤولية
|
||||
|
||||
Vibe-Trading برنامج للبحث والتداول. ليس نصيحة استثمارية، ولا يحتفظ بأي أموال، ولا يشغّل أي منصة تنفيذ. يحدث التداول فقط عبر قناة وسيط تُصرّح بها صراحةً (مثل Robinhood Agentic Trading)، ضمن الحدود التي تضعها، ويمكنك إيقافه في أي وقت. قدرة التداول عبر الوسيط هذه تجريبية ولم نتحقق منها على حساب وسيط حقيقي — استخدمها على مسؤوليتك. الأداء السابق لا يضمن النتائج المستقبلية.
|
||||
|
||||
## الرخصة
|
||||
|
||||
رخصة MIT — راجع [LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
## تاريخ النجوم
|
||||
|
||||
[](https://star-history.com/#HKUDS/Vibe-Trading&Date)
|
||||
|
||||
<p align="center" dir="rtl">
|
||||
⭐ إذا ساعدك <b>Vibe-Trading</b> في بحثك، فإن منح نجمة يساعد المزيد على اكتشافه.
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
شكراً لزيارة <b>Vibe-Trading</b> ✨
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.Vibe-Trading&style=flat" alt="visitors"/>
|
||||
</p>
|
||||
+944
@@ -0,0 +1,944 @@
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | <a href="README_zh.md">中文</a> | <b>日本語</b> | <a href="README_ko.md">한국어</a> | <a href="README_ar.md">العربية</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/icon.png" width="120" alt="Vibe-Trading Logo"/>
|
||||
</p>
|
||||
|
||||
<h1 align="center">Vibe-Trading: あなた専用のトレーディングエージェント</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>1つのコマンドで、包括的なトレーディング能力をエージェントに付与</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Python-3.11%2B-3776AB?style=flat&logo=python&logoColor=white" alt="Python">
|
||||
<img src="https://img.shields.io/badge/Backend-FastAPI-009688?style=flat" alt="FastAPI">
|
||||
<img src="https://img.shields.io/badge/Frontend-React%2019-61DAFB?style=flat&logo=react&logoColor=white" alt="React">
|
||||
<a href="https://pypi.org/project/vibe-trading-ai/"><img src="https://img.shields.io/pypi/v/vibe-trading-ai?style=flat&logo=pypi&logoColor=white" alt="PyPI"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow?style=flat" alt="License"></a>
|
||||
<br>
|
||||
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/Feishu-Group-E9DBFC?style=flat-square&logo=feishu&logoColor=white" alt="Feishu"></a>
|
||||
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat-square&logo=wechat&logoColor=white" alt="WeChat"></a>
|
||||
<a href="https://discord.gg/2vDYc2w5"><img src="https://img.shields.io/badge/Discord-Join-7289DA?style=flat-square&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://vibetrading.wiki/">公式サイト</a> ·
|
||||
<a href="https://vibetrading.wiki/docs/">ドキュメント</a> ·
|
||||
<a href="#-ニュース">ニュース</a> ·
|
||||
<a href="#-主な機能">機能</a> ·
|
||||
<a href="#-shadow-account">Shadow Account</a> ·
|
||||
<a href="#-デモ">デモ</a> ·
|
||||
<a href="#-クイックスタート">クイックスタート</a> ·
|
||||
<a href="#-例">例</a> ·
|
||||
<a href="#-api-サーバー">API / MCP</a> ·
|
||||
<a href="#-ロードマップ">ロードマップ</a> ·
|
||||
<a href="#contributing">Contributing</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#-クイックスタート"><img src="assets/pip-install.svg" height="45" alt="pip install vibe-trading-ai"></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 📰 ニュース
|
||||
|
||||
- **2026-05-31** 🔌 **Connector-first ブローカーアーキテクチャ(IBKR + Robinhood)**: 取引アクセスは、個別のブローカー入口や live 入口ではなく、選択可能な connector profile から始まるようになりました。`vibe-trading connector list/use/check/account/positions/orders/quote/history` と MCP の `trading_*` ツールは同じ選択済み profile を共有し、paper/live は connector 配下の属性として扱われます。IBKR はローカル読み取り専用 TWS / IB Gateway profile ですぐ使え、公式 IBKR リモート MCP は安定した read tool 名が公開されるまで OAuth `mcp.read` probe として seed されています。Robinhood Agentic Trading は引き続き、OAuth、コミット済み mandate、order guard、audit ledger、instant halt の背後にある bounded live MCP connector です。
|
||||
- **2026-05-30** 🧰 **堅牢性パス — backtest、agent loop、session**: LLM 生成の signal engine は、インスタンス化の前にインターフェース事前検証を通すようになりました。循環 self-import、`generate()` の欠落、デフォルト値のない `__init__` 引数、誤った戻り値型といった典型ミスを早期に捕捉し、生の traceback ではなく実行可能な JSON エラーで返します ([#149](https://github.com/HKUDS/Vibe-Trading/pull/149))。続くフォローアップで、ソースレベルの AST 検証エラーも同じクリーンな JSON エンベロープに乗せました。agent loop は 50 反復を使い切って出力のない `failed` 状態に陥らなくなりました——swarm worker の実績ある方式に倣い、反復予算の 80% で wrap-up nudge を注入し、最後の反復で tool 定義を外してテキスト回答を強制します ([#148](https://github.com/HKUDS/Vibe-Trading/pull/148))。途中でのみ発火するようガードしてあり、research-goal の文脈を押しのけることはありません。session のメッセージ書き込みは append ごとに `flush + fsync` するようになり、高価な AI 応答が書き込み途中のクラッシュでも残ります。読み取り側は壊れた JSONL 行をスキップし(復旧用に先頭 200 文字をログ)、`/messages` エンドポイント全体を 500 にしません ([#147](https://github.com/HKUDS/Vibe-Trading/pull/147))。Web の入力欄は IME の Enter 処理も修正し、変換確定の Enter で語の途中送信が起きないようにしました ([#146](https://github.com/HKUDS/Vibe-Trading/pull/146))。
|
||||
- **2026-05-29** 🔐 **Robinhood Agentic Trading 対応(オプトイン・有界自律)**: Robinhood Agentic Trading に対応しました(リモート MCP、OAuth)。デフォルトでは無効かつ読み取り専用。エージェントはユーザーがコミットした mandate(銘柄/注文サイズ/エクスポージャー/レバレッジ/日次上限)の範囲内でのみ自律取引し、ファイルレベルの即時 kill switch、先制的なポジション手仕舞い、mandate の自動失効、完全な監査台帳、永続的な自律 runner を備えます。資金の保管なし・取引所運営なし——資金の保有と執行はブローカーが行い、こちらは意図を中継するだけです。実験的 / 自己責任でご利用ください。
|
||||
<details>
|
||||
<summary>過去のニュース</summary>
|
||||
|
||||
- **2026-05-28** 🧪 **Swarm の安全性 + 厳格 alpha gate + worker 側 MCP**: Swarm DAG は上流タスクが失敗したとき下流タスクをブロックするようになりました ([#145](https://github.com/HKUDS/Vibe-Trading/pull/145))。新規 `run_bench_strict()` は IC gate に同 universe のランダムコントロール + train/test OOS 分割を追加し、市場 beta を追っているだけの偽 factor を捕捉します ([#143](https://github.com/HKUDS/Vibe-Trading/pull/143), @Soli22de さんに感謝)。Swarm worker は operator が設定した外部 MCP server からツールを呼べるようになり、信頼境界は専用テストで固定されています ([#142](https://github.com/HKUDS/Vibe-Trading/pull/142), @shadowinlife さんに感謝)。
|
||||
- **2026-05-27** 📊 **mootdx A 株データソース + 出力スタイル**: 新規 `mootdx` loader はネイティブ 通达信 TCP プロトコルで A 株 OHLCV を取得します(認証不要、IP 速度制限なし、日足 + 分足の 25 ページ walk-back ページング)。fallback chain では tushare と akshare の間に配置されます ([#107](https://github.com/HKUDS/Vibe-Trading/issues/107))。CCXT loader は `HTTP_PROXY/HTTPS_PROXY/ALL_PROXY` を読み込み、制限されたネットワークから Binance/OKX の公開データを取得できるようになりました ([#126](https://github.com/HKUDS/Vibe-Trading/pull/126), @ruok808 さんに感謝)。最終回答のレンダリングからは CLI と Web の見苦しい全幅 `---` セパレータを削除しました: system prompt は markdown table と `##` heading を促し、CLI renderer は単独 HR を defense-in-depth として除去し、chat bubble はすり抜けた `<hr>` を隠します ([#139](https://github.com/HKUDS/Vibe-Trading/issues/139), @sdwxm188 さんに感謝)。
|
||||
- **2026-05-26** ✅ **Research Goal ライフサイクルの閉ループ化**: Goal mode が実際のタスクランナーのように動くようになりました。Web UI で goal を作成すると session を作成または bind し、即座に kickoff turn を送ります。active goal は Web/API/CLI/MCP から continue/edit/cancel/complete でき、agent loop は最初の prompt だけでなく現在の goal snapshot(criteria、evidence、claims、open items)から前進します。criteria が covered でも goal が active のままなら silent stop ではなく audit/status update に入り、backend、CLI、MCP、frontend events の回帰で固定しました。
|
||||
|
||||
- **2026-05-25** 🧼 **よりクリーンな Chat UI + composer workflow**: Web UI は次の入力に集中できる形になりました。upload、swarm、research-goal mode は composer の `+` メニューにまとまり、floating panel で会話を邪魔しません。現在の context は input 上の compact chip として表示され、goal details は chip クリック時だけ inline 展開されます。旧 custom i18n layer も削除し、直接 English copy に統一。Full Report card は report-worthy run のみに表示され、local dev startup/status reporting もブラウザ smoke test 向けに安定化しました。
|
||||
- **2026-05-24** 🎯 **Research Goal runtime**: backend、CLI、API/MCP、SSE、Web UI をまたぐ session-scoped Research Goal layer を追加しました。Goal は claim、acceptance criteria、evidence row、budget、completion policy を永続化します。agent tool は goal 作成と evidence 追加に対応し、`/goal` が CLI 入口になり、REST/MCP は goal snapshot と evidence write を公開し、SSE は chat client の状態を fresh に保ちます。後続 audit fixes では verified evidence をロックダウンし、agent tool からの live-trading risk tier をブロックし、CLI-created goal を後続 turn に接続し、session 削除時の goal ledger cleanup、replay-all 接続、frontend の cross-session snapshot race 修正を行いました。
|
||||
- **2026-05-23** 🖥️ **インタラクティブ CLI の刷新**: ターミナル入口は大きな Vibe-Trading バナー、より見やすい prompt 区切り、前ターンの recap、実行後の所要時間、Claude Code 風の activity rail で live agent 作業を表示します。tool call、web/data fetch、shell 風 action、Markdown 回答、pipe table は読みやすい transcript として描画され、pipe や非 TTY 実行では自動化向けの plain-text 出力を維持します。生成 CLI スクリーンショットは committed docs ではなく local artifact として扱い、リポジトリを軽く保ちます。
|
||||
- **2026-05-22** 🧭 **Swarm リカバリ + MCP keepalive**: Swarm の状態は読み取りのたびに live task ファイルから reconcile されるようになり、API/MCP/SSE/list ビューはクラッシュ済みまたは stale な run を復旧し、永遠に `running` のスナップショットを見せ続けません。`run_swarm` は polling 中に MCP progress heartbeat を送り、transport drop 後に再接続するクライアントでも handle を拾えるよう最初のフレームを `swarm_started run_id=<id>` に固定しました。worker も LLM streaming、grounding fetch、tool execution の各段階で heartbeat を出します。stale-run reaper は run ごとの閾値を使い、task 状態から終端状態を導出します。`SwarmTool` は待機予算が尽きても進行中の team をキャンセルせず、MCP クライアントは `reap_stale_runs()` で明示的に cleanup できます。今日の DX pass では provider の既定モデルも更新し、CI syntax check を新しい `agent/cli/` パッケージに合わせました。hydrate、終端復旧、stale reap、keepalive cadence、env parsing、heartbeat wiring を 22 件の新規回帰テストでカバーし、swarm/MCP 全体スイートは 169 passed、4 skipped です。
|
||||
- **2026-05-21** 🧱 **CLI パッケージリファクタ**: `agent/cli.py`(3216 LOC)を `agent/cli/` パッケージへ分割 — インタラクティブな入口、slash ルーター、Rich コンポーネント、そしてすべてのサブコマンドを保ち `cli.cmd_*` / `cli._INIT_ENV_PATH` / `cli.Confirm` などの公開シンボルを再エクスポートする `_legacy.py` shim。新しい FastAPI ミドルウェアはブラウザが `/runs/{id}` または `/correlation` を直接開いた際に SPA シェルを返し、同じ絞り込みを Vite dev プロキシにも反映。バージョン文字列は `cli/_version.py` で一本化(`--version` とバナーのドリフト解消)、`python -m cli` を `__main__.py` で復活、chat ゲートを絞り `chat --help` / `chat extra` は REPL に飲み込まれずレガシー argparse に届きます。
|
||||
- **2026-05-20** 🔬 **Hypothesis Registry CLI**: 2026-05-16 にバックエンドのみで公開された Hypothesis Registry の CLI 側を完成させました。`vibe-trading hypothesis list` は Rich テーブルまたは JSON を出力(`--status` フィルタと `--limit` をサポート)、`show <id>` はリンクされた run card を含む詳細パネルを描画、`invalidate <id> --note "..."` はステータスを `rejected` に切り替え、`--note` を省略すると既存の invalidation notes を保持します。既存の `VIBE_TRADING_HYPOTHESES_PATH` 環境変数オーバーライドに加え、呼び出し単位の `--path` も使えます。配線、JSON 出力、ステータスフィルタ、limit、ID 不在エラー、ノート永続化を 22 のテストでカバー。
|
||||
- **2026-05-19** ✨ **ツールのライブフィードバック + グレースフルキャンセル**: 長時間実行されるツール(バックテスト、大きい PDF、swarm worker)が固まったように見えなくなりました。各ツール呼び出しは 3 秒ごとのハートビートに加え、構造化された段階進捗を発行します — `run_backtest` はフェーズマーカー(`validate` / `simulate` / `finalize`)、`read_document` は PDF ではページ単位、Excel ではシート単位、`read_url` は `fetch` / `parse` をマーク。CLI の Rich Live ダッシュボードは Unicode スピナー、ASCII プログレスバー、ETA を描画し、ツール名でキー付けして最大 3 つの並列ツールをスタック表示します。フロントエンドのチャットには新規 `ToolProgressIndicator` を追加し、rAF コアレッシング、ARIA `role="status"` + スクリーンリーダー向けの非表示 `<progress>`、合計が既知の場合は determinate な `ProgressRing` SVG を備えます。CLI 実行中の最初の `Ctrl+C` は `agent.cancel()` を呼んでグレースフル終了(現在のステップが完了し、trace がクリーンに閉じる)し、2 秒以内に 2 度目を押すと強制終了します。再利用可能なプリミティブ `ProgressBar.tsx` と `lib/tools.ts`(共有ツール名 i18n マッピング)も抽出。
|
||||
- **2026-05-18** 🧹 **クリーンアップ + 3 つの潜在バグ修正**: `CompositeEngine` が取引所サフィックスのない中国先物コード(`RB2410` 等)を `GlobalFuturesEngine` に誤ルーティングしていた問題を修正。`_is_china_futures` を共有の `_market_hooks` モジュールに移し、製品コード表を大小文字正規化 + 非中国取引所のガードを追加、回帰ケース 9 件を新設しました。session FTS5 インデックスがタイムスタンプを永続化するようになり、クロスセッション検索を日付ソートできるようになりました。同じ修正で、re-upsert 経路が `started_at` を wall-clock で上書きしていた副次バグも解消しました。Vite 開発プロキシに `/alpha` を追加し、AlphaZoo ページが `npm run dev` で解決されるようになりました。`tests/test_e2e_harness_v2.py`(実 LLM の e2e スイート)は `VIBE_TRADING_RUN_LIVE_E2E=1` でゲート化し、CI が環境変数の有無で形を変えないようにしました。ruff に factor zoo 用の `per-file-ignores` を追加(F401 ノイズ 3783 → 0)、フロントエンド tsconfig は `noUnusedLocals` / `noUnusedParameters` を有効化して回帰ガードとし、`gtja191` alpha の未使用 `vw = vwap(...)` 雛形 76 件も削除しました。正味 **-918 行**。
|
||||
- **2026-05-17** 🧬 **Alpha Zoo v1(0.1.8)**: 4 つの zoo にまたがる 452 個の事前構築 quant alpha を同梱しました — `qlib158`(Microsoft Qlib の Alpha158 特徴量、Apache-2.0 出処明示)、`alpha101`(Kakushadze の "101 Formulaic Alphas" を arXiv:1601.00991 から論文ベースで書き直し)、`gtja191`(国泰君安 2014 年の短期取引型 alpha レポート)、`academic`(Fama-French 5 + Carhart 動量の価格ベース proxy 実装)。任意の universe で 1 行 CLI: `vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025`。AST 純関数ゲート、look-ahead ガードテスト、`pytest-socket` ネットワーク遮断、各 zoo ごとの LICENSE.md、コミュニティ PR 用の DCO 署名フローも同梱。Alpha Library 自動レンダリングは [vibetrading.wiki/alpha-library/](https://vibetrading.wiki/alpha-library/)、Research Lab には [Which of the 191 GTJA alphas still work in 2026?](https://vibetrading.wiki/research-lab/posts/alpha-191-in-2026.html) を公開。
|
||||
- **2026-05-16** 🧪 **リサーチ基盤アップデート**: backend Hypothesis Registry を追加し、`create_hypothesis`、`update_hypothesis`、`link_backtest`、`search_hypotheses` を提供します。外部コンテンツ reader は warning-only の `security_warnings` を付与し、Shadow Account scanner は旧 calendar-phase stub から決定的な OHLCV feature evaluation に移行しました。
|
||||
- **2026-05-15** 🪪 Run 詳細ページが metrics と artifacts の隣に Trust Layer の run card を描画するようになり、2026-05-12 に入った `run_card.json` 側の UI 半分が揃いました。`PersistentMemory.add()` も #108/#109/#110 の triage を受け、長さ、空・空白だけの name、C0/C1 制御バイトの各経路で強化されました([#112](https://github.com/HKUDS/Vibe-Trading/pull/112)、@Teerapat-Vatpitak に感謝)。
|
||||
- **2026-05-14** 🌐 公開 Wiki が [vibetrading.wiki](https://vibetrading.wiki/) で公開され、docs、tutorials、Research Lab、Alpha Library セクションを Cloudflare Pages から配信します。永続メモリも CLI から `vibe-trading memory list/show/search/forget` で確認できるようになり([#102](https://github.com/HKUDS/Vibe-Trading/pull/102)、@Teerapat-Vatpitak に感謝)、メモリの tokenization/slug はタイ語、アラビア語、ヘブライ語、キリル文字にも対応しました([#104](https://github.com/HKUDS/Vibe-Trading/pull/104))。
|
||||
|
||||
- **2026-05-13** 🧭 Swarm 実行では、取得済みの市場データでワーカーを grounding し、永続化レポートもより整理されました([#93](https://github.com/HKUDS/Vibe-Trading/pull/93)、[#84](https://github.com/HKUDS/Vibe-Trading/pull/84))。
|
||||
- **2026-05-12** 🧾 バックテストは、再現可能なリサーチ実行のために artifacts と並んで `run_card.json` と `run_card.md` を出力するようになりました。
|
||||
- **2026-05-11** 🧭 **メモリ slug、swarm 集計、CLI プリフライト**: 永続メモリのファイル slug 生成で CJK 文字を保持するようになり、中国語/日本語/韓国語ノートの静かなファイル名衝突を防ぎます([#95](https://github.com/HKUDS/Vibe-Trading/pull/95)、@voidborne-d に感謝)。Swarm run の合計は provider が返す token usage を優先し、従来の推定フォールバックも維持します([#94](https://github.com/HKUDS/Vibe-Trading/pull/94)、@Teerapat-Vatpitak に感謝)。CLI run UI には一般的な環境問題を早めに見つける起動時プリフライトチェックも入りました([#96](https://github.com/HKUDS/Vibe-Trading/pull/96)、@ykykj に感謝)。
|
||||
- **2026-05-10** 🧱 **回帰ガードレール + run メタデータ**: Memory recall はアンダースコアを token 境界として扱うようになり、`mcp_wiring_test` のような snake_case の保存メモリが "mcp wiring" のような自然言語クエリに一致します([#87](https://github.com/HKUDS/Vibe-Trading/pull/87)、@hp083625 に感謝)。MCP server には initialize → `tools/list` → `tools/call` を通す subprocess smoke test を追加し、初回呼び出し deadlock 経路の回帰を防ぎます([#86](https://github.com/HKUDS/Vibe-Trading/pull/86))。さらに Windows のパス依存テスト、API の best-effort 例外処理、backtest `run_dir` allowed-root 検証、SwarmRun provider/model メタデータの低リスク強化も入りました([#88](https://github.com/HKUDS/Vibe-Trading/pull/88)、[#90](https://github.com/HKUDS/Vibe-Trading/pull/90)、[#91](https://github.com/HKUDS/Vibe-Trading/pull/91)、[#92](https://github.com/HKUDS/Vibe-Trading/pull/92)、@Teerapat-Vatpitak に感謝)。
|
||||
- **2026-05-09** 🛡️ **API パス強化 + MCP server 安定化**: API の run/session ルートは参照前にパス ID を検証し、改行を含む不正なパラメータを拒否し、その挙動を auth/security 回帰テストで固定しました([#80](https://github.com/HKUDS/Vibe-Trading/pull/80)、@SJoon99 に感謝)。MCP server は `tools/call` を処理する前にメインスレッドでツールレジストリを事前ウォームアップし、lazy tool discovery の初回呼び出しデッドロックを回避します([#85](https://github.com/HKUDS/Vibe-Trading/pull/85)、@Teerapat-Vatpitak に感謝)。Vite dev proxy も `VITE_API_URL` を尊重し、非デフォルトのバックエンドターゲットを使えるようになりました([#82](https://github.com/HKUDS/Vibe-Trading/pull/82)、@voidborne-d に感謝)。
|
||||
- **2026-05-08** 🧾 **Tushare 財務諸表フィールドをフィルターへ**: A 株の日次バックテストで `fundamental_fields` から PIT-safe な財務諸表フィールドを要求できるようになり、signal engine は公告/開示日以降に `income_total_revenue`、`income_n_income`、`balancesheet_total_hldr_eqy_exc_min_int`、`fina_indicator_roe` など表名プレフィックス付き列でスクリーニングできます([#76](https://github.com/HKUDS/Vibe-Trading/pull/76)、@mrbob-git に感謝)。後続の強化により、明示的な財務諸表フィールド要求で Tushare enrichment が失敗した場合は、価格バーだけに静かに戻るのではなく即時失敗します([#77](https://github.com/HKUDS/Vibe-Trading/pull/77))。
|
||||
- **2026-05-07** 📈 **Tushare fundamentals + コミュニティ整理**: ファンダメンタル調査ワークフロー向けに point-in-time の `TushareFundamentalProvider` 契約を追加し、プロジェクトの `TUSHARE_TOKEN` 環境変数パスを回帰テストでカバーしました([#74](https://github.com/HKUDS/Vibe-Trading/pull/74))。コミュニティ整理では、Vibe-Trading は当面 UI を単一言語に絞って高速反復すること、DuckDuckGo ベースの `web_search` が既に同梱されているため重複する検索依存を追加しないこと、非公式ホスト先は API key やデータソース token を入力する信頼済み場所として扱わないことも明確にしました。
|
||||
- **2026-05-06** 🚀 **v0.1.7 リリース**([Release notes](https://github.com/HKUDS/Vibe-Trading/releases/tag/v0.1.7)、`pip install -U vibe-trading-ai`): セキュリティ境界強化版を PyPI と ClawHub に公開しました。API/読み取り/アップロード/ファイル/URL/生成コード/shell ツール/Docker の既定境界をより安全にしつつ、localhost の CLI/Web UI ワークフローは低摩擦のままです。このサイクルには Web UI Settings、相関ヒートマップ、OpenAI Codex OAuth、A 株 pre-ST フィルター、対話型 CLI UX、swarm preset inspection、配当分析、開発ワークフロー改善、frontend build-dependency floor の監査も含まれます。0.1.7 のコントリビューターと、協調的なセキュリティ検証を行った lemi9090 (S2W) に感謝します。
|
||||
- **2026-05-05** 🛡️ **セキュリティ境界の追加強化**: 明示的な CORS origins、Settings の認証情報表示、Web URL 読み取り、Shadow Account コード生成まわりの残りのセキュリティ境界を補強し、それぞれに回帰テストを追加しました。通常の localhost CLI/Web UI ワークフローは従来どおりです。リモートデプロイでは引き続き `API_AUTH_KEY` と明示的な信頼済み origins を設定してください。
|
||||
- **2026-05-04** 🖥️ **インタラクティブ CLI UX + CI 整理**: インタラクティブモードに、provider/model、セッション時間、直近実行時間、累計ツール呼び出し統計を表示するライブ下部ステータスバーを追加。さらに `prompt_toolkit` により上下キーの履歴移動と左右キーのカーソル編集に対応しました([#69](https://github.com/HKUDS/Vibe-Trading/pull/69))。`prompt_toolkit` または TTY が利用できない場合は、従来どおり Rich prompt にフォールバックします。CI のパス期待値も強化済みファイル import サンドボックスとクロスプラットフォームな `/tmp` 解決に合わせ、main はグリーンに戻りました([`bb67dc7`](https://github.com/HKUDS/Vibe-Trading/commit/bb67dc7cfcc11553c57d8962bee56381dca43758))。
|
||||
- **2026-05-03** 🛡️ **セキュリティハードニングパッチ**: 非ローカルデプロイ向けの既定 API 認証を強化し、機密性の高い run/session/swarm 読み取りを保護、アップロードとローカルファイル読み取り境界を制限、shell 系ツールをエントリーポイント別に制御、生成戦略を import 前に検証し、Docker イメージは既定で非 root ユーザーかつ localhost 限定ポート公開で動作します。ローカル CLI と localhost Web UI は低摩擦のままです。リモート API/Web デプロイでは `API_AUTH_KEY` を設定してください。
|
||||
- **2026-05-02** 🧭 **配当分析 + ロードマップ刷新**: インカム株、配当の持続性、増配、株主還元利回り、権利落ちメカニクス、利回りの罠チェックに対応する `dividend-analysis` skill を追加し、bundled-skill 回帰テストで固定しました。公開ロードマップは Research Autopilot、Data Bridge、Options Lab、Portfolio Studio、Alpha Zoo、Research Delivery、Trust Layer、Community 共有に絞りました。
|
||||
- **2026-05-01** 🔥 **相関ヒートマップ + OpenAI Codex OAuth + A 株 pre-ST フィルター**: 新しい相関ダッシュボード/APIでローリングリターン相関を計算し、ポートフォリオや銘柄分析向けに ECharts ヒートマップで可視化します([#64](https://github.com/HKUDS/Vibe-Trading/pull/64))。OpenAI Codex provider は `vibe-trading provider login openai-codex` による ChatGPT OAuth に対応し、Settings メタデータとアダプター回帰テストも追加([#65](https://github.com/HKUDS/Vibe-Trading/pull/65))。A 株の ST/*ST リスクスクリーニング用 `ashare-pre-st-filter` skill を追加・強化し、Sina 処分公告の関連性フィルターにより証券口座リスト内の言及が E2 回数を水増ししないようにしました([#63](https://github.com/HKUDS/Vibe-Trading/pull/63))。
|
||||
- **2026-04-30** ⚙️ **Web UI Settings + validation CLI 強化**: LLM provider/model、Base URL、reasoning effort、データソース認証情報をローカルで設定できる Settings ページを追加。settings API は local/auth で保護され、provider メタデータもデータ駆動設定に移行しました([#57](https://github.com/HKUDS/Vibe-Trading/pull/57))。さらに `python -m backtest.validation <run_dir>` を強化し、引数なし・空パス・不正パス・存在しないパス・ディレクトリでないパスを検証開始前に分かりやすく失敗させます([#60](https://github.com/HKUDS/Vibe-Trading/pull/60))。
|
||||
- **2026-04-28** 🚀 **v0.1.6 リリース**(`pip install -U vibe-trading-ai`): `pip install` / `uv tool install` 後に `vibe-trading --swarm-presets` が空を返す問題を修正([#55](https://github.com/HKUDS/Vibe-Trading/issues/55))。プリセット YAML は `src.swarm` パッケージ内に同梱され、6 件の回帰テストで固定されています。加えて AKShare loader が ETF(`510300.SH`)と forex(`USDCNH`)を正しい endpoint にルーティングし、registry fallback も強化しました。v0.1.5 以降の更新を集約: benchmark comparison panel、`/upload` streaming + size limits、Futu loader(HK + A 株)、vnpy export skill、security hardening、frontend lazy loading(688KB → 262KB)。
|
||||
- **2026-04-27** 📊 **ベンチマーク比較パネル + アップロード安全性**: バックテスト出力に benchmark comparison panel(ticker / benchmark return / excess return / information ratio)を追加し、yfinance 経由で SPY、CSI 300 などを解決します([#48](https://github.com/HKUDS/Vibe-Trading/issues/48))。加えて `/upload` は request body を 1 MB chunks で stream し、`MAX_UPLOAD_SIZE` 超過時に中断するため、過大/不正な client の下でもメモリを抑えます([#53](https://github.com/HKUDS/Vibe-Trading/pull/53))。4 ケースの回帰テストで固定されています。
|
||||
- **2026-04-22** 🛡️ **ハードニング + 新規連携**: `safe_path` でパス封じ込めを強制し、journal/shadow tool sandbox、`MANIFEST.in` による `.env.example` / tests / Docker files の sdist 同梱、route-level lazy loading による frontend 初期 bundle 688KB → 262KB を実施。さらに Futu data loader for HK & A-share equities([#47](https://github.com/HKUDS/Vibe-Trading/pull/47))と vnpy CtaTemplate export skill([#46](https://github.com/HKUDS/Vibe-Trading/pull/46))も追加しました。
|
||||
- **2026-04-21** 🛡️ **Workspace + docs**: 相対 `run_dir` を active run dir に正規化しました([#43](https://github.com/HKUDS/Vibe-Trading/pull/43))。README usage examples も追加しました([#45](https://github.com/HKUDS/Vibe-Trading/pull/45))。
|
||||
- **2026-04-20** 🔌 **Reasoning + Swarm**: `reasoning_content` をすべての `ChatOpenAI` path で保持し、Kimi / DeepSeek / Qwen thinking が end-to-end で動作します([#39](https://github.com/HKUDS/Vibe-Trading/issues/39))。Swarm streaming と clean Ctrl+C も入りました([#42](https://github.com/HKUDS/Vibe-Trading/issues/42))。
|
||||
- **2026-04-19** 📦 **v0.1.5**: PyPI と ClawHub に公開。`python-multipart` CVE floor bump、新規 MCP tools 5 つ接続(`analyze_trade_journal` + shadow-account tools 4 つ)、`pattern_recognition` → `pattern` registry fix、Docker dep parity、SKILL manifest sync(22 MCP tools / 71 skills)。
|
||||
- **2026-04-18** 👥 **Shadow Account**: broker journal から strategy rules を抽出 → market 横断で shadow を backtest → 8-section HTML/PDF report で取りこぼし(rule violations、early exits、missed signals、counterfactual trades)を正確に可視化。新規 tools 4 つ、skill 1 つ、合計 32 tools。Trade Journal + Shadow Account samples も Web UI welcome screen に追加されました。
|
||||
- **2026-04-17** 📊 **Trade Journal Analyzer + Universal File Reader**: broker exports(同花順/東財/富途/generic CSV)を upload → auto trading profile(holding days、win rate、PnL ratio、drawdown)+ 4 bias diagnostics(disposition effect、overtrading、chasing momentum、anchoring)。`read_document` は PDF、Word、Excel、PowerPoint、images(OCR)、40+ text formats を 1 つの unified call に dispatch します。
|
||||
- **2026-04-16** 🧠 **Agent Harness**: Persistent cross-session memory、FTS5 session search、self-evolving skills(full CRUD)、5-layer context compression、read/write tool batching。27 tools、107 new tests。
|
||||
- **2026-04-15** 🤖 **Z.ai + MiniMax**: Z.ai provider([#35](https://github.com/HKUDS/Vibe-Trading/pull/35))、MiniMax temperature fix + model update([#33](https://github.com/HKUDS/Vibe-Trading/pull/33))。13 providers。
|
||||
- **2026-04-14** 🔧 **MCP Stability**: stdio transport 上の backtest tool `Connection closed` error を修正しました([#32](https://github.com/HKUDS/Vibe-Trading/pull/32))。
|
||||
- **2026-04-13** 🌐 **Cross-Market Composite Backtest**: 新しい `CompositeEngine` が mixed-market portfolios(例: A-shares + crypto)を shared capital pool と per-market rules で backtest します。swarm template variable fallback と frontend timeout も修正しました。
|
||||
- **2026-04-12** 🌍 **Multi-Platform Export**: `/pine` が strategies を TradingView(Pine Script v6)、TDX(通达信/同花顺/东方财富)、MetaTrader 5(MQL5)へ 1 コマンドで export します。
|
||||
- **2026-04-11** 🛡️ **Reliability & DX**: `vibe-trading init` .env bootstrap([#19](https://github.com/HKUDS/Vibe-Trading/pull/19))、preflight checks、runtime data-source fallback、hardened backtest engine。Multi-language README([#21](https://github.com/HKUDS/Vibe-Trading/pull/21))。
|
||||
- **2026-04-10** 📦 **v0.1.4**: Docker fix([#8](https://github.com/HKUDS/Vibe-Trading/issues/8))、`web_search` MCP tool、12 LLM providers、`akshare`/`ccxt` deps。PyPI と ClawHub に公開。
|
||||
- **2026-04-09** 📊 **Backtest Wave 2**: ChinaFutures、GlobalFutures、Forex、Options v2 engines。Monte Carlo、Bootstrap CI、Walk-Forward validation。
|
||||
- **2026-04-08** 🔧 **Multi-market backtest** with per-market rules、Pine Script v6 export、5 data sources with auto-fallback。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## ✨ 主な機能
|
||||
|
||||
<div align="center">
|
||||
<table align="center" width="94%" style="width:94%; margin-left:auto; margin-right:auto;">
|
||||
<tr>
|
||||
<td align="center" width="50%" valign="top">
|
||||
<img src="assets/feature-self-improving-trading-agent.png" height="130" alt="Self-improving trading agent"/><br>
|
||||
<h3>🔍 自己改善型トレーディングエージェント</h3>
|
||||
<div align="left">
|
||||
• 自然言語による市場リサーチ<br>
|
||||
• 戦略ドラフトとファイル/Web 分析<br>
|
||||
• メモリに支えられたワークフロー
|
||||
</div>
|
||||
</td>
|
||||
<td align="center" width="50%" valign="top">
|
||||
<img src="assets/feature-multi-agent-trading-teams.png" height="130" alt="Multi-agent trading teams"/><br>
|
||||
<h3>🐝 マルチエージェント・トレーディングチーム</h3>
|
||||
<div align="left">
|
||||
• 投資、クオンツ、暗号資産、リスクの各チーム<br>
|
||||
• 進捗ストリーミングと永続化レポート<br>
|
||||
• 取得済み市場データで grounding されたワーカー
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="50%" valign="top">
|
||||
<img src="assets/feature-cross-market-data-backtesting.png" height="130" alt="Cross-market data and backtesting"/><br>
|
||||
<h3>📊 クロスマーケットデータ & バックテスト</h3>
|
||||
<div align="left">
|
||||
• A/HK/US 株式、暗号資産、先物、FX<br>
|
||||
• データフォールバックと複合バックテスト<br>
|
||||
• PIT データ、検証、run cards
|
||||
</div>
|
||||
</td>
|
||||
<td align="center" width="50%" valign="top">
|
||||
<img src="assets/feature-shadow-account.png" height="130" alt="Shadow Account"/><br>
|
||||
<h3>👥 Shadow Account</h3>
|
||||
<div align="left">
|
||||
• ブローカー取引日誌の行動診断<br>
|
||||
• ルールベースの Shadow Account 比較<br>
|
||||
• エクスポート可能な監査レポートと戦略コード
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## 💡 Vibe-Trading とは?
|
||||
|
||||
Vibe-Trading は、金融に関する問いを実行可能な分析へ変換するためのオープンソースのリサーチワークスペースです。自然言語プロンプトを、市場データ loader、戦略生成、バックテストエンジン、レポート、エクスポート、永続リサーチメモリへ接続します。
|
||||
|
||||
研究、シミュレーション、バックテストのために設計されています——さらに、お望みであれば、ご自身が認可したブローカー(例: Robinhood Agentic Trading)を通じた自律取引も可能です。資金は一切保管せず、設定した制限を超える取引は決して行わず、いつでも即座に停止できます。
|
||||
|
||||
---
|
||||
|
||||
## ✨ できること
|
||||
|
||||
| タスク | 出力 |
|
||||
|------|--------|
|
||||
| **トレーディングの問いを投げる** | ツール、データ、ドキュメント、再利用可能なセッション文脈を使った市場リサーチ。 |
|
||||
| **戦略アイデアをバックテストする** | 戦略コード、指標、ベンチマーク文脈、検証 artifacts、run cards。 |
|
||||
| **自分の取引をレビューする** | ブローカー取引日誌の解析、行動診断、ルール抽出、Shadow Account 比較。 |
|
||||
| **反復リサーチを改善する** | 永続メモリと編集可能な skills により、有用な手順を再利用可能なワークフローへ変換。 |
|
||||
| **アナリストチームを走らせる** | 投資、クオンツ、暗号資産、マクロ、リスクのワークフロー向けマルチエージェント・リサーチレビュー。 |
|
||||
| **使える artifacts を出力する** | レポート、TradingView Pine Script、TDX、MetaTrader 5、MCP tools、後続リサーチセッション。 |
|
||||
| **事前構築 alpha zoo をベンチ** | 452 個の alpha 因子(Qlib 158 + Kakushadze 101 + GTJA 191 + FF5 + Carhart)に対し、1 行 CLI で IC + IR + alive/reversed/dead 分類を実行 |
|
||||
|
||||
---
|
||||
|
||||
## ⚡ クイック例
|
||||
|
||||
```bash
|
||||
pip install vibe-trading-ai
|
||||
|
||||
# 自然言語リサーチ
|
||||
vibe-trading run -p "Backtest a BTC-USDT 20/50 moving-average strategy for 2024, summarize return and drawdown, then export the report"
|
||||
|
||||
# 事前構築 alpha zoo を 1 行でベンチ
|
||||
vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025 --top 20
|
||||
```
|
||||
|
||||
```bash
|
||||
vibe-trading --upload trades_export.csv
|
||||
vibe-trading run -p "Analyze my trading behavior, extract my shadow strategy, and compare it with my actual trades"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 👥 Shadow Account
|
||||
|
||||
Shadow Account は、汎用的な戦略テンプレートではなく、あなた自身の取引記録から始めます。
|
||||
|
||||
ブローカー export をアップロードし、エージェントに行動を要約させたうえで、実際の取引経路をルールベースの shadow strategy と比較します。
|
||||
|
||||
| ステップ | エージェントの出力 |
|
||||
|------|--------------|
|
||||
| **1. 取引日誌を読む** | 同花順、东方财富、富途、generic CSV 形式のブローカー export を解析します。 |
|
||||
| **2. 行動をプロファイルする** | 保有日数、勝率、PnL ratio、drawdown、disposition effect、overtrading、momentum chasing、anchoring checks。 |
|
||||
| **3. ルールを抽出する** | 繰り返し現れる entries/exits を、曖昧な要約ではなく明示的な strategy profile に変換します。 |
|
||||
| **4. shadow を実行する** | 抽出したルールをバックテストし、rule breaks、early exits、missed signals、alternative trade paths を強調します。 |
|
||||
| **5. レポートを届ける** | 後から確認、アーカイブ、または次回セッションで改善できる HTML/PDF report を生成します。 |
|
||||
|
||||
```bash
|
||||
vibe-trading --upload trades_export.csv
|
||||
vibe-trading run -p "Analyze my trading behavior, extract my shadow strategy, and compare it with my actual trades"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 リサーチワークフロー
|
||||
|
||||
多くの実行は、同じ evidence path をたどります。リクエストを routing し、適切な市場文脈を読み込み、ツールを実行し、出力を検証し、artifacts を確認可能な形で残します。
|
||||
|
||||
| レイヤー | 何が起きるか |
|
||||
|-------|--------------|
|
||||
| **Plan** | 必要な finance skills、tools、data sources、必要に応じて swarm preset を選びます。 |
|
||||
| **Ground** | A 株、HK/US 株式、暗号資産、先物、FX、documents、Web context を利用可能な loaders から取得します。 |
|
||||
| **Execute** | テスト可能な strategy code を生成し、tools を実行し、対応する backtest engine または analysis workflow を使います。 |
|
||||
| **Validate** | metrics、benchmark comparison、Monte Carlo、Bootstrap、Walk-Forward、run cards、必要な warnings を追加します。 |
|
||||
| **Deliver** | TradingView、TDX、MetaTrader 5、MCP clients、後続セッション向けの reports、artifacts、tool traces、exports を返します。 |
|
||||
|
||||
---
|
||||
|
||||
## 🔩 詳細な機能
|
||||
|
||||
メイン README を読みやすく保つため、詳細な一覧は以下に折りたたんでいます。利用できる構成要素を確認したいときに開いてください。
|
||||
|
||||
<details>
|
||||
<summary><b>Finance Skill Library</b> <sub>8カテゴリにわたる77 skills</sub></summary>
|
||||
|
||||
- 📊 77 の金融特化 skills を 8 カテゴリに整理
|
||||
- 🌐 伝統的市場から crypto & DeFi まで完全カバー
|
||||
- 🔬 データ取得からクオンツリサーチまでを横断する包括的能力
|
||||
|
||||
| Category | Skills | Examples |
|
||||
|----------|--------|----------|
|
||||
| Data Source | 7 | `data-routing`, `tushare`, `yfinance`, `okx-market`, `akshare`, `mootdx`, `ccxt` |
|
||||
| Strategy | 17 | `strategy-generate`, `cross-market-strategy`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
|
||||
| Analysis | 17 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis`, `dividend-analysis` |
|
||||
| Asset Class | 9 | `options-strategy`, `options-advanced`, `convertible-bond`, `etf-analysis`, `asset-allocation`, `sector-rotation` |
|
||||
| Crypto | 7 | `perp-funding-basis`, `liquidation-heatmap`, `stablecoin-flow`, `defi-yield`, `onchain-analysis` |
|
||||
| Flow | 7 | `hk-connect-flow`, `us-etf-flow`, `edgar-sec-filings`, `financial-statement`, `adr-hshare` |
|
||||
| Tool | 11 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader`, `vnpy-export`, `alpha-zoo` |
|
||||
| Risk Analysis | 1 | `ashare-pre-st-filter` |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Preset Trading Teams</b> <sub>29 swarm presets</sub></summary>
|
||||
|
||||
- 🏢 すぐ使える 29 の agent teams
|
||||
- ⚡ 事前構成済みの finance workflows
|
||||
- 🎯 投資、トレーディング、リスク管理向け presets
|
||||
|
||||
| Preset | Workflow |
|
||||
|--------|----------|
|
||||
| `investment_committee` | Bull/bear debate → risk review → PM final call |
|
||||
| `global_equities_desk` | A-share + HK/US + crypto researcher → global strategist |
|
||||
| `crypto_trading_desk` | Funding/basis + liquidation + flow → risk manager |
|
||||
| `earnings_research_desk` | Fundamental + revision + options → earnings strategist |
|
||||
| `macro_rates_fx_desk` | Rates + FX + commodity → macro PM |
|
||||
| `quant_strategy_desk` | Screening + factor research → backtest → risk audit |
|
||||
| `technical_analysis_panel` | Classic TA + Ichimoku + harmonic + Elliott + SMC → consensus |
|
||||
| `risk_committee` | Drawdown + tail risk + regime review → sign-off |
|
||||
| `global_allocation_committee` | A-shares + crypto + HK/US → cross-market allocation |
|
||||
|
||||
<sub>さらに 20 以上の specialist presets があります。すべて確認するには vibe-trading --swarm-presets を実行してください。
|
||||
|
||||
</sub>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Alpha Zoo</b> <sub>4 つの zoo に渡る 452 個の事前構築 quant alpha</sub></summary>
|
||||
|
||||
- 🧬 452 個のクロスセクショナル alpha、オペレーター層でルックアヘッドを禁止
|
||||
- 📈 IC + IR + alive/reversed/dead 分類を 1 つの CLI コマンドで
|
||||
- 🔬 AST 純関数ゲート + 300 行のルックアヘッド sentinel テスト + `pytest-socket` によるネットワーク遮断
|
||||
- 📦 Qlib には Apache-2 帰属表示、各 zoo ごとに `LICENSE.md` で formula を数学的内容として宣言
|
||||
- 🤝 コミュニティ PR 向け Developer Certificate of Origin (DCO) 署名フロー
|
||||
|
||||
| Zoo | 件数 | 出典 | ライセンス |
|
||||
|-----|-------|--------|---------|
|
||||
| **qlib158** | 154 | Microsoft Qlib `Alpha158`(Apache-2.0、コミット固定) | Apache-2.0 |
|
||||
| **alpha101** | 101 | Kakushadze (2015)、"101 Formulaic Alphas"、arXiv:1601.00991 | Formula は数学的内容 |
|
||||
| **gtja191** | 191 | 国泰君安 (2014)、「191 短周期取引型 alpha 因子」 | Formula は数学的内容 |
|
||||
| **academic** | 6 | Fama-French 5 + Carhart momentum(価格ベースの proxy) | 公開された学術文献 |
|
||||
|
||||
`vibe-trading alpha list` で閲覧、`vibe-trading alpha show <id>` で formula + ソース、`vibe-trading alpha bench --zoo X --universe Y --period Z` で zoo 全体をスコアリングできます。
|
||||
|
||||
</details>
|
||||
|
||||
## 🎬 デモ
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
https://github.com/user-attachments/assets/4e4dcb80-7358-4b9a-92f0-1e29612e6e86
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
https://github.com/user-attachments/assets/3754a414-c3ee-464f-b1e8-78e1a74fbd30
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" align="center"><sub>☝️ 自然言語バックテスト & マルチエージェント swarm debate — Web UI + CLI</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 🚀 クイックスタート
|
||||
|
||||
### 1行インストール(PyPI)
|
||||
|
||||
```bash
|
||||
pip install vibe-trading-ai
|
||||
```
|
||||
|
||||
最初のリサーチタスクを実行します。
|
||||
|
||||
```bash
|
||||
vibe-trading init
|
||||
vibe-trading run -p "Backtest a BTC-USDT 20/50 moving-average strategy for 2024 and summarize return and drawdown"
|
||||
```
|
||||
|
||||
> **Package name vs commands:** PyPI package は `vibe-trading-ai` です。インストール後、3 つのコマンドが使えます。
|
||||
>
|
||||
> | Command | Purpose |
|
||||
> |---------|---------|
|
||||
> | `vibe-trading` | Interactive CLI / TUI |
|
||||
> | `vibe-trading serve` | FastAPI web server を起動 |
|
||||
> | `vibe-trading-mcp` | MCP server を起動(Claude Desktop、OpenClaw、Cursor など向け) |
|
||||
|
||||
```bash
|
||||
vibe-trading init # interactive .env setup
|
||||
vibe-trading # launch CLI
|
||||
vibe-trading serve --port 8899 # launch web UI
|
||||
vibe-trading-mcp # start MCP server (stdio)
|
||||
```
|
||||
|
||||
### または利用経路を選ぶ
|
||||
|
||||
| Path | Best for | Time |
|
||||
|------|----------|------|
|
||||
| **A. Docker** | すぐ試す、ローカル設定ゼロ | 2 min |
|
||||
| **B. Local install** | 開発、CLI へのフルアクセス | 5 min |
|
||||
| **C. MCP plugin** | 既存 agent へ接続 | 3 min |
|
||||
| **D. ClawHub** | clone 不要、1 コマンド | 1 min |
|
||||
|
||||
### 前提条件
|
||||
|
||||
- 対応 provider の **LLM API key**、または **Ollama** によるローカル実行(key 不要)
|
||||
- Path B では **Python 3.11+**
|
||||
- Path A では **Docker**
|
||||
- OpenAI Codex は ChatGPT OAuth でも利用できます。`LANGCHAIN_PROVIDER=openai-codex` を設定し、`vibe-trading provider login openai-codex` を実行してください。`OPENAI_API_KEY` は使いません。
|
||||
|
||||
> **Supported LLM providers:** OpenRouter、OpenAI、DeepSeek、Gemini、Groq、DashScope/Qwen、Zhipu、Moonshot/Kimi、MiniMax、Xiaomi MIMO、Z.ai、Ollama(local)。設定は `.env.example` を参照してください。
|
||||
|
||||
> **Tip:** 自動フォールバックにより、すべての市場は API key なしで利用できます。yfinance(HK/US)、OKX(crypto)、mootdx(A 株、TCP 直結で IP 制限なし)、AKShare(A-shares、US、HK、futures、forex)はすべて無料です。Tushare token は任意で、A 株は mootdx が推奨の no-token fallback、AKShare がより広いカバレッジのバックアップになります。
|
||||
|
||||
### Path A: Docker(設定ゼロ)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/Vibe-Trading.git
|
||||
cd Vibe-Trading
|
||||
cp agent/.env.example agent/.env
|
||||
# Edit agent/.env — uncomment your LLM provider and set API key
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
`http://localhost:8899` を開きます。Backend + frontend が 1 つの container で動作します。
|
||||
|
||||
Docker は既定で backend を `127.0.0.1:8899` に公開し、app を non-root container user として実行します。意図して API を自分の machine 外へ公開する場合は、強い `API_AUTH_KEY` を設定し、client から `Authorization: Bearer <key>` を送ってください。
|
||||
|
||||
### Path B: Local install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/Vibe-Trading.git
|
||||
cd Vibe-Trading
|
||||
python -m venv .venv
|
||||
|
||||
# Activate
|
||||
source .venv/bin/activate # Linux / macOS
|
||||
# .venv\Scripts\Activate.ps1 # Windows PowerShell
|
||||
|
||||
pip install -e .
|
||||
cp agent/.env.example agent/.env # Edit — set your LLM provider API key
|
||||
vibe-trading # Launch interactive TUI
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Web UI を起動(任意)</b></summary>
|
||||
|
||||
```bash
|
||||
# Terminal 1: API server
|
||||
vibe-trading serve --port 8899
|
||||
|
||||
# Terminal 2: Frontend dev server
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
`http://localhost:5899` を開きます。frontend は API calls を `localhost:8899` へ proxy します。
|
||||
|
||||
**Production mode(single server):**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build && cd ..
|
||||
vibe-trading serve --port 8899 # FastAPI serves dist/ as static files
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Path C: MCP plugin
|
||||
|
||||
下の [MCP Plugin](#-mcp-plugin) セクションを参照してください。
|
||||
|
||||
### Path D: ClawHub(1 コマンド)
|
||||
|
||||
```bash
|
||||
npx clawhub@latest install vibe-trading --force
|
||||
```
|
||||
|
||||
skill + MCP config が agent の skills directory にダウンロードされます。詳細は [ClawHub install](#-mcp-plugin) を参照してください。
|
||||
|
||||
---
|
||||
|
||||
## 🧠 環境変数
|
||||
|
||||
`agent/.env.example` を `agent/.env` にコピーし、使いたい provider block のコメントを外してください。各 provider には 3-4 個の変数が必要です。
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|:--------:|-------------|
|
||||
| `LANGCHAIN_PROVIDER` | Yes | Provider name(`openrouter`, `deepseek`, `groq`, `ollama` など) |
|
||||
| `<PROVIDER>_API_KEY` | Yes* | API key(`OPENROUTER_API_KEY`, `DEEPSEEK_API_KEY` など) |
|
||||
| `<PROVIDER>_BASE_URL` | Yes | API endpoint URL |
|
||||
| `LANGCHAIN_MODEL_NAME` | Yes | Model name(例: `deepseek-v4-pro`) |
|
||||
| `TUSHARE_TOKEN` | No | A-share data 用 Tushare Pro token(AKShare に fallback) |
|
||||
| `TIMEOUT_SECONDS` | No | LLM call timeout、既定 120s |
|
||||
| `API_AUTH_KEY` | Recommended for network deployments | API が非ローカル client から到達可能な場合に必要な Bearer token |
|
||||
| `VIBE_TRADING_ENABLE_SHELL_TOOLS` | No | remote API/MCP-SSE style deployments で shell-capable tools を明示 opt-in |
|
||||
| `VIBE_TRADING_ALLOWED_FILE_ROOTS` | No | document と broker-journal imports 用の追加 comma-separated roots |
|
||||
| `VIBE_TRADING_ALLOWED_RUN_ROOTS` | No | generated-code run directories 用の追加 comma-separated roots |
|
||||
|
||||
<sub>* Ollama は API key 不要です。OpenAI Codex は ChatGPT OAuth を使い、tokens は `agent/.env` ではなく `oauth-cli-kit` 経由で保存します。</sub>
|
||||
|
||||
**無料データ(key 不要):** AKShare による A-shares、yfinance による HK/US equities、OKX による crypto、CCXT による 100+ crypto exchanges。システムは各市場に最適な利用可能 source を自動選択します。
|
||||
|
||||
### 🎯 推奨モデル
|
||||
|
||||
Vibe-Trading は tool-heavy agent です。skills、backtests、memory、swarms はすべて tool calls を通じて流れます。モデル選択は、agent が実際に *tools を使う* か、training data から作り話をするかを直接左右します。
|
||||
|
||||
| Tier | Examples | When to use |
|
||||
|------|----------|-------------|
|
||||
| **Best** | `anthropic/claude-opus-4.7`, `anthropic/claude-sonnet-4.6`, `openai/gpt-5.5-pro`, `google/gemini-3.5-flash` | 複雑な swarms(3+ agents)、長い research sessions、paper-grade analysis |
|
||||
| **Sweet spot** (default) | `deepseek-v4-pro`, `deepseek/deepseek-v4-pro`, `x-ai/grok-4.20`, `z-ai/glm-5.1`, `moonshotai/kimi-k2.6`, `qwen/qwen3-max-thinking` | 日常使い。信頼できる tool-calling を約 1/10 の cost で |
|
||||
| **Avoid for agent use** | `*-nano`, `*-flash-lite`, `*-coder-next`, small / distilled variants | Tool-calling が不安定です。agent は skills 読み込みや backtests 実行ではなく「記憶から答えている」ように見えます |
|
||||
|
||||
既定の `agent/.env.example` は DeepSeek official API + `deepseek-v4-pro` で出荷されています。OpenRouter users は `deepseek/deepseek-v4-pro` を利用できます。
|
||||
|
||||
---
|
||||
|
||||
## 🖥 CLI リファレンス
|
||||
|
||||
```bash
|
||||
vibe-trading # interactive TUI
|
||||
vibe-trading run -p "..." # single run
|
||||
vibe-trading serve # API server
|
||||
vibe-trading alpha list # 452 個の事前構築 alpha を閲覧;show / bench / compare / export-manifest サブコマンド利用可
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>TUI 内の slash commands</b></summary>
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/help` | 全コマンドを表示 |
|
||||
| `/skills` | 77 finance skills を一覧表示 |
|
||||
| `/swarm` | 29 swarm team presets を一覧表示 |
|
||||
| `/swarm run <preset> [vars_json]` | live streaming で swarm team を実行 |
|
||||
| `/swarm list` | Swarm run history |
|
||||
| `/swarm show <run_id>` | Swarm run details |
|
||||
| `/swarm cancel <run_id>` | 実行中の swarm をキャンセル |
|
||||
| `/list` | Recent runs |
|
||||
| `/show <run_id>` | Run details + metrics |
|
||||
| `/code <run_id>` | 生成された strategy code |
|
||||
| `/pine <run_id>` | indicators を export(TradingView + TDX + MT5) |
|
||||
| `/trace <run_id>` | Full execution replay |
|
||||
| `/continue <run_id> <prompt>` | 新しい指示で run を継続 |
|
||||
| `/sessions` | Chat sessions を一覧表示 |
|
||||
| `/settings` | Runtime config を表示 |
|
||||
| `/clear` | 画面をクリア |
|
||||
| `/quit` | 終了 |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Single run & flags</b></summary>
|
||||
|
||||
```bash
|
||||
vibe-trading run -p "Backtest BTC-USDT MACD strategy, last 30 days"
|
||||
vibe-trading run -p "Analyze AAPL momentum" --json
|
||||
vibe-trading run -f strategy.txt
|
||||
echo "Backtest 000001.SZ RSI" | vibe-trading run
|
||||
```
|
||||
|
||||
```bash
|
||||
vibe-trading -p "your prompt"
|
||||
vibe-trading --skills
|
||||
vibe-trading --swarm-presets
|
||||
vibe-trading --swarm-run investment_committee '{"topic":"BTC outlook"}'
|
||||
vibe-trading --list
|
||||
vibe-trading --show <run_id>
|
||||
vibe-trading --code <run_id>
|
||||
vibe-trading --pine <run_id> # Export indicators (TradingView + TDX + MT5)
|
||||
vibe-trading --trace <run_id>
|
||||
vibe-trading --continue <run_id> "refine the strategy"
|
||||
vibe-trading --upload report.pdf
|
||||
vibe-trading alpha list --zoo gtja191 --limit 10
|
||||
vibe-trading alpha show gtja191_171
|
||||
vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025 --top 20
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 💡 例
|
||||
|
||||
### Strategy & Backtesting
|
||||
|
||||
```bash
|
||||
# Moving average crossover on US equities
|
||||
vibe-trading run -p "Backtest a 20/50-day moving average crossover on AAPL for the past year, show Sharpe ratio and max drawdown"
|
||||
|
||||
# RSI mean-reversion on crypto
|
||||
vibe-trading run -p "Test RSI(14) mean-reversion on BTC-USDT: buy below 30, sell above 70, last 6 months"
|
||||
|
||||
# Multi-factor strategy on A-shares
|
||||
vibe-trading run -p "Backtest a momentum + value + quality multi-factor strategy on CSI 300 constituents over 2 years"
|
||||
|
||||
# After backtesting, export to TradingView / TDX / MetaTrader 5
|
||||
vibe-trading --pine <run_id>
|
||||
```
|
||||
|
||||
**事前構築 alpha zoo を 1 行でベンチ**:
|
||||
```bash
|
||||
vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025 --top 20
|
||||
```
|
||||
|
||||
**カタログを閲覧**して個別の alpha を確認:
|
||||
```bash
|
||||
vibe-trading alpha list --zoo gtja191 --theme reversal --limit 10
|
||||
vibe-trading alpha show gtja191_171
|
||||
```
|
||||
|
||||
**zoo からマルチファクターシグナルを構成**(Python):
|
||||
```python
|
||||
from src.skills.multi_factor.zoo_signal_engine import ZooSignalEngine
|
||||
engine = ZooSignalEngine.from_zoo(["gtja191_171", "gtja191_111", "gtja191_163"])
|
||||
panel = ... # your wide OHLCV panel
|
||||
signal = engine.compute_signal(panel)
|
||||
```
|
||||
|
||||
### Market Research
|
||||
|
||||
```bash
|
||||
# Equity deep-dive
|
||||
vibe-trading run -p "Research NVDA: earnings trend, analyst consensus, option flow, and key risks for next quarter"
|
||||
|
||||
# Macro analysis
|
||||
vibe-trading run -p "Analyze the current Fed rate path, USD strength, and impact on EM equities and gold"
|
||||
|
||||
# Crypto on-chain
|
||||
vibe-trading run -p "Deep dive BTC on-chain: whale flows, exchange balances, miner activity, and funding rates"
|
||||
```
|
||||
|
||||
### Swarm Workflows
|
||||
|
||||
```bash
|
||||
# Bull/bear debate on a stock
|
||||
vibe-trading --swarm-run investment_committee '{"topic": "Is TSLA a buy at current levels?"}'
|
||||
|
||||
# Quant strategy from screening to backtest
|
||||
vibe-trading --swarm-run quant_strategy_desk '{"universe": "S&P 500", "horizon": "3 months"}'
|
||||
|
||||
# Crypto desk: funding + liquidation + flow → risk manager
|
||||
vibe-trading --swarm-run crypto_trading_desk '{"asset": "ETH-USDT", "timeframe": "1w"}'
|
||||
|
||||
# Global macro portfolio allocation
|
||||
vibe-trading --swarm-run macro_rates_fx_desk '{"focus": "Fed pivot impact on EM bonds"}'
|
||||
```
|
||||
|
||||
### Cross-Session Memory
|
||||
|
||||
```bash
|
||||
# Save your preferences once
|
||||
vibe-trading run -p "Remember: I prefer RSI-based strategies, max 10% drawdown, hold period 5–20 days"
|
||||
|
||||
# The agent recalls them in future sessions automatically
|
||||
vibe-trading run -p "Build a crypto strategy that fits my risk profile"
|
||||
```
|
||||
|
||||
### Upload & Analyze Documents
|
||||
|
||||
```bash
|
||||
# Analyze a broker export or earnings report
|
||||
vibe-trading --upload trades_export.csv
|
||||
vibe-trading run -p "Profile my trading behavior and identify any biases"
|
||||
|
||||
vibe-trading --upload NVDA_Q1_earnings.pdf
|
||||
vibe-trading run -p "Summarize the key risks and beats/misses from this earnings report"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 API サーバー
|
||||
|
||||
```bash
|
||||
vibe-trading serve --port 8899
|
||||
```
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/runs` | runs を一覧表示 |
|
||||
| `GET` | `/runs/{run_id}` | run details |
|
||||
| `GET` | `/runs/{run_id}/pine` | Multi-platform indicator export |
|
||||
| `POST` | `/sessions` | session を作成 |
|
||||
| `POST` | `/sessions/{id}/messages` | message を送信 |
|
||||
| `GET` | `/sessions/{id}/events` | SSE event stream |
|
||||
| `POST` | `/upload` | PDF/file をアップロード |
|
||||
| `GET` | `/swarm/presets` | swarm presets を一覧表示 |
|
||||
| `POST` | `/swarm/runs` | swarm run を開始 |
|
||||
| `GET` | `/swarm/runs/{id}/events` | Swarm SSE stream |
|
||||
| `GET` | `/alpha/list` | zoo/theme/universe でフィルタした alpha リスト |
|
||||
| `GET` | `/alpha/{alpha_id}` | Alpha のメタデータ + ソースコード |
|
||||
| `POST` | `/alpha/bench` | Bench ジョブを開始(`job_id` を返す) |
|
||||
| `GET` | `/alpha/bench/{job_id}/stream` | SSE 進捗ストリーム |
|
||||
| `GET` | `/settings/llm` | Web UI LLM settings を読み取り |
|
||||
| `PUT` | `/settings/llm` | local LLM settings を更新 |
|
||||
| `GET` | `/settings/data-sources` | local data source settings を読み取り |
|
||||
| `PUT` | `/settings/data-sources` | local data source settings を更新 |
|
||||
|
||||
Interactive docs: `http://localhost:8899/docs`
|
||||
|
||||
### Security defaults
|
||||
|
||||
localhost 開発では、`vibe-trading serve` は browser workflow を簡単に保ちます。非ローカル client では、sensitive API endpoints に `API_AUTH_KEY` が必要です。JSON/upload requests には `Authorization: Bearer <key>` を使ってください。Browser EventSource streams は、Settings で同じ key を一度入力した後、Web UI が処理します。
|
||||
|
||||
Shell-capable tools は local CLI と trusted localhost workflows で利用できますが、`VIBE_TRADING_ENABLE_SHELL_TOOLS=1` を明示的に設定しない限り remote API sessions には公開されません。Document と journal readers は既定で upload/import roots に制限されます。ファイルは `agent/uploads`、`agent/runs`、`./uploads`、`./data`、`~/.vibe-trading/uploads`、`~/.vibe-trading/imports` の下に置くか、`VIBE_TRADING_ALLOWED_FILE_ROOTS` で専用 directory を追加してください。
|
||||
|
||||
### Web UI Settings
|
||||
|
||||
Web UI Settings page では、local users が LLM provider/model、base URL、generation parameters、reasoning effort、Tushare token など任意の market data credentials を更新できます。Settings は `agent/.env` に永続化され、provider defaults は `agent/src/providers/llm_providers.json` から読み込まれます。
|
||||
|
||||
Settings reads は side-effect free です。`GET /settings/llm` と `GET /settings/data-sources` は `agent/.env` を作成せず、project-relative paths だけを返します。Settings の読み書きは credential state の公開や credentials/runtime environment の更新を伴うため、設定済みの場合は `API_AUTH_KEY` が必要です。dev mode で `API_AUTH_KEY` が未設定の場合、settings access は loopback clients からのみ受け付けます。
|
||||
|
||||
---
|
||||
|
||||
## 🔌 MCP Plugin
|
||||
|
||||
Vibe-Trading は MCP-compatible client 向けに 35 MCP tools を公開します。stdio subprocess として動作し、server setup は不要です。Core research tools は HK/US/crypto で API key なしに動作し、trading connector tools は選択中の connector profile を使います。LLM key が必要なのは `run_swarm` のみです。
|
||||
|
||||
<details>
|
||||
<summary><b>Claude Desktop</b></summary>
|
||||
|
||||
`claude_desktop_config.json` に追加:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"vibe-trading": {
|
||||
"command": "vibe-trading-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>OpenClaw</b></summary>
|
||||
|
||||
`~/.openclaw/config.yaml` に追加:
|
||||
|
||||
```yaml
|
||||
skills:
|
||||
- name: vibe-trading
|
||||
command: vibe-trading-mcp
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Cursor / Windsurf / other MCP clients</b></summary>
|
||||
|
||||
```bash
|
||||
vibe-trading-mcp # stdio (default)
|
||||
vibe-trading-mcp --transport sse # SSE for web clients
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**公開される MCP tools(35):** `list_skills`, `load_skill`, `start_research_goal`, `get_research_goal`, `add_goal_evidence`, `update_research_goal_status`, `backtest`, `factor_analysis`, `analyze_options`, `pattern_recognition`, `read_url`, `read_document`, `web_search`, `write_file`, `read_file`, `list_swarm_presets`, `run_swarm`, `get_market_data`, `get_swarm_status`, `get_run_result`, `list_runs`, `reap_stale_runs`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `trading_connections`, `trading_select_connection`, `trading_check`, `trading_account`, `trading_positions`, `trading_orders`, `trading_quote`, `trading_history`.
|
||||
|
||||
<details>
|
||||
<summary><b>ClawHub からインストール(1 コマンド)</b></summary>
|
||||
|
||||
```bash
|
||||
npx clawhub@latest install vibe-trading --force
|
||||
```
|
||||
|
||||
> `--force` が必要なのは、skill が external APIs を参照し、VirusTotal の automated scan が起動するためです。コードは完全に open-source で、自由に確認できます。
|
||||
|
||||
これにより skill + MCP config が agent の skills directory にダウンロードされます。clone は不要です。
|
||||
|
||||
ClawHub で見る: [clawhub.ai/skills/vibe-trading](https://clawhub.ai/skills/vibe-trading)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>OpenSpace — self-evolving skills</b></summary>
|
||||
|
||||
77 の finance skills はすべて [open-space.cloud](https://open-space.cloud) に公開され、OpenSpace の self-evolution engine を通じて自律的に進化します。
|
||||
|
||||
OpenSpace と使うには、agent config に両方の MCP servers を追加してください。
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"openspace": {
|
||||
"command": "openspace-mcp",
|
||||
"toolTimeout": 600,
|
||||
"env": {
|
||||
"OPENSPACE_HOST_SKILL_DIRS": "/path/to/vibe-trading/agent/src/skills",
|
||||
"OPENSPACE_WORKSPACE": "/path/to/OpenSpace"
|
||||
}
|
||||
},
|
||||
"vibe-trading": {
|
||||
"command": "vibe-trading-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
OpenSpace は 77 skills を自動検出し、auto-fix、auto-improve、community sharing を可能にします。OpenSpace-connected agent では `search_skills("finance backtest")` から Vibe-Trading skills を検索できます。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 📁 プロジェクト構成
|
||||
|
||||
<details>
|
||||
<summary><b>クリックして展開</b></summary>
|
||||
|
||||
```
|
||||
Vibe-Trading/
|
||||
├── agent/ # バックエンド (Python)
|
||||
│ ├── cli/ # CLI パッケージ — インタラクティブ TUI + サブコマンド
|
||||
│ ├── api_server.py # FastAPI サーバー — runs、sessions、upload、swarm、SSE
|
||||
│ ├── mcp_server.py # MCP サーバー — OpenClaw / Claude Desktop 向け 35 tools
|
||||
│ │
|
||||
│ ├── src/
|
||||
│ │ ├── agent/ # ReAct エージェントコア
|
||||
│ │ │ ├── loop.py # 5 層コンテキスト圧縮 + read/write ツールバッチング
|
||||
│ │ │ ├── context.py # システムプロンプト + 永続メモリからの自動 recall
|
||||
│ │ │ ├── skills.py # skill ローダー(77 個同梱 + CRUD でユーザー作成)
|
||||
│ │ │ ├── tools.py # ツール基底クラス + レジストリ
|
||||
│ │ │ ├── memory.py # run ごとの軽量ワークスペース状態
|
||||
│ │ │ ├── frontmatter.py # 共有 YAML frontmatter パーサー
|
||||
│ │ │ └── trace.py # 実行トレースライター
|
||||
│ │ │
|
||||
│ │ ├── memory/ # クロスセッション永続メモリ
|
||||
│ │ │ └── persistent.py # ファイルベースメモリ (~/.vibe-trading/memory/)
|
||||
│ │ │
|
||||
│ │ ├── tools/ # 31 個の自動検出エージェントツール
|
||||
│ │ │ ├── backtest_tool.py # バックテスト実行
|
||||
│ │ │ ├── remember_tool.py # クロスセッションメモリ (save/recall/forget)
|
||||
│ │ │ ├── skill_writer_tool.py # skill CRUD (save/patch/delete/file)
|
||||
│ │ │ ├── session_search_tool.py # FTS5 クロスセッション検索
|
||||
│ │ │ ├── swarm_tool.py # swarm チームを起動
|
||||
│ │ │ ├── web_search_tool.py # DuckDuckGo Web 検索
|
||||
│ │ │ └── ... # bash、file I/O、factor analysis、options、alpha browser + bench など
|
||||
│ │ │
|
||||
│ │ ├── factors/ # Alpha Zoo — 4 つの zoo にまたがる 452 個の alpha
|
||||
│ │ │ ├── base.py # 19 個のオペレーター (rank/scale/ts_*/delta/decay_linear/safe_div/vwap)
|
||||
│ │ │ ├── registry.py # AST 限定のメタデータ読み込み + 遅延計算 + sanity gate
|
||||
│ │ │ ├── bench_runner.py # IC + alive/reversed/dead 分類
|
||||
│ │ │ └── zoo/ # qlib158 (154) + alpha101 (101) + gtja191 (191) + academic (6)
|
||||
│ │ │
|
||||
│ │ ├── api/ # FastAPI ルートモジュール
|
||||
│ │ │ └── alpha_routes.py # /alpha/list、/alpha/{id}、/alpha/bench、SSE ストリーム
|
||||
│ │ │
|
||||
│ │ ├── skills/ # 8 カテゴリ 77 個の finance skills(各 SKILL.md)
|
||||
│ │ ├── swarm/ # Swarm DAG 実行エンジン
|
||||
│ │ │ └── presets/ # 29 個の swarm preset YAML 定義
|
||||
│ │ ├── session/ # マルチターンチャット + FTS5 セッション検索
|
||||
│ │ └── providers/ # LLM プロバイダー抽象化
|
||||
│ │
|
||||
│ └── backtest/ # バックテストエンジン
|
||||
│ ├── engines/ # 7 エンジン + クロスマーケット composite engine + options_portfolio
|
||||
│ ├── loaders/ # 7 ソース: tushare、okx、yfinance、akshare、mootdx、ccxt、futu
|
||||
│ │ ├── base.py # DataLoader Protocol
|
||||
│ │ └── registry.py # Registry + 自動フォールバックチェーン
|
||||
│ └── optimizers/ # MVO、equal vol、max div、risk parity
|
||||
│
|
||||
├── frontend/ # Web UI (React 19 + Vite + TypeScript)
|
||||
│ └── src/
|
||||
│ ├── pages/ # Home、Agent、AlphaZoo、RunDetail、Compare、Correlation、Settings
|
||||
│ ├── components/ # chat、charts、layout
|
||||
│ └── stores/ # Zustand 状態管理
|
||||
│
|
||||
├── Dockerfile # マルチステージビルド
|
||||
├── docker-compose.yml # 1 コマンドデプロイ
|
||||
├── pyproject.toml # パッケージ設定 + CLI エントリポイント
|
||||
├── tools/ # リポジトリレベルの CI ヘルパー
|
||||
│ └── ci_grep_gates.sh # yaml.load / 商標 / 銘柄データ漏洩を拒否
|
||||
└── LICENSE # MIT
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🏛 エコシステム
|
||||
|
||||
Vibe-Trading は **[HKUDS](https://github.com/HKUDS)** agent ecosystem の一部です。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/HKUDS/nanobot"><b>NanoBot</b></a><br>
|
||||
<sub>Ultra-Lightweight Personal AI Assistant</sub>
|
||||
</td>
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/HKUDS/AI-Trader"><b>AI-Trader</b></a><br>
|
||||
<sub>Agent-Native Signal & Copy Trading Platform</sub>
|
||||
</td>
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/HKUDS/CLI-Anything"><b>CLI-Anything</b></a><br>
|
||||
<sub>Making All Software Agent-Native</sub>
|
||||
</td>
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/HKUDS/OpenSpace"><b>OpenSpace</b></a><br>
|
||||
<sub>Self-Evolving AI Agent Skills</sub>
|
||||
</td>
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/HKUDS/ClawTeam"><b>ClawTeam</b></a><br>
|
||||
<sub>Agent Swarm Intelligence</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## 🗺 ロードマップ
|
||||
|
||||
> 段階的に出荷します。作業が始まった項目は [Issues](https://github.com/HKUDS/Vibe-Trading/issues) に移動します。
|
||||
|
||||
| Phase | Feature | Status |
|
||||
|-------|---------|--------|
|
||||
| **Trust Layer** | 再現可能な run cards は出力・Run Detail 表示まで完了。v1 では tool traces と citations を追加 | v0 出荷済み |
|
||||
| **Hypothesis Registry** | lifecycle status、data sources、skills、run-card links、invalidation notes を持つ永続リサーチ仮説 | Backend MVP 出荷済み |
|
||||
| **Research Autopilot** | 手動実行から始める research loop: hypothesis → deterministic backtest → evidence report | 次 |
|
||||
| **Data Bridge** | Bring-your-own data: local CSV/Parquet/SQL connectors with schema mapping | Planned |
|
||||
| **Options Lab** | Vol surface, Greeks dashboard, payoff/scenario explorer | Planned |
|
||||
| **Portfolio Studio** | Risk x-ray, constraints, turnover-aware optimizer, rebalance notes | Planned |
|
||||
| **Alpha Zoo** | 452 個の事前構築 alpha 因子(Qlib 158 + Kakushadze 101 + GTJA 191 + FF5 + Carhart)、1 行 CLI でベンチ、agent 統合、Web UI | **0.1.8 でリリース済み** |
|
||||
| **Research Delivery** | Scheduled briefs to Slack / Telegram / email-style channels | Planned |
|
||||
| **Community** | Shareable skills, presets, and strategy cards | Exploring |
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions を歓迎します。ガイドラインは [CONTRIBUTING.md](CONTRIBUTING.md) を参照してください。
|
||||
|
||||
**Good first issues** は [`good first issue`](https://github.com/HKUDS/Vibe-Trading/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) でタグ付けされています。気になるものから始めてください。
|
||||
|
||||
より大きな貢献を検討している場合は、上の [Roadmap](#-ロードマップ) を確認し、着手前に issue を開いて相談してください。
|
||||
|
||||
---
|
||||
|
||||
## Contributors
|
||||
|
||||
Vibe-Trading に貢献してくださった皆さまに感謝します。
|
||||
|
||||
最近の v0.1.8 cycle contributors and credits:
|
||||
|
||||
- @GTC2080 / TaoMu — Web UI Settings and provider/data-source configuration APIs (#57)
|
||||
- @BigNounce90 — validation CLI hardening for backtest `run_dir` input (#60)
|
||||
- @shadowinlife — A-share pre-ST filter skill (#63)
|
||||
- @MB-Ndhlovu — correlation heatmap dashboard and review fixes (#64, #66)
|
||||
- @ykykj — OpenAI Codex OAuth provider option (#65)
|
||||
- @RuifengFu — interactive CLI live status bar and prompt editing (#69)
|
||||
- @SiMinus — swarm preset inspection command (#73)
|
||||
- @warren618 / Haozhe Wu — security hardening, release integration, docs, Docker, packaging, and local dev workflow
|
||||
- lemi9090 (S2W) — coordinated security research, validation, and disclosure support
|
||||
|
||||
<a href="https://github.com/HKUDS/Vibe-Trading/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=HKUDS/Vibe-Trading" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Vibe-Trading は研究・取引ソフトウェアです。投資助言ではなく、資金を一切保管せず、取引所も運営しません。取引はご自身が明示的に認可したブローカーチャネル(例: Robinhood Agentic Trading)を通じてのみ行われ、設定した制限の範囲内で、いつでも停止できます。このブローカー取引機能は実験的であり、当方が実際のブローカー口座で検証したものではありません——自己責任でご利用ください。過去の成績は将来の結果を保証しません。
|
||||
|
||||
## License
|
||||
|
||||
MIT License — see [LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#HKUDS/Vibe-Trading&Date)
|
||||
|
||||
<p align="center">
|
||||
⭐ <b>Vibe-Trading</b> が研究の役に立ったら、Star を付けると他の人にも見つけてもらえます。
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<b>Vibe-Trading</b> をご覧いただきありがとうございます ✨
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.Vibe-Trading&style=flat" alt="visitors"/>
|
||||
</p>
|
||||
+947
@@ -0,0 +1,947 @@
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | <a href="README_zh.md">中文</a> | <a href="README_ja.md">日本語</a> | <b>한국어</b> | <a href="README_ar.md">العربية</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/icon.png" width="120" alt="Vibe-Trading 로고"/>
|
||||
</p>
|
||||
|
||||
<h1 align="center">Vibe-Trading: 당신의 개인 트레이딩 에이전트</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>한 번의 명령으로 에이전트에 종합적인 트레이딩 역량을 더하세요</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Python-3.11%2B-3776AB?style=flat&logo=python&logoColor=white" alt="Python">
|
||||
<img src="https://img.shields.io/badge/Backend-FastAPI-009688?style=flat" alt="FastAPI">
|
||||
<img src="https://img.shields.io/badge/Frontend-React%2019-61DAFB?style=flat&logo=react&logoColor=white" alt="React">
|
||||
<a href="https://pypi.org/project/vibe-trading-ai/"><img src="https://img.shields.io/pypi/v/vibe-trading-ai?style=flat&logo=pypi&logoColor=white" alt="PyPI"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow?style=flat" alt="License"></a>
|
||||
<br>
|
||||
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/Feishu-Group-E9DBFC?style=flat-square&logo=feishu&logoColor=white" alt="Feishu"></a>
|
||||
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat-square&logo=wechat&logoColor=white" alt="WeChat"></a>
|
||||
<a href="https://discord.gg/2vDYc2w5"><img src="https://img.shields.io/badge/Discord-Join-7289DA?style=flat-square&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://vibetrading.wiki/">웹사이트</a> ·
|
||||
<a href="https://vibetrading.wiki/docs/">문서</a> ·
|
||||
<a href="#-뉴스">뉴스</a> ·
|
||||
<a href="#-주요-기능">기능</a> ·
|
||||
<a href="#-섀도우-계정">섀도우 계정</a> ·
|
||||
<a href="#-데모">데모</a> ·
|
||||
<a href="#-빠른-시작">빠른 시작</a> ·
|
||||
<a href="#-예제">예제</a> ·
|
||||
<a href="#-api-서버">API / MCP</a> ·
|
||||
<a href="#-로드맵">로드맵</a> ·
|
||||
<a href="#기여하기">기여하기</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#-빠른-시작"><img src="assets/pip-install.svg" height="45" alt="pip install vibe-trading-ai"></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 📰 뉴스
|
||||
|
||||
- **2026-05-31** 🔌 **Connector-first 브로커 아키텍처(IBKR + Robinhood)**: 거래 접근은 이제 별도의 브로커/live 진입점이 아니라 선택 가능한 connector profile에서 시작합니다. `vibe-trading connector list/use/check/account/positions/orders/quote/history`와 MCP `trading_*` 도구는 동일한 선택 profile을 공유하며, paper/live는 connector의 속성으로 다룹니다. IBKR은 로컬 읽기 전용 TWS / IB Gateway profile로 즉시 사용할 수 있고, 공식 IBKR 원격 MCP 경로는 안정적인 read tool 이름이 제공될 때까지 OAuth `mcp.read` probe로 seed되어 있습니다. Robinhood Agentic Trading은 계속 OAuth, 커밋된 mandate, order guard, audit ledger, instant halt 뒤에 있는 bounded live MCP connector입니다.
|
||||
- **2026-05-30** 🧰 **견고성 패스 — backtest, agent loop, session**: LLM이 생성한 signal engine은 이제 인스턴스화 전에 인터페이스 사전 검증을 거칩니다. 순환 self-import, 누락된 `generate()`, 기본값 없는 `__init__` 인자, 잘못된 반환 타입 같은 흔한 실수를 조기에 잡아 원시 traceback 대신 실행 가능한 JSON 오류로 반환합니다 ([#149](https://github.com/HKUDS/Vibe-Trading/pull/149)). 후속 작업으로 소스 수준 AST 검증 오류도 동일한 깔끔한 JSON 봉투에 실었습니다. agent loop는 더 이상 50회 반복을 모두 소진하고 출력 없는 `failed` 상태로 끝나지 않습니다 — swarm worker의 검증된 방식을 따라 반복 예산의 80%에서 wrap-up nudge를 주입하고 마지막 반복에서 tool 정의를 제거해 텍스트 답변을 강제합니다 ([#148](https://github.com/HKUDS/Vibe-Trading/pull/148)). 중간에만 발동하도록 가드되어 research-goal 컨텍스트를 밀어내지 않습니다. session 메시지 쓰기는 이제 append마다 `flush + fsync`하여 비싼 AI 응답이 쓰기 도중 크래시에도 살아남고, 읽기 경로는 손상된 JSONL 줄을 건너뛰며(복구용으로 앞 200자 로깅) `/messages` 엔드포인트 전체를 500으로 만들지 않습니다 ([#147](https://github.com/HKUDS/Vibe-Trading/pull/147)). Web 입력창은 IME Enter 처리도 수정해 조합 확정 Enter가 단어 도중에 전송되지 않도록 했습니다 ([#146](https://github.com/HKUDS/Vibe-Trading/pull/146)).
|
||||
- **2026-05-29** 🔐 **Robinhood Agentic Trading 지원(옵트인, 제한된 자율성)**: Robinhood Agentic Trading을 지원합니다(원격 MCP, OAuth). 기본적으로 비활성·읽기 전용이며, 에이전트는 사용자가 커밋한 mandate(종목/주문 규모/익스포저/레버리지/일일 한도) 범위 안에서만 자율 거래합니다. 파일 수준의 즉시 kill switch, 선제적 포지션 청산, mandate 자동 만료, 완전한 감사 원장, 영속 자율 runner를 갖췄습니다. 수탁 없음·거래소 없음 — 자금 보유와 체결은 브로커가 하고, 우리는 의도만 중계합니다. 실험적 / 사용에 따른 책임은 본인에게 있습니다.
|
||||
<details>
|
||||
<summary>이전 뉴스</summary>
|
||||
|
||||
- **2026-05-28** 🧪 **Swarm 안전성 + 엄격 alpha 게이트 + worker MCP**: Swarm DAG가 상위 태스크 실패 시 하위 태스크를 차단합니다 ([#145](https://github.com/HKUDS/Vibe-Trading/pull/145)). 새 `run_bench_strict()`는 IC 게이트 위에 동일 universe 랜덤 컨트롤 + 학습/테스트 OOS 분할을 추가해 시장 beta만 따라가는 가짜 factor를 잡아냅니다 ([#143](https://github.com/HKUDS/Vibe-Trading/pull/143), @Soli22de 감사). Swarm worker는 이제 operator가 설정한 외부 MCP server를 호출할 수 있으며 신뢰 경계는 전용 테스트로 고정되어 있습니다 ([#142](https://github.com/HKUDS/Vibe-Trading/pull/142), @shadowinlife 감사).
|
||||
- **2026-05-27** 📊 **mootdx A주 데이터 소스 + 출력 정리**: 새 `mootdx` loader는 네이티브 通达信 TCP 프로토콜로 A주 OHLCV를 가져옵니다(인증 불필요, IP 속도 제한 없음, 일봉 + 분봉의 25 페이지 walk-back 페이지네이션). fallback chain에서 tushare와 akshare 사이에 위치합니다 ([#107](https://github.com/HKUDS/Vibe-Trading/issues/107)). CCXT loader는 이제 `HTTP_PROXY/HTTPS_PROXY/ALL_PROXY`를 읽어 제한된 네트워크에서 Binance/OKX 공개 데이터를 가져올 수 있습니다 ([#126](https://github.com/HKUDS/Vibe-Trading/pull/126), @ruok808 감사). 최종 답변 렌더링에서도 CLI와 Web의 보기 흉한 전체 너비 `---` 구분자를 제거했습니다: system prompt는 markdown 테이블과 `##` 헤딩 사용을 유도하고, CLI 렌더러는 독립 HR을 defense-in-depth로 제거하며, 채팅 버블은 빠져나온 `<hr>`을 숨깁니다 ([#139](https://github.com/HKUDS/Vibe-Trading/issues/139), @sdwxm188 감사).
|
||||
- **2026-05-26** ✅ **Research Goal lifecycle 폐쇄 루프**: Goal mode가 실제 task runner처럼 동작합니다. Web UI에서 goal을 만들면 session을 생성하거나 바인딩하고 즉시 kickoff turn을 보냅니다. active goal은 Web/API/CLI/MCP에서 continue/edit/cancel/complete할 수 있으며, agent loop는 최초 prompt만이 아니라 현재 goal snapshot(criteria, evidence, claims, open items)을 기준으로 진행합니다. criteria가 covered였지만 goal이 active로 남아 있으면 조용히 멈추지 않고 audit/status update로 들어가며, backend, CLI, MCP, frontend events 회귀로 고정했습니다.
|
||||
|
||||
- **2026-05-25** 🧼 **더 깔끔한 Chat UI + composer 워크플로**: Web UI는 이제 다음 입력에 집중하도록 정리되었습니다. upload, swarm, research-goal 모드는 composer의 `+` 메뉴 뒤로 모이고, floating panel로 채팅을 방해하지 않습니다. 현재 context는 입력창 위 compact chip으로 표시되며, goal 세부 정보는 chip을 클릭할 때만 inline으로 펼쳐집니다. 기존 custom i18n layer도 제거하고 직접 English copy로 통일했습니다. Full Report card는 report-worthy run에만 표시되며, 로컬 dev startup/status reporting도 브라우저 smoke test에 맞게 안정화했습니다.
|
||||
- **2026-05-24** 🎯 **Research Goal runtime**: backend, CLI, API/MCP, SSE, Web UI 전반에 session-scoped Research Goal layer를 추가했습니다. Goal은 claim, acceptance criteria, evidence row, budget, completion policy를 영속화합니다. agent tool은 goal 생성과 evidence 추가를 지원하고, `/goal`은 CLI 진입점이 되었으며, REST/MCP는 goal snapshot과 evidence write를 노출하고, SSE는 chat client 상태를 최신으로 유지합니다. 후속 audit fixes에서는 verified evidence 경계를 잠그고, agent tool의 live-trading risk tier 입력을 차단하며, CLI-created goal을 이후 turn에 연결하고, session 삭제 시 goal ledger를 정리하고, replay-all을 연결하고, frontend cross-session snapshot race를 수정했습니다.
|
||||
- **2026-05-23** 🖥️ **대화형 CLI 새 단장**: 터미널 진입점은 더 큰 Vibe-Trading 배너, 더 깔끔한 prompt 구분선, 이전 턴 요약, 실행 후 소요 시간, Claude Code 스타일 activity rail로 live agent 작업을 보여줍니다. 도구 호출, 웹/데이터 fetch, shell 스타일 동작, Markdown 답변, pipe table은 더 읽기 쉬운 transcript로 렌더링되며, pipe 또는 non-TTY 실행은 자동화에 적합한 plain-text 출력을 유지합니다. 생성된 CLI 스크린샷은 커밋되는 docs 파일이 아니라 local artifact로 처리되어 저장소를 가볍게 유지합니다.
|
||||
- **2026-05-22** 🧭 **Swarm 복구 + MCP keepalive**: Swarm 상태는 이제 읽을 때마다 live task 파일에서 reconcile되므로 API/MCP/SSE/list 뷰가 크래시되었거나 오래된 run을 복구하고 영구 `running` 스냅샷을 보여주지 않습니다. `run_swarm`는 polling 중 MCP progress heartbeat를 보내며, transport drop 이후 재연결한 클라이언트가 handle을 회수할 수 있도록 첫 프레임을 `swarm_started run_id=<id>`로 고정했습니다. worker도 LLM streaming, grounding fetch, tool execution 전 과정에서 heartbeat를 냅니다. stale-run reaper는 run별 임계값을 사용하고 task 상태에서 최종 상태를 도출합니다. `SwarmTool`은 wait budget이 끝났다는 이유만으로 진행 중인 team을 취소하지 않으며, MCP 클라이언트는 `reap_stale_runs()`로 명시적 cleanup을 실행할 수 있습니다. 오늘의 DX pass에서는 provider 기본 모델도 갱신하고 CI syntax check를 새 `agent/cli/` 패키지에 맞췄습니다. 22개의 새 회귀 테스트가 hydrate, 최종 상태 복구, stale reap, keepalive cadence, env parsing, heartbeat wiring을 다루며, 전체 swarm/MCP 스위트는 169 passed, 4 skipped입니다.
|
||||
- **2026-05-21** 🧱 **CLI 패키지 리팩토링**: `agent/cli.py`(3216 LOC)를 `agent/cli/` 패키지로 분할 — 대화형 진입점, 슬래시 라우터, Rich 컴포넌트, 그리고 모든 서브커맨드를 보존하고 `cli.cmd_*` / `cli._INIT_ENV_PATH` / `cli.Confirm` 등 공개 심볼을 재내보내는 `_legacy.py` shim. 새 FastAPI 미들웨어는 브라우저가 `/runs/{id}` 또는 `/correlation`에 직접 접근할 때 SPA 셸을 반환하며, 동일한 좁힘을 Vite dev 프록시에도 반영했습니다. 버전 문자열은 `cli/_version.py` 단일 소스로 통합(`--version`과 배너 드리프트 해결), `python -m cli`는 `__main__.py`로 복원, chat 게이트를 좁혀 `chat --help` / `chat extra`가 REPL에 삼켜지지 않고 레거시 argparse에 도달합니다.
|
||||
- **2026-05-20** 🔬 **Hypothesis Registry CLI**: 2026-05-16에 백엔드만 출시된 Hypothesis Registry의 CLI 측을 완성했습니다. `vibe-trading hypothesis list`는 Rich 테이블 또는 JSON을 출력합니다(`--status` 필터, `--limit` 지원). `show <id>`는 링크된 run card를 포함한 상세 패널을 렌더링합니다. `invalidate <id> --note "..."`는 상태를 `rejected`로 전환하며, `--note`를 생략하면 기존 invalidation notes를 유지합니다. 기존 `VIBE_TRADING_HYPOTHESES_PATH` 환경변수 오버라이드와 호출별 `--path`를 모두 지원합니다. 22개의 새 테스트가 와이어링, JSON 출력, 상태 필터, limit, ID 누락 오류, 노트 영속성을 다룹니다.
|
||||
- **2026-05-19** ✨ **도구 라이브 피드백 + 우아한 취소**: 오래 걸리는 도구(백테스트, 큰 PDF, swarm worker)가 멈춘 것처럼 보이지 않게 되었습니다. 모든 도구 호출은 이제 3초 간격의 하트비트와 구조화된 단계별 진행 상황을 발행합니다 — `run_backtest`는 단계 마커(`validate` / `simulate` / `finalize`), `read_document`는 PDF에서는 페이지 단위, Excel에서는 시트 단위, `read_url`은 `fetch` / `parse`를 표시합니다. CLI의 Rich Live 대시보드는 유니코드 스피너, ASCII 진행 표시줄, ETA를 렌더링하고 도구 이름으로 키된 최대 3개의 병렬 도구를 스택 표시합니다. 프런트엔드 채팅에는 새로운 `ToolProgressIndicator`를 추가했으며, rAF 코얼레싱, ARIA `role="status"` + 스크린 리더용 숨겨진 네이티브 `<progress>`, 총량을 알 때 결정적 `ProgressRing` SVG로 전환합니다. CLI 실행 중 첫 번째 `Ctrl+C`는 이제 `agent.cancel()`을 호출해 우아하게 종료(현재 단계가 끝나고 trace가 깨끗하게 닫힘)하고, 2초 이내 두 번째는 강제 종료합니다. 재사용 가능한 기본 요소도 추출했습니다: `ProgressBar.tsx`와 `lib/tools.ts`(공유 도구 이름 i18n).
|
||||
- **2026-05-18** 🧹 **정리 + 3개의 잠재 버그 수정**: `CompositeEngine`이 거래소 접미사가 없는 중국 선물 코드(`RB2410` 등)를 `GlobalFuturesEngine`으로 잘못 라우팅하던 문제를 수정했습니다. `_is_china_futures`를 공유 `_market_hooks` 모듈로 옮기고, 상품 코드 테이블에 대소문자 정규화 + 비중국 거래소 가드를 추가했으며, 회귀 케이스 9개를 새로 작성했습니다. session FTS5 인덱스가 타임스탬프를 영구 저장하게 되어 크로스 세션 검색에서 날짜 정렬이 가능해졌으며, 동일 변경으로 re-upsert 경로가 `started_at`을 wall-clock으로 덮어쓰던 부수 버그도 해결했습니다. Vite 개발 프록시에 누락되었던 `/alpha`를 추가하여 AlphaZoo 페이지가 `npm run dev`에서 정상 해석됩니다. `tests/test_e2e_harness_v2.py`(실 LLM e2e 스위트)는 `VIBE_TRADING_RUN_LIVE_E2E=1`로 게이트하여 CI가 환경변수 유무에 따라 형태를 바꾸지 않도록 했습니다. ruff에 factor zoo용 `per-file-ignores`를 추가(F401 잡음 3783 → 0)하고, 프런트엔드 tsconfig에 `noUnusedLocals` / `noUnusedParameters`를 활성화해 회귀 가드로 두었으며, `gtja191` alpha 파일들의 사용되지 않는 `vw = vwap(...)` 보일러플레이트 76개도 삭제했습니다. 순 **-918줄**.
|
||||
- **2026-05-17** 🧬 **Alpha Zoo v1 (0.1.8)**: 4개 zoo에 걸친 452개의 사전 빌드된 quant alpha를 번들로 제공 — `qlib158`(Microsoft Qlib의 Alpha158 특성, Apache-2.0 출처 표기), `alpha101`(Kakushadze의 "101 Formulaic Alphas"를 arXiv:1601.00991 논문 부록에서 재구현), `gtja191`(국태군안 2014 단기 거래형 alpha 리서치 보고서), `academic`(Fama-French 5 + Carhart 모멘텀의 가격 기반 proxy 구현). 한 줄 CLI로 임의 universe에서 벤치: `vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025`. AST 순수 함수 게이트, look-ahead 가드 테스트, `pytest-socket` 네트워크 차단, zoo별 LICENSE.md, 커뮤니티 PR용 DCO 서명 워크플로우 포함. Alpha Library 자동 렌더링: [vibetrading.wiki/alpha-library/](https://vibetrading.wiki/alpha-library/), Research Lab 글: [Which of the 191 GTJA alphas still work in 2026?](https://vibetrading.wiki/research-lab/posts/alpha-191-in-2026.html).
|
||||
- **2026-05-16** 🧪 **리서치 기반 업데이트**: backend Hypothesis Registry를 추가해 `create_hypothesis`, `update_hypothesis`, `link_backtest`, `search_hypotheses`를 제공합니다. 외부 콘텐츠 reader는 warning-only `security_warnings`를 붙이고, Shadow Account scanner는 기존 calendar-phase stub 대신 deterministic OHLCV feature evaluation을 사용합니다.
|
||||
- **2026-05-15** 🪪 Run 상세 페이지에서 metrics와 artifacts 옆에 Trust Layer run card를 렌더링해, 2026-05-12에 들어간 `run_card.json` 작업의 UI 측을 마무리합니다. `PersistentMemory.add()`도 #108/#109/#110 triage에 따라 길이, 빈 문자열 또는 공백만으로 이루어진 name, C0/C1 제어 바이트 경로에서 강화되었습니다([#112](https://github.com/HKUDS/Vibe-Trading/pull/112), @Teerapat-Vatpitak 감사합니다).
|
||||
- **2026-05-14** 🌐 공개 Wiki가 [vibetrading.wiki](https://vibetrading.wiki/)에 열렸고, docs, tutorials, Research Lab, Alpha Library 섹션을 Cloudflare Pages로 배포합니다. 영구 메모리도 이제 `vibe-trading memory list/show/search/forget`으로 CLI에서 확인할 수 있으며([#102](https://github.com/HKUDS/Vibe-Trading/pull/102), @Teerapat-Vatpitak 감사합니다), memory tokenization/slug는 태국어, 아랍어, 히브리어, 키릴 문자도 지원합니다([#104](https://github.com/HKUDS/Vibe-Trading/pull/104)).
|
||||
|
||||
- **2026-05-13** 🧭 Swarm 실행은 이제 가져온 시장 데이터로 worker를 grounding하고, 더 깔끔한 영구 리포트를 남깁니다([#93](https://github.com/HKUDS/Vibe-Trading/pull/93), [#84](https://github.com/HKUDS/Vibe-Trading/pull/84)).
|
||||
- **2026-05-12** 🧾 백테스트는 이제 재현 가능한 리서치 실행을 위해 artifacts와 함께 `run_card.json` 및 `run_card.md`를 생성합니다.
|
||||
- **2026-05-11** 🧭 **Memory slug, swarm 집계, CLI 프리플라이트**: 영구 메모리는 파일 slug를 생성할 때 CJK 문자를 보존하여 중국어/일본어/한국어 노트에서 조용한 파일명 충돌이 발생하지 않도록 합니다([#95](https://github.com/HKUDS/Vibe-Trading/pull/95), @voidborne-d 감사합니다). Swarm 실행 합계는 이제 provider가 보고한 token usage를 우선 사용하고 기존 추정 fallback도 유지합니다([#94](https://github.com/HKUDS/Vibe-Trading/pull/94), @Teerapat-Vatpitak 감사합니다). CLI 실행 UI에는 일반적인 환경 문제를 확인하는 시작 프리플라이트 체크도 추가되었습니다([#96](https://github.com/HKUDS/Vibe-Trading/pull/96), @ykykj 감사합니다).
|
||||
- **2026-05-10** 🧱 **회귀 가드레일 + run 메타데이터**: Memory recall은 이제 밑줄을 token 경계로 취급하므로 `mcp_wiring_test` 같은 snake_case 저장 메모리가 "mcp wiring" 같은 자연어 쿼리와 매칭됩니다([#87](https://github.com/HKUDS/Vibe-Trading/pull/87), @hp083625 감사합니다). MCP server에는 initialize → `tools/list` → `tools/call` 경로를 실제 subprocess로 검증하는 smoke test가 추가되어 첫 호출 deadlock 경로를 방지합니다([#86](https://github.com/HKUDS/Vibe-Trading/pull/86)). Windows 경로 민감 테스트, API best-effort 예외 처리, backtest `run_dir` 허용 루트 검증, SwarmRun provider/model 메타데이터에 대한 저위험 강화도 반영되었습니다([#88](https://github.com/HKUDS/Vibe-Trading/pull/88), [#90](https://github.com/HKUDS/Vibe-Trading/pull/90), [#91](https://github.com/HKUDS/Vibe-Trading/pull/91), [#92](https://github.com/HKUDS/Vibe-Trading/pull/92), @Teerapat-Vatpitak 감사합니다).
|
||||
- **2026-05-09** 🛡️ **API 경로 강화 + MCP server 안정성**: API run/session 라우트는 조회 전에 path ID를 검증하여 개행이 포함된 잘못된 파라미터를 거부하고, 이 동작을 auth/security 회귀 테스트에 고정했습니다([#80](https://github.com/HKUDS/Vibe-Trading/pull/80), @SJoon99 감사합니다). MCP server는 `tools/call`을 제공하기 전에 메인 스레드에서 도구 레지스트리를 미리 워밍업하여 lazy tool discovery의 첫 호출 deadlock을 피합니다([#85](https://github.com/HKUDS/Vibe-Trading/pull/85), @Teerapat-Vatpitak 감사합니다). Vite dev proxy도 기본값이 아닌 백엔드 타깃을 위해 `VITE_API_URL`을 존중합니다([#82](https://github.com/HKUDS/Vibe-Trading/pull/82), @voidborne-d 감사합니다).
|
||||
- **2026-05-08** 🧾 **Tushare 재무제표 필드를 필터에 연결**: A주 일간 백테스트에서 `fundamental_fields`를 통해 PIT-safe 재무제표 필드를 요청할 수 있으므로 signal engine은 공시/공개일 이후 `income_total_revenue`, `income_n_income`, `balancesheet_total_hldr_eqy_exc_min_int`, `fina_indicator_roe` 등 테이블 접두사 컬럼으로 선별할 수 있습니다([#76](https://github.com/HKUDS/Vibe-Trading/pull/76), @mrbob-git 감사합니다). 후속 강화로 명시적 재무제표 필드 요청 시 Tushare enrichment가 실행되지 않으면 원시 가격 bar로 조용히 fallback하지 않고 즉시 실패합니다([#77](https://github.com/HKUDS/Vibe-Trading/pull/77)).
|
||||
- **2026-05-07** 📈 **Tushare fundamentals + 커뮤니티 정리**: 펀더멘털 리서치 워크플로를 위해 point-in-time `TushareFundamentalProvider` 계약을 추가하고, 프로젝트 `TUSHARE_TOKEN` 환경 경로를 회귀 테스트로 고정했습니다([#74](https://github.com/HKUDS/Vibe-Trading/pull/74)). 커뮤니티 정리에서는 Vibe-Trading이 당분간 빠른 반복을 위해 하나의 UI 언어에 집중하고, DuckDuckGo 기반 `web_search`가 이미 번들되어 있으므로 중복 검색 의존성을 추가하지 않으며, 비공식 호스팅 배포를 API 키나 데이터 소스 토큰을 입력할 수 있는 신뢰 지점으로 보지 않는다는 점도 명확히 했습니다.
|
||||
- **2026-05-06** 🚀 **v0.1.7 릴리스**([Release notes](https://github.com/HKUDS/Vibe-Trading/releases/tag/v0.1.7), `pip install -U vibe-trading-ai`): 보안 경계 강화가 PyPI와 ClawHub에 게시되었습니다. API/read/upload/file/URL/generated-code/shell-tool/Docker 기본값을 더 안전하게 하면서 localhost CLI/Web UI 워크플로는 낮은 마찰을 유지합니다. 이번 사이클에는 Web UI Settings, 상관관계 히트맵, OpenAI Codex OAuth, A주 pre-ST 필터, 대화형 CLI UX, swarm preset inspection, 배당 분석, 개발 워크플로 개선, 감사된 frontend build dependency 하한도 포함됩니다. 0.1.7 기여자들과 조율된 보안 검증을 도와준 lemi9090 (S2W)에게 감사드립니다.
|
||||
- **2026-05-05** 🛡️ **보안 경계 후속 조치**: 명시적 CORS origin, Settings credential indicator, web URL reading, Shadow Account code generation 주변의 남은 보안 경계 강화를 완료하고 각 경로에 회귀 테스트를 추가했습니다. 일반적인 localhost CLI/Web UI 워크플로는 그대로 유지되며, 원격 배포는 계속 `API_AUTH_KEY`와 명시적인 trusted origin을 사용해야 합니다.
|
||||
- **2026-05-04** 🖥️ **대화형 CLI UX + CI 정리**: 대화형 모드에 provider/model, 세션 시간, 직전 실행 latency, 누적 tool-call 통계를 보여주는 live bottom status bar가 추가되었고, `prompt_toolkit`을 통해 방향키 기반 prompt history 탐색과 cursor editing을 지원합니다([#69](https://github.com/HKUDS/Vibe-Trading/pull/69)). `prompt_toolkit` 또는 TTY를 사용할 수 없으면 CLI는 여전히 Rich prompt로 fallback합니다. 강화된 file-import sandbox와 cross-platform `/tmp` 해석에 맞춰 CI path expectation도 정렬되어 main이 다시 green 상태가 되었습니다([`bb67dc7`](https://github.com/HKUDS/Vibe-Trading/commit/bb67dc7cfcc11553c57d8962bee56381dca43758)).
|
||||
- **2026-05-03** 🛡️ **보안 강화 패치**: 비로컬 배포의 기본 API 인증을 강화하고, 민감한 run/session/swarm read를 보호하며, upload와 local file-reading 경계를 제한하고, shell-capable tool을 entry point별로 제어하며, 생성 전략을 import 전에 검증하고, Docker image를 기본적으로 non-root 사용자와 localhost-only published port로 실행합니다. Local CLI와 localhost Web UI 워크플로는 낮은 마찰을 유지하며, 원격 API/Web 배포는 `API_AUTH_KEY`를 설정해야 합니다.
|
||||
- **2026-05-02** 🧭 **배당 분석 + 더 선명한 로드맵**: income stock, payout sustainability, dividend growth, shareholder yield, ex-dividend mechanics, yield-trap check를 위한 `dividend-analysis` 스킬을 추가하고 bundled-skill 회귀 테스트로 고정했습니다. 공개 로드맵은 Research Autopilot, Data Bridge, Options Lab, Portfolio Studio, Alpha Zoo, Research Delivery, Trust Layer, Community sharing에 집중하도록 정리되었습니다.
|
||||
- **2026-05-01** 🔥 **상관관계 히트맵 + OpenAI Codex OAuth + A주 pre-ST 필터**: 새 correlation dashboard/API가 rolling return correlation을 계산하고 포트폴리오 및 종목 분석용 ECharts heatmap을 렌더링합니다([#64](https://github.com/HKUDS/Vibe-Trading/pull/64)). OpenAI Codex provider support는 이제 `vibe-trading provider login openai-codex`를 통해 ChatGPT OAuth를 사용하며, Settings metadata와 adapter regression test가 포함됩니다([#65](https://github.com/HKUDS/Vibe-Trading/pull/65)). A주 ST/*ST 리스크 스크리닝용 `ashare-pre-st-filter` 스킬도 추가 및 강화되었고, Sina penalty relevance filtering으로 securities-account 언급이 E2 count를 부풀리지 않도록 했습니다([#63](https://github.com/HKUDS/Vibe-Trading/pull/63)).
|
||||
- **2026-04-30** ⚙️ **Web UI Settings + validation CLI 강화**: LLM provider/model, base URL, reasoning effort, data source credential을 위한 새 Settings page가 추가되었고, local/auth-protected settings API와 data-driven provider metadata가 이를 뒷받침합니다([#57](https://github.com/HKUDS/Vibe-Trading/pull/57)). 또한 `python -m backtest.validation <run_dir>`가 missing, blank, malformed, non-existent, non-directory input을 validation 시작 전에 operator-facing message로 명확히 실패하도록 강화했습니다([#60](https://github.com/HKUDS/Vibe-Trading/pull/60)).
|
||||
- **2026-04-28** 🚀 **v0.1.6 릴리스**(`pip install -U vibe-trading-ai`): `pip install` / `uv tool install` 이후 `vibe-trading --swarm-presets`가 비어 있던 문제를 수정했습니다([#55](https://github.com/HKUDS/Vibe-Trading/issues/55)). preset YAML은 이제 `src.swarm` 패키지 내부에 번들되며 6개 테스트 회귀 suite로 고정됩니다. AKShare loader도 ETF(`510300.SH`)와 forex(`USDCNH`)를 올바른 endpoint로 routing하고 registry fallback을 강화했습니다. v0.1.5 이후의 benchmark comparison panel, `/upload` streaming + size limit, Futu loader(HK + A주), vnpy export skill, security hardening, frontend lazy loading(688KB → 262KB)을 모두 포함합니다.
|
||||
- **2026-04-27** 📊 **벤치마크 패널 + 업로드 안전성**: 백테스트 출력에 yfinance 기반 SPY, CSI 300 등 resolution을 사용하는 benchmark comparison panel(ticker / benchmark return / excess return / information ratio)이 포함됩니다([#48](https://github.com/HKUDS/Vibe-Trading/issues/48)). 또한 `/upload`는 request body를 1MB chunk로 streaming하고 `MAX_UPLOAD_SIZE` 초과 시 중단하여 oversized/malformed client에서도 메모리를 제한합니다([#53](https://github.com/HKUDS/Vibe-Trading/pull/53)). 4-case regression suite로 고정되었습니다.
|
||||
- **2026-04-22** 🛡️ **하드닝 + 신규 통합**: `safe_path`와 journal/shadow tool sandbox에서 path containment를 강제하고, `MANIFEST.in`이 sdist에 `.env.example` / tests / Docker files를 포함하며, route-level lazy loading으로 frontend initial bundle을 688KB → 262KB로 줄였습니다. Futu data loader for HK & A-share equities([#47](https://github.com/HKUDS/Vibe-Trading/pull/47))와 vnpy CtaTemplate export skill([#46](https://github.com/HKUDS/Vibe-Trading/pull/46))도 추가되었습니다.
|
||||
- **2026-04-21** 🛡️ **워크스페이스 + 문서**: 상대 `run_dir`이 active run dir로 정규화되었습니다([#43](https://github.com/HKUDS/Vibe-Trading/pull/43)). README usage example도 추가되었습니다([#45](https://github.com/HKUDS/Vibe-Trading/pull/45)).
|
||||
- **2026-04-20** 🔌 **Reasoning + Swarm**: 모든 `ChatOpenAI` 경로에서 `reasoning_content`가 보존되어 Kimi / DeepSeek / Qwen thinking이 end-to-end로 작동합니다([#39](https://github.com/HKUDS/Vibe-Trading/issues/39)). Swarm streaming과 깔끔한 Ctrl+C 처리도 반영되었습니다([#42](https://github.com/HKUDS/Vibe-Trading/issues/42)).
|
||||
- **2026-04-19** 📦 **v0.1.5**: PyPI와 ClawHub에 게시되었습니다. `python-multipart` CVE floor bump, 신규 MCP tools 5개 연결(`analyze_trade_journal` + 4 shadow-account tools), `pattern_recognition` → `pattern` registry fix, Docker dependency parity, SKILL manifest sync(22 MCP tools / 71 skills)가 포함됩니다.
|
||||
- **2026-04-18** 👥 **Shadow Account**: broker journal에서 전략 규칙 추출 → 여러 시장에서 shadow backtest → 규칙 위반, 조기 청산, 놓친 signal, counterfactual trade를 통해 정확히 얼마를 놓치는지 보여주는 8-section HTML/PDF report. 신규 tools 4개, skill 1개, 총 tools 32개. Trade Journal + Shadow Account sample은 이제 web UI welcome screen에 있습니다.
|
||||
- **2026-04-17** 📊 **Trade Journal Analyzer + Universal File Reader**: broker export(同花顺/东财/富途/generic CSV) 업로드 → auto trading profile(holding days, win rate, PnL ratio, drawdown) + 4가지 bias diagnostics(disposition effect, overtrading, chasing momentum, anchoring). `read_document`는 이제 PDF, Word, Excel, PowerPoint, image(OCR), 40+ text format을 하나의 unified call로 dispatch합니다.
|
||||
- **2026-04-16** 🧠 **Agent Harness**: persistent cross-session memory, FTS5 session search, self-evolving skills(full CRUD), 5-layer context compression, read/write tool batching. tools 27개, 신규 tests 107개.
|
||||
- **2026-04-15** 🤖 **Z.ai + MiniMax**: Z.ai provider([#35](https://github.com/HKUDS/Vibe-Trading/pull/35)), MiniMax temperature fix + model update([#33](https://github.com/HKUDS/Vibe-Trading/pull/33)). providers 13개.
|
||||
- **2026-04-14** 🔧 **MCP 안정성**: stdio transport에서 backtest tool `Connection closed` error를 수정했습니다([#32](https://github.com/HKUDS/Vibe-Trading/pull/32)).
|
||||
- **2026-04-13** 🌐 **Cross-Market Composite Backtest**: 새 `CompositeEngine`이 A주 + crypto 같은 mixed-market portfolio를 shared capital pool과 per-market rule로 backtest합니다. swarm template variable fallback과 frontend timeout도 수정되었습니다.
|
||||
- **2026-04-12** 🌍 **Multi-Platform Export**: `/pine`은 TradingView(Pine Script v6), TDX(通达信/同花顺/东方财富), MetaTrader 5(MQL5)로 전략을 한 번에 내보냅니다.
|
||||
- **2026-04-11** 🛡️ **Reliability & DX**: `vibe-trading init` .env bootstrap([#19](https://github.com/HKUDS/Vibe-Trading/pull/19)), preflight checks, runtime data-source fallback, hardened backtest engine. Multi-language README([#21](https://github.com/HKUDS/Vibe-Trading/pull/21)).
|
||||
- **2026-04-10** 📦 **v0.1.4**: Docker fix([#8](https://github.com/HKUDS/Vibe-Trading/issues/8)), `web_search` MCP tool, LLM providers 12개, `akshare`/`ccxt` dependencies. PyPI와 ClawHub에 게시되었습니다.
|
||||
- **2026-04-09** 📊 **Backtest Wave 2**: ChinaFutures, GlobalFutures, Forex, Options v2 engines. Monte Carlo, Bootstrap CI, Walk-Forward validation.
|
||||
- **2026-04-08** 🔧 **Multi-market backtest** with per-market rules, Pine Script v6 export, 5 data sources with auto-fallback.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## ✨ 주요 기능
|
||||
|
||||
<div align="center">
|
||||
<table align="center" width="94%" style="width:94%; margin-left:auto; margin-right:auto;">
|
||||
<tr>
|
||||
<td align="center" width="50%" valign="top">
|
||||
<img src="assets/feature-self-improving-trading-agent.png" height="130" alt="자가 개선 트레이딩 에이전트"/><br>
|
||||
<h3>🔍 자가 개선 트레이딩 에이전트</h3>
|
||||
<div align="left">
|
||||
• 자연어 기반 시장 리서치<br>
|
||||
• 전략 초안 작성 및 파일/웹 분석<br>
|
||||
• 메모리 기반 워크플로
|
||||
</div>
|
||||
</td>
|
||||
<td align="center" width="50%" valign="top">
|
||||
<img src="assets/feature-multi-agent-trading-teams.png" height="130" alt="멀티 에이전트 트레이딩 팀"/><br>
|
||||
<h3>🐝 멀티 에이전트 트레이딩 팀</h3>
|
||||
<div align="left">
|
||||
• 투자, 퀀트, 크립토, 리스크 팀<br>
|
||||
• 스트리밍 진행 상황과 영구 저장 리포트<br>
|
||||
• 가져온 시장 데이터로 grounding된 worker
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="50%" valign="top">
|
||||
<img src="assets/feature-cross-market-data-backtesting.png" height="130" alt="크로스마켓 데이터와 백테스팅"/><br>
|
||||
<h3>📊 크로스마켓 데이터 & 백테스팅</h3>
|
||||
<div align="left">
|
||||
• A/HK/US 주식, 크립토, 선물, 외환<br>
|
||||
• 데이터 fallback과 composite backtest<br>
|
||||
• PIT 데이터, 검증, run card
|
||||
</div>
|
||||
</td>
|
||||
<td align="center" width="50%" valign="top">
|
||||
<img src="assets/feature-shadow-account.png" height="130" alt="Shadow Account"/><br>
|
||||
<h3>👥 Shadow Account</h3>
|
||||
<div align="left">
|
||||
• 브로커 거래 일지 행동 진단<br>
|
||||
• 규칙 기반 Shadow Account 비교<br>
|
||||
• 내보낼 수 있는 감사 리포트와 전략 코드
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## 💡 Vibe-Trading이란?
|
||||
|
||||
Vibe-Trading은 금융 질문을 실행 가능한 분석으로 바꾸는 오픈소스 리서치 워크스페이스입니다. 자연어 프롬프트를 시장 데이터 로더, 전략 생성, 백테스트 엔진, 리포트, 내보내기, 영구 리서치 메모리와 연결합니다.
|
||||
|
||||
리서치, 시뮬레이션, 백테스팅을 위해 설계되었습니다 — 그리고 원하신다면, 사용자가 직접 인가한 브로커(예: Robinhood Agentic Trading)를 통한 자율 거래도 가능합니다. 자금을 일절 보유하지 않고, 사용자가 설정한 한도를 결코 넘지 않으며, 언제든 즉시 중단할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## ✨ 무엇을 할 수 있나요?
|
||||
|
||||
| 작업 | 출력 |
|
||||
|------|------|
|
||||
| **트레이딩 질문하기** | 도구, 데이터, 문서, 재사용 가능한 세션 컨텍스트를 활용한 시장 리서치. |
|
||||
| **전략 아이디어 백테스트** | 전략 코드, 지표, 벤치마크 컨텍스트, 검증 artifacts, run cards. |
|
||||
| **내 거래 검토하기** | 브로커 일지 파싱, 행동 진단, 규칙 추출, Shadow Account 비교. |
|
||||
| **반복 리서치 개선하기** | 영구 메모리와 편집 가능한 스킬로 유용한 루틴을 재사용 가능한 워크플로로 전환. |
|
||||
| **애널리스트 팀 실행하기** | 투자, 퀀트, 크립토, 매크로, 리스크 워크플로를 위한 멀티 에이전트 리서치 리뷰. |
|
||||
| **사용 가능한 artifacts 만들기** | 리포트, TradingView Pine Script, TDX, MetaTrader 5, MCP tools, 이후 리서치 세션. |
|
||||
| **사전 빌드된 alpha zoo 벤치** | 452개의 alpha 인자(Qlib 158 + Kakushadze 101 + GTJA 191 + FF5 + Carhart)에 대해 한 줄 CLI로 IC + IR + alive/reversed/dead 분류 수행 |
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 빠른 예제
|
||||
|
||||
```bash
|
||||
pip install vibe-trading-ai
|
||||
|
||||
# 자연어 리서치
|
||||
vibe-trading run -p "Backtest a BTC-USDT 20/50 moving-average strategy for 2024, summarize return and drawdown, then export the report"
|
||||
|
||||
# 한 줄로 사전 빌드된 alpha zoo 벤치
|
||||
vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025 --top 20
|
||||
```
|
||||
|
||||
```bash
|
||||
vibe-trading --upload trades_export.csv
|
||||
vibe-trading run -p "Analyze my trading behavior, extract my shadow strategy, and compare it with my actual trades"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 👥 섀도우 계정
|
||||
|
||||
Shadow Account는 일반적인 전략 템플릿이 아니라 사용자의 실제 거래 기록에서 시작합니다.
|
||||
|
||||
브로커 export를 업로드하고 에이전트가 행동을 요약하게 한 뒤, 실제 거래 경로를 규칙 기반 shadow strategy와 비교합니다.
|
||||
|
||||
| 단계 | 에이전트 출력 |
|
||||
|------|--------------|
|
||||
| **1. 일지 읽기** | 同花顺, 东方财富, 富途, generic CSV 형식의 브로커 export를 파싱합니다. |
|
||||
| **2. 행동 프로파일링** | 보유 일수, 승률, 손익비, drawdown, disposition effect, overtrading, momentum chasing, anchoring 점검. |
|
||||
| **3. 규칙 추출** | 반복되는 진입/청산을 모호한 요약이 아닌 명시적인 strategy profile로 변환합니다. |
|
||||
| **4. Shadow 실행** | 추출된 규칙을 백테스트하고 규칙 위반, 조기 청산, 놓친 signal, 대안 거래 경로를 강조합니다. |
|
||||
| **5. 리포트 제공** | 나중에 점검, 보관, 개선할 수 있는 HTML/PDF 리포트를 생성합니다. |
|
||||
|
||||
```bash
|
||||
vibe-trading --upload trades_export.csv
|
||||
vibe-trading run -p "Analyze my trading behavior, extract my shadow strategy, and compare it with my actual trades"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 리서치 워크플로
|
||||
|
||||
대부분의 실행은 같은 evidence path를 따릅니다. 요청을 라우팅하고, 적절한 시장 컨텍스트를 로드하고, 도구를 실행하고, 출력을 검증하며, artifacts를 점검 가능한 상태로 유지합니다.
|
||||
|
||||
| 계층 | 수행 내용 |
|
||||
|------|-----------|
|
||||
| **Plan** | 유용한 경우 관련 finance skills, tools, data sources, swarm preset을 선택합니다. |
|
||||
| **Ground** | 사용 가능한 loader로 A주, HK/US 주식, 크립토, 선물, 외환, 문서, 웹 컨텍스트를 가져옵니다. |
|
||||
| **Execute** | 테스트 가능한 전략 코드를 생성하고, 도구를 실행하며, 적절한 backtest engine 또는 analysis workflow를 사용합니다. |
|
||||
| **Validate** | 지표, benchmark comparison, Monte Carlo, Bootstrap, Walk-Forward, run cards, 관련 warning을 추가합니다. |
|
||||
| **Deliver** | TradingView, TDX, MetaTrader 5, MCP client, 이후 세션을 위한 리포트, artifacts, tool traces, exports를 반환합니다. |
|
||||
|
||||
---
|
||||
|
||||
## 🔩 상세 기능
|
||||
|
||||
메인 README를 읽기 쉽게 유지하기 위해 상세 목록은 아래에 접어 두었습니다. 사용 가능한 구성 요소를 확인하고 싶을 때 열어보세요.
|
||||
|
||||
<details>
|
||||
<summary><b>금융 스킬 라이브러리</b> <sub>8개 카테고리 77개 스킬</sub></summary>
|
||||
|
||||
- 📊 8개 카테고리로 구성된 77개 전문 금융 스킬
|
||||
- 🌐 전통 시장부터 크립토 & DeFi까지 완전한 커버리지
|
||||
- 🔬 데이터 sourcing부터 정량 리서치까지 포괄하는 기능
|
||||
|
||||
| 카테고리 | 스킬 | 예시 |
|
||||
|----------|------|------|
|
||||
| Data Source | 7 | `data-routing`, `tushare`, `yfinance`, `okx-market`, `akshare`, `mootdx`, `ccxt` |
|
||||
| Strategy | 17 | `strategy-generate`, `cross-market-strategy`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
|
||||
| Analysis | 17 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis`, `dividend-analysis` |
|
||||
| Asset Class | 9 | `options-strategy`, `options-advanced`, `convertible-bond`, `etf-analysis`, `asset-allocation`, `sector-rotation` |
|
||||
| Crypto | 7 | `perp-funding-basis`, `liquidation-heatmap`, `stablecoin-flow`, `defi-yield`, `onchain-analysis` |
|
||||
| Flow | 7 | `hk-connect-flow`, `us-etf-flow`, `edgar-sec-filings`, `financial-statement`, `adr-hshare` |
|
||||
| Tool | 11 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader`, `vnpy-export`, `alpha-zoo` |
|
||||
| Risk Analysis | 1 | `ashare-pre-st-filter` |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>프리셋 트레이딩 팀</b> <sub>29개 swarm preset</sub></summary>
|
||||
|
||||
- 🏢 바로 사용할 수 있는 29개 에이전트 팀
|
||||
- ⚡ 사전 구성된 금융 워크플로
|
||||
- 🎯 투자, 트레이딩, 리스크 관리 preset
|
||||
|
||||
| 프리셋 | 워크플로 |
|
||||
|--------|----------|
|
||||
| `investment_committee` | bull/bear 토론 → 리스크 리뷰 → PM 최종 판단 |
|
||||
| `global_equities_desk` | A주 + HK/US + 크립토 리서처 → 글로벌 전략가 |
|
||||
| `crypto_trading_desk` | funding/basis + liquidation + flow → 리스크 매니저 |
|
||||
| `earnings_research_desk` | 펀더멘털 + revision + options → 실적 전략가 |
|
||||
| `macro_rates_fx_desk` | rates + FX + commodity → macro PM |
|
||||
| `quant_strategy_desk` | screening + factor research → backtest → risk audit |
|
||||
| `technical_analysis_panel` | classic TA + Ichimoku + harmonic + Elliott + SMC → consensus |
|
||||
| `risk_committee` | drawdown + tail risk + regime review → sign-off |
|
||||
| `global_allocation_committee` | A주 + 크립토 + HK/US → cross-market allocation |
|
||||
|
||||
<sub>추가로 20개 이상의 전문 preset이 있습니다. 전체 목록은 vibe-trading --swarm-presets로 확인하세요.
|
||||
|
||||
</sub>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Alpha Zoo</b> <sub>4개 zoo에 걸친 452개 사전 빌드된 quant alpha</sub></summary>
|
||||
|
||||
- 🧬 operator 계층에서 lookahead가 금지된 452개 cross-sectional alpha
|
||||
- 📈 한 줄 CLI로 IC + IR + alive/reversed/dead 분류 수행
|
||||
- 🔬 AST 순수성 게이트 + 300-row lookahead sentinel 테스트 + `pytest-socket` 네트워크 kill-switch
|
||||
- 📦 Qlib에 대한 Apache-2 출처 표기, zoo별 `LICENSE.md`에서 수식을 수학적 콘텐츠로 명시
|
||||
- 🤝 커뮤니티 PR을 위한 Developer Certificate of Origin (DCO) 서명 워크플로
|
||||
|
||||
| Zoo | Count | Source | License |
|
||||
|-----|-------|--------|---------|
|
||||
| **qlib158** | 154 | Microsoft Qlib `Alpha158` (Apache-2.0, 커밋 고정) | Apache-2.0 |
|
||||
| **alpha101** | 101 | Kakushadze (2015), "101 Formulaic Alphas", arXiv:1601.00991 | 수식은 수학적 콘텐츠 |
|
||||
| **gtja191** | 191 | 국태군안 (2014), "191 Short-period Trading Alpha Factors" | 수식은 수학적 콘텐츠 |
|
||||
| **academic** | 6 | Fama-French 5 + Carhart 모멘텀 (가격 기반 proxy) | 공개 학술 문헌 |
|
||||
|
||||
`vibe-trading alpha list`로 카탈로그를 탐색하고, `vibe-trading alpha show <id>`로 수식과 소스 코드를 확인하며, `vibe-trading alpha bench --zoo X --universe Y --period Z`로 zoo 전체를 점수화하세요.
|
||||
|
||||
</details>
|
||||
|
||||
## 🎬 데모
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
https://github.com/user-attachments/assets/4e4dcb80-7358-4b9a-92f0-1e29612e6e86
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
https://github.com/user-attachments/assets/3754a414-c3ee-464f-b1e8-78e1a74fbd30
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" align="center"><sub>☝️ 자연어 백테스트 & 멀티 에이전트 swarm 토론 — Web UI + CLI</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 🚀 빠른 시작
|
||||
|
||||
### 한 줄 설치 (PyPI)
|
||||
|
||||
```bash
|
||||
pip install vibe-trading-ai
|
||||
```
|
||||
|
||||
첫 리서치 작업을 실행하세요:
|
||||
|
||||
```bash
|
||||
vibe-trading init
|
||||
vibe-trading run -p "Backtest a BTC-USDT 20/50 moving-average strategy for 2024 and summarize return and drawdown"
|
||||
```
|
||||
|
||||
> **패키지 이름 vs 명령:** PyPI 패키지는 `vibe-trading-ai`입니다. 설치하면 세 가지 명령을 사용할 수 있습니다:
|
||||
>
|
||||
> | Command | Purpose |
|
||||
> |---------|---------|
|
||||
> | `vibe-trading` | 대화형 CLI / TUI |
|
||||
> | `vibe-trading serve` | FastAPI 웹 서버 실행 |
|
||||
> | `vibe-trading-mcp` | MCP 서버 시작(Claude Desktop, OpenClaw, Cursor 등) |
|
||||
|
||||
```bash
|
||||
vibe-trading init # interactive .env setup
|
||||
vibe-trading # launch CLI
|
||||
vibe-trading serve --port 8899 # launch web UI
|
||||
vibe-trading-mcp # start MCP server (stdio)
|
||||
```
|
||||
|
||||
### 또는 경로 선택
|
||||
|
||||
| 경로 | 적합한 용도 | 시간 |
|
||||
|------|-------------|------|
|
||||
| **A. Docker** | 즉시 체험, 로컬 설정 없음 | 2분 |
|
||||
| **B. Local install** | 개발, 전체 CLI 접근 | 5분 |
|
||||
| **C. MCP plugin** | 기존 에이전트에 연결 | 3분 |
|
||||
| **D. ClawHub** | 한 번의 명령, clone 불필요 | 1분 |
|
||||
|
||||
### 사전 요구사항
|
||||
|
||||
- 지원 provider 중 하나의 **LLM API key** 또는 **Ollama** 로컬 실행(key 불필요)
|
||||
- 경로 B용 **Python 3.11+**
|
||||
- 경로 A용 **Docker**
|
||||
- OpenAI Codex도 ChatGPT OAuth로 사용할 수 있습니다. `LANGCHAIN_PROVIDER=openai-codex`를 설정한 뒤 `vibe-trading provider login openai-codex`를 실행하세요. 이 방식은 `OPENAI_API_KEY`를 사용하지 않습니다.
|
||||
|
||||
> **지원 LLM provider:** OpenRouter, OpenAI, DeepSeek, Gemini, Groq, DashScope/Qwen, Zhipu, Moonshot/Kimi, MiniMax, Xiaomi MIMO, Z.ai, Ollama(local). 설정은 `.env.example`을 참고하세요.
|
||||
|
||||
> **팁:** 자동 fallback 덕분에 모든 시장은 API key 없이도 작동합니다. yfinance(HK/US), OKX(crypto), mootdx(A주, TCP 직결, IP 제한 없음), AKShare(A주, US, HK, futures, forex)는 모두 무료입니다. Tushare token은 선택 사항이며, mootdx가 권장 no-token A주 fallback이고 AKShare는 더 넓은 커버리지의 백업입니다.
|
||||
|
||||
### 경로 A: Docker (설정 불필요)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/Vibe-Trading.git
|
||||
cd Vibe-Trading
|
||||
cp agent/.env.example agent/.env
|
||||
# Edit agent/.env — uncomment your LLM provider and set API key
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
`http://localhost:8899`를 여세요. Backend + frontend가 하나의 container에 들어 있습니다.
|
||||
|
||||
Docker는 기본적으로 backend를 `127.0.0.1:8899`에 게시하고 앱을 non-root container user로 실행합니다. API를 자신의 머신 밖으로 의도적으로 노출하는 경우 강력한 `API_AUTH_KEY`를 설정하고 client에서 `Authorization: Bearer <key>`를 보내세요.
|
||||
|
||||
### 경로 B: Local install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/Vibe-Trading.git
|
||||
cd Vibe-Trading
|
||||
python -m venv .venv
|
||||
|
||||
# Activate
|
||||
source .venv/bin/activate # Linux / macOS
|
||||
# .venv\Scripts\Activate.ps1 # Windows PowerShell
|
||||
|
||||
pip install -e .
|
||||
cp agent/.env.example agent/.env # Edit — set your LLM provider API key
|
||||
vibe-trading # Launch interactive TUI
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>웹 UI 시작(선택 사항)</b></summary>
|
||||
|
||||
```bash
|
||||
# Terminal 1: API server
|
||||
vibe-trading serve --port 8899
|
||||
|
||||
# Terminal 2: Frontend dev server
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
`http://localhost:5899`를 여세요. Frontend는 API 호출을 `localhost:8899`로 proxy합니다.
|
||||
|
||||
**Production mode(single server):**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build && cd ..
|
||||
vibe-trading serve --port 8899 # FastAPI serves dist/ as static files
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### 경로 C: MCP plugin
|
||||
|
||||
아래 [MCP Plugin](#-mcp-plugin) 섹션을 참고하세요.
|
||||
|
||||
### 경로 D: ClawHub (한 번의 명령)
|
||||
|
||||
```bash
|
||||
npx clawhub@latest install vibe-trading --force
|
||||
```
|
||||
|
||||
skill + MCP config가 agent의 skills directory에 다운로드됩니다. 자세한 내용은 [ClawHub install](#-mcp-plugin)을 참고하세요.
|
||||
|
||||
---
|
||||
|
||||
## 🧠 환경 변수
|
||||
|
||||
`agent/.env.example`을 `agent/.env`로 복사하고 사용할 provider block의 주석을 해제하세요. 각 provider에는 3~4개의 변수가 필요합니다:
|
||||
|
||||
| 변수 | 필수 | 설명 |
|
||||
|------|:----:|------|
|
||||
| `LANGCHAIN_PROVIDER` | Yes | Provider name(`openrouter`, `deepseek`, `groq`, `ollama` 등) |
|
||||
| `<PROVIDER>_API_KEY` | Yes* | API key(`OPENROUTER_API_KEY`, `DEEPSEEK_API_KEY` 등) |
|
||||
| `<PROVIDER>_BASE_URL` | Yes | API endpoint URL |
|
||||
| `LANGCHAIN_MODEL_NAME` | Yes | Model name(예: `deepseek-v4-pro`) |
|
||||
| `TUSHARE_TOKEN` | No | A주 data용 Tushare Pro token(AKShare로 fallback) |
|
||||
| `TIMEOUT_SECONDS` | No | LLM call timeout, 기본 120s |
|
||||
| `API_AUTH_KEY` | 네트워크 배포 권장 | API가 non-local client에서 접근 가능할 때 필요한 Bearer token |
|
||||
| `VIBE_TRADING_ENABLE_SHELL_TOOLS` | No | remote API/MCP-SSE 형태 배포에서 shell-capable tools 명시적 opt-in |
|
||||
| `VIBE_TRADING_ALLOWED_FILE_ROOTS` | No | document와 broker-journal import용 추가 comma-separated roots |
|
||||
| `VIBE_TRADING_ALLOWED_RUN_ROOTS` | No | generated-code run directory용 추가 comma-separated roots |
|
||||
|
||||
<sub>* Ollama는 API key가 필요 없습니다. OpenAI Codex는 ChatGPT OAuth를 사용하며 token을 `agent/.env`가 아니라 `oauth-cli-kit`을 통해 저장합니다.</sub>
|
||||
|
||||
**무료 데이터(key 불필요):** AKShare를 통한 A주, yfinance를 통한 HK/US equities, OKX를 통한 crypto, CCXT를 통한 100개 이상 crypto exchanges. 시스템은 시장별로 가장 적합한 source를 자동 선택합니다.
|
||||
|
||||
### 🎯 권장 모델
|
||||
|
||||
Vibe-Trading은 tool-heavy agent입니다. skills, backtests, memory, swarms가 모두 tool call을 통해 흐릅니다. 모델 선택은 에이전트가 실제로 *도구를 사용하는지*, 아니면 학습 데이터에서 답을 만들어내는지를 직접 결정합니다.
|
||||
|
||||
| 등급 | 예시 | 사용 시점 |
|
||||
|------|------|-----------|
|
||||
| **Best** | `anthropic/claude-opus-4.7`, `anthropic/claude-sonnet-4.6`, `openai/gpt-5.5-pro`, `google/gemini-3.5-flash` | 복잡한 swarms(3+ agents), 긴 리서치 세션, 논문급 분석 |
|
||||
| **Sweet spot**(기본값) | `deepseek-v4-pro`, `deepseek/deepseek-v4-pro`, `x-ai/grok-4.20`, `z-ai/glm-5.1`, `moonshotai/kimi-k2.6`, `qwen/qwen3-max-thinking` | Daily driver — 약 1/10 비용으로 안정적인 tool-calling |
|
||||
| **Agent 사용 시 피할 것** | `*-nano`, `*-flash-lite`, `*-coder-next`, small / distilled variants | tool-calling이 불안정합니다. agent가 skills를 로드하거나 backtest를 실행하는 대신 "기억에서 답하는" 것처럼 보일 수 있습니다. |
|
||||
|
||||
기본 `agent/.env.example`은 DeepSeek official API + `deepseek-v4-pro`를 포함합니다. OpenRouter 사용자는 `deepseek/deepseek-v4-pro`를 사용할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 🖥 CLI 참조
|
||||
|
||||
```bash
|
||||
vibe-trading # interactive TUI
|
||||
vibe-trading run -p "..." # single run
|
||||
vibe-trading serve # API server
|
||||
vibe-trading alpha list # 사전 빌드된 452개 alpha 탐색; show / bench / compare / export-manifest 서브커맨드 사용 가능
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>TUI 내 slash commands</b></summary>
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/help` | 모든 명령 표시 |
|
||||
| `/skills` | 77개 finance skills 목록 |
|
||||
| `/swarm` | 29개 swarm team presets 목록 |
|
||||
| `/swarm run <preset> [vars_json]` | live streaming으로 swarm team 실행 |
|
||||
| `/swarm list` | Swarm run history |
|
||||
| `/swarm show <run_id>` | Swarm run details |
|
||||
| `/swarm cancel <run_id>` | 실행 중인 swarm 취소 |
|
||||
| `/list` | Recent runs |
|
||||
| `/show <run_id>` | Run details + metrics |
|
||||
| `/code <run_id>` | Generated strategy code |
|
||||
| `/pine <run_id>` | indicators export(TradingView + TDX + MT5) |
|
||||
| `/trace <run_id>` | Full execution replay |
|
||||
| `/continue <run_id> <prompt>` | 새 instruction으로 run 계속 |
|
||||
| `/sessions` | Chat sessions 목록 |
|
||||
| `/settings` | Runtime config 표시 |
|
||||
| `/clear` | 화면 지우기 |
|
||||
| `/quit` | 종료 |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Single run & flags</b></summary>
|
||||
|
||||
```bash
|
||||
vibe-trading run -p "Backtest BTC-USDT MACD strategy, last 30 days"
|
||||
vibe-trading run -p "Analyze AAPL momentum" --json
|
||||
vibe-trading run -f strategy.txt
|
||||
echo "Backtest 000001.SZ RSI" | vibe-trading run
|
||||
```
|
||||
|
||||
```bash
|
||||
vibe-trading -p "your prompt"
|
||||
vibe-trading --skills
|
||||
vibe-trading --swarm-presets
|
||||
vibe-trading --swarm-run investment_committee '{"topic":"BTC outlook"}'
|
||||
vibe-trading --list
|
||||
vibe-trading --show <run_id>
|
||||
vibe-trading --code <run_id>
|
||||
vibe-trading --pine <run_id> # Export indicators (TradingView + TDX + MT5)
|
||||
vibe-trading --trace <run_id>
|
||||
vibe-trading --continue <run_id> "refine the strategy"
|
||||
vibe-trading --upload report.pdf
|
||||
```
|
||||
|
||||
```bash
|
||||
vibe-trading alpha list --zoo gtja191 --limit 10
|
||||
vibe-trading alpha show gtja191_171
|
||||
vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025 --top 20
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 💡 예제
|
||||
|
||||
### 전략 & 백테스팅
|
||||
|
||||
```bash
|
||||
# Moving average crossover on US equities
|
||||
vibe-trading run -p "Backtest a 20/50-day moving average crossover on AAPL for the past year, show Sharpe ratio and max drawdown"
|
||||
|
||||
# RSI mean-reversion on crypto
|
||||
vibe-trading run -p "Test RSI(14) mean-reversion on BTC-USDT: buy below 30, sell above 70, last 6 months"
|
||||
|
||||
# Multi-factor strategy on A-shares
|
||||
vibe-trading run -p "Backtest a momentum + value + quality multi-factor strategy on CSI 300 constituents over 2 years"
|
||||
|
||||
# After backtesting, export to TradingView / TDX / MetaTrader 5
|
||||
vibe-trading --pine <run_id>
|
||||
```
|
||||
|
||||
**한 줄로 사전 빌드된 alpha zoo 벤치하기**:
|
||||
```bash
|
||||
vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025 --top 20
|
||||
```
|
||||
|
||||
**카탈로그 탐색** 후 단일 alpha 확인:
|
||||
```bash
|
||||
vibe-trading alpha list --zoo gtja191 --theme reversal --limit 10
|
||||
vibe-trading alpha show gtja191_171
|
||||
```
|
||||
|
||||
**zoo 인자들로 다인자 신호 구성**(Python):
|
||||
```python
|
||||
from src.skills.multi_factor.zoo_signal_engine import ZooSignalEngine
|
||||
engine = ZooSignalEngine.from_zoo(["gtja191_171", "gtja191_111", "gtja191_163"])
|
||||
panel = ... # your wide OHLCV panel
|
||||
signal = engine.compute_signal(panel)
|
||||
```
|
||||
|
||||
### 시장 리서치
|
||||
|
||||
```bash
|
||||
# Equity deep-dive
|
||||
vibe-trading run -p "Research NVDA: earnings trend, analyst consensus, option flow, and key risks for next quarter"
|
||||
|
||||
# Macro analysis
|
||||
vibe-trading run -p "Analyze the current Fed rate path, USD strength, and impact on EM equities and gold"
|
||||
|
||||
# Crypto on-chain
|
||||
vibe-trading run -p "Deep dive BTC on-chain: whale flows, exchange balances, miner activity, and funding rates"
|
||||
```
|
||||
|
||||
### Swarm 워크플로
|
||||
|
||||
```bash
|
||||
# Bull/bear debate on a stock
|
||||
vibe-trading --swarm-run investment_committee '{"topic": "Is TSLA a buy at current levels?"}'
|
||||
|
||||
# Quant strategy from screening to backtest
|
||||
vibe-trading --swarm-run quant_strategy_desk '{"universe": "S&P 500", "horizon": "3 months"}'
|
||||
|
||||
# Crypto desk: funding + liquidation + flow → risk manager
|
||||
vibe-trading --swarm-run crypto_trading_desk '{"asset": "ETH-USDT", "timeframe": "1w"}'
|
||||
|
||||
# Global macro portfolio allocation
|
||||
vibe-trading --swarm-run macro_rates_fx_desk '{"focus": "Fed pivot impact on EM bonds"}'
|
||||
```
|
||||
|
||||
### 크로스세션 메모리
|
||||
|
||||
```bash
|
||||
# Save your preferences once
|
||||
vibe-trading run -p "Remember: I prefer RSI-based strategies, max 10% drawdown, hold period 5–20 days"
|
||||
|
||||
# The agent recalls them in future sessions automatically
|
||||
vibe-trading run -p "Build a crypto strategy that fits my risk profile"
|
||||
```
|
||||
|
||||
### 문서 업로드 & 분석
|
||||
|
||||
```bash
|
||||
# Analyze a broker export or earnings report
|
||||
vibe-trading --upload trades_export.csv
|
||||
vibe-trading run -p "Profile my trading behavior and identify any biases"
|
||||
|
||||
vibe-trading --upload NVDA_Q1_earnings.pdf
|
||||
vibe-trading run -p "Summarize the key risks and beats/misses from this earnings report"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 API 서버
|
||||
|
||||
```bash
|
||||
vibe-trading serve --port 8899
|
||||
```
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/runs` | runs 목록 |
|
||||
| `GET` | `/runs/{run_id}` | run details |
|
||||
| `GET` | `/runs/{run_id}/pine` | multi-platform indicator export |
|
||||
| `POST` | `/sessions` | session 생성 |
|
||||
| `POST` | `/sessions/{id}/messages` | message 전송 |
|
||||
| `GET` | `/sessions/{id}/events` | SSE event stream |
|
||||
| `POST` | `/upload` | PDF/file 업로드 |
|
||||
| `GET` | `/swarm/presets` | swarm presets 목록 |
|
||||
| `POST` | `/swarm/runs` | swarm run 시작 |
|
||||
| `GET` | `/swarm/runs/{id}/events` | Swarm SSE stream |
|
||||
| `GET` | `/alpha/list` | zoo/theme/universe로 alpha 목록 필터링 |
|
||||
| `GET` | `/alpha/{alpha_id}` | Alpha 메타데이터 + 소스 코드 |
|
||||
| `POST` | `/alpha/bench` | Bench 작업 시작 (`job_id` 반환) |
|
||||
| `GET` | `/alpha/bench/{job_id}/stream` | SSE 진행 스트림 |
|
||||
| `GET` | `/settings/llm` | Web UI LLM settings 읽기 |
|
||||
| `PUT` | `/settings/llm` | local LLM settings 업데이트 |
|
||||
| `GET` | `/settings/data-sources` | local data source settings 읽기 |
|
||||
| `PUT` | `/settings/data-sources` | local data source settings 업데이트 |
|
||||
|
||||
Interactive docs: `http://localhost:8899/docs`
|
||||
|
||||
### 보안 기본값
|
||||
|
||||
localhost 개발에서 `vibe-trading serve`는 browser workflow를 단순하게 유지합니다. non-local client에서는 민감한 API endpoint에 `API_AUTH_KEY`가 필요합니다. JSON/upload request에는 `Authorization: Bearer <key>`를 사용하세요. Browser EventSource stream은 Web UI Settings에 같은 key를 한 번 입력하면 Web UI가 처리합니다.
|
||||
|
||||
Shell-capable tools는 local CLI와 trusted localhost workflow에서 사용할 수 있지만, `VIBE_TRADING_ENABLE_SHELL_TOOLS=1`을 명시적으로 설정하지 않는 한 remote API session에는 노출되지 않습니다. Document와 journal reader는 기본적으로 upload/import roots로 제한됩니다. 파일은 `agent/uploads`, `agent/runs`, `./uploads`, `./data`, `~/.vibe-trading/uploads`, `~/.vibe-trading/imports` 아래에 두거나, `VIBE_TRADING_ALLOWED_FILE_ROOTS`로 전용 directory를 추가하세요.
|
||||
|
||||
### Web UI Settings
|
||||
|
||||
Web UI Settings page에서는 local user가 LLM provider/model, base URL, generation parameters, reasoning effort, Tushare token 같은 선택적 market data credentials를 업데이트할 수 있습니다. Settings는 `agent/.env`에 저장되며 provider defaults는 `agent/src/providers/llm_providers.json`에서 로드됩니다.
|
||||
|
||||
Settings read는 side effect가 없습니다. `GET /settings/llm`과 `GET /settings/data-sources`는 `agent/.env`를 만들지 않으며 project-relative path만 반환합니다. Settings read/write는 credential state를 노출하거나 credential/runtime environment를 업데이트할 수 있으므로 `API_AUTH_KEY`가 설정되어 있으면 인증이 필요합니다. dev mode에서 `API_AUTH_KEY`가 설정되지 않은 경우 settings access는 loopback client에서만 허용됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 MCP Plugin
|
||||
|
||||
Vibe-Trading은 모든 MCP-compatible client를 위해 22개 MCP tools를 제공합니다. stdio subprocess로 실행되므로 server setup이 필요 없습니다. **22개 중 21개 tools는 API key 없이 작동합니다**(HK/US/crypto). `run_swarm`만 LLM key가 필요합니다.
|
||||
|
||||
<details>
|
||||
<summary><b>Claude Desktop</b></summary>
|
||||
|
||||
`claude_desktop_config.json`에 추가:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"vibe-trading": {
|
||||
"command": "vibe-trading-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>OpenClaw</b></summary>
|
||||
|
||||
`~/.openclaw/config.yaml`에 추가:
|
||||
|
||||
```yaml
|
||||
skills:
|
||||
- name: vibe-trading
|
||||
command: vibe-trading-mcp
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Cursor / Windsurf / 기타 MCP clients</b></summary>
|
||||
|
||||
```bash
|
||||
vibe-trading-mcp # stdio (default)
|
||||
vibe-trading-mcp --transport sse # SSE for web clients
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**노출되는 MCP tools(22):** `list_skills`, `load_skill`, `backtest`, `factor_analysis`, `analyze_options`, `pattern_recognition`, `get_market_data`, `web_search`, `read_url`, `read_document`, `read_file`, `write_file`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `list_swarm_presets`, `run_swarm`, `get_swarm_status`, `get_run_result`, `list_runs`.
|
||||
|
||||
<details>
|
||||
<summary><b>ClawHub에서 설치(한 번의 명령)</b></summary>
|
||||
|
||||
```bash
|
||||
npx clawhub@latest install vibe-trading --force
|
||||
```
|
||||
|
||||
> skill이 외부 API를 참조하여 VirusTotal 자동 스캔이 트리거되므로 `--force`가 필요합니다. 코드는 완전한 오픈소스이며 검토할 수 있습니다.
|
||||
|
||||
이 명령은 skill + MCP config를 agent의 skills directory에 다운로드합니다. clone은 필요 없습니다.
|
||||
|
||||
ClawHub에서 보기: [clawhub.ai/skills/vibe-trading](https://clawhub.ai/skills/vibe-trading)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>OpenSpace — 자가 진화 스킬</b></summary>
|
||||
|
||||
77개 finance skills는 모두 [open-space.cloud](https://open-space.cloud)에 게시되어 있으며 OpenSpace의 self-evolution engine을 통해 자율적으로 발전합니다.
|
||||
|
||||
OpenSpace와 함께 사용하려면 두 MCP server를 agent config에 추가하세요:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"openspace": {
|
||||
"command": "openspace-mcp",
|
||||
"toolTimeout": 600,
|
||||
"env": {
|
||||
"OPENSPACE_HOST_SKILL_DIRS": "/path/to/vibe-trading/agent/src/skills",
|
||||
"OPENSPACE_WORKSPACE": "/path/to/OpenSpace"
|
||||
}
|
||||
},
|
||||
"vibe-trading": {
|
||||
"command": "vibe-trading-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
OpenSpace는 77개 skills를 모두 자동 발견하여 auto-fix, auto-improve, community sharing을 활성화합니다. OpenSpace-connected agent에서 `search_skills("finance backtest")`로 Vibe-Trading skills를 검색하세요.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 📁 프로젝트 구조
|
||||
|
||||
<details>
|
||||
<summary><b>펼쳐 보기</b></summary>
|
||||
|
||||
```
|
||||
Vibe-Trading/
|
||||
├── agent/ # Backend (Python)
|
||||
│ ├── cli/ # CLI package — interactive TUI + subcommands
|
||||
│ ├── api_server.py # FastAPI server — runs, sessions, upload, swarm, SSE
|
||||
│ ├── mcp_server.py # MCP server — 35 tools for OpenClaw / Claude Desktop
|
||||
│ │
|
||||
│ ├── src/
|
||||
│ │ ├── agent/ # ReAct agent core
|
||||
│ │ │ ├── loop.py # 5-layer compression + read/write tool batching
|
||||
│ │ │ ├── context.py # system prompt + auto-recall from persistent memory
|
||||
│ │ │ ├── skills.py # skill loader (77 bundled + user-created via CRUD)
|
||||
│ │ │ ├── tools.py # tool base class + registry
|
||||
│ │ │ ├── memory.py # lightweight workspace state per run
|
||||
│ │ │ ├── frontmatter.py # shared YAML frontmatter parser
|
||||
│ │ │ └── trace.py # execution trace writer
|
||||
│ │ │
|
||||
│ │ ├── memory/ # Cross-session persistent memory
|
||||
│ │ │ └── persistent.py # file-based memory (~/.vibe-trading/memory/)
|
||||
│ │ │
|
||||
│ │ ├── tools/ # 31 auto-discovered agent tools
|
||||
│ │ │ ├── backtest_tool.py # run backtests
|
||||
│ │ │ ├── remember_tool.py # cross-session memory (save/recall/forget)
|
||||
│ │ │ ├── skill_writer_tool.py # skill CRUD (save/patch/delete/file)
|
||||
│ │ │ ├── session_search_tool.py # FTS5 cross-session search
|
||||
│ │ │ ├── swarm_tool.py # launch swarm teams
|
||||
│ │ │ ├── web_search_tool.py # DuckDuckGo web search
|
||||
│ │ │ └── ... # bash, file I/O, factor analysis, options, alpha browser + bench, etc.
|
||||
│ │ │
|
||||
│ │ ├── factors/ # Alpha Zoo — 4개 zoo에 걸친 452개 alpha
|
||||
│ │ │ ├── base.py # 19개 operator (rank/scale/ts_*/delta/decay_linear/safe_div/vwap)
|
||||
│ │ │ ├── registry.py # AST-only 메타데이터 로딩 + lazy compute + sanity gate
|
||||
│ │ │ ├── bench_runner.py # IC + alive/reversed/dead 분류
|
||||
│ │ │ └── zoo/ # qlib158 (154) + alpha101 (101) + gtja191 (191) + academic (6)
|
||||
│ │ │
|
||||
│ │ ├── api/ # FastAPI 라우트 모듈
|
||||
│ │ │ └── alpha_routes.py # /alpha/list, /alpha/{id}, /alpha/bench, SSE stream
|
||||
│ │ │
|
||||
│ │ ├── skills/ # 77 finance skills in 8 categories (SKILL.md each)
|
||||
│ │ ├── swarm/ # Swarm DAG execution engine
|
||||
│ │ │ └── presets/ # 29 swarm preset YAML definitions
|
||||
│ │ ├── session/ # Multi-turn chat + FTS5 session search
|
||||
│ │ └── providers/ # LLM provider abstraction
|
||||
│ │
|
||||
│ └── backtest/ # Backtest engines
|
||||
│ ├── engines/ # 7 engines + composite cross-market engine + options_portfolio
|
||||
│ ├── loaders/ # 7 sources: tushare, okx, yfinance, akshare, mootdx, ccxt, futu
|
||||
│ │ ├── base.py # DataLoader Protocol
|
||||
│ │ └── registry.py # Registry + auto-fallback chains
|
||||
│ └── optimizers/ # MVO, equal vol, max div, risk parity
|
||||
│
|
||||
├── frontend/ # Web UI (React 19 + Vite + TypeScript)
|
||||
│ └── src/
|
||||
│ ├── pages/ # Home, Agent, AlphaZoo, RunDetail, Compare, Correlation, Settings
|
||||
│ ├── components/ # chat, charts, layout
|
||||
│ └── stores/ # Zustand state management
|
||||
│
|
||||
├── Dockerfile # Multi-stage build
|
||||
├── docker-compose.yml # One-command deploy
|
||||
├── pyproject.toml # Package config + CLI entrypoint
|
||||
├── tools/ # 레포 단위 CI helper
|
||||
│ └── ci_grep_gates.sh # yaml.load / 트레이드마크 / 종목별 데이터 누출 차단
|
||||
└── LICENSE # MIT
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🏛 생태계
|
||||
|
||||
Vibe-Trading은 **[HKUDS](https://github.com/HKUDS)** agent ecosystem의 일부입니다:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/HKUDS/nanobot"><b>NanoBot</b></a><br>
|
||||
<sub>초경량 개인 AI 어시스턴트</sub>
|
||||
</td>
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/HKUDS/AI-Trader"><b>AI-Trader</b></a><br>
|
||||
<sub>Agent-Native Signal & Copy Trading Platform</sub>
|
||||
</td>
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/HKUDS/CLI-Anything"><b>CLI-Anything</b></a><br>
|
||||
<sub>모든 소프트웨어를 agent-native로</sub>
|
||||
</td>
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/HKUDS/OpenSpace"><b>OpenSpace</b></a><br>
|
||||
<sub>자가 진화 AI agent skills</sub>
|
||||
</td>
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/HKUDS/ClawTeam"><b>ClawTeam</b></a><br>
|
||||
<sub>Agent Swarm Intelligence</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## 🗺 로드맵
|
||||
|
||||
> 단계적으로 배포합니다. 작업이 시작되면 항목은 [Issues](https://github.com/HKUDS/Vibe-Trading/issues)로 이동합니다.
|
||||
|
||||
| Phase | Feature | Status |
|
||||
|-------|---------|--------|
|
||||
| **Trust Layer** | 재현 가능한 run cards는 생성 및 Run Detail 표시까지 완료. v1은 tool traces와 citations 추가 | v0 출시 |
|
||||
| **Hypothesis Registry** | lifecycle status, data sources, skills, run-card links, invalidation notes를 가진 durable research hypotheses | Backend MVP 출시 |
|
||||
| **Research Autopilot** | 수동 실행 우선 research loop: hypothesis → deterministic backtest → evidence report | 다음 |
|
||||
| **Data Bridge** | Bring-your-own data: local CSV/Parquet/SQL connectors with schema mapping | Planned |
|
||||
| **Options Lab** | Vol surface, Greeks dashboard, payoff/scenario explorer | Planned |
|
||||
| **Portfolio Studio** | Risk x-ray, constraints, turnover-aware optimizer, rebalance notes | Planned |
|
||||
| **Alpha Zoo** | 4개 zoo에 걸친 452개의 사전 빌드된 alpha 인자(Qlib 158 + Kakushadze 101 + GTJA 191 + FF5 + Carhart), 한 줄 CLI 벤치, agent 통합, Web UI | **0.1.8 출시 완료** |
|
||||
| **Research Delivery** | Slack / Telegram / email-style channels로 예약 brief 전달 | Planned |
|
||||
| **Community** | 공유 가능한 skills, presets, strategy cards | Exploring |
|
||||
|
||||
---
|
||||
|
||||
## 기여하기
|
||||
|
||||
기여를 환영합니다! 가이드는 [CONTRIBUTING.md](CONTRIBUTING.md)를 참고하세요.
|
||||
|
||||
**Good first issues**는 [`good first issue`](https://github.com/HKUDS/Vibe-Trading/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) 라벨이 붙어 있습니다. 하나를 골라 시작해 보세요.
|
||||
|
||||
더 큰 기여를 하고 싶나요? 위 [로드맵](#-로드맵)을 확인하고 시작 전에 issue를 열어 논의해 주세요.
|
||||
|
||||
---
|
||||
|
||||
## 기여자
|
||||
|
||||
Vibe-Trading에 기여해 주신 모든 분께 감사드립니다!
|
||||
|
||||
최근 v0.1.8 cycle contributors and credits:
|
||||
|
||||
- @GTC2080 / TaoMu — Web UI Settings and provider/data-source configuration APIs (#57)
|
||||
- @BigNounce90 — validation CLI hardening for backtest `run_dir` input (#60)
|
||||
- @shadowinlife — A-share pre-ST filter skill (#63)
|
||||
- @MB-Ndhlovu — correlation heatmap dashboard and review fixes (#64, #66)
|
||||
- @ykykj — OpenAI Codex OAuth provider option (#65)
|
||||
- @RuifengFu — interactive CLI live status bar and prompt editing (#69)
|
||||
- @SiMinus — swarm preset inspection command (#73)
|
||||
- @warren618 / Haozhe Wu — security hardening, release integration, docs, Docker, packaging, and local dev workflow
|
||||
- lemi9090 (S2W) — coordinated security research, validation, and disclosure support
|
||||
|
||||
<a href="https://github.com/HKUDS/Vibe-Trading/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=HKUDS/Vibe-Trading" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## 면책조항
|
||||
|
||||
Vibe-Trading은 리서치 및 거래 소프트웨어입니다. 투자 조언이 아니며, 자금을 보유하지 않고, 거래소를 운영하지 않습니다. 거래는 사용자가 명시적으로 인가한 브로커 채널(예: Robinhood Agentic Trading)을 통해서만, 사용자가 설정한 한도 내에서 이루어지며 언제든 중단할 수 있습니다. 이 브로커 거래 기능은 실험적이며 당사가 실제 브로커 계정으로 검증하지 않았습니다 — 사용에 따른 책임은 본인에게 있습니다. 과거 성과가 미래 수익을 보장하지 않습니다.
|
||||
|
||||
## 라이선스
|
||||
|
||||
MIT License — [LICENSE](LICENSE) 참조
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#HKUDS/Vibe-Trading&Date)
|
||||
|
||||
<p align="center">
|
||||
⭐ <b>Vibe-Trading</b>이 연구에 도움이 되었다면, Star를 눌러 더 많은 분들이 찾을 수 있도록 도와주세요.
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<b>Vibe-Trading</b>에 방문해 주셔서 감사합니다 ✨
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.Vibe-Trading&style=flat" alt="visitors"/>
|
||||
</p>
|
||||
+943
@@ -0,0 +1,943 @@
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | <b>中文</b> | <a href="README_ja.md">日本語</a> | <a href="README_ko.md">한국어</a> | <a href="README_ar.md">العربية</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/icon.png" width="120" alt="Vibe-Trading Logo"/>
|
||||
</p>
|
||||
|
||||
<h1 align="center">Vibe-Trading:你的个人交易智能体</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>一条命令,让你的智能体具备完整交易研究能力</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Python-3.11%2B-3776AB?style=flat&logo=python&logoColor=white" alt="Python">
|
||||
<img src="https://img.shields.io/badge/Backend-FastAPI-009688?style=flat" alt="FastAPI">
|
||||
<img src="https://img.shields.io/badge/Frontend-React%2019-61DAFB?style=flat&logo=react&logoColor=white" alt="React">
|
||||
<a href="https://pypi.org/project/vibe-trading-ai/"><img src="https://img.shields.io/pypi/v/vibe-trading-ai?style=flat&logo=pypi&logoColor=white" alt="PyPI"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow?style=flat" alt="License"></a>
|
||||
<br>
|
||||
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/Feishu-Group-E9DBFC?style=flat-square&logo=feishu&logoColor=white" alt="Feishu"></a>
|
||||
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat-square&logo=wechat&logoColor=white" alt="WeChat"></a>
|
||||
<a href="https://discord.gg/2vDYc2w5"><img src="https://img.shields.io/badge/Discord-Join-7289DA?style=flat-square&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://vibetrading.wiki/">官网</a> ·
|
||||
<a href="https://vibetrading.wiki/docs/">文档</a> ·
|
||||
<a href="#-news">News</a> ·
|
||||
<a href="#-key-features">Features</a> ·
|
||||
<a href="#-shadow-account">Shadow Account</a> ·
|
||||
<a href="#-demo">Demo</a> ·
|
||||
<a href="#-quick-start">Quick Start</a> ·
|
||||
<a href="#-examples">Examples</a> ·
|
||||
<a href="#-api-server">API / MCP</a> ·
|
||||
<a href="#-roadmap">Roadmap</a> ·
|
||||
<a href="#-contributing">Contributing</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#-quick-start"><img src="assets/pip-install.svg" height="45" alt="pip install vibe-trading-ai"></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 📰 News
|
||||
|
||||
- **2026-05-31** 🔌 **Connector-first 券商架构(IBKR + Robinhood)**:交易接入现在从可选择的 connector profile 开始,不再拆成分散的券商入口和 live 入口。`vibe-trading connector list/use/check/account/positions/orders/quote/history` 与 MCP `trading_*` 工具共享同一个选中的 profile;paper/live 只是该 connector 下的属性。IBKR 可立即通过本地只读 TWS / IB Gateway profile 使用;官方 IBKR 远程 MCP 先作为 OAuth `mcp.read` 探测种子,等待稳定 read 工具名后再映射。Robinhood Agentic Trading 仍是有界 live MCP connector,必须经过 OAuth、已提交 mandate、order guard、审计账本和即时 halt。
|
||||
- **2026-05-30** 🧰 **健壮性专项 — backtest、agent loop、session**:LLM 生成的 signal engine 现在会在实例化前先过接口预检,提前抓出循环 self-import、缺失 `generate()`、`__init__` 参数没有默认值、返回类型错误等常见问题,并给出可操作的 JSON 报错而非原始 traceback([#149](https://github.com/HKUDS/Vibe-Trading/pull/149));后续一并把源码级 AST 校验的报错也走同一套干净的 JSON 信封。agent loop 不再把 50 次迭代全烧光后留下一个没有任何输出的 `failed` 状态——它复用 swarm worker 已验证的做法:在迭代预算 80% 处注入 wrap-up nudge,并在最后一次迭代丢掉 tool 定义以强制产出文本答案([#148](https://github.com/HKUDS/Vibe-Trading/pull/148)),且只在中途触发,绝不挤掉 research-goal 上下文。session 消息写入现在每次 append 后 `flush + fsync`,让昂贵的 AI 回复能在写到一半崩溃时存活;读取端则跳过损坏的 JSONL 行(记录前 200 字符以便人工恢复),而不是让整个 `/messages` 端点 500([#147](https://github.com/HKUDS/Vibe-Trading/pull/147))。Web 输入框也修了 IME 回车处理,让中日韩输入法的确认上屏回车不再误触发提交([#146](https://github.com/HKUDS/Vibe-Trading/pull/146))。
|
||||
- **2026-05-29** 🔐 **支持 Robinhood Agentic Trading(可选开启、有界自主)**:新增对 Robinhood Agentic Trading 的支持(远程 MCP,OAuth)。默认关闭且只读;仅在用户提交的 mandate(标的/单量/敞口/杠杆/每日笔数)内自主交易,配文件级即时 kill switch、抢占式平仓、mandate 自动过期、完整审计账本,以及一个持久自主 runner。无托管、无场所——券商持有资金并执行,我们只中继意图。实验性 / 风险自负。
|
||||
<details>
|
||||
<summary>更早的更新</summary>
|
||||
|
||||
- **2026-05-28** 🧪 **Swarm 安全 + 严格 alpha 门 + worker 端 MCP**:Swarm DAG 在上游任务失败时阻断下游任务([#145](https://github.com/HKUDS/Vibe-Trading/pull/145))。新增 `run_bench_strict()` 在 IC 门之上加入同 universe 随机控制 + 训练/测试 OOS 切分,识别只是跟随市场 beta 的伪因子([#143](https://github.com/HKUDS/Vibe-Trading/pull/143),感谢 @Soli22de)。Swarm worker 现在可以调用 operator 配置的外部 MCP server,信任边界由专项测试固定([#142](https://github.com/HKUDS/Vibe-Trading/pull/142),感谢 @shadowinlife)。
|
||||
- **2026-05-27** 📊 **mootdx A 股数据源 + 输出排版**:新增 `mootdx` loader,走原生通达信 TCP 协议拉 A 股 OHLCV(无需 token,无 IP 速率限制,日线 + 分钟线 25 页 walk-back 分页),在 fallback chain 中位于 tushare 和 akshare 之间([#107](https://github.com/HKUDS/Vibe-Trading/issues/107))。CCXT loader 现在会读取 `HTTP_PROXY/HTTPS_PROXY/ALL_PROXY`,使 Binance/OKX 公开数据可在受限网络下拉取([#126](https://github.com/HKUDS/Vibe-Trading/pull/126),感谢 @ruok808)。最终回答的渲染也去掉了 CLI 和 Web 上丑陋的全宽 `---` 分隔符:系统提示鼓励 agent 用 markdown 表格和 `##` 标题,CLI 渲染端兜底 strip 孤立 HR,前端 chat 气泡隐藏任何漏过去的 `<hr>`([#139](https://github.com/HKUDS/Vibe-Trading/issues/139),感谢 @sdwxm188)。
|
||||
- **2026-05-26** ✅ **Research Goal 生命周期闭环**:Goal 模式现在像真正的任务运行器:Web UI 创建 goal 会创建或绑定 session,并立刻发出 kickoff turn;active goal 可在 Web/API/CLI/MCP 中继续、编辑、取消和完成;agent loop 会按当前 goal snapshot(criteria、evidence、claims、open items)推进,而不是只按最初 prompt。criteria 已 covered 但 goal 仍 active 时,会进入 audit/status 更新,不再静默停住,并用 backend、CLI、MCP 与 frontend events 回归覆盖固定。
|
||||
|
||||
- **2026-05-25** 🧼 **更干净的 Chat UI + composer 工作流**:Web UI 现在把注意力留给下一步输入:upload、swarm 和 research-goal 模式都收进 composer 的 `+` 菜单,不再用漂浮面板打断聊天。当前上下文会以紧凑 chip 附在输入框上方,goal 详情只在点击 chip 时原地展开。UI 也移除了旧的自定义 i18n 层,改用直接英文文案;Full Report card 只在真正有报告价值的 run 出现;本地 dev 启动与状态报告也加固,方便稳定做浏览器 smoke test。
|
||||
- **2026-05-24** 🎯 **Research Goal runtime**:新增 session 级 Research Goal 层,贯通 backend、CLI、API/MCP、SSE 和 Web UI。Goal 会持久化 claim、acceptance criteria、evidence row、budget 与 completion policy;agent tools 可以创建 goal 并追加 evidence;`/goal` 成为 CLI 入口;REST/MCP 暴露 goal snapshot 和 evidence 写入;SSE 保持 chat client 状态新鲜。后续审计修复锁紧 verified evidence,阻断 agent tool 写入 live-trading 风险层,串起 CLI 创建的 goal 与后续 turn,删除 session 时清理 goal ledger,接上 replay-all,并修复前端跨 session snapshot race。
|
||||
- **2026-05-23** 🖥️ **交互式 CLI 刷新**:终端入口现在使用更大的 Vibe-Trading banner、更清晰的 prompt 分隔线、上一轮摘要、运行后耗时,以及 Claude Code 风格的活动轨来展示实时 agent 工作。工具调用、网页/数据抓取、shell 风格动作、Markdown 回答和管道表格都会以更易读的 transcript 渲染;pipe 或非 TTY 运行仍保留适合自动化的纯文本输出。生成的 CLI 截图现在作为本地 artifact 处理,不再提交进 docs,让仓库更轻。
|
||||
- **2026-05-22** 🧭 **Swarm 恢复 + MCP keepalive**:Swarm 状态现在每次读取都会从实时 task 文件 reconcile,API/MCP/SSE/list 视图可以自动恢复 crash 或过期 run,不再永久停在 `running` 快照。`run_swarm` 在 MCP polling 期间持续发送 progress heartbeat,首帧固定为 `swarm_started run_id=<id>`,方便 transport 掉线后的客户端找回句柄;worker 的 LLM streaming、grounding fetch、tool execution 也都包上了 heartbeat。stale-run reaper 按每个 run 的阈值判断,并从 task 状态推导终态;`SwarmTool` wait budget 用尽后不再取消仍在跑的 team,MCP 客户端也可以调用 `reap_stale_runs()` 显式清理。今天的 DX pass 还同步刷新 provider 默认模型,并把 CI syntax check 对齐到新的 `agent/cli/` 包。22 条新回归覆盖 hydrate、终态恢复、stale 回收、keepalive cadence、env 容错和 heartbeat wiring;完整 swarm/MCP 套件 169 passed、4 skipped。
|
||||
- **2026-05-21** 🧱 **CLI 包重构**:`agent/cli.py`(3216 行)拆成 `agent/cli/` 包 —— 交互入口、slash 路由、Rich 组件,加 `_legacy.py` shim 保留所有子命令并 re-export 所有公共符号,`cli.cmd_*` / `cli._INIT_ENV_PATH` / `cli.Confirm` 不变。新增 FastAPI middleware:浏览器直开 `/runs/{id}` 或 `/correlation` 时返回 SPA shell;Vite dev proxy 同步收窄到相同 regex。版本号通过 `cli/_version.py` 单一来源(`--version` 与 banner 不再 drift),`python -m cli` 通过 `__main__.py` 恢复,chat-gate 收窄使 `chat --help` / `chat extra` 正确走 legacy argparse 而不被新 REPL 吞掉。
|
||||
- **2026-05-20** 🔬 **Hypothesis Registry CLI**:补齐了 5-16 上线但只有后端的 Hypothesis Registry 的 CLI 侧。`vibe-trading hypothesis list` 输出 Rich 表格或 JSON(支持 `--status` 过滤、`--limit`);`show <id>` 渲染详情面板,包含已 link 的 run card;`invalidate <id> --note "..."` 把 status 翻成 `rejected`,省略 `--note` 时保留原有 invalidation notes。沿用 `VIBE_TRADING_HYPOTHESES_PATH` 环境变量,并新增按调用覆盖的 `--path`。22 个新单测覆盖 wiring、JSON 输出、状态过滤、limit、缺 id 报错、备注持久化。
|
||||
- **2026-05-19** ✨ **工具实时反馈 + 优雅取消**:长时间运行的工具(回测、大 PDF、swarm worker)不再看起来卡死。每个工具调用现在会发出 3 秒一次的心跳,以及结构化的阶段进度 —— `run_backtest` 输出阶段标记(`validate` / `simulate` / `finalize`),`read_document` 在 PDF 上按页打点 / Excel 上按工作表打点,`read_url` 标记 `fetch` / `parse`。CLI 的 Rich Live 面板渲染 Unicode 转轮、ASCII 进度条、ETA,按工具名最多堆叠 3 个并行工具;前端 chat 新增 `ToolProgressIndicator`,rAF 合并刷新、ARIA `role="status"` + 隐藏的原生 `<progress>` 供屏幕阅读器使用,已知总数时切换为 determinate 的 `ProgressRing` SVG。CLI 中第一次 `Ctrl+C` 现在会调 `agent.cancel()` 优雅退出(当前步骤跑完、trace 干净关闭);2 秒内第二次 `Ctrl+C` 强制退出。顺手抽出可复用基础件:`ProgressBar.tsx` 和 `lib/tools.ts`(共享工具名 i18n 映射)。
|
||||
- **2026-05-18** 🧹 **清理一次 + 3 个潜伏 bug 修复**:`CompositeEngine` 不再把无交易所后缀的中国期货代码(如 `RB2410`)错误路由到 `GlobalFuturesEngine` —— `_is_china_futures` 移到共享的 `_market_hooks` 模块,产品代码表做了大小写归一并加入非中国交易所守卫,新增 9 条回归用例。session FTS5 索引现在会持久化时间戳,跨 session 搜索可按日期排序;同一改动也修复了 re-upsert 路径每次都用 wall-clock 覆盖 `started_at` 的副作用 bug。前端 Vite dev proxy 补上漏配的 `/alpha`,AlphaZoo 页在 `npm run dev` 下不再 404。`tests/test_e2e_harness_v2.py`(真 LLM 的 e2e 套件)现在用 `VIBE_TRADING_RUN_LIVE_E2E=1` 做环境门控,CI 不再因为有无 LLM key 而静默切状态。Ruff 为 factor zoo 添加 `per-file-ignores`(3783 → 0 F401 噪音),前端 tsconfig 打开 `noUnusedLocals` / `noUnusedParameters` 做回归护栏,并删掉了 76 个 `gtja191` alpha 文件里没用上的 `vw = vwap(...)` 残留。净 **-918 行**。
|
||||
- **2026-05-17** 🧬 **Alpha Zoo v1(0.1.8)**:内置 452 个量化 alpha,覆盖 4 个 zoo —— `qlib158`(Microsoft Qlib 的 Alpha158 特征,Apache-2.0 出处声明)、`alpha101`(Kakushadze 的 "101 Formulaic Alphas",从 arXiv:1601.00991 论文公式重写)、`gtja191`(国君证券 2014 短周期交易型因子研报)、`academic`(Fama-French 5 因子 + Carhart 动量的价格代理实现)。一行 CLI 就能在自己的 universe 上跑横评:`vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025`。配套设施包括 AST 纯函数门禁、lookahead 防护测试、`pytest-socket` 网络隔离、每个 zoo 一份 LICENSE.md、社区贡献用的 DCO 签名流程;Alpha Library 自动渲染上线 [vibetrading.wiki/alpha-library/](https://vibetrading.wiki/alpha-library/);Research Lab 同步发布 [《191 个 GTJA alpha 哪些在 2026 还能用》](https://vibetrading.wiki/research-lab/posts/alpha-191-in-2026.html)。
|
||||
- **2026-05-16** 🧪 **研究主干更新**:新增后端 Hypothesis Registry,提供 `create_hypothesis`、`update_hypothesis`、`link_backtest`、`search_hypotheses`;外部内容读取工具现在会附加 warning-only 的 `security_warnings`;Shadow Account 扫描也从旧的日历 phase stub 升级为确定性的 OHLCV 特征评估。
|
||||
- **2026-05-15** 🪪 Run 详情页现在会在 metrics 和 artifacts 旁边渲染 Trust Layer 的 run card,把 2026-05-12 已落地的 `run_card.json` 工作补齐到 UI 一侧。`PersistentMemory.add()` 也根据 #108/#109/#110 的 triage,在长度限制、空 / 纯空白 name、以及 C0/C1 控制字节三条路径上做了加固([#112](https://github.com/HKUDS/Vibe-Trading/pull/112),感谢 @Teerapat-Vatpitak)。
|
||||
- **2026-05-14** 🌐 公开 Wiki 已上线 [vibetrading.wiki](https://vibetrading.wiki/),包含 docs、tutorials、Research Lab 和 Alpha Library,并通过 Cloudflare Pages 部署。持久记忆也可以通过 CLI 使用 `vibe-trading memory list/show/search/forget` 检查([#102](https://github.com/HKUDS/Vibe-Trading/pull/102),感谢 @Teerapat-Vatpitak);记忆 tokenizer/slug 现在支持泰语、阿拉伯语、希伯来语和西里尔文字([#104](https://github.com/HKUDS/Vibe-Trading/pull/104))。
|
||||
- **2026-05-13** 🧭 Swarm 运行现在会用已获取的市场数据为 worker 提供依据,并生成更清晰的持久化报告([#93](https://github.com/HKUDS/Vibe-Trading/pull/93),[#84](https://github.com/HKUDS/Vibe-Trading/pull/84))。
|
||||
- **2026-05-12** 🧾 回测现在会随 artifacts 一起输出 `run_card.json` 和 `run_card.md`,便于复现实验研究。
|
||||
- **2026-05-11** 🧭 **记忆 slug、swarm 统计与 CLI 预检**:持久记忆在生成文件 slug 时会保留 CJK 字符,避免中文/日文/韩文笔记发生静默文件名冲突([#95](https://github.com/HKUDS/Vibe-Trading/pull/95),感谢 @voidborne-d)。Swarm 运行总量现在优先采用 provider 返回的 token 用量,并保留原有估算作为 fallback([#94](https://github.com/HKUDS/Vibe-Trading/pull/94),感谢 @Teerapat-Vatpitak)。CLI 运行界面也新增了启动预检,用于发现常见环境问题([#96](https://github.com/HKUDS/Vibe-Trading/pull/96),感谢 @ykykj)。
|
||||
- **2026-05-10** 🧱 **回归护栏与运行元数据**:记忆召回现在将下划线视为 token 边界,因此 `mcp_wiring_test` 这类 snake_case 记忆可以匹配 "mcp wiring" 等自然语言查询([#87](https://github.com/HKUDS/Vibe-Trading/pull/87),感谢 @hp083625)。MCP server 增加了覆盖 initialize → `tools/list` → `tools/call` 的 subprocess smoke test,以防止首次调用死锁路径回归([#86](https://github.com/HKUDS/Vibe-Trading/pull/86))。同时还完成了多项低风险加固:Windows 路径敏感测试、API best-effort 异常处理、backtest `run_dir` allowed-root 校验,以及 SwarmRun provider/model 元数据([#88](https://github.com/HKUDS/Vibe-Trading/pull/88),[#90](https://github.com/HKUDS/Vibe-Trading/pull/90),[#91](https://github.com/HKUDS/Vibe-Trading/pull/91),[#92](https://github.com/HKUDS/Vibe-Trading/pull/92),感谢 @Teerapat-Vatpitak)。
|
||||
- **2026-05-09** 🛡️ **API 路径加固与 MCP server 稳定性**:API run/session 路由现在会在查询前校验 path ID,拒绝包含换行等异常字符的参数,并将该行为纳入 auth/security 回归测试([#80](https://github.com/HKUDS/Vibe-Trading/pull/80),感谢 @SJoon99)。MCP server 现在会在主线程预热工具注册表再处理 `tools/call`,避免懒加载工具发现中的首次调用死锁([#85](https://github.com/HKUDS/Vibe-Trading/pull/85),感谢 @Teerapat-Vatpitak)。Vite dev proxy 也会为非默认后端目标遵循 `VITE_API_URL`([#82](https://github.com/HKUDS/Vibe-Trading/pull/82),感谢 @voidborne-d)。
|
||||
- **2026-05-08** 🧾 **筛选器支持 Tushare 财报字段**:A 股日线回测现在可以通过 `fundamental_fields` 请求 PIT-safe 财务报表字段,使信号引擎能够在公告/披露日之后筛选 `income_total_revenue`、`income_n_income`、`balancesheet_total_hldr_eqy_exc_min_int`、`fina_indicator_roe` 等带表名前缀的字段([#76](https://github.com/HKUDS/Vibe-Trading/pull/76),感谢 @mrbob-git)。后续加固让显式财报字段请求在 Tushare enrich 无法运行时快速失败,而不是静默回退到原始价格 bar([#77](https://github.com/HKUDS/Vibe-Trading/pull/77))。
|
||||
- **2026-05-07** 📈 **Tushare fundamentals 与社区 triage**:新增面向基本面研究工作流的 point-in-time `TushareFundamentalProvider` contract,并为项目 `TUSHARE_TOKEN` 环境路径加入回归覆盖([#74](https://github.com/HKUDS/Vibe-Trading/pull/74))。社区 triage 也明确了:Vibe-Trading 目前会将快速迭代聚焦在单一 UI 语言;在已内置 DuckDuckGo 支持的 `web_search` 时避免添加冗余搜索依赖;非官方托管部署不应被视为 API key 或数据源 token 的可信存放位置。
|
||||
- **2026-05-06** 🚀 **v0.1.7 发布**([Release notes](https://github.com/HKUDS/Vibe-Trading/releases/tag/v0.1.7),`pip install -U vibe-trading-ai`):安全边界加固已发布到 PyPI 和 ClawHub,覆盖更安全的 API/read/upload/file/URL/generated-code/shell-tool/Docker 默认行为,同时保持 localhost CLI/Web UI 工作流低摩擦。本周期还包含 Web UI Settings、相关性热力图、OpenAI Codex OAuth、A 股 pre-ST 筛选、交互式 CLI UX、swarm preset 检查、股息分析、开发工作流打磨,以及经审计的前端构建依赖下限。感谢 0.1.7 贡献者,也感谢 lemi9090 (S2W) 的协同安全验证。
|
||||
- **2026-05-05** 🛡️ **安全边界后续加固**:完成围绕显式 CORS origins、Settings 凭据指示、Web URL 读取和 Shadow Account 代码生成的剩余安全边界加固,并为每条路径加入回归测试。普通 localhost CLI/Web UI 工作流保持不变;远程部署应继续使用 `API_AUTH_KEY` 和显式可信 origins。
|
||||
- **2026-05-04** 🖥️ **交互式 CLI UX 与 CI 清理**:交互模式现在拥有实时底部状态栏,可显示 provider/model、session 时长、最近一次运行延迟和累计工具调用统计;并通过 `prompt_toolkit` 支持 prompt 历史导航和方向键光标编辑([#69](https://github.com/HKUDS/Vibe-Trading/pull/69))。当 `prompt_toolkit` 或 TTY 不可用时,CLI 仍会回退到 Rich prompts。CI 路径期望也已与加固后的 file-import sandbox 和跨平台 `/tmp` 解析对齐,使 main 恢复绿色([`bb67dc7`](https://github.com/HKUDS/Vibe-Trading/commit/bb67dc7cfcc11553c57d8962bee56381dca43758))。
|
||||
- **2026-05-03** 🛡️ **安全加固补丁**:收紧非本地部署的默认 API 认证,保护敏感 run/session/swarm 读取,限制上传与本地文件读取边界,按入口限制 shell-capable 工具,导入前校验生成策略加载,并让 Docker 镜像默认以非 root 用户和 localhost-only 端口发布运行。本地 CLI 和 localhost Web UI 工作流仍保持低摩擦;远程 API/Web 部署应设置 `API_AUTH_KEY`。
|
||||
- **2026-05-02** 🧭 **股息分析与更清晰路线图**:新增 `dividend-analysis` skill,用于收入型股票、派息可持续性、股息增长、股东收益、除息机制和收益率陷阱检查,并由 bundled-skill 回归测试固定。公开路线图现在聚焦即将开展的工作:Research Autopilot、Data Bridge、Options Lab、Portfolio Studio、Alpha Zoo、Research Delivery、Trust Layer 和 Community sharing。
|
||||
- **2026-05-01** 🔥 **相关性热力图、OpenAI Codex OAuth 与 A 股 pre-ST 筛选**:新的相关性 dashboard/API 会计算滚动收益相关性,并为组合与标的分析渲染 ECharts 热力图([#64](https://github.com/HKUDS/Vibe-Trading/pull/64))。OpenAI Codex provider 现在通过 `vibe-trading provider login openai-codex` 使用 ChatGPT OAuth,并加入 Settings 元数据和 adapter 回归测试([#65](https://github.com/HKUDS/Vibe-Trading/pull/65))。新增并加固 `ashare-pre-st-filter` skill,用于 A 股 ST/*ST 风险筛查,包括 Sina 处罚相关性过滤,避免证券账户提及错误抬高 E2 计数([#63](https://github.com/HKUDS/Vibe-Trading/pull/63))。
|
||||
- **2026-04-30** ⚙️ **Web UI Settings 与 validation CLI 加固**:新增 Settings 页面,用于配置 LLM provider/model、base URL、reasoning effort 和数据源凭据,由本地/认证保护的 settings API 与数据驱动的 provider metadata 支撑([#57](https://github.com/HKUDS/Vibe-Trading/pull/57))。同时加固 `python -m backtest.validation <run_dir>`,让缺失、空白、格式错误、不存在和非目录输入在 validation 开始前以清晰的面向操作者的信息失败([#60](https://github.com/HKUDS/Vibe-Trading/pull/60))。
|
||||
- **2026-04-28** 🚀 **v0.1.6 发布**(`pip install -U vibe-trading-ai`):修复 `pip install` / `uv tool install` 后 `vibe-trading --swarm-presets` 返回空的问题([#55](https://github.com/HKUDS/Vibe-Trading/issues/55))—— preset YAML 现在打包在 `src.swarm` 包内,并由 6 个回归测试固定。同时 AKShare loader 会将 ETF(`510300.SH`)和外汇(`USDCNH`)正确路由到对应 endpoint,并强化 registry fallback。汇总 v0.1.5 以来的所有内容:benchmark comparison panel、`/upload` streaming + size limits、Futu loader(港股 + A 股)、vnpy export skill、安全加固、前端懒加载(688KB → 262KB)。
|
||||
- **2026-04-27** 📊 **Benchmark panel 与上传安全**:回测输出现在包含 benchmark comparison panel(ticker / benchmark return / excess return / information ratio),并通过 yfinance 支持 SPY、沪深 300 等解析([#48](https://github.com/HKUDS/Vibe-Trading/issues/48))。此外 `/upload` 会以 1 MB chunk 流式读取请求体,并在超过 `MAX_UPLOAD_SIZE` 时中止,在超大/畸形客户端场景下限制内存使用([#53](https://github.com/HKUDS/Vibe-Trading/pull/53))——由 4 个回归用例固定。
|
||||
- **2026-04-22** 🛡️ **加固与新集成**:`safe_path` + journal/shadow tool sandbox 强制路径 containment,`MANIFEST.in` 在 sdist 中包含 `.env.example` / tests / Docker files,route-level lazy loading 将前端初始 bundle 从 688KB 降到 262KB。另有面向港股与 A 股 equities 的 Futu data loader([#47](https://github.com/HKUDS/Vibe-Trading/pull/47))和 vnpy CtaTemplate export skill([#46](https://github.com/HKUDS/Vibe-Trading/pull/46))。
|
||||
- **2026-04-21** 🛡️ **Workspace 与文档**:相对 `run_dir` 会规范化到 active run dir([#43](https://github.com/HKUDS/Vibe-Trading/pull/43))。README 使用示例([#45](https://github.com/HKUDS/Vibe-Trading/pull/45))。
|
||||
- **2026-04-20** 🔌 **Reasoning 与 Swarm**:所有 `ChatOpenAI` 路径都会保留 `reasoning_content`,Kimi / DeepSeek / Qwen thinking 全链路可用([#39](https://github.com/HKUDS/Vibe-Trading/issues/39))。Swarm streaming 与干净的 Ctrl+C([#42](https://github.com/HKUDS/Vibe-Trading/issues/42))。
|
||||
- **2026-04-19** 📦 **v0.1.5**:发布到 PyPI 与 ClawHub。`python-multipart` CVE 下限升级,接入 5 个新 MCP tools(`analyze_trade_journal` + 4 个 shadow-account tools),修复 `pattern_recognition` → `pattern` registry,Docker 依赖对齐,SKILL manifest 同步(22 MCP tools / 71 skills)。
|
||||
- **2026-04-18** 👥 **Shadow Account**:从券商流水中提取你的策略规则 → 跨市场回测 shadow → 生成 8 节 HTML/PDF 报告,明确展示你错过了多少机会(规则违背、过早离场、错过信号、反事实交易)。新增 4 个工具、1 个 skill,总计 32 tools。Trade Journal + Shadow Account 示例现在已在 Web UI 欢迎页中提供。
|
||||
- **2026-04-17** 📊 **Trade Journal Analyzer 与 Universal File Reader**:上传券商导出(同花顺/东财/富途/generic CSV)→ 自动生成交易画像(持仓天数、胜率、盈亏比、回撤)+ 4 类行为偏差诊断(处置效应、过度交易、追涨、锚定)。`read_document` 现在以统一调用分发 PDF、Word、Excel、PowerPoint、图片(OCR)和 40+ 文本格式。
|
||||
- **2026-04-16** 🧠 **Agent Harness**:跨 session 持久记忆、FTS5 session search、自进化 skills(完整 CRUD)、5 层上下文压缩、read/write tool batching。27 tools,107 个新增测试。
|
||||
- **2026-04-15** 🤖 **Z.ai 与 MiniMax**:Z.ai provider([#35](https://github.com/HKUDS/Vibe-Trading/pull/35)),MiniMax temperature 修复与模型更新([#33](https://github.com/HKUDS/Vibe-Trading/pull/33))。13 个 providers。
|
||||
- **2026-04-14** 🔧 **MCP 稳定性**:修复 stdio transport 下 backtest tool 的 `Connection closed` 错误([#32](https://github.com/HKUDS/Vibe-Trading/pull/32))。
|
||||
- **2026-04-13** 🌐 **跨市场组合回测**:新的 `CompositeEngine` 可用共享资金池和分市场规则回测混合市场组合(例如 A 股 + crypto)。同时修复 swarm template variable fallback 和前端 timeout。
|
||||
- **2026-04-12** 🌍 **多平台导出**:`/pine` 可一条命令将策略导出到 TradingView(Pine Script v6)、TDX(通达信/同花顺/东方财富)和 MetaTrader 5(MQL5)。
|
||||
- **2026-04-11** 🛡️ **可靠性与 DX**:`vibe-trading init` .env bootstrap([#19](https://github.com/HKUDS/Vibe-Trading/pull/19))、预检、运行时数据源 fallback、加固的回测引擎。多语言 README([#21](https://github.com/HKUDS/Vibe-Trading/pull/21))。
|
||||
- **2026-04-10** 📦 **v0.1.4**:Docker 修复([#8](https://github.com/HKUDS/Vibe-Trading/issues/8))、`web_search` MCP tool、12 个 LLM providers、`akshare`/`ccxt` 依赖。发布到 PyPI 与 ClawHub。
|
||||
- **2026-04-09** 📊 **Backtest Wave 2**:ChinaFutures、GlobalFutures、Forex、Options v2 engines。Monte Carlo、Bootstrap CI、Walk-Forward validation。
|
||||
- **2026-04-08** 🔧 **多市场回测**,支持分市场规则、Pine Script v6 导出、5 个数据源自动 fallback。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
<div align="center">
|
||||
<table align="center" width="94%" style="width:94%; margin-left:auto; margin-right:auto;">
|
||||
<tr>
|
||||
<td align="center" width="50%" valign="top">
|
||||
<img src="assets/feature-self-improving-trading-agent.png" height="130" alt="Self-improving trading agent"/><br>
|
||||
<h3>🔍 自我改进的交易智能体</h3>
|
||||
<div align="left">
|
||||
• 自然语言市场研究<br>
|
||||
• 策略草稿与文件/网页分析<br>
|
||||
• 由记忆驱动的研究工作流
|
||||
</div>
|
||||
</td>
|
||||
<td align="center" width="50%" valign="top">
|
||||
<img src="assets/feature-multi-agent-trading-teams.png" height="130" alt="Multi-agent trading teams"/><br>
|
||||
<h3>🐝 多智能体交易团队</h3>
|
||||
<div align="left">
|
||||
• 投资、量化、加密与风控团队<br>
|
||||
• 流式进度与持久化报告<br>
|
||||
• Worker 基于已获取的市场数据展开分析
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="50%" valign="top">
|
||||
<img src="assets/feature-cross-market-data-backtesting.png" height="130" alt="Cross-market data and backtesting"/><br>
|
||||
<h3>📊 跨市场数据与回测</h3>
|
||||
<div align="left">
|
||||
• A 股、港股、美股、加密、期货与外汇<br>
|
||||
• 数据 fallback 与组合回测<br>
|
||||
• PIT 数据、验证与 run cards
|
||||
</div>
|
||||
</td>
|
||||
<td align="center" width="50%" valign="top">
|
||||
<img src="assets/feature-shadow-account.png" height="130" alt="Shadow Account"/><br>
|
||||
<h3>👥 Shadow Account</h3>
|
||||
<div align="left">
|
||||
• 券商交易日志行为诊断<br>
|
||||
• 基于规则的 Shadow Account 对比<br>
|
||||
• 可导出的审计报告与策略代码
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## 💡 What Is Vibe-Trading?
|
||||
|
||||
Vibe-Trading 是一个开源研究工作台,用于把金融问题转化为可运行的分析。它将自然语言提示连接到市场数据加载器、策略生成、回测引擎、报告、导出和持久研究记忆。
|
||||
|
||||
它面向研究、模拟和回测——并且在你选择时,可通过你自己授权的券商(如 Robinhood Agentic Trading)进行自主交易。它不托管任何资金,绝不超出你设定的限额交易,且你可随时一键停止。
|
||||
|
||||
---
|
||||
|
||||
## ✨ What You Can Do
|
||||
|
||||
| 任务 | 输出 |
|
||||
|------|------|
|
||||
| **提出交易问题** | 结合工具、数据、文档和可复用 session 上下文的市场研究。 |
|
||||
| **回测策略想法** | 策略代码、指标、benchmark 上下文、验证 artifacts 和 run cards。 |
|
||||
| **复盘自己的交易** | 券商日志解析、行为诊断、规则提取和 Shadow Account 对比。 |
|
||||
| **改进重复研究** | 持久记忆和可编辑 skills 将有用流程变成可复用工作流。 |
|
||||
| **运行分析师团队** | 面向投资、量化、加密、宏观和风控工作流的多智能体研究评审。 |
|
||||
| **交付可用成果** | 报告、TradingView Pine Script、TDX、MetaTrader 5、MCP tools,以及可延续的研究 sessions。 |
|
||||
| **跑预置 alpha zoo 横评** | 452 个 alpha 因子(Qlib 158 + Kakushadze 101 + GTJA 191 + FF5 + Carhart),一行 CLI 在你选的 universe 上算 IC + IR + alive/reversed/dead 分类 |
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Quick Example
|
||||
|
||||
```bash
|
||||
pip install vibe-trading-ai
|
||||
|
||||
# 自然语言研究
|
||||
vibe-trading run -p "Backtest a BTC-USDT 20/50 moving-average strategy for 2024, summarize return and drawdown, then export the report"
|
||||
|
||||
# 一行 CLI 跑预置 alpha zoo 横评
|
||||
vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025 --top 20
|
||||
```
|
||||
|
||||
```bash
|
||||
vibe-trading --upload trades_export.csv
|
||||
vibe-trading run -p "Analyze my trading behavior, extract my shadow strategy, and compare it with my actual trades"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 👥 Shadow Account
|
||||
|
||||
Shadow Account 从你自己的交易记录出发,而不是从通用策略模板出发。
|
||||
|
||||
上传券商导出,让智能体总结你的交易行为,然后将真实交易路径与基于规则的 shadow strategy 进行对比。
|
||||
|
||||
| 步骤 | 智能体输出 |
|
||||
|------|------------|
|
||||
| **1. 读取交易日志** | 解析来自同花顺、东方财富、富途和 generic CSV 格式的券商导出。 |
|
||||
| **2. 生成行为画像** | 持仓天数、胜率、盈亏比、回撤、处置效应、过度交易、追涨和锚定检查。 |
|
||||
| **3. 提取你的规则** | 将反复出现的入场/出场行为转化为明确策略画像,而不是空泛总结。 |
|
||||
| **4. 运行 shadow** | 回测提取出的规则,并高亮规则违背、过早离场、错过信号和替代交易路径。 |
|
||||
| **5. 交付报告** | 生成可检查、可归档或在后续 session 中继续精修的 HTML/PDF 报告。 |
|
||||
|
||||
```bash
|
||||
vibe-trading --upload trades_export.csv
|
||||
vibe-trading run -p "Analyze my trading behavior, extract my shadow strategy, and compare it with my actual trades"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Research Workflow
|
||||
|
||||
多数运行都会遵循同一条证据路径:路由请求、加载正确的市场上下文、执行工具、验证输出,并保持 artifacts 可检查。
|
||||
|
||||
| 层 | 发生什么 |
|
||||
|----|----------|
|
||||
| **Plan** | 选择相关金融 skills、tools、数据源,以及在有帮助时选择 swarm preset。 |
|
||||
| **Ground** | 通过可用 loader 拉取 A 股、港股/美股、加密、期货、外汇、文档或网页上下文。 |
|
||||
| **Execute** | 生成可测试的策略代码,运行工具,并使用匹配的回测引擎或分析工作流。 |
|
||||
| **Validate** | 在适用时加入指标、benchmark comparison、Monte Carlo、Bootstrap、Walk-Forward、run cards 和 warnings。 |
|
||||
| **Deliver** | 返回报告、artifacts、tool traces,以及面向 TradingView、TDX、MetaTrader 5、MCP clients 或后续 sessions 的导出。 |
|
||||
|
||||
---
|
||||
|
||||
## 🔩 Detailed Capabilities
|
||||
|
||||
为保持主 README 易读,详细清单折叠在下方。需要检查可用构件时可展开查看。
|
||||
|
||||
<details>
|
||||
<summary><b>Finance Skill Library</b> <sub>8 个类别中的 77 个 skills</sub></summary>
|
||||
|
||||
- 📊 77 个专业金融 skills,分布在 8 个类别中
|
||||
- 🌐 覆盖传统市场、加密与 DeFi
|
||||
- 🔬 从数据源到量化研究的完整能力链路
|
||||
|
||||
| 类别 | Skills | 示例 |
|
||||
|------|--------|------|
|
||||
| Data Source | 7 | `data-routing`, `tushare`, `yfinance`, `okx-market`, `akshare`, `mootdx`, `ccxt` |
|
||||
| Strategy | 17 | `strategy-generate`, `cross-market-strategy`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
|
||||
| Analysis | 17 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis`, `dividend-analysis` |
|
||||
| Asset Class | 9 | `options-strategy`, `options-advanced`, `convertible-bond`, `etf-analysis`, `asset-allocation`, `sector-rotation` |
|
||||
| Crypto | 7 | `perp-funding-basis`, `liquidation-heatmap`, `stablecoin-flow`, `defi-yield`, `onchain-analysis` |
|
||||
| Flow | 7 | `hk-connect-flow`, `us-etf-flow`, `edgar-sec-filings`, `financial-statement`, `adr-hshare` |
|
||||
| Tool | 11 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader`, `vnpy-export`, `alpha-zoo` |
|
||||
| Risk Analysis | 1 | `ashare-pre-st-filter` |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Preset Trading Teams</b> <sub>29 个 swarm presets</sub></summary>
|
||||
|
||||
- 🏢 29 个开箱即用的智能体团队
|
||||
- ⚡ 预配置金融工作流
|
||||
- 🎯 投资、交易与风险管理 presets
|
||||
|
||||
| Preset | 工作流 |
|
||||
|--------|--------|
|
||||
| `investment_committee` | 多空辩论 → 风险审查 → PM 最终决策 |
|
||||
| `global_equities_desk` | A 股 + 港/美股 + 加密研究员 → 全球策略师 |
|
||||
| `crypto_trading_desk` | Funding/basis + liquidation + flow → 风险经理 |
|
||||
| `earnings_research_desk` | 基本面 + 预期修正 + options → 财报策略师 |
|
||||
| `macro_rates_fx_desk` | 利率 + 外汇 + 商品 → 宏观 PM |
|
||||
| `quant_strategy_desk` | 筛选 + 因子研究 → 回测 → 风险审计 |
|
||||
| `technical_analysis_panel` | 经典 TA + Ichimoku + harmonic + Elliott + SMC → 共识 |
|
||||
| `risk_committee` | 回撤 + 尾部风险 + regime review → 审批 |
|
||||
| `global_allocation_committee` | A 股 + 加密 + 港/美股 → 跨市场配置 |
|
||||
|
||||
<sub>另有 20+ 专业 presets,可运行 vibe-trading --swarm-presets 查看全部。
|
||||
|
||||
</sub>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Alpha Zoo</b> <sub>452 个预置 alpha,覆盖 4 个 zoo</sub></summary>
|
||||
|
||||
- 🧬 452 个横截面 alpha,算子层即禁用 lookahead
|
||||
- 📈 一条 CLI 命令完成 IC + IR + alive/reversed/dead 分类
|
||||
- 🔬 AST 纯函数门禁 + 300 行 lookahead 哨兵测试 + `pytest-socket` 网络阻断
|
||||
- 📦 Qlib 部分附 Apache-2 出处声明;每个 zoo 一份 `LICENSE.md`,声明公式属于数学内容
|
||||
- 🤝 社区 PR 走 Developer Certificate of Origin (DCO) 签名流程
|
||||
|
||||
| Zoo | 数量 | 来源 | 许可 |
|
||||
|-----|------|------|------|
|
||||
| **qlib158** | 154 | Microsoft Qlib `Alpha158`(Apache-2.0,锁定 commit) | Apache-2.0 |
|
||||
| **alpha101** | 101 | Kakushadze (2015), "101 Formulaic Alphas", arXiv:1601.00991 | 公式属于数学内容 |
|
||||
| **gtja191** | 191 | 国君证券 (2014)《191 个短周期交易型 alpha 因子》研报 | 公式属于数学内容 |
|
||||
| **academic** | 6 | Fama-French 5 因子 + Carhart 动量(基于价格的代理实现) | 公开学术文献 |
|
||||
|
||||
运行 `vibe-trading alpha list` 浏览全部因子,`vibe-trading alpha show <id>` 查看公式与源码,`vibe-trading alpha bench --zoo X --universe Y --period Z` 给一整个 zoo 打分。
|
||||
|
||||
</details>
|
||||
|
||||
## 🎬 Demo
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
https://github.com/user-attachments/assets/4e4dcb80-7358-4b9a-92f0-1e29612e6e86
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
https://github.com/user-attachments/assets/3754a414-c3ee-464f-b1e8-78e1a74fbd30
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" align="center"><sub>☝️ 自然语言回测与多智能体 swarm 辩论 — Web UI + CLI</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 一行安装(PyPI)
|
||||
|
||||
```bash
|
||||
pip install vibe-trading-ai
|
||||
```
|
||||
|
||||
然后运行第一个研究任务:
|
||||
|
||||
```bash
|
||||
vibe-trading init
|
||||
vibe-trading run -p "Backtest a BTC-USDT 20/50 moving-average strategy for 2024 and summarize return and drawdown"
|
||||
```
|
||||
|
||||
> **包名与命令:** PyPI 包名是 `vibe-trading-ai`。安装后会获得三个命令:
|
||||
>
|
||||
> | 命令 | 用途 |
|
||||
> |------|------|
|
||||
> | `vibe-trading` | 交互式 CLI / TUI |
|
||||
> | `vibe-trading serve` | 启动 FastAPI web server |
|
||||
> | `vibe-trading-mcp` | 启动 MCP server(用于 Claude Desktop、OpenClaw、Cursor 等) |
|
||||
|
||||
```bash
|
||||
vibe-trading init # interactive .env setup
|
||||
vibe-trading # launch CLI
|
||||
vibe-trading serve --port 8899 # launch web UI
|
||||
vibe-trading-mcp # start MCP server (stdio)
|
||||
```
|
||||
|
||||
### 或选择一种路径
|
||||
|
||||
| 路径 | 最适合 | 时间 |
|
||||
|------|--------|------|
|
||||
| **A. Docker** | 立即试用,零本地配置 | 2 min |
|
||||
| **B. Local install** | 开发,完整 CLI 访问 | 5 min |
|
||||
| **C. MCP plugin** | 接入你现有的智能体 | 3 min |
|
||||
| **D. ClawHub** | 一条命令,无需 clone | 1 min |
|
||||
|
||||
### 前置条件
|
||||
|
||||
- 任意受支持 provider 的 **LLM API key**,或使用 **Ollama** 本地运行(无需 key)
|
||||
- 路径 B 需要 **Python 3.11+**
|
||||
- 路径 A 需要 **Docker**
|
||||
- OpenAI Codex 也可通过 ChatGPT OAuth 使用:设置 `LANGCHAIN_PROVIDER=openai-codex`,然后运行 `vibe-trading provider login openai-codex`。它不使用 `OPENAI_API_KEY`。
|
||||
|
||||
> **支持的 LLM providers:** OpenRouter、OpenAI、DeepSeek、Gemini、Groq、DashScope/Qwen、Zhipu、Moonshot/Kimi、MiniMax、Xiaomi MIMO、Z.ai、Ollama(本地)。配置见 `.env.example`。
|
||||
|
||||
> **提示:** 由于自动 fallback,所有市场都可以在没有任何 API key 的情况下工作。yfinance(港/美股)、OKX(加密)、mootdx(A 股,TCP 直连不封 IP)和 AKShare(A 股、美股、港股、期货、外汇)都是免费的。Tushare token 是可选项 —— mootdx 是首选的免 token A 股 fallback,AKShare 作为覆盖更广的兜底。
|
||||
|
||||
### Path A: Docker(零配置)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/Vibe-Trading.git
|
||||
cd Vibe-Trading
|
||||
cp agent/.env.example agent/.env
|
||||
# Edit agent/.env — uncomment your LLM provider and set API key
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
打开 `http://localhost:8899`。后端 + 前端在同一个容器中运行。
|
||||
|
||||
Docker 默认将后端发布在 `127.0.0.1:8899`,并以非 root 容器用户运行应用。如果你有意将 API 暴露到本机之外,请设置强 `API_AUTH_KEY`,并让客户端发送 `Authorization: Bearer <key>`。
|
||||
|
||||
### Path B: Local install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/Vibe-Trading.git
|
||||
cd Vibe-Trading
|
||||
python -m venv .venv
|
||||
|
||||
# Activate
|
||||
source .venv/bin/activate # Linux / macOS
|
||||
# .venv\Scripts\Activate.ps1 # Windows PowerShell
|
||||
|
||||
pip install -e .
|
||||
cp agent/.env.example agent/.env # Edit — set your LLM provider API key
|
||||
vibe-trading # Launch interactive TUI
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>启动 Web UI(可选)</b></summary>
|
||||
|
||||
```bash
|
||||
# Terminal 1: API server
|
||||
vibe-trading serve --port 8899
|
||||
|
||||
# Terminal 2: Frontend dev server
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
打开 `http://localhost:5899`。前端会将 API 调用代理到 `localhost:8899`。
|
||||
|
||||
**生产模式(单 server):**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build && cd ..
|
||||
vibe-trading serve --port 8899 # FastAPI serves dist/ as static files
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Path C: MCP plugin
|
||||
|
||||
见下方 [MCP Plugin](#-mcp-plugin) 章节。
|
||||
|
||||
### Path D: ClawHub(一条命令)
|
||||
|
||||
```bash
|
||||
npx clawhub@latest install vibe-trading --force
|
||||
```
|
||||
|
||||
skill + MCP config 会下载到你的智能体 skills 目录。详情见 [ClawHub install](#-mcp-plugin)。
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Environment Variables
|
||||
|
||||
将 `agent/.env.example` 复制为 `agent/.env`,并取消注释你想使用的 provider block。每个 provider 需要 3-4 个变量:
|
||||
|
||||
| 变量 | 必需 | 说明 |
|
||||
|------|:----:|------|
|
||||
| `LANGCHAIN_PROVIDER` | Yes | Provider 名称(`openrouter`, `deepseek`, `groq`, `ollama` 等) |
|
||||
| `<PROVIDER>_API_KEY` | Yes* | API key(`OPENROUTER_API_KEY`, `DEEPSEEK_API_KEY` 等) |
|
||||
| `<PROVIDER>_BASE_URL` | Yes | API endpoint URL |
|
||||
| `LANGCHAIN_MODEL_NAME` | Yes | 模型名称(例如 `deepseek-v4-pro`) |
|
||||
| `TUSHARE_TOKEN` | No | A 股数据的 Tushare Pro token(会 fallback 到 AKShare) |
|
||||
| `TIMEOUT_SECONDS` | No | LLM 调用超时,默认 120s |
|
||||
| `API_AUTH_KEY` | 网络部署推荐 | API 可被非本地客户端访问时要求的 Bearer token |
|
||||
| `VIBE_TRADING_ENABLE_SHELL_TOOLS` | No | 在远程 API/MCP-SSE 风格部署中显式启用 shell-capable tools |
|
||||
| `VIBE_TRADING_ALLOWED_FILE_ROOTS` | No | 文档和券商日志导入额外允许的逗号分隔 roots |
|
||||
| `VIBE_TRADING_ALLOWED_RUN_ROOTS` | No | 生成代码 run directories 额外允许的逗号分隔 roots |
|
||||
|
||||
<sub>* Ollama 不需要 API key。OpenAI Codex 使用 ChatGPT OAuth,并通过 `oauth-cli-kit` 存储 token,不写入 `agent/.env`。</sub>
|
||||
|
||||
**免费数据(无需 key):** A 股通过 AKShare,港/美股通过 yfinance,加密通过 OKX,100+ 加密交易所通过 CCXT。系统会为每个市场自动选择最佳可用数据源。
|
||||
|
||||
### 🎯 Recommended Models
|
||||
|
||||
Vibe-Trading 是高度依赖工具的智能体:skills、backtests、memory 和 swarms 都会通过工具调用流转。模型选择会直接决定智能体是实际使用工具,还是从训练数据中编造答案。
|
||||
|
||||
| 档位 | 示例 | 使用场景 |
|
||||
|------|------|----------|
|
||||
| **Best** | `anthropic/claude-opus-4.7`, `anthropic/claude-sonnet-4.6`, `openai/gpt-5.5-pro`, `google/gemini-3.5-flash` | 复杂 swarms(3+ agents)、长研究 sessions、论文级分析 |
|
||||
| **Sweet spot**(默认) | `deepseek-v4-pro`, `deepseek/deepseek-v4-pro`, `x-ai/grok-4.20`, `z-ai/glm-5.1`, `moonshotai/kimi-k2.6`, `qwen/qwen3-max-thinking` | 日常主力,约 1/10 成本下具备可靠工具调用 |
|
||||
| **避免用于 agent** | `*-nano`, `*-flash-lite`, `*-coder-next`, 小型 / 蒸馏变体 | 工具调用不可靠,智能体会看起来像是在“凭记忆回答”,而不是加载 skills 或运行回测 |
|
||||
|
||||
默认 `agent/.env.example` 使用 DeepSeek 官方 API + `deepseek-v4-pro`;OpenRouter 用户可以使用 `deepseek/deepseek-v4-pro`。
|
||||
|
||||
---
|
||||
|
||||
## 🖥 CLI Reference
|
||||
|
||||
```bash
|
||||
vibe-trading # interactive TUI
|
||||
vibe-trading run -p "..." # single run
|
||||
vibe-trading serve # API server
|
||||
vibe-trading alpha list # 浏览 452 个预置 alpha;支持 show / bench / compare / export-manifest 子命令
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>TUI 内 slash commands</b></summary>
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `/help` | 显示所有命令 |
|
||||
| `/skills` | 列出全部 77 个 finance skills |
|
||||
| `/swarm` | 列出 29 个 swarm team presets |
|
||||
| `/swarm run <preset> [vars_json]` | 运行一个 swarm team,并实时流式展示 |
|
||||
| `/swarm list` | Swarm 运行历史 |
|
||||
| `/swarm show <run_id>` | Swarm 运行详情 |
|
||||
| `/swarm cancel <run_id>` | 取消运行中的 swarm |
|
||||
| `/list` | 最近 runs |
|
||||
| `/show <run_id>` | Run 详情 + 指标 |
|
||||
| `/code <run_id>` | 生成的策略代码 |
|
||||
| `/pine <run_id>` | 导出指标(TradingView + TDX + MT5) |
|
||||
| `/trace <run_id>` | 完整执行回放 |
|
||||
| `/continue <run_id> <prompt>` | 用新指令继续一个 run |
|
||||
| `/sessions` | 列出 chat sessions |
|
||||
| `/settings` | 显示运行时配置 |
|
||||
| `/clear` | 清屏 |
|
||||
| `/quit` | 退出 |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Single run 与 flags</b></summary>
|
||||
|
||||
```bash
|
||||
vibe-trading run -p "Backtest BTC-USDT MACD strategy, last 30 days"
|
||||
vibe-trading run -p "Analyze AAPL momentum" --json
|
||||
vibe-trading run -f strategy.txt
|
||||
echo "Backtest 000001.SZ RSI" | vibe-trading run
|
||||
```
|
||||
|
||||
```bash
|
||||
vibe-trading -p "your prompt"
|
||||
vibe-trading --skills
|
||||
vibe-trading --swarm-presets
|
||||
vibe-trading --swarm-run investment_committee '{"topic":"BTC outlook"}'
|
||||
vibe-trading --list
|
||||
vibe-trading --show <run_id>
|
||||
vibe-trading --code <run_id>
|
||||
vibe-trading --pine <run_id> # Export indicators (TradingView + TDX + MT5)
|
||||
vibe-trading --trace <run_id>
|
||||
vibe-trading --continue <run_id> "refine the strategy"
|
||||
vibe-trading --upload report.pdf
|
||||
vibe-trading alpha list --zoo gtja191 --limit 10
|
||||
vibe-trading alpha show gtja191_171
|
||||
vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025 --top 20
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 💡 Examples
|
||||
|
||||
### Strategy & Backtesting
|
||||
|
||||
```bash
|
||||
# Moving average crossover on US equities
|
||||
vibe-trading run -p "Backtest a 20/50-day moving average crossover on AAPL for the past year, show Sharpe ratio and max drawdown"
|
||||
|
||||
# RSI mean-reversion on crypto
|
||||
vibe-trading run -p "Test RSI(14) mean-reversion on BTC-USDT: buy below 30, sell above 70, last 6 months"
|
||||
|
||||
# Multi-factor strategy on A-shares
|
||||
vibe-trading run -p "Backtest a momentum + value + quality multi-factor strategy on CSI 300 constituents over 2 years"
|
||||
|
||||
# After backtesting, export to TradingView / TDX / MetaTrader 5
|
||||
vibe-trading --pine <run_id>
|
||||
```
|
||||
|
||||
**一行命令横评预置 alpha zoo**:
|
||||
```bash
|
||||
vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025 --top 20
|
||||
```
|
||||
|
||||
**浏览目录** + 查看单个 alpha:
|
||||
```bash
|
||||
vibe-trading alpha list --zoo gtja191 --theme reversal --limit 10
|
||||
vibe-trading alpha show gtja191_171
|
||||
```
|
||||
|
||||
**用 zoo 因子组合多因子信号**(Python):
|
||||
```python
|
||||
from src.skills.multi_factor.zoo_signal_engine import ZooSignalEngine
|
||||
engine = ZooSignalEngine.from_zoo(["gtja191_171", "gtja191_111", "gtja191_163"])
|
||||
panel = ... # your wide OHLCV panel
|
||||
signal = engine.compute_signal(panel)
|
||||
```
|
||||
|
||||
### Market Research
|
||||
|
||||
```bash
|
||||
# Equity deep-dive
|
||||
vibe-trading run -p "Research NVDA: earnings trend, analyst consensus, option flow, and key risks for next quarter"
|
||||
|
||||
# Macro analysis
|
||||
vibe-trading run -p "Analyze the current Fed rate path, USD strength, and impact on EM equities and gold"
|
||||
|
||||
# Crypto on-chain
|
||||
vibe-trading run -p "Deep dive BTC on-chain: whale flows, exchange balances, miner activity, and funding rates"
|
||||
```
|
||||
|
||||
### Swarm Workflows
|
||||
|
||||
```bash
|
||||
# Bull/bear debate on a stock
|
||||
vibe-trading --swarm-run investment_committee '{"topic": "Is TSLA a buy at current levels?"}'
|
||||
|
||||
# Quant strategy from screening to backtest
|
||||
vibe-trading --swarm-run quant_strategy_desk '{"universe": "S&P 500", "horizon": "3 months"}'
|
||||
|
||||
# Crypto desk: funding + liquidation + flow → risk manager
|
||||
vibe-trading --swarm-run crypto_trading_desk '{"asset": "ETH-USDT", "timeframe": "1w"}'
|
||||
|
||||
# Global macro portfolio allocation
|
||||
vibe-trading --swarm-run macro_rates_fx_desk '{"focus": "Fed pivot impact on EM bonds"}'
|
||||
```
|
||||
|
||||
### Cross-Session Memory
|
||||
|
||||
```bash
|
||||
# Save your preferences once
|
||||
vibe-trading run -p "Remember: I prefer RSI-based strategies, max 10% drawdown, hold period 5–20 days"
|
||||
|
||||
# The agent recalls them in future sessions automatically
|
||||
vibe-trading run -p "Build a crypto strategy that fits my risk profile"
|
||||
```
|
||||
|
||||
### Upload & Analyze Documents
|
||||
|
||||
```bash
|
||||
# Analyze a broker export or earnings report
|
||||
vibe-trading --upload trades_export.csv
|
||||
vibe-trading run -p "Profile my trading behavior and identify any biases"
|
||||
|
||||
vibe-trading --upload NVDA_Q1_earnings.pdf
|
||||
vibe-trading run -p "Summarize the key risks and beats/misses from this earnings report"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 API Server
|
||||
|
||||
```bash
|
||||
vibe-trading serve --port 8899
|
||||
```
|
||||
|
||||
| Method | Endpoint | 说明 |
|
||||
|--------|----------|------|
|
||||
| `GET` | `/runs` | 列出 runs |
|
||||
| `GET` | `/runs/{run_id}` | Run 详情 |
|
||||
| `GET` | `/runs/{run_id}/pine` | 多平台指标导出 |
|
||||
| `POST` | `/sessions` | 创建 session |
|
||||
| `POST` | `/sessions/{id}/messages` | 发送消息 |
|
||||
| `GET` | `/sessions/{id}/events` | SSE event stream |
|
||||
| `POST` | `/upload` | 上传 PDF/file |
|
||||
| `GET` | `/swarm/presets` | 列出 swarm presets |
|
||||
| `POST` | `/swarm/runs` | 启动 swarm run |
|
||||
| `GET` | `/swarm/runs/{id}/events` | Swarm SSE stream |
|
||||
| `GET` | `/alpha/list` | 按 zoo/theme/universe 过滤列出 alpha |
|
||||
| `GET` | `/alpha/{alpha_id}` | Alpha 元数据 + 源代码 |
|
||||
| `POST` | `/alpha/bench` | 启动一个 bench job(返回 `job_id`) |
|
||||
| `GET` | `/alpha/bench/{job_id}/stream` | SSE 进度流 |
|
||||
| `GET` | `/settings/llm` | 读取 Web UI LLM settings |
|
||||
| `PUT` | `/settings/llm` | 更新本地 LLM settings |
|
||||
| `GET` | `/settings/data-sources` | 读取本地数据源 settings |
|
||||
| `PUT` | `/settings/data-sources` | 更新本地数据源 settings |
|
||||
|
||||
交互式文档:`http://localhost:8899/docs`
|
||||
|
||||
### Security defaults
|
||||
|
||||
对于 localhost 开发,`vibe-trading serve` 会保持浏览器工作流简单。对任何非本地客户端,敏感 API endpoints 都要求 `API_AUTH_KEY`;JSON/upload 请求请使用 `Authorization: Bearer <key>`。浏览器 EventSource streams 会在你于 Settings 中输入同一个 key 后由 Web UI 处理。
|
||||
|
||||
Shell-capable tools 可用于本地 CLI 与可信 localhost 工作流,但不会暴露给远程 API sessions,除非你显式设置 `VIBE_TRADING_ENABLE_SHELL_TOOLS=1`。文档和日志读取器默认限制在 upload/import roots 内;请将文件放在 `agent/uploads`、`agent/runs`、`./uploads`、`./data`、`~/.vibe-trading/uploads` 或 `~/.vibe-trading/imports` 下,或通过 `VIBE_TRADING_ALLOWED_FILE_ROOTS` 添加专用目录。
|
||||
|
||||
### Web UI Settings
|
||||
|
||||
Web UI Settings 页面允许本地用户更新 LLM provider/model、base URL、generation parameters、reasoning effort,以及 Tushare token 等可选市场数据凭据。Settings 会持久化到 `agent/.env`;provider defaults 从 `agent/src/providers/llm_providers.json` 加载。
|
||||
|
||||
Settings 读取无副作用:`GET /settings/llm` 和 `GET /settings/data-sources` 永远不会创建 `agent/.env`,并且只返回项目相对路径。Settings 读写可能暴露凭据状态或更新凭据/运行时环境,因此在配置了 `API_AUTH_KEY` 时会要求认证。如果 dev mode 下未设置 `API_AUTH_KEY`,settings 访问只接受 loopback clients。
|
||||
|
||||
---
|
||||
|
||||
## 🔌 MCP Plugin
|
||||
|
||||
Vibe-Trading 为任何 MCP-compatible client 暴露 35 个 MCP tools。它作为 stdio subprocess 运行,无需 server setup。核心 research tools 对港股/美股/加密零 API key 可用;trading connector tools 使用当前选择的 connector profile;只有 `run_swarm` 需要 LLM key。
|
||||
|
||||
<details>
|
||||
<summary><b>Claude Desktop</b></summary>
|
||||
|
||||
添加到 `claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"vibe-trading": {
|
||||
"command": "vibe-trading-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>OpenClaw</b></summary>
|
||||
|
||||
添加到 `~/.openclaw/config.yaml`:
|
||||
|
||||
```yaml
|
||||
skills:
|
||||
- name: vibe-trading
|
||||
command: vibe-trading-mcp
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Cursor / Windsurf / other MCP clients</b></summary>
|
||||
|
||||
```bash
|
||||
vibe-trading-mcp # stdio (default)
|
||||
vibe-trading-mcp --transport sse # SSE for web clients
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**暴露的 MCP tools(35):** `list_skills`, `load_skill`, `start_research_goal`, `get_research_goal`, `add_goal_evidence`, `update_research_goal_status`, `backtest`, `factor_analysis`, `analyze_options`, `pattern_recognition`, `read_url`, `read_document`, `web_search`, `write_file`, `read_file`, `list_swarm_presets`, `run_swarm`, `get_market_data`, `get_swarm_status`, `get_run_result`, `list_runs`, `reap_stale_runs`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `trading_connections`, `trading_select_connection`, `trading_check`, `trading_account`, `trading_positions`, `trading_orders`, `trading_quote`, `trading_history`.
|
||||
|
||||
<details>
|
||||
<summary><b>从 ClawHub 安装(一条命令)</b></summary>
|
||||
|
||||
```bash
|
||||
npx clawhub@latest install vibe-trading --force
|
||||
```
|
||||
|
||||
> 由于该 skill 引用了外部 API,会触发 VirusTotal 自动扫描,因此需要 `--force`。代码完全开源,可自行检查。
|
||||
|
||||
这会将 skill + MCP config 下载到你的智能体 skills 目录。无需 clone。
|
||||
|
||||
在 ClawHub 浏览:[clawhub.ai/skills/vibe-trading](https://clawhub.ai/skills/vibe-trading)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>OpenSpace — 自进化 skills</b></summary>
|
||||
|
||||
全部 77 个 finance skills 都发布在 [open-space.cloud](https://open-space.cloud),并通过 OpenSpace 的自进化引擎自主演进。
|
||||
|
||||
要配合 OpenSpace 使用,请将两个 MCP servers 都加入你的 agent config:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"openspace": {
|
||||
"command": "openspace-mcp",
|
||||
"toolTimeout": 600,
|
||||
"env": {
|
||||
"OPENSPACE_HOST_SKILL_DIRS": "/path/to/vibe-trading/agent/src/skills",
|
||||
"OPENSPACE_WORKSPACE": "/path/to/OpenSpace"
|
||||
}
|
||||
},
|
||||
"vibe-trading": {
|
||||
"command": "vibe-trading-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
OpenSpace 会自动发现全部 77 个 skills,启用 auto-fix、auto-improve 和社区分享。在任意已连接 OpenSpace 的智能体中,可通过 `search_skills("finance backtest")` 搜索 Vibe-Trading skills。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
<details>
|
||||
<summary><b>点击展开</b></summary>
|
||||
|
||||
```
|
||||
Vibe-Trading/
|
||||
├── agent/ # 后端(Python)
|
||||
│ ├── cli/ # CLI 包 —— 交互式 TUI + 子命令
|
||||
│ ├── api_server.py # FastAPI server —— runs、sessions、upload、swarm、SSE
|
||||
│ ├── mcp_server.py # MCP server —— 35 个工具,面向 OpenClaw / Claude Desktop
|
||||
│ │
|
||||
│ ├── src/
|
||||
│ │ ├── agent/ # ReAct agent 内核
|
||||
│ │ │ ├── loop.py # 5 层上下文压缩 + 读/写工具批处理
|
||||
│ │ │ ├── context.py # system prompt + 持久记忆自动召回
|
||||
│ │ │ ├── skills.py # skill loader(77 个内置 + 通过 CRUD 创建的用户 skill)
|
||||
│ │ │ ├── tools.py # tool 基类 + 注册表
|
||||
│ │ │ ├── memory.py # 每个 run 的轻量 workspace 状态
|
||||
│ │ │ ├── frontmatter.py # 共享的 YAML frontmatter 解析器
|
||||
│ │ │ └── trace.py # 执行 trace 写入器
|
||||
│ │ │
|
||||
│ │ ├── memory/ # 跨 session 持久记忆
|
||||
│ │ │ └── persistent.py # 基于文件的记忆(~/.vibe-trading/memory/)
|
||||
│ │ │
|
||||
│ │ ├── tools/ # 31 个自动发现的 agent 工具
|
||||
│ │ │ ├── backtest_tool.py # 运行回测
|
||||
│ │ │ ├── remember_tool.py # 跨 session 记忆(save/recall/forget)
|
||||
│ │ │ ├── skill_writer_tool.py # skill CRUD(save/patch/delete/file)
|
||||
│ │ │ ├── session_search_tool.py # FTS5 跨 session 搜索
|
||||
│ │ │ ├── swarm_tool.py # 启动 swarm team
|
||||
│ │ │ ├── web_search_tool.py # DuckDuckGo 网络搜索
|
||||
│ │ │ └── ... # bash、文件 I/O、因子分析、期权、alpha 浏览 + 横评等
|
||||
│ │ │
|
||||
│ │ ├── factors/ # Alpha Zoo —— 4 个 zoo 共 452 个 alpha
|
||||
│ │ │ ├── base.py # 19 个算子(rank/scale/ts_*/delta/decay_linear/safe_div/vwap)
|
||||
│ │ │ ├── registry.py # 纯 AST 元数据加载 + 惰性计算 + sanity 校验
|
||||
│ │ │ ├── bench_runner.py # IC + alive/reversed/dead 分类
|
||||
│ │ │ └── zoo/ # qlib158 (154) + alpha101 (101) + gtja191 (191) + academic (6)
|
||||
│ │ │
|
||||
│ │ ├── api/ # FastAPI 路由模块
|
||||
│ │ │ └── alpha_routes.py # /alpha/list、/alpha/{id}、/alpha/bench、SSE 流
|
||||
│ │ │
|
||||
│ │ ├── skills/ # 8 个类别共 77 个 finance skills(每个一份 SKILL.md)
|
||||
│ │ ├── swarm/ # Swarm DAG 执行引擎
|
||||
│ │ │ └── presets/ # 29 个 swarm preset YAML 定义
|
||||
│ │ ├── session/ # 多轮对话 + FTS5 session 搜索
|
||||
│ │ └── providers/ # LLM provider 抽象层
|
||||
│ │
|
||||
│ └── backtest/ # 回测引擎
|
||||
│ ├── engines/ # 7 个引擎 + 跨市场 composite 引擎 + options_portfolio
|
||||
│ ├── loaders/ # 7 个数据源:tushare、okx、yfinance、akshare、mootdx、ccxt、futu
|
||||
│ │ ├── base.py # DataLoader Protocol
|
||||
│ │ └── registry.py # Registry + 自动 fallback 链路
|
||||
│ └── optimizers/ # MVO、equal vol、max div、risk parity
|
||||
│
|
||||
├── frontend/ # Web UI(React 19 + Vite + TypeScript)
|
||||
│ └── src/
|
||||
│ ├── pages/ # Home、Agent、AlphaZoo、RunDetail、Compare、Correlation、Settings
|
||||
│ ├── components/ # chat、charts、layout
|
||||
│ └── stores/ # Zustand 状态管理
|
||||
│
|
||||
├── Dockerfile # 多阶段构建
|
||||
├── docker-compose.yml # 一条命令部署
|
||||
├── pyproject.toml # 包配置 + CLI entrypoint
|
||||
├── tools/ # 仓库级 CI 辅助脚本
|
||||
│ └── ci_grep_gates.sh # 拦截 yaml.load / 商标 / 个股数据泄露
|
||||
└── LICENSE # MIT
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🏛 Ecosystem
|
||||
|
||||
Vibe-Trading 是 **[HKUDS](https://github.com/HKUDS)** 智能体生态的一部分:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/HKUDS/nanobot"><b>NanoBot</b></a><br>
|
||||
<sub>Ultra-Lightweight Personal AI Assistant</sub>
|
||||
</td>
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/HKUDS/AI-Trader"><b>AI-Trader</b></a><br>
|
||||
<sub>Agent-Native Signal & Copy Trading Platform</sub>
|
||||
</td>
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/HKUDS/CLI-Anything"><b>CLI-Anything</b></a><br>
|
||||
<sub>Making All Software Agent-Native</sub>
|
||||
</td>
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/HKUDS/OpenSpace"><b>OpenSpace</b></a><br>
|
||||
<sub>Self-Evolving AI Agent Skills</sub>
|
||||
</td>
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/HKUDS/ClawTeam"><b>ClawTeam</b></a><br>
|
||||
<sub>Agent Swarm Intelligence</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## 🗺 Roadmap
|
||||
|
||||
> 我们按阶段交付。工作开始时,条目会移动到 [Issues](https://github.com/HKUDS/Vibe-Trading/issues)。
|
||||
|
||||
| 阶段 | 功能 | 状态 |
|
||||
|------|------|------|
|
||||
| **Trust Layer** | 可复现 run cards 已输出并展示在 Run Detail;v1 会补充 tool traces 与 citations | v0 已发布 |
|
||||
| **Hypothesis Registry** | 持久化研究假设:lifecycle status、data sources、skills、run-card links 与 invalidation notes | Backend MVP 已发布 |
|
||||
| **Research Autopilot** | 手动触发优先的研究循环:hypothesis → deterministic backtest → evidence report | 下一步 |
|
||||
| **Data Bridge** | 自带数据:本地 CSV/Parquet/SQL connectors 与 schema mapping | Planned |
|
||||
| **Options Lab** | Vol surface、Greeks dashboard、payoff/scenario explorer | Planned |
|
||||
| **Portfolio Studio** | Risk x-ray、constraints、turnover-aware optimizer、rebalance notes | Planned |
|
||||
| **Alpha Zoo** | 452 个预置 alpha 因子(Qlib 158 + Kakushadze 101 + GTJA 191 + FF5 + Carhart),一行 CLI 跑横评,agent 集成,Web UI 浏览 | **已发布 0.1.8** |
|
||||
| **Research Delivery** | 定时 briefs 到 Slack / Telegram / email-style channels | Planned |
|
||||
| **Community** | 可分享的 skills、presets 和 strategy cards | Exploring |
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
欢迎贡献!请查看 [CONTRIBUTING.md](CONTRIBUTING.md) 了解指南。
|
||||
|
||||
**Good first issues** 使用 [`good first issue`](https://github.com/HKUDS/Vibe-Trading/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) 标记,可选择一个开始。
|
||||
|
||||
想贡献更大的内容?请查看上方 [Roadmap](#-roadmap),并在开始前先开 issue 讨论。
|
||||
|
||||
---
|
||||
|
||||
## Contributors
|
||||
|
||||
感谢所有为 Vibe-Trading 做出贡献的人!
|
||||
|
||||
近期 v0.1.8 周期贡献者与致谢:
|
||||
|
||||
- @GTC2080 / TaoMu — Web UI Settings 和 provider/data-source configuration APIs (#57)
|
||||
- @BigNounce90 — backtest `run_dir` 输入的 validation CLI 加固 (#60)
|
||||
- @shadowinlife — A 股 pre-ST filter skill (#63)
|
||||
- @MB-Ndhlovu — correlation heatmap dashboard 和 review fixes (#64, #66)
|
||||
- @ykykj — OpenAI Codex OAuth provider option (#65)
|
||||
- @RuifengFu — 交互式 CLI live status bar 和 prompt editing (#69)
|
||||
- @SiMinus — swarm preset inspection command (#73)
|
||||
- @warren618 / Haozhe Wu — 安全加固、release integration、文档、Docker、packaging 和本地开发工作流
|
||||
- lemi9090 (S2W) — 协同安全研究、验证和披露支持
|
||||
|
||||
<a href="https://github.com/HKUDS/Vibe-Trading/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=HKUDS/Vibe-Trading" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Vibe-Trading 是研究与交易软件。它不是投资建议,不托管任何资金,也不运营执行场所。仅通过你自己明确授权的券商通道(如 Robinhood Agentic Trading)进行交易,且只在你设定的限额内、你可随时停止。该券商交易能力为实验性,未经我们对接真实券商账户验证——风险自负。历史表现不代表未来结果。
|
||||
|
||||
## License
|
||||
|
||||
MIT License — see [LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#HKUDS/Vibe-Trading&Date)
|
||||
|
||||
<p align="center">
|
||||
⭐ 如果 <b>Vibe-Trading</b> 对你的研究有帮助,点个 Star 让更多人看到它。
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
感谢访问 <b>Vibe-Trading</b> ✨
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.Vibe-Trading&style=flat" alt="visitors"/>
|
||||
</p>
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
|---------|:---------:|
|
||||
| latest | ✅ |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability, please report it responsibly:
|
||||
|
||||
1. **Do NOT open a public issue.**
|
||||
2. Use the [GitHub Security Advisory](https://github.com/HKUDS/Vibe-Trading/security/advisories/new) to report privately.
|
||||
3. Include steps to reproduce, potential impact, and any suggested fixes.
|
||||
|
||||
We will acknowledge your report within **5 business days** and work with you to resolve the issue.
|
||||
|
||||
## Scope
|
||||
|
||||
This policy applies to the [HKUDS/Vibe-Trading](https://github.com/HKUDS/Vibe-Trading) repository.
|
||||
|
||||
## Disclosure
|
||||
|
||||
- Please do not publicly disclose the vulnerability until we have released a fix.
|
||||
- We will credit reporters in the release notes (unless you prefer anonymity).
|
||||
+133
-11
@@ -1,22 +1,144 @@
|
||||
# ============================================================================
|
||||
# LLM Provider
|
||||
# LLM Provider — uncomment ONE provider block below
|
||||
# ============================================================================
|
||||
|
||||
# --- OpenRouter (recommended, multi-model gateway) ---
|
||||
LANGCHAIN_PROVIDER=openrouter
|
||||
OPENAI_API_KEY=sk-or-v1-your-key-here
|
||||
OPENAI_BASE_URL=https://openrouter.ai/api/v1
|
||||
LANGCHAIN_MODEL_NAME=deepseek/deepseek-v3.2
|
||||
LANGCHAIN_MODEL_NAME=deepseek/deepseek-v4-pro
|
||||
OPENROUTER_API_KEY=sk-or-v1-your-key-here
|
||||
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
|
||||
# --- OpenAI ---
|
||||
# LANGCHAIN_PROVIDER=openai
|
||||
# LANGCHAIN_MODEL_NAME=gpt-5.5-instant
|
||||
# OPENAI_API_KEY=sk-xxx
|
||||
# OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
|
||||
# --- OpenAI Codex (ChatGPT OAuth; not the OpenAI API key path) ---
|
||||
# Login first: vibe-trading provider login openai-codex
|
||||
# Requires a ChatGPT account with Codex access. OAuth tokens are stored by oauth-cli-kit.
|
||||
# LANGCHAIN_PROVIDER=openai-codex
|
||||
# LANGCHAIN_MODEL_NAME=openai-codex/gpt-5.3-codex
|
||||
# OPENAI_CODEX_BASE_URL=https://chatgpt.com/backend-api/codex/responses
|
||||
|
||||
# --- DeepSeek ---
|
||||
# LANGCHAIN_PROVIDER=deepseek
|
||||
# LANGCHAIN_MODEL_NAME=deepseek-v4-pro
|
||||
# DEEPSEEK_API_KEY=sk-xxx
|
||||
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||
|
||||
# --- Gemini ---
|
||||
# LANGCHAIN_PROVIDER=gemini
|
||||
# LANGCHAIN_MODEL_NAME=gemini-3.5-flash
|
||||
# GEMINI_API_KEY=xxx
|
||||
# GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/
|
||||
|
||||
# --- Groq ---
|
||||
# LANGCHAIN_PROVIDER=groq
|
||||
# LANGCHAIN_MODEL_NAME=meta-llama/llama-4-maverick-17b-128e-instruct
|
||||
# GROQ_API_KEY=gsk_xxx
|
||||
# GROQ_BASE_URL=https://api.groq.com/openai/v1
|
||||
|
||||
# --- DashScope / Qwen ---
|
||||
# LANGCHAIN_PROVIDER=dashscope
|
||||
# LANGCHAIN_MODEL_NAME=qwen-plus-latest
|
||||
# DASHSCOPE_API_KEY=sk-xxx
|
||||
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
|
||||
# --- Zhipu ---
|
||||
# LANGCHAIN_PROVIDER=zhipu
|
||||
# LANGCHAIN_MODEL_NAME=glm-5.1
|
||||
# ZHIPU_API_KEY=xxx
|
||||
# ZHIPU_BASE_URL=https://open.bigmodel.cn/api/paas/v4
|
||||
|
||||
# --- Moonshot / Kimi ---
|
||||
# LANGCHAIN_PROVIDER=moonshot
|
||||
# LANGCHAIN_MODEL_NAME=kimi-k2.6
|
||||
# MOONSHOT_API_KEY=sk-xxx
|
||||
# MOONSHOT_BASE_URL=https://api.moonshot.ai/v1
|
||||
|
||||
# --- MiniMax ---
|
||||
# LANGCHAIN_PROVIDER=minimax
|
||||
# LANGCHAIN_MODEL_NAME=MiniMax-M2.7 # or MiniMax-M2.7-highspeed (faster)
|
||||
# MINIMAX_API_KEY=xxx
|
||||
# MINIMAX_BASE_URL=https://api.minimax.io/v1
|
||||
# Note: MiniMax requires temperature > 0. Set LANGCHAIN_TEMPERATURE=1.0 (default when using MiniMax)
|
||||
|
||||
# --- Xiaomi MIMO ---
|
||||
# LANGCHAIN_PROVIDER=mimo
|
||||
# LANGCHAIN_MODEL_NAME=MiMo-72B-A27B
|
||||
# MIMO_API_KEY=xxx
|
||||
# MIMO_BASE_URL=https://api.xiaomimimo.com/v1
|
||||
|
||||
# --- Z.ai (for Coding Plan) ---
|
||||
# LANGCHAIN_PROVIDER=zai
|
||||
# LANGCHAIN_MODEL_NAME=glm-5.1
|
||||
# ZAI_API_KEY=xxx
|
||||
# ZAI_BASE_URL=https://api.z.ai/api/coding/paas/v4
|
||||
|
||||
# --- Ollama (local) ---
|
||||
# LANGCHAIN_PROVIDER=ollama
|
||||
# LANGCHAIN_MODEL_NAME=qwen2.5:32b
|
||||
# OLLAMA_BASE_URL=http://localhost:11434
|
||||
|
||||
# LLM parameters
|
||||
LANGCHAIN_TEMPERATURE=0.0
|
||||
TIMEOUT_SECONDS=120
|
||||
MAX_RETRIES=2
|
||||
|
||||
# Enable reasoning on relays that require opt-in (e.g. OpenRouter thinking models).
|
||||
# Forwarded as extra_body.reasoning.effort = low / medium / high / max.
|
||||
# Leave unset for Moonshot / DeepSeek official APIs — they return reasoning_content by default.
|
||||
# LANGCHAIN_REASONING_EFFORT=medium
|
||||
|
||||
# ============================================================================
|
||||
# Data Providers
|
||||
# Data Sources
|
||||
# ============================================================================
|
||||
# A-shares: Tushare Pro (requires token, https://tushare.pro)
|
||||
# A-shares: Tushare Pro token (https://tushare.pro)
|
||||
TUSHARE_TOKEN=your-tushare-token
|
||||
# HK/US equities: yfinance (free, no key needed)
|
||||
# Crypto: OKX public API (free, no key needed)
|
||||
# HK/US equities: yfinance (free, no config needed)
|
||||
# Crypto: OKX public API (free, no config needed)
|
||||
# Crypto fallback exchange (default: binance). Change if OKX is blocked:
|
||||
# CCXT_EXCHANGE=binance
|
||||
# HK / A-share equities via Futu OpenAPI (optional, requires FutuOpenD running locally)
|
||||
# Download FutuOpenD: https://www.futunn.com/download/openAPI
|
||||
# FUTU_HOST=127.0.0.1
|
||||
# FUTU_PORT=11111
|
||||
|
||||
# ============================================================================
|
||||
# Runtime
|
||||
# API Server (optional)
|
||||
# ============================================================================
|
||||
TIMEOUT_SECONDS=2400
|
||||
MAX_RETRIES=5
|
||||
# Bearer token for API auth.
|
||||
# Empty dev mode is loopback-only. Set this before exposing port 8899 beyond localhost.
|
||||
# API_AUTH_KEY=
|
||||
# CORS allowed origins (comma-separated)
|
||||
# CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://localhost:8000
|
||||
# Enable session runtime
|
||||
# ENABLE_SESSION_RUNTIME=true
|
||||
# Docker compose sets this together with a 127.0.0.1 port bind so browser
|
||||
# requests from the host machine still count as local dev-mode access.
|
||||
# Do not enable this when publishing port 8899 on 0.0.0.0 without API_AUTH_KEY.
|
||||
# VIBE_TRADING_TRUST_DOCKER_LOOPBACK=0
|
||||
# Advanced local-only opt-in: expose shell execution tools to the agent.
|
||||
# VIBE_TRADING_ENABLE_SHELL_TOOLS=0
|
||||
# Optional import roots for read_document/analyze_trade_journal.
|
||||
# Use comma-separated absolute paths. Defaults include agent/uploads, agent/runs,
|
||||
# ./uploads, ./data, ~/.vibe-trading/uploads, and ~/.vibe-trading/imports.
|
||||
# VIBE_TRADING_ALLOWED_FILE_ROOTS=
|
||||
# Optional run roots for generated-code tools such as write_file/backtest.
|
||||
# Defaults include agent/runs, ./runs, and ~/.vibe-trading/shadow_runs.
|
||||
# VIBE_TRADING_ALLOWED_RUN_ROOTS=
|
||||
|
||||
# ============================================================================
|
||||
# Agent Tuning (optional, defaults are sensible)
|
||||
# ============================================================================
|
||||
# SWARM_WORKER_TIMEOUT=300
|
||||
# SWARM_WORKER_MAX_ITER=50
|
||||
# SWARM_MAX_WORKERS=4
|
||||
# SWARM_TIMEOUT=1800
|
||||
# SUBAGENT_TIMEOUT=300
|
||||
# SUBAGENT_MAX_ITER=25
|
||||
# TOKEN_THRESHOLD=40000
|
||||
# Frontend SSE idle timeout in seconds before showing "Execution timed out".
|
||||
# Increase when using slow local models (e.g. Ollama on CPU/limited VRAM).
|
||||
# VIBE_TRADING_SSE_TIMEOUT=90
|
||||
+200
-18
@@ -1,6 +1,7 @@
|
||||
---
|
||||
name: vibe-trading
|
||||
description: Professional finance research toolkit — backtesting, factor analysis, options pricing, 64 finance skills, and 29 multi-agent swarm teams across 3 markets (A-shares, crypto, HK/US equities).
|
||||
version: 0.1.8
|
||||
description: Professional finance research toolkit — backtesting (7 engines + benchmark comparison panel), factor analysis, Alpha Zoo (452 pre-built alphas across qlib158/alpha101/gtja191/academic), options pricing, 77 finance skills, 29 multi-agent swarm teams, Trade Journal analyzer, and Shadow Account (extract → backtest → render) across 7 data sources (tushare, yfinance, okx, akshare, mootdx, ccxt, futu).
|
||||
dependencies:
|
||||
python: ">=3.11"
|
||||
pip:
|
||||
@@ -10,10 +11,10 @@ env:
|
||||
description: "Tushare API token for China A-share data (optional — HK/US/crypto work without any key)"
|
||||
required: false
|
||||
- name: OPENAI_API_KEY
|
||||
description: "OpenAI-compatible API key — only needed for run_swarm (multi-agent teams). All other 15 tools work without it."
|
||||
description: "OpenAI-compatible API key — only needed for run_swarm (multi-agent teams). All other tools work without it."
|
||||
required: false
|
||||
- name: LANGCHAIN_MODEL_NAME
|
||||
description: "LLM model name for run_swarm (e.g. deepseek/deepseek-v3.2). Only needed if using run_swarm."
|
||||
description: "LLM model name for run_swarm (e.g. deepseek/deepseek-v4-pro). Only needed if using run_swarm."
|
||||
required: false
|
||||
mcp:
|
||||
command: vibe-trading-mcp
|
||||
@@ -22,7 +23,7 @@ mcp:
|
||||
|
||||
# Vibe-Trading
|
||||
|
||||
Professional finance research toolkit with AI-powered backtesting, multi-agent teams, and 64 specialized skills.
|
||||
Professional finance research toolkit with AI-powered backtesting (7 engines), multi-agent teams, 77 specialized skills, the **Alpha Zoo** (452 pre-built quantitative alphas across qlib158 / alpha101 / gtja191 / academic with one-line CLI benchmarking), and the Shadow Account loop — extract your implicit trading rules from a journal, backtest them across A股/港股/美股/crypto, then see where they would have served you better.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -52,7 +53,7 @@ Add to your agent's MCP config:
|
||||
|
||||
### API Key Requirements
|
||||
|
||||
**15 of 16 MCP tools work with zero API keys.** After `pip install`, backtesting, market data, factor analysis, options pricing, chart patterns, and all 64 skills are ready to use for HK/US equities and crypto.
|
||||
Core research MCP tools work with zero API keys for HK/US/crypto. After `pip install`, backtesting, market data, factor analysis, options pricing, chart patterns, web search, document reading, trade journal analysis, shadow-account extraction/backtest/report, the Alpha Zoo (452 pre-built alphas), and all 77 skills are ready to use. IBKR tools require a local TWS / IB Gateway session; `run_swarm` requires an LLM key.
|
||||
|
||||
| Feature | Key needed | When |
|
||||
|---------|-----------|------|
|
||||
@@ -62,11 +63,22 @@ Add to your agent's MCP config:
|
||||
|
||||
## What You Can Do
|
||||
|
||||
### Shadow Account — flagship loop
|
||||
|
||||
Feed a CSV broker export (同花顺 / 东财 / 富途 / generic), and the agent will:
|
||||
1. `analyze_trade_journal` — profile your behavior (holding period, win rate, disposition effect, chasing, overtrading, anchoring).
|
||||
2. `extract_shadow_strategy` — distill 3-5 if-then rules that describe your profitable roundtrips.
|
||||
3. `run_shadow_backtest` — backtest those rules across A/HK/US/crypto and compute delta-PnL vs your realized trades.
|
||||
4. `render_shadow_report` — produce an HTML/PDF report (8 sections + charts) with today's matching signals.
|
||||
5. `scan_shadow_signals` — list today's symbols that match your shadow's entry cadence (research only).
|
||||
|
||||
### Backtesting
|
||||
Create and run quantitative strategies across 3 markets:
|
||||
Create and run quantitative strategies across 7 engines (ChinaA, GlobalEquity, Crypto, ChinaFutures, GlobalFutures, Forex + options) with 7 data sources:
|
||||
- **HK/US equities** via yfinance (free, no API key)
|
||||
- **Cryptocurrency** via OKX (free, no API key)
|
||||
- **China A-shares** via Tushare (requires TUSHARE_TOKEN)
|
||||
- **Cryptocurrency** via OKX or CCXT/100+ exchanges (free, no API key)
|
||||
- **China A-shares** via Tushare (token) or AKShare (free fallback)
|
||||
- **Futures, forex, macro** via AKShare (free, no API key)
|
||||
- **HK & A-share equities** via Futu (broker login required, optional)
|
||||
|
||||
Example workflow:
|
||||
1. Use `list_skills()` to discover strategy patterns
|
||||
@@ -87,40 +99,69 @@ Example workflow:
|
||||
|
||||
Use `list_swarm_presets()` to see all teams, then `run_swarm()` to execute.
|
||||
|
||||
### Finance Skills (64)
|
||||
### Alpha Zoo (452 pre-built alphas)
|
||||
One-line cross-sectional IC / IR / alive-reversed-dead categorisation across four bundled zoos:
|
||||
- **qlib158** (154 alphas) — Microsoft Qlib's `Alpha158` feature handler, Apache-2.0 with pinned commit SHA.
|
||||
- **alpha101** (101 alphas) — Kakushadze (2015) "101 Formulaic Alphas" (arXiv:1601.00991), written from the paper appendix.
|
||||
- **gtja191** (191 alphas) — Guotai Junan 2014 "191 Short-period Trading Alpha Factors" research report.
|
||||
- **academic** (6 factors) — Fama-French 5 + Carhart momentum (honest price-based proxies).
|
||||
|
||||
Each alpha ships with `__alpha_meta__` (formula LaTeX + theme + universe + warmup + columns required), guarded by an AST purity gate + 300-row lookahead sentinel test. Use the `vibe-trading alpha {list,show,bench,compare,export-manifest}` CLI, the `/alpha/*` REST routes (browser at `/alpha-zoo`), or compose multi-factor signals via `ZooSignalEngine.from_zoo(...)`.
|
||||
|
||||
### Finance Skills (77)
|
||||
Comprehensive knowledge base covering:
|
||||
- Technical analysis (candlestick, Elliott wave, Ichimoku, SMC)
|
||||
- Quantitative methods (factor research, ML strategy, pair trading)
|
||||
- Technical analysis (candlestick, Elliott wave, Ichimoku, SMC, harmonic, chanlun)
|
||||
- Quantitative methods (factor research, ML strategy, pair trading, multi-factor)
|
||||
- Risk management (VaR/CVaR, stress testing, hedging)
|
||||
- Options (Black-Scholes, Greeks, multi-leg strategies)
|
||||
- Options (Black-Scholes, Greeks, multi-leg strategies, payoff diagrams)
|
||||
- HK/US equities (SEC filings, earnings revisions, ETF flows, ADR/H-share arbitrage)
|
||||
- Crypto trading desk (funding rates, liquidation heatmaps, stablecoin flows, token unlocks, DeFi yields)
|
||||
- Behavioral finance, trade journal diagnostics, shadow account
|
||||
- Macro analysis, credit research, sector rotation, and more
|
||||
|
||||
Use `load_skill(name)` to access full methodology docs with code templates.
|
||||
|
||||
## Available MCP Tools (16)
|
||||
## Available MCP Tools (35)
|
||||
|
||||
| Tool | Description | API Key |
|
||||
|------|-------------|---------|
|
||||
| `list_skills` | List all 64 finance skills | None |
|
||||
| `list_skills` | List all 77 finance skills | None |
|
||||
| `load_skill` | Load full skill documentation | None |
|
||||
| `start_research_goal` | Create an auditable research goal | None |
|
||||
| `get_research_goal` | Read the current research goal | None |
|
||||
| `add_goal_evidence` | Attach evidence to a research goal | None |
|
||||
| `update_research_goal_status` | Update goal lifecycle status | None |
|
||||
| `backtest` | Run vectorized backtest engine | None* |
|
||||
| `factor_analysis` | IC/IR analysis + layered backtest | None* |
|
||||
| `analyze_options` | Black-Scholes price + Greeks | None |
|
||||
| `pattern_recognition` | Detect chart patterns (H&S, double top, etc.) | None |
|
||||
| `get_market_data` | Fetch OHLCV data across 3 markets (auto-detect source) | None* |
|
||||
| `get_market_data` | Fetch OHLCV data across 6 sources (auto-detect + fallback) | None* |
|
||||
| `web_search` | Search the web via DuckDuckGo | None |
|
||||
| `read_url` | Fetch web page as Markdown | None |
|
||||
| `read_document` | Extract text from PDF (with OCR) | None |
|
||||
| `read_document` | Extract text from PDF/DOCX/XLSX/PPTX/images | None |
|
||||
| `write_file` | Write files (config, strategy code) | None |
|
||||
| `read_file` | Read file contents | None |
|
||||
| `analyze_trade_journal` | Parse broker CSV → profile + behavior diagnostics | None |
|
||||
| `extract_shadow_strategy` | Distill 3-5 if-then rules from profitable roundtrips | None |
|
||||
| `run_shadow_backtest` | Multi-market backtest + delta-PnL attribution | None* |
|
||||
| `render_shadow_report` | HTML/PDF shadow report (8 sections + charts) | None |
|
||||
| `scan_shadow_signals` | Today's symbols matching the shadow's cadence | None |
|
||||
| `list_swarm_presets` | List multi-agent team presets | None |
|
||||
| `run_swarm` | Execute a multi-agent research team | LLM key |
|
||||
| `get_swarm_status` | Poll swarm run status without blocking | None |
|
||||
| `get_run_result` | Get final report and task summaries | None |
|
||||
| `list_runs` | List recent swarm runs with metadata | None |
|
||||
| `reap_stale_runs` | Finalize stale swarm runs | None |
|
||||
| `trading_connections` | List selectable connector profiles | None |
|
||||
| `trading_select_connection` | Select the default connector profile | None |
|
||||
| `trading_check` | Check connector readiness | Connector app/OAuth |
|
||||
| `trading_account` | Read account summary from selected connector | Connector app/OAuth |
|
||||
| `trading_positions` | Read positions from selected connector | Connector app/OAuth |
|
||||
| `trading_orders` | Read open orders from selected connector | Connector app/OAuth |
|
||||
| `trading_quote` | Read a quote snapshot from selected connector | Connector app/OAuth |
|
||||
| `trading_history` | Read historical bars from selected connector | Connector app/OAuth |
|
||||
|
||||
<sub>*A-share symbols require `TUSHARE_TOKEN`. HK/US/crypto are free.</sub>
|
||||
<sub>*A-share symbols require `TUSHARE_TOKEN`. HK/US/crypto are free. Trading connector rows use the selected connector profile, e.g. IBKR local TWS/Gateway or Robinhood MCP OAuth.</sub>
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -128,13 +169,154 @@ Use `load_skill(name)` to access full methodology docs with code templates.
|
||||
pip install vibe-trading-ai
|
||||
```
|
||||
|
||||
That's it — no API keys needed for HK/US/crypto markets. Start using `backtest`, `get_market_data`, `analyze_options`, and all 64 skills immediately.
|
||||
That's it — no API keys needed for HK/US/crypto markets. Start using `backtest`, `get_market_data`, `analyze_options`, `analyze_trade_journal`, `extract_shadow_strategy`, `web_search`, the **Alpha Zoo** (`vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025`), and all 77 skills immediately.
|
||||
|
||||
## Loading Tools from External MCP Servers
|
||||
|
||||
The built-in agent can load tools from your own external MCP servers in addition to its local toolset.
|
||||
|
||||
> **Note:** This is the *MCP client* path — the opposite of the MCP plugin listed above. The plugin above makes Vibe-Trading's tools available to your agents. This section lets Vibe-Trading's own agent call tools from *your* servers.
|
||||
|
||||
### Setup
|
||||
|
||||
Create `~/.vibe-trading/agent.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-server": {
|
||||
"command": "uvx",
|
||||
"args": ["my-mcp-server"],
|
||||
"toolTimeout": 30,
|
||||
"enabledTools": ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ordinary external MCP tools appear automatically in every `vibe-trading run` / `vibe-trading chat` call. They are injected after local tools under stable names: `mcp_<server>_<tool>`. Live-broker MCP servers are consumed through the connector-scoped `trading_*` tools instead of exposing raw `mcp_<broker>_*` tools to the agent.
|
||||
|
||||
### Official IBKR MCP read-only probe
|
||||
|
||||
Add Interactive Brokers' official MCP endpoint as a read-only external server:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"ibkr": {
|
||||
"type": "streamableHttp",
|
||||
"url": "https://api.ibkr.com/v1/api/mcp",
|
||||
"auth": {
|
||||
"type": "oauth",
|
||||
"scopes": ["mcp.read"],
|
||||
"clientName": "Vibe-Trading",
|
||||
"cacheDir": "~/.vibe-trading/live/ibkr/oauth"
|
||||
},
|
||||
"enabledTools": ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Authorize it with `vibe-trading connector authorize ibkr-live-official-mcp-readonly`. The wildcard is accepted
|
||||
only for this `mcp.read` probe. Generic `trading_account` and `trading_positions`
|
||||
calls stay disabled until IBKR publishes stable read tool names that Vibe-Trading
|
||||
can map safely; `mcp.write` requires an explicit tool allowlist and live
|
||||
order-guard handling. If IBKR issues a pre-registered OAuth client, add
|
||||
`clientId` and `clientSecret` inside `auth`.
|
||||
|
||||
### Trading connector profiles
|
||||
|
||||
The public trading surface is connector-first. Choose a connector profile, then
|
||||
paper/live is just an attribute under that connector.
|
||||
|
||||
```bash
|
||||
pip install "vibe-trading-ai[ibkr]"
|
||||
vibe-trading connector list
|
||||
vibe-trading connector use ibkr-paper-local
|
||||
vibe-trading connector configure ibkr-paper-local --yes
|
||||
vibe-trading connector check
|
||||
vibe-trading connector account
|
||||
vibe-trading connector positions
|
||||
vibe-trading connector orders
|
||||
vibe-trading connector quote AAPL
|
||||
vibe-trading connector history AAPL --duration "30 D" --bar-size "1 day"
|
||||
```
|
||||
|
||||
Default ports are TWS paper `7497`, IB Gateway paper `4002`, TWS live-readonly
|
||||
`7496`, and IB Gateway live-readonly `4001`.
|
||||
|
||||
### Config fields
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `type` | stdio: no, HTTP: yes | inferred only for stdio | Transport type. Use `sse` or `streamableHttp` for URL-based servers. |
|
||||
| `command` | stdio: yes | — | Executable to launch |
|
||||
| `args` | no | `[]` | Command arguments |
|
||||
| `env` | no | `{}` | Extra env vars for the subprocess |
|
||||
| `url` | HTTP: yes | — | Remote SSE / streamable HTTP endpoint URL |
|
||||
| `headers` | no | `{}` | Extra HTTP headers for SSE / streamable HTTP servers |
|
||||
| `toolTimeout` | no | `30` | Seconds before a tool call is cancelled |
|
||||
| `enabledTools` | no | `["*"]` | Allowlist of remote tool names. `["*"]` enables all |
|
||||
|
||||
For URL-based transports, `type` is required. The agent no longer guesses between SSE and streamable HTTP from the URL suffix.
|
||||
|
||||
### Per-session override (API)
|
||||
|
||||
> **Security — disabled by default.** `mcpServers` defines subprocess `command`/`args`/`env` and is therefore restricted to operator-level trust. API callers **cannot** inject MCP server definitions through `POST /sessions` unless the server operator explicitly opts in.
|
||||
|
||||
To enable session-level MCP injection, set the environment variable on the server before starting the agent:
|
||||
|
||||
```bash
|
||||
export ALLOW_SESSION_MCP_SERVERS=1
|
||||
```
|
||||
|
||||
With the opt-in active, pass `mcpServers` inside `session.config` to extend or replace the global config for that session only:
|
||||
|
||||
```json
|
||||
{
|
||||
"config": {
|
||||
"mcpServers": {
|
||||
"research": {
|
||||
"command": "uvx",
|
||||
"args": ["research-mcp"],
|
||||
"enabledTools": ["search"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Without `ALLOW_SESSION_MCP_SERVERS=1`, any `mcpServers` key in `session.config` is silently stripped before config loading. The global operator config on disk (`~/.vibe-trading/agent.json`) is always respected regardless of this flag.
|
||||
|
||||
### v1 limits
|
||||
|
||||
- **Transport:** stdio, SSE, and streamable HTTP.
|
||||
- **Execution:** serial only. MCP tools never enter the parallel readonly path.
|
||||
- **Surfaces:** tools only. Resources and prompts are not exposed.
|
||||
- **Swarm:** MCP tools are excluded from Swarm worker registries in v1.
|
||||
- **Hot reload:** not supported. Restart the process to pick up config changes.
|
||||
|
||||
### Failure handling
|
||||
|
||||
| Case | Behavior |
|
||||
|------|----------|
|
||||
| Missing config file | falls back to empty config — no MCP servers loaded |
|
||||
| Invalid config file | logs a warning and falls back to empty config |
|
||||
| Server fails to start | that server is skipped; local tools and other servers still load |
|
||||
| Tool call times out | returns a normalized error payload instead of raising |
|
||||
| Two server names collide after sanitization | deterministic hash suffix appended; operator warning emitted |
|
||||
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
**Backtest a MACD strategy on Apple:**
|
||||
> Backtest AAPL with MACD crossover strategy (fast=12, slow=26, signal=9) for 2024
|
||||
|
||||
**Analyze my trade journal and build a Shadow Account:**
|
||||
> Call analyze_trade_journal on ~/Downloads/tonghuashun.csv, then extract_shadow_strategy with min_support=3, then run_shadow_backtest for the last year, then render_shadow_report.
|
||||
|
||||
**Run an investment committee review:**
|
||||
> Use run_swarm with investment_committee preset to evaluate NVDA. Variables: target=NVDA.US, market=US
|
||||
|
||||
|
||||
+2201
-92
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Cross-asset correlation matrix computation.
|
||||
|
||||
Computes pairwise Pearson or Spearman correlation of daily returns
|
||||
over a configurable lookback window. Used by the /correlation API endpoint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Literal
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from scipy.stats import spearmanr
|
||||
|
||||
|
||||
def infer_market(code: str) -> str:
|
||||
"""Infer market key from a ticker symbol."""
|
||||
code_upper = code.upper()
|
||||
crypto_suffixes = ("USDT", "BTC", "ETH", "BNB", "SOL", "ADA", "DOGE")
|
||||
if any(code_upper.endswith(s) for s in crypto_suffixes) or "/" in code:
|
||||
return "crypto"
|
||||
# Check .HK suffix FIRST so leading-zero tickers like 0700.HK / 0005.HK
|
||||
# are correctly classified before the A-share prefix checks
|
||||
if code_upper.endswith(".HK"):
|
||||
return "hk_equity"
|
||||
if code_upper.startswith(("6", "000", "001", "002")):
|
||||
return "a_share"
|
||||
if code_upper.startswith(("0", "399")):
|
||||
return "a_share"
|
||||
if code_upper.startswith(("0", "1", "2", "3", "4")):
|
||||
return "hk_equity"
|
||||
return "us_equity"
|
||||
|
||||
|
||||
def _rolling_correlation_matrix(
|
||||
price_series: Dict[str, pd.DataFrame],
|
||||
window: int,
|
||||
method: Literal["pearson", "spearman"],
|
||||
) -> tuple[list[str], list[list[float]]]:
|
||||
"""Compute correlation matrix for multiple price series.
|
||||
|
||||
Args:
|
||||
price_series: Mapping of asset code -> DataFrame with a ``close`` column.
|
||||
window: Rolling window size in days.
|
||||
method: "pearson" or "spearman".
|
||||
|
||||
Returns:
|
||||
(labels, matrix) where labels is the sorted list of codes and matrix
|
||||
is a symmetric NxN matrix of correlation coefficients.
|
||||
"""
|
||||
if not price_series:
|
||||
return [], []
|
||||
|
||||
codes = sorted(price_series.keys())
|
||||
|
||||
# Build a aligned returns DataFrame (row index = date)
|
||||
returns_frames = []
|
||||
closes = {}
|
||||
for code, df in price_series.items():
|
||||
if df.empty:
|
||||
raise ValueError(f"Price series for '{code}' is empty")
|
||||
if "close" not in df.columns and "close" not in df.index.names:
|
||||
raise ValueError(f"No 'close' column in price series for '{code}'")
|
||||
# Support both column-based and index-based trade_date
|
||||
if "trade_date" in df.index.names and "trade_date" not in df.columns:
|
||||
ts = df["close"]
|
||||
else:
|
||||
ts = df.set_index("trade_date")["close"]
|
||||
closes[code] = ts.sort_index()
|
||||
|
||||
for code in codes:
|
||||
ts = closes[code]
|
||||
# Normalize to date-only (midnight) so that cross-market assets
|
||||
# (e.g. crypto via OKX/CCXT at UTC midnight vs US equity via
|
||||
# yfinance at EDT midnight = 04:00 UTC) align correctly.
|
||||
ts.index = ts.index.normalize()
|
||||
rets = ts.pct_change().dropna()
|
||||
rets.name = code
|
||||
returns_frames.append(rets)
|
||||
|
||||
# Align all series to a common index (inner join)
|
||||
aligned = pd.concat(returns_frames, axis=1).dropna()
|
||||
if aligned.empty:
|
||||
ranges = {
|
||||
code: f"{closes[code].index.min()} .. {closes[code].index.max()}"
|
||||
for code in codes
|
||||
if len(closes[code]) > 0
|
||||
}
|
||||
raise ValueError(
|
||||
f"No overlapping return data between assets. "
|
||||
f"Date ranges: {ranges}"
|
||||
)
|
||||
|
||||
# Apply the trailing window — only use the last `window` rows of aligned data
|
||||
if len(aligned) > window:
|
||||
aligned = aligned.iloc[-window:]
|
||||
|
||||
n = len(aligned)
|
||||
if n < 2:
|
||||
raise ValueError("Not enough data points to compute correlation")
|
||||
|
||||
labels = codes
|
||||
n_assets = len(labels)
|
||||
matrix = [[1.0] * n_assets for _ in range(n_assets)]
|
||||
|
||||
for i in range(n_assets):
|
||||
for j in range(i + 1, n_assets):
|
||||
xi = aligned.iloc[:, i].values
|
||||
xj = aligned.iloc[:, j].values
|
||||
if method == "spearman":
|
||||
corr, _ = spearmanr(xi, xj)
|
||||
else:
|
||||
corr = np.corrcoef(xi, xj)[0, 1]
|
||||
if np.isnan(corr):
|
||||
corr = 0.0
|
||||
matrix[i][j] = round(corr, 4)
|
||||
matrix[j][i] = round(corr, 4)
|
||||
|
||||
return labels, matrix
|
||||
|
||||
|
||||
def compute_correlation_matrix(
|
||||
codes: list[str],
|
||||
days: int = 90,
|
||||
method: Literal["pearson", "spearman"] = "pearson",
|
||||
) -> Dict[str, object]:
|
||||
"""Fetch price data and compute correlation matrix for a list of assets.
|
||||
|
||||
Args:
|
||||
codes: List of asset codes (e.g. ["BTC-USDT", "ETH-USDT", "SPY"]).
|
||||
days: Lookback window in days (default 90).
|
||||
method: Correlation method.
|
||||
|
||||
Returns:
|
||||
Dict with keys: labels, matrix, window, method.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
end_date = datetime.now().strftime("%Y-%m-%d")
|
||||
start_date = (datetime.now() - timedelta(days=days + 60)).strftime("%Y-%m-%d")
|
||||
|
||||
# Import here to avoid circular
|
||||
from backtest.loaders.registry import resolve_loader
|
||||
|
||||
price_series: Dict[str, pd.DataFrame] = {}
|
||||
|
||||
for code in codes:
|
||||
market = infer_market(code)
|
||||
try:
|
||||
loader = resolve_loader(market)
|
||||
except Exception:
|
||||
# Fall back to yfinance for us_equity / hk_equity
|
||||
try:
|
||||
from backtest.loaders.registry import LOADER_REGISTRY
|
||||
if "yfinance" in LOADER_REGISTRY:
|
||||
loader = LOADER_REGISTRY["yfinance"]()
|
||||
else:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
try:
|
||||
result = loader.fetch(
|
||||
codes=[code],
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
interval="1D",
|
||||
fields=["trade_date", "open", "high", "low", "close", "volume"],
|
||||
)
|
||||
if code in result and not result[code].empty:
|
||||
price_series[code] = result[code]
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if len(price_series) < 2:
|
||||
raise ValueError(
|
||||
f"Could not fetch price data for at least 2 assets. "
|
||||
f"Fetched: {list(price_series.keys())}"
|
||||
)
|
||||
|
||||
labels, matrix = _rolling_correlation_matrix(price_series, days, method)
|
||||
return {
|
||||
"labels": labels,
|
||||
"matrix": matrix,
|
||||
"window": days,
|
||||
"method": method,
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Backtest engines.
|
||||
|
||||
Wave 1 (v1):
|
||||
- BaseEngine: ABC for bar-by-bar execution with market rules
|
||||
- ChinaAEngine: A-share (T+1, no short, price limits)
|
||||
- GlobalEquityEngine: US / HK equities
|
||||
- CryptoEngine: Crypto perpetuals (funding fees, liquidation)
|
||||
- options_portfolio: European/American options (Black-Scholes, v2 with IV smile)
|
||||
|
||||
Wave 2:
|
||||
- FuturesBaseEngine: intermediate layer adding contract-multiplier logic
|
||||
- ChinaFuturesEngine: China commodity/financial futures (CFFEX/SHFE/DCE/ZCE/INE)
|
||||
- GlobalFuturesEngine: International futures (CME/ICE/Eurex)
|
||||
- ForexEngine: FX spot/CFD (spread, swap, high leverage)
|
||||
|
||||
Wave 3:
|
||||
- CompositeEngine: Cross-market engine with shared capital pool
|
||||
- _market_hooks: Extracted on_bar logic (funding, liquidation, swap)
|
||||
|
||||
Inheritance:
|
||||
BaseEngine
|
||||
├── ChinaAEngine
|
||||
├── GlobalEquityEngine
|
||||
├── CryptoEngine
|
||||
├── ForexEngine
|
||||
├── CompositeEngine (delegates to sub-engines as rule providers)
|
||||
└── FuturesBaseEngine
|
||||
├── ChinaFuturesEngine
|
||||
└── GlobalFuturesEngine
|
||||
"""
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Extracted per-bar market hooks and symbol-classification helpers.
|
||||
|
||||
Both the original engines (CryptoEngine, ForexEngine) and CompositeEngine
|
||||
call these same functions. Zero duplication — one source of truth.
|
||||
|
||||
Also hosts symbol -> market detection helpers shared by ``runner.py`` and
|
||||
``composite.py``: ``_MARKET_PATTERNS``, ``_detect_market``,
|
||||
``_is_china_futures``, ``_detect_submarket``. Keep regex tables here so the
|
||||
truncated-duplicate routing bug (bare ``RB2410`` getting routed to
|
||||
GlobalFutures because composite.py used a suffix-only check) cannot recur.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Dict, List
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.models import Position
|
||||
|
||||
|
||||
# ── Symbol -> market classification (shared by runner.py + composite.py) ──
|
||||
|
||||
_MARKET_PATTERNS = [
|
||||
(re.compile(r"^\d{6}\.(SZ|SH|BJ)$", re.I), "a_share"),
|
||||
(re.compile(r"^(51|15|56)\d{4}\.(SZ|SH)$", re.I), "a_share"),
|
||||
(re.compile(r"^[A-Z]+\.US$", re.I), "us_equity"),
|
||||
(re.compile(r"^\d{3,5}\.HK$", re.I), "hk_equity"),
|
||||
(re.compile(r"^[A-Z]+-USDT$", re.I), "crypto"),
|
||||
(re.compile(r"^[A-Z]+/USDT$", re.I), "crypto"),
|
||||
# China futures: product+delivery.exchange (e.g. IF2406.CFFEX, rb2410.SHFE)
|
||||
(re.compile(r"^[A-Za-z]{1,2}\d{3,4}\.(ZCE|DCE|SHFE|INE|CFFEX|GFEX)$", re.I), "futures"),
|
||||
# Global futures: product+month-code (e.g. ESZ4, CLF25, GCM2025)
|
||||
(re.compile(r"^[A-Z]{2,4}[FGHJKMNQUVXZ]\d{1,2}$", re.I), "futures"),
|
||||
# Global futures: product+YYMM (e.g. CL2412, ES2503)
|
||||
(re.compile(r"^[A-Z]{2,4}\d{4}$", re.I), "futures"),
|
||||
# Global futures: bare product code with exchange (e.g. ES.CME)
|
||||
(re.compile(r"^[A-Z]{2,4}\.(CME|CBOT|NYMEX|COMEX|ICE|EUREX)$", re.I), "futures"),
|
||||
# Forex pairs: XXX/YYY or XXXXXX.FX
|
||||
(re.compile(r"^[A-Z]{3}/[A-Z]{3}$"), "forex"),
|
||||
(re.compile(r"^[A-Z]{6}\.FX$"), "forex"),
|
||||
]
|
||||
|
||||
_CHINA_EXCHANGES = {"CFFEX", "SHFE", "DCE", "ZCE", "INE", "GFEX"}
|
||||
|
||||
# Known Chinese-futures product codes — used as a heuristic when a symbol
|
||||
# lacks an exchange suffix (e.g. bare ``RB2410``, ``IF2406``). Without this
|
||||
# table composite.py was misrouting such bare codes to GlobalFutures.
|
||||
# Stored lowercase; ``_is_china_futures`` lowercases the extracted product
|
||||
# before lookup so callers can pass any case (``RB2410`` and ``rb2410``
|
||||
# both resolve correctly).
|
||||
_CN_FUTURES_PRODUCTS = {
|
||||
"if", "ic", "ih", "im", "t", "tf", "ts", "tl",
|
||||
"au", "ag", "cu", "al", "zn", "pb", "ni", "sn", "ss",
|
||||
"rb", "hc", "i", "j", "jm",
|
||||
"sc", "fu", "lu", "bu", "nr",
|
||||
"c", "cs", "m", "y", "a", "p", "jd", "lh",
|
||||
"cf", "sr", "ta", "ma", "ap", "rm", "oi",
|
||||
"pp", "l", "v", "eg", "eb", "pf", "sa", "fg", "ur",
|
||||
"si", "lc",
|
||||
}
|
||||
|
||||
|
||||
def _detect_market(code: str) -> str:
|
||||
"""Infer market type from symbol format.
|
||||
|
||||
Args:
|
||||
code: Ticker / symbol string.
|
||||
|
||||
Returns:
|
||||
Market type (a_share/us_equity/hk_equity/crypto/futures/forex);
|
||||
unknown defaults to ``a_share``.
|
||||
"""
|
||||
for pattern, market in _MARKET_PATTERNS:
|
||||
if pattern.match(code):
|
||||
return market
|
||||
return "a_share"
|
||||
|
||||
|
||||
def _is_china_futures(code: str) -> bool:
|
||||
"""Check whether a futures code belongs to a Chinese exchange.
|
||||
|
||||
Recognises two forms:
|
||||
1. ``<product><delivery>.<exchange>`` where exchange is one of
|
||||
CFFEX/SHFE/DCE/ZCE/INE/GFEX (e.g. ``IF2406.CFFEX``, ``rb2410.SHFE``).
|
||||
2. Bare ``<product><delivery>`` with no exchange suffix — matched
|
||||
against ``_CN_FUTURES_PRODUCTS`` (e.g. ``RB2410`` -> True).
|
||||
|
||||
Args:
|
||||
code: Symbol string.
|
||||
|
||||
Returns:
|
||||
True if it looks like a Chinese futures contract.
|
||||
"""
|
||||
parts = code.upper().split(".")
|
||||
if len(parts) == 2:
|
||||
# Has an exchange suffix — trust it. CN exchange = True, anything
|
||||
# else = False. Without this guard the product-code heuristic below
|
||||
# would misclassify global futures whose product letters happen to
|
||||
# collide with a CN product (e.g. ``M2412.CBOT`` — US soybean meal).
|
||||
return parts[1] in _CHINA_EXCHANGES
|
||||
# Bare code (no exchange suffix): fall back to product-code heuristic.
|
||||
m = re.match(r"([A-Za-z]+)\d+", parts[0])
|
||||
if m:
|
||||
product = m.group(1).lower()
|
||||
if product in _CN_FUTURES_PRODUCTS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _detect_submarket(codes: List[str]) -> str:
|
||||
"""Detect US vs HK from symbol suffixes.
|
||||
|
||||
Args:
|
||||
codes: Instrument codes.
|
||||
|
||||
Returns:
|
||||
``"hk"`` if any code ends with ``.HK``, else ``"us"``.
|
||||
"""
|
||||
for code in codes:
|
||||
if code.upper().endswith(".HK"):
|
||||
return "hk"
|
||||
return "us"
|
||||
|
||||
# ── Crypto: OKX tiered maintenance margin table (simplified) ──
|
||||
|
||||
_TIER_TABLE = [
|
||||
(100_000, 0.004),
|
||||
(500_000, 0.006),
|
||||
(1_000_000, 0.01),
|
||||
(5_000_000, 0.02),
|
||||
(10_000_000, 0.05),
|
||||
(float("inf"), 0.10),
|
||||
]
|
||||
|
||||
FUNDING_HOURS = {0, 8, 16}
|
||||
|
||||
|
||||
def _maintenance_rate(notional_usd: float) -> float:
|
||||
"""Look up tiered maintenance margin rate."""
|
||||
for tier_max, rate in _TIER_TABLE:
|
||||
if notional_usd <= tier_max:
|
||||
return rate
|
||||
return _TIER_TABLE[-1][1]
|
||||
|
||||
|
||||
def calc_crypto_funding_fee(
|
||||
symbol: str,
|
||||
bar: pd.Series,
|
||||
timestamp: pd.Timestamp,
|
||||
positions: Dict[str, Position],
|
||||
funding_rate: float,
|
||||
applied_set: set,
|
||||
daily_done_set: set,
|
||||
) -> float:
|
||||
"""Calculate crypto funding fee for one symbol.
|
||||
|
||||
Args:
|
||||
symbol: Instrument code.
|
||||
bar: Current bar data.
|
||||
timestamp: Bar timestamp.
|
||||
positions: Shared positions dict.
|
||||
funding_rate: Fixed rate per settlement.
|
||||
applied_set: (symbol, date, hour) dedup set — mutated.
|
||||
daily_done_set: (symbol, date) dedup set — mutated.
|
||||
|
||||
Returns:
|
||||
Fee amount (positive = longs pay, negative = longs receive).
|
||||
"""
|
||||
if not hasattr(timestamp, "date"):
|
||||
return 0.0
|
||||
|
||||
current_date = timestamp.date()
|
||||
hour = timestamp.hour if hasattr(timestamp, "hour") else 0
|
||||
|
||||
if hour in FUNDING_HOURS:
|
||||
key = (symbol, current_date, hour)
|
||||
if key in applied_set:
|
||||
return 0.0
|
||||
applied_set.add(key)
|
||||
else:
|
||||
day_key = (symbol, current_date)
|
||||
if day_key in daily_done_set:
|
||||
return 0.0
|
||||
daily_done_set.add(day_key)
|
||||
|
||||
pos = positions.get(symbol)
|
||||
if pos is None:
|
||||
return 0.0
|
||||
|
||||
mark_price = float(bar.get("close", pos.entry_price))
|
||||
notional = pos.size * mark_price
|
||||
return notional * funding_rate * pos.direction
|
||||
|
||||
|
||||
def check_crypto_liquidation(
|
||||
symbol: str,
|
||||
bar: pd.Series,
|
||||
positions: Dict[str, Position],
|
||||
) -> bool:
|
||||
"""Check if a crypto position should be liquidated.
|
||||
|
||||
Args:
|
||||
symbol: Instrument code.
|
||||
bar: Current bar data.
|
||||
positions: Shared positions dict.
|
||||
|
||||
Returns:
|
||||
True if liquidation should be triggered.
|
||||
Does NOT execute the liquidation -- caller handles that.
|
||||
"""
|
||||
pos = positions.get(symbol)
|
||||
if pos is None or pos.leverage <= 1.0:
|
||||
return False
|
||||
|
||||
mark_price = float(bar.get("close", pos.entry_price))
|
||||
margin = pos.size * pos.entry_price / pos.leverage
|
||||
unrealized = pos.direction * pos.size * (mark_price - pos.entry_price)
|
||||
|
||||
notional = pos.size * mark_price
|
||||
maint_rate = _maintenance_rate(notional)
|
||||
maint_margin = notional * maint_rate
|
||||
|
||||
return (margin + unrealized) <= maint_margin
|
||||
|
||||
|
||||
# ── Forex: swap tables ──
|
||||
|
||||
_SWAP_LONG: dict[str, float] = {
|
||||
"EUR/USD": -6.5, "GBP/USD": -3.0, "USD/JPY": 8.0, "USD/CHF": 4.0,
|
||||
"AUD/USD": -2.0, "USD/CAD": 2.0, "NZD/USD": -1.5,
|
||||
}
|
||||
_SWAP_SHORT: dict[str, float] = {
|
||||
"EUR/USD": 3.5, "GBP/USD": -1.0, "USD/JPY": -12.0, "USD/CHF": -8.0,
|
||||
"AUD/USD": -1.0, "USD/CAD": -5.0, "NZD/USD": -2.0,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_symbol(symbol: str) -> str:
|
||||
"""Normalize forex symbol to 'XXX/YYY' format."""
|
||||
s = symbol.replace(".FX", "").replace(".", "").strip()
|
||||
if "/" in s:
|
||||
return s.upper()
|
||||
if len(s) == 6:
|
||||
return f"{s[:3]}/{s[3:]}".upper()
|
||||
return s.upper()
|
||||
|
||||
|
||||
def calc_forex_swap(
|
||||
symbol: str,
|
||||
timestamp: pd.Timestamp,
|
||||
positions: Dict[str, Position],
|
||||
lot_size: float,
|
||||
last_swap_dates: dict,
|
||||
) -> float:
|
||||
"""Calculate forex swap for one symbol.
|
||||
|
||||
Args:
|
||||
symbol: Forex pair.
|
||||
timestamp: Bar timestamp.
|
||||
positions: Shared positions dict.
|
||||
lot_size: Standard lot size (e.g. 100_000).
|
||||
last_swap_dates: Per-symbol date tracking dict -- mutated.
|
||||
|
||||
Returns:
|
||||
Swap amount (positive = credit, negative = debit).
|
||||
"""
|
||||
if not hasattr(timestamp, "date"):
|
||||
return 0.0
|
||||
|
||||
current_date = timestamp.date()
|
||||
if last_swap_dates.get(symbol) == current_date:
|
||||
return 0.0
|
||||
last_swap_dates[symbol] = current_date
|
||||
|
||||
pos = positions.get(symbol)
|
||||
if pos is None:
|
||||
return 0.0
|
||||
|
||||
pair = _normalize_symbol(symbol)
|
||||
lots = pos.size / lot_size
|
||||
|
||||
if pos.direction == 1:
|
||||
swap_per_lot = _SWAP_LONG.get(pair, -1.0)
|
||||
else:
|
||||
swap_per_lot = _SWAP_SHORT.get(pair, -1.0)
|
||||
|
||||
# Wednesday = triple swap (covers Sat+Sun)
|
||||
multiplier = 3.0 if timestamp.weekday() == 2 else 1.0
|
||||
return lots * swap_per_lot * multiplier
|
||||
@@ -0,0 +1,719 @@
|
||||
"""Base backtest engine with shared bar-by-bar execution loop.
|
||||
|
||||
All market engines inherit from BaseEngine and override market-rule methods.
|
||||
The shared run_backtest() handles: data loading → signal generation →
|
||||
pre-compute target weights (with optimizer) → bar-by-bar execution with
|
||||
market rule enforcement → metrics → artifacts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import re as _re
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders.tushare_fundamentals import (
|
||||
TushareFundamentalProvider,
|
||||
enrich_price_frames_with_fundamentals,
|
||||
)
|
||||
from backtest.metrics import (
|
||||
by_exit_reason_stats,
|
||||
by_symbol_stats,
|
||||
calc_metrics,
|
||||
)
|
||||
from backtest.models import EquitySnapshot, Position, TradeRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _run_card_data_sources(config: Dict[str, Any], loader: Any) -> List[str]:
|
||||
"""Return source names for run-card evidence."""
|
||||
configured = config.get("_run_card_effective_sources")
|
||||
if isinstance(configured, list):
|
||||
return [str(source) for source in configured if str(source).strip()]
|
||||
if isinstance(configured, str) and configured.strip():
|
||||
return [configured.strip()]
|
||||
|
||||
loader_name = getattr(loader, "name", None)
|
||||
if loader_name:
|
||||
return [str(loader_name)]
|
||||
|
||||
source = config.get("source")
|
||||
return [str(source)] if source else []
|
||||
|
||||
|
||||
# ─── Market detection (lightweight, for signal alignment only) ───
|
||||
|
||||
_CRYPTO_RE = _re.compile(r"^[A-Z]+-USDT$|^[A-Z]+/USDT$", _re.I)
|
||||
_FOREX_RE = _re.compile(r"^[A-Z]{3}/[A-Z]{3}$|^[A-Z]{6}\.FX$")
|
||||
|
||||
|
||||
def _detect_market_for_align(code: str) -> str:
|
||||
"""Lightweight market detection for ffill_limit calculation."""
|
||||
if _CRYPTO_RE.match(code):
|
||||
return "crypto"
|
||||
if _FOREX_RE.match(code):
|
||||
return "forex"
|
||||
return "equity"
|
||||
|
||||
|
||||
# ─── Signal alignment (reused from daily_portfolio logic) ───
|
||||
|
||||
|
||||
def _align(
|
||||
data_map: Dict[str, pd.DataFrame],
|
||||
signal_map: Dict[str, pd.Series],
|
||||
codes: List[str],
|
||||
optimizer: Optional[Callable] = None,
|
||||
) -> tuple:
|
||||
"""Build aligned date index, close matrix, target-position matrix, return matrix.
|
||||
|
||||
Signal is shifted by 1 bar (next-bar-open semantics) then normalised so
|
||||
``sum(abs(weights)) <= 1.0``.
|
||||
|
||||
Args:
|
||||
data_map: code -> OHLCV DataFrame.
|
||||
signal_map: code -> signal Series.
|
||||
codes: Valid instrument codes.
|
||||
optimizer: Optional weight optimiser ``(ret, pos, dates) -> pos``.
|
||||
|
||||
Returns:
|
||||
(dates, close_df, positions_df, returns_df)
|
||||
"""
|
||||
all_dates: set = set()
|
||||
for c in codes:
|
||||
all_dates.update(data_map[c].index)
|
||||
dates = pd.DatetimeIndex(sorted(all_dates))
|
||||
|
||||
close = pd.DataFrame(index=dates, columns=codes, dtype=float)
|
||||
for c in codes:
|
||||
close[c] = data_map[c]["close"].reindex(dates)
|
||||
|
||||
# ffill with limit to avoid masking long suspensions (e.g. 3-week halt)
|
||||
# Cross-market needs larger limit (Chinese New Year can be 9-10 bars)
|
||||
ffill_limit = 10 if len({_detect_market_for_align(c) for c in codes}) > 1 else 5
|
||||
close = close.ffill(limit=ffill_limit)
|
||||
|
||||
# Drop symbols that are entirely NaN (no data overlap with date range)
|
||||
all_nan_cols = [c for c in codes if close[c].isna().all()]
|
||||
if all_nan_cols:
|
||||
logger.warning("Symbols dropped (no usable price data): %s", all_nan_cols)
|
||||
codes = [c for c in codes if c not in all_nan_cols]
|
||||
if not codes:
|
||||
raise ValueError("All symbols have no data in the requested date range")
|
||||
close = close[codes]
|
||||
|
||||
pos = pd.DataFrame(0.0, index=dates, columns=codes)
|
||||
for c in codes:
|
||||
# Shift on each symbol's OWN trading calendar, then ffill to unified
|
||||
own_dates = data_map[c].index
|
||||
raw = signal_map[c].reindex(own_dates).fillna(0.0).clip(-1.0, 1.0)
|
||||
shifted = raw.shift(1).fillna(0.0)
|
||||
pos[c] = shifted.reindex(dates).ffill(limit=ffill_limit).fillna(0.0)
|
||||
|
||||
ret = close.pct_change().fillna(0.0)
|
||||
|
||||
if optimizer is not None:
|
||||
pos = optimizer(ret, pos, dates)
|
||||
|
||||
scale = pos.abs().sum(axis=1).clip(lower=1.0)
|
||||
pos = pos.div(scale, axis=0)
|
||||
|
||||
return dates, close, pos, ret
|
||||
|
||||
|
||||
def _load_optimizer(config: Dict[str, Any]) -> Optional[Callable]:
|
||||
"""Dynamically load an optimizer function from config.
|
||||
|
||||
Args:
|
||||
config: Backtest configuration.
|
||||
|
||||
Returns:
|
||||
Optimizer callable, or None.
|
||||
"""
|
||||
opt_name = config.get("optimizer")
|
||||
if not opt_name:
|
||||
return None
|
||||
opt_params = config.get("optimizer_params") or {}
|
||||
try:
|
||||
mod = importlib.import_module(f"backtest.optimizers.{opt_name}")
|
||||
return lambda ret, pos, dates: mod.optimize(ret, pos, dates, **opt_params)
|
||||
except (ImportError, AttributeError) as e:
|
||||
print(f"[WARN] Failed to load optimizer '{opt_name}': {e}, falling back to equal weight")
|
||||
return None
|
||||
|
||||
|
||||
def _normalise_fundamental_fields(config: Dict[str, Any]) -> dict[str, list[str]]:
|
||||
"""Read the optional statement-table field map from backtest config."""
|
||||
raw_fields = config.get("fundamental_fields")
|
||||
if raw_fields in (None, {}):
|
||||
return {}
|
||||
if not isinstance(raw_fields, dict):
|
||||
raise ValueError("fundamental_fields must map table names to field-name lists")
|
||||
|
||||
normalized: dict[str, list[str]] = {}
|
||||
for table, fields in raw_fields.items():
|
||||
if not isinstance(table, str) or not table.strip():
|
||||
raise ValueError("fundamental_fields table names must be non-empty strings")
|
||||
if fields is None:
|
||||
continue
|
||||
if isinstance(fields, str) or not isinstance(fields, Iterable):
|
||||
raise ValueError(f"fundamental_fields[{table!r}] must be a list of field names")
|
||||
|
||||
field_list = list(fields)
|
||||
if not field_list:
|
||||
continue
|
||||
invalid = [field for field in field_list if not isinstance(field, str) or not field.strip()]
|
||||
if invalid:
|
||||
raise ValueError(f"fundamental_fields[{table!r}] contains invalid field names")
|
||||
normalized[table.strip()] = field_list
|
||||
return normalized
|
||||
|
||||
|
||||
def _maybe_enrich_fundamentals(
|
||||
data_map: Dict[str, pd.DataFrame],
|
||||
config: Dict[str, Any],
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
"""Attach configured Tushare statement fields before signal generation."""
|
||||
fields_by_table = _normalise_fundamental_fields(config)
|
||||
if not fields_by_table:
|
||||
return data_map
|
||||
|
||||
try:
|
||||
provider = TushareFundamentalProvider()
|
||||
return enrich_price_frames_with_fundamentals(
|
||||
data_map,
|
||||
provider,
|
||||
fields_by_table,
|
||||
as_of=config.get("end_date", ""),
|
||||
periods=config.get("fundamental_periods"),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"fundamental_fields requested but Tushare enrichment failed: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
# ─── Base Engine ───
|
||||
|
||||
|
||||
class BaseEngine(ABC):
|
||||
"""Abstract base for all market engines.
|
||||
|
||||
Subclasses override market-rule methods:
|
||||
- can_execute: whether a trade is allowed by market rules
|
||||
- round_size: lot-size rounding
|
||||
- calc_commission: fee structure
|
||||
- apply_slippage: slippage model
|
||||
- on_bar: per-bar hooks (funding fees, liquidation, etc.)
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.config = config
|
||||
self.initial_capital: float = config.get("initial_cash", 1_000_000)
|
||||
self.default_leverage: float = config.get("leverage", 1.0)
|
||||
self.capital: float = self.initial_capital
|
||||
self.positions: Dict[str, Position] = {}
|
||||
self.trades: List[TradeRecord] = []
|
||||
self.equity_snapshots: List[EquitySnapshot] = []
|
||||
self._bar_idx: int = 0
|
||||
self._active_symbol: str = "" # set by _rebalance/_close_position for subclass use
|
||||
|
||||
# ── Market rule interface (subclass must implement) ──
|
||||
|
||||
@abstractmethod
|
||||
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
|
||||
"""Whether market rules allow this trade.
|
||||
|
||||
Args:
|
||||
symbol: Instrument identifier.
|
||||
direction: 1 (long), -1 (short), 0 (close).
|
||||
bar: Current bar data (OHLCV + extras).
|
||||
|
||||
Returns:
|
||||
True if allowed.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def round_size(self, raw_size: float, price: float) -> float:
|
||||
"""Round position size per market lot rules.
|
||||
|
||||
Args:
|
||||
raw_size: Desired size.
|
||||
price: Current price.
|
||||
|
||||
Returns:
|
||||
Rounded size.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def calc_commission(self, size: float, price: float, direction: int, is_open: bool) -> float:
|
||||
"""Calculate commission for a trade.
|
||||
|
||||
Args:
|
||||
size: Trade size.
|
||||
price: Execution price.
|
||||
direction: 1 or -1.
|
||||
is_open: True for opening, False for closing.
|
||||
|
||||
Returns:
|
||||
Commission amount.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def apply_slippage(self, price: float, direction: int) -> float:
|
||||
"""Apply slippage to execution price.
|
||||
|
||||
Args:
|
||||
price: Raw price.
|
||||
direction: 1 (buying / covering short) or -1 (selling / shorting).
|
||||
|
||||
Returns:
|
||||
Slipped price.
|
||||
"""
|
||||
|
||||
def on_bar(self, symbol: str, bar: pd.Series, timestamp: pd.Timestamp) -> None:
|
||||
"""Per-bar market-rule hook (funding fees, liquidation, etc.).
|
||||
|
||||
Default: no-op. Override in subclass as needed.
|
||||
"""
|
||||
|
||||
# ── PnL / margin calculation hooks ──
|
||||
# Override in FuturesBaseEngine to inject contract multiplier.
|
||||
|
||||
def _calc_pnl(
|
||||
self, symbol: str, direction: int, size: float,
|
||||
entry_price: float, exit_price: float,
|
||||
) -> float:
|
||||
"""Realised PnL for a closed position."""
|
||||
return direction * size * (exit_price - entry_price)
|
||||
|
||||
def _calc_margin(
|
||||
self, symbol: str, size: float, price: float, leverage: float,
|
||||
) -> float:
|
||||
"""Margin (collateral) required for a position."""
|
||||
return size * price / leverage
|
||||
|
||||
def _calc_raw_size(
|
||||
self, symbol: str, target_notional: float, price: float,
|
||||
) -> float:
|
||||
"""Convert target notional exposure to number of units/contracts."""
|
||||
return target_notional / price
|
||||
|
||||
# ── Main entry ──
|
||||
|
||||
def run_backtest(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
loader: Any,
|
||||
signal_engine: Any,
|
||||
run_dir: Path,
|
||||
bars_per_year: int = 252,
|
||||
) -> Dict[str, Any]:
|
||||
"""Full backtest pipeline.
|
||||
|
||||
Signature matches ``daily_portfolio.run_backtest`` for drop-in replacement.
|
||||
|
||||
Args:
|
||||
config: Backtest configuration dict.
|
||||
loader: DataLoader with ``fetch()`` method.
|
||||
signal_engine: SignalEngine with ``generate()`` method.
|
||||
run_dir: Artifacts output directory.
|
||||
bars_per_year: Annualisation factor.
|
||||
|
||||
Returns:
|
||||
Metrics dictionary.
|
||||
"""
|
||||
codes = config.get("codes", [])
|
||||
interval = config.get("interval", "1D")
|
||||
extra_fields = config.get("extra_fields") or None
|
||||
|
||||
# 1. Load data
|
||||
data_map = loader.fetch(
|
||||
codes,
|
||||
config.get("start_date", ""),
|
||||
config.get("end_date", ""),
|
||||
fields=extra_fields,
|
||||
interval=interval,
|
||||
)
|
||||
if not data_map:
|
||||
print(json.dumps({"error": "No data fetched"}))
|
||||
sys.exit(1)
|
||||
data_map = _maybe_enrich_fundamentals(data_map, config)
|
||||
|
||||
# 2. Generate signals
|
||||
signal_map = signal_engine.generate(data_map)
|
||||
if not isinstance(signal_map, dict):
|
||||
print(json.dumps({"error": (
|
||||
f"SignalEngine.generate() must return Dict[str, pd.Series], "
|
||||
f"got {type(signal_map).__name__}. "
|
||||
"Return a dict mapping symbol codes to pandas Series of signals."
|
||||
)}))
|
||||
sys.exit(1)
|
||||
for _code, _sig in signal_map.items():
|
||||
if not isinstance(_sig, pd.Series):
|
||||
print(json.dumps({"error": (
|
||||
f"SignalEngine.generate() returned {type(_sig).__name__} for '{_code}', "
|
||||
"expected pd.Series. Each value must be a pandas Series with DatetimeIndex."
|
||||
)}))
|
||||
sys.exit(1)
|
||||
valid_codes = sorted(c for c in signal_map if c in data_map)
|
||||
if not valid_codes:
|
||||
print(json.dumps({"error": "No valid signals generated"}))
|
||||
sys.exit(1)
|
||||
|
||||
# 3. Pre-compute target weights (with optimizer)
|
||||
opt_fn = _load_optimizer(config)
|
||||
dates, close_df, target_pos, ret_df = _align(
|
||||
data_map, signal_map, valid_codes, optimizer=opt_fn,
|
||||
)
|
||||
|
||||
# Sync codes after _align may have dropped all-NaN symbols
|
||||
valid_codes = [c for c in valid_codes if c in target_pos.columns]
|
||||
|
||||
# 4. Bar-by-bar execution
|
||||
self._execute_bars(dates, data_map, close_df, target_pos, valid_codes)
|
||||
|
||||
# 5. Build output series
|
||||
equity_series = pd.Series(
|
||||
[s.equity for s in self.equity_snapshots],
|
||||
index=[s.timestamp for s in self.equity_snapshots],
|
||||
)
|
||||
bench_ret = ret_df.mean(axis=1) if ret_df.shape[1] > 0 else pd.Series(0.0, index=dates)
|
||||
benchmark_metadata = {}
|
||||
|
||||
# ── External benchmark fetch ──────────────────────────────────────────
|
||||
bench_ticker = config.get("benchmark")
|
||||
if bench_ticker and bench_ticker != "auto":
|
||||
from backtest.benchmark import resolve_benchmark
|
||||
bench_result = resolve_benchmark(
|
||||
strategy_codes=codes,
|
||||
source=config.get("source", "yfinance"),
|
||||
start_date=config.get("start_date", ""),
|
||||
end_date=config.get("end_date", ""),
|
||||
interval=interval,
|
||||
explicit=bench_ticker,
|
||||
)
|
||||
if bench_result is not None:
|
||||
bench_ret = bench_result.ret_series.reindex(dates).fillna(0.0)
|
||||
benchmark_metadata = {
|
||||
"benchmark_ticker": bench_result.ticker,
|
||||
"benchmark_return": bench_result.total_ret,
|
||||
}
|
||||
# ── External benchmark fetch ──────────────────────────────────────────
|
||||
|
||||
bench_equity = self.initial_capital * (1 + bench_ret).cumprod()
|
||||
|
||||
# 6. Metrics
|
||||
m = calc_metrics(equity_series, self.trades, self.initial_capital, bars_per_year, bench_ret)
|
||||
m.update(benchmark_metadata)
|
||||
m["by_symbol"] = by_symbol_stats(self.trades)
|
||||
m["by_exit_reason"] = by_exit_reason_stats(self.trades)
|
||||
|
||||
# 7. Validation (optional — triggered by config["validation"])
|
||||
if config.get("validation"):
|
||||
from backtest.validation import run_validation
|
||||
v_results = run_validation(
|
||||
config, equity_series, self.trades, self.initial_capital, bars_per_year,
|
||||
)
|
||||
m["validation"] = v_results
|
||||
# Write validation.json artifact
|
||||
v_path = run_dir / "artifacts" / "validation.json"
|
||||
v_path.write_text(json.dumps(v_results, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
# 8. Artifacts
|
||||
self._write_artifacts(
|
||||
run_dir, data_map, dates, equity_series, bench_equity, bench_ret,
|
||||
target_pos, m, valid_codes,
|
||||
)
|
||||
|
||||
# 9. Trust Layer run card
|
||||
from backtest.run_card import write_run_card
|
||||
write_run_card(
|
||||
run_dir,
|
||||
config,
|
||||
m,
|
||||
data_sources=_run_card_data_sources(config, loader),
|
||||
strategy_path=run_dir / "code" / "signal_engine.py",
|
||||
)
|
||||
|
||||
# Print scalar metrics (skip nested dicts for JSON compat)
|
||||
print(json.dumps({k: v for k, v in m.items() if not isinstance(v, dict)}, indent=2))
|
||||
return m
|
||||
|
||||
# ── Execution loop ──
|
||||
|
||||
def _execute_bars(
|
||||
self,
|
||||
dates: pd.DatetimeIndex,
|
||||
data_map: Dict[str, pd.DataFrame],
|
||||
close_df: pd.DataFrame,
|
||||
target_pos: pd.DataFrame,
|
||||
codes: List[str],
|
||||
) -> None:
|
||||
"""Bar-by-bar execution with market rule enforcement."""
|
||||
for i, ts in enumerate(dates):
|
||||
self._bar_idx = i
|
||||
|
||||
# a. Per-bar hooks (funding fees, liquidation checks)
|
||||
for c in codes:
|
||||
if ts in data_map[c].index:
|
||||
self.on_bar(c, data_map[c].loc[ts], ts)
|
||||
|
||||
# b. Rebalance each symbol to target weight
|
||||
equity = self._calc_equity(close_df, ts)
|
||||
for c in codes:
|
||||
try:
|
||||
target_w = float(target_pos.at[ts, c]) if ts in target_pos.index else 0.0
|
||||
self._rebalance(c, target_w, data_map.get(c), ts, equity)
|
||||
except Exception as exc:
|
||||
logger.warning("Rebalance failed for %s at %s: %s", c, ts, exc)
|
||||
|
||||
# c. Record equity snapshot
|
||||
snap_equity = self._calc_equity(close_df, ts)
|
||||
total_unrealized = 0.0
|
||||
for p in self.positions.values():
|
||||
cp = self._safe_price(close_df, ts, p.symbol, p.entry_price)
|
||||
total_unrealized += self._calc_pnl(p.symbol, p.direction, p.size, p.entry_price, cp)
|
||||
self.equity_snapshots.append(EquitySnapshot(
|
||||
timestamp=ts,
|
||||
capital=self.capital,
|
||||
unrealized=total_unrealized,
|
||||
equity=snap_equity,
|
||||
positions=len(self.positions),
|
||||
))
|
||||
|
||||
# d. Force close all remaining positions
|
||||
if len(dates) > 0:
|
||||
last_ts = dates[-1]
|
||||
for c in list(self.positions.keys()):
|
||||
price = self._safe_price(close_df, last_ts, c, self.positions[c].entry_price)
|
||||
self._close_position(c, price, last_ts, "end_of_backtest")
|
||||
|
||||
def _calc_equity(self, close_df: pd.DataFrame, ts: pd.Timestamp) -> float:
|
||||
"""Total equity = free cash + sum(margin + unrealised) per position."""
|
||||
equity = self.capital
|
||||
for sym, pos in self.positions.items():
|
||||
cp = self._safe_price(close_df, ts, sym, pos.entry_price)
|
||||
margin = self._calc_margin(sym, pos.size, pos.entry_price, pos.leverage)
|
||||
unrealized = self._calc_pnl(sym, pos.direction, pos.size, pos.entry_price, cp)
|
||||
equity += margin + unrealized
|
||||
return equity
|
||||
|
||||
def _rebalance(
|
||||
self,
|
||||
symbol: str,
|
||||
target_weight: float,
|
||||
df: Optional[pd.DataFrame],
|
||||
ts: pd.Timestamp,
|
||||
equity: float,
|
||||
) -> None:
|
||||
"""Adjust position for *symbol* toward *target_weight*."""
|
||||
self._active_symbol = symbol
|
||||
target_dir = 1 if target_weight > 1e-9 else (-1 if target_weight < -1e-9 else 0)
|
||||
current_pos = self.positions.get(symbol)
|
||||
|
||||
# Nothing to do
|
||||
if current_pos is None and target_dir == 0:
|
||||
return
|
||||
if df is None or ts not in df.index:
|
||||
return
|
||||
|
||||
bar = df.loc[ts]
|
||||
|
||||
# Close if target is flat or direction changed
|
||||
if current_pos is not None:
|
||||
need_close = target_dir == 0 or target_dir != current_pos.direction
|
||||
if need_close:
|
||||
if self.can_execute(symbol, 0, bar):
|
||||
open_price = float(bar.get("open", bar.get("close", 0)))
|
||||
price = self.apply_slippage(open_price, -current_pos.direction)
|
||||
self._close_position(symbol, price, ts, "signal")
|
||||
else:
|
||||
return # blocked (e.g. limit-down can't sell)
|
||||
|
||||
# Open new if target non-zero and no remaining position
|
||||
if target_dir != 0 and symbol not in self.positions:
|
||||
if not self.can_execute(symbol, target_dir, bar):
|
||||
return # blocked (e.g. A-share no-short)
|
||||
|
||||
open_price = float(bar.get("open", bar.get("close", 0)))
|
||||
if open_price <= 0:
|
||||
return
|
||||
|
||||
slipped = self.apply_slippage(open_price, target_dir)
|
||||
leverage = self.default_leverage
|
||||
target_notional = abs(target_weight) * equity * leverage
|
||||
raw_size = self._calc_raw_size(symbol, target_notional, slipped)
|
||||
size = self.round_size(raw_size, slipped)
|
||||
if size <= 0:
|
||||
return
|
||||
|
||||
margin = self._calc_margin(symbol, size, slipped, leverage)
|
||||
comm = self.calc_commission(size, slipped, target_dir, is_open=True)
|
||||
|
||||
# Capital check — reduce if insufficient
|
||||
if margin + comm > self.capital:
|
||||
available = self.capital - comm
|
||||
if available <= 0:
|
||||
return
|
||||
size = self.round_size(
|
||||
self._calc_raw_size(symbol, available * leverage, slipped), slipped,
|
||||
)
|
||||
if size <= 0:
|
||||
return
|
||||
margin = self._calc_margin(symbol, size, slipped, leverage)
|
||||
comm = self.calc_commission(size, slipped, target_dir, is_open=True)
|
||||
|
||||
self.capital -= (margin + comm)
|
||||
self.positions[symbol] = Position(
|
||||
symbol=symbol,
|
||||
direction=target_dir,
|
||||
entry_price=slipped,
|
||||
entry_time=ts,
|
||||
size=size,
|
||||
leverage=leverage,
|
||||
entry_bar_idx=self._bar_idx,
|
||||
entry_commission=comm,
|
||||
)
|
||||
|
||||
def _close_position(
|
||||
self,
|
||||
symbol: str,
|
||||
exit_price: float,
|
||||
exit_time: pd.Timestamp,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""Close position, record trade, return capital."""
|
||||
self._active_symbol = symbol
|
||||
pos = self.positions.pop(symbol, None)
|
||||
if pos is None:
|
||||
return
|
||||
|
||||
pnl = self._calc_pnl(symbol, pos.direction, pos.size, pos.entry_price, exit_price)
|
||||
margin = self._calc_margin(symbol, pos.size, pos.entry_price, pos.leverage)
|
||||
pnl_pct = pnl / margin * 100 if margin > 1e-9 else 0.0
|
||||
exit_comm = self.calc_commission(pos.size, exit_price, pos.direction, is_open=False)
|
||||
|
||||
self.capital += margin + pnl - exit_comm
|
||||
|
||||
holding_bars = max(self._bar_idx - pos.entry_bar_idx, 0)
|
||||
|
||||
self.trades.append(TradeRecord(
|
||||
symbol=symbol,
|
||||
direction=pos.direction,
|
||||
entry_price=pos.entry_price,
|
||||
exit_price=exit_price,
|
||||
entry_time=pos.entry_time,
|
||||
exit_time=exit_time,
|
||||
size=pos.size,
|
||||
leverage=pos.leverage,
|
||||
pnl=pnl,
|
||||
pnl_pct=pnl_pct,
|
||||
exit_reason=reason,
|
||||
holding_bars=holding_bars,
|
||||
commission=pos.entry_commission + exit_comm,
|
||||
))
|
||||
|
||||
# ── Artifacts ──
|
||||
|
||||
def _write_artifacts(
|
||||
self,
|
||||
run_dir: Path,
|
||||
data_map: Dict[str, pd.DataFrame],
|
||||
dates: pd.DatetimeIndex,
|
||||
equity_series: pd.Series,
|
||||
bench_equity: pd.Series,
|
||||
bench_ret: pd.Series,
|
||||
target_pos: pd.DataFrame,
|
||||
metrics: dict,
|
||||
codes: List[str],
|
||||
) -> None:
|
||||
"""Write CSV artifacts compatible with daily_portfolio format."""
|
||||
out = run_dir / "artifacts"
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# OHLCV per symbol
|
||||
for code, df in data_map.items():
|
||||
df.to_csv(out / f"ohlcv_{code}.csv")
|
||||
|
||||
# Equity curve
|
||||
port_ret = equity_series.pct_change().fillna(0.0)
|
||||
peak = equity_series.cummax()
|
||||
dd = (equity_series - peak) / peak.replace(0, 1)
|
||||
eq_df = pd.DataFrame({
|
||||
"ret": port_ret,
|
||||
"equity": equity_series,
|
||||
"drawdown": dd,
|
||||
"benchmark_equity": bench_equity.reindex(dates),
|
||||
"active_ret": port_ret - bench_ret.reindex(dates).fillna(0.0),
|
||||
}, index=dates)
|
||||
eq_df.index.name = "timestamp"
|
||||
eq_df.to_csv(out / "equity.csv")
|
||||
|
||||
# Position weights (target, for compatibility)
|
||||
target_pos.index.name = "timestamp"
|
||||
target_pos.to_csv(out / "positions.csv")
|
||||
|
||||
# Trades (compatible format)
|
||||
trade_rows = []
|
||||
for t in self.trades:
|
||||
# Entry event
|
||||
trade_rows.append({
|
||||
"timestamp": str(t.entry_time.date()) if hasattr(t.entry_time, "date") else str(t.entry_time),
|
||||
"code": t.symbol,
|
||||
"side": "buy" if t.direction == 1 else "sell",
|
||||
"price": round(t.entry_price, 4),
|
||||
"qty": round(t.size, 6),
|
||||
"reason": "signal",
|
||||
"pnl": 0.0,
|
||||
"holding_days": 0,
|
||||
"return_pct": 0.0,
|
||||
})
|
||||
# Exit event
|
||||
try:
|
||||
hold_days = (t.exit_time - t.entry_time).days
|
||||
except Exception:
|
||||
hold_days = 0
|
||||
trade_rows.append({
|
||||
"timestamp": str(t.exit_time.date()) if hasattr(t.exit_time, "date") else str(t.exit_time),
|
||||
"code": t.symbol,
|
||||
"side": "sell" if t.direction == 1 else "buy",
|
||||
"price": round(t.exit_price, 4),
|
||||
"qty": round(t.size, 6),
|
||||
"reason": t.exit_reason,
|
||||
"pnl": round(t.pnl, 4),
|
||||
"holding_days": hold_days,
|
||||
"return_pct": round(t.pnl_pct, 2),
|
||||
})
|
||||
|
||||
trade_cols = ["timestamp", "code", "side", "price", "qty", "reason", "pnl", "holding_days", "return_pct"]
|
||||
pd.DataFrame(trade_rows or [], columns=trade_cols).to_csv(out / "trades.csv", index=False)
|
||||
|
||||
# Metrics
|
||||
flat_metrics = {k: v for k, v in metrics.items() if not isinstance(v, dict)}
|
||||
pd.DataFrame([flat_metrics]).to_csv(out / "metrics.csv", index=False)
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
@staticmethod
|
||||
def _safe_price(
|
||||
close_df: pd.DataFrame,
|
||||
ts: pd.Timestamp,
|
||||
symbol: str,
|
||||
fallback: float,
|
||||
) -> float:
|
||||
"""Get close price with fallback."""
|
||||
if ts in close_df.index and symbol in close_df.columns:
|
||||
val = close_df.at[ts, symbol]
|
||||
if pd.notna(val):
|
||||
return float(val)
|
||||
return fallback
|
||||
@@ -0,0 +1,155 @@
|
||||
"""A-share (China mainland) backtest engine.
|
||||
|
||||
Market rules:
|
||||
- T+1: cannot sell shares bought today
|
||||
- No short selling for retail investors
|
||||
- Price limits: ±10% main board, ±20% ChiNext/STAR, ±5% ST
|
||||
- Minimum lot: 100 shares (odd lots can only be sold, not bought)
|
||||
- Commission: ¥5 minimum, 0.025% bilateral
|
||||
- Stamp tax: 0.05% sell-side only
|
||||
- Transfer fee: 0.001% bilateral
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.engines.base import BaseEngine
|
||||
|
||||
|
||||
class ChinaAEngine(BaseEngine):
|
||||
"""A-share market engine.
|
||||
|
||||
Config keys:
|
||||
- commission_rate: default 0.00025 (万2.5)
|
||||
- commission_min: default 5.0 (RMB)
|
||||
- stamp_tax: default 0.0005 (万5, sell-only)
|
||||
- transfer_fee: default 0.00001 (万0.1)
|
||||
- slippage: default 0.001
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
config = {**config, "leverage": 1.0} # A-shares: no leverage
|
||||
super().__init__(config)
|
||||
self.commission_rate: float = config.get("commission_rate", 0.00025)
|
||||
self.commission_min: float = config.get("commission_min", 5.0)
|
||||
self.stamp_tax: float = config.get("stamp_tax", 0.0005)
|
||||
self.transfer_fee: float = config.get("transfer_fee", 0.00001)
|
||||
self.slippage_rate: float = config.get("slippage", 0.001)
|
||||
|
||||
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
|
||||
"""A-share execution rules.
|
||||
|
||||
Args:
|
||||
symbol: Stock code (e.g. 000001.SZ).
|
||||
direction: 1 (buy), -1 (short — always blocked), 0 (sell/close).
|
||||
bar: Current bar (needs 'close', 'pre_close' or 'pct_chg').
|
||||
|
||||
Returns:
|
||||
True if the trade is allowed.
|
||||
"""
|
||||
# 1. No short selling
|
||||
if direction == -1:
|
||||
return False
|
||||
|
||||
# 2. T+1: can't sell shares bought today
|
||||
if direction == 0:
|
||||
pos = self.positions.get(symbol)
|
||||
if pos is not None:
|
||||
bar_date = _bar_date(bar)
|
||||
entry_date = pos.entry_time.date() if hasattr(pos.entry_time, "date") else None
|
||||
if bar_date is not None and entry_date is not None and bar_date == entry_date:
|
||||
return False
|
||||
|
||||
# 3. Price limits
|
||||
pct_chg = _calc_pct_change(bar)
|
||||
if pct_chg is not None:
|
||||
limit = _price_limit(symbol)
|
||||
if direction == 1 and pct_chg >= limit - 0.001:
|
||||
return False # limit-up: can't buy
|
||||
if direction == 0 and pct_chg <= -limit + 0.001:
|
||||
return False # limit-down: can't sell
|
||||
|
||||
return True
|
||||
|
||||
def round_size(self, raw_size: float, price: float) -> float:
|
||||
"""Round down to 100-share lots."""
|
||||
return max(int(raw_size / 100) * 100, 0)
|
||||
|
||||
def calc_commission(self, size: float, price: float, _direction: int, is_open: bool) -> float:
|
||||
"""A-share fee structure: commission + stamp tax (sell) + transfer fee.
|
||||
|
||||
``_direction`` is unused today — reserved for future asymmetric
|
||||
long/short fee schedules (margin trading, securities lending).
|
||||
"""
|
||||
notional = size * price
|
||||
# Commission: 万2.5, min ¥5
|
||||
comm = max(notional * self.commission_rate, self.commission_min)
|
||||
# Transfer fee: 万0.1 bilateral
|
||||
comm += notional * self.transfer_fee
|
||||
# Stamp tax: 万5 sell-only
|
||||
if not is_open:
|
||||
comm += notional * self.stamp_tax
|
||||
return comm
|
||||
|
||||
def apply_slippage(self, price: float, direction: int) -> float:
|
||||
"""A-share slippage (relatively small due to tick size)."""
|
||||
return price * (1 + direction * self.slippage_rate)
|
||||
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
|
||||
def _bar_date(bar: pd.Series):
|
||||
"""Extract date from bar, handling various column names."""
|
||||
for col in ("trade_date", "date"):
|
||||
if col in bar.index:
|
||||
val = bar[col]
|
||||
if hasattr(val, "date"):
|
||||
return val.date()
|
||||
try:
|
||||
return pd.Timestamp(val).date()
|
||||
except Exception:
|
||||
pass
|
||||
# Fall back to bar name (index timestamp)
|
||||
if hasattr(bar, "name") and hasattr(bar.name, "date"):
|
||||
return bar.name.date()
|
||||
return None
|
||||
|
||||
|
||||
# Note: china_futures and global_futures have variants that prioritise
|
||||
# settle/pre_settle (futures-native); see those modules for the
|
||||
# futures-specific logic.
|
||||
def _calc_pct_change(bar: pd.Series):
|
||||
"""Calculate price change percentage from bar data."""
|
||||
if "pct_chg" in bar.index:
|
||||
val = bar["pct_chg"]
|
||||
if pd.notna(val):
|
||||
return float(val) / 100.0 # tushare pct_chg is in percentage points
|
||||
|
||||
close = bar.get("close")
|
||||
pre_close = bar.get("pre_close")
|
||||
if close is not None and pre_close is not None and pre_close > 0:
|
||||
return (float(close) - float(pre_close)) / float(pre_close)
|
||||
return None
|
||||
|
||||
|
||||
def _price_limit(symbol: str) -> float:
|
||||
"""Determine price limit based on board.
|
||||
|
||||
Args:
|
||||
symbol: Stock code (e.g. 300001.SZ, 688001.SH, 000001.SZ).
|
||||
|
||||
Returns:
|
||||
Limit as fraction (0.10, 0.20, or 0.05).
|
||||
"""
|
||||
code = symbol.split(".")[0] if "." in symbol else symbol
|
||||
# ChiNext (300xxx) / STAR (688xxx): ±20%
|
||||
if code.startswith("300") or code.startswith("688"):
|
||||
return 0.20
|
||||
# ST stocks: ±5% (heuristic: can't fully detect from code alone)
|
||||
# Beijing exchange (8xxxxx): ±30% — simplified to 0.30
|
||||
if code.startswith("8") and len(code) == 6:
|
||||
return 0.30
|
||||
# Main board: ±10%
|
||||
return 0.10
|
||||
@@ -0,0 +1,278 @@
|
||||
"""China futures backtest engine.
|
||||
|
||||
Market rules (exchange-level, CFFEX / SHFE / DCE / ZCE / INE / GFEX):
|
||||
- T+0: can open and close same day (intraday trading allowed)
|
||||
- Margin trading: 5%~15% by product (exchange-set minimum)
|
||||
- Price limits: stock-index +-10%, bonds +-2%, commodities +-3%~8%
|
||||
- Commission: per-lot fixed or per-notional rate (varies by product)
|
||||
- Contract multiplier: product-specific (IF=300, rb=10, au=1000, ...)
|
||||
- Minimum trading unit: 1 contract
|
||||
- Night session: 21:00-02:30 (varies by product, not enforced in bar-level sim)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.engines.futures_base import FuturesBaseEngine
|
||||
|
||||
|
||||
# ── Contract multiplier lookup ──
|
||||
|
||||
_MULTIPLIER: dict[str, int] = {
|
||||
# Stock index futures (CFFEX)
|
||||
"IF": 300, "IC": 200, "IH": 300, "IM": 200,
|
||||
# Treasury bond futures (CFFEX)
|
||||
"T": 10000, "TF": 10000, "TS": 20000, "TL": 10000,
|
||||
# Metals (SHFE)
|
||||
"au": 1000, "ag": 15, "cu": 5, "al": 5, "zn": 5,
|
||||
"pb": 5, "ni": 1, "sn": 1, "ss": 5,
|
||||
# Ferrous (SHFE / DCE)
|
||||
"rb": 10, "hc": 10, "i": 100, "j": 100, "jm": 60,
|
||||
# Energy (SHFE / INE)
|
||||
"sc": 1000, "fu": 10, "lu": 10, "bu": 10, "nr": 10,
|
||||
# Agriculture (DCE)
|
||||
"c": 10, "cs": 10, "m": 10, "y": 10, "a": 10,
|
||||
"p": 10, "jd": 10, "lh": 16, "rr": 10, "pg": 20,
|
||||
# Agriculture (ZCE)
|
||||
"CF": 5, "SR": 10, "TA": 5, "MA": 10, "AP": 10,
|
||||
"RM": 10, "OI": 10, "CJ": 5, "PK": 5, "CY": 5,
|
||||
# Chemical (DCE / ZCE)
|
||||
"pp": 5, "l": 5, "v": 5, "eg": 10, "eb": 5,
|
||||
"PF": 5, "SA": 20, "FG": 20, "UR": 20,
|
||||
# GFEX
|
||||
"si": 5, "lc": 1,
|
||||
}
|
||||
|
||||
# ── Margin rate (exchange minimum) ──
|
||||
|
||||
_MARGIN_RATE: dict[str, float] = {
|
||||
# CFFEX stock index
|
||||
"IF": 0.12, "IC": 0.12, "IH": 0.12, "IM": 0.12,
|
||||
# CFFEX bonds
|
||||
"T": 0.03, "TF": 0.02, "TS": 0.015, "TL": 0.035,
|
||||
# SHFE metals
|
||||
"au": 0.08, "ag": 0.09, "cu": 0.08, "al": 0.07,
|
||||
"zn": 0.08, "pb": 0.08, "ni": 0.12, "sn": 0.10, "ss": 0.08,
|
||||
# Ferrous
|
||||
"rb": 0.10, "hc": 0.10, "i": 0.12, "j": 0.12, "jm": 0.12,
|
||||
# Energy
|
||||
"sc": 0.10, "fu": 0.10, "lu": 0.10, "bu": 0.10,
|
||||
# Agriculture
|
||||
"c": 0.07, "cs": 0.07, "m": 0.08, "y": 0.08, "a": 0.08,
|
||||
"p": 0.08, "jd": 0.08, "lh": 0.12,
|
||||
# Textiles / chemical
|
||||
"CF": 0.07, "SR": 0.07, "TA": 0.07, "MA": 0.07,
|
||||
"pp": 0.07, "l": 0.07, "v": 0.07, "eg": 0.08,
|
||||
"SA": 0.08, "FG": 0.08, "UR": 0.08,
|
||||
}
|
||||
|
||||
# ── Price limit (fraction, ± from settlement) ──
|
||||
|
||||
_PRICE_LIMIT: dict[str, float] = {
|
||||
# CFFEX stock index ±10%
|
||||
"IF": 0.10, "IC": 0.10, "IH": 0.10, "IM": 0.10,
|
||||
# CFFEX bonds ±2% (simplified)
|
||||
"T": 0.02, "TF": 0.012, "TS": 0.005, "TL": 0.035,
|
||||
}
|
||||
_DEFAULT_PRICE_LIMIT = 0.05 # most commodities ±4%~7%, use 5% as default
|
||||
|
||||
# ── Commission structure ──
|
||||
# ("rate", pct) = per-notional | ("fixed", amount_per_lot) = per-contract
|
||||
|
||||
_COMMISSION: dict[str, tuple[str, float]] = {
|
||||
# CFFEX stock index: ~0.0023% of notional
|
||||
"IF": ("rate", 0.000023), "IC": ("rate", 0.000023),
|
||||
"IH": ("rate", 0.000023), "IM": ("rate", 0.000023),
|
||||
# CFFEX bonds
|
||||
"T": ("fixed", 3.0), "TF": ("fixed", 3.0), "TS": ("fixed", 3.0),
|
||||
# Metals
|
||||
"au": ("fixed", 10.0), "ag": ("fixed", 3.0), "cu": ("fixed", 5.0),
|
||||
"al": ("fixed", 3.0), "zn": ("fixed", 3.0), "ni": ("fixed", 3.0),
|
||||
# Ferrous
|
||||
"rb": ("rate", 0.0001), "hc": ("rate", 0.0001), "i": ("rate", 0.0001),
|
||||
"j": ("rate", 0.0001), "jm": ("rate", 0.0001),
|
||||
# Energy
|
||||
"sc": ("fixed", 20.0), "fu": ("rate", 0.00005),
|
||||
# Agriculture
|
||||
"c": ("fixed", 1.2), "cs": ("fixed", 1.5), "m": ("fixed", 1.5),
|
||||
"y": ("fixed", 2.5), "a": ("fixed", 2.0), "p": ("fixed", 2.5),
|
||||
"jd": ("rate", 0.00015), "lh": ("rate", 0.0002),
|
||||
# Textiles / chemical
|
||||
"CF": ("fixed", 4.3), "SR": ("fixed", 3.0), "TA": ("fixed", 3.0),
|
||||
"MA": ("fixed", 2.0), "pp": ("fixed", 1.0), "l": ("fixed", 1.0),
|
||||
"v": ("fixed", 1.0), "SA": ("fixed", 3.5), "FG": ("fixed", 3.0),
|
||||
}
|
||||
_DEFAULT_COMMISSION: tuple[str, float] = ("fixed", 5.0)
|
||||
|
||||
|
||||
def _extract_product(symbol: str) -> str:
|
||||
"""Extract product code from futures symbol.
|
||||
|
||||
Examples:
|
||||
'IF2406.CFFEX' -> 'IF'
|
||||
'rb2410.SHFE' -> 'rb'
|
||||
'au2412' -> 'au'
|
||||
|
||||
Args:
|
||||
symbol: Futures symbol string.
|
||||
|
||||
Returns:
|
||||
Product code (e.g. 'IF', 'rb', 'au').
|
||||
"""
|
||||
code = symbol.split(".")[0]
|
||||
m = re.match(r"([A-Za-z]+)", code)
|
||||
return m.group(1) if m else code
|
||||
|
||||
|
||||
class ChinaFuturesEngine(FuturesBaseEngine):
|
||||
"""China futures engine covering CFFEX / SHFE / DCE / ZCE / INE / GFEX.
|
||||
|
||||
Config keys:
|
||||
- slippage: default 0.0005
|
||||
- margin_rate_override: override margin rate for all products
|
||||
- commission_override: override commission for all products
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
# Derive leverage from margin rate of first code, or use config override
|
||||
margin_override = config.get("margin_rate_override")
|
||||
if margin_override:
|
||||
leverage = 1.0 / margin_override
|
||||
else:
|
||||
codes = config.get("codes", [])
|
||||
if codes:
|
||||
product = _extract_product(codes[0])
|
||||
mr = _MARGIN_RATE.get(product, 0.10)
|
||||
leverage = 1.0 / mr
|
||||
else:
|
||||
leverage = 10.0 # ~10% margin default
|
||||
config = {**config, "leverage": leverage}
|
||||
super().__init__(config)
|
||||
self.slippage_rate: float = config.get("slippage", 0.0005)
|
||||
self._commission_override = config.get("commission_override")
|
||||
|
||||
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
|
||||
"""China futures: T+0, both directions, price-limit enforced.
|
||||
|
||||
Args:
|
||||
symbol: Futures code.
|
||||
direction: 1 (long), -1 (short), 0 (close).
|
||||
bar: Current bar data.
|
||||
|
||||
Returns:
|
||||
True if allowed.
|
||||
"""
|
||||
# T+0: no same-day sell restriction
|
||||
# Both long and short allowed
|
||||
|
||||
# Price limit check
|
||||
pct_chg = _calc_pct_change(bar)
|
||||
if pct_chg is not None:
|
||||
product = _extract_product(symbol)
|
||||
limit = _PRICE_LIMIT.get(product, _DEFAULT_PRICE_LIMIT)
|
||||
if direction == 1 and pct_chg >= limit - 0.001:
|
||||
return False # limit-up: can't open long / can't buy
|
||||
if direction == -1 and pct_chg <= -limit + 0.001:
|
||||
return False # limit-down: can't open short
|
||||
if direction == 0:
|
||||
pos = self.positions.get(symbol)
|
||||
if pos is not None:
|
||||
# Can't close long at limit-down, can't close short at limit-up
|
||||
if pos.direction == 1 and pct_chg <= -limit + 0.001:
|
||||
return False
|
||||
if pos.direction == -1 and pct_chg >= limit - 0.001:
|
||||
return False
|
||||
return True
|
||||
|
||||
def round_size(self, raw_size: float, price: float) -> float:
|
||||
"""Minimum 1 contract, integer lots only."""
|
||||
return max(int(raw_size), 0)
|
||||
|
||||
def calc_commission(self, size: float, price: float, _direction: int, is_open: bool) -> float:
|
||||
"""Commission varies by product: fixed per-lot or percentage of notional.
|
||||
|
||||
``_direction`` is unused — reserved for future open/close-fee
|
||||
asymmetry (some products charge different rates for close-today).
|
||||
"""
|
||||
if self._commission_override is not None:
|
||||
return size * price * self._commission_override
|
||||
return self.calc_commission_for_symbol(self._active_symbol, size, price, is_open)
|
||||
|
||||
def calc_commission_for_symbol(
|
||||
self, symbol: str, size: float, price: float, is_open: bool,
|
||||
) -> float:
|
||||
"""Symbol-aware commission calculation.
|
||||
|
||||
Args:
|
||||
symbol: Futures code.
|
||||
size: Number of contracts.
|
||||
price: Execution price.
|
||||
is_open: True for opening trade.
|
||||
|
||||
Returns:
|
||||
Commission in RMB.
|
||||
"""
|
||||
product = _extract_product(symbol)
|
||||
mode, value = _COMMISSION.get(product, _DEFAULT_COMMISSION)
|
||||
cm = _MULTIPLIER.get(product, 10)
|
||||
if mode == "rate":
|
||||
return size * price * cm * value
|
||||
return size * value
|
||||
|
||||
def apply_slippage(self, price: float, direction: int) -> float:
|
||||
"""Futures slippage."""
|
||||
return price * (1 + direction * self.slippage_rate)
|
||||
|
||||
def get_contract_multiplier(self, symbol: str) -> float:
|
||||
"""Look up contract multiplier from product code."""
|
||||
product = _extract_product(symbol)
|
||||
return float(_MULTIPLIER.get(product, 10))
|
||||
|
||||
def get_margin_rate(self, symbol: str) -> float:
|
||||
"""Look up exchange margin rate for a product.
|
||||
|
||||
Args:
|
||||
symbol: Futures symbol.
|
||||
|
||||
Returns:
|
||||
Margin rate (e.g. 0.10 for 10%).
|
||||
"""
|
||||
product = _extract_product(symbol)
|
||||
return _MARGIN_RATE.get(product, 0.10)
|
||||
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
|
||||
# Note: china_a uses close/pre_close-only; global_futures prioritises
|
||||
# close/pre_close before settle. This China-futures variant prefers
|
||||
# settle/pre_settle because tushare reports settlement as the canonical
|
||||
# daily price for domestic contracts. See those modules for the equity /
|
||||
# global-futures equivalents.
|
||||
def _calc_pct_change(bar: pd.Series):
|
||||
"""Calculate bar price change percentage.
|
||||
|
||||
Priority: settle/pre_settle (futures native) > close/pre_close > pct_chg.
|
||||
pct_chg from tushare is always in percentage points (0.5 = 0.5%).
|
||||
"""
|
||||
# Prefer settlement prices (unambiguous for futures)
|
||||
settle = bar.get("settle")
|
||||
pre_settle = bar.get("pre_settle")
|
||||
if settle is not None and pre_settle is not None and pre_settle > 0:
|
||||
return (float(settle) - float(pre_settle)) / float(pre_settle)
|
||||
|
||||
close = bar.get("close")
|
||||
pre_close = bar.get("pre_close")
|
||||
if close is not None and pre_close is not None and pre_close > 0:
|
||||
return (float(close) - float(pre_close)) / float(pre_close)
|
||||
|
||||
# tushare pct_chg is in percentage points (e.g. 0.5 = 0.5%)
|
||||
if "pct_chg" in bar.index:
|
||||
val = bar["pct_chg"]
|
||||
if pd.notna(val):
|
||||
return float(val) / 100.0
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Composite cross-market backtest engine.
|
||||
|
||||
Manages a shared capital pool across multiple market engines.
|
||||
Sub-engines are used as stateless "rule books" for market-specific
|
||||
calculations (commission, slippage, lot rounding, etc.).
|
||||
All state (capital, positions, trades) lives in CompositeEngine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.engines.base import BaseEngine
|
||||
from backtest.engines._market_hooks import (
|
||||
_detect_market,
|
||||
_is_china_futures,
|
||||
calc_crypto_funding_fee,
|
||||
check_crypto_liquidation,
|
||||
calc_forex_swap,
|
||||
)
|
||||
|
||||
|
||||
def _build_rule_engines(config: dict, codes: List[str]) -> Dict[str, BaseEngine]:
|
||||
"""Instantiate one sub-engine per market type detected in codes."""
|
||||
markets = {_detect_market(c) for c in codes}
|
||||
engines: Dict[str, BaseEngine] = {}
|
||||
|
||||
for market in markets:
|
||||
if market == "a_share":
|
||||
from backtest.engines.china_a import ChinaAEngine
|
||||
engines["a_share"] = ChinaAEngine(config)
|
||||
elif market == "us_equity":
|
||||
from backtest.engines.global_equity import GlobalEquityEngine
|
||||
engines["us_equity"] = GlobalEquityEngine(config, market="us")
|
||||
elif market == "hk_equity":
|
||||
from backtest.engines.global_equity import GlobalEquityEngine
|
||||
engines["hk_equity"] = GlobalEquityEngine(config, market="hk")
|
||||
elif market == "crypto":
|
||||
from backtest.engines.crypto import CryptoEngine
|
||||
engines["crypto"] = CryptoEngine(config)
|
||||
elif market == "forex":
|
||||
from backtest.engines.forex import ForexEngine
|
||||
engines["forex"] = ForexEngine(config)
|
||||
elif market == "futures":
|
||||
china = any(
|
||||
_is_china_futures(c) for c in codes if _detect_market(c) == "futures"
|
||||
)
|
||||
if china:
|
||||
from backtest.engines.china_futures import ChinaFuturesEngine
|
||||
engines["futures"] = ChinaFuturesEngine(config)
|
||||
else:
|
||||
from backtest.engines.global_futures import GlobalFuturesEngine
|
||||
engines["futures"] = GlobalFuturesEngine(config)
|
||||
|
||||
return engines
|
||||
|
||||
|
||||
class CompositeEngine(BaseEngine):
|
||||
"""Cross-market engine with shared capital pool.
|
||||
|
||||
Sub-engines are stateless rule providers. All positions, capital,
|
||||
and trades live here (inherited from BaseEngine).
|
||||
|
||||
Args:
|
||||
config: Backtest configuration dict.
|
||||
codes: List of instrument codes spanning multiple markets.
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict, codes: List[str]):
|
||||
super().__init__(config)
|
||||
|
||||
# Build symbol -> market mapping
|
||||
self._symbol_market: Dict[str, str] = {c: _detect_market(c) for c in codes}
|
||||
|
||||
# Build sub-engines (one per market type)
|
||||
self._rule_engines = _build_rule_engines(config, codes)
|
||||
|
||||
# Crypto dedup state (owned by CompositeEngine, not sub-engine)
|
||||
self._funding_applied: set = set()
|
||||
self._funding_daily_done: set = set()
|
||||
|
||||
# Forex dedup state
|
||||
self._last_swap_dates: dict = {}
|
||||
|
||||
def _rule_for(self, symbol: str) -> BaseEngine:
|
||||
"""Get the sub-engine that provides rules for this symbol."""
|
||||
market = self._symbol_market.get(symbol, "a_share")
|
||||
return self._rule_engines[market]
|
||||
|
||||
# ── Stateless method dispatch ──
|
||||
|
||||
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
|
||||
"""Market-rule check with T+1 interceptor for A-shares."""
|
||||
market = self._symbol_market.get(symbol, "a_share")
|
||||
|
||||
# T+1: intercept here because sub-engine has no access to shared positions
|
||||
if market == "a_share" and direction == 0:
|
||||
pos = self.positions.get(symbol)
|
||||
if pos is not None:
|
||||
bar_date = None
|
||||
if hasattr(bar, "name") and hasattr(bar.name, "date"):
|
||||
bar_date = bar.name.date()
|
||||
entry_date = (
|
||||
pos.entry_time.date()
|
||||
if hasattr(pos.entry_time, "date")
|
||||
else None
|
||||
)
|
||||
if bar_date and entry_date and bar_date == entry_date:
|
||||
return False
|
||||
|
||||
# Delegate remaining checks (price limits, short-sell block, etc.)
|
||||
return self._rule_for(symbol).can_execute(symbol, direction, bar)
|
||||
|
||||
def round_size(self, raw_size: float, price: float) -> float:
|
||||
"""Delegate to active symbol's sub-engine."""
|
||||
return self._rule_for(self._active_symbol).round_size(raw_size, price)
|
||||
|
||||
def calc_commission(
|
||||
self, size: float, price: float, direction: int, is_open: bool,
|
||||
) -> float:
|
||||
"""Delegate to active symbol's sub-engine."""
|
||||
return self._rule_for(self._active_symbol).calc_commission(
|
||||
size, price, direction, is_open,
|
||||
)
|
||||
|
||||
def apply_slippage(self, price: float, direction: int) -> float:
|
||||
"""Delegate to active symbol's sub-engine."""
|
||||
sub = self._rule_for(self._active_symbol)
|
||||
# ForexEngine needs _active_symbol set on the sub-engine
|
||||
sub._active_symbol = self._active_symbol
|
||||
return sub.apply_slippage(price, direction)
|
||||
|
||||
# ── PnL / margin dispatch (route by symbol, not _active_symbol) ──
|
||||
|
||||
def _calc_pnl(
|
||||
self, symbol: str, direction: int, size: float,
|
||||
entry_price: float, exit_price: float,
|
||||
) -> float:
|
||||
return self._rule_for(symbol)._calc_pnl(
|
||||
symbol, direction, size, entry_price, exit_price,
|
||||
)
|
||||
|
||||
def _calc_margin(
|
||||
self, symbol: str, size: float, price: float, leverage: float,
|
||||
) -> float:
|
||||
return self._rule_for(symbol)._calc_margin(symbol, size, price, leverage)
|
||||
|
||||
def _calc_raw_size(
|
||||
self, symbol: str, target_notional: float, price: float,
|
||||
) -> float:
|
||||
return self._rule_for(symbol)._calc_raw_size(symbol, target_notional, price)
|
||||
|
||||
# ── Stateful hooks (implemented directly, NO delegation) ──
|
||||
|
||||
def on_bar(self, symbol: str, bar: pd.Series, timestamp: pd.Timestamp) -> None:
|
||||
"""Per-bar hooks dispatched by market type."""
|
||||
market = self._symbol_market.get(symbol)
|
||||
|
||||
if market == "crypto":
|
||||
crypto_sub = self._rule_engines["crypto"]
|
||||
fee = calc_crypto_funding_fee(
|
||||
symbol, bar, timestamp, self.positions,
|
||||
crypto_sub.funding_rate,
|
||||
self._funding_applied, self._funding_daily_done,
|
||||
)
|
||||
self.capital -= fee
|
||||
|
||||
if check_crypto_liquidation(symbol, bar, self.positions):
|
||||
pos = self.positions.get(symbol)
|
||||
if pos is not None:
|
||||
mark_price = float(bar.get("close", pos.entry_price))
|
||||
liq_price = crypto_sub.apply_slippage(mark_price, -pos.direction)
|
||||
self._close_position(symbol, liq_price, timestamp, "liquidation")
|
||||
|
||||
elif market == "forex":
|
||||
forex_sub = self._rule_engines["forex"]
|
||||
if forex_sub.swap_enabled:
|
||||
swap = calc_forex_swap(
|
||||
symbol, timestamp, self.positions,
|
||||
forex_sub.lot_size, self._last_swap_dates,
|
||||
)
|
||||
self.capital += swap
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Crypto perpetual-contract backtest engine.
|
||||
|
||||
Market rules:
|
||||
- 24/7 trading, no restrictions on direction
|
||||
- Maker/Taker fee separation
|
||||
- Funding fee settlement every 8 hours (00:00/08:00/16:00 UTC)
|
||||
- Forced liquidation when maintenance margin ratio <= 100%
|
||||
- Fractional position sizes allowed
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.engines.base import BaseEngine
|
||||
from backtest.engines._market_hooks import (
|
||||
calc_crypto_funding_fee,
|
||||
check_crypto_liquidation,
|
||||
)
|
||||
|
||||
|
||||
class CryptoEngine(BaseEngine):
|
||||
"""Crypto perpetual contract engine.
|
||||
|
||||
Config keys:
|
||||
- leverage: default 1.0
|
||||
- maker_rate: default 0.0002
|
||||
- taker_rate: default 0.0005
|
||||
- slippage: default 0.0005
|
||||
- margin_mode: "isolated" (default) or "cross"
|
||||
- funding_rate: fixed rate per settlement, default 0.0001
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
super().__init__(config)
|
||||
self.maker_rate: float = config.get("maker_rate", 0.0002)
|
||||
self.taker_rate: float = config.get("taker_rate", 0.0005)
|
||||
self.slippage_rate: float = config.get("slippage", 0.0005)
|
||||
self.funding_rate: float = config.get("funding_rate", 0.0001)
|
||||
self._funding_applied: set = set() # (symbol, date, hour) — per-slot dedup
|
||||
self._funding_daily_done: set = set() # (symbol, date) — daily fallback dedup
|
||||
|
||||
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
|
||||
"""Crypto: 24/7, long/short/close all allowed."""
|
||||
return True
|
||||
|
||||
def round_size(self, raw_size: float, price: float) -> float:
|
||||
"""Crypto supports fractional sizes, round to 6 decimals."""
|
||||
return round(max(raw_size, 0.0), 6)
|
||||
|
||||
def calc_commission(self, size: float, price: float, _direction: int, is_open: bool) -> float:
|
||||
"""Maker/Taker separated. Opens typically hit taker, closes hit maker.
|
||||
|
||||
``_direction`` is unused — reserved for future funding-rate asymmetry
|
||||
between long/short legs on perp swaps.
|
||||
"""
|
||||
rate = self.taker_rate if is_open else self.maker_rate
|
||||
return size * price * rate
|
||||
|
||||
def apply_slippage(self, price: float, direction: int) -> float:
|
||||
"""Slippage: unfavourable direction."""
|
||||
return price * (1 + direction * self.slippage_rate)
|
||||
|
||||
def on_bar(self, symbol: str, bar: pd.Series, timestamp: pd.Timestamp) -> None:
|
||||
"""Crypto per-bar hooks: funding fee + liquidation check."""
|
||||
fee = calc_crypto_funding_fee(
|
||||
symbol, bar, timestamp, self.positions,
|
||||
self.funding_rate, self._funding_applied, self._funding_daily_done,
|
||||
)
|
||||
self.capital -= fee
|
||||
|
||||
if check_crypto_liquidation(symbol, bar, self.positions):
|
||||
pos = self.positions.get(symbol)
|
||||
if pos is not None:
|
||||
mark_price = float(bar.get("close", pos.entry_price))
|
||||
liq_price = self.apply_slippage(mark_price, -pos.direction)
|
||||
self._close_position(symbol, liq_price, timestamp, "liquidation")
|
||||
@@ -1,461 +0,0 @@
|
||||
"""Portfolio-level vectorised backtest engine.
|
||||
|
||||
Supports arbitrary bar intervals (1m/5m/15m/30m/1H/4H/1D).
|
||||
Signal lag: bar-T signal executes at bar T+1 (close-to-close return approx).
|
||||
Position normalisation: sum(abs(weights)) <= 1.0, no leverage ever.
|
||||
Commission: deducted as percentage of turnover.
|
||||
Artifacts: equity.csv, metrics.csv, trades.csv, positions.csv, ohlcv_{code}.csv.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
# ─── Annualisation factor mapping (auto-inferred from source and interval) ───
|
||||
|
||||
_TRADING_DAYS = {"tushare": 252, "yfinance": 252, "okx": 365}
|
||||
_BARS_PER_DAY = {
|
||||
"1m": {"tushare": 240, "okx": 1440, "yfinance": 390},
|
||||
"5m": {"tushare": 48, "okx": 288, "yfinance": 78},
|
||||
"15m": {"tushare": 16, "okx": 96, "yfinance": 26},
|
||||
"30m": {"tushare": 8, "okx": 48, "yfinance": 13},
|
||||
"1H": {"tushare": 4, "okx": 24, "yfinance": 7},
|
||||
"4H": {"tushare": 1, "okx": 6, "yfinance": 2},
|
||||
"1D": {"tushare": 1, "okx": 1, "yfinance": 1},
|
||||
}
|
||||
|
||||
|
||||
def _calc_bars_per_year(interval: str = "1D", source: str = "tushare") -> int:
|
||||
"""Calculate the number of bars per year for annualisation.
|
||||
|
||||
Args:
|
||||
interval: Bar interval (e.g. 1m, 1H, 1D).
|
||||
source: Data source (determines trading days and bars per day).
|
||||
|
||||
Returns:
|
||||
Number of bars per year, used for annualised return and Sharpe ratio.
|
||||
"""
|
||||
trading_days = _TRADING_DAYS.get(source, 252)
|
||||
bars_per_day = _BARS_PER_DAY.get(interval, {}).get(source, 1)
|
||||
return trading_days * bars_per_day
|
||||
|
||||
|
||||
def _align(
|
||||
data_map: Dict[str, pd.DataFrame],
|
||||
signal_map: Dict[str, pd.Series],
|
||||
codes: List[str],
|
||||
optimizer: Optional[Callable] = None,
|
||||
) -> tuple:
|
||||
"""Build aligned date index, close matrix, position matrix, and return matrix.
|
||||
|
||||
For multi-market mixes, takes the union of all trading dates and forward-fills gaps.
|
||||
Applies signal shift(1), optional optimizer weighting, and normalisation (sum|w|<=1).
|
||||
|
||||
Args:
|
||||
data_map: code -> OHLCV DataFrame.
|
||||
signal_map: code -> signal Series.
|
||||
codes: List of valid instrument codes.
|
||||
optimizer: Optional weight optimisation function (ret_df, pos_df, date_idx) -> pos_df.
|
||||
|
||||
Returns:
|
||||
(dates, close_df, positions_df, returns_df)
|
||||
"""
|
||||
all_dates = set()
|
||||
for c in codes:
|
||||
all_dates.update(data_map[c].index)
|
||||
dates = pd.DatetimeIndex(sorted(all_dates))
|
||||
|
||||
close = pd.DataFrame(index=dates, columns=codes, dtype=float)
|
||||
for c in codes:
|
||||
close[c] = data_map[c]["close"].reindex(dates)
|
||||
close = close.ffill().bfill()
|
||||
|
||||
pos = pd.DataFrame(0.0, index=dates, columns=codes)
|
||||
for c in codes:
|
||||
raw = signal_map[c].reindex(dates).fillna(0.0).clip(-1.0, 1.0)
|
||||
pos[c] = raw.shift(1).fillna(0.0)
|
||||
|
||||
ret = close.pct_change().fillna(0.0)
|
||||
|
||||
# optimizer weighting (before normalisation)
|
||||
if optimizer is not None:
|
||||
pos = optimizer(ret, pos, dates)
|
||||
|
||||
scale = pos.abs().sum(axis=1).clip(lower=1.0)
|
||||
pos = pos.div(scale, axis=0)
|
||||
|
||||
return dates, close, pos, ret
|
||||
|
||||
|
||||
def _benchmark_equity(
|
||||
ret: pd.DataFrame,
|
||||
cash: float,
|
||||
) -> tuple:
|
||||
"""Calculate equal-weight buy-and-hold benchmark.
|
||||
|
||||
Args:
|
||||
ret: Return matrix.
|
||||
cash: Initial capital.
|
||||
|
||||
Returns:
|
||||
(bench_ret Series, bench_equity Series)
|
||||
"""
|
||||
n_assets = ret.shape[1]
|
||||
bench_ret = ret.mean(axis=1) if n_assets > 0 else pd.Series(0.0, index=ret.index)
|
||||
bench_equity = cash * (1 + bench_ret).cumprod()
|
||||
return bench_ret, bench_equity
|
||||
|
||||
|
||||
def _portfolio_equity(
|
||||
pos: pd.DataFrame,
|
||||
ret: pd.DataFrame,
|
||||
commission: float,
|
||||
cash: float,
|
||||
) -> tuple:
|
||||
"""Portfolio returns -> equity curve -> drawdown.
|
||||
|
||||
Args:
|
||||
pos: Position matrix.
|
||||
ret: Return matrix.
|
||||
commission: Commission rate.
|
||||
cash: Initial capital.
|
||||
|
||||
Returns:
|
||||
(port_ret Series, equity Series, drawdown Series)
|
||||
"""
|
||||
port_ret = (pos * ret).sum(axis=1)
|
||||
turnover = pos.diff().abs().sum(axis=1).fillna(0.0)
|
||||
port_ret = port_ret - turnover * commission
|
||||
equity = cash * (1 + port_ret).cumprod()
|
||||
peak = equity.cummax()
|
||||
dd = (equity - peak) / peak.replace(0, 1)
|
||||
return port_ret, equity, dd
|
||||
|
||||
|
||||
def _log_trades(
|
||||
pos: pd.DataFrame,
|
||||
close: pd.DataFrame,
|
||||
codes: List[str],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Generate per-trade records from position diffs, including per-trade P&L.
|
||||
|
||||
Args:
|
||||
pos: Position matrix.
|
||||
close: Close price matrix.
|
||||
codes: List of instrument codes.
|
||||
|
||||
Returns:
|
||||
Trade list sorted by timestamp.
|
||||
"""
|
||||
diff = pos.diff()
|
||||
diff.iloc[0] = pos.iloc[0]
|
||||
|
||||
# Track entry cost and entry date per instrument
|
||||
cost: Dict[str, float] = {}
|
||||
hold: Dict[str, float] = {}
|
||||
entry_date: Dict[str, str] = {}
|
||||
|
||||
trades: List[Dict[str, Any]] = []
|
||||
# Collect all trade events then sort by timestamp
|
||||
raw_events = []
|
||||
for c in codes:
|
||||
delta = diff[c]
|
||||
for ts in delta.index[delta.abs() > 1e-9]:
|
||||
d = float(delta.loc[ts])
|
||||
p = float(close.at[ts, c]) if pd.notna(close.at[ts, c]) else 0.0
|
||||
raw_events.append((str(ts.date()) if hasattr(ts, "date") else str(ts), c, d, p))
|
||||
raw_events.sort(key=lambda x: x[0])
|
||||
|
||||
for ts_str, c, d, p in raw_events:
|
||||
side = "buy" if d > 0 else "sell"
|
||||
qty = abs(d)
|
||||
pnl = 0.0
|
||||
holding_days = 0
|
||||
return_pct = 0.0
|
||||
|
||||
prev = hold.get(c, 0.0)
|
||||
if prev * d >= 0:
|
||||
# Same-direction add: update average cost
|
||||
old = cost.get(c, p)
|
||||
total = abs(prev) + qty
|
||||
cost[c] = (old * abs(prev) + p * qty) / total if total > 1e-9 else p
|
||||
hold[c] = prev + d
|
||||
if c not in entry_date:
|
||||
entry_date[c] = ts_str
|
||||
else:
|
||||
# Opposite direction: realise P&L
|
||||
close_qty = min(qty, abs(prev))
|
||||
entry = cost.get(c, p)
|
||||
pnl = (p - entry) * close_qty if prev > 0 else (entry - p) * close_qty
|
||||
return_pct = (p / entry - 1) * 100 if entry > 1e-9 and prev > 0 else (entry / p - 1) * 100 if p > 1e-9 else 0.0
|
||||
|
||||
ed = entry_date.get(c, ts_str)
|
||||
try:
|
||||
holding_days = (pd.Timestamp(ts_str) - pd.Timestamp(ed)).days
|
||||
except Exception:
|
||||
holding_days = 0
|
||||
|
||||
remain = prev + d
|
||||
if abs(remain) < 1e-9:
|
||||
hold.pop(c, None)
|
||||
cost.pop(c, None)
|
||||
entry_date.pop(c, None)
|
||||
else:
|
||||
hold[c] = remain
|
||||
if remain * prev <= 0:
|
||||
cost[c] = p
|
||||
entry_date[c] = ts_str
|
||||
|
||||
trades.append({
|
||||
"timestamp": ts_str,
|
||||
"code": c,
|
||||
"side": side,
|
||||
"price": round(p, 4),
|
||||
"qty": round(qty, 6),
|
||||
"reason": "signal",
|
||||
"pnl": round(pnl, 4),
|
||||
"holding_days": holding_days,
|
||||
"return_pct": round(return_pct, 2),
|
||||
})
|
||||
|
||||
return trades
|
||||
|
||||
|
||||
def _win_rate_and_stats(trades: List[Dict[str, Any]]) -> Dict[str, float]:
|
||||
"""Calculate win rate and P&L statistics from trade records.
|
||||
|
||||
Args:
|
||||
trades: List of trade records.
|
||||
|
||||
Returns:
|
||||
Dict containing win_rate, profit_loss_ratio, max_consecutive_loss, avg_holding_days.
|
||||
"""
|
||||
closed = [t for t in trades if t.get("pnl", 0) != 0]
|
||||
if not closed:
|
||||
return {"win_rate": 0.0, "profit_loss_ratio": 0.0, "max_consecutive_loss": 0, "avg_holding_days": 0.0}
|
||||
|
||||
wins = [t["pnl"] for t in closed if t["pnl"] > 0]
|
||||
losses = [t["pnl"] for t in closed if t["pnl"] < 0]
|
||||
win_rate = len(wins) / len(closed) if closed else 0.0
|
||||
|
||||
avg_win = np.mean(wins) if wins else 0.0
|
||||
avg_loss = abs(np.mean(losses)) if losses else 1e-10
|
||||
profit_loss_ratio = avg_win / avg_loss if avg_loss > 1e-10 else 0.0
|
||||
|
||||
# Max consecutive losses
|
||||
max_consec = 0
|
||||
cur_consec = 0
|
||||
for t in closed:
|
||||
if t["pnl"] < 0:
|
||||
cur_consec += 1
|
||||
max_consec = max(max_consec, cur_consec)
|
||||
else:
|
||||
cur_consec = 0
|
||||
|
||||
# Average holding days
|
||||
hold_days = [t["holding_days"] for t in closed if t["holding_days"] > 0]
|
||||
avg_holding = np.mean(hold_days) if hold_days else 0.0
|
||||
|
||||
return {
|
||||
"win_rate": win_rate,
|
||||
"profit_loss_ratio": round(profit_loss_ratio, 4),
|
||||
"max_consecutive_loss": max_consec,
|
||||
"avg_holding_days": round(avg_holding, 1),
|
||||
}
|
||||
|
||||
|
||||
def _metrics(
|
||||
port_ret: pd.Series,
|
||||
equity: pd.Series,
|
||||
dd: pd.Series,
|
||||
cash: float,
|
||||
trades: List[Dict[str, Any]],
|
||||
bench_ret: Optional[pd.Series] = None,
|
||||
bars_per_year: int = 252,
|
||||
) -> Dict[str, Any]:
|
||||
"""Calculate the full set of performance metrics.
|
||||
|
||||
Args:
|
||||
port_ret: Portfolio bar return series.
|
||||
equity: Equity curve.
|
||||
dd: Drawdown series.
|
||||
cash: Initial capital.
|
||||
trades: Trade records.
|
||||
bench_ret: Benchmark return series (optional).
|
||||
bars_per_year: Bars per year (252 for daily, 8760 for 1H OKX, etc.).
|
||||
|
||||
Returns:
|
||||
Metrics dictionary.
|
||||
"""
|
||||
n = len(port_ret)
|
||||
if n == 0:
|
||||
return dict(
|
||||
final_value=cash, total_return=0, annual_return=0,
|
||||
max_drawdown=0, sharpe=0, calmar=0, sortino=0,
|
||||
win_rate=0, profit_loss_ratio=0, max_consecutive_loss=0,
|
||||
avg_holding_days=0, trade_count=0,
|
||||
benchmark_return=0, excess_return=0, information_ratio=0,
|
||||
)
|
||||
|
||||
bpy = bars_per_year
|
||||
|
||||
total_ret = float(equity.iloc[-1] / cash - 1)
|
||||
ann_ret = float((1 + total_ret) ** (bpy / max(n, 1)) - 1)
|
||||
vol = float(port_ret.std())
|
||||
sharpe = float(port_ret.mean() / (vol + 1e-10) * np.sqrt(bpy))
|
||||
|
||||
# Calmar = annualised return / |max drawdown|
|
||||
max_dd = float(dd.min())
|
||||
calmar = ann_ret / abs(max_dd) if abs(max_dd) > 1e-10 else 0.0
|
||||
|
||||
# Sortino = mean(ret) / downside_std * sqrt(bars_per_year) (unchanged)
|
||||
downside = port_ret[port_ret < 0]
|
||||
downside_std = float(downside.std()) if len(downside) > 1 else 1e-10
|
||||
sortino = float(port_ret.mean() / (downside_std + 1e-10) * np.sqrt(bpy))
|
||||
|
||||
# Trade statistics
|
||||
trade_stats = _win_rate_and_stats(trades)
|
||||
|
||||
# Benchmark comparison
|
||||
bench_return = 0.0
|
||||
excess = 0.0
|
||||
ir = 0.0
|
||||
if bench_ret is not None and len(bench_ret) > 0:
|
||||
bench_return = float((1 + bench_ret).prod() - 1)
|
||||
excess = total_ret - bench_return
|
||||
active_ret = port_ret - bench_ret
|
||||
active_std = float(active_ret.std())
|
||||
ir = float(active_ret.mean() / (active_std + 1e-10) * np.sqrt(bpy))
|
||||
|
||||
return {
|
||||
"final_value": float(equity.iloc[-1]),
|
||||
"total_return": total_ret,
|
||||
"annual_return": ann_ret,
|
||||
"max_drawdown": max_dd,
|
||||
"sharpe": sharpe,
|
||||
"calmar": round(calmar, 4),
|
||||
"sortino": round(sortino, 4),
|
||||
"win_rate": trade_stats["win_rate"],
|
||||
"profit_loss_ratio": trade_stats["profit_loss_ratio"],
|
||||
"max_consecutive_loss": trade_stats["max_consecutive_loss"],
|
||||
"avg_holding_days": trade_stats["avg_holding_days"],
|
||||
"trade_count": len(trades),
|
||||
"benchmark_return": round(bench_return, 6),
|
||||
"excess_return": round(excess, 6),
|
||||
"information_ratio": round(ir, 4),
|
||||
}
|
||||
|
||||
|
||||
def run_backtest(
|
||||
config: Dict[str, Any],
|
||||
loader: Any,
|
||||
engine: Any,
|
||||
run_dir: Path,
|
||||
bars_per_year: int = 252,
|
||||
) -> Dict[str, Any]:
|
||||
"""Main entry point: load data -> generate signals -> vectorised backtest -> write artifacts.
|
||||
|
||||
Args:
|
||||
config: Backtest configuration (codes, start_date, end_date, initial_cash, commission,
|
||||
extra_fields, optimizer, interval, source).
|
||||
loader: DataLoader instance (must have a fetch method).
|
||||
engine: SignalEngine instance (must have a generate method).
|
||||
run_dir: Run directory path.
|
||||
bars_per_year: Bars per year, computed by runner from interval+source.
|
||||
|
||||
Returns:
|
||||
Metrics dictionary.
|
||||
|
||||
Raises:
|
||||
SystemExit: When no data is fetched or no valid signals are generated.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
codes = config.get("codes", [])
|
||||
start_date = config.get("start_date", "")
|
||||
end_date = config.get("end_date", "")
|
||||
initial_cash = config.get("initial_cash", 1_000_000)
|
||||
commission = config.get("commission", 0.001)
|
||||
extra_fields = config.get("extra_fields") or None
|
||||
|
||||
interval = config.get("interval", "1D")
|
||||
data_map = loader.fetch(codes, start_date, end_date, fields=extra_fields, interval=interval)
|
||||
if not data_map:
|
||||
print(json.dumps({"error": "No data fetched"}))
|
||||
sys.exit(1)
|
||||
|
||||
signal_map = engine.generate(data_map)
|
||||
valid_codes = sorted(c for c in signal_map if c in data_map)
|
||||
if not valid_codes:
|
||||
print(json.dumps({"error": "No valid signals generated"}))
|
||||
sys.exit(1)
|
||||
|
||||
# Load optimizer if configured
|
||||
opt_fn = _load_optimizer(config)
|
||||
|
||||
dates, close, pos, ret = _align(data_map, signal_map, valid_codes, optimizer=opt_fn)
|
||||
port_ret, equity, dd = _portfolio_equity(pos, ret, commission, initial_cash)
|
||||
bench_ret, bench_equity = _benchmark_equity(ret, initial_cash)
|
||||
trades = _log_trades(pos, close, valid_codes)
|
||||
m = _metrics(port_ret, equity, dd, initial_cash, trades, bench_ret, bars_per_year)
|
||||
|
||||
out = run_dir / "artifacts"
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for code, df in data_map.items():
|
||||
df.to_csv(out / f"ohlcv_{code}.csv")
|
||||
|
||||
eq = pd.DataFrame({
|
||||
"ret": port_ret,
|
||||
"equity": equity,
|
||||
"drawdown": dd,
|
||||
"benchmark_equity": bench_equity,
|
||||
"active_ret": port_ret - bench_ret,
|
||||
}, index=dates)
|
||||
eq.index.name = "timestamp"
|
||||
eq.to_csv(out / "equity.csv")
|
||||
|
||||
pos.index.name = "timestamp"
|
||||
pos.to_csv(out / "positions.csv")
|
||||
|
||||
trade_cols = ["timestamp", "code", "side", "price", "qty", "reason", "pnl", "holding_days", "return_pct"]
|
||||
pd.DataFrame(
|
||||
trades or [],
|
||||
columns=trade_cols,
|
||||
).to_csv(out / "trades.csv", index=False)
|
||||
|
||||
pd.DataFrame([m]).to_csv(out / "metrics.csv", index=False)
|
||||
print(json.dumps(m, indent=2))
|
||||
return m
|
||||
|
||||
|
||||
def _load_optimizer(config: Dict[str, Any]) -> Optional[Callable]:
|
||||
"""Dynamically load an optimizer function from config.
|
||||
|
||||
Supports any module under backtest/optimizers/ that exposes an
|
||||
`optimize(ret, pos, dates, **params)` function.
|
||||
|
||||
Args:
|
||||
config: Backtest configuration.
|
||||
|
||||
Returns:
|
||||
Optimizer callable, or None.
|
||||
"""
|
||||
import importlib
|
||||
|
||||
opt_name = config.get("optimizer")
|
||||
if not opt_name:
|
||||
return None
|
||||
|
||||
opt_params = config.get("optimizer_params") or {}
|
||||
|
||||
try:
|
||||
mod = importlib.import_module(f"backtest.optimizers.{opt_name}")
|
||||
return lambda ret, pos, dates: mod.optimize(ret, pos, dates, **opt_params)
|
||||
except (ImportError, AttributeError) as e:
|
||||
print(f"[WARN] Failed to load optimizer '{opt_name}': {e}, falling back to equal weight")
|
||||
return None
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Forex (FX spot / CFD) backtest engine.
|
||||
|
||||
Market rules:
|
||||
- 24x5 (Mon Sydney open to Fri NYC close)
|
||||
- Spread replaces explicit commission (bid-ask)
|
||||
- Leverage: 50:1 to 500:1 (configurable)
|
||||
- Standard lot = 100,000 units of base currency
|
||||
- Swap (overnight rollover interest) at daily close
|
||||
- No price limits, no restrictions on direction
|
||||
- PnL in quote currency (converted via exit price for cross pairs)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.engines.base import BaseEngine
|
||||
# ``_normalize_symbol`` lives in ``_market_hooks`` (single source of truth);
|
||||
# re-imported here so external callers (tests) keep their existing import path.
|
||||
from backtest.engines._market_hooks import _normalize_symbol, calc_forex_swap
|
||||
|
||||
|
||||
# ── Typical spreads in pips (1 pip = 0.0001 for most pairs, 0.01 for JPY) ──
|
||||
|
||||
_SPREAD_PIPS: dict[str, float] = {
|
||||
# Majors
|
||||
"EUR/USD": 1.0, "GBP/USD": 1.2, "USD/JPY": 1.0, "USD/CHF": 1.3,
|
||||
"AUD/USD": 1.2, "USD/CAD": 1.5, "NZD/USD": 1.5,
|
||||
# Crosses
|
||||
"EUR/GBP": 1.5, "EUR/JPY": 1.5, "GBP/JPY": 2.5, "EUR/CHF": 1.8,
|
||||
"AUD/JPY": 2.0, "CHF/JPY": 2.5, "EUR/AUD": 2.0, "GBP/AUD": 3.0,
|
||||
"EUR/CAD": 2.5, "GBP/CAD": 3.5, "AUD/CAD": 2.5, "NZD/JPY": 2.5,
|
||||
# Exotics (wider spreads)
|
||||
"USD/TRY": 15.0, "USD/ZAR": 10.0, "USD/MXN": 8.0,
|
||||
"USD/SGD": 3.0, "USD/HKD": 3.0, "USD/CNH": 5.0,
|
||||
}
|
||||
_DEFAULT_SPREAD_PIPS = 2.0
|
||||
|
||||
# Standard lot size
|
||||
STANDARD_LOT = 100_000
|
||||
|
||||
|
||||
def _pip_value(symbol: str) -> float:
|
||||
"""Size of 1 pip for the pair.
|
||||
|
||||
Args:
|
||||
symbol: Forex pair (e.g. 'EUR/USD', 'USD/JPY').
|
||||
|
||||
Returns:
|
||||
1 pip in price terms (0.0001 or 0.01 for JPY pairs).
|
||||
"""
|
||||
quote = symbol.split("/")[1] if "/" in symbol else symbol[3:6]
|
||||
return 0.01 if quote.upper() == "JPY" else 0.0001
|
||||
|
||||
|
||||
class ForexEngine(BaseEngine):
|
||||
"""Forex engine for spot / CFD pairs.
|
||||
|
||||
Config keys:
|
||||
- leverage: default 100.0 (100:1)
|
||||
- spread_pips_override: override spread for all pairs
|
||||
- lot_size: default 100000 (standard lot)
|
||||
- swap_enabled: default True
|
||||
- slippage_pips: additional slippage beyond spread, default 0.3
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
config = {**config, "leverage": config.get("leverage", 100.0)}
|
||||
super().__init__(config)
|
||||
self.spread_override = config.get("spread_pips_override")
|
||||
self.lot_size: float = config.get("lot_size", STANDARD_LOT)
|
||||
self.swap_enabled: bool = config.get("swap_enabled", True)
|
||||
self.slippage_pips: float = config.get("slippage_pips", 0.3)
|
||||
self._last_swap_dates: dict = {} # per-symbol swap tracking
|
||||
|
||||
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
|
||||
"""Forex: 24x5, no restrictions."""
|
||||
return True
|
||||
|
||||
def round_size(self, raw_size: float, price: float) -> float:
|
||||
"""Round to micro-lot granularity (0.01 lots = 1000 units).
|
||||
|
||||
Position size is in currency units (not lots) for PnL compatibility.
|
||||
Round to nearest 1000 units (micro lot).
|
||||
"""
|
||||
return max(int(raw_size / 1000) * 1000, 0)
|
||||
|
||||
def calc_commission(self, size: float, price: float, _direction: int, is_open: bool) -> float:
|
||||
"""Forex: spread is the cost, embedded in slippage. No explicit commission.
|
||||
|
||||
Some ECN brokers charge per-lot commission; for simplicity, zero here.
|
||||
The cost is captured via apply_slippage (half-spread applied to execution).
|
||||
``_direction`` is unused — reserved for future ECN per-lot fee
|
||||
modelling (asymmetric long/short funding).
|
||||
"""
|
||||
return 0.0
|
||||
|
||||
def apply_slippage(self, price: float, direction: int) -> float:
|
||||
"""Apply half-spread + slippage using _active_symbol for correct pip/spread."""
|
||||
return self.apply_slippage_for_symbol(self._active_symbol, price, direction)
|
||||
|
||||
def apply_slippage_for_symbol(self, symbol: str, price: float, direction: int) -> float:
|
||||
"""Symbol-aware slippage with correct spread.
|
||||
|
||||
Args:
|
||||
symbol: Forex pair.
|
||||
price: Mid price.
|
||||
direction: 1 (buy) or -1 (sell).
|
||||
|
||||
Returns:
|
||||
Slipped price.
|
||||
"""
|
||||
pair = _normalize_symbol(symbol)
|
||||
pip = _pip_value(pair)
|
||||
|
||||
if self.spread_override is not None:
|
||||
spread_pips = self.spread_override
|
||||
else:
|
||||
spread_pips = _SPREAD_PIPS.get(pair, _DEFAULT_SPREAD_PIPS)
|
||||
|
||||
total_pips = (spread_pips / 2) + self.slippage_pips
|
||||
return price + direction * total_pips * pip
|
||||
|
||||
def on_bar(self, symbol: str, bar: pd.Series, timestamp: pd.Timestamp) -> None:
|
||||
"""Apply daily swap/rollover at end of trading day."""
|
||||
if not self.swap_enabled:
|
||||
return
|
||||
swap = calc_forex_swap(
|
||||
symbol, timestamp, self.positions,
|
||||
self.lot_size, self._last_swap_dates,
|
||||
)
|
||||
self.capital += swap
|
||||
|
||||
def get_contract_multiplier(self, symbol: str) -> float:
|
||||
"""Forex: multiplier is 1.0 (size is in currency units)."""
|
||||
return 1.0
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Base class for all futures engines.
|
||||
|
||||
Adds contract-multiplier awareness on top of BaseEngine.
|
||||
Only futures engines inherit from this; stocks/crypto/forex use BaseEngine directly.
|
||||
|
||||
The multiplier affects:
|
||||
- PnL: direction * size * multiplier * (exit - entry)
|
||||
- Margin: size * price * multiplier / leverage
|
||||
- Position sizing: target_notional / (price * multiplier)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
|
||||
from backtest.engines.base import BaseEngine
|
||||
|
||||
|
||||
class FuturesBaseEngine(BaseEngine):
|
||||
"""BaseEngine with contract-multiplier support.
|
||||
|
||||
Subclasses must implement ``get_contract_multiplier(symbol)``
|
||||
in addition to the standard market-rule methods.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_contract_multiplier(self, symbol: str) -> float:
|
||||
"""Contract multiplier for the instrument.
|
||||
|
||||
Args:
|
||||
symbol: Futures symbol (e.g. 'IF2406.CFFEX', 'ESZ4').
|
||||
|
||||
Returns:
|
||||
Points-to-currency multiplier (e.g. IF=300, ES=50).
|
||||
"""
|
||||
|
||||
# ── Override PnL / margin / sizing to include multiplier ──
|
||||
|
||||
def _calc_pnl(
|
||||
self, symbol: str, direction: int, size: float,
|
||||
entry_price: float, exit_price: float,
|
||||
) -> float:
|
||||
cm = self.get_contract_multiplier(symbol)
|
||||
return direction * size * cm * (exit_price - entry_price)
|
||||
|
||||
def _calc_margin(
|
||||
self, symbol: str, size: float, price: float, leverage: float,
|
||||
) -> float:
|
||||
cm = self.get_contract_multiplier(symbol)
|
||||
return size * price * cm / leverage
|
||||
|
||||
def _calc_raw_size(
|
||||
self, symbol: str, target_notional: float, price: float,
|
||||
) -> float:
|
||||
cm = self.get_contract_multiplier(symbol)
|
||||
return target_notional / (price * cm)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Global equity (US / HK) backtest engine.
|
||||
|
||||
Market rules:
|
||||
US:
|
||||
- T+0, long/short allowed
|
||||
- Zero commission (retail brokers)
|
||||
- Fractional shares supported (round to 0.01)
|
||||
- Low slippage (high liquidity)
|
||||
HK:
|
||||
- T+0, long/short allowed
|
||||
- Stamp tax 0.1% bilateral + levies
|
||||
- Lot-size rounding (simplified to 100 shares)
|
||||
- Higher slippage than US
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.engines.base import BaseEngine
|
||||
|
||||
|
||||
class GlobalEquityEngine(BaseEngine):
|
||||
"""US / HK equity engine, selected by *market* parameter.
|
||||
|
||||
Config keys:
|
||||
- slippage_us: default 0.0005
|
||||
- slippage_hk: default 0.001
|
||||
- hk_stamp_tax: default 0.001 (0.1% bilateral)
|
||||
- hk_commission: default 0.00015 (万1.5)
|
||||
- hk_levy: default 0.0000565 (SFC + FRC)
|
||||
- hk_settlement: default 0.00002 (CCASS)
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict, market: str = "us"):
|
||||
config = {**config, "leverage": config.get("leverage", 1.0)}
|
||||
super().__init__(config)
|
||||
self.market = market
|
||||
|
||||
# US defaults
|
||||
self.slippage_us: float = config.get("slippage_us", 0.0005)
|
||||
# HK defaults
|
||||
self.slippage_hk: float = config.get("slippage_hk", 0.001)
|
||||
self.hk_stamp_tax: float = config.get("hk_stamp_tax", 0.001)
|
||||
self.hk_commission: float = config.get("hk_commission", 0.00015)
|
||||
self.hk_levy: float = config.get("hk_levy", 0.0000565)
|
||||
self.hk_settlement: float = config.get("hk_settlement", 0.00002)
|
||||
|
||||
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
|
||||
"""US/HK: T+0, both directions allowed."""
|
||||
return True
|
||||
|
||||
def round_size(self, raw_size: float, price: float) -> float:
|
||||
"""US: fractional shares (0.01). HK: 100-share lots."""
|
||||
if self.market == "hk":
|
||||
return max(int(raw_size / 100) * 100, 0)
|
||||
return round(max(raw_size, 0.0), 2)
|
||||
|
||||
def calc_commission(self, size: float, price: float, _direction: int, is_open: bool) -> float:
|
||||
"""US: zero commission. HK: stamp tax + levies.
|
||||
|
||||
``_direction`` is unused — reserved for future short-borrow fees
|
||||
(US Reg-T margin, HK SBL costs).
|
||||
"""
|
||||
if self.market == "hk":
|
||||
notional = size * price
|
||||
comm = notional * self.hk_commission # broker commission
|
||||
comm += notional * self.hk_stamp_tax # stamp tax bilateral
|
||||
comm += notional * self.hk_levy # SFC + FRC levies
|
||||
comm += notional * self.hk_settlement # CCASS settlement
|
||||
return comm
|
||||
# US: zero commission (SEC fee negligible)
|
||||
return 0.0
|
||||
|
||||
def apply_slippage(self, price: float, direction: int) -> float:
|
||||
"""US: low slippage. HK: moderate slippage."""
|
||||
rate = self.slippage_hk if self.market == "hk" else self.slippage_us
|
||||
return price * (1 + direction * rate)
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Global futures backtest engine (CME / ICE / Eurex).
|
||||
|
||||
Market rules:
|
||||
- Nearly 24x5 (CME Globex: Sun 17:00 - Fri 16:00 CT, daily pause 16:00-17:00)
|
||||
- Margin: initial + maintenance (exchange-set per contract)
|
||||
- Limit up/down: dynamic for equity index, fixed for commodities
|
||||
- Contract multiplier: per-product (ES=$50/pt, CL=$1000/bbl, GC=$100/oz)
|
||||
- Commission: per-contract ($1-3 per side typical)
|
||||
- Minimum unit: 1 contract
|
||||
- Roll/expiry: not modeled (assumes continuous front-month data)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.engines.futures_base import FuturesBaseEngine
|
||||
|
||||
|
||||
# ── Contract multiplier (USD per point / per unit) ──
|
||||
|
||||
_MULTIPLIER: dict[str, float] = {
|
||||
# Equity index (CME)
|
||||
"ES": 50, "NQ": 20, "YM": 5, "RTY": 50,
|
||||
# Micro equity index
|
||||
"MES": 5, "MNQ": 2, "MYM": 0.5, "M2K": 5,
|
||||
# Energy (NYMEX)
|
||||
"CL": 1000, "NG": 10000, "RB": 42000, "HO": 42000,
|
||||
# Metals (COMEX)
|
||||
"GC": 100, "SI": 5000, "HG": 25000, "PL": 50, "PA": 100,
|
||||
# Micro metals
|
||||
"MGC": 10, "SIL": 1000,
|
||||
# Grains (CBOT)
|
||||
"ZC": 50, "ZS": 50, "ZW": 50, "ZM": 100, "ZL": 600,
|
||||
# Bonds (CBOT)
|
||||
"ZB": 1000, "ZN": 1000, "ZF": 1000, "ZT": 2000,
|
||||
# Currencies (CME)
|
||||
"6E": 125000, "6J": 12500000, "6B": 62500, "6A": 100000, "6C": 100000,
|
||||
# Softs (ICE)
|
||||
"KC": 37500, "SB": 112000, "CC": 10, "CT": 50000,
|
||||
# Livestock (CME)
|
||||
"LE": 400, "HE": 400, "GF": 500,
|
||||
# Eurex
|
||||
"FESX": 10, "FDAX": 25, "FGBL": 1000,
|
||||
}
|
||||
|
||||
# ── Margin per contract (approximate USD, initial margin) ──
|
||||
# Reference table — future use for margin-call checks. Not yet consumed.
|
||||
|
||||
_MARGIN_PER_CONTRACT: dict[str, float] = {
|
||||
# Equity index
|
||||
"ES": 12650, "NQ": 17600, "YM": 8800, "RTY": 6600,
|
||||
"MES": 1265, "MNQ": 1760,
|
||||
# Energy
|
||||
"CL": 6270, "NG": 3300,
|
||||
# Metals
|
||||
"GC": 9950, "SI": 11000, "HG": 4400, "PL": 3300,
|
||||
"MGC": 995,
|
||||
# Grains
|
||||
"ZC": 1650, "ZS": 2200, "ZW": 1925,
|
||||
# Bonds
|
||||
"ZB": 4400, "ZN": 2200, "ZF": 1375,
|
||||
# Currencies
|
||||
"6E": 2475, "6J": 3300, "6B": 2750,
|
||||
}
|
||||
|
||||
# ── Price limit (fraction of prev settlement) ──
|
||||
|
||||
_PRICE_LIMIT: dict[str, float] = {
|
||||
# Equity index: 7% (Level 1), simplified to single level
|
||||
"ES": 0.07, "NQ": 0.07, "YM": 0.07, "RTY": 0.07,
|
||||
"MES": 0.07, "MNQ": 0.07,
|
||||
# Energy: varies, typically ~$10-15 for CL
|
||||
# Not easily expressed as %, skip for most commodities
|
||||
}
|
||||
|
||||
# ── Per-contract commission (USD, one side) ──
|
||||
|
||||
_COMMISSION_PER_CONTRACT: dict[str, float] = {
|
||||
"ES": 2.25, "NQ": 2.25, "YM": 2.25, "RTY": 2.25,
|
||||
"MES": 0.62, "MNQ": 0.62,
|
||||
"CL": 2.25, "NG": 2.25,
|
||||
"GC": 2.25, "SI": 2.25, "HG": 2.25,
|
||||
"MGC": 0.62,
|
||||
"ZC": 2.25, "ZS": 2.25, "ZW": 2.25,
|
||||
"ZB": 1.52, "ZN": 1.52, "ZF": 1.02,
|
||||
"6E": 2.25, "6J": 2.25, "6B": 2.25,
|
||||
}
|
||||
_DEFAULT_COMMISSION = 2.50
|
||||
|
||||
|
||||
_MONTH_CODES = set("FGHJKMNQUVXZ")
|
||||
|
||||
|
||||
def _extract_product(symbol: str) -> str:
|
||||
"""Extract product code from futures symbol.
|
||||
|
||||
Handles CME conventions:
|
||||
- Product + month-code + year: ESZ4, CLF25, GCM2025
|
||||
- Product + YYMM: CL2412, NQ2503
|
||||
- Product.exchange: ES.CME
|
||||
- Bare product: ES
|
||||
|
||||
Args:
|
||||
symbol: Futures symbol string.
|
||||
|
||||
Returns:
|
||||
Product code (e.g. 'ES', 'CL', 'GC').
|
||||
"""
|
||||
code = symbol.split(".")[0].upper()
|
||||
|
||||
# Pattern 1: product + month-code + year (ESZ4, CLF25, GCM2025)
|
||||
m = re.match(r"([A-Z]{2,4})([FGHJKMNQUVXZ])(\d{1,4})$", code)
|
||||
if m:
|
||||
return m.group(1)
|
||||
|
||||
# Pattern 2: product + YYMM (NQ2503, CL2412)
|
||||
m = re.match(r"([A-Z]+)(\d{4})$", code)
|
||||
if m:
|
||||
return m.group(1)
|
||||
|
||||
# Pattern 3: bare product or fallback
|
||||
m = re.match(r"([A-Z]+)", code)
|
||||
return m.group(1) if m else code
|
||||
|
||||
|
||||
class GlobalFuturesEngine(FuturesBaseEngine):
|
||||
"""International futures engine (CME/CBOT/NYMEX/COMEX/ICE/Eurex).
|
||||
|
||||
Config keys:
|
||||
- slippage: default 0.0003
|
||||
- commission_per_contract: override, default varies by product
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
# Leverage: most futures have 5-15% margin → 7-20x leverage.
|
||||
# Price is unknown at init, so use a reasonable fixed default.
|
||||
# User can override via config["leverage"].
|
||||
leverage = config.get("leverage", 10.0)
|
||||
config = {**config, "leverage": leverage}
|
||||
super().__init__(config)
|
||||
self.slippage_rate: float = config.get("slippage", 0.0003)
|
||||
self._comm_override = config.get("commission_per_contract")
|
||||
|
||||
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
|
||||
"""Global futures: T+0, both directions, limit checks for equity index.
|
||||
|
||||
Args:
|
||||
symbol: Futures symbol.
|
||||
direction: 1 (long), -1 (short), 0 (close).
|
||||
bar: Current bar data.
|
||||
|
||||
Returns:
|
||||
True if allowed.
|
||||
"""
|
||||
product = _extract_product(symbol)
|
||||
limit = _PRICE_LIMIT.get(product)
|
||||
if limit is None:
|
||||
return True # no price limit for most commodities
|
||||
|
||||
pct_chg = _calc_pct_change(bar)
|
||||
if pct_chg is not None:
|
||||
if direction == 1 and pct_chg >= limit - 0.001:
|
||||
return False # limit-up
|
||||
if direction == -1 and pct_chg <= -limit + 0.001:
|
||||
return False # limit-down
|
||||
if direction == 0:
|
||||
pos = self.positions.get(symbol)
|
||||
if pos is not None:
|
||||
if pos.direction == 1 and pct_chg <= -limit + 0.001:
|
||||
return False
|
||||
if pos.direction == -1 and pct_chg >= limit - 0.001:
|
||||
return False
|
||||
return True
|
||||
|
||||
def round_size(self, raw_size: float, price: float) -> float:
|
||||
"""Integer contracts, minimum 1."""
|
||||
return max(int(raw_size), 0)
|
||||
|
||||
def calc_commission(self, size: float, price: float, _direction: int, is_open: bool) -> float:
|
||||
"""Per-contract fixed commission (uses _active_symbol for product lookup).
|
||||
|
||||
``_direction`` is unused — reserved for future borrow/financing
|
||||
asymmetry on short positions.
|
||||
"""
|
||||
if self._comm_override is not None:
|
||||
return size * self._comm_override
|
||||
return self.calc_commission_for_symbol(self._active_symbol, size, price, is_open)
|
||||
|
||||
def calc_commission_for_symbol(
|
||||
self, symbol: str, size: float, price: float, is_open: bool,
|
||||
) -> float:
|
||||
"""Symbol-aware commission.
|
||||
|
||||
Args:
|
||||
symbol: Futures code.
|
||||
size: Number of contracts.
|
||||
price: Execution price (unused — fixed per-lot).
|
||||
is_open: Opening or closing.
|
||||
|
||||
Returns:
|
||||
Commission in USD.
|
||||
"""
|
||||
product = _extract_product(symbol)
|
||||
rate = _COMMISSION_PER_CONTRACT.get(product, _DEFAULT_COMMISSION)
|
||||
return size * rate
|
||||
|
||||
def apply_slippage(self, price: float, direction: int) -> float:
|
||||
"""Slippage model for liquid global futures."""
|
||||
return price * (1 + direction * self.slippage_rate)
|
||||
|
||||
def get_contract_multiplier(self, symbol: str) -> float:
|
||||
"""Product-specific contract multiplier."""
|
||||
product = _extract_product(symbol)
|
||||
return float(_MULTIPLIER.get(product, 50))
|
||||
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
|
||||
# Note: china_a uses close/pre_close-only; china_futures prioritises
|
||||
# settle/pre_settle. This global-futures variant prefers close/pre_close
|
||||
# because CME data feeds (yfinance/IB) expose continuous close more
|
||||
# reliably than settlement. See those modules for the equity /
|
||||
# China-futures equivalents.
|
||||
def _calc_pct_change(bar: pd.Series):
|
||||
"""Calculate bar price change percentage.
|
||||
|
||||
Priority: close/pre_close > settle/pre_settle > pct_chg.
|
||||
Falls back to pct_chg only when price fields are absent.
|
||||
"""
|
||||
close = bar.get("close")
|
||||
pre_close = bar.get("pre_close")
|
||||
if close is not None and pre_close is not None and pre_close > 0:
|
||||
return (float(close) - float(pre_close)) / float(pre_close)
|
||||
|
||||
settle = bar.get("settle")
|
||||
pre_settle = bar.get("pre_settle")
|
||||
if settle is not None and pre_settle is not None and pre_settle > 0:
|
||||
return (float(settle) - float(pre_settle)) / float(pre_settle)
|
||||
|
||||
if "pct_chg" in bar.index:
|
||||
val = bar["pct_chg"]
|
||||
if pd.notna(val):
|
||||
raw = float(val)
|
||||
# Heuristic: values > 1 are likely percentage points
|
||||
return raw / 100.0 if abs(raw) > 1.0 else raw
|
||||
|
||||
return None
|
||||
@@ -1,7 +1,14 @@
|
||||
"""Options portfolio backtest engine.
|
||||
"""Options portfolio backtest engine (v2).
|
||||
|
||||
European options backtest engine based on Black-Scholes model.
|
||||
Synthesises theoretical option prices from underlying prices; supports multi-leg strategies.
|
||||
Supports European and American options via Black-Scholes model with
|
||||
IV smile approximation. Synthesises theoretical option prices from
|
||||
underlying prices; supports multi-leg strategies.
|
||||
|
||||
v2 enhancements over v1:
|
||||
- American option support (early exercise heuristic for calls on dividends,
|
||||
always-exercise check for deep ITM puts)
|
||||
- IV smile model: skew adjustment based on moneyness (log(K/S))
|
||||
- Portfolio-level Greeks aggregation
|
||||
|
||||
Signal interface: OptionsSignalEngine.generate(data_map) returns a list of trade instructions.
|
||||
Artifacts: equity.csv, metrics.csv, trades.csv, greeks.csv.
|
||||
@@ -9,7 +16,6 @@ Artifacts: equity.csv, metrics.csv, trades.csv, greeks.csv.
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -123,6 +129,32 @@ def historical_volatility(close: pd.Series, window: int = 30) -> pd.Series:
|
||||
return hv.fillna(hv.dropna().iloc[0] if len(hv.dropna()) > 0 else 0.3)
|
||||
|
||||
|
||||
# --- IV Smile model (v2) ---
|
||||
|
||||
|
||||
def iv_smile_adjustment(S: float, K: float, base_iv: float,
|
||||
skew: float = -0.15, curvature: float = 0.05) -> float:
|
||||
"""Adjust IV for moneyness using a quadratic smile model.
|
||||
|
||||
IV(K) = base_iv + skew * log(K/S) + curvature * log(K/S)^2
|
||||
|
||||
Args:
|
||||
S: Spot price.
|
||||
K: Strike price.
|
||||
base_iv: At-the-money implied volatility.
|
||||
skew: Slope of the smile (negative = put skew). Default -0.15.
|
||||
curvature: Curvature of the smile (always positive). Default 0.05.
|
||||
|
||||
Returns:
|
||||
Adjusted implied volatility, floored at 0.01.
|
||||
"""
|
||||
if S <= 0 or K <= 0:
|
||||
return max(base_iv, 0.01)
|
||||
log_moneyness = np.log(K / S)
|
||||
adj = base_iv + skew * log_moneyness + curvature * log_moneyness ** 2
|
||||
return max(adj, 0.01)
|
||||
|
||||
|
||||
# --- Option positions ---
|
||||
|
||||
|
||||
@@ -227,8 +259,10 @@ def run_options_backtest(
|
||||
commission = config.get("commission", 0.001)
|
||||
options_cfg = config.get("options_config", {})
|
||||
risk_free_rate = options_cfg.get("risk_free_rate", 0.05)
|
||||
iv_source = options_cfg.get("iv_source", "historical")
|
||||
contract_multiplier = options_cfg.get("contract_multiplier", 1.0)
|
||||
exercise_style = options_cfg.get("exercise_style", "european") # v2: "european" or "american"
|
||||
iv_skew = options_cfg.get("iv_skew", 0.0) # v2: smile skew param (0 = flat)
|
||||
iv_curvature = options_cfg.get("iv_curvature", 0.0) # v2: smile curvature
|
||||
|
||||
# Load underlying data
|
||||
data_map = loader.fetch(codes, start_date, end_date)
|
||||
@@ -282,7 +316,38 @@ def run_options_backtest(
|
||||
spot_prices[code] = float(df.at[last, "close"])
|
||||
ivs[code] = float(iv_map[code].at[last]) if last in iv_map[code].index else 0.3
|
||||
|
||||
# 2. Handle expiry
|
||||
# 2a. American early exercise (v2): exercise if intrinsic > continuation
|
||||
if exercise_style == "american":
|
||||
for pos in list(positions):
|
||||
if pos.is_expired(ts):
|
||||
continue # handled below
|
||||
spot = spot_prices.get(pos.underlying_code, 0.0)
|
||||
iv_val_ex = ivs.get(pos.underlying_code, 0.3)
|
||||
T_ex = pos.time_to_expiry(ts)
|
||||
if T_ex <= 0:
|
||||
continue
|
||||
intrinsic = pos.intrinsic_value(spot)
|
||||
continuation = bs_price(spot, pos.strike, T_ex, risk_free_rate, iv_val_ex, pos.option_type)
|
||||
if intrinsic > 0 and intrinsic > continuation * 1.02:
|
||||
# Early exercise is optimal
|
||||
settlement = intrinsic * pos.qty * contract_multiplier
|
||||
cash += settlement
|
||||
pnl = (intrinsic - pos.entry_price) * pos.qty * contract_multiplier
|
||||
trade_records.append({
|
||||
"timestamp": date_str,
|
||||
"code": pos.underlying_code,
|
||||
"option_type": pos.option_type,
|
||||
"strike": pos.strike,
|
||||
"expiry": str(pos.expiry.date()),
|
||||
"side": "early_exercise",
|
||||
"price": round(intrinsic, 4),
|
||||
"qty": pos.qty,
|
||||
"pnl": round(pnl, 4),
|
||||
"entry_date": pos.entry_date,
|
||||
})
|
||||
positions.remove(pos)
|
||||
|
||||
# 2b. Handle expiry
|
||||
expired = [p for p in positions if p.is_expired(ts)]
|
||||
for pos in expired:
|
||||
spot = spot_prices.get(pos.underlying_code, 0.0)
|
||||
@@ -327,8 +392,13 @@ def run_options_backtest(
|
||||
expiry_ts = pd.Timestamp(expiry)
|
||||
T = max((expiry_ts - ts).days / 365.0, 0.001)
|
||||
|
||||
# Black-Scholes price
|
||||
opt_price = bs_price(spot, strike, T, risk_free_rate, iv_val, leg_type)
|
||||
# Apply IV smile adjustment (v2) if configured
|
||||
adj_iv = iv_val
|
||||
if iv_skew != 0 or iv_curvature != 0:
|
||||
adj_iv = iv_smile_adjustment(spot, strike, iv_val, iv_skew, iv_curvature)
|
||||
|
||||
# Black-Scholes price (with smile-adjusted IV if enabled)
|
||||
opt_price = bs_price(spot, strike, T, risk_free_rate, adj_iv, leg_type)
|
||||
|
||||
if action == "open":
|
||||
# Open: long pays premium, short receives premium
|
||||
@@ -452,6 +522,15 @@ def run_options_backtest(
|
||||
pd.DataFrame(greeks_records).to_csv(out / "greeks.csv", index=False)
|
||||
pd.DataFrame([metrics]).to_csv(out / "metrics.csv", index=False)
|
||||
|
||||
from backtest.run_card import write_run_card
|
||||
write_run_card(
|
||||
run_dir,
|
||||
config,
|
||||
metrics,
|
||||
data_sources=[str(getattr(loader, "name", config.get("source", "")))],
|
||||
strategy_path=run_dir / "code" / "signal_engine.py",
|
||||
)
|
||||
|
||||
print(json.dumps(metrics, indent=2))
|
||||
return metrics
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""AKShare loader: free, no-auth data for A-shares, US, HK, futures, forex, macro.
|
||||
|
||||
AKShare (https://github.com/akfamily/akshare) is a completely free financial
|
||||
data aggregator covering Chinese and global markets. No API token required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders.base import validate_date_range
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_INTERVAL_MAP_DAILY = {
|
||||
"1D": "daily",
|
||||
"1W": "weekly",
|
||||
"1M": "monthly",
|
||||
}
|
||||
|
||||
|
||||
def _is_a_share(code: str) -> bool:
|
||||
return code.upper().endswith((".SZ", ".SH", ".BJ"))
|
||||
|
||||
|
||||
def _is_hk(code: str) -> bool:
|
||||
return code.upper().endswith(".HK")
|
||||
|
||||
|
||||
def _is_us(code: str) -> bool:
|
||||
return code.upper().endswith(".US")
|
||||
|
||||
|
||||
def _is_crypto(code: str) -> bool:
|
||||
return "-USDT" in code.upper() or "/USDT" in code.upper()
|
||||
|
||||
|
||||
# Exchange-listed ETF / LOF prefix codes:
|
||||
# SH: 50/51/52/56/58 (ETFs), SZ: 15/16 (ETFs + LOFs).
|
||||
# Issue #50 — these symbols look like A-shares (.SH / .SZ) but stock_zh_a_hist
|
||||
# can't price them; route through fund_etf_hist_sina instead.
|
||||
_ETF_PREFIXES = frozenset({"15", "16", "50", "51", "52", "56", "58"})
|
||||
|
||||
|
||||
def _is_etf_listed(code: str) -> bool:
|
||||
"""Detect exchange-listed ETF / LOF symbols (e.g. 518880.SH, 159915.SZ)."""
|
||||
upper = code.upper()
|
||||
if not upper.endswith((".SH", ".SZ")):
|
||||
return False
|
||||
digits = upper.split(".")[0]
|
||||
if len(digits) != 6 or not digits.isdigit():
|
||||
return False
|
||||
return digits[:2] in _ETF_PREFIXES
|
||||
|
||||
|
||||
def _is_forex(code: str) -> bool:
|
||||
"""Detect forex pairs by matching against AKShare's symbol_market_map.
|
||||
|
||||
Issue #54 — forex symbols (EURUSD, GBPUSD, etc.) have no exchange suffix
|
||||
and previously fell through to the A-share endpoint.
|
||||
"""
|
||||
upper = code.upper().removesuffix(".FX")
|
||||
try:
|
||||
from akshare.forex.cons import symbol_market_map
|
||||
except Exception:
|
||||
return False
|
||||
return upper in symbol_market_map
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""AKShare universal OHLCV loader (free, no auth)."""
|
||||
|
||||
name = "akshare"
|
||||
markets = {"a_share", "us_equity", "hk_equity", "futures", "fund", "macro", "forex"}
|
||||
requires_auth = False
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Available if akshare is installed."""
|
||||
try:
|
||||
import akshare # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
codes: List[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
interval: str = "1D",
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
"""Fetch OHLCV data via AKShare.
|
||||
|
||||
Args:
|
||||
codes: Symbol list.
|
||||
start_date: YYYY-MM-DD.
|
||||
end_date: YYYY-MM-DD.
|
||||
interval: Bar size (only 1D supported currently).
|
||||
fields: Ignored.
|
||||
|
||||
Returns:
|
||||
Mapping symbol -> OHLCV DataFrame.
|
||||
"""
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
result: Dict[str, pd.DataFrame] = {}
|
||||
for code in codes:
|
||||
try:
|
||||
df = self._fetch_one(code, start_date, end_date, interval)
|
||||
if df is not None and not df.empty:
|
||||
result[code] = df
|
||||
except Exception as exc:
|
||||
logger.warning("akshare failed for %s: %s", code, exc)
|
||||
return result
|
||||
|
||||
def _fetch_one(
|
||||
self, code: str, start_date: str, end_date: str, interval: str,
|
||||
) -> Optional[pd.DataFrame]:
|
||||
"""Fetch a single symbol."""
|
||||
import akshare as ak
|
||||
|
||||
# ETF check must precede A-share — 518880.SH ends with .SH but is an ETF.
|
||||
if _is_etf_listed(code):
|
||||
return self._fetch_etf(ak, code, start_date, end_date)
|
||||
if _is_a_share(code):
|
||||
return self._fetch_a_share(ak, code, start_date, end_date, interval)
|
||||
if _is_us(code):
|
||||
return self._fetch_us(ak, code, start_date, end_date)
|
||||
if _is_hk(code):
|
||||
return self._fetch_hk(ak, code, start_date, end_date)
|
||||
if _is_forex(code):
|
||||
return self._fetch_forex(ak, code, start_date, end_date)
|
||||
# Default: try A-share
|
||||
return self._fetch_a_share(ak, code, start_date, end_date, interval)
|
||||
|
||||
def _fetch_a_share(
|
||||
self, ak, code: str, start_date: str, end_date: str, interval: str,
|
||||
) -> Optional[pd.DataFrame]:
|
||||
"""Fetch A-share via stock_zh_a_hist."""
|
||||
symbol = code.split(".")[0]
|
||||
period = _INTERVAL_MAP_DAILY.get(interval, "daily")
|
||||
sd = start_date.replace("-", "")
|
||||
ed = end_date.replace("-", "")
|
||||
df = ak.stock_zh_a_hist(
|
||||
symbol=symbol,
|
||||
period=period,
|
||||
start_date=sd,
|
||||
end_date=ed,
|
||||
adjust="qfq",
|
||||
)
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
return self._normalize(df, date_col="日期")
|
||||
|
||||
def _fetch_us(self, ak, code: str, start_date: str, end_date: str) -> Optional[pd.DataFrame]:
|
||||
"""Fetch US stock via stock_us_hist."""
|
||||
symbol = code.replace(".US", "")
|
||||
# akshare uses the format like "105.AAPL" for NASDAQ
|
||||
# Try common prefixes
|
||||
for prefix in ["105.", "106.", ""]:
|
||||
try:
|
||||
df = ak.stock_us_hist(
|
||||
symbol=f"{prefix}{symbol}",
|
||||
period="daily",
|
||||
start_date=start_date.replace("-", ""),
|
||||
end_date=end_date.replace("-", ""),
|
||||
adjust="qfq",
|
||||
)
|
||||
if df is not None and not df.empty:
|
||||
return self._normalize(df, date_col="日期")
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
def _fetch_etf(self, ak, code: str, start_date: str, end_date: str) -> Optional[pd.DataFrame]:
|
||||
"""Fetch exchange-listed ETF / LOF via fund_etf_hist_sina.
|
||||
|
||||
Sina symbol format is ``sh518880`` / ``sz159915``. The endpoint returns
|
||||
the full history; we filter to the requested window after fetching.
|
||||
"""
|
||||
digits, _, suffix = code.upper().partition(".")
|
||||
symbol = f"{suffix.lower()}{digits}"
|
||||
df = ak.fund_etf_hist_sina(symbol=symbol)
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
df = self._normalize(df, date_col="date")
|
||||
# fund_etf_hist_sina returns full history — clip to window.
|
||||
return df.loc[start_date:end_date]
|
||||
|
||||
def _fetch_forex(self, ak, code: str, start_date: str, end_date: str) -> Optional[pd.DataFrame]:
|
||||
"""Fetch forex pair via forex_hist_em.
|
||||
|
||||
Columns returned are 日期 / 代码 / 名称 / 今开 / 最新价 / 最高 / 最低 / 振幅
|
||||
— note ``最新价`` (latest) plays the role of close. Volume isn't reported,
|
||||
so we synthesize a zero column to satisfy the OHLCV contract.
|
||||
"""
|
||||
symbol = code.upper().removesuffix(".FX")
|
||||
df = ak.forex_hist_em(symbol=symbol)
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
df = df.rename(columns={
|
||||
"日期": "trade_date",
|
||||
"今开": "open",
|
||||
"最新价": "close",
|
||||
"最高": "high",
|
||||
"最低": "low",
|
||||
})
|
||||
df["trade_date"] = pd.to_datetime(df["trade_date"])
|
||||
df = df.set_index("trade_date").sort_index()
|
||||
df["volume"] = 0.0
|
||||
for col in ("open", "high", "low", "close"):
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
df = df[["open", "high", "low", "close", "volume"]].dropna(
|
||||
subset=["open", "high", "low", "close"]
|
||||
)
|
||||
return df.loc[start_date:end_date]
|
||||
|
||||
def _fetch_hk(self, ak, code: str, start_date: str, end_date: str) -> Optional[pd.DataFrame]:
|
||||
"""Fetch HK stock via stock_hk_hist."""
|
||||
symbol = code.replace(".HK", "").zfill(5)
|
||||
df = ak.stock_hk_hist(
|
||||
symbol=symbol,
|
||||
period="daily",
|
||||
start_date=start_date.replace("-", ""),
|
||||
end_date=end_date.replace("-", ""),
|
||||
adjust="qfq",
|
||||
)
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
return self._normalize(df, date_col="日期")
|
||||
|
||||
@staticmethod
|
||||
def _normalize(df: pd.DataFrame, date_col: str = "日期") -> pd.DataFrame:
|
||||
"""Normalize AKShare DataFrame to standard OHLCV schema.
|
||||
|
||||
AKShare Chinese column names: 日期, 开盘, 最高, 最低, 收盘, 成交量
|
||||
AKShare English column names: date, open, high, low, close, volume
|
||||
"""
|
||||
col_map_cn = {"开盘": "open", "最高": "high", "最低": "low", "收盘": "close", "成交量": "volume"}
|
||||
col_map_en = {"date": "trade_date", "open": "open", "high": "high", "low": "low", "close": "close", "volume": "volume"}
|
||||
|
||||
if date_col in df.columns:
|
||||
df = df.rename(columns={date_col: "trade_date"})
|
||||
elif "date" in df.columns:
|
||||
df = df.rename(columns={"date": "trade_date"})
|
||||
|
||||
# Try Chinese column names first, then English
|
||||
if "开盘" in df.columns:
|
||||
df = df.rename(columns=col_map_cn)
|
||||
else:
|
||||
df = df.rename(columns=col_map_en)
|
||||
|
||||
df["trade_date"] = pd.to_datetime(df["trade_date"])
|
||||
df = df.set_index("trade_date").sort_index()
|
||||
|
||||
for col in ["open", "high", "low", "close", "volume"]:
|
||||
if col in df.columns:
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
|
||||
ohlcv_cols = [c for c in ["open", "high", "low", "close", "volume"] if c in df.columns]
|
||||
df = df[ohlcv_cols].dropna(subset=["open", "high", "low", "close"])
|
||||
if "volume" not in df.columns:
|
||||
df["volume"] = 0.0
|
||||
return df
|
||||
@@ -0,0 +1,152 @@
|
||||
"""DataLoader Protocol, shared exceptions, and bounded-retry helpers.
|
||||
|
||||
The retry/budget helpers are the canonical pattern for any loader that calls
|
||||
a flaky external API: a wall-clock deadline plus a small backoff schedule
|
||||
applied only to a declared transient exception class. New loaders should
|
||||
import :func:`check_budget` and :func:`retry_with_budget` rather than
|
||||
re-implementing the loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Callable, Protocol, TypeVar, runtime_checkable
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class NoAvailableSourceError(Exception):
|
||||
"""Raised when no data source is available for a given market."""
|
||||
|
||||
|
||||
def validate_date_range(start_date: str, end_date: str) -> None:
|
||||
"""Validate that start_date <= end_date.
|
||||
|
||||
Args:
|
||||
start_date: Start date string (YYYY-MM-DD).
|
||||
end_date: End date string (YYYY-MM-DD).
|
||||
|
||||
Raises:
|
||||
ValueError: If dates are invalid or start > end.
|
||||
"""
|
||||
try:
|
||||
start = pd.Timestamp(start_date)
|
||||
end = pd.Timestamp(end_date)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Invalid date format: start={start_date!r}, end={end_date!r}") from exc
|
||||
if start > end:
|
||||
raise ValueError(f"start_date ({start_date}) > end_date ({end_date})")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bounded retry / budget helpers (shared by ccxt_loader, okx, and any future
|
||||
# loader calling a flaky external API).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_BACKOFF: tuple[float, ...] = (0.5, 1.5, 4.0)
|
||||
DEFAULT_MAX_RETRIES = 3
|
||||
|
||||
|
||||
def check_budget(deadline: float, label: str, budget_s: float | None = None) -> None:
|
||||
"""Raise :class:`TimeoutError` if the monotonic clock has crossed ``deadline``.
|
||||
|
||||
Use this between pages of a paginated fetch to fail fast instead of
|
||||
grinding through more requests once the wall-clock budget is gone.
|
||||
|
||||
Args:
|
||||
deadline: ``time.monotonic()`` instant past which we abort.
|
||||
label: Free-form label used in the exception message
|
||||
(e.g. ``"ccxt fetch for BTC/USDT"``).
|
||||
budget_s: Original budget in seconds, included verbatim in the
|
||||
message when present.
|
||||
"""
|
||||
if time.monotonic() > deadline:
|
||||
suffix = f" exceeded {budget_s:.0f}s budget" if budget_s is not None else " exceeded budget"
|
||||
raise TimeoutError(f"{label}{suffix}")
|
||||
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
def retry_with_budget(
|
||||
fn: Callable[[], _T],
|
||||
*,
|
||||
transient: type[BaseException] | tuple[type[BaseException], ...],
|
||||
deadline: float,
|
||||
label: str,
|
||||
max_retries: int = DEFAULT_MAX_RETRIES,
|
||||
backoff: tuple[float, ...] = DEFAULT_BACKOFF,
|
||||
) -> _T:
|
||||
"""Call ``fn`` with a bounded retry budget on declared transient errors.
|
||||
|
||||
Between attempts sleeps ``min(backoff[attempt], remaining_budget)`` so a
|
||||
short remaining budget never spends the full backoff. The terminal
|
||||
transient failure — whether ``max_retries`` is exhausted OR the deadline
|
||||
has passed — is wrapped in :class:`TimeoutError`, preserving the original
|
||||
exception as ``__cause__``. Anything not in ``transient`` propagates
|
||||
unchanged on the first occurrence (we never retry an exception class
|
||||
the caller didn't opt in to).
|
||||
|
||||
Args:
|
||||
fn: Zero-arg callable producing the result.
|
||||
transient: Exception class(es) considered transient and retryable.
|
||||
deadline: ``time.monotonic()`` instant past which retries are aborted.
|
||||
label: Free-form label used in the TimeoutError message
|
||||
(e.g. ``"OKX fetch for BTC-USDT"``).
|
||||
max_retries: Additional attempts after the first call. Total
|
||||
attempts = ``max_retries + 1``.
|
||||
backoff: Per-retry sleep seconds. Must have at least
|
||||
``max_retries`` entries.
|
||||
|
||||
Returns:
|
||||
Whatever ``fn`` returns.
|
||||
|
||||
Raises:
|
||||
ValueError: ``backoff`` is shorter than ``max_retries``.
|
||||
TimeoutError: All retries exhausted or the deadline crossed.
|
||||
Any non-transient exception: Propagated unchanged from ``fn``.
|
||||
"""
|
||||
if len(backoff) < max_retries:
|
||||
raise ValueError(
|
||||
f"backoff has {len(backoff)} entries; need >= max_retries ({max_retries})"
|
||||
)
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
return fn()
|
||||
except transient as exc:
|
||||
remaining = deadline - time.monotonic()
|
||||
if attempt == max_retries or remaining <= 0:
|
||||
raise TimeoutError(
|
||||
f"{label} failed after {attempt + 1} attempt(s): {exc}"
|
||||
) from exc
|
||||
time.sleep(min(backoff[attempt], max(0.0, remaining)))
|
||||
raise AssertionError("unreachable: retry loop must return or raise") # pragma: no cover
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DataLoaderProtocol(Protocol):
|
||||
"""Interface that every data source loader must satisfy."""
|
||||
|
||||
name: str
|
||||
markets: set[str]
|
||||
requires_auth: bool
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check whether this data source is usable (token present, network ok, etc.)."""
|
||||
...
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
codes: list[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
interval: str = "1D",
|
||||
fields: list[str] | None = None,
|
||||
) -> dict[str, pd.DataFrame]:
|
||||
"""Fetch OHLCV data.
|
||||
|
||||
Returns:
|
||||
Mapping ``{symbol: DataFrame(trade_date, open, high, low, close, volume)}``.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,184 @@
|
||||
"""CCXT loader: unified crypto exchange data (100+ exchanges).
|
||||
|
||||
Uses the CCXT library to fetch OHLCV candles from any supported exchange.
|
||||
Defaults to Binance; configurable via CCXT_EXCHANGE env var.
|
||||
No API key required for public market data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders.base import (
|
||||
check_budget,
|
||||
retry_with_budget,
|
||||
validate_date_range,
|
||||
)
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_INTERVAL_MAP = {
|
||||
"1m": "1m", "5m": "5m", "15m": "15m", "30m": "30m",
|
||||
"1H": "1h", "4H": "4h", "1D": "1d",
|
||||
}
|
||||
|
||||
# P12-b: ccxt had no request timeout and an unbounded paginated fetch with
|
||||
# no retry budget, so a transient disconnect hung get_market_data for 10+
|
||||
# minutes. Cap each HTTP call, bound transient retries, and enforce a hard
|
||||
# wall-clock budget so the fetch fails fast instead of hanging. Retry
|
||||
# scheduling is delegated to :mod:`backtest.loaders.base`.
|
||||
_CCXT_TIMEOUT_MS = int(os.getenv("CCXT_TIMEOUT_MS", "15000"))
|
||||
_CCXT_FETCH_BUDGET_S = float(os.getenv("CCXT_FETCH_BUDGET_S", "60"))
|
||||
|
||||
|
||||
def _first_proxy_env(*names: str) -> str:
|
||||
for name in names:
|
||||
value = os.getenv(name, "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _ccxt_proxy_config() -> dict[str, str]:
|
||||
"""Build CCXT proxy settings from conventional proxy environment variables."""
|
||||
all_proxy = _first_proxy_env("ALL_PROXY", "all_proxy")
|
||||
http_proxy = _first_proxy_env("HTTP_PROXY", "http_proxy") or all_proxy
|
||||
https_proxy = _first_proxy_env("HTTPS_PROXY", "https_proxy") or all_proxy or http_proxy
|
||||
|
||||
proxies: dict[str, str] = {}
|
||||
if http_proxy:
|
||||
proxies["http"] = http_proxy
|
||||
if https_proxy:
|
||||
proxies["https"] = https_proxy
|
||||
return proxies
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""CCXT-backed crypto OHLCV loader (100+ exchanges)."""
|
||||
|
||||
name = "ccxt"
|
||||
markets = {"crypto"}
|
||||
requires_auth = False
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Available if ccxt is installed."""
|
||||
try:
|
||||
import ccxt # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
def _get_exchange(self):
|
||||
"""Create exchange instance."""
|
||||
import ccxt
|
||||
exchange_id = os.getenv("CCXT_EXCHANGE", "binance").lower()
|
||||
exchange_cls = getattr(ccxt, exchange_id, None)
|
||||
if exchange_cls is None:
|
||||
logger.warning("Unknown CCXT exchange %s, falling back to binance", exchange_id)
|
||||
exchange_cls = ccxt.binance
|
||||
|
||||
config = {"enableRateLimit": True, "timeout": _CCXT_TIMEOUT_MS}
|
||||
proxies = _ccxt_proxy_config()
|
||||
if proxies:
|
||||
config["proxies"] = proxies
|
||||
return exchange_cls(config)
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
codes: List[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
interval: str = "1D",
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
"""Fetch crypto OHLCV via CCXT.
|
||||
|
||||
Args:
|
||||
codes: Symbols like ``["BTC-USDT", "ETH-USDT"]``.
|
||||
start_date: Start date (YYYY-MM-DD).
|
||||
end_date: End date (YYYY-MM-DD).
|
||||
interval: Bar size.
|
||||
fields: Ignored.
|
||||
|
||||
Returns:
|
||||
Mapping symbol -> OHLCV DataFrame.
|
||||
"""
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
exchange = self._get_exchange()
|
||||
timeframe = _INTERVAL_MAP.get(interval, "1d")
|
||||
since_ms = int(pd.Timestamp(start_date).timestamp() * 1000)
|
||||
end_ms = int((pd.Timestamp(end_date) + pd.Timedelta(days=1)).timestamp() * 1000)
|
||||
|
||||
result: Dict[str, pd.DataFrame] = {}
|
||||
for code in codes:
|
||||
try:
|
||||
ccxt_symbol = code.replace("-", "/").upper()
|
||||
df = self._fetch_one(exchange, ccxt_symbol, timeframe, since_ms, end_ms)
|
||||
if df is not None and not df.empty:
|
||||
result[code] = df
|
||||
except Exception as exc:
|
||||
logger.warning("CCXT failed for %s: %s", code, exc)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _fetch_one(
|
||||
exchange, symbol: str, timeframe: str, since_ms: int, end_ms: int,
|
||||
) -> Optional[pd.DataFrame]:
|
||||
"""Paginated OHLCV fetch for one symbol."""
|
||||
import ccxt
|
||||
|
||||
all_rows: list = []
|
||||
cursor = since_ms
|
||||
limit = 1000
|
||||
deadline = time.monotonic() + _CCXT_FETCH_BUDGET_S
|
||||
label = f"ccxt fetch for {symbol}"
|
||||
|
||||
for _ in range(200):
|
||||
check_budget(deadline, label, budget_s=_CCXT_FETCH_BUDGET_S)
|
||||
# ``ccxt.NetworkError`` covers RequestTimeout / DDoSProtection /
|
||||
# ExchangeNotAvailable — the transient family. Anything else
|
||||
# (e.g. ``ExchangeError`` for a bad symbol) is not retried.
|
||||
ohlcv = retry_with_budget(
|
||||
lambda: exchange.fetch_ohlcv(symbol, timeframe, since=cursor, limit=limit),
|
||||
transient=ccxt.NetworkError,
|
||||
deadline=deadline,
|
||||
label=label,
|
||||
)
|
||||
if not ohlcv:
|
||||
break
|
||||
all_rows.extend(ohlcv)
|
||||
last_ts = ohlcv[-1][0]
|
||||
if last_ts >= end_ms or len(ohlcv) < limit:
|
||||
break
|
||||
cursor = last_ts + 1
|
||||
|
||||
if not all_rows:
|
||||
return None
|
||||
|
||||
df = pd.DataFrame(all_rows, columns=["timestamp", "open", "high", "low", "close", "volume"])
|
||||
df["trade_date"] = pd.to_datetime(df["timestamp"], unit="ms")
|
||||
df = df.set_index("trade_date").sort_index()
|
||||
|
||||
start_dt = pd.Timestamp(since_ms, unit="ms")
|
||||
end_dt = pd.Timestamp(end_ms, unit="ms")
|
||||
df = df[(df.index >= start_dt) & (df.index < end_dt)]
|
||||
|
||||
for col in ["open", "high", "low", "close", "volume"]:
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
|
||||
df = df[["open", "high", "low", "close", "volume"]].dropna(
|
||||
subset=["open", "high", "low", "close"]
|
||||
)
|
||||
return df if not df.empty else None
|
||||
@@ -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
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Mootdx loader: A-share OHLCV via TCP-direct 通达信 servers (no IP ban).
|
||||
|
||||
Mootdx (https://github.com/mootdx/mootdx) talks the native 通达信 binary
|
||||
protocol over TCP and is not subject to the HTTP scraping rate limits that
|
||||
periodically break the akshare → East Money path. Public market data only,
|
||||
no token required, no per-IP throttling.
|
||||
|
||||
Scope: A-share OHLCV only (沪/深/京 auto-detected from symbol). Mootdx's
|
||||
extended-market endpoint (futures/options) is upstream-broken as of
|
||||
v0.11.7 — falls through to tushare/akshare for those markets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders.base import validate_date_range
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mootdx frequency codes (see mootdx.consts).
|
||||
_INTRADAY_FREQ: dict[str, int] = {
|
||||
"1m": 8,
|
||||
"5m": 0,
|
||||
"15m": 1,
|
||||
"30m": 2,
|
||||
"1H": 3,
|
||||
}
|
||||
_DAILY_FREQ: dict[str, int] = {
|
||||
"1D": 4,
|
||||
"1W": 5,
|
||||
"1M": 6,
|
||||
}
|
||||
|
||||
# bars() returns one page of N rows ending at the latest bar. Pages older
|
||||
# than this need to be requested with ``start=offset_into_history``. We cap
|
||||
# pagination at MAX_PAGES so a wildly out-of-range request can't grind for
|
||||
# minutes against the TDX server.
|
||||
_BARS_PAGE = 800
|
||||
_MAX_PAGES = 25 # 25 × 800 = 20 000 bars (~10y daily, ~5y 1H, ~3mo 1m)
|
||||
|
||||
|
||||
def _is_a_share(code: str) -> bool:
|
||||
"""Accept either explicit `.SH/.SZ/.BJ` suffix or bare 6-digit ticker."""
|
||||
upper = code.upper()
|
||||
if upper.endswith((".SH", ".SZ", ".BJ")):
|
||||
return True
|
||||
return len(code) == 6 and code.isdigit()
|
||||
|
||||
|
||||
def _is_bj(code: str) -> bool:
|
||||
"""Detect 北交所 symbols. Mootdx std factory does not serve BJ data
|
||||
(get_k_data raises KeyError, bars() returns empty), so the loader logs
|
||||
a warning and skips these instead of silently returning nothing."""
|
||||
upper = code.upper()
|
||||
if upper.endswith(".BJ"):
|
||||
return True
|
||||
# 4xxxxx / 8xxxxx are BJ prefixes (bare 6-digit form).
|
||||
return len(code) == 6 and code.isdigit() and code[0] in ("4", "8")
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""Mootdx-backed A-share OHLCV loader (TCP-direct, no auth)."""
|
||||
|
||||
name = "mootdx"
|
||||
markets = {"a_share"}
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._client = None
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Available if mootdx is installed."""
|
||||
try:
|
||||
import mootdx # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
def _get_client(self):
|
||||
if self._client is None:
|
||||
from mootdx.quotes import Quotes
|
||||
self._client = Quotes.factory(market="std")
|
||||
return self._client
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
codes: List[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
interval: str = "1D",
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
"""Fetch A-share OHLCV via mootdx.
|
||||
|
||||
Args:
|
||||
codes: Symbol list. `.SH/.SZ/.BJ` suffix or bare 6-digit
|
||||
tickers; non-A-share symbols are silently skipped.
|
||||
start_date: YYYY-MM-DD.
|
||||
end_date: YYYY-MM-DD.
|
||||
interval: One of ``1m / 5m / 15m / 30m / 1H / 1D / 1W / 1M``.
|
||||
fields: Ignored.
|
||||
|
||||
Returns:
|
||||
Mapping symbol -> OHLCV DataFrame.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``interval`` is not in the supported set.
|
||||
"""
|
||||
validate_date_range(start_date, end_date)
|
||||
if interval not in _DAILY_FREQ and interval not in _INTRADAY_FREQ:
|
||||
raise ValueError(
|
||||
f"Unsupported interval for mootdx: {interval!r}. "
|
||||
f"Supported: {sorted(_DAILY_FREQ) + sorted(_INTRADAY_FREQ)}"
|
||||
)
|
||||
|
||||
result: Dict[str, pd.DataFrame] = {}
|
||||
for code in codes:
|
||||
if not _is_a_share(code):
|
||||
logger.debug("mootdx: skipping non-A-share symbol %s", code)
|
||||
continue
|
||||
if _is_bj(code):
|
||||
logger.warning(
|
||||
"mootdx: 北交所 (%s) not supported upstream; use akshare/tushare",
|
||||
code,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
df = self._fetch_one(code, start_date, end_date, interval)
|
||||
if df is not None and not df.empty:
|
||||
result[code] = df
|
||||
except Exception as exc:
|
||||
logger.warning("mootdx failed for %s: %s", code, exc)
|
||||
return result
|
||||
|
||||
def _fetch_one(
|
||||
self, code: str, start_date: str, end_date: str, interval: str,
|
||||
) -> Optional[pd.DataFrame]:
|
||||
symbol = code.split(".")[0]
|
||||
client = self._get_client()
|
||||
|
||||
# Daily has a native date-range API; intraday and weekly/monthly
|
||||
# only expose offset-from-latest, so we page back through history
|
||||
# until the first row of the page is older than start_date.
|
||||
if interval == "1D":
|
||||
df = client.get_k_data(code=symbol, start_date=start_date, end_date=end_date)
|
||||
return self._normalize_daily(df)
|
||||
|
||||
freq = _DAILY_FREQ.get(interval) or _INTRADAY_FREQ[interval]
|
||||
return self._fetch_bars_paginated(client, symbol, freq, start_date, end_date)
|
||||
|
||||
@staticmethod
|
||||
def _fetch_bars_paginated(
|
||||
client, symbol: str, freq: int, start_date: str, end_date: str,
|
||||
) -> Optional[pd.DataFrame]:
|
||||
"""Walk backward through ``bars()`` pages until the requested
|
||||
window is covered, then clip and concatenate.
|
||||
|
||||
Mootdx ``bars()`` returns the latest ``_BARS_PAGE`` rows by default;
|
||||
``start=N`` skips the newest N rows. We page until the oldest row
|
||||
in a page is at or before ``start_date``, or until ``_MAX_PAGES``
|
||||
is exhausted (very old or thinly-traded symbols).
|
||||
"""
|
||||
start_ts = pd.Timestamp(start_date)
|
||||
chunks: list[pd.DataFrame] = []
|
||||
for page in range(_MAX_PAGES):
|
||||
df = client.bars(
|
||||
symbol=symbol,
|
||||
frequency=freq,
|
||||
start=page * _BARS_PAGE,
|
||||
offset=_BARS_PAGE,
|
||||
)
|
||||
if df is None or df.empty:
|
||||
break
|
||||
chunks.append(df)
|
||||
first_dt = pd.to_datetime(df["datetime"].iloc[0])
|
||||
if first_dt <= start_ts:
|
||||
break
|
||||
else:
|
||||
logger.warning(
|
||||
"mootdx: %s %s pagination hit cap (%d pages) without reaching %s",
|
||||
symbol, freq, _MAX_PAGES, start_date,
|
||||
)
|
||||
if not chunks:
|
||||
return None
|
||||
combined = pd.concat(chunks, ignore_index=False)
|
||||
return DataLoader._normalize_bars(combined, start_date, end_date)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_daily(df: Optional[pd.DataFrame]) -> Optional[pd.DataFrame]:
|
||||
"""Normalize `get_k_data()` output to the OHLCV contract.
|
||||
|
||||
get_k_data returns columns ``[open, close, high, low, vol, amount,
|
||||
date, code]`` with ``date`` as the index name.
|
||||
"""
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
out = df.rename(columns={"vol": "volume"}).copy()
|
||||
out.index = pd.to_datetime(out.index)
|
||||
out.index.name = "trade_date"
|
||||
for col in ("open", "high", "low", "close", "volume"):
|
||||
out[col] = pd.to_numeric(out[col], errors="coerce")
|
||||
out = out[["open", "high", "low", "close", "volume"]].dropna(
|
||||
subset=["open", "high", "low", "close"]
|
||||
)
|
||||
return out.sort_index() if not out.empty else None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_bars(
|
||||
df: Optional[pd.DataFrame], start_date: str, end_date: str,
|
||||
) -> Optional[pd.DataFrame]:
|
||||
"""Normalize `bars()` output and clip to the requested window.
|
||||
|
||||
bars() returns ``[open, close, high, low, vol, amount, year, month,
|
||||
day, hour, minute, datetime, volume]`` with a datetime index.
|
||||
``volume`` (lowercase, last column) is the canonical share count;
|
||||
``vol`` is a historical alias kept by mootdx for compatibility.
|
||||
"""
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
out = df.copy()
|
||||
if "datetime" in out.columns:
|
||||
out["trade_date"] = pd.to_datetime(out["datetime"])
|
||||
out = out.set_index("trade_date")
|
||||
else:
|
||||
out.index = pd.to_datetime(out.index)
|
||||
out.index.name = "trade_date"
|
||||
out = out.sort_index()
|
||||
for col in ("open", "high", "low", "close", "volume"):
|
||||
out[col] = pd.to_numeric(out[col], errors="coerce")
|
||||
out = out[["open", "high", "low", "close", "volume"]].dropna(
|
||||
subset=["open", "high", "low", "close"]
|
||||
)
|
||||
# Inclusive end-of-day so a `2025-02-01` window keeps the 15:00 bar.
|
||||
end_ts = pd.Timestamp(end_date) + pd.Timedelta(days=1) - pd.Timedelta(seconds=1)
|
||||
out = out.loc[pd.Timestamp(start_date):end_ts]
|
||||
return out if not out.empty else None
|
||||
@@ -5,18 +5,42 @@ Supports 1m/5m/15m/30m/1H/4H/1D.
|
||||
Up to 300 bars per request; paginates with ``after`` for longer history.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
from backtest.loaders.base import (
|
||||
check_budget,
|
||||
retry_with_budget,
|
||||
validate_date_range,
|
||||
)
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
BASE_URL = "https://www.okx.com/api/v5"
|
||||
_MAX_PER_PAGE = 300
|
||||
# P12-b parity: OKX already sets a per-request timeout but had no retry
|
||||
# budget, so a transient blip dropped the whole symbol and a slow tier
|
||||
# could stall ~max_pages*timeout. Bound it like the ccxt loader; retry
|
||||
# scheduling is delegated to :mod:`backtest.loaders.base`.
|
||||
_OKX_TIMEOUT = int(os.getenv("OKX_TIMEOUT_S", "15"))
|
||||
_OKX_FETCH_BUDGET_S = float(os.getenv("OKX_FETCH_BUDGET_S", "60"))
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""OKX crypto OHLCV loader."""
|
||||
|
||||
name = "okx"
|
||||
markets = {"crypto"}
|
||||
requires_auth = False
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Always available (public API, no auth)."""
|
||||
return True
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""No credentials required for public candles."""
|
||||
pass
|
||||
@@ -41,6 +65,8 @@ class DataLoader:
|
||||
Returns:
|
||||
Mapping symbol -> DataFrame.
|
||||
"""
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
if fields:
|
||||
print(f"[WARN] OKX ignores extra fields: {fields}")
|
||||
|
||||
@@ -84,16 +110,32 @@ class DataLoader:
|
||||
"""
|
||||
all_rows: list = []
|
||||
after = str(end_ts)
|
||||
deadline = time.monotonic() + _OKX_FETCH_BUDGET_S
|
||||
label = f"OKX fetch for {inst_id}"
|
||||
|
||||
for _ in range(max_pages):
|
||||
check_budget(deadline, label, budget_s=_OKX_FETCH_BUDGET_S)
|
||||
params = {
|
||||
"instId": inst_id,
|
||||
"bar": bar,
|
||||
"limit": str(_MAX_PER_PAGE),
|
||||
"after": after,
|
||||
}
|
||||
resp = requests.get(f"{BASE_URL}/market/candles", params=params, timeout=15)
|
||||
data = resp.json()
|
||||
|
||||
def _do_request() -> dict:
|
||||
resp = requests.get(
|
||||
f"{BASE_URL}/market/candles",
|
||||
params=params,
|
||||
timeout=_OKX_TIMEOUT,
|
||||
)
|
||||
return resp.json()
|
||||
|
||||
data = retry_with_budget(
|
||||
_do_request,
|
||||
transient=requests.RequestException,
|
||||
deadline=deadline,
|
||||
label=label,
|
||||
)
|
||||
if data.get("code") != "0" or not data.get("data"):
|
||||
break
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Loader registry with market-level fallback chains.
|
||||
|
||||
Loaders self-register via the ``@register`` decorator when their module is
|
||||
first imported. The ``_ensure_registered()`` helper lazily imports every
|
||||
known loader module so that callers of ``resolve_loader`` /
|
||||
``get_loader_cls_with_fallback`` never see an empty registry — regardless
|
||||
of import order.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Type
|
||||
|
||||
from backtest.loaders.base import NoAvailableSourceError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global registry: source_name -> loader class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LOADER_REGISTRY: dict[str, Type[Any]] = {}
|
||||
|
||||
_registered = False
|
||||
|
||||
|
||||
def register(cls: Type[Any]) -> Type[Any]:
|
||||
"""Class decorator: register a loader into the global registry.
|
||||
|
||||
The class must have a ``name`` class attribute.
|
||||
"""
|
||||
LOADER_REGISTRY[cls.name] = cls
|
||||
return cls
|
||||
|
||||
|
||||
def _ensure_registered() -> None:
|
||||
"""Import every known loader module so ``@register`` decorators fire.
|
||||
|
||||
Safe to call multiple times — only runs the imports once.
|
||||
Loaders whose dependencies are missing (e.g. ``akshare`` not installed)
|
||||
are silently skipped.
|
||||
"""
|
||||
global _registered
|
||||
if _registered:
|
||||
return
|
||||
_registered = True
|
||||
|
||||
_loader_modules = [
|
||||
"backtest.loaders.tushare",
|
||||
"backtest.loaders.okx",
|
||||
"backtest.loaders.yfinance_loader",
|
||||
"backtest.loaders.akshare_loader",
|
||||
"backtest.loaders.mootdx_loader",
|
||||
"backtest.loaders.ccxt_loader",
|
||||
"backtest.loaders.futu",
|
||||
]
|
||||
import importlib
|
||||
for mod in _loader_modules:
|
||||
try:
|
||||
importlib.import_module(mod)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fallback chains: market_type -> ordered list of source names
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FALLBACK_CHAINS: dict[str, list[str]] = {
|
||||
"a_share": ["tushare", "mootdx", "akshare"],
|
||||
"us_equity": ["yfinance", "akshare"],
|
||||
"hk_equity": ["yfinance", "futu", "akshare"],
|
||||
"crypto": ["okx", "ccxt"],
|
||||
"futures": ["tushare", "akshare"],
|
||||
"fund": ["tushare", "akshare"],
|
||||
"macro": ["akshare", "tushare"],
|
||||
"forex": ["akshare", "yfinance"],
|
||||
}
|
||||
|
||||
|
||||
def resolve_loader(market: str) -> Any:
|
||||
"""Return the first *available* loader instance for *market*.
|
||||
|
||||
Walks the fallback chain and returns the first loader whose
|
||||
``is_available()`` returns ``True``.
|
||||
|
||||
Args:
|
||||
market: Market type key (e.g. ``"a_share"``, ``"crypto"``).
|
||||
|
||||
Returns:
|
||||
A loader instance.
|
||||
|
||||
Raises:
|
||||
NoAvailableSourceError: If every candidate is unavailable.
|
||||
"""
|
||||
_ensure_registered()
|
||||
chain = FALLBACK_CHAINS.get(market, [])
|
||||
tried: list[str] = []
|
||||
for name in chain:
|
||||
if name not in LOADER_REGISTRY:
|
||||
continue
|
||||
tried.append(name)
|
||||
# Issue #50 — some loaders (e.g. Tushare) call into the SDK during
|
||||
# __init__ and raise on missing credentials. Treat that the same as
|
||||
# is_available()=False so the fallback chain keeps walking.
|
||||
try:
|
||||
loader = LOADER_REGISTRY[name]()
|
||||
except Exception as exc:
|
||||
logger.debug("loader %s failed to construct: %s", name, exc)
|
||||
continue
|
||||
if loader.is_available():
|
||||
return loader
|
||||
raise NoAvailableSourceError(
|
||||
f"No available data source for market '{market}'. "
|
||||
f"Tried: {tried or chain}. Check network and API token config."
|
||||
)
|
||||
|
||||
|
||||
def get_loader_cls_with_fallback(source: str) -> Type[Any]:
|
||||
"""Return a loader *class* for *source*, falling back if unavailable.
|
||||
|
||||
Args:
|
||||
source: Requested data source name.
|
||||
|
||||
Returns:
|
||||
A DataLoader class (not instance).
|
||||
|
||||
Raises:
|
||||
NoAvailableSourceError: If the source and all fallbacks are unavailable.
|
||||
"""
|
||||
_ensure_registered()
|
||||
if source not in LOADER_REGISTRY:
|
||||
raise NoAvailableSourceError(f"Unknown data source: {source}")
|
||||
|
||||
loader_cls = LOADER_REGISTRY[source]
|
||||
try:
|
||||
instance = loader_cls()
|
||||
except Exception as exc:
|
||||
logger.debug("loader %s failed to construct: %s", source, exc)
|
||||
instance = None
|
||||
if instance is not None and instance.is_available():
|
||||
return loader_cls
|
||||
|
||||
# Source unavailable — try same-market fallback
|
||||
for market in loader_cls.markets:
|
||||
try:
|
||||
fallback = resolve_loader(market)
|
||||
logger.warning(
|
||||
"%s is unavailable, falling back to %s for market %s",
|
||||
source, fallback.name, market,
|
||||
)
|
||||
return type(fallback)
|
||||
except NoAvailableSourceError:
|
||||
continue
|
||||
|
||||
raise NoAvailableSourceError(
|
||||
f"Data source '{source}' is unavailable and no fallback found."
|
||||
)
|
||||
@@ -9,10 +9,25 @@ from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders.base import validate_date_range
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
|
||||
TUSHARE_TOKEN_PLACEHOLDERS = {"", "your-tushare-token"}
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""Tushare-backed OHLCV loader."""
|
||||
|
||||
name = "tushare"
|
||||
markets = {"a_share", "futures", "fund"}
|
||||
requires_auth = True
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Available when TUSHARE_TOKEN is set."""
|
||||
return os.getenv("TUSHARE_TOKEN", "").strip() not in TUSHARE_TOKEN_PLACEHOLDERS
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize Tushare pro API."""
|
||||
import tushare as ts
|
||||
@@ -40,6 +55,8 @@ class DataLoader:
|
||||
Returns:
|
||||
Mapping code -> OHLCV DataFrame.
|
||||
"""
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
if interval != "1D":
|
||||
return self._fetch_minutes(codes, start_date, end_date, interval)
|
||||
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Tushare fundamental data provider with point-in-time safeguards."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable
|
||||
|
||||
import pandas as pd
|
||||
|
||||
TUSHARE_TOKEN_PLACEHOLDERS = {"", "your-tushare-token"}
|
||||
|
||||
|
||||
class DataProviderError(Exception):
|
||||
"""Base error for fundamental provider failures."""
|
||||
|
||||
|
||||
class UnknownTableError(DataProviderError):
|
||||
"""Raised when a requested fundamental table is not supported."""
|
||||
|
||||
|
||||
class SchemaValidationError(DataProviderError):
|
||||
"""Raised when provider output is missing required columns."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ColumnSchema:
|
||||
"""Machine-readable column metadata for a provider table."""
|
||||
|
||||
name: str
|
||||
dtype: str
|
||||
required: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TableSchema:
|
||||
"""Machine-readable metadata for a Tushare fundamental table."""
|
||||
|
||||
name: str
|
||||
api_name: str
|
||||
point_in_time_column: str
|
||||
columns: tuple[ColumnSchema, ...]
|
||||
|
||||
@property
|
||||
def required_columns(self) -> tuple[str, ...]:
|
||||
return tuple(column.name for column in self.columns if column.required)
|
||||
|
||||
|
||||
_SCHEMAS: dict[str, TableSchema] = {
|
||||
"balancesheet": TableSchema(
|
||||
name="balancesheet",
|
||||
api_name="balancesheet",
|
||||
point_in_time_column="f_ann_date",
|
||||
columns=(
|
||||
ColumnSchema("ts_code", "str", required=True),
|
||||
ColumnSchema("ann_date", "date", required=True),
|
||||
ColumnSchema("f_ann_date", "date", required=False),
|
||||
ColumnSchema("end_date", "date", required=True),
|
||||
ColumnSchema("total_assets", "float"),
|
||||
ColumnSchema("total_liab", "float"),
|
||||
ColumnSchema("total_hldr_eqy_exc_min_int", "float"),
|
||||
),
|
||||
),
|
||||
"cashflow": TableSchema(
|
||||
name="cashflow",
|
||||
api_name="cashflow",
|
||||
point_in_time_column="f_ann_date",
|
||||
columns=(
|
||||
ColumnSchema("ts_code", "str", required=True),
|
||||
ColumnSchema("ann_date", "date", required=True),
|
||||
ColumnSchema("f_ann_date", "date", required=False),
|
||||
ColumnSchema("end_date", "date", required=True),
|
||||
ColumnSchema("net_profit", "float"),
|
||||
ColumnSchema("n_cashflow_act", "float"),
|
||||
ColumnSchema("c_cash_equ_end_period", "float"),
|
||||
),
|
||||
),
|
||||
"fina_indicator": TableSchema(
|
||||
name="fina_indicator",
|
||||
api_name="fina_indicator",
|
||||
point_in_time_column="ann_date",
|
||||
columns=(
|
||||
ColumnSchema("ts_code", "str", required=True),
|
||||
ColumnSchema("ann_date", "date", required=True),
|
||||
ColumnSchema("end_date", "date", required=True),
|
||||
ColumnSchema("eps", "float"),
|
||||
ColumnSchema("grossprofit_margin", "float"),
|
||||
ColumnSchema("netprofit_margin", "float"),
|
||||
ColumnSchema("roe", "float"),
|
||||
ColumnSchema("debt_to_assets", "float"),
|
||||
),
|
||||
),
|
||||
"income": TableSchema(
|
||||
name="income",
|
||||
api_name="income",
|
||||
point_in_time_column="f_ann_date",
|
||||
columns=(
|
||||
ColumnSchema("ts_code", "str", required=True),
|
||||
ColumnSchema("ann_date", "date", required=True),
|
||||
ColumnSchema("f_ann_date", "date", required=False),
|
||||
ColumnSchema("end_date", "date", required=True),
|
||||
ColumnSchema("total_revenue", "float"),
|
||||
ColumnSchema("revenue", "float"),
|
||||
ColumnSchema("operate_profit", "float"),
|
||||
ColumnSchema("n_income", "float"),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class TushareFundamentalProvider:
|
||||
"""Small DataProvider contract for Tushare financial statement tables."""
|
||||
|
||||
def __init__(self, api: Any | None = None) -> None:
|
||||
if api is None:
|
||||
import tushare as ts
|
||||
|
||||
token = os.getenv("TUSHARE_TOKEN", "").strip()
|
||||
if token in TUSHARE_TOKEN_PLACEHOLDERS:
|
||||
token = ""
|
||||
api = ts.pro_api(token)
|
||||
self.api = api
|
||||
|
||||
def list_tables(self) -> list[str]:
|
||||
"""Return supported fundamental tables in stable order."""
|
||||
return sorted(_SCHEMAS)
|
||||
|
||||
def describe_table(self, table: str) -> TableSchema:
|
||||
"""Return schema metadata for a supported table."""
|
||||
try:
|
||||
return _SCHEMAS[table]
|
||||
except KeyError as exc:
|
||||
raise UnknownTableError(f"Unsupported Tushare fundamental table: {table}") from exc
|
||||
|
||||
def query_fundamentals(
|
||||
self,
|
||||
table: str,
|
||||
codes: Iterable[str],
|
||||
*,
|
||||
as_of: str | pd.Timestamp,
|
||||
periods: Iterable[str] | None = None,
|
||||
fields: Iterable[str] | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Query a fundamental table and filter out rows unpublished by ``as_of``."""
|
||||
schema = self.describe_table(table)
|
||||
requested_periods = set(periods or [])
|
||||
frames: list[pd.DataFrame] = []
|
||||
|
||||
api_method = getattr(self.api, schema.api_name, None)
|
||||
if api_method is None:
|
||||
raise DataProviderError(f"Tushare API object has no method: {schema.api_name}")
|
||||
|
||||
for code in codes:
|
||||
frame = api_method(ts_code=code, period=None)
|
||||
if frame is not None and not frame.empty:
|
||||
frames.append(frame.copy())
|
||||
|
||||
if not frames:
|
||||
return self._empty_frame(schema, fields)
|
||||
|
||||
result = pd.concat(frames, ignore_index=True)
|
||||
self._validate_schema(schema, result)
|
||||
|
||||
if requested_periods:
|
||||
result = result[result["end_date"].astype(str).isin(requested_periods)]
|
||||
|
||||
pit_column = schema.point_in_time_column
|
||||
if pit_column not in result.columns or result[pit_column].isna().all():
|
||||
pit_column = "ann_date"
|
||||
as_of_date = _parse_tushare_date(as_of)
|
||||
pit_values = result[pit_column]
|
||||
if pit_column != "ann_date" and "ann_date" in result.columns:
|
||||
pit_values = pit_values.where(pit_values.notna(), result["ann_date"])
|
||||
pit_dates = pit_values.map(_parse_tushare_date)
|
||||
result = result[pit_dates <= as_of_date]
|
||||
|
||||
output_columns = self._output_columns(schema, result, fields)
|
||||
result = result.loc[:, output_columns].sort_values(["ts_code", "end_date"]).reset_index(drop=True)
|
||||
return result
|
||||
|
||||
def _validate_schema(self, schema: TableSchema, frame: pd.DataFrame) -> None:
|
||||
missing = [column for column in schema.required_columns if column not in frame.columns]
|
||||
if missing:
|
||||
raise SchemaValidationError(f"{schema.name} missing required columns: {', '.join(missing)}")
|
||||
|
||||
def _output_columns(
|
||||
self,
|
||||
schema: TableSchema,
|
||||
frame: pd.DataFrame,
|
||||
fields: Iterable[str] | None,
|
||||
) -> list[str]:
|
||||
identity = ["ts_code", "end_date", "ann_date"]
|
||||
if schema.point_in_time_column in frame.columns and schema.point_in_time_column not in identity:
|
||||
identity.append(schema.point_in_time_column)
|
||||
wanted = identity + list(fields or [])
|
||||
return [column for column in dict.fromkeys(wanted) if column in frame.columns]
|
||||
|
||||
def _empty_frame(self, schema: TableSchema, fields: Iterable[str] | None) -> pd.DataFrame:
|
||||
columns = self._output_columns(schema, pd.DataFrame(columns=[c.name for c in schema.columns]), fields)
|
||||
return pd.DataFrame(columns=columns)
|
||||
|
||||
|
||||
def _parse_tushare_date(value: str | pd.Timestamp) -> pd.Timestamp:
|
||||
"""Parse Tushare YYYYMMDD strings and common timestamp/date strings."""
|
||||
if isinstance(value, pd.Timestamp):
|
||||
return value.normalize()
|
||||
text = str(value)
|
||||
if len(text) == 8 and text.isdigit():
|
||||
return pd.to_datetime(text, format="%Y%m%d")
|
||||
return pd.to_datetime(text).normalize()
|
||||
|
||||
|
||||
def enrich_price_frames_with_fundamentals(
|
||||
data_map: dict[str, pd.DataFrame],
|
||||
provider: TushareFundamentalProvider,
|
||||
fields_by_table: dict[str, Iterable[str]],
|
||||
*,
|
||||
as_of: str | pd.Timestamp,
|
||||
periods: Iterable[str] | None = None,
|
||||
) -> dict[str, pd.DataFrame]:
|
||||
"""Attach PIT-safe fundamental snapshots to daily price frames.
|
||||
|
||||
Fundamental columns are prefixed with their table name, for example
|
||||
``income_total_revenue`` and ``fina_indicator_roe``. Each row becomes
|
||||
visible only on or after its announcement/disclosure date.
|
||||
"""
|
||||
if not data_map or not fields_by_table:
|
||||
return data_map
|
||||
|
||||
enriched = {code: frame.copy() for code, frame in data_map.items()}
|
||||
codes = list(enriched)
|
||||
|
||||
for table, fields in fields_by_table.items():
|
||||
field_list = list(fields or [])
|
||||
fundamentals = provider.query_fundamentals(
|
||||
table,
|
||||
codes,
|
||||
as_of=as_of,
|
||||
periods=periods,
|
||||
fields=field_list,
|
||||
)
|
||||
if fundamentals.empty:
|
||||
continue
|
||||
|
||||
schema = provider.describe_table(table)
|
||||
pit_column = schema.point_in_time_column
|
||||
if pit_column not in fundamentals.columns or fundamentals[pit_column].isna().all():
|
||||
pit_column = "ann_date"
|
||||
|
||||
for code, frame in enriched.items():
|
||||
rows = fundamentals[fundamentals["ts_code"] == code].copy()
|
||||
if rows.empty or frame.empty:
|
||||
continue
|
||||
|
||||
pit_values = rows[pit_column]
|
||||
if pit_column != "ann_date" and "ann_date" in rows.columns:
|
||||
pit_values = pit_values.where(pit_values.notna(), rows["ann_date"])
|
||||
rows["_pit_date"] = pit_values.map(_parse_tushare_date)
|
||||
rows = rows.dropna(subset=["_pit_date"]).sort_values("_pit_date")
|
||||
if rows.empty:
|
||||
continue
|
||||
|
||||
value_columns = [column for column in rows.columns if column not in {"ts_code", "_pit_date"}]
|
||||
right = rows[["_pit_date", *value_columns]].rename(
|
||||
columns={column: f"{table}_{column}" for column in value_columns}
|
||||
)
|
||||
|
||||
left = frame.copy()
|
||||
original_index = left.index
|
||||
left["_trade_date"] = pd.to_datetime(left.index).normalize()
|
||||
left["_original_order"] = range(len(left))
|
||||
|
||||
merged = pd.merge_asof(
|
||||
left.sort_values("_trade_date"),
|
||||
right.sort_values("_pit_date"),
|
||||
left_on="_trade_date",
|
||||
right_on="_pit_date",
|
||||
direction="backward",
|
||||
)
|
||||
merged = merged.sort_values("_original_order").drop(
|
||||
columns=["_trade_date", "_original_order", "_pit_date"]
|
||||
)
|
||||
merged.index = original_index
|
||||
enriched[code] = merged
|
||||
|
||||
return enriched
|
||||
@@ -8,6 +8,9 @@ from typing import Dict, List, Optional, Union
|
||||
import pandas as pd
|
||||
import yfinance as yf
|
||||
|
||||
from backtest.loaders.base import validate_date_range
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
_OHLCV_COLUMNS = ["open", "high", "low", "close", "volume"]
|
||||
_COLUMN_RENAMES = {
|
||||
"Open": "open",
|
||||
@@ -186,9 +189,18 @@ def _normalize_frame(frame: pd.DataFrame, requested_interval: str) -> pd.DataFra
|
||||
return normalized
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""Fetch HK/US equity bars from Yahoo Finance via yfinance."""
|
||||
|
||||
name = "yfinance"
|
||||
markets = {"us_equity", "hk_equity"}
|
||||
requires_auth = False
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Always available (free public data, no auth)."""
|
||||
return True
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the loader.
|
||||
|
||||
@@ -219,6 +231,7 @@ class DataLoader:
|
||||
del fields
|
||||
if not codes:
|
||||
return {}
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
requested_interval = str(interval or "1D").strip()
|
||||
yf_interval = _to_yfinance_interval(requested_interval)
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Shared backtest metrics, extracted from daily_portfolio.py for reuse.
|
||||
|
||||
Provides annualisation helpers, trade statistics, and full metric calculation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from backtest.models import TradeRecord
|
||||
|
||||
# ─── Annualisation factor mapping ───
|
||||
|
||||
_TRADING_DAYS = {"tushare": 252, "yfinance": 252, "okx": 365, "akshare": 252, "ccxt": 365}
|
||||
_BARS_PER_DAY = {
|
||||
"1m": {"tushare": 240, "okx": 1440, "yfinance": 390, "akshare": 240, "ccxt": 1440},
|
||||
"5m": {"tushare": 48, "okx": 288, "yfinance": 78, "akshare": 48, "ccxt": 288},
|
||||
"15m": {"tushare": 16, "okx": 96, "yfinance": 26, "akshare": 16, "ccxt": 96},
|
||||
"30m": {"tushare": 8, "okx": 48, "yfinance": 13, "akshare": 8, "ccxt": 48},
|
||||
"1H": {"tushare": 4, "okx": 24, "yfinance": 7, "akshare": 4, "ccxt": 24},
|
||||
"4H": {"tushare": 1, "okx": 6, "yfinance": 2, "akshare": 1, "ccxt": 6},
|
||||
"1D": {"tushare": 1, "okx": 1, "yfinance": 1, "akshare": 1, "ccxt": 1},
|
||||
}
|
||||
|
||||
|
||||
def calc_bars_per_year(interval: str = "1D", source: str = "tushare") -> int:
|
||||
"""Number of bars per year for annualisation.
|
||||
|
||||
Args:
|
||||
interval: Bar size (1m / 5m / 15m / 30m / 1H / 4H / 1D).
|
||||
source: Data source (tushare / yfinance / okx).
|
||||
|
||||
Returns:
|
||||
Bars per year.
|
||||
"""
|
||||
trading_days = _TRADING_DAYS.get(source, 252)
|
||||
bars_per_day = _BARS_PER_DAY.get(interval, {}).get(source, 1)
|
||||
return trading_days * bars_per_day
|
||||
|
||||
|
||||
def win_rate_and_stats(trades: List[TradeRecord]) -> Dict[str, float]:
|
||||
"""Win rate and P&L statistics from completed trades.
|
||||
|
||||
Args:
|
||||
trades: Completed round-trip trades.
|
||||
|
||||
Returns:
|
||||
Dict with win_rate, profit_loss_ratio, max_consecutive_loss,
|
||||
avg_holding_bars, profit_factor.
|
||||
"""
|
||||
if not trades:
|
||||
return {
|
||||
"win_rate": 0.0,
|
||||
"profit_loss_ratio": 0.0,
|
||||
"max_consecutive_loss": 0,
|
||||
"avg_holding_bars": 0.0,
|
||||
"profit_factor": 0.0,
|
||||
}
|
||||
|
||||
wins = [t.pnl for t in trades if t.pnl > 0]
|
||||
losses = [t.pnl for t in trades if t.pnl < 0]
|
||||
|
||||
win_rate = len(wins) / len(trades)
|
||||
|
||||
avg_win = float(np.mean(wins)) if wins else 0.0
|
||||
avg_loss = abs(float(np.mean(losses))) if losses else 1e-10
|
||||
profit_loss_ratio = avg_win / avg_loss if avg_loss > 1e-10 else 0.0
|
||||
|
||||
gross_profit = sum(wins) if wins else 0.0
|
||||
gross_loss = abs(sum(losses)) if losses else 1e-10
|
||||
profit_factor = gross_profit / gross_loss if gross_loss > 1e-10 else 0.0
|
||||
|
||||
max_consec = 0
|
||||
cur_consec = 0
|
||||
for t in trades:
|
||||
if t.pnl < 0:
|
||||
cur_consec += 1
|
||||
max_consec = max(max_consec, cur_consec)
|
||||
else:
|
||||
cur_consec = 0
|
||||
|
||||
hold_bars = [t.holding_bars for t in trades if t.holding_bars > 0]
|
||||
avg_holding = float(np.mean(hold_bars)) if hold_bars else 0.0
|
||||
|
||||
return {
|
||||
"win_rate": win_rate,
|
||||
"profit_loss_ratio": round(profit_loss_ratio, 4),
|
||||
"max_consecutive_loss": max_consec,
|
||||
"avg_holding_bars": round(avg_holding, 1),
|
||||
"profit_factor": round(profit_factor, 4),
|
||||
}
|
||||
|
||||
|
||||
def by_symbol_stats(trades: List[TradeRecord]) -> Dict[str, Dict[str, Any]]:
|
||||
"""Per-symbol trade statistics.
|
||||
|
||||
Args:
|
||||
trades: Completed round-trip trades.
|
||||
|
||||
Returns:
|
||||
{symbol: {count, win_rate, total_pnl, avg_pnl}}.
|
||||
"""
|
||||
groups: Dict[str, list] = {}
|
||||
for t in trades:
|
||||
groups.setdefault(t.symbol, []).append(t)
|
||||
|
||||
result = {}
|
||||
for sym, sym_trades in groups.items():
|
||||
pnls = [t.pnl for t in sym_trades]
|
||||
wins = [p for p in pnls if p > 0]
|
||||
result[sym] = {
|
||||
"count": len(sym_trades),
|
||||
"win_rate": round(len(wins) / len(sym_trades), 4) if sym_trades else 0.0,
|
||||
"total_pnl": round(sum(pnls), 2),
|
||||
"avg_pnl": round(float(np.mean(pnls)), 2) if pnls else 0.0,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def by_exit_reason_stats(trades: List[TradeRecord]) -> Dict[str, Dict[str, Any]]:
|
||||
"""Per-exit-reason trade statistics.
|
||||
|
||||
Args:
|
||||
trades: Completed round-trip trades.
|
||||
|
||||
Returns:
|
||||
{reason: {count, total_pnl}}.
|
||||
"""
|
||||
groups: Dict[str, list] = {}
|
||||
for t in trades:
|
||||
groups.setdefault(t.exit_reason, []).append(t)
|
||||
|
||||
result = {}
|
||||
for reason, reason_trades in groups.items():
|
||||
pnls = [t.pnl for t in reason_trades]
|
||||
result[reason] = {
|
||||
"count": len(reason_trades),
|
||||
"total_pnl": round(sum(pnls), 2),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def calc_metrics(
|
||||
equity_curve: pd.Series,
|
||||
trades: List[TradeRecord],
|
||||
initial_cash: float,
|
||||
bars_per_year: Optional[int] = 252,
|
||||
bench_ret: Optional[pd.Series] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Full set of performance metrics.
|
||||
|
||||
Args:
|
||||
equity_curve: Equity time series (index=timestamp, values=equity).
|
||||
trades: Completed round-trip trades.
|
||||
initial_cash: Starting capital.
|
||||
bars_per_year: Bars per year for annualisation. None = auto-detect
|
||||
from equity curve dates (calendar-day method, for cross-market).
|
||||
bench_ret: Benchmark per-bar return series (optional).
|
||||
|
||||
Returns:
|
||||
Metrics dictionary (compatible with daily_portfolio format).
|
||||
"""
|
||||
if len(equity_curve) == 0:
|
||||
return _empty_metrics(initial_cash)
|
||||
|
||||
n = len(equity_curve)
|
||||
|
||||
# Calendar-day annualization for cross-market (bars_per_year=None)
|
||||
if bars_per_year is None:
|
||||
first, last = equity_curve.index[0], equity_curve.index[-1]
|
||||
calendar_days = (last - first).days
|
||||
years = calendar_days / 365.25 if calendar_days > 0 else 1.0
|
||||
bpy = int(n / years) if years > 0 else 252
|
||||
else:
|
||||
bpy = bars_per_year
|
||||
|
||||
port_ret = equity_curve.pct_change().fillna(0.0)
|
||||
|
||||
total_ret = float(equity_curve.iloc[-1] / initial_cash - 1)
|
||||
ann_ret = float((1 + total_ret) ** (bpy / max(n, 1)) - 1)
|
||||
vol = float(port_ret.std())
|
||||
sharpe = float(port_ret.mean() / (vol + 1e-10) * np.sqrt(bpy))
|
||||
|
||||
# Drawdown
|
||||
peak = equity_curve.cummax()
|
||||
dd = (equity_curve - peak) / peak.replace(0, 1)
|
||||
max_dd = float(dd.min())
|
||||
|
||||
calmar = ann_ret / abs(max_dd) if abs(max_dd) > 1e-10 else 0.0
|
||||
|
||||
# Sortino
|
||||
downside = port_ret[port_ret < 0]
|
||||
downside_std = float(downside.std()) if len(downside) > 1 else 1e-10
|
||||
sortino = float(port_ret.mean() / (downside_std + 1e-10) * np.sqrt(bpy))
|
||||
|
||||
trade_stats = win_rate_and_stats(trades)
|
||||
|
||||
# Benchmark comparison
|
||||
bench_return = 0.0
|
||||
excess = 0.0
|
||||
ir = 0.0
|
||||
if bench_ret is not None and len(bench_ret) > 0:
|
||||
bench_return = float((1 + bench_ret).prod() - 1)
|
||||
excess = total_ret - bench_return
|
||||
active_ret = port_ret - bench_ret.reindex(port_ret.index).fillna(0.0)
|
||||
active_std = float(active_ret.std())
|
||||
ir = float(active_ret.mean() / (active_std + 1e-10) * np.sqrt(bpy))
|
||||
|
||||
return {
|
||||
"final_value": float(equity_curve.iloc[-1]),
|
||||
"total_return": total_ret,
|
||||
"annual_return": ann_ret,
|
||||
"max_drawdown": max_dd,
|
||||
"sharpe": sharpe,
|
||||
"calmar": round(calmar, 4),
|
||||
"sortino": round(sortino, 4),
|
||||
"win_rate": trade_stats["win_rate"],
|
||||
"profit_loss_ratio": trade_stats["profit_loss_ratio"],
|
||||
"profit_factor": trade_stats["profit_factor"],
|
||||
"max_consecutive_loss": trade_stats["max_consecutive_loss"],
|
||||
"avg_holding_days": trade_stats["avg_holding_bars"],
|
||||
"trade_count": len(trades),
|
||||
"benchmark_return": round(bench_return, 6),
|
||||
"excess_return": round(excess, 6),
|
||||
"information_ratio": round(ir, 4),
|
||||
}
|
||||
|
||||
|
||||
def _empty_metrics(initial_cash: float) -> Dict[str, Any]:
|
||||
"""Return zero-valued metrics when no data is available."""
|
||||
return {
|
||||
"final_value": initial_cash,
|
||||
"total_return": 0, "annual_return": 0, "max_drawdown": 0,
|
||||
"sharpe": 0, "calmar": 0, "sortino": 0,
|
||||
"win_rate": 0, "profit_loss_ratio": 0, "profit_factor": 0,
|
||||
"max_consecutive_loss": 0, "avg_holding_days": 0, "trade_count": 0,
|
||||
"benchmark_return": 0, "excess_return": 0, "information_ratio": 0,
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Shared data models for backtest engines.
|
||||
|
||||
Immutable dataclasses for positions, trades, and equity snapshots.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Position:
|
||||
"""An open position in a single instrument.
|
||||
|
||||
Args:
|
||||
symbol: Instrument identifier.
|
||||
direction: 1 for long, -1 for short.
|
||||
entry_price: Execution price at entry.
|
||||
entry_time: Timestamp when position was opened.
|
||||
size: Number of shares / coins.
|
||||
leverage: Effective leverage (1 for spot/stocks).
|
||||
entry_bar_idx: Index in the dates array at entry (for holding_bars).
|
||||
entry_commission: Commission paid at entry.
|
||||
"""
|
||||
|
||||
symbol: str
|
||||
direction: int
|
||||
entry_price: float
|
||||
entry_time: pd.Timestamp
|
||||
size: float
|
||||
leverage: float = 1.0
|
||||
entry_bar_idx: int = 0
|
||||
entry_commission: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TradeRecord:
|
||||
"""A completed round-trip trade.
|
||||
|
||||
Args:
|
||||
symbol: Instrument identifier.
|
||||
direction: 1 for long, -1 for short.
|
||||
entry_price: Entry execution price.
|
||||
exit_price: Exit execution price.
|
||||
entry_time: Entry timestamp.
|
||||
exit_time: Exit timestamp.
|
||||
size: Number of shares / coins traded.
|
||||
leverage: Effective leverage.
|
||||
pnl: Realised profit/loss in cash terms.
|
||||
pnl_pct: Realised P&L as percentage of margin.
|
||||
exit_reason: Why closed (signal / liquidation / end_of_backtest).
|
||||
holding_bars: Number of bars held.
|
||||
commission: Total commission (entry + exit).
|
||||
"""
|
||||
|
||||
symbol: str
|
||||
direction: int
|
||||
entry_price: float
|
||||
exit_price: float
|
||||
entry_time: pd.Timestamp
|
||||
exit_time: pd.Timestamp
|
||||
size: float
|
||||
leverage: float
|
||||
pnl: float
|
||||
pnl_pct: float
|
||||
exit_reason: str
|
||||
holding_bars: int
|
||||
commission: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EquitySnapshot:
|
||||
"""Portfolio state at a single point in time.
|
||||
|
||||
Args:
|
||||
timestamp: Bar timestamp.
|
||||
capital: Free cash.
|
||||
unrealized: Total unrealised P&L across all positions.
|
||||
equity: capital + margin_in_use + unrealized.
|
||||
positions: Number of open positions.
|
||||
"""
|
||||
|
||||
timestamp: pd.Timestamp
|
||||
capital: float
|
||||
unrealized: float
|
||||
equity: float
|
||||
positions: int
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Trust Layer run card generator for backtest runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
|
||||
SCHEMA_VERSION = "0.1"
|
||||
BACKTEST_SUMMARY_KEYS = (
|
||||
"codes",
|
||||
"start_date",
|
||||
"end_date",
|
||||
"interval",
|
||||
"engine",
|
||||
"initial_cash",
|
||||
"source",
|
||||
)
|
||||
|
||||
|
||||
def write_run_card(
|
||||
run_dir: Path,
|
||||
config: Mapping[str, Any],
|
||||
metrics: Mapping[str, Any],
|
||||
*,
|
||||
data_sources: Sequence[str] | None = None,
|
||||
strategy_path: Path | None = None,
|
||||
warnings: Sequence[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Write JSON and Markdown run cards for a backtest run.
|
||||
|
||||
Args:
|
||||
run_dir: Directory where run_card.json and run_card.md are written.
|
||||
config: Full backtest configuration. Only a summary and hash are stored.
|
||||
metrics: Backtest metrics. Scalar values are stored; ``validation`` is
|
||||
stored separately when present.
|
||||
data_sources: Data sources used by the run.
|
||||
strategy_path: Optional strategy source file to hash for reproducibility.
|
||||
warnings: Optional warnings to include in the card.
|
||||
|
||||
Returns:
|
||||
The run card payload written to ``run_card.json``.
|
||||
"""
|
||||
run_dir = Path(run_dir)
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config_file = run_dir / "config.json"
|
||||
reproducibility: dict[str, Any] = {
|
||||
"config_hash": _file_hash(config_file) if config_file.exists() else _json_hash(config),
|
||||
}
|
||||
if strategy_path is not None:
|
||||
strategy_file = Path(strategy_path)
|
||||
if strategy_file.exists() and strategy_file.is_file():
|
||||
reproducibility["strategy_hash"] = _file_hash(strategy_file)
|
||||
|
||||
card: dict[str, Any] = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"generated_at": _utc_now(),
|
||||
"run_dir": str(run_dir),
|
||||
"backtest": _backtest_summary(config),
|
||||
"reproducibility": reproducibility,
|
||||
"data_sources": list(data_sources or []),
|
||||
"metrics": _scalar_metrics(metrics),
|
||||
"warnings": list(warnings or []),
|
||||
"artifacts": _list_artifacts(run_dir),
|
||||
}
|
||||
if "validation" in metrics:
|
||||
card["validation"] = metrics["validation"]
|
||||
|
||||
json_path = run_dir / "run_card.json"
|
||||
md_path = run_dir / "run_card.md"
|
||||
json_path.write_text(
|
||||
json.dumps(card, ensure_ascii=False, indent=2, sort_keys=True, default=str) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
md_path.write_text(_render_markdown(card), encoding="utf-8")
|
||||
return card
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _json_hash(value: Mapping[str, Any]) -> str:
|
||||
payload = json.dumps(
|
||||
{key: val for key, val in value.items() if not str(key).startswith("_")},
|
||||
sort_keys=True,
|
||||
default=str,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _file_hash(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _backtest_summary(config: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {key: config.get(key) for key in BACKTEST_SUMMARY_KEYS if key in config}
|
||||
|
||||
|
||||
def _scalar_metrics(metrics: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
key: value
|
||||
for key, value in metrics.items()
|
||||
if key != "validation" and _is_scalar(value)
|
||||
}
|
||||
|
||||
|
||||
def _is_scalar(value: Any) -> bool:
|
||||
return value is None or isinstance(value, (str, int, float, bool))
|
||||
|
||||
|
||||
def _list_artifacts(run_dir: Path) -> list[dict[str, Any]]:
|
||||
candidates: list[Path] = []
|
||||
for relative in (Path("config.json"), Path("code/signal_engine.py")):
|
||||
path = run_dir / relative
|
||||
if path.exists() and path.is_file():
|
||||
candidates.append(path)
|
||||
|
||||
artifacts_dir = run_dir / "artifacts"
|
||||
if artifacts_dir.exists() and artifacts_dir.is_dir():
|
||||
candidates.extend(path for path in artifacts_dir.rglob("*") if path.is_file())
|
||||
|
||||
artifacts = []
|
||||
for path in sorted(candidates, key=lambda item: item.relative_to(run_dir).as_posix()):
|
||||
artifacts.append(
|
||||
{
|
||||
"path": path.relative_to(run_dir).as_posix(),
|
||||
"size_bytes": path.stat().st_size,
|
||||
"sha256": _file_hash(path),
|
||||
}
|
||||
)
|
||||
return artifacts
|
||||
|
||||
|
||||
def _render_markdown(card: Mapping[str, Any]) -> str:
|
||||
lines = [
|
||||
"# Backtest Run Card",
|
||||
"",
|
||||
f"Generated: {card['generated_at']}",
|
||||
f"Run directory: `{card['run_dir']}`",
|
||||
"",
|
||||
"## Backtest Summary",
|
||||
]
|
||||
|
||||
backtest = card.get("backtest", {})
|
||||
if backtest:
|
||||
lines.extend(f"- {key}: {value}" for key, value in backtest.items())
|
||||
else:
|
||||
lines.append("- No backtest summary fields provided.")
|
||||
|
||||
lines.extend(["", "## Reproducibility"])
|
||||
reproducibility = card.get("reproducibility", {})
|
||||
lines.append(f"- config_hash: `{reproducibility.get('config_hash', '')}`")
|
||||
if "strategy_hash" in reproducibility:
|
||||
lines.append(f"- strategy_hash: `{reproducibility['strategy_hash']}`")
|
||||
|
||||
lines.extend(["", "## Data Sources"])
|
||||
data_sources = card.get("data_sources", [])
|
||||
lines.extend(f"- {source}" for source in data_sources) if data_sources else lines.append("- None recorded.")
|
||||
|
||||
lines.extend(["", "## Metrics"])
|
||||
metric_values = card.get("metrics", {})
|
||||
lines.extend(f"- {key}: {value}" for key, value in metric_values.items()) if metric_values else lines.append("- No scalar metrics recorded.")
|
||||
|
||||
lines.extend(["", "## Validation"])
|
||||
if "validation" in card:
|
||||
validation = card["validation"]
|
||||
if isinstance(validation, Mapping):
|
||||
lines.extend(f"- {key}: {value}" for key, value in validation.items())
|
||||
else:
|
||||
lines.append(f"- {validation}")
|
||||
else:
|
||||
lines.append("- Not present.")
|
||||
|
||||
warnings = card.get("warnings", [])
|
||||
if warnings:
|
||||
lines.extend(["", "## Warnings"])
|
||||
lines.extend(f"- {warning}" for warning in warnings)
|
||||
|
||||
lines.extend(["", "## Artifacts"])
|
||||
artifacts = card.get("artifacts", [])
|
||||
if artifacts:
|
||||
lines.extend(
|
||||
f"- `{artifact['path']}` ({artifact['size_bytes']} bytes, sha256 `{artifact['sha256']}`)"
|
||||
for artifact in artifacts
|
||||
)
|
||||
else:
|
||||
lines.append("- None found.")
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
+457
-61
@@ -7,12 +7,123 @@ Supports ``engine`` for backtest engine (daily/options, default daily).
|
||||
Usage: ``python -m backtest.runner <run_dir>``
|
||||
"""
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import inspect
|
||||
import json
|
||||
import re
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
from pydantic import BaseModel, ConfigDict, model_validator, field_validator
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
from backtest.loaders.registry import (
|
||||
FALLBACK_CHAINS,
|
||||
LOADER_REGISTRY,
|
||||
get_loader_cls_with_fallback,
|
||||
resolve_loader,
|
||||
)
|
||||
from backtest.loaders.base import NoAvailableSourceError
|
||||
# Symbol classification lives in ``_market_hooks`` so runner.py and
|
||||
# composite.py share a single source of truth (audit-2026-05-18 B1+C1+C2).
|
||||
# ``_detect_market`` is also re-exported here for back-compat with
|
||||
# ``agent/src/swarm/grounding.py`` and existing tests that import it
|
||||
# from ``backtest.runner``.
|
||||
from backtest.engines._market_hooks import ( # noqa: F401 (re-exported)
|
||||
_detect_market,
|
||||
_detect_submarket,
|
||||
_is_china_futures,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_VALID_INTERVALS = {"1m", "5m", "15m", "30m", "1H", "4H", "1D"}
|
||||
_VALID_ENGINES = {"daily", "options"}
|
||||
_VALID_SOURCES = {"tushare", "okx", "yfinance", "akshare", "ccxt", "auto"}
|
||||
|
||||
|
||||
class BacktestConfigSchema(BaseModel):
|
||||
"""Validates backtest config.json before execution."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
codes: List[str]
|
||||
start_date: str
|
||||
end_date: str
|
||||
source: str = "tushare"
|
||||
interval: str = "1D"
|
||||
engine: str = "daily"
|
||||
fundamental_fields: Optional[Dict[str, List[str]]] = None
|
||||
|
||||
@field_validator("codes")
|
||||
@classmethod
|
||||
def codes_not_empty(cls, v: List[str]) -> List[str]:
|
||||
if not v:
|
||||
raise ValueError("codes must be a non-empty list")
|
||||
if any(not c.strip() for c in v):
|
||||
raise ValueError("codes must not contain empty strings")
|
||||
return v
|
||||
|
||||
@field_validator("start_date", "end_date")
|
||||
@classmethod
|
||||
def valid_date(cls, v: str) -> str:
|
||||
try:
|
||||
pd.Timestamp(v)
|
||||
except Exception:
|
||||
raise ValueError(f"invalid date format: {v!r} (expected YYYY-MM-DD)")
|
||||
return v
|
||||
|
||||
@field_validator("interval")
|
||||
@classmethod
|
||||
def valid_interval(cls, v: str) -> str:
|
||||
if v not in _VALID_INTERVALS:
|
||||
raise ValueError(f"unsupported interval {v!r}, must be one of {_VALID_INTERVALS}")
|
||||
return v
|
||||
|
||||
@field_validator("engine")
|
||||
@classmethod
|
||||
def valid_engine(cls, v: str) -> str:
|
||||
if v not in _VALID_ENGINES:
|
||||
raise ValueError(f"unsupported engine {v!r}, must be one of {_VALID_ENGINES}")
|
||||
return v
|
||||
|
||||
@field_validator("source")
|
||||
@classmethod
|
||||
def valid_source(cls, v: str) -> str:
|
||||
if v not in _VALID_SOURCES:
|
||||
raise ValueError(f"unsupported source {v!r}, must be one of {_VALID_SOURCES}")
|
||||
return v
|
||||
|
||||
@field_validator("fundamental_fields")
|
||||
@classmethod
|
||||
def valid_fundamental_fields(
|
||||
cls,
|
||||
v: Optional[Dict[str, List[str]]],
|
||||
) -> Optional[Dict[str, List[str]]]:
|
||||
if v is None:
|
||||
return v
|
||||
for table, fields in v.items():
|
||||
if not table.strip():
|
||||
raise ValueError("fundamental_fields table names must be non-empty strings")
|
||||
if any(not field.strip() for field in fields):
|
||||
raise ValueError("fundamental_fields field names must be non-empty strings")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def start_before_end(self) -> "BacktestConfigSchema":
|
||||
if pd.Timestamp(self.start_date) > pd.Timestamp(self.end_date):
|
||||
raise ValueError(
|
||||
f"start_date ({self.start_date}) must be <= end_date ({self.end_date})"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
def _load_module_from_file(file_path: Path, module_name: str):
|
||||
@@ -25,6 +136,7 @@ def _load_module_from_file(file_path: Path, module_name: str):
|
||||
Returns:
|
||||
Loaded module object.
|
||||
"""
|
||||
_validate_signal_engine_source(file_path)
|
||||
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
@@ -32,33 +144,185 @@ def _load_module_from_file(file_path: Path, module_name: str):
|
||||
return module
|
||||
|
||||
|
||||
# --- Market detection ---
|
||||
def _is_literal_node(node: ast.AST) -> bool:
|
||||
"""Return whether an AST node is made only from literal values."""
|
||||
if isinstance(node, ast.Constant):
|
||||
return True
|
||||
if isinstance(node, (ast.Tuple, ast.List, ast.Set)):
|
||||
return all(_is_literal_node(item) for item in node.elts)
|
||||
if isinstance(node, ast.Dict):
|
||||
return all(
|
||||
(key is None or _is_literal_node(key)) and _is_literal_node(value)
|
||||
for key, value in zip(node.keys, node.values)
|
||||
)
|
||||
return False
|
||||
|
||||
_PATTERNS = [
|
||||
(re.compile(r"^\d{6}\.(SZ|SH|BJ)$", re.I), "tushare"),
|
||||
(re.compile(r"^[A-Z]+\.US$", re.I), "yfinance"),
|
||||
(re.compile(r"^\d{3,5}\.HK$", re.I), "yfinance"),
|
||||
(re.compile(r"^[A-Z]+-USDT$", re.I), "okx"),
|
||||
]
|
||||
|
||||
def _is_safe_constant_assignment(node: ast.AST) -> bool:
|
||||
"""Return whether a top-level assignment is literal-only."""
|
||||
if isinstance(node, ast.Assign):
|
||||
return _is_literal_node(node.value)
|
||||
if isinstance(node, ast.AnnAssign):
|
||||
return node.value is None or _is_literal_node(node.value)
|
||||
return False
|
||||
|
||||
|
||||
def _is_safe_reference(node: ast.AST | None) -> bool:
|
||||
"""Return whether an annotation/base expression cannot call code."""
|
||||
if node is None:
|
||||
return True
|
||||
if isinstance(node, (ast.Name, ast.Attribute, ast.Constant)):
|
||||
return True
|
||||
if isinstance(node, ast.Subscript):
|
||||
return _is_safe_reference(node.value) and _is_safe_reference(node.slice)
|
||||
if isinstance(node, ast.Tuple):
|
||||
return all(_is_safe_reference(item) for item in node.elts)
|
||||
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
|
||||
return _is_safe_reference(node.left) and _is_safe_reference(node.right)
|
||||
return False
|
||||
|
||||
|
||||
def _validate_function_def(node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
||||
"""Reject import-time execution in function definitions."""
|
||||
if node.decorator_list:
|
||||
raise ValueError(f"Decorators are not allowed on function {node.name!r}")
|
||||
for default in [*node.args.defaults, *[d for d in node.args.kw_defaults if d]]:
|
||||
if not _is_literal_node(default):
|
||||
raise ValueError(f"Non-literal default is not allowed on function {node.name!r}")
|
||||
annotations = [node.returns]
|
||||
annotations.extend(arg.annotation for arg in node.args.posonlyargs)
|
||||
annotations.extend(arg.annotation for arg in node.args.args)
|
||||
annotations.extend(arg.annotation for arg in node.args.kwonlyargs)
|
||||
annotations.append(node.args.vararg.annotation if node.args.vararg else None)
|
||||
annotations.append(node.args.kwarg.annotation if node.args.kwarg else None)
|
||||
for annotation in annotations:
|
||||
if not _is_safe_reference(annotation):
|
||||
raise ValueError(f"Unsafe annotation is not allowed on function {node.name!r}")
|
||||
|
||||
|
||||
def _validate_class_body(node: ast.ClassDef) -> None:
|
||||
"""Reject import-time execution inside class bodies."""
|
||||
if node.decorator_list:
|
||||
raise ValueError(f"Decorators are not allowed on class {node.name!r}")
|
||||
for base in node.bases:
|
||||
if not _is_safe_reference(base):
|
||||
raise ValueError(f"Unsafe base class is not allowed on class {node.name!r}")
|
||||
if node.keywords:
|
||||
raise ValueError(f"Class keywords are not allowed on class {node.name!r}")
|
||||
for child in node.body:
|
||||
if isinstance(child, ast.Expr) and isinstance(child.value, ast.Constant):
|
||||
continue
|
||||
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
_validate_function_def(child)
|
||||
continue
|
||||
if _is_safe_constant_assignment(child):
|
||||
continue
|
||||
if isinstance(child, ast.Pass):
|
||||
continue
|
||||
raise ValueError(
|
||||
f"Executable class-level statement {type(child).__name__} is not allowed"
|
||||
)
|
||||
|
||||
|
||||
def _validate_signal_engine_source(file_path: Path) -> None:
|
||||
"""Reject import-time executable statements before loading signal_engine.py."""
|
||||
try:
|
||||
tree = ast.parse(file_path.read_text(encoding="utf-8"), filename=str(file_path))
|
||||
except SyntaxError as exc:
|
||||
raise ValueError(f"Invalid signal_engine.py syntax: {exc}") from exc
|
||||
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant):
|
||||
continue
|
||||
if isinstance(node, ast.ImportFrom) and node.module == "signal_engine":
|
||||
raise ValueError(
|
||||
"Circular import: 'from signal_engine import ...' imports the file from itself. "
|
||||
"Remove this import — SignalEngine is defined in this same file."
|
||||
)
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||
continue
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
_validate_function_def(node)
|
||||
continue
|
||||
if isinstance(node, ast.ClassDef):
|
||||
_validate_class_body(node)
|
||||
continue
|
||||
if _is_safe_constant_assignment(node):
|
||||
continue
|
||||
raise ValueError(
|
||||
f"Executable top-level statement {type(node).__name__} is not allowed"
|
||||
)
|
||||
|
||||
|
||||
def _validate_signal_engine_class(engine_cls) -> None:
|
||||
"""Pre-flight check: SignalEngine can be instantiated with no args and has generate()."""
|
||||
sig = inspect.signature(engine_cls.__init__)
|
||||
required = [
|
||||
p.name for p in sig.parameters.values()
|
||||
if p.name != "self" and p.default is inspect.Parameter.empty
|
||||
and p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
||||
]
|
||||
if required:
|
||||
raise ValueError(
|
||||
f"SignalEngine.__init__() has required arguments {required}. "
|
||||
"All parameters must have default values so the runner can call SignalEngine()."
|
||||
)
|
||||
if not callable(getattr(engine_cls, "generate", None)):
|
||||
raise ValueError(
|
||||
"SignalEngine must have a callable 'generate' method. "
|
||||
"Expected: def generate(self, data_map: Dict[str, pd.DataFrame]) -> Dict[str, pd.Series]"
|
||||
)
|
||||
|
||||
|
||||
# --- Market detection ---
|
||||
# ``_MARKET_PATTERNS``, ``_detect_market``, ``_is_china_futures``,
|
||||
# ``_detect_submarket`` are imported from ``_market_hooks`` above and
|
||||
# re-exported here for back-compat (swarm/grounding.py, tests).
|
||||
|
||||
# Back-compat: market type -> legacy source name (for engine selection & metrics)
|
||||
_MARKET_TO_SOURCE = {
|
||||
"a_share": "tushare",
|
||||
"us_equity": "yfinance",
|
||||
"hk_equity": "yfinance",
|
||||
"crypto": "okx",
|
||||
"futures": "tushare",
|
||||
"fund": "tushare",
|
||||
"macro": "akshare",
|
||||
"forex": "akshare",
|
||||
}
|
||||
|
||||
|
||||
def _detect_source(code: str) -> str:
|
||||
"""Infer data source from symbol format.
|
||||
"""Infer legacy source name from symbol (back-compat for metrics/engine).
|
||||
|
||||
Args:
|
||||
code: Ticker / symbol string.
|
||||
|
||||
Returns:
|
||||
Source name (tushare/okx/yfinance); unknown defaults to ``tushare``.
|
||||
Source name (tushare/okx/yfinance/akshare).
|
||||
"""
|
||||
for pattern, source in _PATTERNS:
|
||||
if pattern.match(code):
|
||||
return source
|
||||
return "tushare"
|
||||
market = _detect_market(code)
|
||||
return _MARKET_TO_SOURCE.get(market, "tushare")
|
||||
|
||||
|
||||
def _group_codes_by_market(codes: List[str]) -> Dict[str, List[str]]:
|
||||
"""Group symbols by detected market type.
|
||||
|
||||
Args:
|
||||
codes: List of symbol strings.
|
||||
|
||||
Returns:
|
||||
Mapping market_type -> list of codes.
|
||||
"""
|
||||
groups: Dict[str, List[str]] = {}
|
||||
for code in codes:
|
||||
market = _detect_market(code)
|
||||
groups.setdefault(market, []).append(code)
|
||||
return groups
|
||||
|
||||
|
||||
def _group_codes_by_source(codes: List[str]) -> Dict[str, List[str]]:
|
||||
"""Group symbols by inferred source.
|
||||
"""Group symbols by inferred source (back-compat).
|
||||
|
||||
Args:
|
||||
codes: List of symbol strings.
|
||||
@@ -74,23 +338,21 @@ def _group_codes_by_source(codes: List[str]) -> Dict[str, List[str]]:
|
||||
|
||||
|
||||
def _get_loader(source: str):
|
||||
"""Return the DataLoader class for a source name.
|
||||
"""Return a DataLoader class for a source name, with fallback.
|
||||
|
||||
Args:
|
||||
source: Source name.
|
||||
source: Source name (tushare/okx/yfinance/akshare/ccxt).
|
||||
|
||||
Returns:
|
||||
DataLoader class.
|
||||
"""
|
||||
if source == "okx":
|
||||
from backtest.loaders.okx import DataLoader
|
||||
elif source == "yfinance":
|
||||
from backtest.loaders.yfinance_loader import DataLoader
|
||||
elif source == "tushare":
|
||||
from backtest.loaders.tushare import DataLoader
|
||||
else:
|
||||
from backtest.loaders.tushare import DataLoader
|
||||
return DataLoader
|
||||
try:
|
||||
return get_loader_cls_with_fallback(source)
|
||||
except NoAvailableSourceError:
|
||||
# Ultimate fallback for unknown sources
|
||||
if "tushare" in LOADER_REGISTRY:
|
||||
return LOADER_REGISTRY["tushare"]
|
||||
raise
|
||||
|
||||
|
||||
def _normalize_codes(codes: List[str], source: str) -> List[str]:
|
||||
@@ -103,7 +365,7 @@ def _normalize_codes(codes: List[str], source: str) -> List[str]:
|
||||
Returns:
|
||||
Normalized codes.
|
||||
"""
|
||||
if source == "okx":
|
||||
if source in ("okx", "ccxt"):
|
||||
return [c.replace("/", "-").upper() for c in codes]
|
||||
return codes
|
||||
|
||||
@@ -117,13 +379,40 @@ def main(run_dir: Path) -> None:
|
||||
|
||||
Args:
|
||||
run_dir: Run directory containing ``config.json`` and ``code/signal_engine.py``.
|
||||
The path is validated against the allowed run roots
|
||||
(``VIBE_TRADING_ALLOWED_RUN_ROOTS`` plus the defaults) before any
|
||||
file is read so an arbitrary filesystem location cannot be used
|
||||
to source ``code/signal_engine.py``.
|
||||
"""
|
||||
# Guard the CLI entry point with the same root whitelist the MCP
|
||||
# ``backtest`` tool already uses (src/tools/backtest_tool.py:23). Without
|
||||
# this, ``python -m backtest.runner /tmp/attacker_path`` would happily
|
||||
# import ``signal_engine.py`` from anywhere on disk; the AST scrubber
|
||||
# below blocks executable top-level statements but a method body still
|
||||
# runs on instantiation. See ``safe_run_dir`` for the policy.
|
||||
from src.tools.path_utils import safe_run_dir
|
||||
try:
|
||||
run_dir = safe_run_dir(str(run_dir))
|
||||
except ValueError as exc:
|
||||
print(json.dumps({"error": str(exc)}))
|
||||
sys.exit(1)
|
||||
|
||||
config_path = run_dir / "config.json"
|
||||
if not config_path.exists():
|
||||
print(json.dumps({"error": "config.json not found"}))
|
||||
sys.exit(1)
|
||||
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
raw_config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
|
||||
# Validate config schema
|
||||
try:
|
||||
BacktestConfigSchema(**raw_config)
|
||||
except Exception as exc:
|
||||
errors = str(exc)
|
||||
print(json.dumps({"error": f"Invalid config: {errors}"}))
|
||||
sys.exit(1)
|
||||
|
||||
config = raw_config
|
||||
source = config.get("source", "tushare")
|
||||
codes = config.get("codes", [])
|
||||
|
||||
@@ -133,12 +422,26 @@ def main(run_dir: Path) -> None:
|
||||
print(json.dumps({"error": "code/signal_engine.py not found"}))
|
||||
sys.exit(1)
|
||||
|
||||
signal_module = _load_module_from_file(signal_path, "signal_engine")
|
||||
try:
|
||||
signal_module = _load_module_from_file(signal_path, "signal_engine")
|
||||
except ValueError as exc:
|
||||
# Source-level AST validation (circular self-import, unsafe imports,
|
||||
# decorators, top-level statements) raises ValueError. Surface it as a
|
||||
# clean JSON envelope instead of a raw traceback so the agent gets an
|
||||
# actionable message.
|
||||
print(json.dumps({"error": f"SignalEngine source error: {exc}"}))
|
||||
sys.exit(1)
|
||||
engine_cls = getattr(signal_module, "SignalEngine", None)
|
||||
if engine_cls is None:
|
||||
print(json.dumps({"error": "SignalEngine class not found in signal_engine.py"}))
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
_validate_signal_engine_class(engine_cls)
|
||||
except ValueError as exc:
|
||||
print(json.dumps({"error": f"SignalEngine interface error: {exc}"}))
|
||||
sys.exit(1)
|
||||
|
||||
# Data: auto split vs single loader
|
||||
interval = config.get("interval", "1D")
|
||||
|
||||
@@ -156,44 +459,115 @@ def main(run_dir: Path) -> None:
|
||||
fields=config.get("extra_fields") or None,
|
||||
interval=interval,
|
||||
)
|
||||
# Runtime fallback: try next sources in chain when primary returns empty
|
||||
if not data_map and codes:
|
||||
market = _detect_market(codes[0])
|
||||
for fb_name in FALLBACK_CHAINS.get(market, []):
|
||||
if fb_name == source or fb_name not in LOADER_REGISTRY:
|
||||
continue
|
||||
fb_loader = LOADER_REGISTRY[fb_name]()
|
||||
if not fb_loader.is_available():
|
||||
continue
|
||||
fb_codes = _normalize_codes(codes, fb_name)
|
||||
data_map = fb_loader.fetch(
|
||||
fb_codes, config.get("start_date", ""),
|
||||
config.get("end_date", ""), interval=interval,
|
||||
)
|
||||
if data_map:
|
||||
logger.info("Runtime fallback: %s -> %s", source, fb_name)
|
||||
source = fb_name
|
||||
loader = fb_loader
|
||||
break
|
||||
if not data_map:
|
||||
print(json.dumps({"error": "No data fetched"}))
|
||||
sys.exit(1)
|
||||
|
||||
if source == "auto":
|
||||
config["_run_card_effective_sources"] = sorted(_group_codes_by_source(codes))
|
||||
else:
|
||||
config["_run_card_effective_sources"] = [source]
|
||||
|
||||
# Engine
|
||||
engine_type = config.get("engine", "daily")
|
||||
run_backtest_fn = _get_engine(engine_type)
|
||||
|
||||
engine = engine_cls()
|
||||
signal_engine = engine_cls()
|
||||
|
||||
# Annualization bars
|
||||
effective_source = _detect_primary_source(codes, source)
|
||||
from backtest.engines.daily_portfolio import _calc_bars_per_year
|
||||
bars_per_year = _calc_bars_per_year(interval, effective_source)
|
||||
from backtest.metrics import calc_bars_per_year
|
||||
# Cross-market: use calendar-day annualization (bars_per_year=None)
|
||||
market_types = {_detect_market(c) for c in codes}
|
||||
if len(market_types) > 1:
|
||||
bars_per_year = None
|
||||
else:
|
||||
bars_per_year = calc_bars_per_year(interval, effective_source)
|
||||
|
||||
# Auto mode: wrap preloaded data in a dummy loader
|
||||
if source == "auto":
|
||||
loader = _AutoLoader(data_map)
|
||||
run_backtest_fn(config, loader, engine, run_dir, bars_per_year=bars_per_year)
|
||||
else:
|
||||
run_backtest_fn(config, loader, engine, run_dir, bars_per_year=bars_per_year)
|
||||
|
||||
|
||||
def _get_engine(engine_type: str):
|
||||
"""Resolve ``run_backtest`` / ``run_options_backtest`` by engine name.
|
||||
|
||||
Args:
|
||||
engine_type: ``daily`` or ``options``.
|
||||
|
||||
Returns:
|
||||
Callable backtest entry.
|
||||
"""
|
||||
if engine_type == "options":
|
||||
from backtest.engines.options_portfolio import run_options_backtest
|
||||
return run_options_backtest
|
||||
run_options_backtest(config, loader, signal_engine, run_dir, bars_per_year=bars_per_year)
|
||||
else:
|
||||
from backtest.engines.daily_portfolio import run_backtest
|
||||
return run_backtest
|
||||
market_engine = _create_market_engine(effective_source, config, codes)
|
||||
market_engine.run_backtest(config, loader, signal_engine, run_dir, bars_per_year=bars_per_year)
|
||||
|
||||
|
||||
def _create_market_engine(source: str, config: dict, codes: List[str]):
|
||||
"""Create the appropriate market engine based on data source and market type.
|
||||
|
||||
Routing priority:
|
||||
1. Detect market type from symbol patterns (futures, forex, etc.)
|
||||
2. Fall back to source-based routing (okx->crypto, tushare->china_a, etc.)
|
||||
|
||||
Args:
|
||||
source: Data source (okx/ccxt/tushare/akshare/yfinance).
|
||||
config: Backtest configuration.
|
||||
codes: Instrument codes.
|
||||
|
||||
Returns:
|
||||
BaseEngine subclass instance.
|
||||
"""
|
||||
# Detect dominant market type from codes
|
||||
markets = {_detect_market(c) for c in codes} if codes else set()
|
||||
|
||||
# Cross-market -> CompositeEngine
|
||||
if len(markets) > 1:
|
||||
from backtest.engines.composite import CompositeEngine
|
||||
return CompositeEngine(config, codes)
|
||||
|
||||
# Futures routing (Wave 2)
|
||||
if "futures" in markets:
|
||||
# Distinguish China vs global futures by exchange suffix
|
||||
if any(_is_china_futures(c) for c in codes):
|
||||
from backtest.engines.china_futures import ChinaFuturesEngine
|
||||
return ChinaFuturesEngine(config)
|
||||
from backtest.engines.global_futures import GlobalFuturesEngine
|
||||
return GlobalFuturesEngine(config)
|
||||
|
||||
# Forex routing (Wave 2)
|
||||
if "forex" in markets:
|
||||
from backtest.engines.forex import ForexEngine
|
||||
return ForexEngine(config)
|
||||
|
||||
# Original routing (Wave 1)
|
||||
if source in ("okx", "ccxt"):
|
||||
from backtest.engines.crypto import CryptoEngine
|
||||
return CryptoEngine(config)
|
||||
elif source in ("tushare", "akshare"):
|
||||
if markets & {"us_equity", "hk_equity"}:
|
||||
from backtest.engines.global_equity import GlobalEquityEngine
|
||||
market = _detect_submarket(codes)
|
||||
return GlobalEquityEngine(config, market=market)
|
||||
from backtest.engines.china_a import ChinaAEngine
|
||||
return ChinaAEngine(config)
|
||||
elif source == "yfinance":
|
||||
from backtest.engines.global_equity import GlobalEquityEngine
|
||||
market = _detect_submarket(codes)
|
||||
return GlobalEquityEngine(config, market=market)
|
||||
else:
|
||||
from backtest.engines.crypto import CryptoEngine
|
||||
return CryptoEngine(config)
|
||||
|
||||
|
||||
def _detect_primary_source(codes: List[str], source: str) -> str:
|
||||
@@ -216,7 +590,7 @@ def _detect_primary_source(codes: List[str], source: str) -> str:
|
||||
|
||||
|
||||
def _fetch_auto(codes: List[str], config: dict, interval: str = "1D") -> dict:
|
||||
"""Auto mode: fetch per source and merge into one data map.
|
||||
"""Auto mode: route each market group through fallback chain.
|
||||
|
||||
Args:
|
||||
codes: All symbols.
|
||||
@@ -226,18 +600,40 @@ def _fetch_auto(codes: List[str], config: dict, interval: str = "1D") -> dict:
|
||||
Returns:
|
||||
Merged ``code -> DataFrame`` map.
|
||||
"""
|
||||
groups = _group_codes_by_source(codes)
|
||||
market_groups = _group_codes_by_market(codes)
|
||||
merged = {}
|
||||
start_date = config.get("start_date", "")
|
||||
end_date = config.get("end_date", "")
|
||||
|
||||
for src, src_codes in groups.items():
|
||||
src_codes = _normalize_codes(src_codes, src)
|
||||
LoaderCls = _get_loader(src)
|
||||
loader = LoaderCls()
|
||||
# Only tushare supports extra_fields
|
||||
fields = config.get("extra_fields") if src == "tushare" else None
|
||||
result = loader.fetch(src_codes, start_date, end_date, fields=fields, interval=interval)
|
||||
for market, market_codes in market_groups.items():
|
||||
try:
|
||||
loader = resolve_loader(market)
|
||||
except NoAvailableSourceError as exc:
|
||||
# Fallback: try legacy source mapping
|
||||
legacy_src = _MARKET_TO_SOURCE.get(market, "tushare")
|
||||
logger.warning("Fallback chain failed for %s: %s — trying %s", market, exc, legacy_src)
|
||||
LoaderCls = _get_loader(legacy_src)
|
||||
loader = LoaderCls()
|
||||
|
||||
src_name = getattr(loader, "name", "unknown")
|
||||
normalized_codes = _normalize_codes(market_codes, src_name)
|
||||
fields = config.get("extra_fields") if src_name == "tushare" else None
|
||||
result = loader.fetch(normalized_codes, start_date, end_date, fields=fields, interval=interval)
|
||||
|
||||
# Runtime fallback: try remaining sources when primary returns empty
|
||||
if not result:
|
||||
for fb_name in FALLBACK_CHAINS.get(market, []):
|
||||
if fb_name == src_name or fb_name not in LOADER_REGISTRY:
|
||||
continue
|
||||
fb_loader = LOADER_REGISTRY[fb_name]()
|
||||
if not fb_loader.is_available():
|
||||
continue
|
||||
fb_codes = _normalize_codes(market_codes, fb_name)
|
||||
result = fb_loader.fetch(fb_codes, start_date, end_date, interval=interval)
|
||||
if result:
|
||||
logger.info("Runtime fallback: %s -> %s for %s", src_name, fb_name, market)
|
||||
break
|
||||
|
||||
merged.update(result)
|
||||
|
||||
return merged
|
||||
@@ -256,6 +652,6 @@ class _AutoLoader:
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: python -m backtest.runner <run_dir>")
|
||||
print("Usage: python -m backtest.runner <run_dir>")
|
||||
sys.exit(1)
|
||||
main(Path(sys.argv[1]))
|
||||
@@ -0,0 +1,399 @@
|
||||
"""Statistical validation for backtest results.
|
||||
|
||||
Three independent tools:
|
||||
- Monte Carlo permutation test: is the strategy significantly better than random?
|
||||
- Bootstrap Sharpe CI: how stable is the risk-adjusted return?
|
||||
- Walk-Forward analysis: is performance consistent across time windows?
|
||||
|
||||
Usage: called automatically by BaseEngine.run_backtest when config[\"validation\"]
|
||||
is present, or invoked directly on backtest outputs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from backtest.models import TradeRecord
|
||||
|
||||
|
||||
# ─── Monte Carlo Permutation Test ───
|
||||
|
||||
|
||||
def monte_carlo_test(
|
||||
trades: List[TradeRecord],
|
||||
initial_capital: float,
|
||||
n_simulations: int = 1000,
|
||||
seed: int = 42,
|
||||
) -> Dict[str, Any]:
|
||||
"""Shuffle trade PnL order to test path significance.
|
||||
|
||||
Null hypothesis: the observed Sharpe / max-drawdown is no better than
|
||||
a random ordering of the same trades.
|
||||
|
||||
Args:
|
||||
trades: Completed round-trip trades from backtest.
|
||||
initial_capital: Starting capital.
|
||||
n_simulations: Number of random permutations.
|
||||
seed: Random seed for reproducibility.
|
||||
|
||||
Returns:
|
||||
Dict with actual_sharpe, p_value_sharpe, actual_max_dd,
|
||||
p_value_max_dd, simulated_sharpes (percentiles).
|
||||
"""
|
||||
if len(trades) < 3:
|
||||
return {"error": "need at least 3 trades", "p_value_sharpe": 1.0}
|
||||
|
||||
pnls = np.array([t.pnl for t in trades])
|
||||
actual = _path_metrics(pnls, initial_capital)
|
||||
|
||||
rng = np.random.default_rng(seed)
|
||||
sharpe_count = 0
|
||||
dd_count = 0
|
||||
sim_sharpes = []
|
||||
|
||||
for _ in range(n_simulations):
|
||||
shuffled = rng.permutation(pnls)
|
||||
sim = _path_metrics(shuffled, initial_capital)
|
||||
sim_sharpes.append(sim["sharpe"])
|
||||
if sim["sharpe"] >= actual["sharpe"]:
|
||||
sharpe_count += 1
|
||||
if sim["max_dd"] >= actual["max_dd"]: # less negative = "better"
|
||||
dd_count += 1
|
||||
|
||||
sim_arr = np.array(sim_sharpes)
|
||||
return {
|
||||
"actual_sharpe": round(actual["sharpe"], 4),
|
||||
"actual_max_dd": round(actual["max_dd"], 4),
|
||||
"p_value_sharpe": round(sharpe_count / n_simulations, 4),
|
||||
"p_value_max_dd": round(dd_count / n_simulations, 4),
|
||||
"simulated_sharpe_mean": round(float(sim_arr.mean()), 4),
|
||||
"simulated_sharpe_std": round(float(sim_arr.std()), 4),
|
||||
"simulated_sharpe_p5": round(float(np.percentile(sim_arr, 5)), 4),
|
||||
"simulated_sharpe_p95": round(float(np.percentile(sim_arr, 95)), 4),
|
||||
"n_simulations": n_simulations,
|
||||
"n_trades": len(trades),
|
||||
}
|
||||
|
||||
|
||||
def _path_metrics(pnls: np.ndarray, initial_capital: float) -> Dict[str, float]:
|
||||
"""Compute Sharpe and max drawdown from a PnL sequence."""
|
||||
equity = initial_capital + np.cumsum(pnls)
|
||||
returns = np.diff(equity) / equity[:-1] if len(equity) > 1 else np.array([0.0])
|
||||
std = returns.std()
|
||||
sharpe = float(returns.mean() / (std + 1e-10) * np.sqrt(252))
|
||||
peak = np.maximum.accumulate(equity)
|
||||
dd = (equity - peak) / np.where(peak > 0, peak, 1.0)
|
||||
max_dd = float(dd.min())
|
||||
return {"sharpe": sharpe, "max_dd": max_dd}
|
||||
|
||||
|
||||
# ─── Bootstrap Sharpe CI ───
|
||||
|
||||
|
||||
def bootstrap_sharpe_ci(
|
||||
equity_curve: pd.Series,
|
||||
n_bootstrap: int = 1000,
|
||||
confidence: float = 0.95,
|
||||
bars_per_year: int = 252,
|
||||
seed: int = 42,
|
||||
) -> Dict[str, Any]:
|
||||
"""Resample daily returns to estimate Sharpe confidence interval.
|
||||
|
||||
Args:
|
||||
equity_curve: Equity time series.
|
||||
n_bootstrap: Number of bootstrap samples.
|
||||
confidence: Confidence level (e.g. 0.95 for 95% CI).
|
||||
bars_per_year: Annualisation factor.
|
||||
seed: Random seed.
|
||||
|
||||
Returns:
|
||||
Dict with observed_sharpe, ci_lower, ci_upper, median_sharpe,
|
||||
prob_positive (fraction of samples with Sharpe > 0).
|
||||
"""
|
||||
returns = equity_curve.pct_change().dropna().values
|
||||
if len(returns) < 5:
|
||||
return {"error": "need at least 5 return observations"}
|
||||
|
||||
observed = _sharpe(returns, bars_per_year)
|
||||
|
||||
rng = np.random.default_rng(seed)
|
||||
boot_sharpes = []
|
||||
for _ in range(n_bootstrap):
|
||||
sample = rng.choice(returns, size=len(returns), replace=True)
|
||||
boot_sharpes.append(_sharpe(sample, bars_per_year))
|
||||
|
||||
arr = np.array(boot_sharpes)
|
||||
alpha = (1 - confidence) / 2
|
||||
lower = float(np.percentile(arr, alpha * 100))
|
||||
upper = float(np.percentile(arr, (1 - alpha) * 100))
|
||||
prob_pos = float(np.mean(arr > 0))
|
||||
|
||||
return {
|
||||
"observed_sharpe": round(observed, 4),
|
||||
"ci_lower": round(lower, 4),
|
||||
"ci_upper": round(upper, 4),
|
||||
"median_sharpe": round(float(np.median(arr)), 4),
|
||||
"prob_positive": round(prob_pos, 4),
|
||||
"confidence": confidence,
|
||||
"n_bootstrap": n_bootstrap,
|
||||
}
|
||||
|
||||
|
||||
def _sharpe(returns: np.ndarray, bars_per_year: int = 252) -> float:
|
||||
std = returns.std()
|
||||
return float(returns.mean() / (std + 1e-10) * np.sqrt(bars_per_year))
|
||||
|
||||
|
||||
# ─── Walk-Forward Analysis ───
|
||||
|
||||
|
||||
def walk_forward_analysis(
|
||||
equity_curve: pd.Series,
|
||||
trades: List[TradeRecord],
|
||||
n_windows: int = 5,
|
||||
bars_per_year: int = 252,
|
||||
) -> Dict[str, Any]:
|
||||
"""Split backtest into sequential windows, check consistency.
|
||||
|
||||
Each window is evaluated independently (returns normalised to window start).
|
||||
|
||||
Args:
|
||||
equity_curve: Equity time series.
|
||||
trades: Completed trades.
|
||||
n_windows: Number of non-overlapping windows.
|
||||
bars_per_year: Annualisation factor.
|
||||
|
||||
Returns:
|
||||
Dict with per_window stats, consistency metrics.
|
||||
"""
|
||||
if len(equity_curve) < n_windows * 2:
|
||||
return {"error": f"need at least {n_windows * 2} bars for {n_windows} windows"}
|
||||
|
||||
indices = equity_curve.index
|
||||
window_size = len(indices) // n_windows
|
||||
windows = []
|
||||
|
||||
for i in range(n_windows):
|
||||
start_idx = i * window_size
|
||||
end_idx = (i + 1) * window_size if i < n_windows - 1 else len(indices)
|
||||
win_eq = equity_curve.iloc[start_idx:end_idx]
|
||||
win_start = indices[start_idx]
|
||||
win_end = indices[end_idx - 1]
|
||||
|
||||
# Per-window trades
|
||||
win_trades = [
|
||||
t for t in trades
|
||||
if win_start <= t.entry_time <= win_end
|
||||
]
|
||||
|
||||
# Per-window metrics
|
||||
ret = float(win_eq.iloc[-1] / win_eq.iloc[0] - 1) if win_eq.iloc[0] > 0 else 0.0
|
||||
win_returns = win_eq.pct_change().dropna().values
|
||||
sharpe = _sharpe(win_returns, bars_per_year) if len(win_returns) > 1 else 0.0
|
||||
|
||||
peak = win_eq.cummax()
|
||||
dd = (win_eq - peak) / peak.replace(0, 1)
|
||||
max_dd = float(dd.min())
|
||||
|
||||
win_pnls = [t.pnl for t in win_trades]
|
||||
win_rate = (
|
||||
len([p for p in win_pnls if p > 0]) / len(win_pnls)
|
||||
if win_pnls else 0.0
|
||||
)
|
||||
|
||||
windows.append({
|
||||
"window": i + 1,
|
||||
"start": str(win_start.date()) if hasattr(win_start, "date") else str(win_start),
|
||||
"end": str(win_end.date()) if hasattr(win_end, "date") else str(win_end),
|
||||
"return": round(ret, 6),
|
||||
"sharpe": round(sharpe, 4),
|
||||
"max_dd": round(max_dd, 6),
|
||||
"trades": len(win_trades),
|
||||
"win_rate": round(win_rate, 4),
|
||||
})
|
||||
|
||||
# Consistency metrics
|
||||
returns_list = [w["return"] for w in windows]
|
||||
sharpes_list = [w["sharpe"] for w in windows]
|
||||
profitable_windows = sum(1 for r in returns_list if r > 0)
|
||||
|
||||
return {
|
||||
"n_windows": n_windows,
|
||||
"windows": windows,
|
||||
"profitable_windows": profitable_windows,
|
||||
"consistency_rate": round(profitable_windows / n_windows, 4),
|
||||
"return_mean": round(float(np.mean(returns_list)), 6),
|
||||
"return_std": round(float(np.std(returns_list)), 6),
|
||||
"sharpe_mean": round(float(np.mean(sharpes_list)), 4),
|
||||
"sharpe_std": round(float(np.std(sharpes_list)), 4),
|
||||
}
|
||||
|
||||
|
||||
# ─── Runner integration ───
|
||||
|
||||
|
||||
def run_validation(
|
||||
config: Dict[str, Any],
|
||||
equity_curve: pd.Series,
|
||||
trades: List[TradeRecord],
|
||||
initial_capital: float,
|
||||
bars_per_year: int = 252,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run configured validation checks.
|
||||
|
||||
Reads from config["validation"]:
|
||||
- monte_carlo: {n_simulations, seed}
|
||||
- bootstrap: {n_bootstrap, confidence, seed}
|
||||
- walk_forward: {n_windows}
|
||||
|
||||
Args:
|
||||
config: Backtest config (must contain "validation" key).
|
||||
equity_curve: Equity time series.
|
||||
trades: Completed trades.
|
||||
initial_capital: Starting capital.
|
||||
bars_per_year: Annualisation factor.
|
||||
|
||||
Returns:
|
||||
Dict keyed by validation type with results.
|
||||
"""
|
||||
v_cfg = config.get("validation", {})
|
||||
results: Dict[str, Any] = {}
|
||||
|
||||
if "monte_carlo" in v_cfg:
|
||||
mc_cfg = v_cfg["monte_carlo"] if isinstance(v_cfg["monte_carlo"], dict) else {}
|
||||
results["monte_carlo"] = monte_carlo_test(
|
||||
trades, initial_capital,
|
||||
n_simulations=mc_cfg.get("n_simulations", 1000),
|
||||
seed=mc_cfg.get("seed", 42),
|
||||
)
|
||||
|
||||
if "bootstrap" in v_cfg:
|
||||
bs_cfg = v_cfg["bootstrap"] if isinstance(v_cfg["bootstrap"], dict) else {}
|
||||
results["bootstrap"] = bootstrap_sharpe_ci(
|
||||
equity_curve, bars_per_year=bars_per_year,
|
||||
n_bootstrap=bs_cfg.get("n_bootstrap", 1000),
|
||||
confidence=bs_cfg.get("confidence", 0.95),
|
||||
seed=bs_cfg.get("seed", 42),
|
||||
)
|
||||
|
||||
if "walk_forward" in v_cfg:
|
||||
wf_cfg = v_cfg["walk_forward"] if isinstance(v_cfg["walk_forward"], dict) else {}
|
||||
results["walk_forward"] = walk_forward_analysis(
|
||||
equity_curve, trades,
|
||||
n_windows=wf_cfg.get("n_windows", 5),
|
||||
bars_per_year=bars_per_year,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ─── Standalone CLI ───
|
||||
|
||||
|
||||
def _load_equity(run_dir: Path) -> pd.Series:
|
||||
"""Load equity curve from artifacts/equity.csv."""
|
||||
path = run_dir / "artifacts" / "equity.csv"
|
||||
df = pd.read_csv(path, index_col=0, parse_dates=True)
|
||||
return df["equity"]
|
||||
|
||||
|
||||
def _load_trades(run_dir: Path) -> List[TradeRecord]:
|
||||
"""Load trades from artifacts/trades.csv and convert to TradeRecord list."""
|
||||
path = run_dir / "artifacts" / "trades.csv"
|
||||
df = pd.read_csv(path)
|
||||
if df.empty:
|
||||
return []
|
||||
|
||||
# trades.csv has entry+exit row pairs; extract exit rows (they have pnl != 0)
|
||||
trades = []
|
||||
exit_rows = df[df["pnl"] != 0].reset_index(drop=True)
|
||||
for _, row in exit_rows.iterrows():
|
||||
trades.append(TradeRecord(
|
||||
symbol=str(row.get("code", "")),
|
||||
direction=1 if row.get("side") == "sell" else -1,
|
||||
entry_price=0.0,
|
||||
exit_price=float(row.get("price", 0)),
|
||||
entry_time=pd.Timestamp(row.get("timestamp", "2000-01-01")),
|
||||
exit_time=pd.Timestamp(row.get("timestamp", "2000-01-01")),
|
||||
size=float(row.get("qty", 0)),
|
||||
leverage=1.0,
|
||||
pnl=float(row.get("pnl", 0)),
|
||||
pnl_pct=float(row.get("return_pct", 0)),
|
||||
exit_reason=str(row.get("reason", "signal")),
|
||||
holding_bars=int(row.get("holding_days", 0)),
|
||||
commission=0.0,
|
||||
))
|
||||
return trades
|
||||
|
||||
|
||||
def _parse_run_dir(argv: List[str]) -> Path:
|
||||
"""Validate CLI input and return a usable run directory path."""
|
||||
if len(argv) < 2:
|
||||
raise SystemExit("Usage: python -m backtest.validation <run_dir>")
|
||||
|
||||
raw_run_dir = argv[1]
|
||||
if not raw_run_dir.strip():
|
||||
raise SystemExit("run_dir must be a non-empty path")
|
||||
if "\0" in raw_run_dir:
|
||||
raise SystemExit("Invalid run_dir path: embedded NUL byte")
|
||||
|
||||
try:
|
||||
run_dir = Path(raw_run_dir).expanduser()
|
||||
exists = run_dir.exists()
|
||||
is_dir = run_dir.is_dir() if exists else False
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
raise SystemExit(f"Invalid run_dir path: {exc}") from exc
|
||||
|
||||
if not exists:
|
||||
raise SystemExit(f"run_dir does not exist: {run_dir}")
|
||||
if not is_dir:
|
||||
raise SystemExit(f"run_dir is not a directory: {run_dir}")
|
||||
return run_dir
|
||||
|
||||
|
||||
def main(run_dir: Path) -> Dict[str, Any]:
|
||||
"""Run all three validations on existing backtest artifacts.
|
||||
|
||||
Reads equity.csv, trades.csv, and config.json from run_dir.
|
||||
|
||||
Args:
|
||||
run_dir: Directory with artifacts/ subdirectory.
|
||||
|
||||
Returns:
|
||||
Validation results dict.
|
||||
"""
|
||||
import json
|
||||
|
||||
# Load config for initial_cash
|
||||
config_path = run_dir / "config.json"
|
||||
if config_path.exists():
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
config = {}
|
||||
initial_capital = config.get("initial_cash", 1_000_000)
|
||||
|
||||
equity = _load_equity(run_dir)
|
||||
trades = _load_trades(run_dir)
|
||||
|
||||
results = {
|
||||
"monte_carlo": monte_carlo_test(trades, initial_capital),
|
||||
"bootstrap": bootstrap_sharpe_ci(equity),
|
||||
"walk_forward": walk_forward_analysis(equity, trades),
|
||||
}
|
||||
|
||||
# Write results
|
||||
out = run_dir / "artifacts" / "validation.json"
|
||||
out.write_text(json.dumps(results, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
print(json.dumps(results, indent=2))
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
main(_parse_run_dir(sys.argv))
|
||||
-1521
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,106 @@
|
||||
"""Vibe-Trading CLI package.
|
||||
|
||||
The legacy single-file CLI has been preserved verbatim as
|
||||
``cli/_legacy.py`` and is the source of truth for non-interactive
|
||||
subcommands (``serve``, ``run``, ``mcp``, ``sessions``, ``swarm`` ...).
|
||||
The front door (:mod:`cli.main`) shows the banner, runs the
|
||||
onboarding wizard when needed, then drives the interactive loop
|
||||
built on :mod:`cli.input`, :mod:`cli.completer`, and
|
||||
:mod:`cli.commands.*`. Non-interactive entries still pass through to
|
||||
``_legacy.main``.
|
||||
|
||||
The console-script entry in ``pyproject.toml``
|
||||
(``vibe-trading = "cli:main"``) points at the ``main`` callable exported
|
||||
here.
|
||||
|
||||
Compatibility note: tests and downstream callers historically reached
|
||||
into ``cli._INIT_ENV_PATH`` / ``cli.cmd_memory_list`` / ``cli.Confirm``
|
||||
etc. To preserve that surface we re-export every public name from
|
||||
``_legacy`` at package import time. New code should import the same
|
||||
helpers from ``cli._legacy`` directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cli import _legacy as _legacy
|
||||
from cli.main import main
|
||||
|
||||
# Re-export the legacy module's public surface (anything not prefixed
|
||||
# with two underscores). This lets ``cli.cmd_memory_list`` /
|
||||
# ``cli._INIT_ENV_PATH`` / ``cli.Prompt`` etc. keep working without us
|
||||
# having to enumerate every name by hand.
|
||||
for _name in dir(_legacy):
|
||||
if _name.startswith("__"):
|
||||
continue
|
||||
globals().setdefault(_name, getattr(_legacy, _name))
|
||||
del _name
|
||||
|
||||
|
||||
# Symbols tests may monkeypatch on the ``cli`` package that legacy
|
||||
# callables still read from their own module globals. Keep this list
|
||||
# explicit so accidental package-level attributes don't bleed into
|
||||
# ``_legacy``.
|
||||
_LEGACY_SYNCED_GLOBALS: tuple[str, ...] = (
|
||||
"_INIT_ENV_PATH",
|
||||
"AGENT_DIR",
|
||||
"RUNS_DIR",
|
||||
"SWARM_DIR",
|
||||
"SESSIONS_DIR",
|
||||
"UPLOADS_DIR",
|
||||
"_PROVIDER_CHOICES",
|
||||
)
|
||||
|
||||
|
||||
def _sync_legacy_test_overrides() -> None:
|
||||
"""Mirror package-level monkeypatches onto ``_legacy``'s module globals.
|
||||
|
||||
Tests reach into ``cli.<NAME>`` to override constants, but legacy
|
||||
callables read ``<NAME>`` from their own module namespace. This hook
|
||||
copies any patched value back to ``_legacy`` for the allowlist below.
|
||||
"""
|
||||
pkg_globals = globals()
|
||||
for name in _LEGACY_SYNCED_GLOBALS:
|
||||
if name not in pkg_globals:
|
||||
continue
|
||||
new_value = pkg_globals[name]
|
||||
if getattr(_legacy, name, None) is not new_value:
|
||||
setattr(_legacy, name, new_value)
|
||||
|
||||
|
||||
def cmd_init() -> int:
|
||||
"""Compatibility wrapper for callers patching ``cli._INIT_ENV_PATH``."""
|
||||
_sync_legacy_test_overrides()
|
||||
return _legacy.cmd_init()
|
||||
|
||||
|
||||
# Wrap every ``cmd_*`` callable re-exported from ``_legacy`` so a package-level
|
||||
# monkeypatch of any symbol in ``_LEGACY_SYNCED_GLOBALS`` is propagated to
|
||||
# ``_legacy`` before the call. Without this, ``patch.object(cli, "RUNS_DIR",
|
||||
# tmp); cli.cmd_list()`` would silently read the unpatched ``_legacy.RUNS_DIR``.
|
||||
import functools as _functools # noqa: E402
|
||||
|
||||
def _make_synced_legacy_wrapper(legacy_fn): # noqa: ANN001
|
||||
"""Wrap ``legacy_fn`` so the package-level monkeypatch sync fires first."""
|
||||
|
||||
@_functools.wraps(legacy_fn)
|
||||
def _wrapper(*args, **kwargs):
|
||||
_sync_legacy_test_overrides()
|
||||
# Re-read the (possibly just-synced) attribute from ``_legacy`` in
|
||||
# case the test also patched the function itself.
|
||||
return getattr(_legacy, legacy_fn.__name__)(*args, **kwargs)
|
||||
|
||||
_wrapper.__wrapped__ = legacy_fn
|
||||
return _wrapper
|
||||
|
||||
|
||||
for _cmd_name in [n for n in globals() if n.startswith("cmd_") and n != "cmd_init"]:
|
||||
_cmd_obj = globals()[_cmd_name]
|
||||
if callable(_cmd_obj) and getattr(_cmd_obj, "__module__", "") == "cli._legacy":
|
||||
globals()[_cmd_name] = _make_synced_legacy_wrapper(_cmd_obj)
|
||||
del _cmd_name, _cmd_obj
|
||||
|
||||
|
||||
__all__ = ["main", *sorted(
|
||||
name for name in globals()
|
||||
if not name.startswith("_") and name != "main"
|
||||
)]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Allow ``python -m cli`` to run the CLI entrypoint."""
|
||||
|
||||
from cli.main import _entrypoint
|
||||
|
||||
_entrypoint()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,26 @@
|
||||
"""Single source of truth for the CLI version string.
|
||||
|
||||
Reads ``vibe-trading-ai``'s installed package metadata when available
|
||||
(``pip install -e .`` is enough). Falls back to the static constant
|
||||
below for un-installed checkouts (e.g. running straight from a clone
|
||||
with ``PYTHONPATH=agent``).
|
||||
|
||||
Keep the fallback in sync with ``pyproject.toml`` ``[project] version``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
try:
|
||||
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
||||
|
||||
try:
|
||||
__version__: Final[str] = _pkg_version("vibe-trading-ai")
|
||||
except PackageNotFoundError:
|
||||
__version__ = "0.1.8"
|
||||
except ImportError: # pragma: no cover — importlib.metadata is stdlib on 3.8+
|
||||
__version__ = "0.1.8"
|
||||
|
||||
|
||||
__all__ = ["__version__"]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Slash command implementations.
|
||||
|
||||
Each command module exports a ``run(ctx, *args) -> int`` callable. Modules
|
||||
that own multiple commands (``chat``, ``show``, ``session``) also expose
|
||||
named ``cmd_<name>`` callables so the slash router can dispatch by
|
||||
command keyword.
|
||||
|
||||
The :data:`SLASH_COMMANDS` registry lives in :mod:`.slash_router`.
|
||||
"""
|
||||
|
||||
from .slash_router import SLASH_COMMANDS, Command, find_exact, match_commands
|
||||
|
||||
__all__ = [
|
||||
"Command",
|
||||
"SLASH_COMMANDS",
|
||||
"find_exact",
|
||||
"match_commands",
|
||||
]
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Chat-flow slash commands: ``/model``, ``/clear``, ``/journal``,
|
||||
``/shadow``, ``/swarm``, ``/debug``, ``/quit``.
|
||||
|
||||
``/model`` renders the current configuration via the legacy
|
||||
``_show_settings`` helper. ``/swarm`` dispatches to the legacy
|
||||
``_handle_swarm_command`` so the existing presets keep working. ``/clear``
|
||||
clears the screen and reprints the banner. ``/journal``, ``/shadow``, and
|
||||
``/debug`` show a "Coming soon" placeholder pointing at the established
|
||||
fallback workflows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from cli.theme import get_console
|
||||
|
||||
|
||||
def _resolve_console(console: Optional[Console] = None) -> Console:
|
||||
"""Return the shared CLI console, or a caller-supplied override."""
|
||||
if console is not None:
|
||||
return console
|
||||
return get_console()
|
||||
|
||||
|
||||
def _coming_soon(command: str, *, hint: str) -> int:
|
||||
"""Render the placeholder panel for not-yet-wired commands."""
|
||||
console = _resolve_console()
|
||||
body = Text()
|
||||
body.append(f"/{command} is not yet wired up to the interactive CLI.\n\n", style="dim")
|
||||
body.append("Until then: ", style="dim")
|
||||
body.append(hint, style="bold")
|
||||
console.print(Panel(body, title=f"/{command}", border_style="dim", padding=(1, 2)))
|
||||
return 0
|
||||
|
||||
|
||||
# --- /model ----------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_model(ctx: Any = None, *args: str) -> int: # noqa: ARG001 — ctx unused
|
||||
"""Print the current provider/model + how to re-run the wizard."""
|
||||
console = _resolve_console()
|
||||
try:
|
||||
from cli._legacy import _show_settings
|
||||
|
||||
_show_settings()
|
||||
except Exception as exc: # noqa: BLE001 — legacy may be absent on partial install
|
||||
provider = os.getenv("LANGCHAIN_PROVIDER", "(not set)")
|
||||
model = os.getenv("LANGCHAIN_MODEL_NAME", "(not set)")
|
||||
console.print(Text(f"Provider: {provider}", style="bold"))
|
||||
console.print(Text(f"Model: {model}", style="bold"))
|
||||
console.print(Text(f"(legacy _show_settings unavailable: {exc})", style="dim"))
|
||||
|
||||
console.print()
|
||||
console.print(
|
||||
Text(
|
||||
"Run `vibe-trading init` to switch provider, model, or credentials.",
|
||||
style="dim",
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
# --- /clear, /journal, /shadow, /swarm, /debug, /quit ----------------------
|
||||
|
||||
|
||||
def cmd_clear(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""Clear the screen and reprint the welcome banner."""
|
||||
console = _resolve_console()
|
||||
try:
|
||||
console.clear()
|
||||
except Exception: # noqa: BLE001 — clear can fail on dumb terminals
|
||||
pass
|
||||
# Best-effort: reprint the banner so the conversation appears fresh.
|
||||
# Reuse the cached stats populated at startup so the redrawn banner
|
||||
# shows the real skills / tools / sessions counts, not zeros.
|
||||
try:
|
||||
from cli.intro import print_banner
|
||||
from cli.main import _collect_banner_stats
|
||||
|
||||
print_banner(console, **_collect_banner_stats())
|
||||
except Exception: # noqa: BLE001
|
||||
console.print(Text("Conversation cleared.", style="dim"))
|
||||
# Caller is expected to also drop in-memory conversation history.
|
||||
if ctx is not None and hasattr(ctx, "history"):
|
||||
try:
|
||||
ctx.history.clear()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def _queue_prompt(ctx: Any, prompt: str) -> bool:
|
||||
"""Stash ``prompt`` on ``ctx.pending_prompt`` for the loop to consume.
|
||||
|
||||
Returns ``True`` if the context exposes a writable ``pending_prompt``
|
||||
attribute, ``False`` otherwise (e.g. a tiny test stub) — the caller
|
||||
falls back to printing the canonical instruction in that case.
|
||||
"""
|
||||
if ctx is None or not hasattr(ctx, "pending_prompt"):
|
||||
return False
|
||||
try:
|
||||
ctx.pending_prompt = prompt
|
||||
return True
|
||||
except Exception: # noqa: BLE001 — never crash the slash dispatch
|
||||
return False
|
||||
|
||||
|
||||
def cmd_journal(ctx: Any = None, *args: str) -> int:
|
||||
"""Queue an "analyze my trade journal at <path>" agent turn.
|
||||
|
||||
With a path: ``/journal trades.csv`` becomes the prompt
|
||||
``Analyze my trade journal at trades.csv`` which is queued on
|
||||
``ctx.pending_prompt`` and executed by the interactive loop on the
|
||||
next tick. Without a path the command prints the canonical
|
||||
instruction so the user can paste it themselves.
|
||||
"""
|
||||
console = _resolve_console()
|
||||
path = " ".join(args).strip()
|
||||
if not path:
|
||||
body = Text()
|
||||
body.append("Usage: ", style="dim")
|
||||
body.append("/journal <path-to-csv>\n", style="bold")
|
||||
body.append("Example: ", style="dim")
|
||||
body.append('/journal ~/Downloads/journal.csv\n\n', style="bold")
|
||||
body.append("Or type the prompt directly: ", style="dim")
|
||||
body.append('"analyze my trade journal at <path>"', style="bold")
|
||||
console.print(Panel(body, title="/journal", border_style="dim", padding=(1, 2)))
|
||||
return 0
|
||||
|
||||
prompt = f"Analyze my trade journal at {path}"
|
||||
if _queue_prompt(ctx, prompt):
|
||||
console.print(Text(f"→ Running: {prompt}", style="dim"))
|
||||
return 0
|
||||
# Fallback when the context does not support queuing (legacy callers).
|
||||
console.print(Text(f'Type: "{prompt}"', style="bold"))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_shadow(ctx: Any = None, *args: str) -> int:
|
||||
"""Queue a Shadow Account agent turn.
|
||||
|
||||
``/shadow`` opens / inspects the shadow account. ``/shadow <path>``
|
||||
trains a new shadow from a trade journal at that path. Both forms
|
||||
queue the canonical natural-language prompt on ``ctx.pending_prompt``
|
||||
so the ReAct loop picks the right tool (``extract_shadow_strategy``,
|
||||
``run_shadow_backtest``, ``render_shadow_report``).
|
||||
"""
|
||||
console = _resolve_console()
|
||||
path = " ".join(args).strip()
|
||||
if path:
|
||||
prompt = f"Train a shadow account from my trade journal at {path}"
|
||||
else:
|
||||
prompt = "Open the shadow account dashboard and show the latest report"
|
||||
|
||||
if _queue_prompt(ctx, prompt):
|
||||
console.print(Text(f"→ Running: {prompt}", style="dim"))
|
||||
return 0
|
||||
console.print(Text(f'Type: "{prompt}"', style="bold"))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_swarm(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""Dispatch to the legacy swarm handler."""
|
||||
try:
|
||||
from cli._legacy import _handle_swarm_command
|
||||
|
||||
_handle_swarm_command(" ".join(args))
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console = _resolve_console()
|
||||
console.print(Text(f"/swarm failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_debug(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""Toggle the debug summary that prints after each agent turn.
|
||||
|
||||
When ON the interactive loop appends a single muted line after every
|
||||
turn containing ``iter``, ``tools``, ``elapsed``, and an approximate
|
||||
context-size estimate — see ``_print_debug_summary`` in
|
||||
:mod:`cli.main`. When OFF the summary is suppressed.
|
||||
"""
|
||||
console = _resolve_console()
|
||||
if ctx is not None and hasattr(ctx, "debug"):
|
||||
try:
|
||||
ctx.debug = not bool(getattr(ctx, "debug", False))
|
||||
state = "ON" if ctx.debug else "OFF"
|
||||
console.print(Text(f"Debug summary: {state}", style="bold"))
|
||||
if ctx.debug:
|
||||
console.print(
|
||||
Text(
|
||||
"After each turn a one-line summary will print: "
|
||||
"iterations, tool count, elapsed, approx context tokens.",
|
||||
style="dim",
|
||||
)
|
||||
)
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(Text(f"/debug toggle failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
return _coming_soon(
|
||||
"debug",
|
||||
hint="set `VIBE_TRADING_DEBUG=1` and restart for verbose logging.",
|
||||
)
|
||||
|
||||
|
||||
def cmd_quit(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""Signal exit by returning a distinct exit code.
|
||||
|
||||
The interactive loop interprets ``2`` as "user-requested quit" and
|
||||
performs session persistence + final farewell before terminating.
|
||||
"""
|
||||
return 2
|
||||
|
||||
|
||||
# --- Module-level dispatch table -----------------------------------------
|
||||
|
||||
_DISPATCH = {
|
||||
"model": cmd_model,
|
||||
"clear": cmd_clear,
|
||||
"journal": cmd_journal,
|
||||
"shadow": cmd_shadow,
|
||||
"swarm": cmd_swarm,
|
||||
"debug": cmd_debug,
|
||||
"quit": cmd_quit,
|
||||
}
|
||||
|
||||
|
||||
def run(ctx: Any = None, command: str = "model", *args: str) -> int:
|
||||
"""Dispatch ``run("model")`` / ``run("clear")`` / ... to the right handler."""
|
||||
handler = _DISPATCH.get(command)
|
||||
if handler is None:
|
||||
console = _resolve_console()
|
||||
console.print(Text(f"Unknown chat command: /{command}", style="bold red"))
|
||||
return 1
|
||||
return handler(ctx, *args)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"run",
|
||||
"cmd_model",
|
||||
"cmd_clear",
|
||||
"cmd_journal",
|
||||
"cmd_shadow",
|
||||
"cmd_swarm",
|
||||
"cmd_debug",
|
||||
"cmd_quit",
|
||||
]
|
||||
@@ -0,0 +1,400 @@
|
||||
"""``/goal`` — manage the current finance research goal from the CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from rich import box
|
||||
from rich.console import Console
|
||||
from rich.console import Group
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from cli.theme import get_console
|
||||
from src.goal.context import default_goal_criteria
|
||||
|
||||
_goal_store = None
|
||||
|
||||
|
||||
def _resolve_console() -> Console:
|
||||
"""Return the shared CLI console."""
|
||||
return get_console()
|
||||
|
||||
|
||||
def _get_goal_store():
|
||||
"""Return the shared goal store, lazily initialized."""
|
||||
global _goal_store
|
||||
if _goal_store is None:
|
||||
from src.goal import GoalStore
|
||||
|
||||
_goal_store = GoalStore()
|
||||
return _goal_store
|
||||
|
||||
|
||||
def _default_criteria() -> list[str]:
|
||||
"""Return the MVP finance protocol checklist."""
|
||||
return default_goal_criteria()
|
||||
|
||||
|
||||
def _criterion_is_covered(criterion: dict, evidence: list[dict]) -> bool:
|
||||
"""Return whether a criterion has completion status or attached evidence."""
|
||||
status = str(criterion.get("status") or "").lower()
|
||||
if status not in {"", "pending", "open", "unsatisfied"}:
|
||||
return True
|
||||
criterion_id = criterion.get("criterion_id")
|
||||
return any(item.get("criterion_id") == criterion_id for item in evidence)
|
||||
|
||||
|
||||
def _create_cli_session(ctx: Any, title: str) -> str | None:
|
||||
"""Create a normal CLI session when /goal is used before the first turn."""
|
||||
try:
|
||||
from cli._legacy import SESSIONS_DIR
|
||||
from src.session.models import Session, SessionStatus
|
||||
from src.session.search import get_shared_index
|
||||
from src.session.store import SessionStore
|
||||
|
||||
session = Session(
|
||||
title=(title[:60] or "Goal research"),
|
||||
status=SessionStatus.ACTIVE,
|
||||
)
|
||||
SessionStore(base_dir=SESSIONS_DIR).create_session(session)
|
||||
get_shared_index().index_session(session.session_id, session.title)
|
||||
if ctx is not None:
|
||||
setattr(ctx, "session_id", session.session_id)
|
||||
return session.session_id
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def _session_id(ctx: Any, *, title: str = "Goal research", create: bool) -> str | None:
|
||||
"""Return the active CLI session id, optionally creating one."""
|
||||
existing = getattr(ctx, "session_id", None)
|
||||
if existing:
|
||||
return str(existing)
|
||||
env_session_id = os.getenv("VIBE_GOAL_SESSION_ID")
|
||||
if env_session_id:
|
||||
return env_session_id
|
||||
if not create:
|
||||
return None
|
||||
created = _create_cli_session(ctx, title)
|
||||
if created:
|
||||
return created
|
||||
return "cli-default"
|
||||
|
||||
|
||||
def _render_snapshot(snapshot: dict, *, title: str = "/goal") -> None:
|
||||
"""Render a compact goal card."""
|
||||
console = _resolve_console()
|
||||
goal = snapshot["goal"]
|
||||
criteria = snapshot.get("criteria") or []
|
||||
evidence = snapshot.get("evidence") or []
|
||||
evidence_count = int(snapshot.get("evidence_count", len(evidence)))
|
||||
covered = sum(1 for item in criteria if _criterion_is_covered(item, evidence))
|
||||
total = len(criteria)
|
||||
|
||||
meta = Table.grid(padding=(0, 2))
|
||||
meta.add_column(style="dim", no_wrap=True)
|
||||
meta.add_column(ratio=1)
|
||||
meta.add_column(style="dim", no_wrap=True)
|
||||
meta.add_column(no_wrap=True)
|
||||
meta.add_row("goal", str(goal["objective"]), "status", f"[green]{goal['status']}[/green]")
|
||||
meta.add_row("id", str(goal["goal_id"]), "progress", f"[cyan]{covered}/{total}[/cyan]")
|
||||
meta.add_row("evidence", str(evidence_count), "protocol", str(goal.get("protocol", "thesis_review")))
|
||||
|
||||
criteria_table = Table(
|
||||
box=box.SIMPLE_HEAVY,
|
||||
show_lines=False,
|
||||
expand=True,
|
||||
padding=(0, 1),
|
||||
)
|
||||
criteria_table.add_column("#", style="dim", no_wrap=True, width=3)
|
||||
criteria_table.add_column("Criterion", ratio=1)
|
||||
criteria_table.add_column("Status", no_wrap=True)
|
||||
criteria_table.add_column("Evidence", no_wrap=True)
|
||||
for index, criterion in enumerate(criteria, start=1):
|
||||
criterion_id = criterion["criterion_id"]
|
||||
criterion_evidence = [item for item in evidence if item.get("criterion_id") == criterion_id]
|
||||
is_covered = _criterion_is_covered(criterion, evidence)
|
||||
status = "[green]covered[/green]" if is_covered else "[yellow]pending[/yellow]"
|
||||
evidence_label = (
|
||||
f"{len(criterion_evidence)} item{'s' if len(criterion_evidence) != 1 else ''}"
|
||||
if criterion_evidence
|
||||
else "[dim]needed[/dim]"
|
||||
)
|
||||
criteria_table.add_row(
|
||||
str(index),
|
||||
str(criterion["text"]),
|
||||
status,
|
||||
evidence_label,
|
||||
)
|
||||
if not criteria:
|
||||
criteria_table.add_row("-", "[dim](none)[/dim]", "[dim]pending[/dim]", "[dim]needed[/dim]")
|
||||
|
||||
next_steps = Text()
|
||||
next_steps.append("Next ", style="dim")
|
||||
next_steps.append("/goal status", style="bold")
|
||||
if criteria:
|
||||
next_steps.append(" ")
|
||||
next_steps.append("/goal evidence <#> <note>", style="bold")
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
Group(meta, criteria_table, next_steps),
|
||||
title=title,
|
||||
border_style="cyan",
|
||||
padding=(1, 2),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def cmd_status(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""Show the current goal snapshot."""
|
||||
session_id = _session_id(ctx, create=False)
|
||||
if session_id is None:
|
||||
_resolve_console().print(Text("No current goal. Use /goal <objective> to start one.", style="dim"))
|
||||
return 0
|
||||
snapshot = _get_goal_store().get_current_snapshot(session_id)
|
||||
if snapshot is None:
|
||||
_resolve_console().print(Text("No current goal. Use /goal <objective> to start one.", style="dim"))
|
||||
return 0
|
||||
_render_snapshot(snapshot)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_start(ctx: Any = None, *args: str) -> int:
|
||||
"""Start or replace the current research goal."""
|
||||
objective = " ".join(args).strip()
|
||||
if not objective:
|
||||
_resolve_console().print(Text("Usage: /goal <research objective>", style="bold red"))
|
||||
return 1
|
||||
|
||||
session_id = _session_id(ctx, title=objective, create=True)
|
||||
if session_id is None:
|
||||
_resolve_console().print(Text("Could not create or resolve a session for /goal.", style="bold red"))
|
||||
return 1
|
||||
try:
|
||||
goal = _get_goal_store().replace_goal(
|
||||
session_id=session_id,
|
||||
objective=objective,
|
||||
criteria=_default_criteria(),
|
||||
source="cli",
|
||||
protocol="thesis_review",
|
||||
)
|
||||
except ValueError as exc:
|
||||
_resolve_console().print(Text(f"/goal failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
snapshot = _get_goal_store().get_goal_snapshot(goal.goal_id)
|
||||
if snapshot is None:
|
||||
_resolve_console().print(Text("Goal created but could not be reloaded.", style="bold red"))
|
||||
return 1
|
||||
_render_snapshot(snapshot, title="/goal started")
|
||||
return 0
|
||||
|
||||
|
||||
def _resolve_criterion_id(snapshot: dict, token: str) -> str:
|
||||
"""Resolve a criterion by 1-based index, exact id, or id prefix."""
|
||||
criteria = snapshot.get("criteria") or []
|
||||
if token.isdigit():
|
||||
index = int(token)
|
||||
if 1 <= index <= len(criteria):
|
||||
return str(criteria[index - 1]["criterion_id"])
|
||||
raise ValueError(f"criterion index out of range: {token}")
|
||||
|
||||
matches = [
|
||||
item["criterion_id"]
|
||||
for item in criteria
|
||||
if item["criterion_id"] == token or str(item["criterion_id"]).startswith(token)
|
||||
]
|
||||
if len(matches) == 1:
|
||||
return str(matches[0])
|
||||
if not matches:
|
||||
raise ValueError(f"unknown criterion: {token}")
|
||||
raise ValueError(f"ambiguous criterion prefix: {token}")
|
||||
|
||||
|
||||
def cmd_evidence(ctx: Any = None, *args: str) -> int:
|
||||
"""Append a manual evidence note to the current goal."""
|
||||
if len(args) < 2:
|
||||
_resolve_console().print(Text("Usage: /goal evidence <criterion-index-or-id> <note>", style="bold red"))
|
||||
return 1
|
||||
|
||||
session_id = _session_id(ctx, create=False)
|
||||
if session_id is None:
|
||||
_resolve_console().print(Text("No current goal. Use /goal <objective> first.", style="bold red"))
|
||||
return 1
|
||||
snapshot = _get_goal_store().get_current_snapshot(session_id)
|
||||
if snapshot is None:
|
||||
_resolve_console().print(Text("No current goal. Use /goal <objective> first.", style="bold red"))
|
||||
return 1
|
||||
|
||||
criterion_token, *text_parts = args
|
||||
text = " ".join(text_parts).strip()
|
||||
if not text:
|
||||
_resolve_console().print(Text("Evidence note cannot be empty.", style="bold red"))
|
||||
return 1
|
||||
|
||||
try:
|
||||
criterion_id = _resolve_criterion_id(snapshot, criterion_token)
|
||||
from src.goal import EvidenceInput
|
||||
|
||||
_get_goal_store().append_evidence(
|
||||
session_id=session_id,
|
||||
goal_id=snapshot["goal"]["goal_id"],
|
||||
expected_goal_id=snapshot["goal"]["goal_id"],
|
||||
evidence=EvidenceInput(
|
||||
criterion_id=criterion_id,
|
||||
text=text,
|
||||
source_provider="cli",
|
||||
source_type="manual_note",
|
||||
),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_resolve_console().print(Text(f"/goal evidence failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
|
||||
updated = _get_goal_store().get_goal_snapshot(snapshot["goal"]["goal_id"])
|
||||
if updated is not None:
|
||||
_render_snapshot(updated, title="/goal evidence added")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_cancel(ctx: Any = None, *args: str) -> int:
|
||||
"""Cancel the current research goal."""
|
||||
session_id = _session_id(ctx, create=False)
|
||||
if session_id is None:
|
||||
_resolve_console().print(Text("No current goal. Use /goal <objective> first.", style="dim"))
|
||||
return 0
|
||||
snapshot = _get_goal_store().get_current_snapshot(session_id)
|
||||
if snapshot is None:
|
||||
_resolve_console().print(Text("No current goal. Use /goal <objective> first.", style="dim"))
|
||||
return 0
|
||||
|
||||
recap = " ".join(args).strip() or "Cancelled from CLI."
|
||||
try:
|
||||
from src.goal import GoalStatus
|
||||
|
||||
updated = _get_goal_store().update_status(
|
||||
session_id=session_id,
|
||||
goal_id=snapshot["goal"]["goal_id"],
|
||||
expected_goal_id=snapshot["goal"]["goal_id"],
|
||||
status=GoalStatus.CANCELLED,
|
||||
recap=recap,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_resolve_console().print(Text(f"/goal cancel failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
|
||||
terminal_snapshot = _get_goal_store().get_goal_snapshot(updated.goal_id)
|
||||
if terminal_snapshot is not None:
|
||||
_render_snapshot(terminal_snapshot, title="/goal cancelled")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_complete(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""Complete the current research goal after auditing verified evidence."""
|
||||
session_id = _session_id(ctx, create=False)
|
||||
if session_id is None:
|
||||
_resolve_console().print(Text("No current goal. Use /goal <objective> first.", style="dim"))
|
||||
return 0
|
||||
snapshot = _get_goal_store().get_current_snapshot(session_id)
|
||||
if snapshot is None:
|
||||
_resolve_console().print(Text("No current goal. Use /goal <objective> first.", style="dim"))
|
||||
return 0
|
||||
|
||||
evidence = snapshot.get("evidence") or []
|
||||
audit_rows = []
|
||||
for criterion in snapshot.get("criteria") or []:
|
||||
criterion_evidence = [
|
||||
item
|
||||
for item in evidence
|
||||
if item.get("criterion_id") == criterion["criterion_id"]
|
||||
and item.get("verification_status") == "verified"
|
||||
]
|
||||
if not criterion_evidence:
|
||||
_resolve_console().print(
|
||||
Text(
|
||||
"Cannot complete: every criterion needs verified run/artifact evidence.",
|
||||
style="bold red",
|
||||
)
|
||||
)
|
||||
return 1
|
||||
from src.goal import AuditRow
|
||||
|
||||
audit_rows.append(
|
||||
AuditRow(
|
||||
criterion_id=criterion["criterion_id"],
|
||||
result="satisfied",
|
||||
evidence_ids=[item["evidence_id"] for item in criterion_evidence],
|
||||
notes="Verified evidence attached from CLI audit.",
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
from src.goal import GoalStatus
|
||||
|
||||
updated = _get_goal_store().update_status(
|
||||
session_id=session_id,
|
||||
goal_id=snapshot["goal"]["goal_id"],
|
||||
expected_goal_id=snapshot["goal"]["goal_id"],
|
||||
status=GoalStatus.COMPLETE,
|
||||
audit=audit_rows,
|
||||
recap=" ".join(args).strip() or "Completed from CLI audit.",
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_resolve_console().print(Text(f"/goal complete failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
|
||||
terminal_snapshot = _get_goal_store().get_goal_snapshot(updated.goal_id)
|
||||
if terminal_snapshot is not None:
|
||||
_render_snapshot(terminal_snapshot, title="/goal completed")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_help() -> int:
|
||||
"""Render /goal usage."""
|
||||
body = Text()
|
||||
body.append("/goal <objective>", style="bold")
|
||||
body.append(" start or replace the current research goal\n", style="dim")
|
||||
body.append("/goal status", style="bold")
|
||||
body.append(" show the current goal snapshot\n", style="dim")
|
||||
body.append("/goal evidence <criterion-index-or-id> <note>", style="bold")
|
||||
body.append(" append manual evidence\n", style="dim")
|
||||
body.append("/goal complete [recap]", style="bold")
|
||||
body.append(" complete after verified evidence audit\n", style="dim")
|
||||
body.append("/goal cancel [recap]", style="bold")
|
||||
body.append(" cancel the current goal\n", style="dim")
|
||||
_resolve_console().print(Panel(body, title="/goal", border_style="dim", padding=(1, 2)))
|
||||
return 0
|
||||
|
||||
|
||||
def run(ctx: Any = None, *args: str) -> int:
|
||||
"""Dispatch /goal subcommands."""
|
||||
if not args:
|
||||
return cmd_status(ctx)
|
||||
|
||||
command = args[0].lower()
|
||||
if command in {"help", "-h", "--help"}:
|
||||
return cmd_help()
|
||||
if command in {"status", "show"}:
|
||||
return cmd_status(ctx, *args[1:])
|
||||
if command == "start":
|
||||
return cmd_start(ctx, *args[1:])
|
||||
if command == "evidence":
|
||||
return cmd_evidence(ctx, *args[1:])
|
||||
if command == "complete":
|
||||
return cmd_complete(ctx, *args[1:])
|
||||
if command in {"cancel", "cancelled"}:
|
||||
return cmd_cancel(ctx, *args[1:])
|
||||
return cmd_start(ctx, *args)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"run",
|
||||
"cmd_start",
|
||||
"cmd_status",
|
||||
"cmd_evidence",
|
||||
"cmd_complete",
|
||||
"cmd_cancel",
|
||||
]
|
||||
@@ -0,0 +1,64 @@
|
||||
"""``/help`` — Show the slash command list and keyboard shortcuts.
|
||||
|
||||
Renders ``SLASH_COMMANDS`` as a two-column Rich table (name in primary
|
||||
color, description muted) plus a short keyboard-shortcuts panel.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from .slash_router import SLASH_COMMANDS
|
||||
|
||||
|
||||
def _resolve_console() -> Console:
|
||||
"""Return the shared CLI console."""
|
||||
from cli.theme import get_console
|
||||
|
||||
return get_console()
|
||||
|
||||
|
||||
_SHORTCUTS: tuple[tuple[str, str], ...] = (
|
||||
("⏎", "Send"),
|
||||
("Shift+⏎", "Newline"),
|
||||
("Tab", "Accept completion"),
|
||||
("↑/↓", "Browse history (or navigate completions)"),
|
||||
("Ctrl+C", "Clear input · then exit hint"),
|
||||
("Ctrl+D", "Exit (auto-saves session)"),
|
||||
("/", "Open slash command typeahead"),
|
||||
)
|
||||
|
||||
|
||||
def run(ctx: Any = None, *args: str) -> int: # noqa: ARG001 — ctx unused here
|
||||
"""Print the help screen. Always returns 0."""
|
||||
console = _resolve_console()
|
||||
|
||||
commands_table = Table.grid(padding=(0, 2))
|
||||
commands_table.add_column(style="bold #d97706", no_wrap=True)
|
||||
commands_table.add_column(style="dim")
|
||||
for cmd in SLASH_COMMANDS:
|
||||
commands_table.add_row(f"/{cmd.name}", cmd.description)
|
||||
|
||||
console.print()
|
||||
console.print(Text("Commands", style="bold"))
|
||||
console.print(commands_table)
|
||||
console.print()
|
||||
|
||||
shortcuts_table = Table.grid(padding=(0, 2))
|
||||
shortcuts_table.add_column(style="bold", no_wrap=True)
|
||||
shortcuts_table.add_column(style="dim")
|
||||
for key, desc in _SHORTCUTS:
|
||||
shortcuts_table.add_row(key, desc)
|
||||
|
||||
console.print(Text("Keyboard", style="bold"))
|
||||
console.print(shortcuts_table)
|
||||
console.print()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
__all__ = ["run"]
|
||||
@@ -0,0 +1,67 @@
|
||||
"""``/memory`` — list / show persistent memory snippets.
|
||||
|
||||
Delegates to the legacy ``cmd_memory_list`` / ``cmd_memory_show`` /
|
||||
``cmd_memory_search`` callables. ``/memory`` with no args lists; with
|
||||
a single arg shows one; with ``search <q>`` runs FTS; with
|
||||
``forget <name>`` deletes one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
|
||||
from cli.theme import get_console
|
||||
|
||||
|
||||
def _resolve_console() -> Console:
|
||||
"""Return the shared CLI console."""
|
||||
return get_console()
|
||||
|
||||
|
||||
def cmd_memory(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""Dispatch sub-actions:
|
||||
|
||||
* ``/memory`` → list
|
||||
* ``/memory <name>`` → show
|
||||
* ``/memory search <q>`` → full-text search
|
||||
* ``/memory forget <name>``→ delete a snippet
|
||||
"""
|
||||
console = _resolve_console()
|
||||
|
||||
if not args:
|
||||
try:
|
||||
from cli._legacy import cmd_memory_list
|
||||
|
||||
return cmd_memory_list()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(Text(f"/memory list failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
|
||||
sub = args[0]
|
||||
rest = args[1:]
|
||||
try:
|
||||
if sub == "search" and rest:
|
||||
from cli._legacy import cmd_memory_search
|
||||
|
||||
return cmd_memory_search(" ".join(rest))
|
||||
if sub == "forget" and rest:
|
||||
from cli._legacy import cmd_memory_forget
|
||||
|
||||
return cmd_memory_forget(rest[0], yes=False)
|
||||
from cli._legacy import cmd_memory_show
|
||||
|
||||
return cmd_memory_show(sub)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(Text(f"/memory failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
|
||||
|
||||
def run(ctx: Any = None, *args: str) -> int:
|
||||
"""Single-entrypoint wrapper for the slash router."""
|
||||
return cmd_memory(ctx, *args)
|
||||
|
||||
|
||||
__all__ = ["run", "cmd_memory"]
|
||||
@@ -0,0 +1,98 @@
|
||||
"""``/history``, ``/search``, ``/export`` — session lifecycle commands.
|
||||
|
||||
* ``/history`` lists recent sessions (legacy ``cmd_sessions``).
|
||||
* ``/search <query>`` runs FTS5 across the session store via
|
||||
:class:`src.session.search.SessionSearchIndex`.
|
||||
* ``/export`` is a placeholder — the rich export lives in the Web bubble.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from cli.theme import get_console
|
||||
|
||||
|
||||
def _resolve_console() -> Console:
|
||||
"""Return the shared CLI console."""
|
||||
return get_console()
|
||||
|
||||
|
||||
def cmd_history(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""List recent sessions via legacy ``cmd_sessions``."""
|
||||
console = _resolve_console()
|
||||
try:
|
||||
from cli._legacy import cmd_sessions
|
||||
|
||||
cmd_sessions()
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(Text(f"/history failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_search(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""Full-text search across recorded sessions."""
|
||||
console = _resolve_console()
|
||||
if not args:
|
||||
console.print(Text("Usage: /search <query>", style="bold red"))
|
||||
return 1
|
||||
|
||||
query = " ".join(args)
|
||||
try:
|
||||
from src.session.search import get_shared_index
|
||||
|
||||
index = get_shared_index()
|
||||
matches = index.search(query)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(Text(f"/search failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
|
||||
if not matches:
|
||||
console.print(Text(f"No matches for '{query}'.", style="dim"))
|
||||
return 0
|
||||
|
||||
for hit in matches:
|
||||
line = Text()
|
||||
line.append(str(hit.session_id), style="bold")
|
||||
line.append(" ")
|
||||
line.append(hit.title, style="dim")
|
||||
line.append(" ")
|
||||
line.append(hit.snippet or "", style="dim")
|
||||
console.print(line)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_export(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""Placeholder until the rich export lands."""
|
||||
console = _resolve_console()
|
||||
body = Text()
|
||||
body.append("/export is not yet wired up to the interactive CLI.\n\n", style="dim")
|
||||
body.append("Until then: ", style="dim")
|
||||
body.append("the web UI exports md/json from the message footer.", style="bold")
|
||||
console.print(Panel(body, title="/export", border_style="dim", padding=(1, 2)))
|
||||
return 0
|
||||
|
||||
|
||||
_DISPATCH = {
|
||||
"history": cmd_history,
|
||||
"search": cmd_search,
|
||||
"export": cmd_export,
|
||||
}
|
||||
|
||||
|
||||
def run(ctx: Any = None, command: str = "history", *args: str) -> int:
|
||||
"""Dispatch ``run("history")`` / ``run("search", ...)`` / ``run("export")``."""
|
||||
handler = _DISPATCH.get(command)
|
||||
if handler is None:
|
||||
console = _resolve_console()
|
||||
console.print(Text(f"Unknown session command: /{command}", style="bold red"))
|
||||
return 1
|
||||
return handler(ctx, *args)
|
||||
|
||||
|
||||
__all__ = ["run", "cmd_history", "cmd_search", "cmd_export"]
|
||||
@@ -0,0 +1,86 @@
|
||||
"""``/show``, ``/skill``, ``/pine`` — read-only inspect commands.
|
||||
|
||||
All three already exist as fully-featured ``cmd_*`` callables in
|
||||
``cli._legacy``; this module is a thin shim so the slash router can
|
||||
dispatch by command name without importing the legacy module top-level
|
||||
(which is heavy — it pulls in providers, swarm, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
|
||||
from cli.theme import get_console
|
||||
|
||||
|
||||
def _resolve_console() -> Console:
|
||||
"""Return the shared CLI console."""
|
||||
return get_console()
|
||||
|
||||
|
||||
def cmd_show(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""``/show <run_id>`` — replay a prior run via the legacy handler."""
|
||||
console = _resolve_console()
|
||||
if not args:
|
||||
console.print(Text("Usage: /show <run_id>", style="bold red"))
|
||||
return 1
|
||||
try:
|
||||
from cli._legacy import cmd_show as legacy_show
|
||||
|
||||
legacy_show(args[0])
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(Text(f"/show failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_skill(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""``/skill`` — list bundled + user skills (legacy ``cmd_skills``)."""
|
||||
console = _resolve_console()
|
||||
try:
|
||||
from cli._legacy import cmd_skills
|
||||
|
||||
cmd_skills()
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(Text(f"/skill failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_pine(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""``/pine <run_id>`` — emit Pine Script for a prior backtest."""
|
||||
console = _resolve_console()
|
||||
if not args:
|
||||
console.print(Text("Usage: /pine <run_id>", style="bold red"))
|
||||
return 1
|
||||
try:
|
||||
from cli._legacy import cmd_pine as legacy_pine
|
||||
|
||||
legacy_pine(args[0])
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(Text(f"/pine failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
|
||||
|
||||
_DISPATCH = {
|
||||
"show": cmd_show,
|
||||
"skill": cmd_skill,
|
||||
"pine": cmd_pine,
|
||||
}
|
||||
|
||||
|
||||
def run(ctx: Any = None, command: str = "show", *args: str) -> int:
|
||||
"""Dispatch ``run("show", run_id)`` / ``run("skill")`` etc."""
|
||||
handler = _DISPATCH.get(command)
|
||||
if handler is None:
|
||||
console = _resolve_console()
|
||||
console.print(Text(f"Unknown show command: /{command}", style="bold red"))
|
||||
return 1
|
||||
return handler(ctx, *args)
|
||||
|
||||
|
||||
__all__ = ["run", "cmd_show", "cmd_skill", "cmd_pine"]
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Slash command registry + fuzzy matcher.
|
||||
|
||||
Source of truth for the 15 user-facing slash commands defined in
|
||||
``docs/2026-05-19_session02_uiux_design_proposal.md`` §3.4.
|
||||
|
||||
Each :class:`Command` is a frozen dataclass — registry entries are
|
||||
immutable so callers can cache filtered slices without worrying about
|
||||
mutation. ``match_commands`` powers the typeahead in
|
||||
:mod:`agent.cli.completer`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Command:
|
||||
"""A single slash command entry.
|
||||
|
||||
Attributes:
|
||||
name: The command keyword, without leading ``/``.
|
||||
description: One-line muted description shown in the completion menu.
|
||||
handler_module: Dotted path to the module exposing ``run(ctx, *args)``.
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
handler_module: str
|
||||
|
||||
|
||||
# Order here = order shown in bare ``/`` typeahead. Group by frequency so the
|
||||
# most-used commands surface first (help/model/memory/history are top).
|
||||
SLASH_COMMANDS: tuple[Command, ...] = (
|
||||
Command("help", "Show keyboard shortcuts and command list", "cli.commands.help"),
|
||||
Command("model", "Switch LLM provider and model", "cli.commands.chat"),
|
||||
Command("memory", "Show / manage persistent memory", "cli.commands.memory"),
|
||||
Command("history", "Browse and resume prior sessions", "cli.commands.session"),
|
||||
Command("goal", "Start / inspect a finance research goal", "cli.commands.goal"),
|
||||
Command("search", "Full-text search across all sessions", "cli.commands.session"),
|
||||
Command("swarm", "Multi-agent presets (committee / quant / risk)", "cli.commands.chat"),
|
||||
Command("skill", "List / load / unload skills", "cli.commands.show"),
|
||||
Command("show", "Show prior run by id", "cli.commands.show"),
|
||||
Command("clear", "Clear current conversation", "cli.commands.chat"),
|
||||
Command("pine", "Export current strategy as Pine Script", "cli.commands.show"),
|
||||
Command("journal", "Analyze trade journal CSV", "cli.commands.chat"),
|
||||
Command("shadow", "Train / view shadow account", "cli.commands.chat"),
|
||||
Command("export", "Export current session (md / json)", "cli.commands.session"),
|
||||
Command("debug", "Toggle debug panel (token usage / latency)", "cli.commands.chat"),
|
||||
Command("quit", "Exit (also: q, exit, :q)", "cli.commands.chat"),
|
||||
)
|
||||
|
||||
|
||||
# Aliases — same handler, different surface keyword. Keep separate from the
|
||||
# main registry so typeahead does not duplicate rows.
|
||||
_ALIASES: dict[str, str] = {
|
||||
"q": "quit",
|
||||
"exit": "quit",
|
||||
":q": "quit",
|
||||
"?": "help",
|
||||
}
|
||||
|
||||
|
||||
def _parse_token(input_text: str) -> str:
|
||||
"""Strip the leading ``/`` and isolate the command token.
|
||||
|
||||
>>> _parse_token("/me arg")
|
||||
'me'
|
||||
>>> _parse_token("/")
|
||||
''
|
||||
>>> _parse_token("not a slash")
|
||||
''
|
||||
"""
|
||||
text = input_text.lstrip()
|
||||
if not text.startswith("/"):
|
||||
return ""
|
||||
# ``/foo bar`` → ``foo``. Use ``split(None, 1)`` to handle any whitespace.
|
||||
parts = text[1:].split(None, 1)
|
||||
return parts[0] if parts else ""
|
||||
|
||||
|
||||
def _fuzzy_score(needle: str, name: str) -> int:
|
||||
"""Return a heuristic score for ``needle`` matching ``name``.
|
||||
|
||||
Higher = better. ``0`` means no match.
|
||||
|
||||
Scoring order (boundary-friendly, like dexter's slash matcher):
|
||||
prefix match → 100 + length bonus
|
||||
substring match → 50 + length bonus
|
||||
subsequence match (chars in order) → 10 + chars matched
|
||||
otherwise → 0
|
||||
"""
|
||||
if not needle:
|
||||
return 1 # bare ``/`` shows everything in registry order
|
||||
needle_l = needle.lower()
|
||||
name_l = name.lower()
|
||||
if name_l.startswith(needle_l):
|
||||
return 100 + len(needle_l)
|
||||
if needle_l in name_l:
|
||||
return 50 + len(needle_l)
|
||||
# subsequence match: every char of needle appears in order inside name
|
||||
j = 0
|
||||
for ch in name_l:
|
||||
if j < len(needle_l) and ch == needle_l[j]:
|
||||
j += 1
|
||||
if j == len(needle_l):
|
||||
return 10 + j
|
||||
return 0
|
||||
|
||||
|
||||
def match_commands(input_text: str) -> list[Command]:
|
||||
"""Return commands that fuzzy-match ``input_text``.
|
||||
|
||||
Only matches when the trimmed input starts with ``/``. The leading slash
|
||||
and any trailing arguments are stripped before scoring; only the command
|
||||
token participates in the match.
|
||||
|
||||
Result is ordered best-match first, ties broken by registry order.
|
||||
|
||||
Args:
|
||||
input_text: Raw input line, possibly empty.
|
||||
|
||||
Returns:
|
||||
A new list — callers may freely mutate without side effects.
|
||||
"""
|
||||
token = _parse_token(input_text)
|
||||
if not input_text.lstrip().startswith("/"):
|
||||
return []
|
||||
|
||||
# Resolve aliases up front so ``/q`` shows the ``quit`` row.
|
||||
if token in _ALIASES:
|
||||
token = _ALIASES[token]
|
||||
|
||||
scored: list[tuple[int, int, Command]] = []
|
||||
for idx, cmd in enumerate(SLASH_COMMANDS):
|
||||
score = _fuzzy_score(token, cmd.name)
|
||||
if score > 0:
|
||||
# Negative idx so ties prefer earlier registry position when
|
||||
# sorted descending by score.
|
||||
scored.append((score, -idx, cmd))
|
||||
scored.sort(reverse=True)
|
||||
return [cmd for _score, _idx, cmd in scored]
|
||||
|
||||
|
||||
def find_exact(name: str) -> Command | None:
|
||||
"""Resolve an exact command name or alias.
|
||||
|
||||
Returns ``None`` if no match — callers handle the "unknown command"
|
||||
response themselves so error UX stays consistent.
|
||||
"""
|
||||
key = name.lstrip("/").strip()
|
||||
key = _ALIASES.get(key, key)
|
||||
for cmd in SLASH_COMMANDS:
|
||||
if cmd.name == key:
|
||||
return cmd
|
||||
return None
|
||||
@@ -0,0 +1,115 @@
|
||||
"""FuzzyCompleter for slash commands.
|
||||
|
||||
Returns completions only when the line starts with ``/`` so normal prose
|
||||
typing is not polluted by suggestions. Each completion advertises the
|
||||
command description in muted style with the command name in the brand
|
||||
primary color (sourced from :mod:`agent.cli.theme` when available; falls
|
||||
back to ``ansibrightyellow`` so the file is importable before Parcel α
|
||||
ships ``theme.py``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
|
||||
from prompt_toolkit.document import Document
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
|
||||
from .commands.slash_router import SLASH_COMMANDS, Command, match_commands
|
||||
|
||||
|
||||
def _primary_style() -> str:
|
||||
"""Resolve the brand-orange ``style`` string for prompt_toolkit.
|
||||
|
||||
Rich and prompt_toolkit do not share a style grammar, so we read the
|
||||
brand hex from :mod:`cli.theme` (the single source of truth) and
|
||||
rewrite it for prompt_toolkit's ``fg:#xxxxxx`` syntax. NO_COLOR users
|
||||
pick the no-op style so completions remain visible without color.
|
||||
"""
|
||||
try:
|
||||
from cli.theme import Theme
|
||||
|
||||
brand = getattr(Theme, "brand_hex", None)
|
||||
if isinstance(brand, str) and brand:
|
||||
return f"fg:{brand} bold"
|
||||
except Exception: # noqa: BLE001 — never block typeahead on theme import
|
||||
pass
|
||||
# Fallback approximates brand orange in 256-color terminals.
|
||||
return "fg:#d97706 bold"
|
||||
|
||||
|
||||
def _muted_style() -> str:
|
||||
"""Resolve the muted ``style`` string for prompt_toolkit descriptions."""
|
||||
return "fg:#9ca3af"
|
||||
|
||||
|
||||
class SlashCompleter(Completer):
|
||||
"""prompt_toolkit completer that fuzzy-matches the slash registry.
|
||||
|
||||
Behaviour mirrors dexter's slash typeahead: bare ``/`` lists everything,
|
||||
``/m`` filters by prefix/substring/subsequence. Completions are emitted
|
||||
only when the cursor sits on the first token of the line — once the user
|
||||
types ``/help `` (with a trailing space) we get out of the way so they
|
||||
can fill in arguments without the menu re-popping.
|
||||
"""
|
||||
|
||||
def __init__(self, commands: Iterable[Command] = SLASH_COMMANDS) -> None:
|
||||
# Snapshot the registry; commands is a tuple of frozen dataclasses
|
||||
# so the snapshot itself is effectively immutable.
|
||||
self._commands = tuple(commands)
|
||||
|
||||
# ----------------------------------------------------------------- API
|
||||
def get_completions(
|
||||
self,
|
||||
document: Document,
|
||||
complete_event: CompleteEvent,
|
||||
) -> Iterable[Completion]:
|
||||
text = document.text_before_cursor
|
||||
stripped = text.lstrip()
|
||||
|
||||
# Bail unless the line starts with ``/`` AND we are still typing the
|
||||
# command token (no space yet). This means ``/help <enter args>``
|
||||
# stops triggering completions once the user moves past the keyword.
|
||||
if not stripped.startswith("/"):
|
||||
return
|
||||
# Find the slash position (the leading whitespace is irrelevant once
|
||||
# we have confirmed it leads with ``/``).
|
||||
slash_idx = text.index("/")
|
||||
token_zone = text[slash_idx + 1 :]
|
||||
if " " in token_zone or "\t" in token_zone:
|
||||
return
|
||||
|
||||
matches = match_commands(stripped)
|
||||
if not matches:
|
||||
return
|
||||
|
||||
# Compute the replacement window so the chosen completion overwrites
|
||||
# whatever the user typed after ``/``.
|
||||
start_position = -len(token_zone)
|
||||
|
||||
# Right-pad each command name so the description column lines up.
|
||||
name_width = max((len(c.name) for c in matches), default=0) + 2
|
||||
|
||||
primary = _primary_style()
|
||||
muted = _muted_style()
|
||||
|
||||
for cmd in matches:
|
||||
padded_name = cmd.name.ljust(name_width)
|
||||
display: FormattedText = FormattedText(
|
||||
[
|
||||
(primary, f"/{padded_name}"),
|
||||
(muted, cmd.description),
|
||||
]
|
||||
)
|
||||
# ``text`` is the value inserted on accept (no leading slash —
|
||||
# the slash is already in the buffer at ``slash_idx``).
|
||||
yield Completion(
|
||||
text=cmd.name,
|
||||
start_position=start_position,
|
||||
display=display,
|
||||
display_meta=cmd.description,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["SlashCompleter"]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Reusable CLI render components.
|
||||
|
||||
Each component returns a Rich renderable (Text / Panel / Table) so callers
|
||||
can ``console.print`` or stuff it into a Live group.
|
||||
"""
|
||||
|
||||
from .chat_log import render_history
|
||||
from .hint_bar import render_hint_bar
|
||||
from .tool_event import render_tool_event, render_tool_events
|
||||
from .working_indicator import ThinkingSpinner
|
||||
|
||||
__all__ = [
|
||||
"ThinkingSpinner",
|
||||
"render_history",
|
||||
"render_hint_bar",
|
||||
"render_tool_event",
|
||||
"render_tool_events",
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Minimal chat-log renderer — stub for the demo.
|
||||
|
||||
Iterates a list of turn dicts and prints user / assistant lines with the
|
||||
expected meta header. Defer the rich features (collapsible tool
|
||||
timeline, markdown rendering, action footer) to post-demo work — they
|
||||
belong in the Web bubble (Parcel C / D), not in the CLI replay.
|
||||
|
||||
Turn shape:
|
||||
{
|
||||
"role": "user" | "assistant",
|
||||
"content": str,
|
||||
"timestamp": str | None, # ISO or formatted clock
|
||||
"meta": str | None, # "Vibe · 4.1s · 1.2k tokens · $0.003"
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Mapping, Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
def _resolve_console(console: Optional[Console]) -> Console:
|
||||
"""Return ``console`` if given, else the shared CLI console."""
|
||||
if console is not None:
|
||||
return console
|
||||
from cli.theme import get_console
|
||||
|
||||
return get_console()
|
||||
|
||||
|
||||
def _render_turn(turn: Mapping[str, object]) -> Text:
|
||||
"""Compose the meta header line for one turn."""
|
||||
role = str(turn.get("role", "user"))
|
||||
timestamp = turn.get("timestamp")
|
||||
meta = turn.get("meta")
|
||||
header = Text()
|
||||
if role == "assistant":
|
||||
# "Vibe" is the brand wordmark for assistant turns — primary color
|
||||
header.append("Vibe", style="bold #d97706")
|
||||
else:
|
||||
header.append("you", style="bold")
|
||||
if timestamp:
|
||||
header.append(f" {timestamp}", style="dim")
|
||||
if meta:
|
||||
header.append(f" · {meta}", style="dim")
|
||||
return header
|
||||
|
||||
|
||||
def render_history(
|
||||
turns: Iterable[Mapping[str, object]],
|
||||
*,
|
||||
console: Optional[Console] = None,
|
||||
) -> None:
|
||||
"""Print past turns to ``console``.
|
||||
|
||||
Args:
|
||||
turns: Iterable of turn dicts (see module docstring for shape).
|
||||
console: Override; defaults to the shared CLI console.
|
||||
|
||||
No return value — this is a side-effecting renderer.
|
||||
"""
|
||||
cons = _resolve_console(console)
|
||||
for turn in turns:
|
||||
cons.print(_render_turn(turn))
|
||||
content = str(turn.get("content", ""))
|
||||
if content:
|
||||
# Body intentionally renders as plain text — markdown / katex
|
||||
# / syntax highlighting live in the Web bubble. The CLI keeps
|
||||
# the body monospaced for diff-friendly replay.
|
||||
cons.print(content)
|
||||
cons.print() # blank line between turns
|
||||
|
||||
|
||||
__all__ = ["render_history"]
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Bottom hint bar — left + right aligned.
|
||||
|
||||
Renders a single-line hint where the right-aligned segment hugs the
|
||||
terminal edge regardless of left-segment width. Mirrors dexter's
|
||||
``hint-bar.ts``.
|
||||
|
||||
Used to surface keyboard guidance ("Tab to complete · /help for
|
||||
commands") and ephemeral state ("input cleared", "cancelled").
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
def _terminal_width(default: int = 80) -> int:
|
||||
"""Best-effort terminal width detection (falls back gracefully)."""
|
||||
try:
|
||||
size = shutil.get_terminal_size((default, 20))
|
||||
return max(20, size.columns)
|
||||
except (OSError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def render_hint_bar(left: str, right: str = "", *, width: int | None = None) -> Text:
|
||||
"""Return a Rich :class:`Text` row with ``left`` and right-aligned ``right``.
|
||||
|
||||
The bar always fits the terminal width:
|
||||
* If ``left + right`` fits, pad with spaces between them
|
||||
* Otherwise truncate the left segment with an ellipsis so the
|
||||
right hint remains visible (right-side hint usually carries
|
||||
the *current* Ctrl+C semantics — losing it is worse than
|
||||
losing context)
|
||||
|
||||
Args:
|
||||
left: Left segment (e.g. ``"↑/↓ navigate · Tab/⏎ select"``).
|
||||
right: Right segment (e.g. ``"Esc to cancel"``); may be empty.
|
||||
width: Override terminal width. Defaults to autodetect.
|
||||
"""
|
||||
cols = width if width is not None else _terminal_width()
|
||||
if right == "":
|
||||
# Only a left hint — clip and pad to width, dim style throughout.
|
||||
text = Text(left[:cols], style="dim")
|
||||
return text
|
||||
|
||||
# Reserve at least one space between left and right segments.
|
||||
available_for_left = cols - len(right) - 1
|
||||
if available_for_left < 1:
|
||||
# Right segment alone is wider than the terminal — show right only.
|
||||
return Text(right[: cols].rjust(cols), style="dim")
|
||||
|
||||
if len(left) > available_for_left:
|
||||
left = left[: max(1, available_for_left - 1)].rstrip() + "…"
|
||||
|
||||
padding = cols - len(left) - len(right)
|
||||
if padding < 1:
|
||||
padding = 1
|
||||
bar = Text()
|
||||
bar.append(left, style="dim")
|
||||
bar.append(" " * padding)
|
||||
bar.append(right, style="dim")
|
||||
return bar
|
||||
|
||||
|
||||
__all__ = ["render_hint_bar"]
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Single tool invocation render.
|
||||
|
||||
Layout (matches design_proposal §3.5):
|
||||
|
||||
● Get Financials ("AAPL", quarterly, last 8 quarters) 1.4s · 8 quarters
|
||||
|
||||
* ``●`` (U+25CF black circle) marker, color-coded by status:
|
||||
running → ``--warning`` amber (pulse via Rich style ``blink`` when
|
||||
the terminal advertises it, otherwise solid amber)
|
||||
ok → ``--success`` green, solid
|
||||
error → ``--danger`` red, solid
|
||||
* Tool name pretty-printed (``get_financials`` → ``Get Financials``)
|
||||
* Args summary prefers ``query`` then ``prompt``, else the first 2
|
||||
``key=value`` pairs each truncated to 40 chars
|
||||
* Duration formatted via :func:`agent.cli.utils.format.format_duration`
|
||||
when Parcel α has shipped it; otherwise a local fallback is used
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable, Literal, Mapping
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
Status = Literal["running", "ok", "error"]
|
||||
|
||||
|
||||
# Status → (Rich style, marker glyph). The glyph stays the same; the style
|
||||
# encodes color + decoration (bold/blink). dexter only changes brightness
|
||||
# of the marker rather than swapping glyphs.
|
||||
_STATUS_STYLE: dict[Status, str] = {
|
||||
# ``blink`` is gracefully ignored by Rich when the terminal does not
|
||||
# advertise the capability, so we always include it.
|
||||
"running": "bold blink #f59e0b", # amber, F-002 warning
|
||||
"ok": "bold #16a34a", # green, F-002 success
|
||||
"error": "bold #dc2626", # red, F-002 danger
|
||||
}
|
||||
|
||||
_ARG_VALUE_MAX = 40
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- helpers ----
|
||||
|
||||
|
||||
def _pretty_tool_name(name: str) -> str:
|
||||
"""Convert ``get_financials`` to ``Get Financials``.
|
||||
|
||||
Strips a leading ``get_`` prefix when present (dexter convention).
|
||||
Underscores and hyphens become spaces; each whitespace-separated
|
||||
token is title-cased. Acronyms preserved when fully upper.
|
||||
"""
|
||||
cleaned = name.strip()
|
||||
if cleaned.lower().startswith("get_"):
|
||||
cleaned = cleaned[4:]
|
||||
tokens = cleaned.replace("-", " ").replace("_", " ").split()
|
||||
out: list[str] = []
|
||||
for tok in tokens:
|
||||
if tok.isupper() and len(tok) <= 4:
|
||||
out.append(tok)
|
||||
else:
|
||||
out.append(tok[:1].upper() + tok[1:].lower())
|
||||
return " ".join(out) if out else cleaned
|
||||
|
||||
|
||||
def _truncate(value: str, limit: int = _ARG_VALUE_MAX) -> str:
|
||||
if len(value) <= limit:
|
||||
return value
|
||||
return value[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def _summarize_args(args: Mapping[str, Any] | None) -> str:
|
||||
"""Produce a one-line args summary.
|
||||
|
||||
Rules:
|
||||
* ``args is None`` or empty → empty string
|
||||
* ``query`` field present → ``"<value>"`` (quoted)
|
||||
* ``prompt`` field present → ``"<value>"`` (quoted)
|
||||
* otherwise → first 2 ``key=value`` pairs, value truncated to 40 chars
|
||||
|
||||
Returns the bare summary string; callers wrap it in parens.
|
||||
"""
|
||||
if not args:
|
||||
return ""
|
||||
|
||||
# Prefer the well-known semantic keys
|
||||
for primary_key in ("query", "prompt"):
|
||||
if primary_key in args:
|
||||
return f'"{_truncate(str(args[primary_key]))}"'
|
||||
|
||||
pairs: list[str] = []
|
||||
for key, value in list(args.items())[:2]:
|
||||
rendered_value = _truncate(_render_value(value))
|
||||
pairs.append(f"{key}={rendered_value}")
|
||||
return ", ".join(pairs)
|
||||
|
||||
|
||||
def _render_value(value: Any) -> str:
|
||||
"""Compact ``repr``-ish formatting suitable for inline arg display."""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if value is None:
|
||||
return "null"
|
||||
if isinstance(value, (list, tuple)):
|
||||
# Show length to avoid blowing up the line on huge arrays.
|
||||
if len(value) <= 3:
|
||||
return "[" + ", ".join(_render_value(v) for v in value) + "]"
|
||||
return f"[{len(value)} items]"
|
||||
if isinstance(value, Mapping):
|
||||
return f"{{{len(value)} keys}}"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _format_duration_local(ms: float) -> str:
|
||||
"""Local fallback when ``agent.cli.utils.format.format_duration`` is absent.
|
||||
|
||||
Parcel α owns the canonical implementation; until it lands we keep a
|
||||
minimal version here so the demo build is not blocked on cross-parcel
|
||||
ordering. Numbers match the contract in design_proposal §3.5:
|
||||
|
||||
< 1000ms → "740ms"
|
||||
< 60s → "1.4s"
|
||||
≥ 60s → "1m12s"
|
||||
"""
|
||||
if ms < 1000:
|
||||
return f"{int(round(ms))}ms"
|
||||
seconds = ms / 1000
|
||||
if seconds < 60:
|
||||
return f"{seconds:.1f}s"
|
||||
minutes = int(seconds // 60)
|
||||
rem = int(seconds - minutes * 60)
|
||||
return f"{minutes}m{rem:02d}s"
|
||||
|
||||
|
||||
def _format_duration(ms: float) -> str:
|
||||
"""Delegate to the shared formatter; fall back locally on import error."""
|
||||
try:
|
||||
from cli.utils.format import format_duration
|
||||
|
||||
return format_duration(ms)
|
||||
except Exception: # noqa: BLE001 — never block render on helper issues
|
||||
return _format_duration_local(ms)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- public ----
|
||||
|
||||
|
||||
def render_tool_event(
|
||||
name: str,
|
||||
args: Mapping[str, Any] | None = None,
|
||||
status: Status = "running",
|
||||
duration_ms: float | None = None,
|
||||
*,
|
||||
result_summary: str | None = None,
|
||||
) -> Text:
|
||||
"""Return a Rich :class:`Text` row describing a single tool call.
|
||||
|
||||
Args:
|
||||
name: Raw tool name (``get_financials``); pretty-printed in output.
|
||||
args: Argument mapping; summarised inline.
|
||||
status: ``"running"`` | ``"ok"`` | ``"error"``.
|
||||
duration_ms: When the call finished, total wall time in milliseconds.
|
||||
result_summary: Optional one-line summary appended after the
|
||||
duration (e.g. ``"8 quarters"``).
|
||||
|
||||
Returns:
|
||||
A Rich ``Text`` object — caller decides whether to ``console.print``
|
||||
it, stuff it into a ``Group``, or stream it as a transient row.
|
||||
"""
|
||||
if status not in _STATUS_STYLE:
|
||||
# Be lenient — unknown statuses degrade to "running" instead of
|
||||
# raising; CLI rendering should never crash the agent loop.
|
||||
status = "running"
|
||||
|
||||
marker = Text("● ", style=_STATUS_STYLE[status])
|
||||
line = Text()
|
||||
line.append(marker)
|
||||
line.append(_pretty_tool_name(name), style="bold")
|
||||
|
||||
summary = _summarize_args(args)
|
||||
if summary:
|
||||
line.append(f" ({summary})", style="dim")
|
||||
|
||||
suffix_parts: list[str] = []
|
||||
if duration_ms is not None:
|
||||
suffix_parts.append(_format_duration(duration_ms))
|
||||
if result_summary:
|
||||
suffix_parts.append(result_summary)
|
||||
if suffix_parts:
|
||||
line.append(" " + " · ".join(suffix_parts), style="dim")
|
||||
|
||||
return line
|
||||
|
||||
|
||||
def render_tool_events(events: Iterable[Mapping[str, Any]]) -> list[Text]:
|
||||
"""Convenience: render a list of event dicts.
|
||||
|
||||
Each event dict needs ``name`` plus any of ``args`` / ``status`` /
|
||||
``duration_ms`` / ``result_summary``. Useful for replaying a stored
|
||||
run via :mod:`agent.cli.commands.show`.
|
||||
"""
|
||||
rendered: list[Text] = []
|
||||
for ev in events:
|
||||
rendered.append(
|
||||
render_tool_event(
|
||||
name=str(ev.get("name", "tool")),
|
||||
args=ev.get("args"),
|
||||
status=ev.get("status", "ok"),
|
||||
duration_ms=ev.get("duration_ms"),
|
||||
result_summary=ev.get("result_summary"),
|
||||
)
|
||||
)
|
||||
return rendered
|
||||
|
||||
|
||||
__all__ = ["render_tool_event", "render_tool_events"]
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Thinking spinner — picks a random verb on entry, hides on exit.
|
||||
|
||||
Wraps :class:`rich.live.Live` with ``transient=True`` so the spinner
|
||||
erases itself once the context exits — leaving the answer print
|
||||
uncluttered (the bug nanobot called out in their StreamRenderer
|
||||
docstring: transient=True avoids the "ghost spinner" duplication).
|
||||
|
||||
If :mod:`agent.cli.utils.thinking_verbs` has not landed yet (Parcel α
|
||||
ships it) we fall back to ``"Pondering…"`` so the demo build still
|
||||
works.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from types import TracebackType
|
||||
from typing import Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.live import Live
|
||||
from rich.spinner import Spinner
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
_FALLBACK_VERBS: tuple[str, ...] = (
|
||||
"Pondering…",
|
||||
"Analyzing…",
|
||||
"Reasoning…",
|
||||
"Thinking…",
|
||||
"Investigating…",
|
||||
)
|
||||
|
||||
|
||||
def _pick_verb() -> str:
|
||||
"""Pick a thinking verb via the shared helper, with a local fallback."""
|
||||
try:
|
||||
from cli.utils.thinking_verbs import pick_thinking_verb
|
||||
|
||||
verb = pick_thinking_verb()
|
||||
if isinstance(verb, str) and verb:
|
||||
return verb
|
||||
except Exception: # noqa: BLE001 — never block the spinner on helper issues
|
||||
pass
|
||||
return random.choice(_FALLBACK_VERBS)
|
||||
|
||||
|
||||
def _resolve_console() -> Console:
|
||||
"""Return the shared CLI console."""
|
||||
from cli.theme import get_console
|
||||
|
||||
return get_console()
|
||||
|
||||
|
||||
class ThinkingSpinner:
|
||||
"""Context manager that shows a Rich spinner + thinking verb.
|
||||
|
||||
Usage:
|
||||
with ThinkingSpinner() as spinner:
|
||||
spinner.update_verb("Backtesting…") # optional mid-run swap
|
||||
... do agent work ...
|
||||
|
||||
The spinner is ``transient=True`` so it disappears when the block
|
||||
exits. Use :meth:`pause` to temporarily hide the spinner while
|
||||
printing auxiliary lines (e.g. a tool event row) — borrowed from
|
||||
nanobot's StreamRenderer pattern.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
verb: Optional[str] = None,
|
||||
*,
|
||||
console: Optional[Console] = None,
|
||||
spinner_name: str = "dots",
|
||||
) -> None:
|
||||
self._verb = verb or _pick_verb()
|
||||
self._console = console or _resolve_console()
|
||||
self._spinner_name = spinner_name
|
||||
self._live: Optional[Live] = None
|
||||
|
||||
# --------------------------------------------------------- lifecycle ----
|
||||
def __enter__(self) -> "ThinkingSpinner":
|
||||
self._live = Live(
|
||||
self._renderable(),
|
||||
console=self._console,
|
||||
transient=True,
|
||||
refresh_per_second=12,
|
||||
)
|
||||
self._live.__enter__()
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: Optional[type[BaseException]],
|
||||
exc: Optional[BaseException],
|
||||
tb: Optional[TracebackType],
|
||||
) -> None:
|
||||
live, self._live = self._live, None
|
||||
if live is not None:
|
||||
live.__exit__(exc_type, exc, tb)
|
||||
|
||||
# ------------------------------------------------------------- helpers --
|
||||
def _renderable(self) -> Spinner:
|
||||
# ``Spinner.text`` accepts Rich Text for styling — keep the verb in
|
||||
# the dim style so the live region is unobtrusive.
|
||||
return Spinner(self._spinner_name, text=Text(self._verb, style="dim"))
|
||||
|
||||
def update_verb(self, verb: str) -> None:
|
||||
"""Swap the verb mid-run (e.g. ``"Backtesting…"`` once tools fire)."""
|
||||
self._verb = verb
|
||||
if self._live is not None:
|
||||
self._live.update(self._renderable())
|
||||
|
||||
def pause(self) -> "_SpinnerPause":
|
||||
"""Yield a context manager that hides the spinner inside its block.
|
||||
|
||||
Useful when callers need to ``console.print`` a tool-event row
|
||||
without the spinner frame racing with the print to stdout.
|
||||
"""
|
||||
return _SpinnerPause(self)
|
||||
|
||||
|
||||
class _SpinnerPause:
|
||||
"""Inner context manager returned by :meth:`ThinkingSpinner.pause`."""
|
||||
|
||||
def __init__(self, parent: ThinkingSpinner) -> None:
|
||||
self._parent = parent
|
||||
self._saved_live: Optional[Live] = None
|
||||
|
||||
def __enter__(self) -> None:
|
||||
# Stash the live so __exit__ on the outer spinner does nothing
|
||||
# while we are paused; restart it on exit.
|
||||
if self._parent._live is not None:
|
||||
self._parent._live.__exit__(None, None, None)
|
||||
self._saved_live = self._parent._live
|
||||
self._parent._live = None
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
|
||||
if self._saved_live is not None:
|
||||
self._parent._live = self._saved_live
|
||||
self._parent._live.__enter__()
|
||||
self._saved_live = None
|
||||
|
||||
|
||||
__all__ = ["ThinkingSpinner"]
|
||||
@@ -0,0 +1,385 @@
|
||||
"""prompt_toolkit input editor + ``SafeFileHistory``.
|
||||
|
||||
Wraps a :class:`PromptSession` with:
|
||||
* The slash :class:`~cli.completer.SlashCompleter`
|
||||
* Multi-line editing — Enter inserts a newline only when the buffer is
|
||||
mid-bracket; Alt+Enter / Esc-Enter inserts a newline unconditionally;
|
||||
a plain Enter on a balanced buffer submits.
|
||||
* Ctrl+C with three-state semantics (clear buffer → exit hint → exit)
|
||||
* A surrogate-safe :class:`FileHistory` subclass for Windows users
|
||||
* UTF-8 stdout reconfigure on Windows so the brand glyph ``●`` and the
|
||||
Rich box-drawing characters print without ``UnicodeEncodeError``
|
||||
|
||||
Cancel-during-generation lives outside the input loop — that is owned by
|
||||
the agent runner in :mod:`cli.main`. Here we only handle the *input
|
||||
editing* and *idle* states.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.layout.containers import Window
|
||||
from prompt_toolkit.layout.controls import FormattedTextControl
|
||||
from prompt_toolkit.layout.dimension import Dimension
|
||||
from prompt_toolkit.styles import Style
|
||||
|
||||
|
||||
# Sentinel raised by the Ctrl+C path so the caller can distinguish
|
||||
# "user pressed Ctrl+C on an empty line" from real EOF. We reuse
|
||||
# :class:`EOFError` so prompt_toolkit's existing plumbing keeps working;
|
||||
# the caller decides between "show hint" and "exit" via timing.
|
||||
_EXIT_HINT_GAP_SEC = 2.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- history ----
|
||||
|
||||
|
||||
class SafeFileHistory(FileHistory):
|
||||
""":class:`FileHistory` that strips invalid surrogate code points.
|
||||
|
||||
Background:
|
||||
Windows terminals occasionally inject lone surrogate halves into
|
||||
pasted Unicode (emoji, mixed-script CJK). prompt_toolkit's default
|
||||
``store_string`` writes the line straight to disk using the
|
||||
system encoding, which raises ``UnicodeEncodeError`` and corrupts
|
||||
the history file. This subclass round-trips the string through
|
||||
``utf-16-le`` with ``surrogatepass`` and then back to a sanitised
|
||||
string before delegating to the parent implementation.
|
||||
|
||||
The cleanup is safe to run on every line — sanitised input that
|
||||
contains only valid code points round-trips unchanged.
|
||||
"""
|
||||
|
||||
def store_string(self, string: str) -> None: # type: ignore[override]
|
||||
super().store_string(_strip_surrogates(string))
|
||||
|
||||
|
||||
def _strip_surrogates(text: str) -> str:
|
||||
"""Drop unpaired surrogate code points from ``text``."""
|
||||
try:
|
||||
round_tripped = text.encode("utf-16-le", "surrogatepass").decode(
|
||||
"utf-16-le", "replace"
|
||||
)
|
||||
except UnicodeError:
|
||||
return "".join(ch for ch in text if not 0xD800 <= ord(ch) <= 0xDFFF)
|
||||
cleaned = round_tripped.encode("utf-8", "ignore").decode("utf-8", "ignore")
|
||||
return cleaned.replace("�", "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- session ----
|
||||
|
||||
|
||||
class _VibePromptSession(PromptSession):
|
||||
"""PromptSession with a prompt-height that hugs the edited text."""
|
||||
|
||||
def _create_layout(self): # type: ignore[no-untyped-def]
|
||||
layout = super()._create_layout()
|
||||
# prompt_toolkit's bottom_toolbar is a screen-bottom status bar. Insert
|
||||
# our divider directly after the input container so it hugs the prompt.
|
||||
layout.container.children.insert(1, _prompt_divider_window())
|
||||
return layout
|
||||
|
||||
def _get_default_buffer_control_height(self) -> Dimension: # type: ignore[override]
|
||||
line_count = self.default_buffer.document.line_count
|
||||
return Dimension.exact(max(1, line_count))
|
||||
|
||||
|
||||
def _prompt_divider_window() -> Window:
|
||||
return Window(
|
||||
FormattedTextControl(
|
||||
lambda: FormattedText([("class:prompt-border", _prompt_rule())])
|
||||
),
|
||||
height=1,
|
||||
style="class:prompt-border",
|
||||
dont_extend_height=True,
|
||||
)
|
||||
|
||||
|
||||
def _force_utf8_stdout() -> None:
|
||||
"""Reconfigure stdout to UTF-8 on Windows so brand glyphs render."""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
reconfigure = getattr(stream, "reconfigure", None)
|
||||
if callable(reconfigure):
|
||||
try:
|
||||
reconfigure(encoding="utf-8")
|
||||
except (OSError, ValueError):
|
||||
# Best-effort — a redirected pipe may refuse encoding swaps.
|
||||
pass
|
||||
|
||||
|
||||
def _has_unbalanced_brackets(text: str) -> bool:
|
||||
"""Return True if ``text`` contains unbalanced ``()``/``[]``/``{}`` pairs.
|
||||
|
||||
Used to decide whether a plain Enter should submit or insert a
|
||||
newline. Strings inside ``"..."`` / ``'...'`` are skipped so a user
|
||||
typing ``"hello (world)"`` does not get stuck in multi-line mode.
|
||||
"""
|
||||
stack: list[str] = []
|
||||
pairs = {")": "(", "]": "[", "}": "{"}
|
||||
openers = set(pairs.values())
|
||||
in_str: Optional[str] = None
|
||||
escape = False
|
||||
for ch in text:
|
||||
if escape:
|
||||
escape = False
|
||||
continue
|
||||
if ch == "\\":
|
||||
escape = True
|
||||
continue
|
||||
if in_str is not None:
|
||||
if ch == in_str:
|
||||
in_str = None
|
||||
continue
|
||||
if ch in ("'", '"'):
|
||||
in_str = ch
|
||||
continue
|
||||
if ch in openers:
|
||||
stack.append(ch)
|
||||
elif ch in pairs:
|
||||
if not stack or stack[-1] != pairs[ch]:
|
||||
return True
|
||||
stack.pop()
|
||||
return bool(stack) or in_str is not None
|
||||
|
||||
|
||||
class _CtrlCState:
|
||||
"""Track Ctrl+C presses so the outer loop can implement two-press exit.
|
||||
|
||||
Attributes:
|
||||
previous_press_ts: Monotonic timestamp of the press *before* the
|
||||
most recent one. ``0.0`` means "no prior press".
|
||||
last_window_hit: Set by :meth:`record_press_and_check_window` —
|
||||
``True`` iff the press that just landed was inside the
|
||||
configured window relative to ``previous_press_ts``. Cached
|
||||
so :func:`ctrl_c_within_window` does not re-read the clock
|
||||
and decide independently of the keybinding.
|
||||
|
||||
The two-press semantics live here (not in the outer loop) because
|
||||
the keybinding fires *before* the EOFError propagates, and we want
|
||||
the outer loop's view of "are we inside the window?" to reflect the
|
||||
decision made at the exact press moment — not whatever ``time.monotonic()``
|
||||
reads a few microseconds later.
|
||||
"""
|
||||
|
||||
__slots__ = ("previous_press_ts", "last_window_hit")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.previous_press_ts: float = 0.0
|
||||
self.last_window_hit: bool = False
|
||||
|
||||
def record_press_and_check_window(self, window_sec: float = _EXIT_HINT_GAP_SEC) -> bool:
|
||||
"""Record the current press, return True iff inside the window.
|
||||
|
||||
Args:
|
||||
window_sec: Two-press window in seconds.
|
||||
|
||||
Returns:
|
||||
``False`` for the very first press (``previous_press_ts == 0``).
|
||||
``True`` if the gap between the prior press and this one is
|
||||
below ``window_sec``. Otherwise ``False`` (treated as a fresh
|
||||
first press for the next round).
|
||||
"""
|
||||
now = time.monotonic()
|
||||
prev = self.previous_press_ts
|
||||
self.previous_press_ts = now
|
||||
if prev == 0.0:
|
||||
self.last_window_hit = False
|
||||
return False
|
||||
hit = (now - prev) < window_sec
|
||||
self.last_window_hit = hit
|
||||
return hit
|
||||
|
||||
|
||||
def _build_keybindings(state: _CtrlCState) -> KeyBindings:
|
||||
"""Wire Ctrl+C + multi-line submit semantics.
|
||||
|
||||
State machine (idle / typing):
|
||||
|
||||
Ctrl+C with text → clear the buffer and stay at the prompt
|
||||
Ctrl+C empty (first) → exit with ``EOFError``; caller prints hint
|
||||
and records the press timestamp.
|
||||
Ctrl+C empty (twice) → caller sees the timestamp inside 2 s and
|
||||
actually exits the loop.
|
||||
|
||||
Enter on balanced buffer → submit
|
||||
Enter on unbalanced buffer → insert newline
|
||||
Alt-Enter / Esc-Enter → insert newline unconditionally
|
||||
"""
|
||||
kb = KeyBindings()
|
||||
|
||||
@kb.add("c-c")
|
||||
def _(event) -> None: # noqa: ANN001 — prompt_toolkit event
|
||||
buf = event.app.current_buffer
|
||||
if buf.text:
|
||||
buf.reset()
|
||||
event.app.invalidate()
|
||||
return
|
||||
# Empty buffer → record the press (this updates the state so the
|
||||
# outer loop's two-press check has the right prior timestamp) and
|
||||
# propagate EOF so the outer loop can decide whether to print the
|
||||
# exit hint or actually exit.
|
||||
state.record_press_and_check_window(_EXIT_HINT_GAP_SEC)
|
||||
event.app.exit(exception=EOFError())
|
||||
|
||||
@kb.add("enter")
|
||||
def _(event) -> None: # noqa: ANN001
|
||||
buf = event.app.current_buffer
|
||||
text = buf.text
|
||||
if _has_unbalanced_brackets(text):
|
||||
buf.insert_text("\n")
|
||||
return
|
||||
buf.validate_and_handle()
|
||||
|
||||
# Alt+Enter / Esc-Enter — unconditional newline.
|
||||
@kb.add("escape", "enter")
|
||||
def _(event) -> None: # noqa: ANN001
|
||||
event.app.current_buffer.insert_text("\n")
|
||||
|
||||
return kb
|
||||
|
||||
|
||||
def _default_history_path() -> Path:
|
||||
"""Where ``~/.vibe-trading/history`` lives by default."""
|
||||
home = Path.home() / ".vibe-trading"
|
||||
return home / "history"
|
||||
|
||||
|
||||
def make_session(history_path: Optional[Path] = None) -> PromptSession:
|
||||
"""Construct a configured :class:`PromptSession`.
|
||||
|
||||
Args:
|
||||
history_path: Override for the persistent history file. ``None``
|
||||
uses ``~/.vibe-trading/history``.
|
||||
|
||||
Returns:
|
||||
A ready-to-use ``PromptSession`` wired to the slash completer,
|
||||
Ctrl+C bindings, multi-line editing, and a surrogate-safe
|
||||
history file. The session exposes ``vibe_ctrl_c_state`` on the
|
||||
returned object so callers can implement the two-press exit
|
||||
confirmation.
|
||||
"""
|
||||
_force_utf8_stdout()
|
||||
|
||||
path = history_path or _default_history_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Touch the file so FileHistory's first read does not fail on a fresh
|
||||
# install.
|
||||
if not path.exists():
|
||||
path.touch(mode=0o600)
|
||||
|
||||
# Lazy import so unit tests can import this module without instantiating
|
||||
# the slash registry side-effects.
|
||||
from cli.completer import SlashCompleter
|
||||
|
||||
ctrl_c_state = _CtrlCState()
|
||||
session = _VibePromptSession(
|
||||
history=SafeFileHistory(str(path)),
|
||||
completer=SlashCompleter(),
|
||||
complete_while_typing=True,
|
||||
key_bindings=_build_keybindings(ctrl_c_state),
|
||||
enable_history_search=True,
|
||||
mouse_support=False,
|
||||
multiline=True,
|
||||
reserve_space_for_menu=0,
|
||||
style=Style.from_dict(
|
||||
{
|
||||
"prompt": "#258bff bold",
|
||||
"prompt-border": "#4b5563",
|
||||
}
|
||||
),
|
||||
)
|
||||
# Expose the state so the outer loop can implement two-press exit.
|
||||
setattr(session, "vibe_ctrl_c_state", ctrl_c_state)
|
||||
return session
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- helpers ----
|
||||
|
||||
|
||||
def get_user_input(
|
||||
prompt_message: str = "❯ ",
|
||||
*,
|
||||
session: Optional[PromptSession] = None,
|
||||
) -> str:
|
||||
"""Prompt the user with the configured session and return the input.
|
||||
|
||||
Convenience for one-shot callers. Reuses ``session`` when supplied so
|
||||
the persistent history and completer carry across calls — otherwise
|
||||
a fresh session is built (and torn down).
|
||||
|
||||
Raises:
|
||||
EOFError: When the user hits Ctrl+D, or Ctrl+C on an empty line.
|
||||
"""
|
||||
sess = session or make_session()
|
||||
formatted = FormattedText(
|
||||
[
|
||||
("class:prompt-border", _prompt_rule() + "\n"),
|
||||
("class:prompt", prompt_message),
|
||||
]
|
||||
)
|
||||
return sess.prompt(formatted)
|
||||
|
||||
|
||||
def _prompt_rule() -> str:
|
||||
cols = shutil.get_terminal_size((88, 24)).columns
|
||||
return "─" * max(10, cols)
|
||||
|
||||
|
||||
def ctrl_c_within_window(session: PromptSession, window_sec: float = _EXIT_HINT_GAP_SEC) -> bool:
|
||||
"""Return True if the most recent Ctrl+C press was a "second press".
|
||||
|
||||
A "second press" means the user pressed Ctrl+C twice within
|
||||
``window_sec`` on an empty buffer — that's the signal to actually
|
||||
exit. The decision is made at *press time* by
|
||||
:meth:`_CtrlCState.record_press_and_check_window` and cached on the
|
||||
state object; the outer loop reads the cached flag here.
|
||||
|
||||
Falls back to a timestamp comparison against ``previous_press_ts``
|
||||
for two cases:
|
||||
|
||||
* ``SimpleNamespace`` test doubles that set ``last_press_ts`` directly
|
||||
(legacy test fixtures predate the two-timestamp design).
|
||||
* ``vibe_ctrl_c_state`` being absent entirely (defensive — returns
|
||||
``False`` so the caller treats it as "no exit").
|
||||
|
||||
Args:
|
||||
session: The active prompt_toolkit session (or a duck-typed
|
||||
stand-in exposing ``vibe_ctrl_c_state``).
|
||||
window_sec: Window length in seconds. Only used by the fallback
|
||||
paths described above; the primary path trusts the cached
|
||||
``last_window_hit`` already computed against the configured
|
||||
window.
|
||||
|
||||
Returns:
|
||||
``True`` iff the loop should now exit.
|
||||
"""
|
||||
state = getattr(session, "vibe_ctrl_c_state", None)
|
||||
if state is None:
|
||||
return False
|
||||
# Primary path — the keybinding cached the press-time decision.
|
||||
if hasattr(state, "last_window_hit"):
|
||||
return bool(state.last_window_hit)
|
||||
# Legacy fallback for test doubles that only set ``last_press_ts``.
|
||||
last_ts = getattr(state, "last_press_ts", 0.0)
|
||||
if last_ts <= 0.0:
|
||||
return False
|
||||
return (time.monotonic() - last_ts) < window_sec
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SafeFileHistory",
|
||||
"make_session",
|
||||
"get_user_input",
|
||||
"ctrl_c_within_window",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Compatibility wrapper for the interactive startup banner."""
|
||||
|
||||
from cli.ui.banner import print_banner
|
||||
|
||||
__all__ = ["print_banner"]
|
||||
+1309
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,499 @@
|
||||
"""First-launch onboarding wizard.
|
||||
|
||||
Triggered automatically when ``~/.vibe-trading/.env`` does not exist. Five
|
||||
back-steppable steps (provider → model → key → timeout → optional Tushare),
|
||||
matching §3.2 of the 2026-05-19 UI/UX design proposal.
|
||||
|
||||
Each step persists immediately to ``~/.vibe-trading/.env.partial`` and the
|
||||
file is atomically renamed to ``.env`` only on completion, so a crash mid-
|
||||
wizard never leaves a corrupt ``.env`` behind. API key entry is masked.
|
||||
|
||||
Prefers ``questionary`` when available; otherwise drives prompt_toolkit
|
||||
directly so Esc / Left-arrow can be bound to a back-step sentinel.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Final, Sequence
|
||||
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
|
||||
from cli.theme import Theme, get_console
|
||||
|
||||
# Sentinels for back-navigation / cancel returned by selectors.
|
||||
BACK = object()
|
||||
CANCEL = object()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider catalogue — mirrors _PROVIDER_CHOICES in cli/_legacy.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Provider:
|
||||
"""One selectable LLM provider option shown in step 1."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
description: str
|
||||
default_model: str
|
||||
key_env: str | None
|
||||
base_env: str
|
||||
base_url: str
|
||||
key_prefix: str | None
|
||||
suggested_models: tuple[str, ...]
|
||||
|
||||
|
||||
PROVIDERS: Final[tuple[Provider, ...]] = (
|
||||
Provider("openrouter", "OpenRouter", "recommended — 200+ models, one key",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
"OPENROUTER_API_KEY", "OPENROUTER_BASE_URL",
|
||||
"https://openrouter.ai/api/v1", "sk-or-",
|
||||
("deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash",
|
||||
"openai/gpt-5.5-pro", "google/gemini-3.5-flash")),
|
||||
Provider("openai", "OpenAI", "GPT-5.5 direct",
|
||||
"gpt-5.5-instant", "OPENAI_API_KEY", "OPENAI_BASE_URL",
|
||||
"https://api.openai.com/v1", "sk-",
|
||||
("gpt-5.5-instant", "gpt-5.5-pro", "gpt-5.5")),
|
||||
Provider("deepseek", "DeepSeek",
|
||||
"cheapest tier — good for batch backtest research",
|
||||
"deepseek-v4-pro", "DEEPSEEK_API_KEY", "DEEPSEEK_BASE_URL",
|
||||
"https://api.deepseek.com/v1", "sk-",
|
||||
("deepseek-v4-pro", "deepseek-v4-flash")),
|
||||
Provider("ollama", "Ollama", "local, free, no API key",
|
||||
"qwen2.5:32b", None, "OLLAMA_BASE_URL",
|
||||
"http://localhost:11434", None,
|
||||
("qwen2.5:32b", "llama3.3:70b", "deepseek-r1:14b")),
|
||||
)
|
||||
|
||||
TIMEOUT_CHOICES: Final[tuple[tuple[str, str], ...]] = (
|
||||
("2400", "2400s (4 minutes — research mode, recommended)"),
|
||||
("600", "600s (10 minutes — large backtests / swarm runs)"),
|
||||
("120", "120s (2 minutes — quick lookup mode)"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filesystem helpers (atomic partial save → final rename)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _env_dir() -> Path: return Path.home() / ".vibe-trading"
|
||||
def _env_path() -> Path: return _env_dir() / ".env"
|
||||
def _partial_path() -> Path: return _env_dir() / ".env.partial"
|
||||
|
||||
|
||||
def _render_env(values: dict[str, str]) -> str:
|
||||
"""Render values as a stable ``.env`` body (KEY=value lines)."""
|
||||
return "\n".join(f"{k}={v}" for k, v in values.items() if v) + "\n"
|
||||
|
||||
|
||||
def _save_partial(values: dict[str, str]) -> None:
|
||||
"""Best-effort write to ``.env.partial`` (crash-resilience nicety)."""
|
||||
try:
|
||||
_env_dir().mkdir(parents=True, exist_ok=True)
|
||||
_partial_path().write_text(_render_env(values), encoding="utf-8")
|
||||
try:
|
||||
_partial_path().chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _finalize(values: dict[str, str]) -> Path:
|
||||
"""Atomically write ``.env``. Returns the final path."""
|
||||
_env_dir().mkdir(parents=True, exist_ok=True)
|
||||
content = _render_env(values)
|
||||
fd, tmp_name = tempfile.mkstemp(prefix=".env.", dir=str(_env_dir()))
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
tmp_path = Path(tmp_name)
|
||||
try:
|
||||
tmp_path.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
tmp_path.replace(_env_path())
|
||||
finally:
|
||||
try:
|
||||
Path(tmp_name).unlink()
|
||||
except (FileNotFoundError, OSError):
|
||||
pass
|
||||
try:
|
||||
_partial_path().unlink()
|
||||
except (FileNotFoundError, OSError):
|
||||
pass
|
||||
return _env_path()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Selector with back-step (custom prompt_toolkit Application)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _select_with_back(prompt: str, choices: Sequence[tuple[str, str]], *,
|
||||
default_index: int = 0,
|
||||
console: Console | None = None) -> str | object:
|
||||
"""Vertically-scrollable selector. Returns chosen value, BACK, or CANCEL.
|
||||
|
||||
Keybindings: ↑/↓ navigate, Enter confirm, Esc/← back, Ctrl+C cancel.
|
||||
Falls back to a numeric stdin prompt if prompt_toolkit is unavailable.
|
||||
"""
|
||||
cons = console or get_console()
|
||||
cons.print()
|
||||
cons.print(Text(f"? {prompt}", style=Theme.label))
|
||||
|
||||
try:
|
||||
from prompt_toolkit import Application
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.layout import Layout
|
||||
from prompt_toolkit.layout.containers import HSplit, Window
|
||||
from prompt_toolkit.layout.controls import FormattedTextControl
|
||||
from prompt_toolkit.styles import Style as PTStyle
|
||||
except ImportError:
|
||||
return _select_numeric(choices, default_index, cons)
|
||||
|
||||
state = {"index": max(0, min(default_index, len(choices) - 1)), "result": None}
|
||||
|
||||
def _format() -> FormattedText:
|
||||
out: list[tuple[str, str]] = []
|
||||
for i, (_, label) in enumerate(choices):
|
||||
if i == state["index"]:
|
||||
out.append(("class:cursor", " > "))
|
||||
out.append(("class:selected", f"{label}\n"))
|
||||
else:
|
||||
out.append(("", " "))
|
||||
out.append(("class:option", f"{label}\n"))
|
||||
out.append(("class:hint",
|
||||
"\n ↑/↓ navigate · Enter select · Esc/← back · Ctrl+C cancel"))
|
||||
return FormattedText(out)
|
||||
|
||||
kb = KeyBindings()
|
||||
|
||||
@kb.add("up")
|
||||
@kb.add("c-p")
|
||||
def _(event): # type: ignore[no-redef]
|
||||
state["index"] = (state["index"] - 1) % len(choices); event.app.invalidate()
|
||||
|
||||
@kb.add("down")
|
||||
@kb.add("c-n")
|
||||
def _(event): # type: ignore[no-redef]
|
||||
state["index"] = (state["index"] + 1) % len(choices); event.app.invalidate()
|
||||
|
||||
@kb.add("enter")
|
||||
def _(event): # type: ignore[no-redef]
|
||||
state["result"] = choices[state["index"]][0]; event.app.exit()
|
||||
|
||||
@kb.add("escape", eager=True)
|
||||
@kb.add("left")
|
||||
def _(event): # type: ignore[no-redef]
|
||||
state["result"] = BACK; event.app.exit()
|
||||
|
||||
@kb.add("c-c")
|
||||
@kb.add("c-d")
|
||||
def _(event): # type: ignore[no-redef]
|
||||
state["result"] = CANCEL; event.app.exit()
|
||||
|
||||
style = PTStyle.from_dict({
|
||||
"cursor": f"{Theme.brand_hex} bold",
|
||||
"selected": f"{Theme.brand_hex} bold",
|
||||
"option": "",
|
||||
"hint": "#808080",
|
||||
})
|
||||
layout = Layout(HSplit([Window(FormattedTextControl(_format), wrap_lines=False)]))
|
||||
app: Application = Application(layout=layout, key_bindings=kb, style=style, full_screen=False)
|
||||
try:
|
||||
app.run()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return CANCEL
|
||||
return state["result"] if state["result"] is not None else CANCEL
|
||||
|
||||
|
||||
def _select_numeric(choices: Sequence[tuple[str, str]], default_index: int,
|
||||
console: Console) -> str | object:
|
||||
"""Stdin-only fallback selector."""
|
||||
for i, (_, label) in enumerate(choices, start=1):
|
||||
marker = ">" if (i - 1) == default_index else " "
|
||||
console.print(f" {marker} [{i}] {label}", style=Theme.muted)
|
||||
console.print(Text(" (type number, b=back, q=cancel)", style=Theme.muted))
|
||||
try:
|
||||
raw = input("> ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return CANCEL
|
||||
if raw in {"b", "back"}: return BACK
|
||||
if raw in {"q", "quit", "cancel"}: return CANCEL
|
||||
if not raw: return choices[default_index][0]
|
||||
try:
|
||||
idx = int(raw)
|
||||
if 1 <= idx <= len(choices):
|
||||
return choices[idx - 1][0]
|
||||
except ValueError:
|
||||
pass
|
||||
console.print(Text(" invalid selection, try again", style=Theme.danger))
|
||||
return _select_numeric(choices, default_index, console)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Masked / plain prompts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _prompt_secret(prompt: str, *, console: Console) -> str | object:
|
||||
"""Read a masked secret. Returns string, BACK, or CANCEL."""
|
||||
console.print()
|
||||
console.print(Text(f"? {prompt}", style=Theme.label))
|
||||
console.print(Text(
|
||||
" (input hidden · Enter to submit · Esc to go back · Ctrl+C to cancel)",
|
||||
style=Theme.muted,
|
||||
))
|
||||
try:
|
||||
from prompt_toolkit import prompt as pt_prompt
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
|
||||
kb = KeyBindings()
|
||||
sentinel: dict[str, object] = {"action": None}
|
||||
|
||||
@kb.add("escape", eager=True)
|
||||
def _(event): # type: ignore[no-redef]
|
||||
sentinel["action"] = BACK; event.app.exit(result="")
|
||||
|
||||
try:
|
||||
value = pt_prompt("> ", is_password=True, key_bindings=kb)
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return CANCEL
|
||||
if sentinel["action"] is BACK:
|
||||
return BACK
|
||||
return value.strip()
|
||||
except ImportError:
|
||||
import getpass
|
||||
try:
|
||||
return getpass.getpass("> ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return CANCEL
|
||||
|
||||
|
||||
def _prompt_text(prompt: str, *, default: str = "",
|
||||
console: Console) -> str | object:
|
||||
"""Read a plain string. Returns string, BACK, or CANCEL."""
|
||||
console.print()
|
||||
console.print(Text(f"? {prompt}", style=Theme.label))
|
||||
if default:
|
||||
console.print(Text(f" (Enter for default: {default} · Esc to go back)",
|
||||
style=Theme.muted))
|
||||
else:
|
||||
console.print(Text(" (Enter to skip · Esc to go back)", style=Theme.muted))
|
||||
|
||||
try:
|
||||
from prompt_toolkit import prompt as pt_prompt
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
|
||||
kb = KeyBindings()
|
||||
sentinel: dict[str, object] = {"action": None}
|
||||
|
||||
@kb.add("escape", eager=True)
|
||||
def _(event): # type: ignore[no-redef]
|
||||
sentinel["action"] = BACK; event.app.exit(result="")
|
||||
|
||||
try:
|
||||
value = pt_prompt("> ", key_bindings=kb)
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return CANCEL
|
||||
if sentinel["action"] is BACK:
|
||||
return BACK
|
||||
v = value.strip()
|
||||
return v if v else default
|
||||
except ImportError:
|
||||
try:
|
||||
raw = input("> ").strip()
|
||||
return raw if raw else default
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return CANCEL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wizard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _validate_key(provider: Provider, key: str) -> str | None:
|
||||
"""Return error message or None if key looks plausible."""
|
||||
if not key:
|
||||
return "API key cannot be empty."
|
||||
if provider.key_prefix and not key.startswith(provider.key_prefix):
|
||||
return f"Expected key to start with '{provider.key_prefix}'."
|
||||
if len(key) < 12:
|
||||
return "That key looks too short."
|
||||
return None
|
||||
|
||||
|
||||
def _intro_header(console: Console) -> None:
|
||||
console.print()
|
||||
console.print(Text(" Vibe-Trading setup", style=Theme.primary))
|
||||
console.print(Text(
|
||||
" We didn't find a config at ~/.vibe-trading/.env.\n"
|
||||
" Let's set up in under a minute.",
|
||||
style=Theme.muted,
|
||||
))
|
||||
|
||||
|
||||
def run_onboarding(*, console: Console | None = None) -> Path | None:
|
||||
"""Run the 5-step wizard. Returns the written ``.env`` path or None.
|
||||
|
||||
Returns ``None`` when the user cancels (Ctrl+C / Esc at step 1) so the
|
||||
caller can exit cleanly without writing a partial config.
|
||||
"""
|
||||
cons = console or get_console()
|
||||
_intro_header(cons)
|
||||
|
||||
values: dict[str, str] = {"LANGCHAIN_TEMPERATURE": "0.0", "MAX_RETRIES": "2"}
|
||||
state: dict[str, object] = {"provider": None, "model": None, "key": None}
|
||||
|
||||
def step_provider() -> object:
|
||||
choices = [(p.key, f"{p.label:<14} {p.description}") for p in PROVIDERS]
|
||||
result = _select_with_back("Pick a model provider", choices,
|
||||
default_index=0, console=cons)
|
||||
if result is BACK or result is CANCEL:
|
||||
return result
|
||||
provider = next(p for p in PROVIDERS if p.key == result)
|
||||
state["provider"] = provider
|
||||
values["LANGCHAIN_PROVIDER"] = provider.key
|
||||
values[provider.base_env] = provider.base_url
|
||||
_save_partial(values)
|
||||
return "ok"
|
||||
|
||||
def step_model() -> object:
|
||||
provider: Provider = state["provider"] # type: ignore[assignment]
|
||||
choices: list[tuple[str, str]] = [
|
||||
(m, f"{m}{' (default)' if m == provider.default_model else ''}")
|
||||
for m in provider.suggested_models
|
||||
]
|
||||
choices.append(("__custom__", "other (type custom model id)"))
|
||||
default_idx = next(
|
||||
(i for i, (v, _) in enumerate(choices) if v == provider.default_model),
|
||||
0,
|
||||
)
|
||||
choice = _select_with_back("Pick a model", choices,
|
||||
default_index=default_idx, console=cons)
|
||||
if choice is BACK or choice is CANCEL:
|
||||
return choice
|
||||
if choice == "__custom__":
|
||||
custom = _prompt_text("Type the model id",
|
||||
default=provider.default_model, console=cons)
|
||||
if custom is BACK or custom is CANCEL:
|
||||
return custom
|
||||
model = str(custom) or provider.default_model
|
||||
else:
|
||||
model = str(choice)
|
||||
state["model"] = model
|
||||
values["LANGCHAIN_MODEL_NAME"] = model
|
||||
_save_partial(values)
|
||||
return "ok"
|
||||
|
||||
def step_key() -> object:
|
||||
provider: Provider = state["provider"] # type: ignore[assignment]
|
||||
if provider.key_env is None:
|
||||
cons.print()
|
||||
cons.print(Text(" Ollama runs locally — no API key needed.",
|
||||
style=Theme.success))
|
||||
return "ok"
|
||||
while True:
|
||||
key = _prompt_secret(
|
||||
f"Paste your {provider.label} API key "
|
||||
"(saved to ~/.vibe-trading/.env, never logged)",
|
||||
console=cons,
|
||||
)
|
||||
if key is BACK or key is CANCEL:
|
||||
return key
|
||||
err = _validate_key(provider, str(key))
|
||||
if err is None:
|
||||
state["key"] = key
|
||||
values[provider.key_env] = str(key)
|
||||
_save_partial(values)
|
||||
return "ok"
|
||||
cons.print(Text(f" {err} Try again, or press Esc to go back.",
|
||||
style=Theme.danger))
|
||||
|
||||
def step_timeout() -> object:
|
||||
choice = _select_with_back("Default request timeout",
|
||||
list(TIMEOUT_CHOICES),
|
||||
default_index=0, console=cons)
|
||||
if choice is BACK or choice is CANCEL:
|
||||
return choice
|
||||
values["TIMEOUT_SECONDS"] = str(choice)
|
||||
_save_partial(values)
|
||||
return "ok"
|
||||
|
||||
def step_tushare() -> object:
|
||||
choices = [
|
||||
("__skip__", "No, skip (most users)"),
|
||||
("__paste__", "Yes — paste my Tushare token"),
|
||||
]
|
||||
decision = _select_with_back(
|
||||
"Enable Tushare for China A-share data? (optional)",
|
||||
choices, default_index=0, console=cons,
|
||||
)
|
||||
if decision is BACK or decision is CANCEL:
|
||||
return decision
|
||||
if decision == "__paste__":
|
||||
token = _prompt_secret("Tushare token", console=cons)
|
||||
if token is BACK or token is CANCEL:
|
||||
return token
|
||||
if str(token).strip():
|
||||
values["TUSHARE_TOKEN"] = str(token).strip()
|
||||
_save_partial(values)
|
||||
return "ok"
|
||||
|
||||
steps: list[Callable[[], object]] = [
|
||||
step_provider, step_model, step_key, step_timeout, step_tushare,
|
||||
]
|
||||
|
||||
i = 0
|
||||
while i < len(steps):
|
||||
result = steps[i]()
|
||||
if result is CANCEL:
|
||||
cons.print()
|
||||
cons.print(Text(" Setup cancelled. No config written.",
|
||||
style=Theme.warning))
|
||||
return None
|
||||
if result is BACK:
|
||||
if i == 0:
|
||||
cons.print()
|
||||
cons.print(Text(" Setup cancelled. No config written.",
|
||||
style=Theme.warning))
|
||||
return None
|
||||
i -= 1
|
||||
continue
|
||||
i += 1
|
||||
|
||||
final_path = _finalize(values)
|
||||
|
||||
cons.print()
|
||||
cons.print(Text(f" ✓ Wrote {final_path}", style=Theme.success))
|
||||
cons.print()
|
||||
|
||||
tour = _select_with_back(
|
||||
"Want a quick tour? (or jump straight in)",
|
||||
[("__skip__", "Skip — drop me in chat"),
|
||||
("__tour__", "Show me a 30-second sample run")],
|
||||
default_index=0, console=cons,
|
||||
)
|
||||
if tour == "__tour__":
|
||||
cons.print()
|
||||
cons.print(Text(
|
||||
" Tip: try `analyze AAPL last 30 days` as your first prompt.\n"
|
||||
" Type /help any time to see all commands.",
|
||||
style=Theme.muted,
|
||||
))
|
||||
|
||||
return final_path
|
||||
|
||||
|
||||
__all__ = ["run_onboarding", "PROVIDERS", "Provider", "BACK", "CANCEL"]
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Streaming renderer + thinking spinner for the interactive CLI.
|
||||
|
||||
Single-agent mode (the only mode demoed in Parcel α): a short-lived
|
||||
``Rich.Live`` with ``transient=True`` drives the spinner while the model
|
||||
thinks; tool events and the final answer are emitted as *static* prints so
|
||||
they persist on screen after the live area is erased. This avoids the
|
||||
"ghost re-paint" bug that plagued Vibe-Trading's earlier Live-based
|
||||
dashboards (nanobot lesson, design proposal §3.5).
|
||||
|
||||
Swarm mode is a stub: callers should fall through to the legacy Rich Live
|
||||
dashboard in ``cli/_legacy.py``. The full multi-agent grid lands in Parcel β.
|
||||
|
||||
Tool events use the dexter-style ``⏺ Tool Name (args) duration · summary``
|
||||
format. Tool names are auto-Title-Cased with a small acronym whitelist.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Iterator, Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.live import Live
|
||||
from rich.text import Text
|
||||
|
||||
from cli.theme import Theme, get_console
|
||||
from cli.utils.format import format_duration
|
||||
from cli.utils.thinking_verbs import pick_thinking_verb
|
||||
|
||||
_PREFIX_RE = re.compile(r"^(get|run|do|fetch|load|build|compute|calc(?:ulate)?)_")
|
||||
|
||||
# Canonical acronyms that should render UPPERCASE rather than Title-Case.
|
||||
# Whitelist (not "len <= 3" heuristic) so common words like "web" stay "Web".
|
||||
_ACRONYMS: frozenset[str] = frozenset({
|
||||
"api", "url", "csv", "json", "yaml", "sql", "tsv", "pdf",
|
||||
"ai", "ml", "dcf", "fcf", "roe", "roi", "pe", "pb", "iv",
|
||||
"btc", "eth", "usd", "cny", "id",
|
||||
})
|
||||
|
||||
|
||||
def beautify_tool_name(raw: str) -> str:
|
||||
"""``get_market_data`` → ``Market Data``. Acronyms in whitelist UPPER."""
|
||||
if not raw:
|
||||
return raw
|
||||
parts = _PREFIX_RE.sub("", raw).split("_")
|
||||
out: list[str] = []
|
||||
for part in parts:
|
||||
if not part:
|
||||
continue
|
||||
out.append(part.upper() if part.lower() in _ACRONYMS else part.capitalize())
|
||||
return " ".join(out) if out else raw
|
||||
|
||||
|
||||
def summarize_args(args: dict | str | None, *, max_len: int = 60) -> str:
|
||||
"""Compact single-line preview. Prefers query/symbol/url; truncates long."""
|
||||
if not args:
|
||||
return ""
|
||||
if isinstance(args, str):
|
||||
return _truncate(args, max_len)
|
||||
if not isinstance(args, dict):
|
||||
return _truncate(str(args), max_len)
|
||||
for priority_key in ("query", "prompt", "url", "symbol", "ticker", "code"):
|
||||
if priority_key in args and args[priority_key]:
|
||||
return f'"{_truncate(str(args[priority_key]), max_len - 2)}"'
|
||||
pieces: list[str] = []
|
||||
used = 0
|
||||
for k, v in args.items():
|
||||
token = f"{k}={_truncate(str(v), 20)}"
|
||||
if used + len(token) + 2 > max_len:
|
||||
pieces.append("…"); break
|
||||
pieces.append(token); used += len(token) + 2
|
||||
return ", ".join(pieces)
|
||||
|
||||
|
||||
def _truncate(value: str, max_len: int) -> str:
|
||||
if len(value) <= max_len:
|
||||
return value
|
||||
if max_len <= 1:
|
||||
return "…"
|
||||
return value[: max_len - 1] + "…"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ThinkingSpinner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SpinnerState:
|
||||
verb: str
|
||||
started_at: float
|
||||
paused: bool = False
|
||||
stopped: bool = False
|
||||
extra: str = ""
|
||||
|
||||
|
||||
class ThinkingSpinner:
|
||||
"""Transient spinner that can be paused/resumed safely.
|
||||
|
||||
``pause()`` is a context manager: stops the underlying ``Live`` (clearing
|
||||
its line), yields so the caller can print a static line, then restarts.
|
||||
This is nanobot's pattern and avoids ANSI escape interleaving.
|
||||
"""
|
||||
|
||||
def __init__(self, console: Console | None = None) -> None:
|
||||
self._console = console or get_console()
|
||||
self._state = _SpinnerState(
|
||||
verb=pick_thinking_verb(), started_at=time.monotonic()
|
||||
)
|
||||
self._live: Optional[Live] = None
|
||||
self._lock = threading.Lock()
|
||||
self._tick_thread: Optional[threading.Thread] = None
|
||||
|
||||
def start(self, verb: str | None = None) -> None:
|
||||
"""Begin rendering. ``verb`` rerolls per turn if not supplied."""
|
||||
with self._lock:
|
||||
if self._live is not None:
|
||||
return
|
||||
self._state.verb = verb or pick_thinking_verb()
|
||||
self._state.started_at = time.monotonic()
|
||||
self._state.paused = False
|
||||
self._state.stopped = False
|
||||
self._live = Live(self._render(), console=self._console,
|
||||
refresh_per_second=10, transient=True)
|
||||
self._live.start(refresh=False)
|
||||
self._tick_thread = threading.Thread(target=self._tick, daemon=True)
|
||||
self._tick_thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop and erase the spinner line."""
|
||||
with self._lock:
|
||||
self._state.stopped = True
|
||||
if self._live is not None:
|
||||
try:
|
||||
self._live.stop()
|
||||
except Exception: # noqa: BLE001 — Rich shutdown can race
|
||||
pass
|
||||
self._live = None
|
||||
|
||||
def set_extra(self, extra: str) -> None:
|
||||
"""Update the right-hand suffix (e.g. token / cost preview)."""
|
||||
self._state.extra = extra
|
||||
|
||||
@contextmanager
|
||||
def pause(self) -> Iterator[None]:
|
||||
"""Suspend the spinner so the caller can safely ``console.print``."""
|
||||
was_running = False
|
||||
with self._lock:
|
||||
if self._live is not None and not self._state.stopped:
|
||||
was_running = True
|
||||
self._state.paused = True
|
||||
try:
|
||||
self._live.stop()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self._live = None
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if was_running and not self._state.stopped:
|
||||
with self._lock:
|
||||
self._state.paused = False
|
||||
self._live = Live(self._render(), console=self._console,
|
||||
refresh_per_second=10, transient=True)
|
||||
self._live.start(refresh=False)
|
||||
|
||||
def _render(self) -> Text:
|
||||
elapsed_ms = int((time.monotonic() - self._state.started_at) * 1000)
|
||||
text = Text()
|
||||
text.append(" ")
|
||||
text.append("●", style=Theme.warning) # pulse anchor (no emoji)
|
||||
text.append(" ")
|
||||
text.append(self._state.verb, style=Theme.primary_dim)
|
||||
text.append(" ")
|
||||
text.append(format_duration(elapsed_ms), style=Theme.muted)
|
||||
if self._state.extra:
|
||||
text.append(" · ", style=Theme.muted)
|
||||
text.append(self._state.extra, style=Theme.muted)
|
||||
return text
|
||||
|
||||
def _tick(self) -> None:
|
||||
"""Refresh duration label every 100 ms."""
|
||||
while not self._state.stopped:
|
||||
time.sleep(0.1)
|
||||
with self._lock:
|
||||
if self._live is not None and not self._state.paused:
|
||||
try:
|
||||
self._live.update(self._render(), refresh=True)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# StreamRenderer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCall:
|
||||
"""A single tool invocation observed during a turn."""
|
||||
|
||||
name: str
|
||||
args: dict | str | None
|
||||
started_at: float = field(default_factory=time.monotonic)
|
||||
finished_at: Optional[float] = None
|
||||
summary: str = ""
|
||||
|
||||
@property
|
||||
def duration_ms(self) -> int | None:
|
||||
if self.finished_at is None:
|
||||
return None
|
||||
return int((self.finished_at - self.started_at) * 1000)
|
||||
|
||||
|
||||
class StreamRenderer:
|
||||
"""Drives the streaming display for a single agent turn.
|
||||
|
||||
Mode ``"single"`` is the focus of Parcel α. Mode ``"swarm"`` is a stub
|
||||
— callers should detect it and route to ``_legacy`` swarm rendering.
|
||||
"""
|
||||
|
||||
def __init__(self, *, mode: str = "single",
|
||||
console: Console | None = None) -> None:
|
||||
if mode not in {"single", "swarm"}:
|
||||
raise ValueError(f"unknown StreamRenderer mode: {mode!r}")
|
||||
self._mode = mode
|
||||
self._console = console or get_console()
|
||||
self._spinner: Optional[ThinkingSpinner] = None
|
||||
self._active_calls: dict[str, ToolCall] = {}
|
||||
|
||||
@property
|
||||
def mode(self) -> str:
|
||||
return self._mode
|
||||
|
||||
@contextmanager
|
||||
def turn(self, *, verb: str | None = None) -> Iterator["StreamRenderer"]:
|
||||
"""Context manager wrapping one agent turn. Manages spinner lifecycle."""
|
||||
if self._mode == "swarm":
|
||||
yield self
|
||||
return
|
||||
self._spinner = ThinkingSpinner(self._console)
|
||||
self._spinner.start(verb=verb)
|
||||
try:
|
||||
yield self
|
||||
finally:
|
||||
if self._spinner is not None:
|
||||
self._spinner.stop()
|
||||
self._spinner = None
|
||||
self._active_calls.clear()
|
||||
|
||||
def on_tool_start(self, name: str, args: dict | str | None) -> None:
|
||||
"""Record a tool call start. Line emitted only when call finishes."""
|
||||
self._active_calls[name] = ToolCall(name=name, args=args)
|
||||
|
||||
def on_tool_end(self, name: str, *, summary: str = "") -> None:
|
||||
"""Mark a tool call finished and emit its static line."""
|
||||
call = self._active_calls.pop(name, None)
|
||||
if call is None:
|
||||
call = ToolCall(name=name, args=None,
|
||||
finished_at=time.monotonic(), summary=summary)
|
||||
else:
|
||||
call.finished_at = time.monotonic()
|
||||
call.summary = summary
|
||||
self._print_tool_line(call)
|
||||
|
||||
def _print_tool_line(self, call: ToolCall) -> None:
|
||||
line = Text()
|
||||
line.append("●", style=Theme.success)
|
||||
line.append(" ")
|
||||
line.append(beautify_tool_name(call.name), style=Theme.label)
|
||||
args_preview = summarize_args(
|
||||
call.args if isinstance(call.args, (dict, str)) else None
|
||||
)
|
||||
if args_preview:
|
||||
line.append(" ")
|
||||
line.append(f"({args_preview})", style=Theme.muted)
|
||||
# Right-pad so the duration column aligns at column 60.
|
||||
pad_to = 60
|
||||
current_len = len(line.plain)
|
||||
line.append(" " * max(2, pad_to - current_len))
|
||||
if call.duration_ms is not None:
|
||||
line.append(format_duration(call.duration_ms), style=Theme.muted)
|
||||
if call.summary:
|
||||
line.append(" · ", style=Theme.muted)
|
||||
line.append(call.summary, style=Theme.muted)
|
||||
self._emit_static(line)
|
||||
|
||||
def _emit_static(self, renderable) -> None: # type: ignore[no-untyped-def]
|
||||
"""Print a static line, pausing the spinner if running."""
|
||||
if self._spinner is not None:
|
||||
with self._spinner.pause():
|
||||
self._console.print(renderable)
|
||||
else:
|
||||
self._console.print(renderable)
|
||||
|
||||
def print_answer(self, body: str) -> None:
|
||||
"""Print the assistant's final answer, prefixed with the brand marker."""
|
||||
if self._spinner is not None:
|
||||
self._spinner.stop()
|
||||
self._spinner = None
|
||||
lines = body.splitlines() or [""]
|
||||
head = Text()
|
||||
head.append("● ", style=Theme.primary)
|
||||
head.append(lines[0])
|
||||
self._console.print(head)
|
||||
for line in lines[1:]:
|
||||
self._console.print(Text(" " + line))
|
||||
self._console.print()
|
||||
|
||||
def print_footer(self, *, run_id: str, tool_count: int,
|
||||
duration_ms: int | float | None,
|
||||
token_count: int | None, cost: float | None) -> None:
|
||||
"""``/show <id> · N tool calls · 4.1s · 1.2k tokens · $0.003``."""
|
||||
from cli.utils.format import abbreviate_num, format_tokens
|
||||
|
||||
line = Text(" ")
|
||||
line.append(f"/show {run_id}", style=Theme.info)
|
||||
line.append(" · ", style=Theme.muted)
|
||||
line.append(
|
||||
f"{tool_count} tool call{'s' if tool_count != 1 else ''}",
|
||||
style=Theme.muted,
|
||||
)
|
||||
line.append(" · ", style=Theme.muted)
|
||||
line.append(format_duration(duration_ms), style=Theme.muted)
|
||||
if token_count is not None:
|
||||
line.append(" · ", style=Theme.muted)
|
||||
line.append(format_tokens(token_count), style=Theme.muted)
|
||||
if cost is not None:
|
||||
line.append(" · ", style=Theme.muted)
|
||||
line.append(abbreviate_num(cost, currency="$"), style=Theme.muted)
|
||||
self._console.print(line)
|
||||
self._console.print()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"StreamRenderer",
|
||||
"ThinkingSpinner",
|
||||
"ToolCall",
|
||||
"beautify_tool_name",
|
||||
"summarize_args",
|
||||
]
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Centralised Rich Style table for the Vibe-Trading CLI.
|
||||
|
||||
All visible colour decisions for the CLI live here so the look stays consistent
|
||||
with the web app (see ``frontend/src/index.css`` design tokens). The brand
|
||||
accent is the orange wordmark colour selected in the 2026-05-19 UI/UX design
|
||||
proposal (§1.3):
|
||||
|
||||
* light terminals: ``#d97706`` (WCAG-AA white-on-orange contrast 4.8:1)
|
||||
* dark terminals: ``#fa9842`` (matches web ``--primary`` in dark mode)
|
||||
|
||||
Semantic styles follow the design proposal's "Orange discipline rule" — only
|
||||
the brand wordmark, the primary CTA, and the agent identity should ever use
|
||||
``Theme.primary``. Tool status, success, danger and warning use their own
|
||||
semantic tokens so the orange stays meaningful.
|
||||
|
||||
The module exposes a single shared :class:`rich.console.Console` instance via
|
||||
:func:`get_console` so every component (banner, onboarding wizard, stream
|
||||
renderer, status bar) renders to the same TTY. ``NO_COLOR`` is honoured by
|
||||
forcing Rich into no-color mode at construction time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
from rich.console import Console
|
||||
from rich.style import Style
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Brand palette (single source of truth — mirrored in frontend/src/index.css)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BRAND_ORANGE_LIGHT: Final[str] = "#d97706"
|
||||
"""Wordmark / primary CTA colour on light terminals (web ``--primary`` light)."""
|
||||
|
||||
BRAND_ORANGE_DARK: Final[str] = "#fa9842"
|
||||
"""Wordmark / primary CTA colour on dark terminals (web ``--primary`` dark)."""
|
||||
|
||||
|
||||
def _is_dark_terminal(console: Console) -> bool:
|
||||
"""Heuristically decide whether the terminal is rendering on a dark theme.
|
||||
|
||||
Rich does not expose a foolproof "dark mode?" probe, so we use a best-effort
|
||||
chain:
|
||||
|
||||
1. Explicit override via ``VIBE_TRADING_THEME`` env var (``dark``/``light``).
|
||||
2. ``COLORFGBG`` (set by xterm/rxvt/Konsole): the part after ``;`` is the
|
||||
background colour index. ``0`` (black) → dark, anything else → light.
|
||||
3. macOS Terminal.app sets ``TERM_PROGRAM=Apple_Terminal`` without a colour
|
||||
hint — assume dark (it is the macOS Big Sur+ default).
|
||||
4. Fallback: dark (matches the modern default across iTerm, Alacritty,
|
||||
Windows Terminal, VS Code integrated terminal).
|
||||
|
||||
Args:
|
||||
console: Rich console (used to short-circuit when colour is disabled).
|
||||
|
||||
Returns:
|
||||
``True`` if dark mode should be assumed.
|
||||
"""
|
||||
|
||||
override = os.environ.get("VIBE_TRADING_THEME", "").strip().lower()
|
||||
if override in {"dark", "light"}:
|
||||
return override == "dark"
|
||||
|
||||
if console.color_system is None:
|
||||
# No colour at all — pick dark so dim styles read correctly when forced
|
||||
# to ANSI later, but in practice nothing will be coloured.
|
||||
return True
|
||||
|
||||
colorfgbg = os.environ.get("COLORFGBG", "")
|
||||
if ";" in colorfgbg:
|
||||
bg = colorfgbg.split(";")[-1].strip()
|
||||
if bg.isdigit():
|
||||
return int(bg) in {0, 1, 2, 3, 4, 5, 6, 7, 8} # low ANSI ⇒ dark
|
||||
|
||||
if os.environ.get("TERM_PROGRAM", "").lower() == "apple_terminal":
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ThemeStyles:
|
||||
"""Immutable bundle of Rich :class:`Style` instances used CLI-wide."""
|
||||
|
||||
primary: Style
|
||||
"""Brand orange. Reserved for wordmark, primary CTA, agent identity."""
|
||||
|
||||
primary_dim: Style
|
||||
"""Slightly muted brand orange for hover/inactive variants."""
|
||||
|
||||
success: Style
|
||||
"""Tool completed, backtest passed, env saved (green)."""
|
||||
|
||||
danger: Style
|
||||
"""Failures, validation errors, destructive prompts (red)."""
|
||||
|
||||
warning: Style
|
||||
"""Tool running, partial state, non-blocking caution (amber)."""
|
||||
|
||||
info: Style
|
||||
"""Hints, secondary headings, neutral attention (cyan)."""
|
||||
|
||||
muted: Style
|
||||
"""Captions, dim metadata, separators (dim grey)."""
|
||||
|
||||
bold: Style
|
||||
"""Section headers, table column titles."""
|
||||
|
||||
label: Style
|
||||
"""Inline labels (e.g. ``Model:``) — slightly bolder than ``muted``."""
|
||||
|
||||
accent_bg: Style
|
||||
"""Reverse-video accent — used for the slash-typeahead highlight."""
|
||||
|
||||
|
||||
_NO_COLOR: Final[bool] = "NO_COLOR" in os.environ
|
||||
|
||||
|
||||
def _build_styles(dark: bool, no_color: bool) -> _ThemeStyles:
|
||||
"""Construct the style bundle for the current terminal mode."""
|
||||
|
||||
if no_color:
|
||||
plain = Style()
|
||||
return _ThemeStyles(
|
||||
primary=Style(bold=True),
|
||||
primary_dim=Style(),
|
||||
success=Style(),
|
||||
danger=Style(bold=True),
|
||||
warning=Style(),
|
||||
info=Style(),
|
||||
muted=Style(dim=True),
|
||||
bold=Style(bold=True),
|
||||
label=Style(bold=True),
|
||||
accent_bg=Style(reverse=True),
|
||||
)
|
||||
|
||||
brand = BRAND_ORANGE_DARK if dark else BRAND_ORANGE_LIGHT
|
||||
brand_dim = "#a35a04" if not dark else "#c87a2f"
|
||||
|
||||
return _ThemeStyles(
|
||||
primary=Style(color=brand, bold=True),
|
||||
primary_dim=Style(color=brand_dim),
|
||||
success=Style(color="#16a34a", bold=True),
|
||||
danger=Style(color="#dc2626", bold=True),
|
||||
warning=Style(color="#d97706"), # amber for in-flight tools
|
||||
info=Style(color="#0891b2"),
|
||||
muted=Style(color="#737373", dim=True) if not dark else Style(color="#9ca3af", dim=True),
|
||||
bold=Style(bold=True),
|
||||
label=Style(color="#525252", bold=True) if not dark else Style(color="#d4d4d8", bold=True),
|
||||
accent_bg=Style(color=brand, reverse=True, bold=True),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Singleton console + style accessors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_console() -> Console:
|
||||
"""Create the shared Rich console.
|
||||
|
||||
``force_terminal`` is *not* set: Rich's own ``isatty`` detection is
|
||||
correct and forcing it leaks ANSI escapes into ``docker exec -i`` /
|
||||
piped output (nanobot lesson, issue #3265).
|
||||
|
||||
``no_color`` honours ``NO_COLOR`` (https://no-color.org).
|
||||
"""
|
||||
|
||||
return Console(
|
||||
no_color=_NO_COLOR,
|
||||
soft_wrap=False,
|
||||
highlight=False,
|
||||
emoji=False, # project rule: no emoji anywhere
|
||||
markup=True,
|
||||
stderr=False,
|
||||
legacy_windows=False if sys.platform == "win32" else None,
|
||||
)
|
||||
|
||||
|
||||
_console: Console = _make_console()
|
||||
_dark: bool = _is_dark_terminal(_console)
|
||||
_styles: _ThemeStyles = _build_styles(_dark, _NO_COLOR or _console.color_system is None)
|
||||
|
||||
|
||||
def get_console() -> Console:
|
||||
"""Return the shared console instance.
|
||||
|
||||
Returns:
|
||||
The single :class:`Console` everyone in ``agent/cli`` writes to.
|
||||
"""
|
||||
|
||||
return _console
|
||||
|
||||
|
||||
def is_dark() -> bool:
|
||||
"""Return ``True`` if dark-mode styles are active."""
|
||||
|
||||
return _dark
|
||||
|
||||
|
||||
class Theme:
|
||||
"""Namespace of Rich :class:`Style` instances used across the CLI.
|
||||
|
||||
Use as attribute access (``Theme.primary``) so that swapping the
|
||||
underlying palette only requires editing this module. Each attribute is a
|
||||
:class:`rich.style.Style` and can be passed to ``console.print`` directly
|
||||
or composed via ``Text("…", style=Theme.primary)``.
|
||||
|
||||
Example:
|
||||
>>> from cli.theme import Theme, get_console
|
||||
>>> get_console().print("Vibe-Trading", style=Theme.primary)
|
||||
"""
|
||||
|
||||
primary: Final[Style] = _styles.primary
|
||||
primary_dim: Final[Style] = _styles.primary_dim
|
||||
success: Final[Style] = _styles.success
|
||||
danger: Final[Style] = _styles.danger
|
||||
warning: Final[Style] = _styles.warning
|
||||
info: Final[Style] = _styles.info
|
||||
muted: Final[Style] = _styles.muted
|
||||
bold: Final[Style] = _styles.bold
|
||||
label: Final[Style] = _styles.label
|
||||
accent_bg: Final[Style] = _styles.accent_bg
|
||||
|
||||
# Convenience aliases used by intro / stream
|
||||
brand_hex: Final[str] = BRAND_ORANGE_DARK if _dark else BRAND_ORANGE_LIGHT
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Theme",
|
||||
"get_console",
|
||||
"is_dark",
|
||||
"BRAND_ORANGE_LIGHT",
|
||||
"BRAND_ORANGE_DARK",
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Visual building blocks for the interactive CLI."""
|
||||
|
||||
from cli.ui.banner import print_banner
|
||||
from cli.ui.rail import RailRunDashboard
|
||||
from cli.ui.transcript import render_answer, render_elapsed_status, render_prompt_footer, render_recap
|
||||
|
||||
__all__ = [
|
||||
"RailRunDashboard",
|
||||
"print_banner",
|
||||
"render_answer",
|
||||
"render_elapsed_status",
|
||||
"render_prompt_footer",
|
||||
"render_recap",
|
||||
]
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Large startup banner for the interactive CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
|
||||
from cli._version import __version__ as _VERSION
|
||||
from cli.theme import Theme
|
||||
|
||||
_LOGO: Final[tuple[str, ...]] = (
|
||||
r"__ ___ _ _______ _ _ ",
|
||||
r"\ \ / (_) | |__ __| | (_) ",
|
||||
r" \ \ / / _| |__ ___ | |_ __ __ _ __| |_ _ __ __ _ ",
|
||||
r" \ \/ / | | '_ \ / _ \ | | '__/ _` |/ _` | | '_ \ / _` |",
|
||||
r" \ / | | |_) | __/ | | | | (_| | (_| | | | | | (_| |",
|
||||
r" \/ |_|_.__/ \___| |_|_| \__,_|\__,_|_|_| |_|\__, |",
|
||||
r" __/ |",
|
||||
r" |___/ ",
|
||||
)
|
||||
|
||||
_GRADIENT_START: Final[tuple[int, int, int]] = (0x25, 0x8B, 0xFF)
|
||||
_GRADIENT_END: Final[tuple[int, int, int]] = (0xA5, 0xCF, 0xFF)
|
||||
|
||||
|
||||
def _lerp(start: int, end: int, ratio: float) -> int:
|
||||
return round(start + (end - start) * ratio)
|
||||
|
||||
|
||||
def _gradient_style(index: int, total: int) -> str:
|
||||
ratio = 0.0 if total <= 1 else index / (total - 1)
|
||||
red = _lerp(_GRADIENT_START[0], _GRADIENT_END[0], ratio)
|
||||
green = _lerp(_GRADIENT_START[1], _GRADIENT_END[1], ratio)
|
||||
blue = _lerp(_GRADIENT_START[2], _GRADIENT_END[2], ratio)
|
||||
return f"bold #{red:02x}{green:02x}{blue:02x}"
|
||||
|
||||
|
||||
def _gradient_line(line: str) -> Text:
|
||||
text = Text()
|
||||
total = max(1, len(line.rstrip()))
|
||||
for idx, char in enumerate(line):
|
||||
text.append(char, style=_gradient_style(idx, total) if char != " " else None)
|
||||
return text
|
||||
|
||||
|
||||
def _center_pad(console: Console, width: int) -> str:
|
||||
columns = console.size.width
|
||||
if columns >= width + 4:
|
||||
return " " * max(0, (columns - width) // 2)
|
||||
return " "
|
||||
|
||||
|
||||
def print_banner(
|
||||
console: Console,
|
||||
*,
|
||||
model: str,
|
||||
skills: int,
|
||||
tools: int,
|
||||
sessions: int,
|
||||
version: str = _VERSION,
|
||||
mode: str = "cli",
|
||||
**_: object,
|
||||
) -> None:
|
||||
"""Render the cold-start banner once before the prompt appears."""
|
||||
|
||||
del skills, tools, sessions
|
||||
width = max(len(line.rstrip()) for line in _LOGO)
|
||||
pad = _center_pad(console, width)
|
||||
|
||||
console.print()
|
||||
for line in _LOGO:
|
||||
rendered = Text(pad)
|
||||
rendered.append(_gradient_line(line.rstrip()))
|
||||
console.print(rendered)
|
||||
|
||||
meta = Text(pad)
|
||||
meta.append(f"vibe-trading v{version} · {mode} · {model}", style=Theme.muted)
|
||||
console.print(meta)
|
||||
console.print()
|
||||
@@ -0,0 +1,599 @@
|
||||
"""Codex-style rail renderer for agent activity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import textwrap
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import PureWindowsPath
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from rich.console import Group
|
||||
from rich.text import Text
|
||||
|
||||
from cli.theme import Theme
|
||||
from cli.utils.thinking_verbs import pick_thinking_verb
|
||||
|
||||
StepStatus = Literal["active", "done", "error", "warning"]
|
||||
|
||||
_MARKER = "#8a8f98"
|
||||
_MARKER_ACTIVE = "#d1d5db"
|
||||
_ACTION = "bold white"
|
||||
_CMD = "bold #3b82f6"
|
||||
_ARG = "#7dd3fc"
|
||||
_PATH = "#f0abfc"
|
||||
_DETAIL = "#6b7280"
|
||||
_DONE = "bold #22c55e"
|
||||
_ERROR = "bold #ef4444"
|
||||
_WARNING = "bold #f59e0b"
|
||||
_PLAIN_TAG = re.compile(
|
||||
r"\[/?(?:#[0-9a-fA-F]{3,6}|[a-zA-Z][a-zA-Z0-9_ -]*)(?:\s+[^\]]+)?\]"
|
||||
)
|
||||
_PATH_TOKEN = re.compile(
|
||||
r'("[^"]*[\\/][^"]*"|[A-Za-z]:[\\/][^\s,)]+|(?:\.{1,2}[\\/])?[^\s"]*[\\/][^\s"]+)'
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RailStep:
|
||||
title: str
|
||||
tool: str = ""
|
||||
args: Any = None
|
||||
status: StepStatus = "active"
|
||||
lines: list[str] = field(default_factory=list)
|
||||
started_at: float = field(default_factory=time.monotonic)
|
||||
duration_s: float = 0.0
|
||||
|
||||
|
||||
def _strip_markup(value: Any) -> str:
|
||||
return _PLAIN_TAG.sub("", str(value or "")).replace("\r\n", "\n").strip()
|
||||
|
||||
|
||||
def _collapse_ws(value: Any) -> str:
|
||||
return re.sub(r"\s+", " ", _strip_markup(value)).strip()
|
||||
|
||||
|
||||
def _coerce_int(value: Any) -> int:
|
||||
try:
|
||||
return max(0, int(value or 0))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _format_tool_name(name: str) -> str:
|
||||
cleaned = str(name or "tool").strip().replace("-", " ").replace("_", " ")
|
||||
if cleaned.lower().startswith("get "):
|
||||
cleaned = cleaned[4:]
|
||||
return " ".join(part[:1].upper() + part[1:] for part in cleaned.split()) or "Tool"
|
||||
|
||||
|
||||
def _tool_title(tool: str, args: Any) -> str:
|
||||
lower = str(tool or "tool").lower()
|
||||
if lower in {"bash", "background_run", "shell"}:
|
||||
return "shell command"
|
||||
if lower == "load_skill":
|
||||
return "skill"
|
||||
if lower in {"read_file", "file_read"}:
|
||||
return "file"
|
||||
if lower in {"write_file", "edit_file"}:
|
||||
return "file"
|
||||
if lower in {"read_url", "web_fetch", "fetch", "read_url_tool"}:
|
||||
return "url"
|
||||
if "search" in lower:
|
||||
return "web"
|
||||
if lower in {"get_market_data", "market_data", "stock_price", "get_stock_price"}:
|
||||
return "market data"
|
||||
if lower in {"answer", "final_answer"}:
|
||||
return "answer"
|
||||
return _format_tool_name(tool)
|
||||
|
||||
|
||||
def _arg_value(args: Any, *keys: str) -> str:
|
||||
if not isinstance(args, dict):
|
||||
return ""
|
||||
for key in keys:
|
||||
value = args.get(key)
|
||||
if value:
|
||||
return _collapse_ws(value)
|
||||
return ""
|
||||
|
||||
|
||||
def _initial_detail(tool: str, args: Any) -> str | None:
|
||||
if not isinstance(args, dict):
|
||||
return None
|
||||
lower = str(tool or "").lower()
|
||||
if lower in {"bash", "background_run", "shell"}:
|
||||
return None
|
||||
if lower in {"read_file", "file_read", "write_file", "edit_file"}:
|
||||
return _arg_value(args, "path", "file_path") or None
|
||||
if lower in {"read_url", "web_fetch", "fetch", "read_url_tool"}:
|
||||
url = _arg_value(args, "url")
|
||||
return f"fetching {_short_url(url)}" if url else None
|
||||
if "search" in lower:
|
||||
query = _arg_value(args, "query", "q")
|
||||
return query or None
|
||||
symbol = _arg_value(args, "symbol", "ticker", "code", "asset")
|
||||
return symbol or None
|
||||
|
||||
|
||||
def _result_summary(tool: str, status: str, preview: Any) -> list[str]:
|
||||
text = _strip_markup(preview)
|
||||
lower = str(tool or "").lower()
|
||||
if lower == "load_skill":
|
||||
match = re.search(r"<skill\s+name=[\"']?([^\"'>\s]+)", text)
|
||||
if match:
|
||||
return [f"loaded {match.group(1)}"]
|
||||
|
||||
payload: Any = None
|
||||
compact = _collapse_ws(text)
|
||||
source = text.strip()
|
||||
if source.startswith("{") or source.startswith("["):
|
||||
try:
|
||||
payload = json.loads(source)
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
payload = json.loads(compact)
|
||||
except json.JSONDecodeError:
|
||||
payload = None
|
||||
|
||||
if isinstance(payload, dict):
|
||||
if status != "ok":
|
||||
detail = payload.get("error") or payload.get("stderr") or payload.get("message") or compact
|
||||
return _compact_lines(f"Error: {_collapse_ws(detail)}", max_lines=3)
|
||||
if lower in {"bash", "background_run", "shell"}:
|
||||
stdout = payload.get("stdout")
|
||||
stderr = payload.get("stderr")
|
||||
if stdout:
|
||||
return _compact_lines(str(stdout), max_lines=5)
|
||||
if stderr:
|
||||
return _compact_lines(str(stderr), max_lines=4)
|
||||
return ["completed"]
|
||||
if lower in {"read_file", "file_read"}:
|
||||
content = str(payload.get("content") or "")
|
||||
return [f"read {len(content):,} chars"] if content else ["read file"]
|
||||
if lower == "load_skill":
|
||||
content = str(payload.get("content") or "")
|
||||
match = re.search(r"<skill\s+name=[\"']([^\"']+)[\"']", content)
|
||||
return [f"loaded {match.group(1)}"] if match else ["loaded skill"]
|
||||
if lower in {"read_url", "web_fetch", "fetch", "read_url_tool"}:
|
||||
title = payload.get("title")
|
||||
if title:
|
||||
return [_clip(_collapse_ws(title), 96)]
|
||||
content = payload.get("content") or payload.get("markdown") or payload.get("text")
|
||||
if content:
|
||||
return [f"read {len(str(content)):,} chars"]
|
||||
results = payload.get("results")
|
||||
if isinstance(results, list):
|
||||
return [f"Did 1 search · {len(results)} results"]
|
||||
for key in ("summary", "message", "stdout", "content"):
|
||||
if payload.get(key):
|
||||
return _compact_lines(str(payload[key]), max_lines=4)
|
||||
|
||||
if not compact:
|
||||
return []
|
||||
if status != "ok":
|
||||
return _compact_lines(f"Error: {compact}", max_lines=3)
|
||||
return _compact_lines(text, max_lines=4)
|
||||
|
||||
|
||||
def _compact_lines(value: str, *, max_lines: int) -> list[str]:
|
||||
raw_lines = [line.rstrip() for line in _strip_markup(value).splitlines() if line.strip()]
|
||||
if not raw_lines:
|
||||
return []
|
||||
clipped = [_clip_preserve(line, 112) for line in raw_lines]
|
||||
if len(clipped) <= max_lines:
|
||||
return clipped
|
||||
head_count = max(1, max_lines // 2)
|
||||
tail_count = max(1, max_lines - head_count - 1)
|
||||
hidden = len(clipped) - head_count - tail_count
|
||||
return [
|
||||
*clipped[:head_count],
|
||||
f"... +{hidden} lines (ctrl + t to view transcript)",
|
||||
*clipped[-tail_count:],
|
||||
]
|
||||
|
||||
|
||||
def _short_url(url: str, limit: int = 64) -> str:
|
||||
if not url:
|
||||
return ""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except ValueError:
|
||||
return _clip(url, limit)
|
||||
if not parsed.netloc:
|
||||
return _clip(url, limit)
|
||||
display = parsed.netloc + parsed.path
|
||||
return _clip(display.rstrip("/") or parsed.netloc, limit)
|
||||
|
||||
|
||||
def _clip(text: str, limit: int) -> str:
|
||||
text = _collapse_ws(text)
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: max(1, limit - 1)].rstrip() + "…"
|
||||
|
||||
|
||||
def _clip_preserve(text: str, limit: int) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: max(1, limit - 1)].rstrip() + "…"
|
||||
|
||||
|
||||
def _wrap(text: str, width: int) -> list[str]:
|
||||
if width <= 8:
|
||||
return [_clip_preserve(text, max(1, width))]
|
||||
return textwrap.wrap(
|
||||
text,
|
||||
width=width,
|
||||
break_long_words=False,
|
||||
break_on_hyphens=False,
|
||||
replace_whitespace=False,
|
||||
drop_whitespace=False,
|
||||
) or [""]
|
||||
|
||||
|
||||
def _command_from_args(args: Any) -> str:
|
||||
return _arg_value(args, "command", "cmd")
|
||||
|
||||
|
||||
def _split_command(command: str) -> tuple[str, str]:
|
||||
command = _collapse_ws(command)
|
||||
if not command:
|
||||
return "", ""
|
||||
if command.startswith('"'):
|
||||
end = command.find('"', 1)
|
||||
if end > 0:
|
||||
return _display_executable(command[1:end]), command[end + 1 :].strip()
|
||||
first, _, rest = command.partition(" ")
|
||||
return _display_executable(first.strip('"')), rest.strip()
|
||||
|
||||
|
||||
def _display_executable(executable: str) -> str:
|
||||
if not executable:
|
||||
return ""
|
||||
cleaned = executable.strip('"')
|
||||
if "\\" in cleaned or "/" in cleaned:
|
||||
cleaned = PureWindowsPath(cleaned).name
|
||||
if cleaned.lower().endswith(".exe"):
|
||||
cleaned = cleaned[:-4]
|
||||
return cleaned or executable
|
||||
|
||||
|
||||
def _append_arg_text(line: Text, args: str, *, limit: int) -> None:
|
||||
args = _clip(args, limit)
|
||||
if not args:
|
||||
return
|
||||
pos = 0
|
||||
for match in _PATH_TOKEN.finditer(args):
|
||||
if match.start() > pos:
|
||||
line.append(args[pos : match.start()], style=_ARG)
|
||||
line.append(match.group(0), style=_PATH)
|
||||
pos = match.end()
|
||||
if pos < len(args):
|
||||
line.append(args[pos:], style=_ARG)
|
||||
|
||||
|
||||
class RailRunDashboard:
|
||||
"""Compatibility dashboard that renders agent events like Codex transcripts."""
|
||||
|
||||
def __init__(self, prompt: str, max_iter: int) -> None:
|
||||
self.prompt = prompt
|
||||
self.max_iter = max_iter
|
||||
self.start_time = time.monotonic()
|
||||
self.iterations = 0
|
||||
self.live: Any = None
|
||||
self.steps: list[RailStep] = []
|
||||
self.latest_text = ""
|
||||
self.thinking_active = True
|
||||
self.completion_summary: str | None = None
|
||||
self.status: str = "running"
|
||||
self.thinking_verb = pick_thinking_verb()
|
||||
self.input_tokens = 0
|
||||
self.output_tokens = 0
|
||||
self._last_progress_render = 0.0
|
||||
self._ticker_stop = threading.Event()
|
||||
self._ticker = threading.Thread(
|
||||
target=self._tick,
|
||||
daemon=True,
|
||||
name="vibe-rail-ticker",
|
||||
)
|
||||
self._ticker.start()
|
||||
|
||||
def _tick(self) -> None:
|
||||
while not self._ticker_stop.wait(1.0):
|
||||
if self.status == "running" and self.live is not None:
|
||||
self.refresh()
|
||||
|
||||
def close(self) -> None:
|
||||
self._ticker_stop.set()
|
||||
|
||||
def refresh(self) -> None:
|
||||
if self.live is not None:
|
||||
self.live.update(self.render())
|
||||
|
||||
def handle_event(self, event_type: str, data: dict[str, Any]) -> None:
|
||||
if event_type == "text_delta":
|
||||
delta = _collapse_ws(data.get("delta"))
|
||||
if delta:
|
||||
self.latest_text = (self.latest_text + delta).strip()[-500:]
|
||||
self._append_to_active(delta, throttle=True)
|
||||
self.refresh()
|
||||
return
|
||||
|
||||
if event_type == "thinking_done":
|
||||
self.thinking_active = False
|
||||
self.refresh()
|
||||
return
|
||||
|
||||
if event_type == "llm_usage":
|
||||
input_tokens = _coerce_int(data.get("input_tokens"))
|
||||
output_tokens = _coerce_int(data.get("output_tokens"))
|
||||
total_tokens = _coerce_int(data.get("total_tokens"))
|
||||
if input_tokens or output_tokens:
|
||||
self.input_tokens += input_tokens
|
||||
self.output_tokens += output_tokens
|
||||
else:
|
||||
self.output_tokens += total_tokens
|
||||
self.refresh()
|
||||
return
|
||||
|
||||
if event_type == "tool_call":
|
||||
self.thinking_active = False
|
||||
tool = str(data.get("tool") or "tool")
|
||||
args = data.get("arguments") or {}
|
||||
self.iterations += 1
|
||||
step = RailStep(title=_tool_title(tool, args), tool=tool, args=args)
|
||||
detail = _initial_detail(tool, args)
|
||||
if detail:
|
||||
step.lines.append(detail)
|
||||
self.steps.append(step)
|
||||
self.steps = self.steps[-10:]
|
||||
self.refresh()
|
||||
return
|
||||
|
||||
if event_type == "tool_progress":
|
||||
tool = str(data.get("tool") or "")
|
||||
step = self._active_step(tool)
|
||||
pieces: list[str] = []
|
||||
stage = _collapse_ws(data.get("stage"))
|
||||
current = data.get("current")
|
||||
total = data.get("total")
|
||||
message = _collapse_ws(data.get("message"))
|
||||
if stage:
|
||||
pieces.append(stage)
|
||||
if isinstance(current, int) and isinstance(total, int) and total > 0:
|
||||
pieces.append(f"{current}/{total}")
|
||||
if message:
|
||||
pieces.append(message)
|
||||
if pieces:
|
||||
step.lines.append(" · ".join(pieces))
|
||||
step.lines = step.lines[-6:]
|
||||
now = time.monotonic()
|
||||
if now - self._last_progress_render >= 0.2:
|
||||
self._last_progress_render = now
|
||||
self.refresh()
|
||||
return
|
||||
|
||||
if event_type == "tool_heartbeat":
|
||||
tool = str(data.get("tool") or "")
|
||||
self._active_step(tool)
|
||||
self.refresh()
|
||||
return
|
||||
|
||||
if event_type == "tool_result":
|
||||
tool = str(data.get("tool") or "")
|
||||
status = str(data.get("status") or "ok")
|
||||
step = self._active_step(tool)
|
||||
step.status = "done" if status == "ok" else "error"
|
||||
step.duration_s = float(data.get("elapsed_ms") or 0.0) / 1000
|
||||
for line in _result_summary(tool, status, data.get("preview")):
|
||||
if not step.lines or step.lines[-1] != line:
|
||||
if step.lines and step.lines[-1].startswith("still running…"):
|
||||
step.lines[-1] = line
|
||||
else:
|
||||
step.lines.append(line)
|
||||
step.lines = step.lines[-7:]
|
||||
self.refresh()
|
||||
return
|
||||
|
||||
if event_type == "compact":
|
||||
tokens = data.get("tokens_before", "?")
|
||||
self.steps.append(
|
||||
RailStep(
|
||||
title="context",
|
||||
tool="compact",
|
||||
status="warning",
|
||||
lines=[f"{tokens} tokens summarized"],
|
||||
duration_s=0.0,
|
||||
)
|
||||
)
|
||||
self.steps = self.steps[-10:]
|
||||
self.refresh()
|
||||
|
||||
def finish(self, result: dict[str, Any], elapsed: float | None = None) -> None:
|
||||
self.status = str(result.get("status") or "done")
|
||||
for step in self.steps:
|
||||
if step.status == "active":
|
||||
step.status = "done"
|
||||
if not step.duration_s and elapsed is not None:
|
||||
step.duration_s = max(0.0, elapsed)
|
||||
content = _collapse_ws(result.get("content"))
|
||||
reason = _collapse_ws(result.get("reason"))
|
||||
if content:
|
||||
self.completion_summary = "Done."
|
||||
elif reason:
|
||||
self.completion_summary = f"{self.status.title()}. {reason}"
|
||||
else:
|
||||
self.completion_summary = f"{self.status.title()}."
|
||||
self.thinking_active = False
|
||||
self.refresh()
|
||||
self.close()
|
||||
|
||||
def render(self) -> Group:
|
||||
width = max(44, shutil.get_terminal_size((88, 24)).columns)
|
||||
detail_width = max(18, width - 7)
|
||||
rows: list[Text] = []
|
||||
|
||||
for step in self.steps:
|
||||
rows.append(self._step_line(step, width=max(18, width - 3)))
|
||||
for line in self._render_lines(step):
|
||||
rows.extend(self._detail_lines(line, width=detail_width, active=step.status == "active"))
|
||||
|
||||
if self.completion_summary is None:
|
||||
rows.append(self._activity_line())
|
||||
|
||||
if self.completion_summary is not None:
|
||||
if rows:
|
||||
rows.append(Text(""))
|
||||
rows.append(self._finish_line(self.completion_summary))
|
||||
|
||||
return Group(*rows)
|
||||
|
||||
def _render_lines(self, step: RailStep) -> list[str]:
|
||||
lines = list(step.lines)
|
||||
return lines[-7:]
|
||||
|
||||
def _active_step(self, tool: str) -> RailStep:
|
||||
raw_tool = str(tool or "")
|
||||
for step in reversed(self.steps):
|
||||
if step.status == "active" and (not raw_tool or step.tool == raw_tool):
|
||||
return step
|
||||
step = RailStep(title=_tool_title(raw_tool or "tool", None), tool=raw_tool)
|
||||
self.steps.append(step)
|
||||
self.steps = self.steps[-10:]
|
||||
return step
|
||||
|
||||
def _append_to_active(self, text: str, *, throttle: bool) -> None:
|
||||
if not text:
|
||||
return
|
||||
now = time.monotonic()
|
||||
if throttle and now - self._last_progress_render < 0.2:
|
||||
return
|
||||
self._last_progress_render = now
|
||||
if self.steps and self.steps[-1].status == "active":
|
||||
self.steps[-1].lines.append(_clip(text, 112))
|
||||
self.steps[-1].lines = self.steps[-1].lines[-6:]
|
||||
|
||||
def _activity_line(self) -> Text:
|
||||
elapsed = time.monotonic() - self.start_time
|
||||
line = Text()
|
||||
line.append("·", style=_MARKER_ACTIVE)
|
||||
line.append(" ")
|
||||
line.append(self.thinking_verb, style=Theme.muted)
|
||||
line.append(f" ({elapsed:.0f}s)", style=Theme.muted)
|
||||
return line
|
||||
|
||||
def _step_line(self, step: RailStep, *, width: int) -> Text:
|
||||
marker_style = _MARKER_ACTIVE if step.status == "active" else _MARKER
|
||||
if step.status == "error":
|
||||
marker_style = _ERROR
|
||||
elif step.status == "warning":
|
||||
marker_style = _WARNING
|
||||
|
||||
line = Text()
|
||||
line.append("•", style=marker_style)
|
||||
line.append(" ")
|
||||
|
||||
lower = step.tool.lower()
|
||||
if lower in {"bash", "background_run", "shell"}:
|
||||
verb = "Running" if step.status == "active" else "Ran"
|
||||
command = _command_from_args(step.args)
|
||||
exe, rest = _split_command(command)
|
||||
duration = self._duration_label(step)
|
||||
budget = max(12, width - len(verb) - len(exe) - len(duration) - 7)
|
||||
line.append(verb, style=_ACTION)
|
||||
if exe:
|
||||
line.append(" ")
|
||||
line.append(exe, style=_CMD)
|
||||
if rest:
|
||||
line.append(" ")
|
||||
_append_arg_text(line, rest, limit=budget)
|
||||
elif not exe:
|
||||
line.append(" shell command", style=_ACTION)
|
||||
self._append_duration(line, duration)
|
||||
return line
|
||||
|
||||
action = self._action_label(step)
|
||||
duration = self._duration_label(step)
|
||||
line.append(action, style=_ACTION)
|
||||
detail = self._title_detail(step)
|
||||
if detail:
|
||||
line.append(" ")
|
||||
if _looks_like_path(detail):
|
||||
line.append(_clip(detail, max(12, width - len(action) - len(duration) - 6)), style=_PATH)
|
||||
else:
|
||||
line.append(_clip(detail, max(12, width - len(action) - len(duration) - 6)), style=_ARG)
|
||||
self._append_duration(line, duration)
|
||||
return line
|
||||
|
||||
def _duration_label(self, step: RailStep) -> str:
|
||||
return ""
|
||||
|
||||
def _append_duration(self, line: Text, duration: str) -> None:
|
||||
if duration:
|
||||
line.append(" ")
|
||||
line.append(duration, style=Theme.muted)
|
||||
|
||||
def _action_label(self, step: RailStep) -> str:
|
||||
lower = step.tool.lower()
|
||||
active = step.status == "active"
|
||||
if lower == "load_skill":
|
||||
return "Loading skill…" if active else "Loaded skill"
|
||||
if lower in {"read_file", "file_read"}:
|
||||
return "Reading file…" if active else "Read file"
|
||||
if lower in {"write_file", "edit_file"}:
|
||||
return "Writing file…" if active else "Wrote file"
|
||||
if lower in {"read_url", "web_fetch", "fetch", "read_url_tool"}:
|
||||
return "Fetching…" if active else "Fetched"
|
||||
if "search" in lower:
|
||||
return "Searching web…" if active else "Searched web"
|
||||
if lower in {"get_market_data", "market_data", "stock_price", "get_stock_price"}:
|
||||
return "Reading market data…" if active else "Read market data"
|
||||
if lower == "compact":
|
||||
return "Compacted context"
|
||||
return step.title
|
||||
|
||||
def _title_detail(self, step: RailStep) -> str:
|
||||
args = step.args
|
||||
lower = step.tool.lower()
|
||||
if lower == "load_skill":
|
||||
return _arg_value(args, "name", "skill")
|
||||
if lower in {"read_file", "file_read", "write_file", "edit_file"}:
|
||||
return _arg_value(args, "path", "file_path")
|
||||
if lower in {"read_url", "web_fetch", "fetch", "read_url_tool"}:
|
||||
return _short_url(_arg_value(args, "url"))
|
||||
if "search" in lower:
|
||||
query = _arg_value(args, "query", "q")
|
||||
return f'"{query}"' if query else ""
|
||||
return _arg_value(args, "symbol", "ticker", "code", "asset")
|
||||
|
||||
def _detail_lines(self, body: str, *, width: int, active: bool) -> list[Text]:
|
||||
style = _DETAIL
|
||||
parts = _wrap(_strip_markup(body), width)
|
||||
rows: list[Text] = []
|
||||
for index, part in enumerate(parts):
|
||||
line = Text(" ")
|
||||
if index == 0:
|
||||
line.append("└", style=style)
|
||||
line.append(" ")
|
||||
else:
|
||||
line.append(" ")
|
||||
line.append(part, style=style)
|
||||
rows.append(line)
|
||||
return rows
|
||||
|
||||
def _finish_line(self, summary: str) -> Text:
|
||||
line = Text()
|
||||
line.append("•", style=_DONE if self.status in {"success", "ok", "done"} else _WARNING)
|
||||
line.append(" ")
|
||||
line.append(summary, style="bold")
|
||||
return line
|
||||
|
||||
|
||||
def _looks_like_path(value: str) -> bool:
|
||||
return bool(_PATH_TOKEN.search(value))
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Transcript helpers for the interactive CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
from rich import box
|
||||
from rich.console import Group
|
||||
from rich.markdown import Markdown
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from cli.theme import Theme
|
||||
|
||||
_PIPE_SEPARATOR = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$")
|
||||
# Standalone markdown horizontal rule (---, ***, ___). Stripped from
|
||||
# assistant answers so they don't render as full-width terminal lines.
|
||||
_HR_LINE = re.compile(r"^\s*(?:-{3,}|\*{3,}|_{3,})\s*$")
|
||||
_PROMPT_BORDER = "#4b5563"
|
||||
_MUTED = "#6b7280"
|
||||
|
||||
|
||||
def render_prompt_footer(width: int | None = None) -> Text:
|
||||
"""Bottom border printed after prompt_toolkit accepts the user input."""
|
||||
|
||||
cols = width or shutil.get_terminal_size((88, 24)).columns
|
||||
return Text("─" * max(10, cols), style=_PROMPT_BORDER)
|
||||
|
||||
|
||||
def render_recap(history: list[dict[str, str]]) -> Text | None:
|
||||
"""Return a deterministic dim recap of the previous turn."""
|
||||
|
||||
user = ""
|
||||
assistant = ""
|
||||
for message in reversed(history):
|
||||
role = message.get("role")
|
||||
content = (message.get("content") or "").strip()
|
||||
if not content:
|
||||
continue
|
||||
if role == "assistant" and not assistant:
|
||||
assistant = content
|
||||
elif role == "user" and not user:
|
||||
user = content
|
||||
if user and assistant:
|
||||
break
|
||||
if not user and not assistant:
|
||||
return None
|
||||
|
||||
parts = []
|
||||
if user:
|
||||
parts.append(f"Last request: {_sentence(user, 92)}")
|
||||
if assistant:
|
||||
parts.append(f"Result: {_sentence(assistant, 128)}")
|
||||
line = Text("※ recap: ", style=Theme.muted)
|
||||
line.append("; ".join(parts), style=Theme.muted)
|
||||
return line
|
||||
|
||||
|
||||
def render_elapsed_status(elapsed: float) -> Text:
|
||||
"""Render the post-run timing line."""
|
||||
|
||||
line = Text("✻ ", style=_MUTED)
|
||||
line.append("Analyzed", style=Theme.muted)
|
||||
line.append(f" for {_format_duration(elapsed)}", style=Theme.muted)
|
||||
return line
|
||||
|
||||
|
||||
def render_answer(content: str) -> Group:
|
||||
"""Render assistant Markdown, upgrading pipe tables to Rich tables."""
|
||||
|
||||
blocks: list[Any] = []
|
||||
pending: list[str] = []
|
||||
|
||||
def flush_pending() -> None:
|
||||
if pending:
|
||||
blocks.append(Markdown("\n".join(pending).strip()))
|
||||
pending.clear()
|
||||
|
||||
lines = content.splitlines()
|
||||
index = 0
|
||||
while index < len(lines):
|
||||
if _is_table_start(lines, index):
|
||||
flush_pending()
|
||||
table_lines = [lines[index], lines[index + 1]]
|
||||
index += 2
|
||||
while index < len(lines) and _is_pipe_row(lines[index]):
|
||||
table_lines.append(lines[index])
|
||||
index += 1
|
||||
table = _parse_table(table_lines)
|
||||
if table is not None:
|
||||
blocks.append(table)
|
||||
else:
|
||||
pending.extend(table_lines)
|
||||
continue
|
||||
if _HR_LINE.match(lines[index]):
|
||||
# Drop standalone horizontal rules — Rich renders them as a
|
||||
# full-width terminal line that looks like a separator border.
|
||||
index += 1
|
||||
continue
|
||||
pending.append(lines[index])
|
||||
index += 1
|
||||
|
||||
flush_pending()
|
||||
return Group(*blocks) if blocks else Group(Text(content))
|
||||
|
||||
|
||||
def _is_table_start(lines: list[str], index: int) -> bool:
|
||||
return (
|
||||
index + 1 < len(lines)
|
||||
and _is_pipe_row(lines[index])
|
||||
and bool(_PIPE_SEPARATOR.match(lines[index + 1]))
|
||||
)
|
||||
|
||||
|
||||
def _is_pipe_row(line: str) -> bool:
|
||||
stripped = line.strip()
|
||||
return stripped.startswith("|") and stripped.endswith("|") and stripped.count("|") >= 2
|
||||
|
||||
|
||||
def _parse_table(lines: list[str]) -> Table | None:
|
||||
if len(lines) < 2:
|
||||
return None
|
||||
headers = _split_row(lines[0])
|
||||
rows = [_split_row(line) for line in lines[2:] if _is_pipe_row(line)]
|
||||
if not headers or not rows:
|
||||
return None
|
||||
|
||||
table = Table(
|
||||
box=box.SQUARE,
|
||||
show_header=True,
|
||||
show_lines=True,
|
||||
header_style="bold white",
|
||||
border_style="dim",
|
||||
padding=(0, 1),
|
||||
)
|
||||
for header in headers:
|
||||
table.add_column(_clean_inline_markdown(header), overflow="fold")
|
||||
for row in rows:
|
||||
cells = list(row[: len(headers)])
|
||||
while len(cells) < len(headers):
|
||||
cells.append("")
|
||||
table.add_row(*[_clean_inline_markdown(cell) for cell in cells])
|
||||
return table
|
||||
|
||||
|
||||
def _split_row(line: str) -> list[str]:
|
||||
stripped = line.strip().strip("|")
|
||||
return [cell.strip() for cell in stripped.split("|")]
|
||||
|
||||
|
||||
def _clean_inline_markdown(text: str) -> str:
|
||||
cleaned = re.sub(r"(\*\*|__)(.*?)\1", r"\2", text)
|
||||
cleaned = re.sub(r"(\*|_)(.*?)\1", r"\2", cleaned)
|
||||
cleaned = re.sub(r"`([^`]+)`", r"\1", cleaned)
|
||||
return cleaned.strip()
|
||||
|
||||
|
||||
def _sentence(text: str, limit: int) -> str:
|
||||
collapsed = re.sub(r"\s+", " ", _clean_inline_markdown(text)).strip()
|
||||
if len(collapsed) <= limit:
|
||||
return collapsed
|
||||
return collapsed[: max(1, limit - 1)].rstrip() + "…"
|
||||
|
||||
|
||||
def _format_duration(seconds: float) -> str:
|
||||
total = max(0, int(round(seconds)))
|
||||
if total < 60:
|
||||
return f"{total}s"
|
||||
minutes, secs = divmod(total, 60)
|
||||
if minutes < 60:
|
||||
return f"{minutes}m {secs:02d}s"
|
||||
hours, minutes = divmod(minutes, 60)
|
||||
return f"{hours}h {minutes:02d}m {secs:02d}s"
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Small, pure helpers shared across CLI components.
|
||||
|
||||
Everything here is stateless and side-effect free — safe to import from any
|
||||
module in ``agent/cli``. Larger or stateful helpers belong in their own module
|
||||
(see ``cli/stream.py`` for the streaming renderer).
|
||||
"""
|
||||
|
||||
from cli.utils.format import abbreviate_num, format_duration, format_tokens
|
||||
from cli.utils.thinking_verbs import pick_thinking_verb
|
||||
|
||||
__all__ = [
|
||||
"abbreviate_num",
|
||||
"format_duration",
|
||||
"format_tokens",
|
||||
"pick_thinking_verb",
|
||||
]
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Pure formatting helpers for the CLI surface.
|
||||
|
||||
These mirror the formatting conventions used in the web app (see
|
||||
``frontend/src/lib/format.ts``) so a duration printed in the CLI reads the
|
||||
same as one printed in the chat bubble:
|
||||
|
||||
* ``format_duration(ms_or_s)`` → ``"230ms" | "1.4s" | "4m 12s"``
|
||||
* ``format_tokens(n)`` → ``"1.2k tokens" | "452 tokens"``
|
||||
* ``abbreviate_num(n)`` → ``"$0.003" | "12.4M" | "452"``
|
||||
|
||||
All functions are pure and tolerate ``None`` / negative inputs so callers can
|
||||
hand them raw counter values without pre-validation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Union
|
||||
|
||||
Number = Union[int, float]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Duration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def format_duration(value: Number | None, *, unit: str = "ms") -> str:
|
||||
"""Render a duration as a short, human-readable string.
|
||||
|
||||
Args:
|
||||
value: Numeric duration. Treated as milliseconds by default — this
|
||||
matches the rest of the codebase (tool callbacks, ``elapsed_ms``
|
||||
in ``cli/_legacy.py``, the spinner clock). Pass ``unit="s"`` if
|
||||
you already have seconds.
|
||||
unit: Either ``"ms"`` (default) or ``"s"``.
|
||||
|
||||
Returns:
|
||||
Compact label, e.g. ``"230ms"``, ``"1.4s"``, ``"2m 05s"``. ``None``
|
||||
and negative values render as ``"—"``.
|
||||
"""
|
||||
|
||||
if value is None:
|
||||
return "—"
|
||||
try:
|
||||
v = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
if v < 0:
|
||||
return "—"
|
||||
|
||||
if unit == "s":
|
||||
seconds = v
|
||||
elif unit == "ms":
|
||||
seconds = v / 1000.0
|
||||
else:
|
||||
raise ValueError(f"unit must be 'ms' or 's', got {unit!r}")
|
||||
|
||||
if seconds < 1.0:
|
||||
return f"{int(round(seconds * 1000))}ms"
|
||||
if seconds < 60:
|
||||
# 1.4s, 12.0s — keep one decimal to communicate sub-second jitter
|
||||
return f"{seconds:.1f}s"
|
||||
minutes, rem = divmod(int(round(seconds)), 60)
|
||||
if minutes < 60:
|
||||
return f"{minutes}m {rem:02d}s"
|
||||
hours, minutes = divmod(minutes, 60)
|
||||
return f"{hours}h {minutes:02d}m"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tokens
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def format_tokens(count: Number | None) -> str:
|
||||
"""Render a token count with a thousands-suffix.
|
||||
|
||||
Args:
|
||||
count: Integer-ish token count. ``None`` / negatives → ``"0 tokens"``.
|
||||
|
||||
Returns:
|
||||
E.g. ``"452 tokens"``, ``"1.2k tokens"``, ``"3.4M tokens"``.
|
||||
"""
|
||||
|
||||
if count is None:
|
||||
return "0 tokens"
|
||||
try:
|
||||
n = int(count)
|
||||
except (TypeError, ValueError):
|
||||
return "0 tokens"
|
||||
if n <= 0:
|
||||
return "0 tokens"
|
||||
|
||||
if n < 1_000:
|
||||
return f"{n} tokens"
|
||||
if n < 1_000_000:
|
||||
return f"{n / 1_000:.1f}k tokens"
|
||||
if n < 1_000_000_000:
|
||||
return f"{n / 1_000_000:.1f}M tokens"
|
||||
return f"{n / 1_000_000_000:.1f}B tokens"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generic abbreviation (dollars / counts / sizes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def abbreviate_num(value: Number | None, *, currency: str | None = None) -> str:
|
||||
"""Abbreviate a number with magnitude suffix.
|
||||
|
||||
Use this for status-bar numbers where horizontal space is scarce. Currency
|
||||
amounts under ``$1`` keep 3-decimal precision so per-call cost reads
|
||||
sensibly (e.g. ``$0.003``).
|
||||
|
||||
Args:
|
||||
value: Raw number to abbreviate. ``None`` → ``"—"``.
|
||||
currency: Optional currency symbol prefix (e.g. ``"$"``). When set,
|
||||
small fractional values keep 3 decimals.
|
||||
|
||||
Returns:
|
||||
Abbreviated string. Examples::
|
||||
|
||||
abbreviate_num(452) → "452"
|
||||
abbreviate_num(12_400) → "12.4k"
|
||||
abbreviate_num(3_200_000) → "3.2M"
|
||||
abbreviate_num(0.003, currency="$") → "$0.003"
|
||||
abbreviate_num(1.42, currency="$") → "$1.42"
|
||||
"""
|
||||
|
||||
if value is None:
|
||||
return "—"
|
||||
try:
|
||||
n = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
|
||||
prefix = currency or ""
|
||||
sign = "-" if n < 0 else ""
|
||||
n_abs = abs(n)
|
||||
|
||||
if currency is not None and n_abs < 1.0:
|
||||
# 3 decimals communicates per-token / per-call cost precision
|
||||
return f"{sign}{prefix}{n_abs:.3f}"
|
||||
|
||||
if n_abs < 1_000:
|
||||
if currency is not None or not float(n_abs).is_integer():
|
||||
return f"{sign}{prefix}{n_abs:.2f}".rstrip("0").rstrip(".")
|
||||
return f"{sign}{prefix}{int(n_abs)}"
|
||||
|
||||
for unit, divisor in (("k", 1_000), ("M", 1_000_000), ("B", 1_000_000_000), ("T", 1_000_000_000_000)):
|
||||
if n_abs < divisor * 1_000:
|
||||
return f"{sign}{prefix}{n_abs / divisor:.1f}{unit}"
|
||||
|
||||
return f"{sign}{prefix}{n_abs:.1e}"
|
||||
|
||||
|
||||
__all__ = ["format_duration", "format_tokens", "abbreviate_num"]
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Random "thinking" verb for the streaming spinner.
|
||||
|
||||
The status bar shows a verb while the agent is generating to communicate that
|
||||
work is happening even before the first token arrives. Borrowed from the
|
||||
dexter CLI: the verb is rerolled per agent turn so the user perceives variety
|
||||
across runs rather than a single hard-coded label.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Final, Tuple
|
||||
|
||||
THINKING_VERBS: Final[Tuple[str, ...]] = (
|
||||
"Pondering",
|
||||
"Analyzing",
|
||||
"Reasoning",
|
||||
"Investigating",
|
||||
"Synthesizing",
|
||||
"Cross-checking",
|
||||
)
|
||||
|
||||
|
||||
def pick_thinking_verb(*, seed: int | None = None) -> str:
|
||||
"""Pick a random verb suffixed with an ellipsis.
|
||||
|
||||
Args:
|
||||
seed: Optional deterministic seed (used by tests). When ``None`` the
|
||||
module-level :func:`random.choice` is used.
|
||||
|
||||
Returns:
|
||||
``"Pondering…"``, ``"Analyzing…"``, etc.
|
||||
"""
|
||||
|
||||
if seed is not None:
|
||||
rng = random.Random(seed)
|
||||
return f"{rng.choice(THINKING_VERBS)}…"
|
||||
return f"{random.choice(THINKING_VERBS)}…"
|
||||
|
||||
|
||||
__all__ = ["pick_thinking_verb", "THINKING_VERBS"]
|
||||
+1020
-89
File diff suppressed because it is too large.
Load diff
+25
-6
@@ -1,12 +1,14 @@
|
||||
# Core LLM & Orchestration
|
||||
rich>=13.0.0
|
||||
pyyaml>=6.0.0
|
||||
langchain>=0.1.0
|
||||
langchain-core>=0.1.0
|
||||
langchain-openai>=0.0.5
|
||||
langgraph>=0.2.50,<0.3
|
||||
langgraph-checkpoint>=2.0.0
|
||||
langchain>=1.0.0,<2
|
||||
langchain-core>=1.0.0,<2
|
||||
langchain-openai>=1.0.0,<2
|
||||
langgraph>=1.0.10,<1.1
|
||||
langgraph-checkpoint>=2.1.0,<5
|
||||
python-dotenv>=1.0.0
|
||||
httpx>=0.28.0
|
||||
oauth-cli-kit>=0.1.3
|
||||
|
||||
# Data & Computation
|
||||
pandas>=2.0.0
|
||||
@@ -14,6 +16,13 @@ numpy>=1.24.0
|
||||
scipy>=1.10.0
|
||||
duckdb>=1.2.0
|
||||
|
||||
# Document Readers (doc_reader_tool)
|
||||
openpyxl>=3.1.0
|
||||
python-docx>=1.1.0
|
||||
python-pptx>=0.6.23
|
||||
pypdfium2>=4.0.0
|
||||
Pillow>=10.0.0
|
||||
|
||||
# ML Strategy
|
||||
scikit-learn>=1.3.0
|
||||
joblib>=1.3.0
|
||||
@@ -26,13 +35,23 @@ pyharmonics>=1.5.0
|
||||
tushare>=1.2.89
|
||||
requests>=2.31.0
|
||||
yfinance>=0.2.30
|
||||
akshare>=1.12.0
|
||||
ccxt>=4.0.0
|
||||
|
||||
# REST API & SSE
|
||||
fastapi>=0.104.0
|
||||
uvicorn[standard]>=0.24.0
|
||||
pydantic>=2.0.0
|
||||
python-multipart>=0.0.6
|
||||
python-multipart>=0.0.18
|
||||
sse-starlette>=1.6.0
|
||||
|
||||
# MCP Server
|
||||
fastmcp>=2.0.0
|
||||
|
||||
# Web Search
|
||||
ddgs>=6.0.0
|
||||
|
||||
# Shadow Account HTML/PDF Report
|
||||
jinja2>=3.1.0
|
||||
matplotlib>=3.7.0
|
||||
weasyprint>=60.0
|
||||
@@ -0,0 +1,250 @@
|
||||
"""W4.a blog patcher: fill TBD placeholders in alpha-191-in-2026.html.
|
||||
|
||||
Reads ~/.vibe-trading/reports/bench_summary.json (produced by w4a_run_benches.py)
|
||||
and patches the HTML in place.
|
||||
|
||||
Substitutions performed:
|
||||
- num-alive / num-reversed / num-dead spans in the two stat blocks
|
||||
- Theme survival table rows (count + survival rate per theme)
|
||||
- Top 5 alphas (id + formula + IC mean)
|
||||
- 3 famously-dead alphas (replace invented IDs with worst-IC from gtja191)
|
||||
- Section-level "bench failed" callouts where the corresponding bench errored
|
||||
|
||||
The patcher never fabricates a number: if the gtja191 bench is missing, every
|
||||
TBD that depends on it becomes a "bench failed" note instead. The script is
|
||||
idempotent — re-running with the same summary produces the same HTML.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
HTML_PATH = REPO / "wiki" / "research-lab" / "posts" / "alpha-191-in-2026.html"
|
||||
SUMMARY_PATH = Path.home() / ".vibe-trading" / "reports" / "bench_summary.json"
|
||||
|
||||
GTJA_KEY = "gtja191_csi300"
|
||||
|
||||
# Themes shown in the table — these are the human labels in the HTML, mapped
|
||||
# to the canonical theme tags in registry meta. "Volume-price interaction"
|
||||
# requires *both* volume and reversal-or-momentum, so it gets a custom probe.
|
||||
THEME_LABEL_TO_TAGS: dict[str, list[str]] = {
|
||||
"Volume-price interaction": ["volume"],
|
||||
"Short-horizon volatility": ["volatility"],
|
||||
"Reversal": ["reversal"],
|
||||
"Momentum": ["momentum"],
|
||||
"Turnover / liquidity": ["liquidity"],
|
||||
"Microstructure / range": ["microstructure"],
|
||||
}
|
||||
|
||||
|
||||
def _load_summary() -> dict[str, Any]:
|
||||
if not SUMMARY_PATH.is_file():
|
||||
raise SystemExit(f"summary not found: {SUMMARY_PATH}")
|
||||
return json.loads(SUMMARY_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _gtja_entry(summary: dict[str, Any]) -> dict[str, Any] | None:
|
||||
entry = summary.get("zoos", {}).get(GTJA_KEY)
|
||||
if not entry or entry.get("status") != "ok":
|
||||
return None
|
||||
return entry
|
||||
|
||||
|
||||
def _patch_stats(text: str, entry: dict[str, Any]) -> str:
|
||||
for span_class, key in (
|
||||
("num-alive", "alive"),
|
||||
("num-reversed", "reversed"),
|
||||
("num-dead", "dead"),
|
||||
):
|
||||
value = entry.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
pattern = re.compile(
|
||||
rf'(<span class="{span_class}">)\[TBD\](</span>)'
|
||||
)
|
||||
text = pattern.sub(rf"\g<1>{int(value)}\g<2>", text)
|
||||
return text
|
||||
|
||||
|
||||
def _theme_survival_rows(entry: dict[str, Any]) -> str:
|
||||
"""Build the inner <tr> rows for the theme-survival table."""
|
||||
by_theme = entry.get("by_theme", {})
|
||||
rows: list[str] = []
|
||||
definitions = {
|
||||
"Volume-price interaction": "Correlation / covariance of volume with close, high-low range",
|
||||
"Short-horizon volatility": "Rolling std / range over 5-20 day windows",
|
||||
"Reversal": "Negative-sign return signals over 1-5 day horizons",
|
||||
"Momentum": "Positive-sign return signals over 10-60 day horizons",
|
||||
"Turnover / liquidity": "Volume ratios, turnover-rate transforms",
|
||||
"Microstructure / range": "Open-close-high-low decompositions, intraday range proxies",
|
||||
}
|
||||
for label, tags in THEME_LABEL_TO_TAGS.items():
|
||||
# Aggregate across all listed tags (most themes are single-tag).
|
||||
alive = dead = reversed_ = count = 0
|
||||
for tag in tags:
|
||||
bucket = by_theme.get(tag)
|
||||
if bucket is None:
|
||||
continue
|
||||
alive += bucket.get("alive", 0)
|
||||
dead += bucket.get("dead", 0)
|
||||
reversed_ += bucket.get("reversed", 0)
|
||||
count += bucket.get("count", 0)
|
||||
if count == 0:
|
||||
count_cell = "n/a"
|
||||
rate_cell = "n/a"
|
||||
else:
|
||||
count_cell = str(count)
|
||||
rate_cell = f"{alive / count * 100:.0f}% ({alive}/{count})"
|
||||
rows.append(
|
||||
" <tr>\n"
|
||||
f" <td>{html.escape(label)}</td>\n"
|
||||
f" <td>{html.escape(definitions[label])}</td>\n"
|
||||
f" <td>{count_cell}</td>\n"
|
||||
f" <td>{rate_cell}</td>\n"
|
||||
" </tr>"
|
||||
)
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
def _patch_theme_table(text: str, entry: dict[str, Any]) -> str:
|
||||
new_rows = _theme_survival_rows(entry)
|
||||
pattern = re.compile(
|
||||
r'(<tbody>)(.*?)(</tbody>)',
|
||||
re.DOTALL,
|
||||
)
|
||||
# The HTML has exactly one <tbody> inside the theme-table; surgical replace.
|
||||
def _swap(match: re.Match[str]) -> str:
|
||||
body = match.group(2)
|
||||
if "Volume-price interaction" not in body:
|
||||
return match.group(0) # not the theme table
|
||||
return f"{match.group(1)}\n{new_rows}\n {match.group(3)}"
|
||||
|
||||
return pattern.sub(_swap, text, count=1)
|
||||
|
||||
|
||||
def _formula_to_html(formula: str) -> str:
|
||||
return html.escape(formula)
|
||||
|
||||
|
||||
def _build_top5_cards(entry: dict[str, Any]) -> str:
|
||||
rows = entry.get("top5_by_ir", [])[:5]
|
||||
cards: list[str] = []
|
||||
for row in rows:
|
||||
aid = row["id"]
|
||||
formula = _formula_to_html(row.get("formula_latex", ""))
|
||||
ic = row.get("ic_mean", 0.0)
|
||||
ir = row.get("ir", 0.0)
|
||||
cards.append(
|
||||
" <article class=\"alpha-card\">\n"
|
||||
f" <span class=\"alpha-id\">{html.escape(aid)}</span>\n"
|
||||
f" <pre class=\"formula-block\"><code>{formula}</code></pre>\n"
|
||||
" <p class=\"alpha-note\">"
|
||||
f"Mean IC = {ic:.4f}, IR = {ir:.4f} over the CSI 300 / 2018–2025 window. "
|
||||
"Formula reproduced verbatim from the registry "
|
||||
f"(<code>__alpha_meta__[\"formula_latex\"]</code> of <code>{html.escape(aid)}</code>)."
|
||||
"</p>\n"
|
||||
" </article>"
|
||||
)
|
||||
return "\n".join(cards)
|
||||
|
||||
|
||||
def _build_dead_cards(entry: dict[str, Any]) -> str:
|
||||
rows = entry.get("dead_examples", [])[:3]
|
||||
cards: list[str] = []
|
||||
for row in rows:
|
||||
aid = row["id"]
|
||||
formula = _formula_to_html(row.get("formula_latex", ""))
|
||||
ic = row.get("ic_mean", 0.0)
|
||||
cat = row.get("category", "dead")
|
||||
ir = row.get("ir", 0.0)
|
||||
cards.append(
|
||||
" <article class=\"alpha-card\">\n"
|
||||
f" <span class=\"alpha-id\">{html.escape(aid)} ({html.escape(cat)})</span>\n"
|
||||
f" <pre class=\"formula-block\"><code>{formula}</code></pre>\n"
|
||||
" <p class=\"alpha-note\">"
|
||||
f"Mean IC = {ic:.4f}, IR = {ir:.4f}. Worst-performing slice of the gtja191 "
|
||||
"zoo on CSI 300 / 2018–2025 by raw IC."
|
||||
"</p>\n"
|
||||
" </article>"
|
||||
)
|
||||
return "\n".join(cards)
|
||||
|
||||
|
||||
def _patch_top5(text: str, entry: dict[str, Any]) -> str:
|
||||
cards = _build_top5_cards(entry)
|
||||
pattern = re.compile(
|
||||
r'(<h3>Top 5 surviving alphas</h3>.*?</p>\s*)' # heading + lead paragraph
|
||||
r'(?:<article class="alpha-card">.*?</article>\s*){5}',
|
||||
re.DOTALL,
|
||||
)
|
||||
return pattern.sub(rf"\g<1>{cards}\n\n ", text, count=1)
|
||||
|
||||
|
||||
def _patch_dead(text: str, entry: dict[str, Any]) -> str:
|
||||
cards = _build_dead_cards(entry)
|
||||
pattern = re.compile(
|
||||
r'(<h3>Three famously dead alphas</h3>.*?</p>\s*)'
|
||||
r'(?:<article class="alpha-card">.*?</article>\s*){3}',
|
||||
re.DOTALL,
|
||||
)
|
||||
return pattern.sub(rf"\g<1>{cards}\n\n ", text, count=1)
|
||||
|
||||
|
||||
def _patch_bench_failed_note(text: str) -> str:
|
||||
"""Replace the W4-roadmap placeholder callout with a real timestamp note."""
|
||||
return text.replace(
|
||||
"Numbers will be filled in once the full bench finishes (Vibe-Trading\n roadmap W4.a). "
|
||||
"This post is published as a methodology preview — the structure, definitions\n and caveats are final.",
|
||||
"Numbers below are the live W4.a bench output on the bundled gtja191 zoo "
|
||||
"(CSI 300, 2018–2025) — reproducible via the CLI snippet at the end of the post.",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
summary = _load_summary()
|
||||
entry = _gtja_entry(summary)
|
||||
|
||||
text = HTML_PATH.read_text(encoding="utf-8")
|
||||
|
||||
if entry is None:
|
||||
# Surface the failure once at the top; leave all TBDs intact so the
|
||||
# reader sees we have not invented numbers.
|
||||
zoo_blob = summary.get("zoos", {}).get(GTJA_KEY, {})
|
||||
reason = zoo_blob.get("error", "bench did not run")
|
||||
note = (
|
||||
'<p class="bench-failed"><strong>Note:</strong> the gtja191 / CSI 300 bench '
|
||||
f"failed during W4.a (reason: {html.escape(str(reason))}). All TBD numbers below "
|
||||
"remain to be filled in 0.1.9 once the bench is re-run.</p>"
|
||||
)
|
||||
text = text.replace(
|
||||
'roadmap W4.a). This post',
|
||||
f'roadmap W4.a). {note} This post'
|
||||
)
|
||||
HTML_PATH.write_text(text, encoding="utf-8")
|
||||
print(f"bench failed; left TBDs intact and added note. tbd count = "
|
||||
f"{text.count('[TBD]')}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
text = _patch_stats(text, entry)
|
||||
text = _patch_theme_table(text, entry)
|
||||
text = _patch_top5(text, entry)
|
||||
text = _patch_dead(text, entry)
|
||||
text = _patch_bench_failed_note(text)
|
||||
|
||||
# Mean IC ~ [TBD] placeholders inside the per-card paragraphs are no longer
|
||||
# needed (we rewrote the cards with real numbers). Sanity-check sweep.
|
||||
HTML_PATH.write_text(text, encoding="utf-8")
|
||||
|
||||
remaining = text.count("[TBD]")
|
||||
print(f"patched {HTML_PATH}; remaining [TBD] = {remaining}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,170 @@
|
||||
"""W4.a bench driver: run 4 zoo×universe combinations and emit summary JSON.
|
||||
|
||||
Usage:
|
||||
cd agent && python scripts/w4a_run_benches.py
|
||||
|
||||
Output:
|
||||
~/.vibe-trading/reports/alpha_bench_*.html — per-run HTML reports
|
||||
~/.vibe-trading/reports/bench_<zoo>_<uni>.json — per-run raw results
|
||||
~/.vibe-trading/reports/bench_summary.json — aggregate summary
|
||||
|
||||
Safe to re-run; each bench result is cached by ``_load_universe_panel`` so the
|
||||
expensive Tushare/yfinance fetches only happen once per (universe, period).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Repo-relative imports
|
||||
HERE = Path(__file__).resolve().parent
|
||||
AGENT_DIR = HERE.parent
|
||||
sys.path.insert(0, str(AGENT_DIR))
|
||||
|
||||
load_dotenv(AGENT_DIR / ".env")
|
||||
|
||||
# Configure logging early so universe loaders log their progress.
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s | %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("w4a")
|
||||
|
||||
from src.factors.bench_runner import run_bench # noqa: E402
|
||||
from src.tools.alpha_bench_tool import run_alpha_bench # noqa: E402
|
||||
|
||||
|
||||
REPORTS_DIR = Path.home() / ".vibe-trading" / "reports"
|
||||
|
||||
BENCHES = [
|
||||
{"key": "gtja191_csi300", "zoo": "gtja191", "universe": "csi300", "period": "2018-2025"},
|
||||
{"key": "alpha101_sp500", "zoo": "alpha101", "universe": "sp500", "period": "2020-2025"},
|
||||
{"key": "qlib158_csi300", "zoo": "qlib158", "universe": "csi300", "period": "2020-2025"},
|
||||
{"key": "alpha101_btc", "zoo": "alpha101", "universe": "btc-usdt", "period": "2022-2025"},
|
||||
]
|
||||
|
||||
|
||||
def _run_one(bench: dict) -> dict:
|
||||
"""Run a single bench end-to-end. Returns a summary entry.
|
||||
|
||||
Delegates the math to ``src.factors.bench_runner.run_bench``; this wrapper
|
||||
just adds the HTML report (via ``run_alpha_bench``) and persists the raw
|
||||
per-alpha rows for downstream tooling, both of which the CLI driver wants
|
||||
but the API route does not.
|
||||
"""
|
||||
key = bench["key"]
|
||||
zoo = bench["zoo"]
|
||||
universe = bench["universe"]
|
||||
period = bench["period"]
|
||||
logger.info("=== bench %s: zoo=%s universe=%s period=%s ===", key, zoo, universe, period)
|
||||
start = time.monotonic()
|
||||
|
||||
entry: dict = {
|
||||
"key": key,
|
||||
"zoo": zoo,
|
||||
"universe": universe,
|
||||
"period": period,
|
||||
"status": "pending",
|
||||
}
|
||||
|
||||
# 1. HTML report (also exercises the universe loader so the panel is cached
|
||||
# on disk for the run_bench call below).
|
||||
try:
|
||||
envelope = run_alpha_bench(
|
||||
zoo=zoo, universe=universe, period=period, top=20
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — never let one bench abort the suite
|
||||
logger.exception("bench %s crashed (run_alpha_bench)", key)
|
||||
entry["status"] = "error"
|
||||
entry["error"] = f"unexpected: {exc!s}"
|
||||
entry["wall_seconds"] = round(time.monotonic() - start, 2)
|
||||
return entry
|
||||
|
||||
if envelope.get("status") != "ok":
|
||||
entry["status"] = "error"
|
||||
entry["error"] = envelope.get("error", "unknown")
|
||||
entry["wall_seconds"] = round(time.monotonic() - start, 2)
|
||||
return entry
|
||||
|
||||
# 2. Full bench (per-alpha rows + categorisation).
|
||||
try:
|
||||
result = run_bench(zoo=zoo, universe=universe, period=period, top=20)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("bench %s crashed (run_bench)", key)
|
||||
entry["status"] = "error"
|
||||
entry["error"] = f"unexpected: {exc!s}"
|
||||
entry["wall_seconds"] = round(time.monotonic() - start, 2)
|
||||
return entry
|
||||
|
||||
if result.get("status") != "ok":
|
||||
entry["status"] = "error"
|
||||
entry["error"] = result.get("error", "unknown")
|
||||
entry["wall_seconds"] = round(time.monotonic() - start, 2)
|
||||
return entry
|
||||
|
||||
rows = result.pop("rows", [])
|
||||
skipped = result.pop("skipped", [])
|
||||
|
||||
entry.update(result)
|
||||
entry["report_path"] = envelope.get("report_path")
|
||||
entry["wall_seconds"] = round(time.monotonic() - start, 2)
|
||||
|
||||
# Write per-bench raw rows for downstream tooling.
|
||||
raw_path = REPORTS_DIR / f"bench_{key}.json"
|
||||
raw_path.write_text(
|
||||
json.dumps(
|
||||
{"meta": {k: entry[k] for k in ("key", "zoo", "universe", "period", "status")},
|
||||
"rows": rows, "skipped": skipped},
|
||||
indent=2,
|
||||
default=str,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"bench %s done: tested=%d skipped=%d alive=%d reversed=%d dead=%d (%.1fs)",
|
||||
key, len(rows), len(skipped),
|
||||
entry["alive"], entry["reversed"], entry["dead"],
|
||||
entry["wall_seconds"],
|
||||
)
|
||||
return entry
|
||||
|
||||
|
||||
def main() -> int:
|
||||
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
summary: dict = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"zoos": {},
|
||||
}
|
||||
|
||||
tushare_ok = bool(os.getenv("TUSHARE_TOKEN", "").strip())
|
||||
if not tushare_ok:
|
||||
logger.warning("TUSHARE_TOKEN unset; csi300 benches will be skipped")
|
||||
|
||||
for bench in BENCHES:
|
||||
if bench["universe"] == "csi300" and not tushare_ok:
|
||||
summary["zoos"][bench["key"]] = {
|
||||
**{k: bench[k] for k in ("zoo", "universe", "period")},
|
||||
"status": "skipped",
|
||||
"error": "TUSHARE_TOKEN not set",
|
||||
}
|
||||
continue
|
||||
entry = _run_one(bench)
|
||||
summary["zoos"][bench["key"]] = entry
|
||||
|
||||
out = REPORTS_DIR / "bench_summary.json"
|
||||
out.write_text(json.dumps(summary, indent=2, default=str), encoding="utf-8")
|
||||
logger.info("wrote summary -> %s", out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+95
-12
@@ -3,14 +3,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
from src.agent.memory import WorkspaceMemory
|
||||
from src.agent.skills import SkillsLoader
|
||||
from src.agent.tools import ToolRegistry
|
||||
|
||||
_SYSTEM_PROMPT = """You are a finance research agent with 64 specialist skills, 19 tools, and 29 multi-agent swarm teams.
|
||||
You handle backtesting, factor analysis, options pricing, risk audits, research reports, document/web reading, and team-based workflows.
|
||||
if TYPE_CHECKING:
|
||||
from src.memory.persistent import PersistentMemory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SYSTEM_PROMPT = """You are a finance research agent with {skill_count} specialist skills, {tool_count} tools, 7 data sources (with auto-fallback), and 29 multi-agent swarm teams.
|
||||
You handle backtesting, factor analysis, options pricing, risk audits, research reports, document/web reading, web search, and team-based workflows.
|
||||
|
||||
## Tools
|
||||
|
||||
@@ -35,10 +42,9 @@ Decide which workflow to use based on the request:
|
||||
4. Syntax check → `backtest(run_dir=...)` → `read_file("artifacts/metrics.csv")`
|
||||
5. Do NOT write run_backtest.py. The engine is built-in.
|
||||
|
||||
**Swarm team** — user wants multi-agent analysis, risk audit, committee review, or any task with "team/committee/panel/desk":
|
||||
**Swarm team** — ONLY when the user explicitly requests team/committee/swarm analysis:
|
||||
- Call `run_swarm(prompt="<user's full request>")` — it auto-selects the right preset.
|
||||
- Examples: risk audit, investment committee, equity research, technical panel, cross-market allocation.
|
||||
- Do NOT attempt multi-dimensional analysis solo. Use swarm.
|
||||
- Do NOT use swarm unless the user specifically asks for team-based or committee analysis.
|
||||
|
||||
**Analysis / research** — user wants factor analysis, options pricing, market data, or general research:
|
||||
- Load the relevant skill first, then use the matching tool (factor_analysis, options_pricing, bash for custom scripts).
|
||||
@@ -46,13 +52,42 @@ Decide which workflow to use based on the request:
|
||||
**Document / web** — user provides a PDF or URL:
|
||||
- `read_document(path=...)` for PDFs, `read_url(url=...)` for web pages.
|
||||
|
||||
**Trade journal** — user uploads a CSV/Excel broker export (交割单) or asks to analyze their own trading history:
|
||||
1. `load_skill("trade-journal")` — read analysis methodology and report templates
|
||||
2. `analyze_trade_journal(file_path=..., analysis_type="full")` — parse + profile + behavior diagnostics
|
||||
3. Present results as the markdown report in the skill. Offer follow-ups: time-slice, symbol deep-dive, market split.
|
||||
4. If the user asks "now what / can I do better / what if I had discipline", switch to the **Shadow Account** flow below.
|
||||
|
||||
**Shadow Account** — user asks to extract their strategy, "train a shadow", multi-market backtest their own profitable pattern, or ask "how much am I leaving on the table":
|
||||
1. **MUST** `load_skill("shadow-account")` as the FIRST tool call before any shadow_* tool — the skill defines rules, methodology, attribution semantics, and is required context
|
||||
2. Confirm the journal has been parsed (same session or known `journal_path`). If not, run `analyze_trade_journal` first.
|
||||
3. `extract_shadow_strategy(journal_path=...)` → show rules, ask user to confirm they look like their own behavior
|
||||
4. `run_shadow_backtest(shadow_id=..., journal_path=...)` → multi-market metrics + delta attribution
|
||||
5. `render_shadow_report(shadow_id=...)` → share html/pdf path, lead with the Section 5 "you vs shadow" delta
|
||||
6. Optional: `scan_shadow_signals(shadow_id=...)` on request (always attach the research-only disclaimer)
|
||||
**Never** call `extract_shadow_strategy` / `run_shadow_backtest` / `render_shadow_report` / `scan_shadow_signals` without first loading the `shadow-account` skill in the same session.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Load the relevant skill BEFORE starting any task. Skills contain the exact API contracts and examples.
|
||||
- Ask the user if critical info is missing (assets, dates, strategy type). Never guess.
|
||||
- Output results as markdown tables. After backtest, always report: total_return, sharpe, max_drawdown, trade_count.
|
||||
- Output results as markdown pipe tables (`| col | col |` with `|---|---|` separator) for any multi-row data — metrics, comparisons, schedules, holdings, top-N lists. Renderers upgrade these to native tables. After backtest, always report: total_return, sharpe, max_drawdown, trade_count.
|
||||
- Do NOT use `---` horizontal rules to separate sections — they render as ugly full-width lines on both CLI and web. Use `##` / `###` markdown headings instead.
|
||||
- All file paths are relative to run_dir (auto-injected).
|
||||
- Respond in the same language the user used.
|
||||
- You have persistent cross-session memory (`remember` tool). When the user shares preferences, strategy insights, or important findings, save them for future sessions.
|
||||
- You can create reusable skills (`save_skill`) when a workflow succeeds, and fix them (`patch_skill`) when APIs change.
|
||||
{memory_section}
|
||||
## Current Date & Time
|
||||
|
||||
Today is {current_datetime}.
|
||||
"""
|
||||
|
||||
_MEMORY_SECTION = """
|
||||
## Persistent Memory (cross-session)
|
||||
|
||||
{snapshot}
|
||||
|
||||
"""
|
||||
|
||||
|
||||
@@ -66,22 +101,26 @@ class ContextBuilder:
|
||||
"""
|
||||
|
||||
def __init__(self, registry: ToolRegistry, memory: WorkspaceMemory,
|
||||
skills_loader: Optional[SkillsLoader] = None) -> None:
|
||||
skills_loader: Optional[SkillsLoader] = None,
|
||||
persistent_memory: Optional[PersistentMemory] = None) -> None:
|
||||
"""Initialize ContextBuilder.
|
||||
|
||||
Args:
|
||||
registry: Tool registry.
|
||||
memory: Workspace memory.
|
||||
skills_loader: Skills loader (auto-created if not provided).
|
||||
persistent_memory: PersistentMemory instance for cross-session recall.
|
||||
"""
|
||||
self.registry = registry
|
||||
self.memory = memory
|
||||
self.skills_loader = skills_loader or SkillsLoader()
|
||||
self._persistent_memory = persistent_memory
|
||||
|
||||
def build_system_prompt(self, user_message: str = "") -> str:
|
||||
"""Build system prompt.
|
||||
|
||||
Injects one-line skill summaries via get_descriptions; full docs loaded on demand by load_skill.
|
||||
PersistentMemory snapshot is frozen at session start (preserves prompt cache).
|
||||
|
||||
Args:
|
||||
user_message: User message (kept for API compatibility).
|
||||
@@ -89,15 +128,32 @@ class ContextBuilder:
|
||||
Returns:
|
||||
System prompt text.
|
||||
"""
|
||||
now = datetime.now()
|
||||
|
||||
# Build memory section only if there are saved memories
|
||||
memory_section = ""
|
||||
if self._persistent_memory and self._persistent_memory.snapshot:
|
||||
memory_section = _MEMORY_SECTION.format(
|
||||
snapshot=self._persistent_memory.snapshot,
|
||||
)
|
||||
|
||||
return _SYSTEM_PROMPT.format(
|
||||
tool_count=len(self.registry._tools),
|
||||
skill_count=len(self.skills_loader.skills),
|
||||
tool_descriptions=self._format_tool_descriptions(),
|
||||
skill_descriptions=self.skills_loader.get_descriptions(),
|
||||
memory_summary=self.memory.to_summary(),
|
||||
memory_section=memory_section,
|
||||
current_datetime=now.strftime("%A, %B %d, %Y %H:%M (local)"),
|
||||
)
|
||||
|
||||
def build_messages(self, user_message: str, history: Optional[List[Dict[str, Any]]] = None) -> List[Dict[str, Any]]:
|
||||
"""Build full message list.
|
||||
|
||||
Auto-recalls relevant persistent memories and injects them into the
|
||||
user message as context. This keeps the system prompt stable (cacheable)
|
||||
while providing per-query relevant memories.
|
||||
|
||||
Args:
|
||||
user_message: User message.
|
||||
history: Prior conversation messages.
|
||||
@@ -110,7 +166,23 @@ class ContextBuilder:
|
||||
]
|
||||
if history:
|
||||
messages.extend(history)
|
||||
messages.append({"role": "user", "content": user_message})
|
||||
|
||||
# Auto-recall: inject relevant memories into user message
|
||||
enriched = user_message
|
||||
if self._persistent_memory:
|
||||
try:
|
||||
recalls = self._persistent_memory.find_relevant(user_message, max_results=3)
|
||||
if recalls:
|
||||
lines = [f"- **{r.title}** ({r.memory_type}): {r.body[:500]}" for r in recalls]
|
||||
recall_block = "\n".join(lines)
|
||||
enriched = (
|
||||
f"<recalled-memories>\n{recall_block}\n</recalled-memories>\n\n"
|
||||
f"{user_message}"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Auto-recall failed: %s", exc)
|
||||
|
||||
messages.append({"role": "user", "content": enriched})
|
||||
return messages
|
||||
|
||||
def _format_tool_descriptions(self) -> str:
|
||||
@@ -138,17 +210,25 @@ class ContextBuilder:
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def format_assistant_tool_calls(tool_calls: list, content: Optional[str] = None) -> Dict[str, Any]:
|
||||
def format_assistant_tool_calls(
|
||||
tool_calls: list,
|
||||
content: Optional[str] = None,
|
||||
reasoning_content: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Format an assistant tool_calls message, preserving thinking text.
|
||||
|
||||
Args:
|
||||
tool_calls: List of tool call objects.
|
||||
content: Model reasoning/thinking text.
|
||||
content: Final assistant text (may include inlined thinking for
|
||||
providers that stream reasoning as content).
|
||||
reasoning_content: Provider-specific reasoning field (Kimi K2.5,
|
||||
DeepSeek reasoner, Qwen thinking). Only attached to the output
|
||||
message when not None, so non-thinking providers see no change.
|
||||
|
||||
Returns:
|
||||
OpenAI-format assistant message.
|
||||
"""
|
||||
return {
|
||||
message = {
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
"tool_calls": [
|
||||
@@ -163,3 +243,6 @@ class ContextBuilder:
|
||||
for tc in tool_calls
|
||||
],
|
||||
}
|
||||
if reasoning_content is not None:
|
||||
message["reasoning_content"] = reasoning_content
|
||||
return message
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Shared YAML-like frontmatter parser for skills and memory files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
def parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
|
||||
"""Parse YAML-like frontmatter and body from a markdown file.
|
||||
|
||||
Supports string, list (``[a, b]``), and boolean values.
|
||||
|
||||
Args:
|
||||
text: Markdown text with optional ``---`` delimited frontmatter.
|
||||
|
||||
Returns:
|
||||
Tuple of (metadata dict, body text).
|
||||
"""
|
||||
match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)", text, re.DOTALL)
|
||||
if not match:
|
||||
return {}, text.strip()
|
||||
|
||||
meta: Dict[str, Any] = {}
|
||||
for line in match.group(1).strip().split("\n"):
|
||||
line = line.strip()
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if value.startswith("[") and value.endswith("]"):
|
||||
items = [item.strip().strip("'\"") for item in value[1:-1].split(",")]
|
||||
meta[key] = [i for i in items if i]
|
||||
elif value.lower() in ("true", "false"):
|
||||
meta[key] = value.lower() == "true"
|
||||
else:
|
||||
meta[key] = value
|
||||
|
||||
return meta, match.group(2).strip()
|
||||
+737
-92
@@ -1,31 +1,57 @@
|
||||
"""AgentLoop: ReAct core loop.
|
||||
|
||||
Three-layer context management:
|
||||
Layer 1 (microcompact) — silently prunes old tool results each iteration, keeping the most recent 3
|
||||
Layer 2 (auto_compact) — LLM summarises and compresses when token count exceeds threshold, saves transcript
|
||||
Layer 3 (compact tool) — model explicitly calls the compact tool to trigger compression
|
||||
Five-layer context management:
|
||||
Layer 1 (microcompact) — silently prunes old tool results each iteration
|
||||
Layer 2 (context_collapse) — folds long text blocks without LLM call (zero cost)
|
||||
Layer 3 (auto_compact) — LLM structured summary with token-budget tail protection
|
||||
Layer 4 (compact tool) — model explicitly calls the compact tool to trigger L3
|
||||
Layer 5 (iterative update) — Nth compression updates previous summary instead of starting fresh
|
||||
|
||||
Tool execution:
|
||||
- Read/write batching: consecutive readonly tools run in parallel via threads
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time as _time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from src.agent.context import ContextBuilder
|
||||
from src.agent.memory import WorkspaceMemory
|
||||
from src.agent.progress import HeartbeatTimer, ProgressEvent, _set_emitter
|
||||
from src.agent.tools import ToolRegistry
|
||||
from src.agent.trace import TraceWriter
|
||||
from src.core.state import RunStateStore
|
||||
from src.goal.context import (
|
||||
format_goal_continuation_prompt,
|
||||
get_current_goal_context,
|
||||
goal_needs_continuation,
|
||||
goal_progress_tuple,
|
||||
)
|
||||
from src.providers.chat import ChatLLM
|
||||
from src.tools.background_tools import get_background_manager
|
||||
|
||||
RUNS_DIR = Path(__file__).resolve().parents[2] / "runs"
|
||||
TOKEN_THRESHOLD = 50_000
|
||||
TOKEN_THRESHOLD = int(os.getenv("TOKEN_THRESHOLD", "40000"))
|
||||
KEEP_RECENT = 3
|
||||
TOOL_RESULT_LIMIT = 10_000
|
||||
HEARTBEAT_INTERVAL_S = float(os.getenv("VT_HEARTBEAT_INTERVAL_S", "3.0"))
|
||||
GOAL_MAX_CONTINUATIONS = int(os.getenv("VIBE_TRADING_GOAL_MAX_CONTINUATIONS", "3"))
|
||||
|
||||
# Layer 2: Context collapse thresholds
|
||||
COLLAPSE_THRESHOLD = int(TOKEN_THRESHOLD * 0.7)
|
||||
COLLAPSE_PRESERVE_RECENT = 6
|
||||
COLLAPSE_TEXT_MIN = 2400
|
||||
COLLAPSE_HEAD = 900
|
||||
COLLAPSE_TAIL = 500
|
||||
|
||||
# Layer 3: Token-budget tail protection
|
||||
TAIL_TOKEN_BUDGET = 20_000
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -57,9 +83,192 @@ def _microcompact(messages: list) -> None:
|
||||
msg["content"] = "[cleared]"
|
||||
|
||||
|
||||
def _context_collapse(messages: list) -> None:
|
||||
"""Layer 2: fold long text blocks in older messages without LLM call.
|
||||
|
||||
Preserves head + tail of large text, collapses the middle.
|
||||
Zero API cost — pure string operation.
|
||||
|
||||
Args:
|
||||
messages: Message list (mutated in place).
|
||||
"""
|
||||
if len(messages) <= COLLAPSE_PRESERVE_RECENT + 1:
|
||||
return
|
||||
for msg in messages[1:-COLLAPSE_PRESERVE_RECENT]:
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, str) or len(content) <= COLLAPSE_TEXT_MIN:
|
||||
continue
|
||||
if content == "[cleared]":
|
||||
continue
|
||||
head = content[:COLLAPSE_HEAD]
|
||||
tail = content[-COLLAPSE_TAIL:]
|
||||
trimmed = len(content) - COLLAPSE_HEAD - COLLAPSE_TAIL
|
||||
msg["content"] = f"{head}\n\n...[{trimmed} chars collapsed]...\n\n{tail}"
|
||||
|
||||
|
||||
def _fix_tool_pairs(messages: list) -> None:
|
||||
"""Repair orphaned tool_call / tool_result pairs after compression.
|
||||
|
||||
Two fixes:
|
||||
1. Remove tool results whose matching tool_call was compressed away.
|
||||
2. Insert stub results for tool_calls whose results were compressed away.
|
||||
|
||||
Args:
|
||||
messages: Message list (mutated in place).
|
||||
"""
|
||||
# Collect all tool_call IDs from assistant messages
|
||||
call_ids: set[str] = set()
|
||||
for msg in messages:
|
||||
if msg.get("role") == "assistant":
|
||||
for tc in msg.get("tool_calls", []):
|
||||
tc_id = tc.get("id", "")
|
||||
if tc_id:
|
||||
call_ids.add(tc_id)
|
||||
|
||||
# Remove orphaned tool results
|
||||
i = 0
|
||||
while i < len(messages):
|
||||
msg = messages[i]
|
||||
if msg.get("role") == "tool" and msg.get("tool_call_id") not in call_ids:
|
||||
messages.pop(i)
|
||||
else:
|
||||
i += 1
|
||||
|
||||
# Collect existing result IDs
|
||||
result_ids: set[str] = set()
|
||||
for msg in messages:
|
||||
if msg.get("role") == "tool":
|
||||
tcid = msg.get("tool_call_id", "")
|
||||
if tcid:
|
||||
result_ids.add(tcid)
|
||||
|
||||
# Insert stub results for orphaned tool_calls
|
||||
inserts: list[tuple[int, dict]] = []
|
||||
for idx, msg in enumerate(messages):
|
||||
if msg.get("role") != "assistant":
|
||||
continue
|
||||
for tc in msg.get("tool_calls", []):
|
||||
tc_id = tc.get("id", "")
|
||||
if tc_id and tc_id not in result_ids:
|
||||
stub = {
|
||||
"role": "tool",
|
||||
"tool_call_id": tc_id,
|
||||
"name": tc.get("function", {}).get("name", "unknown"),
|
||||
"content": "[Result from earlier context — see summary above]",
|
||||
}
|
||||
inserts.append((idx + 1, stub))
|
||||
result_ids.add(tc_id)
|
||||
|
||||
for pos, stub in reversed(inserts):
|
||||
messages.insert(pos, stub)
|
||||
|
||||
|
||||
# -- Structured summary templates ------------------------------------------
|
||||
|
||||
_STRUCTURED_SUMMARY_PROMPT = """\
|
||||
Summarize this conversation for handoff to a fresh context window.
|
||||
This summary is the ONLY context available — omitted information is lost.
|
||||
|
||||
Use EXACTLY this structure:
|
||||
|
||||
## Goal
|
||||
What the user is trying to accomplish.
|
||||
|
||||
## Constraints & Preferences
|
||||
User-stated requirements: risk tolerance, strategy parameters, asset preferences.
|
||||
|
||||
## Progress
|
||||
### Done
|
||||
- Completed steps with key results and specific numbers.
|
||||
### In Progress
|
||||
- Current work when compression triggered.
|
||||
|
||||
## Key Decisions
|
||||
Choices made and rationale.
|
||||
|
||||
## Resolved Questions
|
||||
Questions already answered — do NOT re-answer these.
|
||||
|
||||
## Pending User Asks
|
||||
Unfinished requests still needing action.
|
||||
|
||||
## Relevant Files
|
||||
File paths, run_dir, signal engines, artifact locations.
|
||||
|
||||
## Remaining Work
|
||||
What still needs to be done (background reference, NOT active instructions).
|
||||
|
||||
## Critical Context
|
||||
Specific numbers, parameters, error messages, configuration values.
|
||||
|
||||
## Tools & Patterns
|
||||
Which tools worked, what failed, effective approaches.
|
||||
|
||||
IMPORTANT: This is a handoff — background reference, NOT active instructions.
|
||||
Preserve ALL specific numbers, file paths, and parameter values.
|
||||
{focus_section}
|
||||
Conversation to summarize:
|
||||
"""
|
||||
|
||||
_FOCUS_SECTION = """
|
||||
FOCUS TOPIC: {topic}
|
||||
Allocate 60-70% of the summary budget to content related to this topic.
|
||||
Aggressively compress unrelated content to make room.
|
||||
"""
|
||||
|
||||
_ITERATIVE_UPDATE_PROMPT = """\
|
||||
Update the existing summary with new conversation turns.
|
||||
|
||||
PREVIOUS SUMMARY:
|
||||
{previous_summary}
|
||||
|
||||
NEW TURNS TO INCORPORATE:
|
||||
{new_turns}
|
||||
|
||||
Rules:
|
||||
- PRESERVE all existing information from the previous summary.
|
||||
- ADD new progress, decisions, and findings.
|
||||
- Move "In Progress" items to "Done" when completed.
|
||||
- Move answered questions to "Resolved Questions".
|
||||
- Keep the same section structure.
|
||||
- Do NOT drop any critical context from the previous summary.
|
||||
{focus_section}"""
|
||||
|
||||
|
||||
def _is_tool_success(result: str) -> bool:
|
||||
"""Return True if the tool result does not contain an error."""
|
||||
return '"error"' not in result[:200]
|
||||
"""Return True if the tool result does not look like an error response."""
|
||||
try:
|
||||
data = json.loads(result)
|
||||
if isinstance(data, dict) and data.get("status") == "error":
|
||||
return False
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def _normalize_tool_run_dir(args: dict[str, Any], memory_run_dir: str | None) -> dict[str, Any]:
|
||||
"""Normalize ``run_dir`` in tool args to an absolute path when possible.
|
||||
|
||||
If the model supplies a relative ``run_dir`` (for example ``"."`` or
|
||||
``"risk_parity_run"``), resolve it against the active run directory.
|
||||
"""
|
||||
normalized = dict(args)
|
||||
if not memory_run_dir:
|
||||
return normalized
|
||||
|
||||
if "run_dir" not in normalized:
|
||||
normalized["run_dir"] = memory_run_dir
|
||||
return normalized
|
||||
|
||||
run_dir_value = str(normalized["run_dir"]).strip()
|
||||
if not run_dir_value:
|
||||
normalized["run_dir"] = memory_run_dir
|
||||
return normalized
|
||||
|
||||
candidate = Path(run_dir_value)
|
||||
if not candidate.is_absolute():
|
||||
normalized["run_dir"] = str((Path(memory_run_dir) / candidate).resolve())
|
||||
return normalized
|
||||
|
||||
|
||||
class AgentLoop:
|
||||
@@ -79,6 +288,7 @@ class AgentLoop:
|
||||
memory: Optional[WorkspaceMemory] = None,
|
||||
event_callback: Optional[Callable[[str, Dict[str, Any]], None]] = None,
|
||||
max_iterations: int = 50,
|
||||
persistent_memory: Optional[Any] = None,
|
||||
) -> None:
|
||||
"""Initialize AgentLoop.
|
||||
|
||||
@@ -88,6 +298,7 @@ class AgentLoop:
|
||||
memory: Workspace memory (created fresh if not provided).
|
||||
event_callback: Event callback (event_type, data).
|
||||
max_iterations: Maximum number of loop iterations.
|
||||
persistent_memory: PersistentMemory for cross-session recall.
|
||||
"""
|
||||
self.registry = registry
|
||||
self.llm = llm
|
||||
@@ -96,6 +307,8 @@ class AgentLoop:
|
||||
self.max_iterations = max_iterations
|
||||
self._called_ok: set[str] = set()
|
||||
self._cancelled: bool = False
|
||||
self._previous_summary: str = ""
|
||||
self._persistent_memory = persistent_memory
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Cancel the current loop.
|
||||
@@ -115,6 +328,11 @@ class AgentLoop:
|
||||
Returns:
|
||||
Execution result dict.
|
||||
"""
|
||||
# Reset per-run state (safe for reuse across multiple run() calls)
|
||||
self._cancelled = False
|
||||
self._called_ok = set()
|
||||
self._previous_summary = ""
|
||||
|
||||
state_store = RunStateStore()
|
||||
RUNS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -126,8 +344,18 @@ class AgentLoop:
|
||||
|
||||
state_store.save_request(run_dir, user_message, {"session_id": session_id})
|
||||
|
||||
context = ContextBuilder(self.registry, self.memory)
|
||||
messages = context.build_messages(user_message, history)
|
||||
context = ContextBuilder(self.registry, self.memory,
|
||||
persistent_memory=self._persistent_memory)
|
||||
goal_context, active_goal_id = get_current_goal_context(session_id) if session_id else ("", None)
|
||||
llm_user_message = user_message
|
||||
if goal_context:
|
||||
llm_user_message = (
|
||||
f"{goal_context}\n\n"
|
||||
f"<user-message>\n{user_message}\n</user-message>"
|
||||
)
|
||||
goal_store = None
|
||||
goal_turn_accounted = False
|
||||
messages = context.build_messages(llm_user_message, history)
|
||||
react_trace: List[Dict[str, Any]] = []
|
||||
|
||||
trace = TraceWriter(run_dir)
|
||||
@@ -135,6 +363,9 @@ class AgentLoop:
|
||||
|
||||
iteration = 0
|
||||
final_content = ""
|
||||
goal_continuations = 0
|
||||
goal_last_progress: tuple[int, int] | None = None
|
||||
wrap_up_at = max(1, int(self.max_iterations * 0.8))
|
||||
|
||||
try:
|
||||
while iteration < self.max_iterations:
|
||||
@@ -156,14 +387,36 @@ class AgentLoop:
|
||||
# Layer 1: microcompact (every iteration)
|
||||
_microcompact(messages)
|
||||
|
||||
# Layer 2: auto_compact (token threshold exceeded)
|
||||
# Layer 2: context collapse (fold long text, zero API cost)
|
||||
tokens = estimate_tokens(messages)
|
||||
if tokens > COLLAPSE_THRESHOLD:
|
||||
_context_collapse(messages)
|
||||
tokens = estimate_tokens(messages)
|
||||
|
||||
# Layer 3: auto_compact (token threshold exceeded)
|
||||
if tokens > TOKEN_THRESHOLD:
|
||||
logger.info(f"Auto compact triggered: {tokens} tokens > {TOKEN_THRESHOLD}")
|
||||
self._auto_compact(messages, run_dir, trace)
|
||||
|
||||
logger.info(f"ReAct iteration {iteration}/{self.max_iterations}")
|
||||
|
||||
# Inject wrap-up nudge when approaching iteration limit.
|
||||
# Skip on the first iteration (tiny budgets) and on the last
|
||||
# iteration (the forced text-only path already guarantees an
|
||||
# answer there) so the nudge never displaces the active-goal
|
||||
# context as the most recent user message.
|
||||
if iteration == wrap_up_at and 1 < iteration < self.max_iterations:
|
||||
remaining = self.max_iterations - iteration
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"[SYSTEM] You have {remaining} iterations remaining out of "
|
||||
f"{self.max_iterations}. Please wrap up your work. "
|
||||
"Stop calling tools and provide your final answer as plain text. "
|
||||
"If you have partial results, summarize what you have so far."
|
||||
),
|
||||
})
|
||||
|
||||
# Streaming output + collect thinking text
|
||||
thinking_chunks: List[str] = []
|
||||
|
||||
@@ -171,11 +424,53 @@ class AgentLoop:
|
||||
thinking_chunks.append(delta)
|
||||
self._emit("text_delta", {"delta": delta, "iter": iteration})
|
||||
|
||||
# On last iteration, drop tool definitions to force text output
|
||||
is_last_iteration = (iteration == self.max_iterations)
|
||||
tool_defs = None if is_last_iteration else self.registry.get_definitions()
|
||||
if is_last_iteration:
|
||||
trace.write({"type": "forced_text_only", "iter": iteration})
|
||||
|
||||
response = self.llm.stream_chat(
|
||||
messages,
|
||||
tools=self.registry.get_definitions(),
|
||||
tools=tool_defs,
|
||||
on_text_chunk=_on_text_chunk,
|
||||
)
|
||||
usage = getattr(response, "usage_metadata", None) or {}
|
||||
if usage:
|
||||
self._emit(
|
||||
"llm_usage",
|
||||
{
|
||||
"input_tokens": int(usage.get("input_tokens") or 0),
|
||||
"output_tokens": int(usage.get("output_tokens") or 0),
|
||||
"total_tokens": int(usage.get("total_tokens") or 0),
|
||||
"iter": iteration,
|
||||
},
|
||||
)
|
||||
if active_goal_id and session_id:
|
||||
token_delta = int(usage.get("total_tokens") or 0) if usage else 0
|
||||
turn_delta = 0 if goal_turn_accounted else 1
|
||||
if token_delta or turn_delta:
|
||||
try:
|
||||
if goal_store is None:
|
||||
from src.goal import GoalStore
|
||||
|
||||
goal_store = GoalStore()
|
||||
goal_store.account_usage(
|
||||
session_id=session_id,
|
||||
goal_id=active_goal_id,
|
||||
expected_goal_id=active_goal_id,
|
||||
token_delta=token_delta,
|
||||
turn_delta=turn_delta,
|
||||
)
|
||||
goal_turn_accounted = True
|
||||
snapshot = goal_store.get_goal_snapshot(active_goal_id)
|
||||
if snapshot is not None:
|
||||
self._emit(
|
||||
"goal.updated",
|
||||
{"goal": snapshot["goal"], "snapshot": snapshot},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("Goal usage accounting skipped: %s", exc)
|
||||
|
||||
thinking_text = "".join(thinking_chunks)
|
||||
if thinking_text:
|
||||
@@ -184,62 +479,88 @@ class AgentLoop:
|
||||
|
||||
if not response.has_tool_calls:
|
||||
final_content = response.content or ""
|
||||
should_continue_goal = False
|
||||
continuation_snapshot = None
|
||||
if active_goal_id and session_id and GOAL_MAX_CONTINUATIONS > 0:
|
||||
try:
|
||||
if goal_store is None:
|
||||
from src.goal import GoalStore
|
||||
|
||||
goal_store = GoalStore()
|
||||
continuation_snapshot = goal_store.get_goal_snapshot(active_goal_id)
|
||||
should_continue_goal = bool(
|
||||
continuation_snapshot
|
||||
and goal_needs_continuation(continuation_snapshot)
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("Goal continuation check skipped: %s", exc)
|
||||
|
||||
if should_continue_goal and continuation_snapshot is not None:
|
||||
current_progress = goal_progress_tuple(continuation_snapshot)
|
||||
no_new_progress = (
|
||||
goal_last_progress is not None
|
||||
and current_progress <= goal_last_progress
|
||||
)
|
||||
if goal_continuations >= GOAL_MAX_CONTINUATIONS or (
|
||||
no_new_progress and goal_continuations > 0
|
||||
):
|
||||
trace.write(
|
||||
{
|
||||
"type": "goal_continuation_suppressed",
|
||||
"iter": iteration,
|
||||
"goal_id": active_goal_id,
|
||||
"progress": current_progress,
|
||||
"continuations": goal_continuations,
|
||||
}
|
||||
)
|
||||
else:
|
||||
trace.write(
|
||||
{
|
||||
"type": "goal_intermediate_answer",
|
||||
"iter": iteration,
|
||||
"goal_id": active_goal_id,
|
||||
"content": final_content[:2000],
|
||||
"progress": current_progress,
|
||||
}
|
||||
)
|
||||
react_trace.append(
|
||||
{"type": "goal_intermediate_answer", "content": final_content[:500]}
|
||||
)
|
||||
messages.append({"role": "assistant", "content": final_content})
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": format_goal_continuation_prompt(
|
||||
continuation_snapshot,
|
||||
previous_answer=final_content,
|
||||
),
|
||||
}
|
||||
)
|
||||
goal_last_progress = current_progress
|
||||
goal_continuations += 1
|
||||
continue
|
||||
|
||||
trace.write({"type": "answer", "iter": iteration, "content": final_content[:2000]})
|
||||
react_trace.append({"type": "answer", "content": final_content[:500]})
|
||||
break
|
||||
|
||||
messages.append(context.format_assistant_tool_calls(response.tool_calls, content=response.content))
|
||||
messages.append(
|
||||
context.format_assistant_tool_calls(
|
||||
response.tool_calls,
|
||||
content=response.content,
|
||||
reasoning_content=response.reasoning_content or thinking_text or None,
|
||||
)
|
||||
)
|
||||
|
||||
compact_requested = False
|
||||
|
||||
for tc in response.tool_calls:
|
||||
tool_def = self.registry.get(tc.name)
|
||||
|
||||
# Layer 3: compact tool — mark then defer execution
|
||||
if tc.name == "compact":
|
||||
compact_requested = True
|
||||
messages.append(context.format_tool_result(tc.id, "compact", '{"status":"ok","message":"Compressing..."}'))
|
||||
trace.write({"type": "compact_requested", "iter": iteration})
|
||||
continue
|
||||
|
||||
is_repeatable = tool_def.repeatable if tool_def else False
|
||||
if tc.name in self._called_ok and not is_repeatable:
|
||||
logger.warning(f"Blocked duplicate call: {tc.name} (already succeeded)")
|
||||
skip_msg = json.dumps({"skipped": True, "reason": f"{tc.name} already completed successfully. Use the previous result."})
|
||||
messages.append(context.format_tool_result(tc.id, tc.name, skip_msg))
|
||||
trace.write({"type": "tool_skipped", "iter": iteration, "tool": tc.name})
|
||||
react_trace.append({"type": "tool_skipped", "tool": tc.name})
|
||||
continue
|
||||
|
||||
self._emit("tool_call", {"tool": tc.name, "arguments": {k: str(v)[:200] for k, v in tc.arguments.items()}, "iter": iteration})
|
||||
trace.write({"type": "tool_call", "iter": iteration, "tool": tc.name, "args": {k: str(v)[:200] for k, v in tc.arguments.items()}})
|
||||
logger.info(f"Tool call: {tc.name}({list(tc.arguments.keys())})")
|
||||
|
||||
if "run_dir" not in tc.arguments and self.memory.run_dir:
|
||||
tc.arguments["run_dir"] = self.memory.run_dir
|
||||
|
||||
t0 = _time.perf_counter()
|
||||
result = self.registry.execute(tc.name, tc.arguments)
|
||||
elapsed_ms = int((_time.perf_counter() - t0) * 1000)
|
||||
|
||||
self._update_memory(tc.name, result, state_store, run_dir)
|
||||
|
||||
if _is_tool_success(result):
|
||||
self._called_ok.add(tc.name)
|
||||
|
||||
status = "ok" if _is_tool_success(result) else "error"
|
||||
truncated = result[:TOOL_RESULT_LIMIT]
|
||||
messages.append(context.format_tool_result(tc.id, tc.name, truncated))
|
||||
|
||||
trace.write({"type": "tool_result", "iter": iteration, "tool": tc.name, "status": status, "elapsed_ms": elapsed_ms, "preview": result[:200]})
|
||||
react_trace.append({"type": "tool_call", "tool": tc.name, "result_preview": result[:200]})
|
||||
|
||||
self._emit("tool_result", {"tool": tc.name, "status": status, "elapsed_ms": elapsed_ms, "preview": result[:200]})
|
||||
# Execute tools with read/write batching
|
||||
compact_requested, focus_topic = self._process_tool_calls(
|
||||
response.tool_calls, context, messages, trace, react_trace, iteration,
|
||||
)
|
||||
|
||||
# Layer 3: compress after all tools have executed
|
||||
if compact_requested:
|
||||
logger.info("Manual compact triggered by model")
|
||||
self._auto_compact(messages, run_dir, trace)
|
||||
self._auto_compact(messages, run_dir, trace, focus_topic=focus_topic)
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception(f"AgentLoop error: {exc}")
|
||||
@@ -255,69 +576,399 @@ class AgentLoop:
|
||||
"react_trace": react_trace,
|
||||
}
|
||||
|
||||
# Determine final status
|
||||
# Determine final status. The reason is also propagated into the
|
||||
# returned dict so SessionService can surface a meaningful UI
|
||||
# message instead of "Execution failed: unknown" (issue #114).
|
||||
final_reason: str | None = None
|
||||
if self._cancelled:
|
||||
state_store.mark_failure(run_dir, "cancelled by user")
|
||||
final_reason = "cancelled by user"
|
||||
state_store.mark_failure(run_dir, final_reason)
|
||||
final_status = "cancelled"
|
||||
elif (run_dir / "artifacts" / "metrics.csv").exists() or final_content:
|
||||
state_store.mark_success(run_dir)
|
||||
final_status = "success"
|
||||
else:
|
||||
state_store.mark_failure(run_dir, "pipeline did not complete")
|
||||
final_reason = (
|
||||
f"reached max iterations ({self.max_iterations}) without final answer"
|
||||
)
|
||||
state_store.mark_failure(run_dir, final_reason)
|
||||
final_status = "failed"
|
||||
|
||||
trace.write({"type": "end", "status": final_status, "iterations": iteration})
|
||||
end_event: dict[str, Any] = {
|
||||
"type": "end",
|
||||
"status": final_status,
|
||||
"iterations": iteration,
|
||||
}
|
||||
if final_reason is not None:
|
||||
end_event["reason"] = final_reason
|
||||
trace.write(end_event)
|
||||
trace.close()
|
||||
|
||||
return {
|
||||
result: dict[str, Any] = {
|
||||
"status": final_status,
|
||||
"run_dir": str(run_dir),
|
||||
"run_id": run_dir.name,
|
||||
"content": final_content,
|
||||
"react_trace": react_trace,
|
||||
"iterations": iteration,
|
||||
"max_iterations": self.max_iterations,
|
||||
}
|
||||
if final_reason is not None:
|
||||
result["reason"] = final_reason
|
||||
return result
|
||||
|
||||
def _auto_compact(self, messages: list, run_dir: Path, trace: TraceWriter) -> None:
|
||||
"""Layer 2/3: LLM summarise-and-compress; saves transcript before compressing.
|
||||
# -- Tool execution with read/write batching --------------------------------
|
||||
|
||||
def _process_tool_calls(
|
||||
self,
|
||||
tool_calls: list,
|
||||
context: ContextBuilder,
|
||||
messages: list,
|
||||
trace: TraceWriter,
|
||||
react_trace: list,
|
||||
iteration: int,
|
||||
) -> tuple[bool, str]:
|
||||
"""Pre-process tool calls: handle compact, filter duplicates, batch execute.
|
||||
|
||||
Args:
|
||||
tool_calls: Raw tool calls from LLM response.
|
||||
context: ContextBuilder for formatting messages.
|
||||
messages: Conversation messages (appended in place).
|
||||
trace: TraceWriter.
|
||||
react_trace: React trace list.
|
||||
iteration: Current iteration number.
|
||||
|
||||
Returns:
|
||||
Tuple of (compact_requested, focus_topic).
|
||||
"""
|
||||
compact_requested = False
|
||||
focus_topic = ""
|
||||
to_execute = []
|
||||
|
||||
for tc in tool_calls:
|
||||
# Layer 4: compact tool — mark then defer execution
|
||||
if tc.name == "compact":
|
||||
compact_requested = True
|
||||
focus_topic = tc.arguments.get("focus_topic", "")
|
||||
messages.append(context.format_tool_result(tc.id, "compact", '{"status":"ok","message":"Compressing..."}'))
|
||||
trace.write({"type": "compact_requested", "iter": iteration})
|
||||
continue
|
||||
|
||||
tool_def = self.registry.get(tc.name)
|
||||
is_repeatable = tool_def.repeatable if tool_def else False
|
||||
if tc.name in self._called_ok and not is_repeatable:
|
||||
logger.warning(f"Blocked duplicate call: {tc.name} (already succeeded)")
|
||||
skip_msg = json.dumps({"skipped": True, "reason": f"{tc.name} already completed successfully. Use the previous result."})
|
||||
messages.append(context.format_tool_result(tc.id, tc.name, skip_msg))
|
||||
trace.write({"type": "tool_skipped", "iter": iteration, "tool": tc.name})
|
||||
react_trace.append({"type": "tool_skipped", "tool": tc.name})
|
||||
continue
|
||||
|
||||
to_execute.append(tc)
|
||||
|
||||
if not to_execute:
|
||||
return compact_requested, focus_topic
|
||||
|
||||
# Batch execute: consecutive readonly → parallel, write → serial
|
||||
if len(to_execute) == 1:
|
||||
self._execute_single(to_execute[0], context, messages, trace, react_trace, iteration)
|
||||
else:
|
||||
self._batch_execute(to_execute, context, messages, trace, react_trace, iteration)
|
||||
|
||||
return compact_requested, focus_topic
|
||||
|
||||
def _batch_execute(
|
||||
self,
|
||||
tool_calls: list,
|
||||
context: ContextBuilder,
|
||||
messages: list,
|
||||
trace: TraceWriter,
|
||||
react_trace: list,
|
||||
iteration: int,
|
||||
) -> None:
|
||||
"""Execute tools with read/write batching.
|
||||
|
||||
Consecutive readonly tools run in parallel via ThreadPoolExecutor.
|
||||
Write tools run serially between readonly batches.
|
||||
|
||||
Args:
|
||||
tool_calls: Tool calls to execute.
|
||||
context: ContextBuilder.
|
||||
messages: Conversation messages.
|
||||
trace: TraceWriter.
|
||||
react_trace: React trace list.
|
||||
iteration: Current iteration.
|
||||
"""
|
||||
# Split into batches: consecutive readonly → parallel, write → serial
|
||||
batches: list[tuple[str, list]] = []
|
||||
current_ro: list = []
|
||||
|
||||
for tc in tool_calls:
|
||||
tool_def = self.registry.get(tc.name)
|
||||
if tool_def and tool_def.is_readonly:
|
||||
current_ro.append(tc)
|
||||
else:
|
||||
if current_ro:
|
||||
batches.append(("parallel", current_ro))
|
||||
current_ro = []
|
||||
batches.append(("serial", [tc]))
|
||||
if current_ro:
|
||||
batches.append(("parallel", current_ro))
|
||||
|
||||
for mode, batch in batches:
|
||||
if mode == "parallel" and len(batch) > 1:
|
||||
self._execute_parallel(batch, context, messages, trace, react_trace, iteration)
|
||||
else:
|
||||
for tc in batch:
|
||||
self._execute_single(tc, context, messages, trace, react_trace, iteration)
|
||||
|
||||
def _execute_parallel(
|
||||
self,
|
||||
tool_calls: list,
|
||||
context: ContextBuilder,
|
||||
messages: list,
|
||||
trace: TraceWriter,
|
||||
react_trace: list,
|
||||
iteration: int,
|
||||
) -> None:
|
||||
"""Execute readonly tools in parallel using threads.
|
||||
|
||||
Args:
|
||||
tool_calls: Readonly tool calls to execute in parallel.
|
||||
context: ContextBuilder.
|
||||
messages: Conversation messages.
|
||||
trace: TraceWriter.
|
||||
react_trace: React trace list.
|
||||
iteration: Current iteration.
|
||||
"""
|
||||
# Prepare args + emit events
|
||||
runnable: list[tuple] = []
|
||||
for tc in tool_calls:
|
||||
args = _normalize_tool_run_dir(tc.arguments, self.memory.run_dir)
|
||||
self._emit("tool_call", {"tool": tc.name, "arguments": {k: str(v)[:200] for k, v in args.items()}, "iter": iteration})
|
||||
trace.write({"type": "tool_call", "iter": iteration, "tool": tc.name, "args": {k: str(v)[:200] for k, v in args.items()}})
|
||||
runnable.append((tc, args))
|
||||
|
||||
# Execute in parallel — each worker gets its own heartbeat + progress emitter.
|
||||
def _run(tc_args: tuple) -> tuple:
|
||||
tc, args = tc_args
|
||||
result, elapsed_ms = self._invoke_tool(tc.name, args)
|
||||
return tc, result, elapsed_ms
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(runnable), 8)) as pool:
|
||||
futures = [pool.submit(_run, item) for item in runnable]
|
||||
results = []
|
||||
for i, f in enumerate(futures):
|
||||
try:
|
||||
results.append(f.result())
|
||||
except Exception as exc:
|
||||
tc = runnable[i][0]
|
||||
results.append((tc, json.dumps({"status": "error", "error": str(exc)}), 0))
|
||||
|
||||
# Process results in order
|
||||
for tc, result, elapsed_ms in results:
|
||||
self._finalize_tool_result(tc, result, elapsed_ms, context, messages, trace, react_trace, iteration)
|
||||
|
||||
def _execute_single(
|
||||
self,
|
||||
tc: Any,
|
||||
context: ContextBuilder,
|
||||
messages: list,
|
||||
trace: TraceWriter,
|
||||
react_trace: list,
|
||||
iteration: int,
|
||||
) -> None:
|
||||
"""Execute a single tool call.
|
||||
|
||||
Args:
|
||||
tc: Tool call object.
|
||||
context: ContextBuilder.
|
||||
messages: Conversation messages.
|
||||
trace: TraceWriter.
|
||||
react_trace: React trace list.
|
||||
iteration: Current iteration.
|
||||
"""
|
||||
args = _normalize_tool_run_dir(tc.arguments, self.memory.run_dir)
|
||||
|
||||
self._emit("tool_call", {"tool": tc.name, "arguments": {k: str(v)[:200] for k, v in args.items()}, "iter": iteration})
|
||||
trace.write({"type": "tool_call", "iter": iteration, "tool": tc.name, "args": {k: str(v)[:200] for k, v in args.items()}})
|
||||
logger.info(f"Tool call: {tc.name}({list(args.keys())})")
|
||||
|
||||
result, elapsed_ms = self._invoke_tool(tc.name, args)
|
||||
|
||||
self._finalize_tool_result(tc, result, elapsed_ms, context, messages, trace, react_trace, iteration)
|
||||
|
||||
def _invoke_tool(self, tool_name: str, args: Dict[str, Any]) -> tuple[str, int]:
|
||||
"""Execute a tool with heartbeat + structured progress emission.
|
||||
|
||||
Installs a thread-local progress emitter so the tool may call
|
||||
``emit_progress()`` without taking a callback parameter, and runs a
|
||||
background heartbeat timer that ticks every ``HEARTBEAT_INTERVAL_S``
|
||||
seconds. Both event streams are forwarded through ``self._emit`` and
|
||||
therefore land in the same SSE bus and CLI dashboard as normal
|
||||
tool events.
|
||||
|
||||
Args:
|
||||
tool_name: Tool name to execute.
|
||||
args: Tool arguments dict.
|
||||
|
||||
Returns:
|
||||
Tuple of (result_str, elapsed_ms).
|
||||
"""
|
||||
def _on_progress(event: ProgressEvent) -> None:
|
||||
payload = event.to_dict()
|
||||
payload["tool"] = tool_name
|
||||
self._emit("tool_progress", payload)
|
||||
|
||||
def _on_heartbeat(payload: Dict[str, Any]) -> None:
|
||||
self._emit("tool_heartbeat", payload)
|
||||
|
||||
_set_emitter(_on_progress)
|
||||
t0 = _time.perf_counter()
|
||||
try:
|
||||
with HeartbeatTimer(
|
||||
tool_name=tool_name,
|
||||
interval=HEARTBEAT_INTERVAL_S,
|
||||
emit=_on_heartbeat,
|
||||
):
|
||||
result = self.registry.execute(tool_name, args)
|
||||
finally:
|
||||
_set_emitter(None)
|
||||
elapsed_ms = int((_time.perf_counter() - t0) * 1000)
|
||||
return result, elapsed_ms
|
||||
|
||||
def _finalize_tool_result(
|
||||
self,
|
||||
tc: Any,
|
||||
result: str,
|
||||
elapsed_ms: int,
|
||||
context: ContextBuilder,
|
||||
messages: list,
|
||||
trace: TraceWriter,
|
||||
react_trace: list,
|
||||
iteration: int,
|
||||
) -> None:
|
||||
"""Record a tool result: update memory, append message, write trace, emit event.
|
||||
|
||||
Args:
|
||||
tc: Tool call object.
|
||||
result: Raw tool result string.
|
||||
elapsed_ms: Execution time in milliseconds.
|
||||
context: ContextBuilder.
|
||||
messages: Conversation messages.
|
||||
trace: TraceWriter.
|
||||
react_trace: React trace list.
|
||||
iteration: Current iteration.
|
||||
"""
|
||||
self._update_memory(tc.name)
|
||||
|
||||
success = _is_tool_success(result)
|
||||
if success:
|
||||
self._called_ok.add(tc.name)
|
||||
|
||||
status = "ok" if success else "error"
|
||||
truncated = result[:TOOL_RESULT_LIMIT]
|
||||
messages.append(context.format_tool_result(tc.id, tc.name, truncated))
|
||||
|
||||
trace.write({"type": "tool_result", "iter": iteration, "tool": tc.name, "status": status, "elapsed_ms": elapsed_ms, "preview": result[:200]})
|
||||
react_trace.append({"type": "tool_call", "tool": tc.name, "result_preview": result[:200]})
|
||||
self._emit("tool_result", {"tool": tc.name, "status": status, "elapsed_ms": elapsed_ms, "preview": result[:200]})
|
||||
|
||||
# -- Context compression ---------------------------------------------------
|
||||
|
||||
def _auto_compact(self, messages: list, run_dir: Path, trace: TraceWriter,
|
||||
focus_topic: str = "") -> None:
|
||||
"""Layer 3/4/5: structured LLM summary with token-budget tail protection.
|
||||
|
||||
Upgrades over the original:
|
||||
- Token-budget tail: keeps ~20K tokens of recent messages (not a fixed count).
|
||||
- Structured summary template: preserves goal, progress, decisions, files, etc.
|
||||
- Iterative update: Nth compression updates previous summary, zero info decay.
|
||||
- Tool pair fix: repairs orphaned tool_call/tool_result after compression.
|
||||
- Focus-topic: optionally prioritize specific topic in summary.
|
||||
|
||||
Args:
|
||||
messages: Message list (replaced in place).
|
||||
run_dir: Run directory.
|
||||
trace: TraceWriter.
|
||||
focus_topic: Optional topic to prioritize in the summary.
|
||||
"""
|
||||
# Save full transcript (timestamped, one file per compression)
|
||||
# Save full transcript before compressing
|
||||
transcript_path = run_dir / f"transcript_{int(_time.time())}.jsonl"
|
||||
with open(transcript_path, "w", encoding="utf-8") as f:
|
||||
for msg in messages:
|
||||
f.write(json.dumps(msg, default=str, ensure_ascii=False) + "\n")
|
||||
|
||||
# LLM summary (no tools, plain text)
|
||||
conv_text = json.dumps(messages[1:], default=str, ensure_ascii=False)[:80000]
|
||||
summary_resp = self.llm.chat([
|
||||
{"role": "user", "content": (
|
||||
"Summarize this conversation for continuity. Include: "
|
||||
"1) What was accomplished, 2) Current state, 3) Key decisions made. "
|
||||
"Be concise but preserve critical details.\n\n" + conv_text
|
||||
)},
|
||||
])
|
||||
system_msg = messages[0]
|
||||
body = messages[1:]
|
||||
|
||||
# Token-budget tail: walk backward to find how many recent messages to preserve
|
||||
accumulated = 0
|
||||
cut_idx = len(body)
|
||||
for i in range(len(body) - 1, -1, -1):
|
||||
content = body[i].get("content", "")
|
||||
msg_tokens = (len(str(content)) // 4) + 10
|
||||
if accumulated + msg_tokens > TAIL_TOKEN_BUDGET:
|
||||
cut_idx = i + 1
|
||||
break
|
||||
accumulated += msg_tokens
|
||||
cut_idx = i
|
||||
|
||||
# Don't split in the middle of a tool_call/tool_result pair
|
||||
while 0 < cut_idx < len(body) and body[cut_idx].get("role") == "tool":
|
||||
cut_idx += 1
|
||||
|
||||
head = body[:cut_idx]
|
||||
tail = body[cut_idx:]
|
||||
|
||||
if not head:
|
||||
# All body fits in tail budget — force a split to avoid infinite loop
|
||||
if len(body) > 2:
|
||||
cut_idx = max(1, len(body) // 2)
|
||||
head = body[:cut_idx]
|
||||
tail = body[cut_idx:]
|
||||
else:
|
||||
logger.warning("Auto compact: nothing to compress (body too small)")
|
||||
return
|
||||
|
||||
# Build focus section
|
||||
focus_section = _FOCUS_SECTION.format(topic=focus_topic) if focus_topic else ""
|
||||
|
||||
# Build summary prompt (structured template or iterative update)
|
||||
conv_text = json.dumps(head, default=str, ensure_ascii=False)[:80000]
|
||||
|
||||
if self._previous_summary:
|
||||
prompt = _ITERATIVE_UPDATE_PROMPT.format(
|
||||
previous_summary=self._previous_summary,
|
||||
new_turns=conv_text,
|
||||
focus_section=focus_section,
|
||||
)
|
||||
else:
|
||||
prompt = _STRUCTURED_SUMMARY_PROMPT.format(focus_section=focus_section) + conv_text
|
||||
|
||||
summary_resp = self.llm.chat([{"role": "user", "content": prompt}])
|
||||
summary = summary_resp.content or ""
|
||||
self._previous_summary = summary
|
||||
|
||||
tokens_before = estimate_tokens(messages)
|
||||
trace.write({"type": "compact", "tokens_before": tokens_before, "summary": summary[:500]})
|
||||
trace.write({"type": "compact", "tokens_before": tokens_before, "summary": summary[:500],
|
||||
"focus_topic": focus_topic or "(none)"})
|
||||
self._emit("compact", {"tokens_before": tokens_before, "summary": summary[:200]})
|
||||
|
||||
# Replace: keep system prompt + inject compressed summary + restore identity
|
||||
system_msg = messages[0]
|
||||
# Reconstruct: system + summary + acknowledge + preserved tail
|
||||
state_summary = self.memory.to_summary()
|
||||
compressed = f"[Conversation compressed. Transcript: {transcript_path}]\n\n{summary}"
|
||||
compressed = f"[Conversation compressed — handoff summary. Transcript: {transcript_path}]\n\n{summary}"
|
||||
if state_summary and state_summary != "(empty state)":
|
||||
compressed += f"\n\nCurrent agent state:\n{state_summary}"
|
||||
|
||||
messages.clear()
|
||||
messages.extend([
|
||||
system_msg,
|
||||
{"role": "user", "content": compressed},
|
||||
{"role": "assistant", "content": "Understood. I have the context from the summary. Continuing."},
|
||||
])
|
||||
messages.append(system_msg)
|
||||
messages.append({"role": "user", "content": compressed})
|
||||
messages.append({"role": "assistant", "content": "Understood. Continuing from the summary."})
|
||||
messages.extend(tail)
|
||||
|
||||
# Fix orphaned tool pairs in the reconstructed message list
|
||||
_fix_tool_pairs(messages)
|
||||
|
||||
def _emit(self, event_type: str, data: Dict[str, Any]) -> None:
|
||||
"""Fire an event via the callback."""
|
||||
@@ -327,12 +978,6 @@ class AgentLoop:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _update_memory(self, tool_name: str, result: str, state_store: RunStateStore, run_dir: Path) -> None:
|
||||
"""Update memory and persist tool result."""
|
||||
def _update_memory(self, tool_name: str) -> None:
|
||||
"""Update workspace memory counters after tool execution."""
|
||||
self.memory.increment(tool_name)
|
||||
try:
|
||||
data = json.loads(result)
|
||||
if isinstance(data, dict):
|
||||
state_store.persist_tool_result(tool_name, data, run_dir)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
+13
-86
@@ -1,64 +1,32 @@
|
||||
"""Workspace memory: shared state across tool calls.
|
||||
"""Workspace memory: shared state across tool calls within a single run.
|
||||
|
||||
WorkspaceMemory passes intermediate results between tools during an agent run.
|
||||
Generic KV store + counters; no tool-specific fields.
|
||||
Lightweight runtime state — survives within one AgentLoop.run() invocation only.
|
||||
Cross-session persistence is handled by src.memory.persistent.PersistentMemory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkspaceMemory:
|
||||
"""Shared workspace state between tools.
|
||||
"""Shared workspace state between tools during a single agent run.
|
||||
|
||||
Attributes:
|
||||
run_dir: Current run directory.
|
||||
store: Generic result store (tool memory_key -> JSON string).
|
||||
run_dir: Current run directory path.
|
||||
counters: Tool invocation counters.
|
||||
extra: Generic KV store for derived values passed between tools.
|
||||
"""
|
||||
|
||||
run_dir: Optional[str] = None
|
||||
store: Dict[str, str] = field(default_factory=dict)
|
||||
counters: Dict[str, int] = field(default_factory=dict)
|
||||
extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset all state."""
|
||||
self.run_dir = None
|
||||
self.store.clear()
|
||||
self.counters.clear()
|
||||
self.extra.clear()
|
||||
|
||||
def set_result(self, key: str, value: str) -> None:
|
||||
"""Store a tool result.
|
||||
|
||||
Args:
|
||||
key: Memory key (e.g. "plan_output").
|
||||
value: JSON string.
|
||||
"""
|
||||
self.store[key] = value
|
||||
|
||||
def get_result(self, key: str, default: Optional[str] = None) -> Optional[str]:
|
||||
"""Retrieve a tool result.
|
||||
|
||||
Args:
|
||||
key: Memory key.
|
||||
default: Default value.
|
||||
|
||||
Returns:
|
||||
JSON string or default.
|
||||
"""
|
||||
return self.store.get(key, default)
|
||||
|
||||
def increment(self, key: str) -> int:
|
||||
"""Increment a counter and return the new value.
|
||||
|
||||
Args:
|
||||
key: Counter key.
|
||||
key: Counter key (typically tool name).
|
||||
|
||||
Returns:
|
||||
Updated counter value.
|
||||
@@ -66,60 +34,19 @@ class WorkspaceMemory:
|
||||
self.counters[key] = self.counters.get(key, 0) + 1
|
||||
return self.counters[key]
|
||||
|
||||
def set_extra(self, key: str, value: Any) -> None:
|
||||
"""Set an extra KV entry.
|
||||
|
||||
Args:
|
||||
key: Key name.
|
||||
value: Value.
|
||||
"""
|
||||
self.extra[key] = value
|
||||
|
||||
def get_extra(self, key: str, default: Any = None) -> Any:
|
||||
"""Get an extra KV entry.
|
||||
|
||||
Args:
|
||||
key: Key name.
|
||||
default: Default value.
|
||||
|
||||
Returns:
|
||||
Stored value or default.
|
||||
"""
|
||||
return self.extra.get(key, default)
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""Set a KV entry (alias for set_extra).
|
||||
|
||||
Args:
|
||||
key: Key name.
|
||||
value: Value.
|
||||
"""
|
||||
self.extra[key] = value
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a KV entry (alias for get_extra).
|
||||
|
||||
Args:
|
||||
key: Key name.
|
||||
default: Default value.
|
||||
|
||||
Returns:
|
||||
Stored value or default.
|
||||
"""
|
||||
return self.extra.get(key, default)
|
||||
|
||||
def to_summary(self) -> str:
|
||||
"""Generate a state summary for the LLM indicating completed steps.
|
||||
"""Generate a state summary for the LLM.
|
||||
|
||||
Includes run_dir and tool counters.
|
||||
This summary survives context compression and helps the LLM
|
||||
remember what it was working on.
|
||||
|
||||
Returns:
|
||||
State summary text.
|
||||
"""
|
||||
lines = []
|
||||
lines: list[str] = []
|
||||
if self.run_dir:
|
||||
lines.append(f"- run_dir: {self.run_dir}")
|
||||
if self.store:
|
||||
completed = list(self.store.keys())
|
||||
lines.append(f"- completed: {', '.join(completed)}")
|
||||
if self.counters:
|
||||
counter_parts = [f"{k}={v}" for k, v in self.counters.items()]
|
||||
lines.append(f"- counters: {', '.join(counter_parts)}")
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Progress emission channel for long-running tools.
|
||||
|
||||
Two mechanisms:
|
||||
* Tool-level heartbeat: a background timer fires keepalive events every N
|
||||
seconds while a tool runs, so the UI never looks frozen. Driven by the
|
||||
agent loop, opaque to the tool.
|
||||
* Structured progress: tools can opt in via ``emit_progress()`` to publish
|
||||
quantified state (stage, current/total, message). Routed back to the
|
||||
agent loop's event channel via a thread-local emitter set before the
|
||||
tool executes.
|
||||
|
||||
Thread model: tools run synchronously inside ``ToolRegistry.execute`` from
|
||||
worker threads (read batches) or the main loop thread (writes). A
|
||||
``threading.local`` slot holds the per-thread emitter so structured
|
||||
progress flows back to the correct AgentLoop instance even when multiple
|
||||
read-only tools run in parallel.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProgressEvent:
|
||||
"""Structured progress event emitted by a tool mid-execution.
|
||||
|
||||
Attributes:
|
||||
tool: Tool name (filled by the loop, tools don't supply this).
|
||||
stage: Short stage label, e.g. ``loading_data`` or ``simulating``.
|
||||
current: Current unit count (e.g. page 23 of 100). Optional.
|
||||
total: Total unit count. Optional.
|
||||
message: Free-form human-readable detail.
|
||||
elapsed_s: Seconds since the tool started.
|
||||
ts: Wall-clock timestamp.
|
||||
"""
|
||||
|
||||
tool: str = ""
|
||||
stage: str = ""
|
||||
current: Optional[int] = None
|
||||
total: Optional[int] = None
|
||||
message: str = ""
|
||||
elapsed_s: float = 0.0
|
||||
ts: float = field(default_factory=time.time)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return a JSON-serializable dict for SSE payloads."""
|
||||
return {
|
||||
"tool": self.tool,
|
||||
"stage": self.stage,
|
||||
"current": self.current,
|
||||
"total": self.total,
|
||||
"message": self.message,
|
||||
"elapsed_s": round(self.elapsed_s, 2),
|
||||
"ts": self.ts,
|
||||
}
|
||||
|
||||
|
||||
# Thread-local emitter slot. The agent loop sets ``_local.emit`` to a callable
|
||||
# before invoking the tool and clears it after, so tools running on the same
|
||||
# thread can publish structured progress without a circular import.
|
||||
_local = threading.local()
|
||||
|
||||
|
||||
def _set_emitter(emit: Optional[Callable[[ProgressEvent], None]]) -> None:
|
||||
"""Install the active progress emitter for the current thread.
|
||||
|
||||
Args:
|
||||
emit: Callable that consumes a ``ProgressEvent``. Pass ``None`` to clear.
|
||||
"""
|
||||
if emit is None:
|
||||
if hasattr(_local, "emit"):
|
||||
del _local.emit
|
||||
return
|
||||
_local.emit = emit
|
||||
|
||||
|
||||
def _get_emitter() -> Optional[Callable[[ProgressEvent], None]]:
|
||||
"""Return the active emitter on the current thread, if any."""
|
||||
return getattr(_local, "emit", None)
|
||||
|
||||
|
||||
def emit_progress(
|
||||
stage: str = "",
|
||||
*,
|
||||
current: Optional[int] = None,
|
||||
total: Optional[int] = None,
|
||||
message: str = "",
|
||||
) -> None:
|
||||
"""Publish a structured progress event from a tool.
|
||||
|
||||
Silently no-ops when called outside an active tool context (e.g. during
|
||||
unit tests that invoke tools directly). Never raises.
|
||||
|
||||
Args:
|
||||
stage: Short stage label.
|
||||
current: Current unit count.
|
||||
total: Total unit count.
|
||||
message: Free-form human-readable detail.
|
||||
"""
|
||||
emit = _get_emitter()
|
||||
if emit is None:
|
||||
return
|
||||
try:
|
||||
event = ProgressEvent(
|
||||
stage=stage,
|
||||
current=current,
|
||||
total=total,
|
||||
message=message,
|
||||
)
|
||||
emit(event)
|
||||
except Exception:
|
||||
# Progress emission must never break a tool.
|
||||
pass
|
||||
|
||||
|
||||
class HeartbeatTimer:
|
||||
"""Background thread that emits keepalive ticks while a tool runs.
|
||||
|
||||
Use as a context manager around a single tool invocation:
|
||||
|
||||
with HeartbeatTimer(tool_name="run_backtest", interval=3.0, emit=fn):
|
||||
result = registry.execute(...)
|
||||
|
||||
The timer wakes every ``interval`` seconds and calls ``emit`` with a
|
||||
dict containing ``tool`` and ``elapsed_s``. Stops cleanly on context
|
||||
exit; ``join`` is bounded so a hung emitter can't deadlock the loop.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tool_name: str,
|
||||
interval: float,
|
||||
emit: Callable[[Dict[str, Any]], None],
|
||||
) -> None:
|
||||
"""Initialize the heartbeat timer (not started until ``__enter__``).
|
||||
|
||||
Args:
|
||||
tool_name: Tool name surfaced in each tick payload.
|
||||
interval: Seconds between ticks. Values <0.5 are clamped.
|
||||
emit: Callback invoked with each tick payload.
|
||||
"""
|
||||
self._tool_name = tool_name
|
||||
requested_interval = float(interval)
|
||||
self._interval = max(0.5, requested_interval)
|
||||
if requested_interval < 0.5:
|
||||
logger.warning(
|
||||
"HeartbeatTimer interval %s clamped to 0.5s", interval
|
||||
)
|
||||
self._emit = emit
|
||||
self._stop_event = threading.Event()
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._t0 = 0.0
|
||||
|
||||
def __enter__(self) -> "HeartbeatTimer":
|
||||
self._t0 = time.perf_counter()
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name=f"heartbeat-{self._tool_name}",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
self._stop_event.set()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=1.0)
|
||||
|
||||
def _run(self) -> None:
|
||||
"""Tick loop: wait + emit until the stop event fires."""
|
||||
while not self._stop_event.wait(self._interval):
|
||||
elapsed = time.perf_counter() - self._t0
|
||||
try:
|
||||
self._emit({"tool": self._tool_name, "elapsed_s": round(elapsed, 2)})
|
||||
except Exception:
|
||||
# A failing callback must not crash the heartbeat thread.
|
||||
pass
|
||||
+59
-47
@@ -7,7 +7,6 @@ Uses progressive disclosure:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
@@ -20,6 +19,7 @@ class Skill:
|
||||
Attributes:
|
||||
name: Skill name.
|
||||
description: Skill description.
|
||||
category: Skill category for grouped display.
|
||||
body: SKILL.md body text.
|
||||
dir_path: Skill directory path (used for on-demand loading of supporting files).
|
||||
metadata: Parsed frontmatter metadata.
|
||||
@@ -27,6 +27,7 @@ class Skill:
|
||||
|
||||
name: str
|
||||
description: str = ""
|
||||
category: str = "other"
|
||||
body: str = ""
|
||||
dir_path: Optional[Path] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
@@ -51,36 +52,7 @@ class Skill:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
|
||||
"""Parse YAML frontmatter and body.
|
||||
|
||||
Args:
|
||||
text: Markdown text.
|
||||
|
||||
Returns:
|
||||
Tuple of (metadata dict, body text).
|
||||
"""
|
||||
match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)", text, re.DOTALL)
|
||||
if not match:
|
||||
return {}, text.strip()
|
||||
|
||||
meta: Dict[str, Any] = {}
|
||||
for line in match.group(1).strip().split("\n"):
|
||||
line = line.strip()
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if value.startswith("[") and value.endswith("]"):
|
||||
items = [item.strip().strip("'\"") for item in value[1:-1].split(",")]
|
||||
meta[key] = [i for i in items if i]
|
||||
elif value.lower() in ("true", "false"):
|
||||
meta[key] = value.lower() == "true"
|
||||
else:
|
||||
meta[key] = value
|
||||
|
||||
return meta, match.group(2).strip()
|
||||
from src.agent.frontmatter import parse_frontmatter as _parse_frontmatter # shared util
|
||||
|
||||
|
||||
def _load_skill_dir(dir_path: Path) -> Optional[Skill]:
|
||||
@@ -108,55 +80,87 @@ def _load_skill_dir(dir_path: Path) -> Optional[Skill]:
|
||||
return Skill(
|
||||
name=name,
|
||||
description=meta.get("description", ""),
|
||||
category=meta.get("category", "other"),
|
||||
body=body,
|
||||
dir_path=dir_path,
|
||||
metadata=meta,
|
||||
)
|
||||
|
||||
|
||||
USER_SKILLS_DIR = Path.home() / ".vibe-trading" / "skills" / "user"
|
||||
|
||||
|
||||
class SkillsLoader:
|
||||
"""Load skills from the skills/ directory.
|
||||
"""Load skills from bundled skills/ directory and user skills directory.
|
||||
|
||||
Attributes:
|
||||
skills: Loaded skill list.
|
||||
skills: Loaded skill list (bundled + user-created).
|
||||
"""
|
||||
|
||||
def __init__(self, skills_dir: Optional[Path] = None) -> None:
|
||||
def __init__(self, skills_dir: Optional[Path] = None,
|
||||
user_skills_dir: Optional[Path] = None) -> None:
|
||||
"""Initialize SkillsLoader.
|
||||
|
||||
Args:
|
||||
skills_dir: Skills directory path; defaults to agent/skills/.
|
||||
skills_dir: Bundled skills directory path; defaults to agent/skills/.
|
||||
user_skills_dir: User-created skills directory; defaults to ~/.vibe-trading/skills/user/.
|
||||
"""
|
||||
self.skills_dir = skills_dir or Path(__file__).resolve().parents[1] / "skills"
|
||||
self._user_skills_dir = user_skills_dir or USER_SKILLS_DIR
|
||||
self.skills: List[Skill] = []
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
"""Load all skill subdirectories from the skills directory."""
|
||||
if not self.skills_dir.exists():
|
||||
return
|
||||
for path in sorted(self.skills_dir.iterdir()):
|
||||
if path.is_dir() and (path / "SKILL.md").exists():
|
||||
skill = _load_skill_dir(path)
|
||||
if skill:
|
||||
self.skills.append(skill)
|
||||
"""Load all skill subdirectories from user and bundled directories.
|
||||
|
||||
User skills are loaded first so they override bundled skills with the same name
|
||||
(e.g. after patch_skill copies and modifies a bundled skill).
|
||||
"""
|
||||
seen_names: set[str] = set()
|
||||
for directory in (self._user_skills_dir, self.skills_dir):
|
||||
if not directory or not directory.exists():
|
||||
continue
|
||||
for path in sorted(directory.iterdir()):
|
||||
if path.is_dir() and (path / "SKILL.md").exists():
|
||||
skill = _load_skill_dir(path)
|
||||
if skill and skill.name not in seen_names:
|
||||
self.skills.append(skill)
|
||||
seen_names.add(skill.name)
|
||||
|
||||
# Display order for categories (unlisted categories appear at the end).
|
||||
_CATEGORY_ORDER = [
|
||||
"data-source", "strategy", "analysis", "asset-class",
|
||||
"crypto", "flow", "tool", "other",
|
||||
]
|
||||
|
||||
def get_descriptions(self) -> str:
|
||||
"""Return a one-line summary of every skill for the system prompt (low token cost).
|
||||
"""Return skills grouped by category for the system prompt.
|
||||
|
||||
Returns:
|
||||
One line per skill with name + description.
|
||||
Grouped skill list with category headers.
|
||||
"""
|
||||
if not self.skills:
|
||||
return "(no skills)"
|
||||
lines = []
|
||||
|
||||
groups: Dict[str, List[Skill]] = {}
|
||||
for skill in self.skills:
|
||||
lines.append(f" - {skill.name}: {skill.description}")
|
||||
groups.setdefault(skill.category, []).append(skill)
|
||||
|
||||
ordered_cats = [c for c in self._CATEGORY_ORDER if c in groups]
|
||||
ordered_cats += [c for c in sorted(groups) if c not in ordered_cats]
|
||||
|
||||
lines: List[str] = []
|
||||
for cat in ordered_cats:
|
||||
lines.append(f"\n### {cat}")
|
||||
for skill in groups[cat]:
|
||||
lines.append(f" - {skill.name}: {skill.description}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def get_content(self, name: str) -> str:
|
||||
"""Return the full documentation for a skill (used by the load_skill tool).
|
||||
|
||||
Falls back to disk lookup for user skills created mid-session.
|
||||
|
||||
Args:
|
||||
name: Skill name.
|
||||
|
||||
@@ -166,5 +170,13 @@ class SkillsLoader:
|
||||
for skill in self.skills:
|
||||
if skill.name == name:
|
||||
return f'<skill name="{name}">\n{skill.body}\n</skill>'
|
||||
|
||||
# Fallback: check user skills directory on disk (mid-session created skills)
|
||||
if self._user_skills_dir:
|
||||
skill = _load_skill_dir(self._user_skills_dir / name)
|
||||
if skill:
|
||||
self.skills.append(skill)
|
||||
return f'<skill name="{name}">\n{skill.body}\n</skill>'
|
||||
|
||||
available = ", ".join(s.name for s in self.skills)
|
||||
return f"Error: Unknown skill '{name}'. Available: {available}"
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
class TraceWriter:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""HTTP route packages mounted onto the main FastAPI app in ``api_server.py``."""
|
||||
Loaded 100 of 1042 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user