Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ body:
|
||||
attributes:
|
||||
label: Version
|
||||
description: "Run `pip show vibe-trading-ai` to check"
|
||||
placeholder: "e.g., 0.1.3"
|
||||
placeholder: "e.g., 0.1.5"
|
||||
|
||||
- type: input
|
||||
id: environment
|
||||
|
||||
+24
-1
@@ -1,4 +1,4 @@
|
||||
.venv/
|
||||
.venv/
|
||||
agent/.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
@@ -16,10 +16,15 @@ agent/sessions/
|
||||
agent/runs/
|
||||
agent/.ui_runtime/
|
||||
agent/.tasks/
|
||||
agent/uploads/
|
||||
agent/tests/*_result.json
|
||||
.ruff_cache/
|
||||
frontend/dist/
|
||||
frontend/tsconfig.tsbuildinfo
|
||||
.vibe-dev/
|
||||
.vibe-record/
|
||||
.cache/
|
||||
.remotion/
|
||||
|
||||
# DuckDB data cache
|
||||
data/*.duckdb
|
||||
@@ -40,3 +45,21 @@ agent/vibe_trading_ai.egg-info/
|
||||
agent/tests/e2e_backtest/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Local demo videos (too large for git; link to external host instead)
|
||||
assets/*.mp4
|
||||
|
||||
# Local demo / promo production workspace
|
||||
demo/
|
||||
|
||||
# Manual E2E smoke tests — require live API keys (OpenRouter / yfinance),
|
||||
# would break CI if committed. Kept local; run explicitly when validating
|
||||
# a release.
|
||||
agent/tests/e2e_*.py
|
||||
agent/tests/test_e2e_*.py
|
||||
|
||||
# Local working notes
|
||||
NOTES.md
|
||||
|
||||
# Agent tool aliases (auto-generated mirror of CLAUDE.md)
|
||||
AGENTS.md
|
||||
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socioeconomic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement via
|
||||
[GitHub Issues](https://github.com/HKUDS/Vibe-Trading/issues).
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
+3
-3
@@ -42,10 +42,10 @@ cd frontend && npm install && npm run dev
|
||||
|
||||
| Directory | What lives here | Open to contribute? |
|
||||
|-----------|----------------|:-------------------:|
|
||||
| `agent/src/skills/` | 68 finance skill definitions (SKILL.md) | Yes |
|
||||
| `agent/src/skills/` | 74 finance skill definitions (SKILL.md) | Yes |
|
||||
| `agent/src/tools/` | 21 agent tools | Yes |
|
||||
| `agent/backtest/` | Backtest engines, loaders, optimizers | Yes |
|
||||
| `agent/config/swarm/` | 29 swarm preset YAMLs | Yes |
|
||||
| `agent/src/swarm/presets/` | 29 swarm preset YAMLs | Yes |
|
||||
| `frontend/` | React 19 + Vite web UI | Yes |
|
||||
| `agent/src/agent/` | ReAct agent core (loop, context, skills) | Ask first |
|
||||
| `agent/src/session/` | Session management | Ask first |
|
||||
@@ -112,7 +112,7 @@ Each skill is a single `SKILL.md` file in `agent/src/skills/<category>/<skill-na
|
||||
|
||||
### Easy: New Swarm Preset
|
||||
|
||||
A YAML file in `agent/config/swarm/` defining agents, roles, and DAG workflow. See existing presets.
|
||||
A YAML file in `agent/src/swarm/presets/` defining agents, roles, and DAG workflow. See existing presets.
|
||||
|
||||
### Medium: New Data Source Loader
|
||||
|
||||
|
||||
+13
@@ -14,6 +14,12 @@ RUN npm run build
|
||||
# ============================================================================
|
||||
FROM python:3.11-slim AS runtime
|
||||
|
||||
LABEL org.opencontainers.image.title="Vibe-Trading" \
|
||||
org.opencontainers.image.description="Natural-language finance research AI agent with backtesting" \
|
||||
org.opencontainers.image.version="0.1.7" \
|
||||
org.opencontainers.image.source="https://github.com/HKUDS/Vibe-Trading" \
|
||||
org.opencontainers.image.licenses="MIT"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# System deps
|
||||
@@ -35,6 +41,13 @@ COPY --from=frontend-build /app/frontend/dist frontend/dist
|
||||
# Install CLI entrypoint
|
||||
RUN pip install --no-cache-dir -e .
|
||||
|
||||
# Runtime should not run as root. Keep writable app data directories owned by
|
||||
# the service user so named Docker volumes inherit usable permissions.
|
||||
RUN useradd --create-home --shell /usr/sbin/nologin vibe \
|
||||
&& mkdir -p agent/runs agent/sessions agent/uploads agent/.swarm/runs \
|
||||
&& chown -R vibe:vibe /app
|
||||
USER vibe
|
||||
|
||||
# Default port
|
||||
EXPOSE 8899
|
||||
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
include LICENSE
|
||||
include README.md
|
||||
include README_zh.md
|
||||
include README_ja.md
|
||||
include README_ko.md
|
||||
include README_ar.md
|
||||
include pyproject.toml
|
||||
include Dockerfile
|
||||
include docker-compose.yml
|
||||
|
||||
# Developer entry-point template
|
||||
include agent/.env.example
|
||||
|
||||
# Tests shipped so dev installs can run `pytest` against the package.
|
||||
# Files prefixed with `_` are local debug scripts kept untracked — keep
|
||||
# them out of the sdist even if present in the working tree.
|
||||
recursive-include agent/tests *.py
|
||||
recursive-exclude agent/tests _*.py
|
||||
|
||||
# Strip generated / VCS / editor noise
|
||||
global-exclude __pycache__
|
||||
global-exclude *.py[cod]
|
||||
global-exclude *.so
|
||||
global-exclude .DS_Store
|
||||
global-exclude .pytest_cache
|
||||
global-exclude *.egg-info
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<b>English</b> | <a href="README_zh.md">中文</a> | <a href="README_ja.md">日本語</a> | <a href="README_ko.md">한국어</a>
|
||||
<b>English</b> | <a href="README_zh.md">中文</a> | <a href="README_ja.md">日本語</a> | <a href="README_ko.md">한국어</a> | <a href="README_ar.md">العربية</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -19,10 +19,10 @@
|
||||
<a href="https://pypi.org/project/vibe-trading-ai/"><img src="https://img.shields.io/pypi/v/vibe-trading-ai?style=flat&logo=pypi&logoColor=white" alt="PyPI"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow?style=flat" alt="License"></a>
|
||||
<br>
|
||||
<img src="https://img.shields.io/badge/Skills-68-orange" alt="Skills">
|
||||
<img src="https://img.shields.io/badge/Skills-74-orange" alt="Skills">
|
||||
<img src="https://img.shields.io/badge/Swarm_Presets-29-7C3AED" alt="Swarm">
|
||||
<img src="https://img.shields.io/badge/Tools-21-0F766E" alt="Tools">
|
||||
<img src="https://img.shields.io/badge/Data_Sources-5-2563EB" alt="Data Sources">
|
||||
<img src="https://img.shields.io/badge/Tools-27-0F766E" alt="Tools">
|
||||
<img src="https://img.shields.io/badge/Data_Sources-6-2563EB" alt="Data Sources">
|
||||
<br>
|
||||
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/Feishu-Group-E9DBFC?style=flat-square&logo=feishu&logoColor=white" alt="Feishu"></a>
|
||||
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat-square&logo=wechat&logoColor=white" alt="WeChat"></a>
|
||||
@@ -51,10 +51,35 @@
|
||||
|
||||
## 📰 News
|
||||
|
||||
- **2026-04-10** 📦 **v0.1.4**: Fix Docker build ([#8](https://github.com/HKUDS/Vibe-Trading/issues/8)), add `web_search` MCP tool (17 total), `akshare`/`ccxt` in deps & MCP. 11 LLM providers (DeepSeek, Groq, Gemini, Ollama, etc.), all tuning params via `.env`. Hardened `ml-strategy` skill. Published to PyPI and ClawHub.
|
||||
- **2026-04-09** 📊 **Backtest Wave 2 — multi-asset engines**: added ChinaFutures (CFFEX/SHFE/DCE/ZCE, 50+ contracts), GlobalFutures (CME/ICE/Eurex, 30+ contracts), Forex (24 pairs, spread + swap), Options v2 (American exercise, IV smile). Statistical validation: Monte Carlo permutation test, Bootstrap Sharpe CI, Walk-Forward analysis.
|
||||
- **2026-04-08** 🔧 **Multi-market backtest** with per-market rules; **Pine Script v6 export** for TradingView. **Data source expansion**: 5 sources with auto-fallback, `web_search` tool, skill categorization (7 categories).
|
||||
- **2026-04-01** 🚀 **v0.1.0** — Initial release: ReAct agent, 64 skills, 29 swarm presets, cross-market backtest, CLI + Web UI + MCP server.
|
||||
- **2026-05-05** 🛡️ **Security boundary follow-up**: Completes the remaining security-boundary hardening around explicit CORS origins, Settings credential indicators, web URL reading, and Shadow Account code generation, with regression tests added for each path. Normal localhost CLI/Web UI workflows stay the same; remote deployments should continue using `API_AUTH_KEY` and explicit trusted origins.
|
||||
- **2026-05-04** 🖥️ **Interactive CLI UX + CI cleanup**: Interactive mode now has a live bottom status bar showing provider/model, session duration, last-run latency, and cumulative tool-call stats, plus prompt history navigation and cursor editing with arrow keys via `prompt_toolkit` ([#69](https://github.com/HKUDS/Vibe-Trading/pull/69)). The CLI still falls back to Rich prompts when `prompt_toolkit` or a TTY is unavailable. CI path expectations were also aligned with the hardened file-import sandbox and cross-platform `/tmp` resolution, returning main to green ([`bb67dc7`](https://github.com/HKUDS/Vibe-Trading/commit/bb67dc7cfcc11553c57d8962bee56381dca43758)).
|
||||
- **2026-05-03** 🛡️ **Security hardening patch**: Tightens default API authentication for non-local deployments, protects sensitive run/session/swarm reads, restricts upload and local file-reading boundaries, gates shell-capable tools by entry point, validates generated strategy loading before import, and runs the Docker image as a non-root user with a localhost-only published port by default. Local CLI and localhost Web UI workflows remain low-friction; remote API/Web deployments should set `API_AUTH_KEY`.
|
||||
|
||||
<details>
|
||||
<summary>Earlier news</summary>
|
||||
|
||||
- **2026-05-02** 🧭 **Dividend analysis + sharper roadmap**: Added the `dividend-analysis` skill for income stocks, payout sustainability, dividend growth, shareholder yield, ex-dividend mechanics, and yield-trap checks, pinned by bundled-skill regression tests. The public roadmap now focuses on upcoming work: Research Autopilot, Data Bridge, Options Lab, Portfolio Studio, Alpha Zoo, Research Delivery, Trust Layer, and Community sharing.
|
||||
- **2026-05-01** 🔥 **Correlation heatmap + OpenAI Codex OAuth + A-share pre-ST filter**: New correlation dashboard/API computes rolling return correlations and renders an ECharts heatmap for portfolio and symbol analysis ([#64](https://github.com/HKUDS/Vibe-Trading/pull/64)). OpenAI Codex provider support now uses ChatGPT OAuth via `vibe-trading provider login openai-codex`, with Settings metadata and adapter regression tests ([#65](https://github.com/HKUDS/Vibe-Trading/pull/65)). Added and hardened the `ashare-pre-st-filter` skill for A-share ST/*ST risk screening, including Sina penalty relevance filtering so securities-account mentions do not inflate E2 counts ([#63](https://github.com/HKUDS/Vibe-Trading/pull/63)).
|
||||
- **2026-04-30** ⚙️ **Web UI Settings + validation CLI hardening**: New Settings page for LLM provider/model, base URL, reasoning effort, and data source credentials, backed by local/auth-protected settings APIs and data-driven provider metadata ([#57](https://github.com/HKUDS/Vibe-Trading/pull/57)). Also hardens `python -m backtest.validation <run_dir>` so missing, blank, malformed, non-existent, and non-directory inputs fail with clear operator-facing messages before validation starts ([#60](https://github.com/HKUDS/Vibe-Trading/pull/60)).
|
||||
- **2026-04-28** 🚀 **v0.1.6 released** (`pip install -U vibe-trading-ai`): Fixes `vibe-trading --swarm-presets` returning empty after `pip install` / `uv tool install` ([#55](https://github.com/HKUDS/Vibe-Trading/issues/55)) — preset YAMLs now bundled inside the `src.swarm` package and pinned by a 6-test regression suite. Plus AKShare loader correctly routes ETFs (`510300.SH`) and forex (`USDCNH`) to the right endpoints with hardened registry fallback. Rolls up everything since v0.1.5: benchmark comparison panel, `/upload` streaming + size limits, Futu loader (HK + A-share), vnpy export skill, security hardening, frontend lazy loading (688KB → 262KB).
|
||||
- **2026-04-27** 📊 **Benchmark panel + upload safety**: Backtest output now ships a benchmark comparison panel (ticker / benchmark return / excess return / information ratio) with yfinance-backed resolution for SPY, CSI 300, etc. ([#48](https://github.com/HKUDS/Vibe-Trading/issues/48)). Plus `/upload` streams the request body in 1 MB chunks and aborts past `MAX_UPLOAD_SIZE`, bounding memory under oversized/malformed clients ([#53](https://github.com/HKUDS/Vibe-Trading/pull/53)) — pinned by a 4-case regression suite.
|
||||
- **2026-04-22** 🛡️ **Hardening + new integrations**: Path containment enforced in `safe_path` + journal/shadow tool sandbox, `MANIFEST.in` ships `.env.example` / tests / Docker files in sdist, route-level lazy loading shrinks frontend initial bundle 688KB → 262KB. Plus Futu data loader for HK & A-share equities ([#47](https://github.com/HKUDS/Vibe-Trading/pull/47)) and vnpy CtaTemplate export skill ([#46](https://github.com/HKUDS/Vibe-Trading/pull/46)).
|
||||
- **2026-04-21** 🛡️ **Workspace + docs**: Relative `run_dir` normalized to active run dir ([#43](https://github.com/HKUDS/Vibe-Trading/pull/43)). README usage examples ([#45](https://github.com/HKUDS/Vibe-Trading/pull/45)).
|
||||
- **2026-04-20** 🔌 **Reasoning + Swarm**: `reasoning_content` preserved across all `ChatOpenAI` paths — Kimi / DeepSeek / Qwen thinking work end-to-end ([#39](https://github.com/HKUDS/Vibe-Trading/issues/39)). Swarm streaming + clean Ctrl+C ([#42](https://github.com/HKUDS/Vibe-Trading/issues/42)).
|
||||
- **2026-04-19** 📦 **v0.1.5**: Published to PyPI & ClawHub. `python-multipart` CVE floor bump, 5 new MCP tools wired (`analyze_trade_journal` + 4 shadow-account tools), `pattern_recognition` → `pattern` registry fix, Docker dep parity, SKILL manifest synced (22 MCP tools / 71 skills).
|
||||
- **2026-04-18** 👥 **Shadow Account**: Extract your strategy rules from a broker journal → backtest the shadow across markets → 8-section HTML/PDF report showing exactly how much you leave on the table (rule violations, early exits, missed signals, counterfactual trades). 4 new tools, 1 skill, 32 tools total. Trade Journal + Shadow Account samples now live in the web UI welcome screen.
|
||||
- **2026-04-17** 📊 **Trade Journal Analyzer + Universal File Reader**: Upload broker exports (同花顺/东财/富途/generic CSV) → auto trading profile (holding days, win rate, PnL ratio, drawdown) + 4 bias diagnostics (disposition effect, overtrading, chasing momentum, anchoring). `read_document` now dispatches PDF, Word, Excel, PowerPoint, images (OCR), and 40+ text formats behind one unified call.
|
||||
- **2026-04-16** 🧠 **Agent Harness**: Persistent cross-session memory, FTS5 session search, self-evolving skills (full CRUD), 5-layer context compression, read/write tool batching. 27 tools, 107 new tests.
|
||||
- **2026-04-15** 🤖 **Z.ai + MiniMax**: Z.ai provider ([#35](https://github.com/HKUDS/Vibe-Trading/pull/35)), MiniMax temperature fix + model update ([#33](https://github.com/HKUDS/Vibe-Trading/pull/33)). 13 providers.
|
||||
- **2026-04-14** 🔧 **MCP Stability**: Fixed backtest tool `Connection closed` error on stdio transport ([#32](https://github.com/HKUDS/Vibe-Trading/pull/32)).
|
||||
- **2026-04-13** 🌐 **Cross-Market Composite Backtest**: New `CompositeEngine` backtests mixed-market portfolios (e.g. A-shares + crypto) with shared capital pool and per-market rules. Also fixed swarm template variable fallback and frontend timeout.
|
||||
- **2026-04-12** 🌍 **Multi-Platform Export**: `/pine` exports strategies to TradingView (Pine Script v6), TDX (通达信/同花顺/东方财富), and MetaTrader 5 (MQL5) in one command.
|
||||
- **2026-04-11** 🛡️ **Reliability & DX**: `vibe-trading init` .env bootstrap ([#19](https://github.com/HKUDS/Vibe-Trading/pull/19)), preflight checks, runtime data-source fallback, hardened backtest engine. Multi-language README ([#21](https://github.com/HKUDS/Vibe-Trading/pull/21)).
|
||||
- **2026-04-10** 📦 **v0.1.4**: Docker fix ([#8](https://github.com/HKUDS/Vibe-Trading/issues/8)), `web_search` MCP tool, 12 LLM providers, `akshare`/`ccxt` deps. Published to PyPI and ClawHub.
|
||||
- **2026-04-09** 📊 **Backtest Wave 2**: ChinaFutures, GlobalFutures, Forex, Options v2 engines. Monte Carlo, Bootstrap CI, Walk-Forward validation.
|
||||
- **2026-04-08** 🔧 **Multi-market backtest** with per-market rules, Pine Script v6 export, 5 data sources with auto-fallback.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@@ -63,12 +88,12 @@
|
||||
Vibe-Trading is an AI-powered multi-agent finance workspace that turns natural language requests into executable trading strategies, research insights, and portfolio analysis across global markets.
|
||||
|
||||
### Key Capabilities:
|
||||
• **Strategy Generation** — Automatically writes trading code from your ideas<br>
|
||||
• **Smart Data Access** — 5 data sources with automatic fallback; zero-config for all markets<br>
|
||||
• **Performance Testing** — Tests your strategies against historical market data<br>
|
||||
• **TradingView Export** — One-click convert strategies to Pine Script v6 for TradingView<br>
|
||||
• **Expert Teams** — Deploys specialized AI agents for complex research tasks<br>
|
||||
• **Live Updates** — Watch the entire analysis process in real-time
|
||||
• **Natural Language → Strategy** — Describe an idea; the agent writes, tests, and exports trading code<br>
|
||||
• **6 Data Sources, Zero Config** — A-shares, HK/US, crypto, futures & forex with automatic fallback<br>
|
||||
• **29 Expert Teams** — Pre-built multi-agent swarm workflows for investment, trading & risk<br>
|
||||
• **Cross-Session Memory** — Remembers preferences and insights; creates & evolves reusable skills<br>
|
||||
• **7 Backtest Engines** — Cross-market composite testing with statistical validation & 4 optimizers<br>
|
||||
• **Multi-Platform Export** — One-click to TradingView, TDX (通达信/同花顺), and MetaTrader 5
|
||||
|
||||
---
|
||||
|
||||
@@ -79,12 +104,12 @@ Vibe-Trading is an AI-powered multi-agent finance workspace that turns natural l
|
||||
<td align="center" width="25%" valign="top">
|
||||
<img src="assets/scene-research.png" height="150" alt="Research"/><br>
|
||||
<h3>🔍 DeepResearch for Trading</h3>
|
||||
<img src="https://img.shields.io/badge/68_Skills-FF6B6B?style=for-the-badge&logo=bookstack&logoColor=white" alt="Skills" /><br><br>
|
||||
<img src="https://img.shields.io/badge/74_Skills-FF6B6B?style=for-the-badge&logo=bookstack&logoColor=white" alt="Skills" /><br><br>
|
||||
<div align="left" style="font-size: 4px;">
|
||||
• Multi-domain analysis coverage across markets<br>
|
||||
• Auto strategy and signal generation<br>
|
||||
• Macro economic research and insights<br>
|
||||
• Natural-language task routing via chat
|
||||
• 74 specialist skills with persistent cross-session memory<br>
|
||||
• Self-evolving: agent creates & refines workflows from experience<br>
|
||||
• 5-layer context compression — no info lost in long sessions<br>
|
||||
• Natural-language task routing across all finance domains
|
||||
</div>
|
||||
</td>
|
||||
<td align="center" width="25%" valign="top">
|
||||
@@ -94,17 +119,17 @@ Vibe-Trading is an AI-powered multi-agent finance workspace that turns natural l
|
||||
<div align="left">
|
||||
• 29 out-of-the-box trading team presets<br>
|
||||
• DAG-based multi-agent orchestration<br>
|
||||
• Real-time decision streaming dashboard<br>
|
||||
• Custom team building through YAML
|
||||
• Real-time streaming dashboard with live agent status<br>
|
||||
• FTS5 session search across all past conversations
|
||||
</div>
|
||||
</td>
|
||||
<td align="center" width="25%" valign="top">
|
||||
<img src="assets/scene-backtest.png" height="150" alt="Backtest"/><br>
|
||||
<h3>📊 Cross-Market Backtest</h3>
|
||||
<img src="https://img.shields.io/badge/5_Data_Sources-FFD93D?style=for-the-badge&logo=bitcoin&logoColor=black" alt="Backtest" /><br><br>
|
||||
<img src="https://img.shields.io/badge/6_Data_Sources-FFD93D?style=for-the-badge&logo=bitcoin&logoColor=black" alt="Backtest" /><br><br>
|
||||
<div align="left">
|
||||
• A-shares, HK/US equities, crypto, futures & forex<br>
|
||||
• 7 market engines: A-share, US/HK equity, crypto, China futures, global futures, forex<br>
|
||||
• 7 market engines + composite cross-market engine with shared capital pool<br>
|
||||
• Statistical validation: Monte Carlo, Bootstrap CI, Walk-Forward<br>
|
||||
• 15+ performance metrics & 4 optimizers
|
||||
</div>
|
||||
@@ -123,21 +148,22 @@ Vibe-Trading is an AI-powered multi-agent finance workspace that turns natural l
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 68 Skills across 7 Categories
|
||||
## 74 Skills across 8 Categories
|
||||
|
||||
- 📊 68 specialized finance skills organized into 7 categories
|
||||
- 📊 74 specialized finance skills organized into 8 categories
|
||||
- 🌐 Complete coverage from traditional markets to crypto & DeFi
|
||||
- 🔬 Comprehensive capabilities spanning data sourcing to quantitative research
|
||||
|
||||
| Category | Skills | Examples |
|
||||
|----------|--------|----------|
|
||||
| Data Source | 6 | `data-routing`, `tushare`, `yfinance`, `okx-market`, `akshare`, `ccxt` |
|
||||
| Strategy | 16 | `strategy-generate`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
|
||||
| Analysis | 15 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis` |
|
||||
| Strategy | 17 | `strategy-generate`, `cross-market-strategy`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
|
||||
| Analysis | 17 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis`, `dividend-analysis` |
|
||||
| Asset Class | 9 | `options-strategy`, `options-advanced`, `convertible-bond`, `etf-analysis`, `asset-allocation`, `sector-rotation` |
|
||||
| Crypto | 7 | `perp-funding-basis`, `liquidation-heatmap`, `stablecoin-flow`, `defi-yield`, `onchain-analysis` |
|
||||
| Flow | 7 | `hk-connect-flow`, `us-etf-flow`, `edgar-sec-filings`, `financial-statement`, `adr-hshare` |
|
||||
| Tool | 8 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader` |
|
||||
| Tool | 10 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader`, `vnpy-export` |
|
||||
| Risk Analysis | 1 | `ashare-pre-st-filter` |
|
||||
|
||||
## 29 Agent Swarm Team Presets
|
||||
|
||||
@@ -222,8 +248,9 @@ vibe-trading-mcp # start MCP server (stdio)
|
||||
- An **LLM API key** from any supported provider — or run locally with **Ollama** (no key needed)
|
||||
- **Python 3.11+** for Path B
|
||||
- **Docker** for Path A
|
||||
- OpenAI Codex can also be used with ChatGPT OAuth: set `LANGCHAIN_PROVIDER=openai-codex`, then run `vibe-trading provider login openai-codex`. This does not use `OPENAI_API_KEY`.
|
||||
|
||||
> **Supported LLM providers:** OpenRouter, OpenAI, DeepSeek, Gemini, Groq, DashScope/Qwen, Zhipu, Moonshot/Kimi, MiniMax, Xiaomi MIMO, Ollama (local). See `.env.example` for config.
|
||||
> **Supported LLM providers:** OpenRouter, OpenAI, DeepSeek, Gemini, Groq, DashScope/Qwen, Zhipu, Moonshot/Kimi, MiniMax, Xiaomi MIMO, Z.ai, Ollama (local). See `.env.example` for config.
|
||||
|
||||
> **Tip:** All markets work without any API keys thanks to automatic fallback. yfinance (HK/US), OKX (crypto), and AKShare (A-shares, US, HK, futures, forex) are all free. Tushare token is optional — AKShare covers A-shares as a free fallback.
|
||||
|
||||
@@ -239,6 +266,8 @@ docker compose up --build
|
||||
|
||||
Open `http://localhost:8899`. Backend + frontend in one container.
|
||||
|
||||
Docker publishes the backend on `127.0.0.1:8899` by default and runs the app as a non-root container user. If you intentionally expose the API beyond your own machine, set a strong `API_AUTH_KEY` and send `Authorization: Bearer <key>` from clients.
|
||||
|
||||
### Path B: Local install
|
||||
|
||||
```bash
|
||||
@@ -303,11 +332,27 @@ Copy `agent/.env.example` to `agent/.env` and uncomment the provider block you w
|
||||
| `LANGCHAIN_MODEL_NAME` | Yes | Model name (e.g. `deepseek/deepseek-v3.2`) |
|
||||
| `TUSHARE_TOKEN` | No | Tushare Pro token for A-share data (falls back to AKShare) |
|
||||
| `TIMEOUT_SECONDS` | No | LLM call timeout, default 120s |
|
||||
| `API_AUTH_KEY` | Recommended for network deployments | Bearer token required when the API is reachable from non-local clients |
|
||||
| `VIBE_TRADING_ENABLE_SHELL_TOOLS` | No | Explicit opt-in for shell-capable tools in remote API/MCP-SSE style deployments |
|
||||
| `VIBE_TRADING_ALLOWED_FILE_ROOTS` | No | Extra comma-separated roots for document and broker-journal imports |
|
||||
| `VIBE_TRADING_ALLOWED_RUN_ROOTS` | No | Extra comma-separated roots for generated-code run directories |
|
||||
|
||||
<sub>* Ollama does not require an API key.</sub>
|
||||
<sub>* Ollama does not require an API key. OpenAI Codex uses ChatGPT OAuth and stores tokens via `oauth-cli-kit`, not in `agent/.env`.</sub>
|
||||
|
||||
**Free data (no key needed):** A-shares via AKShare, HK/US equities via yfinance, crypto via OKX, 100+ crypto exchanges via CCXT. The system automatically selects the best available source for each market.
|
||||
|
||||
### 🎯 Recommended Models
|
||||
|
||||
Vibe-Trading is a tool-heavy agent — skills, backtests, memory, and swarms all flow through tool calls. Model choice directly decides whether the agent *uses* its tools or fabricates answers from training data.
|
||||
|
||||
| Tier | Examples | When to use |
|
||||
|------|----------|-------------|
|
||||
| **Best** | `anthropic/claude-opus-4.7`, `anthropic/claude-sonnet-4.6`, `openai/gpt-5.4`, `google/gemini-3.1-pro-preview` | Complex swarms (3+ agents), long research sessions, paper-grade analysis |
|
||||
| **Sweet spot** (default) | `deepseek/deepseek-v3.2`, `x-ai/grok-4.20`, `z-ai/glm-5.1`, `moonshotai/kimi-k2.5`, `qwen/qwen3-max-thinking` | Daily driver — reliable tool-calling at ~1/10 the cost |
|
||||
| **Avoid for agent use** | `*-nano`, `*-flash-lite`, `*-coder-next`, small / distilled variants | Tool-calling is unreliable — the agent will appear to "answer from memory" instead of loading skills or running backtests |
|
||||
|
||||
The default `agent/.env.example` ships with `deepseek/deepseek-v3.2` — the cheapest option in the sweet-spot tier.
|
||||
|
||||
---
|
||||
|
||||
## 🖥 CLI Reference
|
||||
@@ -324,7 +369,7 @@ vibe-trading serve # API server
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/help` | Show all commands |
|
||||
| `/skills` | List all 68 finance skills |
|
||||
| `/skills` | List all 74 finance skills |
|
||||
| `/swarm` | List 29 swarm team presets |
|
||||
| `/swarm run <preset> [vars_json]` | Run a swarm team with live streaming |
|
||||
| `/swarm list` | Swarm run history |
|
||||
@@ -333,7 +378,7 @@ vibe-trading serve # API server
|
||||
| `/list` | Recent runs |
|
||||
| `/show <run_id>` | Run details + metrics |
|
||||
| `/code <run_id>` | Generated strategy code |
|
||||
| `/pine <run_id>` | Pine Script for TradingView |
|
||||
| `/pine <run_id>` | Export indicators (TradingView + TDX + MT5) |
|
||||
| `/trace <run_id>` | Full execution replay |
|
||||
| `/continue <run_id> <prompt>` | Continue a run with new instructions |
|
||||
| `/sessions` | List chat sessions |
|
||||
@@ -361,7 +406,7 @@ vibe-trading --swarm-run investment_committee '{"topic":"BTC outlook"}'
|
||||
vibe-trading --list
|
||||
vibe-trading --show <run_id>
|
||||
vibe-trading --code <run_id>
|
||||
vibe-trading --pine <run_id> # Pine Script for TradingView
|
||||
vibe-trading --pine <run_id> # Export indicators (TradingView + TDX + MT5)
|
||||
vibe-trading --trace <run_id>
|
||||
vibe-trading --continue <run_id> "refine the strategy"
|
||||
vibe-trading --upload report.pdf
|
||||
@@ -371,6 +416,76 @@ vibe-trading --upload report.pdf
|
||||
|
||||
---
|
||||
|
||||
## 💡 Examples
|
||||
|
||||
### Strategy & Backtesting
|
||||
|
||||
```bash
|
||||
# Moving average crossover on US equities
|
||||
vibe-trading run -p "Backtest a 20/50-day moving average crossover on AAPL for the past year, show Sharpe ratio and max drawdown"
|
||||
|
||||
# RSI mean-reversion on crypto
|
||||
vibe-trading run -p "Test RSI(14) mean-reversion on BTC-USDT: buy below 30, sell above 70, last 6 months"
|
||||
|
||||
# Multi-factor strategy on A-shares
|
||||
vibe-trading run -p "Backtest a momentum + value + quality multi-factor strategy on CSI 300 constituents over 2 years"
|
||||
|
||||
# After backtesting, export to TradingView / TDX / MetaTrader 5
|
||||
vibe-trading --pine <run_id>
|
||||
```
|
||||
|
||||
### Market Research
|
||||
|
||||
```bash
|
||||
# Equity deep-dive
|
||||
vibe-trading run -p "Research NVDA: earnings trend, analyst consensus, option flow, and key risks for next quarter"
|
||||
|
||||
# Macro analysis
|
||||
vibe-trading run -p "Analyze the current Fed rate path, USD strength, and impact on EM equities and gold"
|
||||
|
||||
# Crypto on-chain
|
||||
vibe-trading run -p "Deep dive BTC on-chain: whale flows, exchange balances, miner activity, and funding rates"
|
||||
```
|
||||
|
||||
### Swarm Workflows
|
||||
|
||||
```bash
|
||||
# Bull/bear debate on a stock
|
||||
vibe-trading --swarm-run investment_committee '{"topic": "Is TSLA a buy at current levels?"}'
|
||||
|
||||
# Quant strategy from screening to backtest
|
||||
vibe-trading --swarm-run quant_strategy_desk '{"universe": "S&P 500", "horizon": "3 months"}'
|
||||
|
||||
# Crypto desk: funding + liquidation + flow → risk manager
|
||||
vibe-trading --swarm-run crypto_trading_desk '{"asset": "ETH-USDT", "timeframe": "1w"}'
|
||||
|
||||
# Global macro portfolio allocation
|
||||
vibe-trading --swarm-run macro_rates_fx_desk '{"focus": "Fed pivot impact on EM bonds"}'
|
||||
```
|
||||
|
||||
### Cross-Session Memory
|
||||
|
||||
```bash
|
||||
# Save your preferences once
|
||||
vibe-trading run -p "Remember: I prefer RSI-based strategies, max 10% drawdown, hold period 5–20 days"
|
||||
|
||||
# The agent recalls them in future sessions automatically
|
||||
vibe-trading run -p "Build a crypto strategy that fits my risk profile"
|
||||
```
|
||||
|
||||
### Upload & Analyze Documents
|
||||
|
||||
```bash
|
||||
# Analyze a broker export or earnings report
|
||||
vibe-trading --upload trades_export.csv
|
||||
vibe-trading run -p "Profile my trading behavior and identify any biases"
|
||||
|
||||
vibe-trading --upload NVDA_Q1_earnings.pdf
|
||||
vibe-trading run -p "Summarize the key risks and beats/misses from this earnings report"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 API Server
|
||||
|
||||
```bash
|
||||
@@ -381,7 +496,7 @@ vibe-trading serve --port 8899
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/runs` | List runs |
|
||||
| `GET` | `/runs/{run_id}` | Run details |
|
||||
| `GET` | `/runs/{run_id}/pine` | Pine Script export |
|
||||
| `GET` | `/runs/{run_id}/pine` | Multi-platform indicator export |
|
||||
| `POST` | `/sessions` | Create session |
|
||||
| `POST` | `/sessions/{id}/messages` | Send message |
|
||||
| `GET` | `/sessions/{id}/events` | SSE event stream |
|
||||
@@ -389,14 +504,30 @@ vibe-trading serve --port 8899
|
||||
| `GET` | `/swarm/presets` | List swarm presets |
|
||||
| `POST` | `/swarm/runs` | Start swarm run |
|
||||
| `GET` | `/swarm/runs/{id}/events` | Swarm SSE stream |
|
||||
| `GET` | `/settings/llm` | Read Web UI LLM settings |
|
||||
| `PUT` | `/settings/llm` | Update local LLM settings |
|
||||
| `GET` | `/settings/data-sources` | Read local data source settings |
|
||||
| `PUT` | `/settings/data-sources` | Update local data source settings |
|
||||
|
||||
Interactive docs: `http://localhost:8899/docs`
|
||||
|
||||
### Security defaults
|
||||
|
||||
For localhost development, `vibe-trading serve` keeps the browser workflow simple. For any non-local client, sensitive API endpoints require `API_AUTH_KEY`; use `Authorization: Bearer <key>` for JSON/upload requests. Browser EventSource streams are handled by the Web UI after you enter the same key once in Settings.
|
||||
|
||||
Shell-capable tools are available to local CLI and trusted localhost workflows, but are not exposed to remote API sessions unless you explicitly set `VIBE_TRADING_ENABLE_SHELL_TOOLS=1`. Document and journal readers are limited to upload/import roots by default; place files under `agent/uploads`, `agent/runs`, `./uploads`, `./data`, `~/.vibe-trading/uploads`, or `~/.vibe-trading/imports`, or add a dedicated directory through `VIBE_TRADING_ALLOWED_FILE_ROOTS`.
|
||||
|
||||
### Web UI Settings
|
||||
|
||||
The Web UI Settings page lets local users update the LLM provider/model, base URL, generation parameters, reasoning effort, and optional market data credentials such as the Tushare token. Settings are persisted to `agent/.env`; provider defaults are loaded from `agent/src/providers/llm_providers.json`.
|
||||
|
||||
Settings reads are side-effect free: `GET /settings/llm` and `GET /settings/data-sources` never create `agent/.env`, and they only return project-relative paths. Settings reads and writes can expose credential state or update credentials/runtime environment, so they require `API_AUTH_KEY` when configured. If `API_AUTH_KEY` is unset for dev mode, settings access is accepted only from loopback clients.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 MCP Plugin
|
||||
|
||||
Vibe-Trading exposes 17 MCP tools for any MCP-compatible client. Runs as a stdio subprocess — no server setup needed. **16 of 17 tools work with zero API keys** (HK/US/crypto). Only `run_swarm` needs an LLM key.
|
||||
Vibe-Trading exposes 22 MCP tools for any MCP-compatible client. Runs as a stdio subprocess — no server setup needed. **21 of 22 tools work with zero API keys** (HK/US/crypto). Only `run_swarm` needs an LLM key.
|
||||
|
||||
<details>
|
||||
<summary><b>Claude Desktop</b></summary>
|
||||
@@ -438,7 +569,7 @@ vibe-trading-mcp --transport sse # SSE for web clients
|
||||
|
||||
</details>
|
||||
|
||||
**MCP tools exposed (17):** `list_skills`, `load_skill`, `backtest`, `factor_analysis`, `analyze_options`, `pattern_recognition`, `get_market_data`, `web_search`, `read_url`, `read_document`, `read_file`, `write_file`, `list_swarm_presets`, `run_swarm`, `get_swarm_status`, `get_run_result`, `list_runs`.
|
||||
**MCP tools exposed (22):** `list_skills`, `load_skill`, `backtest`, `factor_analysis`, `analyze_options`, `pattern_recognition`, `get_market_data`, `web_search`, `read_url`, `read_document`, `read_file`, `write_file`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `list_swarm_presets`, `run_swarm`, `get_swarm_status`, `get_run_result`, `list_runs`.
|
||||
|
||||
<details>
|
||||
<summary><b>Install from ClawHub (one command)</b></summary>
|
||||
@@ -458,7 +589,7 @@ Browse on ClawHub: [clawhub.ai/skills/vibe-trading](https://clawhub.ai/skills/vi
|
||||
<details>
|
||||
<summary><b>OpenSpace — self-evolving skills</b></summary>
|
||||
|
||||
All 68 finance skills are published on [open-space.cloud](https://open-space.cloud) and evolve autonomously through OpenSpace's self-evolution engine.
|
||||
All 74 finance skills are published on [open-space.cloud](https://open-space.cloud) and evolve autonomously through OpenSpace's self-evolution engine.
|
||||
|
||||
To use with OpenSpace, add both MCP servers to your agent config:
|
||||
|
||||
@@ -480,7 +611,7 @@ To use with OpenSpace, add both MCP servers to your agent config:
|
||||
}
|
||||
```
|
||||
|
||||
OpenSpace will auto-discover all 68 skills, enabling auto-fix, auto-improve, and community sharing. Search for Vibe-Trading skills via `search_skills("finance backtest")` in any OpenSpace-connected agent.
|
||||
OpenSpace will auto-discover all 74 skills, enabling auto-fix, auto-improve, and community sharing. Search for Vibe-Trading skills via `search_skills("finance backtest")` in any OpenSpace-connected agent.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -496,41 +627,42 @@ Vibe-Trading/
|
||||
├── agent/ # Backend (Python)
|
||||
│ ├── cli.py # CLI entrypoint — interactive TUI + subcommands
|
||||
│ ├── api_server.py # FastAPI server — runs, sessions, upload, swarm, SSE
|
||||
│ ├── mcp_server.py # MCP server — 17 tools for OpenClaw / Claude Desktop
|
||||
│ ├── mcp_server.py # MCP server — 22 tools for OpenClaw / Claude Desktop
|
||||
│ │
|
||||
│ ├── src/
|
||||
│ │ ├── agent/ # ReAct agent core
|
||||
│ │ │ ├── loop.py # main reasoning loop
|
||||
│ │ │ ├── skills.py # skill loader (68 SKILL.md files, 7 categories)
|
||||
│ │ │ ├── tools.py # tool orchestration
|
||||
│ │ │ ├── context.py # system prompt builder
|
||||
│ │ │ ├── memory.py # run memory / artifact store
|
||||
│ │ │ ├── loop.py # 5-layer compression + read/write tool batching
|
||||
│ │ │ ├── context.py # system prompt + auto-recall from persistent memory
|
||||
│ │ │ ├── skills.py # skill loader (74 bundled + user-created via CRUD)
|
||||
│ │ │ ├── tools.py # tool base class + registry
|
||||
│ │ │ ├── memory.py # lightweight workspace state per run
|
||||
│ │ │ ├── frontmatter.py # shared YAML frontmatter parser
|
||||
│ │ │ └── trace.py # execution trace writer
|
||||
│ │ │
|
||||
│ │ ├── tools/ # 21 agent tools
|
||||
│ │ │ ├── backtest_tool.py # run backtests
|
||||
│ │ │ ├── factor_analysis_tool.py
|
||||
│ │ │ ├── options_pricing_tool.py
|
||||
│ │ │ ├── pattern_tool.py # chart pattern detection
|
||||
│ │ │ ├── doc_reader_tool.py # PDF reader (OCR fallback)
|
||||
│ │ │ ├── web_reader_tool.py # web page reader (Jina)
|
||||
│ │ │ ├── web_search_tool.py # DuckDuckGo web search
|
||||
│ │ │ ├── swarm_tool.py # launch swarm teams
|
||||
│ │ │ └── ... # file I/O, bash, tasks, etc.
|
||||
│ │ ├── memory/ # Cross-session persistent memory
|
||||
│ │ │ └── persistent.py # file-based memory (~/.vibe-trading/memory/)
|
||||
│ │ │
|
||||
│ │ ├── skills/ # 68 finance skills in 7 categories (SKILL.md each)
|
||||
│ │ ├── tools/ # 27 auto-discovered agent tools
|
||||
│ │ │ ├── backtest_tool.py # run backtests
|
||||
│ │ │ ├── remember_tool.py # cross-session memory (save/recall/forget)
|
||||
│ │ │ ├── skill_writer_tool.py # skill CRUD (save/patch/delete/file)
|
||||
│ │ │ ├── session_search_tool.py # FTS5 cross-session search
|
||||
│ │ │ ├── swarm_tool.py # launch swarm teams
|
||||
│ │ │ ├── web_search_tool.py # DuckDuckGo web search
|
||||
│ │ │ └── ... # bash, file I/O, factor analysis, options, etc.
|
||||
│ │ │
|
||||
│ │ ├── skills/ # 74 finance skills in 8 categories (SKILL.md each)
|
||||
│ │ ├── swarm/ # Swarm DAG execution engine
|
||||
│ │ ├── session/ # Multi-turn chat session management
|
||||
│ │ │ └── presets/ # 29 swarm preset YAML definitions
|
||||
│ │ ├── session/ # Multi-turn chat + FTS5 session search
|
||||
│ │ └── providers/ # LLM provider abstraction
|
||||
│ │
|
||||
│ ├── backtest/ # Backtest engines
|
||||
│ │ ├── engines/ # 7 engines: china_a, global_equity, crypto, china_futures, global_futures, forex + options_portfolio
|
||||
│ │ ├── loaders/ # 5 sources: tushare, okx, yfinance, akshare, ccxt
|
||||
│ │ │ ├── base.py # DataLoader Protocol
|
||||
│ │ │ └── registry.py # Registry + auto-fallback chains
|
||||
│ │ └── optimizers/ # MVO, equal vol, max div, risk parity
|
||||
│ │
|
||||
│ └── config/swarm/ # 29 swarm preset YAML definitions
|
||||
│ └── backtest/ # Backtest engines
|
||||
│ ├── engines/ # 7 engines + composite cross-market engine + options_portfolio
|
||||
│ ├── loaders/ # 6 sources: tushare, okx, yfinance, akshare, ccxt, futu
|
||||
│ │ ├── base.py # DataLoader Protocol
|
||||
│ │ └── registry.py # Registry + auto-fallback chains
|
||||
│ └── optimizers/ # MVO, equal vol, max div, risk parity
|
||||
│
|
||||
├── frontend/ # Web UI (React 19 + Vite + TypeScript)
|
||||
│ └── src/
|
||||
@@ -581,17 +713,14 @@ Vibe-Trading is part of the **[HKUDS](https://github.com/HKUDS)** agent ecosyste
|
||||
|
||||
| Phase | Feature | Status |
|
||||
|-------|---------|--------|
|
||||
| **Analysis & Viz** | Options volatility surface & Greeks 3D visualization | Planned |
|
||||
| | Cross-asset correlation heatmap with rolling window & clustering | Planned |
|
||||
| | Benchmark comparison in CLI backtest output | Planned |
|
||||
| | Calmar Ratio & Omega Ratio in backtest metrics | Planned |
|
||||
| **Skills & Presets** | Dividend Analysis skill | Planned |
|
||||
| | ESG / Sustainable Investing swarm preset | Planned |
|
||||
| | Emerging Markets Research Desk swarm preset | Planned |
|
||||
| **Portfolio & Optimization** | Advanced portfolio optimizer: leverage, sector caps, turnover constraints | Planned |
|
||||
| **Future** | Beginner tutorial: "5-minute natural language backtest" | Planned |
|
||||
| | Live data streaming via WebSocket | Exploring |
|
||||
| | Strategy marketplace (share & discover) | Exploring |
|
||||
| **Research Autopilot** | Overnight research loop: hypothesis → data pull → backtest → evidence report | In Progress |
|
||||
| **Data Bridge** | Bring-your-own data: local CSV/Parquet/SQL connectors with schema mapping | Planned |
|
||||
| **Options Lab** | Vol surface, Greeks dashboard, payoff/scenario explorer | Planned |
|
||||
| **Portfolio Studio** | Risk x-ray, constraints, turnover-aware optimizer, rebalance notes | Planned |
|
||||
| **Alpha Zoo** | Alpha101 / Alpha158 / Alpha191 factor libraries with screening + IC tests | Planned |
|
||||
| **Research Delivery** | Scheduled briefs to Slack / Telegram / email-style channels | Planned |
|
||||
| **Trust Layer** | Reproducible run cards: tool trace, data sources, assumptions, citations | Planned |
|
||||
| **Community** | Shareable skills, presets, and strategy cards | Exploring |
|
||||
|
||||
---
|
||||
|
||||
@@ -625,6 +754,12 @@ MIT License — see [LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#HKUDS/Vibe-Trading&Date)
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
Thanks for visiting <b>Vibe-Trading</b> ✨
|
||||
</p>
|
||||
|
||||
+697
@@ -0,0 +1,697 @@
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | <a href="README_zh.md">中文</a> | <a href="README_ja.md">日本語</a> | <a href="README_ko.md">한국어</a> | <b>العربية</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/icon.png" width="120" alt="شعار Vibe-Trading"/>
|
||||
</p>
|
||||
|
||||
<h1 align="center">Vibe-Trading: وكيل التداول الشخصي الخاص بك</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>أمر واحد لتمكين وكيلك بقدرات تداول شاملة</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Python-3.11%2B-3776AB?style=flat&logo=python&logoColor=white" alt="Python">
|
||||
<img src="https://img.shields.io/badge/Backend-FastAPI-009688?style=flat" alt="FastAPI">
|
||||
<img src="https://img.shields.io/badge/Frontend-React%2019-61DAFB?style=flat&logo=react&logoColor=white" alt="React">
|
||||
<a href="https://pypi.org/project/vibe-trading-ai/"><img src="https://img.shields.io/pypi/v/vibe-trading-ai?style=flat&logo=pypi&logoColor=white" alt="PyPI"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow?style=flat" alt="الرخصة"></a>
|
||||
<br>
|
||||
<img src="https://img.shields.io/badge/Skills-74-orange" alt="المهارات">
|
||||
<img src="https://img.shields.io/badge/Swarm_Presets-29-7C3AED" alt="السرب">
|
||||
<img src="https://img.shields.io/badge/Tools-27-0F766E" alt="الأدوات">
|
||||
<img src="https://img.shields.io/badge/Data_Sources-6-2563EB" alt="مصادر البيانات">
|
||||
<br>
|
||||
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/Feishu-Group-E9DBFC?style=flat-square&logo=feishu&logoColor=white" alt="Feishu"></a>
|
||||
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat-square&logo=wechat&logoColor=white" alt="WeChat"></a>
|
||||
<a href="https://discord.gg/2vDYc2w5"><img src="https://img.shields.io/badge/Discord-Join-7289DA?style=flat-square&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#-أحدث-الأخبار">الأخبار</a> ·
|
||||
<a href="#-ما-هو-vibe-trading">ما هو</a> ·
|
||||
<a href="#-الميزات-الرئيسية">الميزات</a> ·
|
||||
<a href="#-البدء-السريع">البدء</a> ·
|
||||
<a href="#-مرجع-سطر-الأوامر">CLI</a> ·
|
||||
<a href="#-خادم-api">API</a> ·
|
||||
<a href="#-إضافة-mcp">MCP</a> ·
|
||||
<a href="#-هيكل-المشروع">الهيكل</a> ·
|
||||
<a href="#-خارطة-الطريق">خارطة الطريق</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-05** 🛡️ **متابعة لتعزيز الحدود الأمنية**: يستكمل تعزيز الحدود الأمنية حول origins الصريحة في CORS، ومؤشرات بيانات الاعتماد في Settings، وقراءة عناوين URL على الويب، وتوليد كود Shadow Account، مع إضافة اختبارات انحدار لكل مسار. تبقى تدفقات CLI وWeb UI على localhost كما هي؛ ويجب أن تستمر عمليات النشر البعيدة في استخدام `API_AUTH_KEY` وorigins موثوقة صريحة.
|
||||
- **2026-05-04** 🖥️ **تحسين تجربة CLI التفاعلية + تنظيف CI**: أصبح الوضع التفاعلي يعرض شريط حالة سفلياً مباشراً يوضح provider/model ومدة الجلسة ووقت آخر تشغيل وإحصاءات استدعاءات الأدوات التراكمية، مع دعم تصفح السجل وتحرير المؤشر بأسهم لوحة المفاتيح عبر `prompt_toolkit` ([#69](https://github.com/HKUDS/Vibe-Trading/pull/69)). وعند غياب `prompt_toolkit` أو TTY، يعود CLI إلى Rich prompts. كما تمت مواءمة توقعات مسارات CI مع صندوق استيراد الملفات المعزز وحل `/tmp` عبر المنصات، فعاد main إلى الحالة الخضراء ([`bb67dc7`](https://github.com/HKUDS/Vibe-Trading/commit/bb67dc7cfcc11553c57d8962bee56381dca43758)).
|
||||
- **2026-05-03** 🛡️ **تصحيح لتعزيز الأمان**: يشدد المصادقة الافتراضية للـ API في النشر غير المحلي، ويحمي قراءات run/session/swarm الحساسة، ويقيّد حدود الرفع وقراءة الملفات المحلية، ويتحكم في أدوات shell بحسب نقطة الدخول، ويتحقق من الاستراتيجيات المولدة قبل الاستيراد، ويجعل صورة Docker تعمل افتراضياً كمستخدم غير root وبمنفذ منشور على localhost فقط. تبقى تجربة CLI وWeb UI المحلي منخفضة الاحتكاك؛ ويجب على نشر API/Web البعيد ضبط `API_AUTH_KEY`.
|
||||
|
||||
<details>
|
||||
<summary>أخبار سابقة</summary>
|
||||
|
||||
- **2026-05-02** 🧭 **تحليل التوزيعات + خارطة طريق أوضح**: تمت إضافة مهارة `dividend-analysis` لأسهم الدخل، واستدامة التوزيعات، ونمو التوزيعات، وعائد المساهمين، وآليات تاريخ الاستحقاق السابق، وفحص فخاخ العائد المرتفع، مع تثبيتها باختبارات انحدار للمهارات المضمنة. تركز خارطة الطريق العامة الآن على Research Autopilot وData Bridge وOptions Lab وPortfolio Studio وAlpha Zoo وResearch Delivery وTrust Layer ومشاركة Community.
|
||||
- **2026-05-01** 🔥 **خريطة ارتباط حرارية + OpenAI Codex OAuth + مرشح pre-ST لأسهم A**: لوحة/API ارتباط جديدة تحسب ارتباطات العوائد المتحركة وتعرضها كخريطة حرارية ECharts لتحليل المحافظ والرموز ([#64](https://github.com/HKUDS/Vibe-Trading/pull/64)). مزود OpenAI Codex يدعم الآن ChatGPT OAuth عبر `vibe-trading provider login openai-codex`، مع بيانات Settings واختبارات انحدار للمحوّل ([#65](https://github.com/HKUDS/Vibe-Trading/pull/65)). تمت إضافة وتعزيز مهارة `ashare-pre-st-filter` لفحص مخاطر ST/*ST في أسهم A، مع فلترة صلة عقوبات Sina حتى لا تضخم إشارات قوائم حسابات الأوراق المالية عدّادات E2 ([#63](https://github.com/HKUDS/Vibe-Trading/pull/63)).
|
||||
- **2026-04-30** ⚙️ **إعدادات Web UI + تعزيز validation CLI**: تمت إضافة صفحة Settings لإعداد LLM provider/model وBase URL وreasoning effort وبيانات اعتماد مصادر البيانات محلياً. واجهات settings API محمية الآن عبر local/auth، كما أصبحت بيانات مزودي النماذج إعدادات مدفوعة بالبيانات ([#57](https://github.com/HKUDS/Vibe-Trading/pull/57)). كذلك تم تعزيز `python -m backtest.validation <run_dir>` لرفض غياب الوسيط، والمسار الفارغ، والمسار غير الصالح، والمسار غير الموجود، والمسار الذي ليس دليلاً برسائل واضحة قبل بدء التحقق ([#60](https://github.com/HKUDS/Vibe-Trading/pull/60)).
|
||||
- **2026-04-28** 🚀 **إصدار v0.1.6** (`pip install -U vibe-trading-ai`): إصلاح إرجاع `vibe-trading --swarm-presets` فارغًا بعد `pip install` / `uv tool install` ([#55](https://github.com/HKUDS/Vibe-Trading/issues/55)) — ملفات YAML للإعدادات المسبقة الآن مضمّنة داخل حزمة `src.swarm` ومثبّتة بـ 6 اختبارات انحدار. إضافة إلى ذلك، محمّل AKShare يوجّه الآن صناديق ETF (`510300.SH`) والعملات الأجنبية (`USDCNH`) إلى نقاط النهاية الصحيحة مع تعزيز سلسلة البديل. تجميع كل التحديثات منذ v0.1.5: لوحة مقارنة المرجع، تدفق `/upload` + حدود الحجم، محمّل Futu (HK + أسهم A)، مهارة تصدير vnpy، تصليب أمني، التحميل الكسول للواجهة (688KB → 262KB).
|
||||
- **2026-04-27** 📊 **لوحة مقارنة المرجع + أمان الرفع**: مخرجات الاختبار الخلفي تشمل الآن لوحة مقارنة مرجعية (الرمز / عائد المرجع / العائد الفائض / نسبة المعلومات) مع الحلّ عبر yfinance لـ SPY و CSI 300 وغيرها ([#48](https://github.com/HKUDS/Vibe-Trading/issues/48)). إضافة إلى ذلك، نقطة `/upload` تتدفق جسم الطلب في أجزاء 1 ميغابايت وتتوقف فور تجاوز `MAX_UPLOAD_SIZE` مع تنظيف الملف الجزئي، بما يجعل حد 50 ميغابايت فعالاً تحت الطلبات الخبيثة/الضخمة ([#53](https://github.com/HKUDS/Vibe-Trading/pull/53)) — مثبّت بـ 4 اختبارات انحدار.
|
||||
- **2026-04-22** 🛡️ **تصليب + تكاملات جديدة**: فرض احتواء المسار في `safe_path` + عزل أدوات سجل التداول/حساب الظل، إضافة `MANIFEST.in` لتضمين `.env.example` / الاختبارات / ملفات Docker في sdist، التحميل الكسول على مستوى المسار يقلّص حزمة الواجهة الأولية من 688KB إلى 262KB. إضافة محمّل بيانات Futu لأسهم هونغ كونغ وA ([#47](https://github.com/HKUDS/Vibe-Trading/pull/47)) ومهارة تصدير vnpy CtaTemplate ([#46](https://github.com/HKUDS/Vibe-Trading/pull/46)).
|
||||
- **2026-04-21** 🛡️ **مساحة العمل + الوثائق**: تطبيع `run_dir` النسبي إلى دليل التشغيل النشط ([#43](https://github.com/HKUDS/Vibe-Trading/pull/43)). إضافة أمثلة استخدام إلى README ([#45](https://github.com/HKUDS/Vibe-Trading/pull/45)).
|
||||
- **2026-04-20** 🔌 **نماذج التفكير + إصلاحات Swarm**: الحفاظ على `reasoning_content` عبر جميع مسارات تسلسل `ChatOpenAI` — يعمل Kimi / DeepSeek / Qwen thinking من البداية إلى النهاية ([#39](https://github.com/HKUDS/Vibe-Trading/issues/39)). تدفق Swarm + إيقاف نظيف بـ Ctrl+C ([#42](https://github.com/HKUDS/Vibe-Trading/issues/42)).
|
||||
- **2026-04-19** 📦 **v0.1.5**: تم النشر على PyPI وClawHub. رفع حد `python-multipart` لسد ثغرة CVE، ربط 5 أدوات MCP جديدة (`analyze_trade_journal` + 4 أدوات حساب الظل)، إصلاح عدم تطابق اسم السجل `pattern_recognition` → `pattern`، مزامنة تبعيات Docker، مزامنة بيان SKILL (22 أداة MCP / 71 مهارة).
|
||||
- **2026-04-18** 👥 **حساب الظل Shadow Account**: استخرج قواعد إستراتيجيتك من سجل التداول الخاص بك → اختبر الظل عبر الأسواق → تقرير HTML/PDF من 8 أقسام يوضح بدقة أين فقدت المال (خرق القواعد، الخروج المبكر، الإشارات المفقودة، الصفقات العكسية). 4 أدوات جديدة، مهارة واحدة جديدة، إجمالي 32 أداة. أمثلة Trade Journal / Shadow Account متاحة الآن في شاشة الترحيب على واجهة الويب.
|
||||
- **2026-04-17** 📊 **محلل سجل التداول + قارئ ملفات عالمي**: حمّل سجلات التداول من الوسطاء (同花顺/东财/富途/CSV عام) → ملف تعريف تداول تلقائي (أيام الاحتفاظ، معدل الفوز، نسبة الربح/الخسارة، أقصى تراجع) + تشخيص 4 تحيزات سلوكية (تأثير التصرف، الإفراط في التداول، مطاردة الزخم، التثبيت السعري). `read_document` يوزّع الآن PDF وWord وExcel وPowerPoint والصور (OCR) و40+ صيغة نصية خلف استدعاء موحد.
|
||||
- **2026-04-16** 🧠 **هيكل الوكيل**: ذاكرة دائمة عبر الجلسات، بحث جلسات FTS5، مهارات ذاتية التطور (CRUD كامل)، ضغط سياق 5 طبقات، معالجة أدوات القراءة/الكتابة دفعة واحدة. 27 أداة، 107 اختبار جديد.
|
||||
- **2026-04-15** 🤖 **Z.ai + MiniMax**: إضافة مزود Z.ai ([#35](https://github.com/HKUDS/Vibe-Trading/pull/35))، إصلاح temperature وتحديث نموذج MiniMax ([#33](https://github.com/HKUDS/Vibe-Trading/pull/33)). 13 مزوداً.
|
||||
- **2026-04-14** 🔧 **استقرار MCP**: إصلاح خطأ `Connection closed` في أداة الاختبار الرجعي عبر نقل stdio ([#32](https://github.com/HKUDS/Vibe-Trading/pull/32)).
|
||||
- **2026-04-13** 🌐 **الاختبار الرجعي المركب عبر الأسواق**: محرك `CompositeEngine` الجديد لاختبار محافظ متعددة الأسواق (مثل أسهم A + العملات المشفرة) بمجمع رأسمال مشترك. إصلاح متغيرات قوالب Swarm ومهلة الواجهة الأمامية.
|
||||
- **2026-04-12** 🌍 **تصدير متعدد المنصات**: أمر `/pine` يصدّر إلى TradingView (Pine Script v6) وTDX (通达信/同花顺/东方财富) وMetaTrader 5 (MQL5) دفعة واحدة.
|
||||
- **2026-04-11** 🛡️ **الموثوقية وDX**: إعداد `.env` عبر `vibe-trading init` ([#19](https://github.com/HKUDS/Vibe-Trading/pull/19))، فحوصات مسبقة، بديل تلقائي لمصادر البيانات، تعزيز محرك الاختبار الرجعي. README متعدد اللغات ([#21](https://github.com/HKUDS/Vibe-Trading/pull/21)).
|
||||
- **2026-04-10** 📦 **v0.1.4**: إصلاح Docker ([#8](https://github.com/HKUDS/Vibe-Trading/issues/8))، أداة MCP `web_search`، 12 مزود LLM، تبعيات `akshare`/`ccxt`. النشر على PyPI وClawHub.
|
||||
- **2026-04-09** 📊 **الموجة الثانية من الاختبار الرجعي**: محركات ChinaFutures وGlobalFutures وForex وOptions v2. تحقق مونت كارلو وBootstrap CI وWalk-Forward.
|
||||
- **2026-04-08** 🔧 **اختبار رجعي متعدد الأسواق**: قواعد خاصة بكل سوق، تصدير Pine Script v6، 5 مصادر بيانات مع بديل تلقائي.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 💡 ما هو Vibe-Trading؟
|
||||
|
||||
Vibe-Trading هو مساحة عمل مالية متعددة الوكلاء مدعومة بالذكاء الاصطناعي تحول الطلبات بلغة طبيعية إلى استراتيجيات تداول قابلة للتنفيذ ورؤى بحثية وتحليل محافظ عبر الأسواق العالمية.
|
||||
|
||||
### القدرات الرئيسية:
|
||||
• **لغة طبيعية → استراتيجية** — صِف فكرتك؛ الوكيل يكتب الكود ويختبره ويصدّره<br>
|
||||
• **6 مصادر بيانات، بدون إعداد** — أسهم A، HK/US، العملات المشفرة، العقود الآجلة، الفوركس مع بديل تلقائي<br>
|
||||
• **29 فريق خبراء** — سير عمل سرب متعدد الوكلاء للاستثمار والتداول وإدارة المخاطر<br>
|
||||
• **ذاكرة عبر الجلسات** — يتذكر التفضيلات والرؤى؛ ينشئ ويطور المهارات القابلة لإعادة الاستخدام<br>
|
||||
• **7 محركات اختبار رجعي** — اختبار مركب عبر الأسواق + تحقق إحصائي + 4 محسّنات<br>
|
||||
• **تصدير متعدد المنصات** — نقرة واحدة إلى TradingView وTDX وMetaTrader 5
|
||||
|
||||
---
|
||||
|
||||
## ✨ الميزات الرئيسية
|
||||
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="center" width="25%" valign="top">
|
||||
<img src="assets/scene-research.png" height="150" alt="البحث"/><br>
|
||||
<h3>🔍 بحث عميق للتداول</h3>
|
||||
<img src="https://img.shields.io/badge/74_Skills-FF6B6B?style=for-the-badge&logo=bookstack&logoColor=white" alt="المهارات" /><br><br>
|
||||
<div align="left" style="font-size: 4px;">
|
||||
• 74 مهارة متخصصة مع ذاكرة دائمة عبر الجلسات<br>
|
||||
• تطور ذاتي: الوكيل ينشئ ويحسّن سير العمل من التجربة<br>
|
||||
• ضغط سياق 5 طبقات — بلا فقدان معلومات في المحادثات الطويلة<br>
|
||||
• توجيه المهام بلغة طبيعية عبر جميع المجالات المالية
|
||||
</div>
|
||||
</td>
|
||||
<td align="center" width="25%" valign="top">
|
||||
<img src="assets/scene-swarm.png" height="150" alt="السرب"/><br>
|
||||
<h3>🐝 ذكاء السرب</h3>
|
||||
<img src="https://img.shields.io/badge/29_Trading_Teams-4ECDC4?style=for-the-badge&logo=hive&logoColor=white" alt="السرب" /><br><br>
|
||||
<div align="left">
|
||||
• 29 إعداد مسبق لفرق التداول الجاهزة<br>
|
||||
• تنسيق متعدد الوكلاء قائم على DAG<br>
|
||||
• لوحة معلومات بث مباشر مع حالة الوكلاء الحية<br>
|
||||
• بحث FTS5 عبر الجلسات في جميع المحادثات السابقة
|
||||
</div>
|
||||
</td>
|
||||
<td align="center" width="25%" valign="top">
|
||||
<img src="assets/scene-backtest.png" height="150" alt="الاختبار الرجعي"/><br>
|
||||
<h3>📊 اختبار رجعي عبر الأسواق</h3>
|
||||
<img src="https://img.shields.io/badge/6_Data_Sources-FFD93D?style=for-the-badge&logo=bitcoin&logoColor=black" alt="الاختبار الرجعي" /><br><br>
|
||||
<div align="left">
|
||||
• أسهم A، أسهم HK/US، العملات المشفرة، العقود الآجلة والفوركس<br>
|
||||
• 7 محركات سوق: أسهم A، أسهم US/HK، العملات المشفرة، العقود الآجلة الصينية، العقود الآجلة العالمية، الفوركس<br>
|
||||
• التحقق الإحصائي: مونت كارلو، Bootstrap CI، المشي للأمام<br>
|
||||
• 15+ مقياس أداء و4 محسّنات
|
||||
</div>
|
||||
</td>
|
||||
<td align="center" width="25%" valign="top">
|
||||
<img src="assets/scene-quant.png" height="150" alt="الكمي"/><br>
|
||||
<h3>🧮 أدوات التحليل الكمي</h3>
|
||||
<img src="https://img.shields.io/badge/Quant_Tools-C77DFF?style=for-the-badge&logo=wolfram&logoColor=white" alt="الكمي" /><br><br>
|
||||
<div align="left">
|
||||
• تحليل العامل IC/IR والاختبار الرجعي للشرائح<br>
|
||||
• تسعير Black-Scholes وحساب كامل للمتغيرات اليونانية<br>
|
||||
• التعرف على الأنماط الفنية واكتشافها<br>
|
||||
• تحسين المحافظ عبر MVO/Risk Parity/BL
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 74 مهارة عبر 8 فئات
|
||||
|
||||
- 📊 74 مهارة مالية متخصصة منظمة في 8 فئات
|
||||
- 🌐 تغطية شاملة من الأسواق التقليدية إلى العملات المشفرة وDeFi
|
||||
- 🔬 قدرات شاملة تمتد من مصادر البيانات إلى البحث الكمي
|
||||
|
||||
| الفئة | المهارات | أمثلة |
|
||||
|----------|--------|----------|
|
||||
| مصدر البيانات | 6 | `data-routing`, `tushare`, `yfinance`, `okx-market`, `akshare`, `ccxt` |
|
||||
| الاستراتيجية | 17 | `strategy-generate`, `cross-market-strategy`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
|
||||
| التحليل | 17 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis`, `dividend-analysis` |
|
||||
| فئة الأصول | 9 | `options-strategy`, `options-advanced`, `convertible-bond`, `etf-analysis`, `asset-allocation`, `sector-rotation` |
|
||||
| العملات المشفرة | 7 | `perp-funding-basis`, `liquidation-heatmap`, `stablecoin-flow`, `defi-yield`, `onchain-analysis` |
|
||||
| التدفقات | 7 | `hk-connect-flow`, `us-etf-flow`, `edgar-sec-filings`, `financial-statement`, `adr-hshare` |
|
||||
| الأدوات | 10 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader`, `vnpy-export` |
|
||||
| Risk Analysis | 1 | `ashare-pre-st-filter` |
|
||||
|
||||
## 29 إعداد مسبق لفرق وكلاء السرب
|
||||
|
||||
- 🏢 29 فرق وكلاء جاهزة للاستخدام
|
||||
- ⚡ سير عمل مالية مُعدة مسبقاً
|
||||
- 🎯 إعدادات مسبقة للاستثمار والتداول وإدارة المخاطر
|
||||
|
||||
| الإعداد المسبق | سير العمل |
|
||||
|--------|----------|
|
||||
| `investment_committee` | مناظرة صعود/هبوط ← مراجعة مخاطر ← قرار مدير المحفظة النهائي |
|
||||
| `global_equities_desk` | باحث أسهم A + HK/US + العملات المشفرة ← استراتيجي عالمي |
|
||||
| `crypto_trading_desk` | التمويل/الأساس + التصفية + التدفق ← مدير مخاطر |
|
||||
| `earnings_research_desk` | أساسي + مراجعة + خيارات ← استراتيجي الأرباح |
|
||||
| `macro_rates_fx_desk` | أسعار الفائدة + الفوركس + السلع ← مدير محفظة كلية |
|
||||
| `quant_strategy_desk` | فرز + بحث العوامل ← اختبار رجعي ← تدقيق مخاطر |
|
||||
| `technical_analysis_panel` | TA كلاسيكي + إيشيموكو + هارمونيك + إليوت + SMC ← إجماع |
|
||||
| `risk_committee` | السحب + مخاطر الذيل + مراجعة النظام ← موافقة |
|
||||
| `global_allocation_committee` | أسهم A + عملات مشفرة + HK/US ← تخصيص عبر الأسواق |
|
||||
|
||||
<sub>بالإضافة إلى 20+ إعداد مسبق متخصص إضافي — شغّل vibe-trading --swarm-presets لاستكشافها جميعاً.
|
||||
|
||||
</sub>
|
||||
|
||||
### 🎬 عرض توضيحي
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
https://github.com/user-attachments/assets/4e4dcb80-7358-4b9a-92f0-1e29612e6e86
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
https://github.com/user-attachments/assets/3754a414-c3ee-464f-b1e8-78e1a74fbd30
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" align="center"><sub>☝️ اختبار رجعي بلغة طبيعية ومناظرة سرب متعدد الوكلاء — واجهة ويب + CLI</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 🚀 البدء السريع
|
||||
|
||||
### تثبيت بسطر واحد (PyPI)
|
||||
|
||||
```bash
|
||||
pip install vibe-trading-ai
|
||||
```
|
||||
|
||||
> **اسم الحزمة مقابل الأوامر:** حزمة PyPI هي `vibe-trading-ai`. بعد التثبيت، ستحصل على ثلاثة أوامر:
|
||||
>
|
||||
> | الأمر | الغرض |
|
||||
> |---------|---------|
|
||||
> | `vibe-trading` | CLI تفاعلي / TUI |
|
||||
> | `vibe-trading serve` | تشغيل خادم ويب FastAPI |
|
||||
> | `vibe-trading-mcp` | بدء خادم MCP (لـ Claude Desktop, OpenClaw, Cursor, إلخ) |
|
||||
|
||||
```bash
|
||||
vibe-trading init # إعداد تفاعلي لملف .env
|
||||
vibe-trading # تشغيل CLI
|
||||
vibe-trading serve --port 8899 # تشغيل واجهة الويب
|
||||
vibe-trading-mcp # بدء خادم MCP (stdio)
|
||||
```
|
||||
|
||||
### أو اختر مساراً
|
||||
|
||||
| المسار | الأنسب لـ | الوقت |
|
||||
|------|----------|------|
|
||||
| **A. Docker** | تجربته الآن، بدون إعداد محلي | دقيقتان |
|
||||
| **B. تثبيت محلي** | التطوير، وصول كامل لـ CLI | 5 دقائق |
|
||||
| **C. إضافة MCP** | ربطه بوكيلك الحالي | 3 دقائق |
|
||||
| **D. ClawHub** | أمر واحد، بدون استنساخ | دقيقة واحدة |
|
||||
|
||||
### المتطلبات المسبقة
|
||||
|
||||
- **مفتاح API لنموذج لغة** من أي مزود مدعوم — أو التشغيل محلياً مع **Ollama** (بدون مفتاح)
|
||||
- **Python 3.11+** للمسار B
|
||||
- **Docker** للمسار A
|
||||
|
||||
> **مزودو نماذج اللغة المدعومون:** OpenRouter, OpenAI, DeepSeek, Gemini, Groq, DashScope/Qwen, Zhipu, Moonshot/Kimi, MiniMax, Xiaomi MIMO, Z.ai, Ollama (محلي). راجع `.env.example` للإعدادات.
|
||||
|
||||
> **نصيحة:** جميع الأسواق تعمل بدون أي مفاتيح API بفضل البديل التلقائي. yfinance (HK/US) و OKX (العملات المشفرة) و AKShare (أسهم A، US، HK، العقود الآجلة، الفوركس) جميعها مجانية. رمز Tushare اختياري — AKShare يغطي أسهم A كبديل مجاني.
|
||||
|
||||
### المسار A: Docker (بدون إعداد)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/Vibe-Trading.git
|
||||
cd Vibe-Trading
|
||||
cp agent/.env.example agent/.env
|
||||
# عدّل agent/.env — أزل التعليق عن مزود نموذج اللغة وحدد مفتاح API
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
افتح `http://localhost:8899`. الخلفية + الواجهة الأمامية في حاوية واحدة.
|
||||
|
||||
ينشر Docker الخلفية افتراضياً على `127.0.0.1:8899` فقط، ويشغل التطبيق كمستخدم حاوية غير root. إذا كنت تنوي تعريض الـ API خارج جهازك، فاضبط `API_AUTH_KEY` قوياً وأرسل `Authorization: Bearer <key>` من العملاء.
|
||||
|
||||
### المسار B: التثبيت المحلي
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/Vibe-Trading.git
|
||||
cd Vibe-Trading
|
||||
python -m venv .venv
|
||||
|
||||
# التفعيل
|
||||
source .venv/bin/activate # Linux / macOS
|
||||
# .venv\Scripts\Activate.ps1 # Windows PowerShell
|
||||
|
||||
pip install -e .
|
||||
cp agent/.env.example agent/.env # عدّل — حدد مفتاح API لمزود نموذج اللغة
|
||||
vibe-trading # تشغيل TUI التفاعلي
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>تشغيل واجهة الويب (اختياري)</b></summary>
|
||||
|
||||
```bash
|
||||
# الطرفية 1: خادم API
|
||||
vibe-trading serve --port 8899
|
||||
|
||||
# الطرفية 2: خادم تطوير الواجهة الأمامية
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
افتح `http://localhost:5899`. تعيد الواجهة الأمامية توجيه استدعاءات API إلى `localhost:8899`.
|
||||
|
||||
**وضع الإنتاج (خادم واحد):**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build && cd ..
|
||||
vibe-trading serve --port 8899 # يخدم FastAPI مجلد dist/ كملفات ثابتة
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### المسار C: إضافة MCP
|
||||
|
||||
راجع قسم [إضافة MCP](#-إضافة-mcp) أدناه.
|
||||
|
||||
### المسار D: ClawHub (أمر واحد)
|
||||
|
||||
```bash
|
||||
npx clawhub@latest install vibe-trading --force
|
||||
```
|
||||
|
||||
يتم تنزيل المهارة + إعدادات MCP إلى مجلد مهارات وكيلك. راجع [تثبيت ClawHub](#-إضافة-mcp) للتفاصيل.
|
||||
|
||||
---
|
||||
|
||||
## 🧠 متغيرات البيئة
|
||||
|
||||
انسخ `agent/.env.example` إلى `agent/.env` وأزل التعليق عن كتلة المزود التي تريدها. كل مزود يحتاج إلى 3-4 متغيرات:
|
||||
|
||||
| المتغير | مطلوب | الوصف |
|
||||
|----------|:--------:|-------------|
|
||||
| `LANGCHAIN_PROVIDER` | نعم | اسم المزود (`openrouter`, `deepseek`, `groq`, `z.ai`, `ollama`, إلخ) |
|
||||
| `<PROVIDER>_API_KEY` | نعم* | مفتاح API (`OPENROUTER_API_KEY`, `DEEPSEEK_API_KEY`, إلخ) |
|
||||
| `<PROVIDER>_BASE_URL` | نعم | رابط نقطة نهاية API |
|
||||
| `LANGCHAIN_MODEL_NAME` | نعم | اسم النموذج (مثلاً `deepseek/deepseek-v3.2`) |
|
||||
| `TUSHARE_TOKEN` | لا | رمز Tushare Pro لبيانات أسهم A (بديل AKShare) |
|
||||
| `TIMEOUT_SECONDS` | لا | مهلة استدعاء نموذج اللغة، الافتراضي 120 ثانية |
|
||||
| `API_AUTH_KEY` | موصى به للنشر الشبكي | Bearer token مطلوب عندما يكون الـ API قابلاً للوصول من عملاء غير محليين |
|
||||
| `VIBE_TRADING_ENABLE_SHELL_TOOLS` | لا | تفعيل صريح لأدوات shell في نشر API / MCP-SSE البعيد |
|
||||
| `VIBE_TRADING_ALLOWED_FILE_ROOTS` | لا | جذور إضافية مفصولة بفواصل لاستيراد المستندات وسجلات الوسطاء |
|
||||
| `VIBE_TRADING_ALLOWED_RUN_ROOTS` | لا | جذور إضافية مفصولة بفواصل لأدلة تشغيل الكود المولد |
|
||||
|
||||
<sub>* Ollama لا يتطلب مفتاح API.</sub>
|
||||
|
||||
**بيانات مجانية (بدون مفتاح):** أسهم A عبر AKShare، أسهم HK/US عبر yfinance، العملات المشفرة عبر OKX، 100+ بورصة عملات مشفرة عبر CCXT. يختار النظام تلقائياً أفضل مصدر متاح لكل سوق.
|
||||
|
||||
### 🎯 النماذج الموصى بها
|
||||
|
||||
Vibe-Trading وكيل يعتمد بكثافة على استدعاءات الأدوات — المهارات والاختبار الخلفي والذاكرة و swarm كلها تعمل عبر tool calls. اختيار النموذج يحدد مباشرة ما إذا كان الوكيل **يستخدم أدواته فعلاً** أو يلفّق الإجابات من بيانات التدريب.
|
||||
|
||||
| المستوى | أمثلة | متى يُستخدم |
|
||||
|---------|-------|-------------|
|
||||
| **الأفضل** | `anthropic/claude-opus-4.7`، `anthropic/claude-sonnet-4.6`، `openai/gpt-5.4`، `google/gemini-3.1-pro-preview` | swarm معقد (3+ وكلاء)، جلسات بحث طويلة، تحليل بمستوى ورقة علمية |
|
||||
| **نقطة مثلى** (افتراضي) | `deepseek/deepseek-v3.2`، `x-ai/grok-4.20`، `z-ai/glm-5.1`، `moonshotai/kimi-k2.5`، `qwen/qwen3-max-thinking` | الاستخدام اليومي — tool-calling موثوق بحوالي 1/10 من التكلفة |
|
||||
| **تجنّب كوكيل** | `*-nano`، `*-flash-lite`، `*-coder-next`، إصدارات صغيرة / مُقطّرة | tool-calling غير موثوق — سيبدو الوكيل وكأنه "يجيب من الذاكرة" بدلاً من تحميل المهارات أو تشغيل الاختبار الخلفي |
|
||||
|
||||
يأتي `agent/.env.example` الافتراضي مع `deepseek/deepseek-v3.2` — الخيار الأرخص في مستوى النقطة المثلى.
|
||||
|
||||
---
|
||||
|
||||
## 🖥 مرجع سطر الأوامر
|
||||
|
||||
```bash
|
||||
vibe-trading # TUI تفاعلي
|
||||
vibe-trading run -p "..." # تشغيل واحد
|
||||
vibe-trading serve # خادم API
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>أوامر الشرطة المائلة داخل TUI</b></summary>
|
||||
|
||||
| الأمر | الوصف |
|
||||
|---------|-------------|
|
||||
| `/help` | عرض جميع الأوامر |
|
||||
| `/skills` | عرض جميع مهارات التداول الـ 74 |
|
||||
| `/swarm` | عرض إعدادات فرق السرب الـ 29 |
|
||||
| `/swarm run <preset> [vars_json]` | تشغيل فريق سرب مع بث مباشر |
|
||||
| `/swarm list` | سجل تشغيلات السرب |
|
||||
| `/swarm show <run_id>` | تفاصيل تشغيل السرب |
|
||||
| `/swarm cancel <run_id>` | إلغاء سرب قيد التشغيل |
|
||||
| `/list` | التشغيلات الأخيرة |
|
||||
| `/show <run_id>` | تفاصيل التشغيل + المقاييس |
|
||||
| `/code <run_id>` | كود الاستراتيجية المولّدة |
|
||||
| `/pine <run_id>` | Pine Script لـ TradingView |
|
||||
| `/trace <run_id>` | إعادة تشغيل التنفيذ الكاملة |
|
||||
| `/continue <run_id> <prompt>` | متابعة تشغيل بتعليمات جديدة |
|
||||
| `/sessions` | عرض جلسات الدردشة |
|
||||
| `/settings` | عرض إعدادات التشغيل |
|
||||
| `/clear` | مسح الشاشة |
|
||||
| `/quit` | الخروج |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>التشغيل الفردي والعلامات</b></summary>
|
||||
|
||||
```bash
|
||||
vibe-trading run -p "اختبر استراتيجية BTC-USDT MACD رجعياً، آخر 30 يوماً"
|
||||
vibe-trading run -p "حلل زخم AAPL" --json
|
||||
vibe-trading run -f strategy.txt
|
||||
echo "اختبر 000001.SZ RSI رجعياً" | vibe-trading run
|
||||
```
|
||||
|
||||
```bash
|
||||
vibe-trading -p "طلبك"
|
||||
vibe-trading --skills
|
||||
vibe-trading --swarm-presets
|
||||
vibe-trading --swarm-run investment_committee '{"topic":"توقعات BTC"}'
|
||||
vibe-trading --list
|
||||
vibe-trading --show <run_id>
|
||||
vibe-trading --code <run_id>
|
||||
vibe-trading --pine <run_id> # Pine Script لـ TradingView
|
||||
vibe-trading --trace <run_id>
|
||||
vibe-trading --continue <run_id> "حسّن الاستراتيجية"
|
||||
vibe-trading --upload report.pdf
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🌐 خادم API
|
||||
|
||||
```bash
|
||||
vibe-trading serve --port 8899
|
||||
```
|
||||
|
||||
| الطريقة | نقطة النهاية | الوصف |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/runs` | عرض التشغيلات |
|
||||
| `GET` | `/runs/{run_id}` | تفاصيل التشغيل |
|
||||
| `GET` | `/runs/{run_id}/pine` | تصدير Pine Script |
|
||||
| `POST` | `/sessions` | إنشاء جلسة |
|
||||
| `POST` | `/sessions/{id}/messages` | إرسال رسالة |
|
||||
| `GET` | `/sessions/{id}/events` | بث أحداث SSE |
|
||||
| `POST` | `/upload` | رفع PDF/ملف |
|
||||
| `GET` | `/swarm/presets` | عرض إعدادات السرب |
|
||||
| `POST` | `/swarm/runs` | بدء تشغيل سرب |
|
||||
| `GET` | `/swarm/runs/{id}/events` | بث SSE للسرب |
|
||||
| `GET` | `/settings/llm` | قراءة إعدادات LLM في واجهة الويب |
|
||||
| `PUT` | `/settings/llm` | تحديث إعدادات LLM المحلية |
|
||||
| `GET` | `/settings/data-sources` | قراءة إعدادات مصادر البيانات المحلية |
|
||||
| `PUT` | `/settings/data-sources` | تحديث إعدادات مصادر البيانات المحلية |
|
||||
|
||||
توثيق تفاعلي: `http://localhost:8899/docs`
|
||||
|
||||
### الإعدادات الأمنية الافتراضية
|
||||
|
||||
في التطوير المحلي، يحافظ `vibe-trading serve` على بساطة سير عمل المتصفح. أي عميل غير محلي يصل إلى واجهات API الحساسة يحتاج إلى `API_AUTH_KEY`؛ استخدم `Authorization: Bearer <key>` لطلبات JSON والرفع. يتعامل Web UI مع بث EventSource بعد إدخال المفتاح نفسه مرة واحدة في Settings.
|
||||
|
||||
أدوات shell متاحة للـ CLI المحلي وسير عمل localhost الموثوق، لكنها لا تُعرض افتراضياً لجلسات API البعيدة إلا إذا ضبطت صراحة `VIBE_TRADING_ENABLE_SHELL_TOOLS=1`. قارئات المستندات وسجلات التداول مقيدة افتراضياً بجذور الرفع/الاستيراد؛ ضع الملفات تحت `agent/uploads` أو `agent/runs` أو `./uploads` أو `./data` أو `~/.vibe-trading/uploads` أو `~/.vibe-trading/imports`، أو أضف دليلاً مخصصاً عبر `VIBE_TRADING_ALLOWED_FILE_ROOTS`.
|
||||
|
||||
### إعدادات Web UI
|
||||
|
||||
تتيح صفحة Settings في واجهة الويب للمستخدمين المحليين تحديث LLM provider/model وBase URL ومعلمات التوليد وreasoning effort وبيانات اعتماد مصادر السوق الاختيارية مثل رمز Tushare. تُحفظ الإعدادات في `agent/.env`، وتُحمّل القيم الافتراضية للمزودين من `agent/src/providers/llm_providers.json`.
|
||||
|
||||
قراءة Settings بلا آثار جانبية: لا تنشئ `GET /settings/llm` و`GET /settings/data-sources` ملف `agent/.env`، وتعيدان فقط مسارات نسبية للمشروع. قد تكشف قراءة وكتابة Settings حالة بيانات الاعتماد أو تحدّث بيانات الاعتماد/بيئة التشغيل، لذلك تتطلب `API_AUTH_KEY` عند ضبطه. إذا لم يُضبط `API_AUTH_KEY` في وضع التطوير، فلا يُسمح بالوصول إلى Settings إلا من عملاء loopback المحليين.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 إضافة MCP
|
||||
|
||||
يقدم Vibe-Trading 22 أداة MCP لأي عميل متوافق مع MCP. يعمل كعملية فرعية stdio — بدون إعداد خادم. **21 من 22 أداة تعمل بدون مفاتيح API** (HK/US/العملات المشفرة). فقط `run_swarm` يحتاج إلى مفتاح نموذج لغة.
|
||||
|
||||
<details>
|
||||
<summary><b>Claude Desktop</b></summary>
|
||||
|
||||
أضف إلى `claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"vibe-trading": {
|
||||
"command": "vibe-trading-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>OpenClaw</b></summary>
|
||||
|
||||
أضف إلى `~/.openclaw/config.yaml`:
|
||||
|
||||
```yaml
|
||||
skills:
|
||||
- name: vibe-trading
|
||||
command: vibe-trading-mcp
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Cursor / Windsurf / عملاء MCP الآخرين</b></summary>
|
||||
|
||||
```bash
|
||||
vibe-trading-mcp # stdio (الافتراضي)
|
||||
vibe-trading-mcp --transport sse # SSE لعملاء الويب
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**أدوات MCP المتاحة (22):** `list_skills`, `load_skill`, `backtest`, `factor_analysis`, `analyze_options`, `pattern_recognition`, `get_market_data`, `web_search`, `read_url`, `read_document`, `read_file`, `write_file`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `list_swarm_presets`, `run_swarm`, `get_swarm_status`, `get_run_result`, `list_runs`.
|
||||
|
||||
<details>
|
||||
<summary><b>التثبيت من ClawHub (أمر واحد)</b></summary>
|
||||
|
||||
```bash
|
||||
npx clawhub@latest install vibe-trading --force
|
||||
```
|
||||
|
||||
> `--force` مطلوب لأن المهارة تشير إلى واجهات برمجية خارجية، مما يؤدي إلى فحص تلقائي من VirusTotal. الكود مفتوح المصدر بالكامل وآمن للفحص.
|
||||
|
||||
هذا ينزّل المهارة + إعدادات MCP إلى مجلد مهارات وكيلك. بدون الحاجة للاستنساخ.
|
||||
|
||||
تصفح على ClawHub: [clawhub.ai/skills/vibe-trading](https://clawhub.ai/skills/vibe-trading)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>OpenSpace — مهارات ذاتية التطور</b></summary>
|
||||
|
||||
جميع مهارات التداول الـ 74 منشورة على [open-space.cloud](https://open-space.cloud) وتتطور بشكل مستقل عبر محرك التطور الذاتي من OpenSpace.
|
||||
|
||||
للاستخدام مع OpenSpace، أضف خادمي MCP إلى إعدادات وكيلك:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"openspace": {
|
||||
"command": "openspace-mcp",
|
||||
"toolTimeout": 600,
|
||||
"env": {
|
||||
"OPENSPACE_HOST_SKILL_DIRS": "/path/to/vibe-trading/agent/src/skills",
|
||||
"OPENSPACE_WORKSPACE": "/path/to/OpenSpace"
|
||||
}
|
||||
},
|
||||
"vibe-trading": {
|
||||
"command": "vibe-trading-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
سيكتشف OpenSpace تلقائياً جميع المهارات الـ 74، مما يتيح الإصلاح التلقائي والتحسين التلقائي والمشاركة المجتمعية. ابحث عن مهارات Vibe-Trading عبر `search_skills("finance backtest")` في أي وكيل متصل بـ OpenSpace.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 📁 هيكل المشروع
|
||||
|
||||
<details>
|
||||
<summary><b>انقر للتوسيع</b></summary>
|
||||
|
||||
```
|
||||
Vibe-Trading/
|
||||
├── agent/ # الخلفية (Python)
|
||||
│ ├── cli.py # نقطة دخول CLI — TUI تفاعلي + أوامر فرعية
|
||||
│ ├── api_server.py # خادم FastAPI — تشغيلات، جلسات، رفع، سرب، SSE
|
||||
│ ├── mcp_server.py # خادم MCP — 22 أداة لـ OpenClaw / Claude Desktop
|
||||
│ │
|
||||
│ ├── src/
|
||||
│ │ ├── agent/ # نواة وكيل ReAct
|
||||
│ │ │ ├── loop.py # ضغط 5 طبقات + معالجة أدوات القراءة/الكتابة دفعة واحدة
|
||||
│ │ │ ├── context.py # موجه النظام + استرجاع تلقائي من الذاكرة الدائمة
|
||||
│ │ │ ├── skills.py # محمل المهارات (74 مدمجة + إنشاء CRUD من المستخدم)
|
||||
│ │ │ ├── tools.py # فئة الأدوات الأساسية + السجل
|
||||
│ │ │ ├── memory.py # حالة مساحة عمل خفيفة لكل تشغيل
|
||||
│ │ │ ├── frontmatter.py # محلل YAML frontmatter مشترك
|
||||
│ │ │ └── trace.py # كاتب أثر التنفيذ
|
||||
│ │ │
|
||||
│ │ ├── memory/ # ذاكرة دائمة عبر الجلسات
|
||||
│ │ │ └── persistent.py # ذاكرة قائمة على الملفات (~/.vibe-trading/memory/)
|
||||
│ │ │
|
||||
│ │ ├── tools/ # 27 أداة وكيل مكتشفة تلقائياً
|
||||
│ │ │ ├── backtest_tool.py # تشغيل الاختبارات الرجعية
|
||||
│ │ │ ├── remember_tool.py # ذاكرة عبر الجلسات (حفظ/استرجاع/نسيان)
|
||||
│ │ │ ├── skill_writer_tool.py # CRUD للمهارات (حفظ/تصحيح/حذف/ملف)
|
||||
│ │ │ ├── session_search_tool.py # بحث FTS5 عبر الجلسات
|
||||
│ │ │ ├── swarm_tool.py # إطلاق فرق السرب
|
||||
│ │ │ ├── web_search_tool.py # بحث ويب DuckDuckGo
|
||||
│ │ │ └── ... # bash، إدخال/إخراج ملف، تحليل العوامل، الخيارات، إلخ
|
||||
│ │ │
|
||||
│ │ ├── skills/ # 74 مهارة مالية في 8 فئات (SKILL.md لكل منها)
|
||||
│ │ ├── swarm/ # محرك تنفيذ DAG للسرب
|
||||
│ │ │ └── presets/ # 29 تعريف YAML للإعدادات المسبقة للسرب
|
||||
│ │ ├── session/ # دردشة متعددة الأدوار + بحث FTS5 عبر الجلسات
|
||||
│ │ └── providers/ # تجريد مزود نموذج اللغة
|
||||
│ │
|
||||
│ └── backtest/ # محركات الاختبار الرجعي
|
||||
│ ├── engines/ # 7 محركات + محرك مركب عبر الأسواق + options_portfolio
|
||||
│ ├── loaders/ # 6 مصادر: tushare, okx, yfinance, akshare, ccxt, futu
|
||||
│ │ ├── base.py # بروتوكول DataLoader
|
||||
│ │ └── registry.py # السجل + سلاسل البديل التلقائي
|
||||
│ └── optimizers/ # MVO، تساوي التقلب، أقصى تنويع، تكافؤ المخاطر
|
||||
│
|
||||
├── frontend/ # واجهة الويب (React 19 + Vite + TypeScript)
|
||||
│ └── src/
|
||||
│ ├── pages/ # الرئيسية، الوكيل، تفاصيل التشغيل، المقارنة
|
||||
│ ├── components/ # دردشة، رسوم بيانية، تخطيط
|
||||
│ └── stores/ # إدارة حالة Zustand
|
||||
│
|
||||
├── Dockerfile # بناء متعدد المراحل
|
||||
├── docker-compose.yml # نشر بأمر واحد
|
||||
├── pyproject.toml # إعدادات الحزمة + نقطة دخول CLI
|
||||
└── LICENSE # MIT
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🏛 النظام البيئي
|
||||
|
||||
Vibe-Trading هو جزء من النظام البيئي للوكلاء **[HKUDS](https://github.com/HKUDS)**:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="25%">
|
||||
<a href="https://github.com/HKUDS/ClawTeam"><b>ClawTeam</b></a><br>
|
||||
<sub>ذكاء سرب الوكلاء</sub>
|
||||
</td>
|
||||
<td align="center" width="25%">
|
||||
<a href="https://github.com/HKUDS/nanobot"><b>NanoBot</b></a><br>
|
||||
<sub>مساعد ذكاء اصطناعي شخصي فائق الخفة</sub>
|
||||
</td>
|
||||
<td align="center" width="25%">
|
||||
<a href="https://github.com/HKUDS/CLI-Anything"><b>CLI-Anything</b></a><br>
|
||||
<sub>جعل جميع البرامج أصلية للوكلاء</sub>
|
||||
</td>
|
||||
<td align="center" width="25%">
|
||||
<a href="https://github.com/HKUDS/OpenSpace"><b>OpenSpace</b></a><br>
|
||||
<sub>مهارات وكلاء ذكاء اصطناعي ذاتية التطور</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## 🗺 خارطة الطريق
|
||||
|
||||
> نشحن على مراحل. تنتقل العناصر إلى [المشكلات](https://github.com/HKUDS/Vibe-Trading/issues) عند بدء العمل.
|
||||
|
||||
| المرحلة | الميزة | الحالة |
|
||||
|-------|---------|--------|
|
||||
| **Research Autopilot** | حلقة بحث ليلية: فرضية → جلب بيانات → اختبار رجعي → تقرير أدلة | قيد التنفيذ |
|
||||
| **Data Bridge** | أحضر بياناتك: موصلات CSV/Parquet/SQL محلية مع schema mapping | مخطط |
|
||||
| **Options Lab** | سطح تقلب، لوحة Greeks، ومستكشف العوائد/السيناريوهات | مخطط |
|
||||
| **Portfolio Studio** | أشعة مخاطر، قيود، محسّن يراعي الدوران، وملاحظات إعادة التوازن | مخطط |
|
||||
| **Alpha Zoo** | مكتبات عوامل Alpha101 / Alpha158 / Alpha191 مع فحص واختبارات IC | مخطط |
|
||||
| **Research Delivery** | موجزات مجدولة إلى Slack / Telegram / قنوات شبيهة بالبريد | مخطط |
|
||||
| **Trust Layer** | بطاقات تشغيل قابلة للإعادة: أثر الأدوات، مصادر البيانات، الافتراضات، الاستشهادات | مخطط |
|
||||
| **Community** | skills وpresets وstrategy cards قابلة للمشاركة | قيد الاستكشاف |
|
||||
|
||||
---
|
||||
|
||||
## المساهمة
|
||||
|
||||
نرحب بالمساهمات! راجع [CONTRIBUTING.md](CONTRIBUTING.md) للإرشادات.
|
||||
|
||||
**المشكلات الجيدة للمبتدئين** محددة بعلامة [`good first issue`](https://github.com/HKUDS/Vibe-Trading/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) — اختر واحدة وابدأ.
|
||||
|
||||
ترغب في المساهمة بشيء أكبر؟ راجع [خارطة الطريق](#-خارطة-الطريق) أعلاه وافتح مشكلة للمناقشة قبل البدء.
|
||||
|
||||
---
|
||||
|
||||
## المساهمون
|
||||
|
||||
شكراً لكل من ساهم في Vibe-Trading!
|
||||
|
||||
<a href="https://github.com/HKUDS/Vibe-Trading/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=HKUDS/Vibe-Trading" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## إخلاء المسؤولية
|
||||
|
||||
Vibe-Trading مخصص للبحث والمحاكاة والاختبار الرجعي فقط. وهو ليس نصيحة استثمارية ولا ينفذ صفقات حية. الأداء السابق لا يضمن النتائج المستقبلية.
|
||||
|
||||
## الرخصة
|
||||
|
||||
رخصة MIT — راجع [LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
## تاريخ النجوم
|
||||
|
||||
[](https://star-history.com/#HKUDS/Vibe-Trading&Date)
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
شكراً لزيارتك <b>Vibe-Trading</b> ✨
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.Vibe-Trading&style=flat" alt="الزوار"/>
|
||||
</p>
|
||||
+138
-74
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | <a href="README_zh.md">中文</a> | <b>日本語</b> | <a href="README_ko.md">한국어</a>
|
||||
<a href="README.md">English</a> | <a href="README_zh.md">中文</a> | <b>日本語</b> | <a href="README_ko.md">한국어</a> | <a href="README_ar.md">العربية</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -19,10 +19,10 @@
|
||||
<a href="https://pypi.org/project/vibe-trading-ai/"><img src="https://img.shields.io/pypi/v/vibe-trading-ai?style=flat&logo=pypi&logoColor=white" alt="PyPI"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow?style=flat" alt="License"></a>
|
||||
<br>
|
||||
<img src="https://img.shields.io/badge/Skills-68-orange" alt="Skills">
|
||||
<img src="https://img.shields.io/badge/Skills-74-orange" alt="Skills">
|
||||
<img src="https://img.shields.io/badge/Swarm_Presets-29-7C3AED" alt="Swarm">
|
||||
<img src="https://img.shields.io/badge/Tools-21-0F766E" alt="Tools">
|
||||
<img src="https://img.shields.io/badge/Data_Sources-5-2563EB" alt="Data Sources">
|
||||
<img src="https://img.shields.io/badge/Tools-27-0F766E" alt="Tools">
|
||||
<img src="https://img.shields.io/badge/Data_Sources-6-2563EB" alt="Data Sources">
|
||||
<br>
|
||||
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/Feishu-Group-E9DBFC?style=flat-square&logo=feishu&logoColor=white" alt="Feishu"></a>
|
||||
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat-square&logo=wechat&logoColor=white" alt="WeChat"></a>
|
||||
@@ -51,10 +51,35 @@
|
||||
|
||||
## 📰 ニュース
|
||||
|
||||
- **2026-04-10** 📦 **v0.1.4**: Dockerビルドを修正([#8](https://github.com/HKUDS/Vibe-Trading/issues/8))、`web_search` MCPツールを追加(合計17)、依存関係とMCPに`akshare`/`ccxt`を追加。11のLLMプロバイダー(DeepSeek, Groq, Gemini, Ollama など)、すべての調整パラメータを`.env`で設定可能。`ml-strategy`スキルを強化。PyPIとClawHubに公開。
|
||||
- **2026-04-09** 📊 **Backtest Wave 2 — マルチアセットエンジン**: ChinaFutures(CFFEX/SHFE/DCE/ZCE、50+銘柄)、GlobalFutures(CME/ICE/Eurex、30+銘柄)、Forex(24通貨ペア、スプレッド+スワップ)、Options v2(アメリカン行使、IVスマイル)を追加。統計的検証: モンテカルロ置換検定、ブートストラップSharpe信頼区間、ウォークフォワード分析。
|
||||
- **2026-04-08** 🔧 **マルチマーケットバックテスト**(市場別ルール対応)と**TradingView向けPine Script v6エクスポート**。**データソース拡張**: 自動フォールバック付き5ソース、`web_search`ツール、スキル分類(7カテゴリ)。
|
||||
- **2026-04-01** 🚀 **v0.1.0** — 初期リリース: ReActエージェント、64スキル、29スウォームプリセット、クロスマーケットバックテスト、CLI + Web UI + MCPサーバー。
|
||||
- **2026-05-05** 🛡️ **セキュリティ境界の追加強化**: 明示的な CORS origin、Settings の認証情報ステータス表示、Web URL 読み取り、Shadow Account コード生成まわりの残りのセキュリティ境界を補強し、それぞれに回帰テストを追加しました。localhost の CLI/Web UI ワークフローは従来どおりです。リモートデプロイでは引き続き `API_AUTH_KEY` と明示的な信頼済み origin を設定してください。
|
||||
- **2026-05-04** 🖥️ **インタラクティブCLI UX + CI整理**: インタラクティブモードに、provider/model、セッション時間、直近実行時間、累計ツール呼び出し統計を表示するライブ下部ステータスバーを追加。さらに `prompt_toolkit` により上下キーの履歴移動と左右キーのカーソル編集に対応しました([#69](https://github.com/HKUDS/Vibe-Trading/pull/69))。`prompt_toolkit` またはTTYが利用できない場合は、従来どおりRich promptにフォールバックします。CIのパス期待値も強化済みファイルimportサンドボックスとクロスプラットフォームな `/tmp` 解決に合わせ、mainはグリーンに戻りました([`bb67dc7`](https://github.com/HKUDS/Vibe-Trading/commit/bb67dc7cfcc11553c57d8962bee56381dca43758))。
|
||||
- **2026-05-03** 🛡️ **セキュリティハードニングパッチ**: 非ローカルデプロイ向けの既定API認証を強化し、機密性の高いrun/session/swarm読み取りを保護、アップロードとローカルファイル読み取り境界を制限、shell系ツールをエントリーポイント別に制御、生成戦略をimport前に検証し、Dockerイメージは既定で非rootユーザーかつlocalhost限定ポート公開で動作します。CLIとlocalhost Web UIは低摩擦のままです。リモートAPI/Webデプロイでは`API_AUTH_KEY`を設定してください。
|
||||
|
||||
<details>
|
||||
<summary>過去のニュース</summary>
|
||||
|
||||
- **2026-05-02** 🧭 **配当分析 + ロードマップ刷新**: インカム株、配当の持続性、増配、株主還元利回り、権利落ちメカニクス、利回りの罠チェックに対応する `dividend-analysis` スキルを追加し、バンドルスキル回帰テストで固定しました。公開ロードマップは Research Autopilot、Data Bridge、Options Lab、Portfolio Studio、Alpha Zoo、Research Delivery、Trust Layer、Community 共有に絞りました。
|
||||
- **2026-05-01** 🔥 **相関ヒートマップ + OpenAI Codex OAuth + A株 pre-ST フィルター**: 新しい相関ダッシュボード/APIでローリングリターン相関を計算し、ポートフォリオや銘柄分析向けに ECharts ヒートマップで可視化します([#64](https://github.com/HKUDS/Vibe-Trading/pull/64))。OpenAI Codex provider は `vibe-trading provider login openai-codex` による ChatGPT OAuth に対応し、Settings メタデータとアダプター回帰テストも追加([#65](https://github.com/HKUDS/Vibe-Trading/pull/65))。A株の ST/*ST リスクスクリーニング用 `ashare-pre-st-filter` スキルを追加・強化し、Sina 処分公告の関連性フィルターにより証券口座リスト内の言及が E2 回数を水増ししないようにしました([#63](https://github.com/HKUDS/Vibe-Trading/pull/63))。
|
||||
- **2026-04-30** ⚙️ **Web UI設定 + validation CLI強化**: LLM provider/model、Base URL、reasoning effort、データソース認証情報をローカルで設定できる Settings ページを追加。settings API は local/auth で保護され、provider メタデータもデータ駆動設定に移行([#57](https://github.com/HKUDS/Vibe-Trading/pull/57))。さらに `python -m backtest.validation <run_dir>` を強化し、引数なし・空パス・不正パス・存在しないパス・ディレクトリでないパスを検証開始前に分かりやすく失敗させます([#60](https://github.com/HKUDS/Vibe-Trading/pull/60))。
|
||||
- **2026-04-28** 🚀 **v0.1.6 リリース**(`pip install -U vibe-trading-ai`): `pip install` / `uv tool install` 後に `vibe-trading --swarm-presets` が空を返す問題を修正([#55](https://github.com/HKUDS/Vibe-Trading/issues/55))— プリセット YAML を `src.swarm` パッケージ内に同梱、6 件の回帰テストでピン留め。加えて AKShare ローダーが ETF(`510300.SH`)と外国為替(`USDCNH`)を正しいエンドポイントにルーティング、レジストリフォールバックも強化。v0.1.5 以降の更新を集約: ベンチマーク比較パネル、`/upload` ストリーミング + サイズ制限、Futu ローダー(HK + A 株)、vnpy エクスポートスキル、セキュリティ強化、フロントエンド遅延ロード(688KB → 262KB)。
|
||||
- **2026-04-27** 📊 **ベンチマーク比較パネル + アップロード安全性**: バックテスト出力にベンチマーク比較パネル(銘柄 / ベンチマークリターン / 超過リターン / 情報比率)を追加、yfinance 経由で SPY・CSI 300 などを解決([#48](https://github.com/HKUDS/Vibe-Trading/issues/48))。加えて `/upload` を 1MB チャンクのストリーミングに変更し、`MAX_UPLOAD_SIZE` を超えた時点で中断 + 部分ファイルをクリーンアップ。50MB 上限が悪意/巨大リクエスト下でも実効化([#53](https://github.com/HKUDS/Vibe-Trading/pull/53))—— 4 件の回帰テストでピン留め。
|
||||
- **2026-04-22** 🛡️ **ハードニング + 新規連携**: `safe_path` でパス封じ込めを強制 + 取引明細/シャドウアカウント系ツールをサンドボックス化、`MANIFEST.in` を追加して sdist に `.env.example` / テスト / Docker ファイルを同梱、フロントエンドのルート単位遅延ロードで初期バンドル 688KB → 262KB。加えて富途(Futu)の香港株/A 株データローダー([#47](https://github.com/HKUDS/Vibe-Trading/pull/47))と vnpy CtaTemplate エクスポートスキル([#46](https://github.com/HKUDS/Vibe-Trading/pull/46))を追加。
|
||||
- **2026-04-21** 🛡️ **ワークスペース + ドキュメント**: 相対 `run_dir` をアクティブな run ディレクトリに正規化([#43](https://github.com/HKUDS/Vibe-Trading/pull/43))。README に使用例を追加([#45](https://github.com/HKUDS/Vibe-Trading/pull/45))。
|
||||
- **2026-04-20** 🔌 **推論モデル + Swarm 修正**: `reasoning_content` を `ChatOpenAI` の全シリアライズパスで保持 — Kimi / DeepSeek / Qwen thinking がエンドツーエンドで動作([#39](https://github.com/HKUDS/Vibe-Trading/issues/39))。Swarm をストリーミング化 + Ctrl+C のクリーン終了([#42](https://github.com/HKUDS/Vibe-Trading/issues/42))。
|
||||
- **2026-04-19** 📦 **v0.1.5**: PyPI と ClawHub に公開。`python-multipart` CVE 下限バンプ、新規 MCP ツール5つ接続(`analyze_trade_journal` + シャドウアカウント系4つ)、`pattern_recognition` → `pattern` レジストリ名の不一致を修正、Docker 依存を本体に合わせる、SKILL マニフェスト同期(22 MCP ツール / 71 スキル)。
|
||||
- **2026-04-18** 👥 **シャドウアカウント Shadow Account**: ブローカーの取引明細から自分の戦略ルールを抽出 → マーケット横断でシャドウをバックテスト → 8セクションのHTML/PDFレポートが、どこでいくら取りこぼしたか(ルール違反・早すぎる利確・見逃したシグナル・逆張り)を正確に可視化。新規ツール4つ、新スキル1つ、合計32ツール。Trade Journal / Shadow Accountのサンプル例文がWeb UIウェルカム画面に追加。
|
||||
- **2026-04-17** 📊 **取引明細アナライザー + ユニバーサルファイルリーダー**: ブローカーの取引明細(同花順/東方財富/富途/汎用CSV)をアップロード → 取引プロフィール(保有日数、勝率、損益比、最大ドローダウン)+ 4つの行動バイアス診断(処分効果、過剰取引、追随買い、アンカリング)を自動生成。`read_document`はPDF、Word、Excel、PowerPoint、画像(OCR)、40+テキスト形式を1回の呼び出しで統一処理。
|
||||
- **2026-04-16** 🧠 **エージェントハーネス**: クロスセッション永続メモリ、FTS5セッション検索、自己進化スキル(完全CRUD)、5層コンテキスト圧縮、読み書きツールバッチ処理。27ツール、107新テスト。
|
||||
- **2026-04-15** 🤖 **Z.ai + MiniMax**: Z.aiプロバイダー追加([#35](https://github.com/HKUDS/Vibe-Trading/pull/35))、MiniMax temperature修正+モデル更新([#33](https://github.com/HKUDS/Vibe-Trading/pull/33))。13プロバイダー対応。
|
||||
- **2026-04-14** 🔧 **MCP安定性**: バックテストツールのstdioトランスポートにおける`Connection closed`エラーを修正([#32](https://github.com/HKUDS/Vibe-Trading/pull/32))。
|
||||
- **2026-04-13** 🌐 **クロスマーケット複合バックテスト**: 新`CompositeEngine`で異なる市場の銘柄(例:A株+暗号資産)を共有資金プールで同時バックテスト、市場ルールは銘柄ごとに適用。Swarmテンプレート変数フォールバックとフロントエンドタイムアウトも修正。
|
||||
- **2026-04-12** 🌍 **マルチプラットフォーム出力**: `/pine`でTradingView (Pine Script v6)、TDX(通達信/同花順/東方財富)、MetaTrader 5 (MQL5) に一括エクスポート。
|
||||
- **2026-04-11** 🛡️ **信頼性とDX**: `vibe-trading init` .envブートストラップ([#19](https://github.com/HKUDS/Vibe-Trading/pull/19))、プリフライトチェック、データソースフォールバック、バックテストエンジン強化。多言語README([#21](https://github.com/HKUDS/Vibe-Trading/pull/21))。
|
||||
- **2026-04-10** 📦 **v0.1.4**: Docker修正([#8](https://github.com/HKUDS/Vibe-Trading/issues/8))、`web_search` MCPツール、12 LLMプロバイダー、`akshare`/`ccxt`依存追加。PyPIとClawHubに公開。
|
||||
- **2026-04-09** 📊 **Backtest Wave 2**: ChinaFutures、GlobalFutures、Forex、Options v2エンジン追加。モンテカルロ、Bootstrap CI、ウォークフォワード検証。
|
||||
- **2026-04-08** 🔧 **マルチマーケットバックテスト**: 市場別ルール、Pine Script v6エクスポート、自動フォールバック付き5データソース。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@@ -63,12 +88,12 @@
|
||||
Vibe-Tradingは、自然言語リクエストをグローバル市場向けの実行可能なトレーディング戦略、リサーチ洞察、ポートフォリオ分析へと変換する、AI駆動のマルチエージェント金融ワークスペースです。
|
||||
|
||||
### 主な能力:
|
||||
• **Strategy Generation** — アイデアから自動でトレーディングコードを生成<br>
|
||||
• **Smart Data Access** — 自動フォールバック付き5つのデータソース、全市場ゼロ設定<br>
|
||||
• **Performance Testing** — 歴史的市場データで戦略を検証<br>
|
||||
• **TradingView Export** — ワンクリックでTradingView用Pine Script v6に変換<br>
|
||||
• **Expert Teams** — 複雑なリサーチタスクに特化したAIエージェントチームを展開<br>
|
||||
• **Live Updates** — 分析プロセス全体をリアルタイムで観察
|
||||
• **自然言語 → 戦略** — アイデアを記述するだけ、エージェントがコードの作成・テスト・エクスポートを実行<br>
|
||||
• **6データソース、ゼロ設定** — A株、HK/US、暗号、先物、FX対応、自動フォールバック<br>
|
||||
• **29の専門チーム** — 投資・トレーディング・リスク向けマルチエージェントスウォームワークフロー<br>
|
||||
• **クロスセッションメモリ** — ユーザーの好みやインサイトを記憶し、再利用可能なスキルを自動生成・進化<br>
|
||||
• **7つのバックテストエンジン** — クロスマーケット複合テスト + 統計検証 + 4種オプティマイザー<br>
|
||||
• **マルチプラットフォームエクスポート** — ワンクリックでTradingView、TDX、MetaTrader 5へ
|
||||
|
||||
---
|
||||
|
||||
@@ -79,12 +104,12 @@ Vibe-Tradingは、自然言語リクエストをグローバル市場向けの
|
||||
<td align="center" width="25%" valign="top">
|
||||
<img src="assets/scene-research.png" height="150" alt="Research"/><br>
|
||||
<h3>🔍 トレーディング向けDeepResearch</h3>
|
||||
<img src="https://img.shields.io/badge/68_Skills-FF6B6B?style=for-the-badge&logo=bookstack&logoColor=white" alt="Skills" /><br><br>
|
||||
<img src="https://img.shields.io/badge/74_Skills-FF6B6B?style=for-the-badge&logo=bookstack&logoColor=white" alt="Skills" /><br><br>
|
||||
<div align="left" style="font-size: 4px;">
|
||||
• 市場横断のマルチドメイン分析<br>
|
||||
• 自動ストラテジー/シグナル生成<br>
|
||||
• マクロ経済リサーチとインサイト<br>
|
||||
• チャットによる自然言語タスクルーティング
|
||||
• 74の専門スキル + クロスセッション永続メモリ<br>
|
||||
• 自己進化: エージェントが経験からワークフローを構築・改善<br>
|
||||
• 5層コンテキスト圧縮 — 長い会話でも情報損失なし<br>
|
||||
• 全金融ドメインにわたる自然言語タスクルーティング
|
||||
</div>
|
||||
</td>
|
||||
<td align="center" width="25%" valign="top">
|
||||
@@ -94,17 +119,17 @@ Vibe-Tradingは、自然言語リクエストをグローバル市場向けの
|
||||
<div align="left">
|
||||
• 即戦力の29種トレーディングチームプリセット<br>
|
||||
• DAGベースのマルチエージェントオーケストレーション<br>
|
||||
• リアルタイム意思決定ストリーミングダッシュボード<br>
|
||||
• YAMLによるカスタムチーム構築
|
||||
• リアルタイムストリーミングダッシュボード(エージェントステータス表示)<br>
|
||||
• FTS5クロスセッション検索で過去の全会話を横断検索
|
||||
</div>
|
||||
</td>
|
||||
<td align="center" width="25%" valign="top">
|
||||
<img src="assets/scene-backtest.png" height="150" alt="Backtest"/><br>
|
||||
<h3>📊 クロスマーケットバックテスト</h3>
|
||||
<img src="https://img.shields.io/badge/5_Data_Sources-FFD93D?style=for-the-badge&logo=bitcoin&logoColor=black" alt="Backtest" /><br><br>
|
||||
<img src="https://img.shields.io/badge/6_Data_Sources-FFD93D?style=for-the-badge&logo=bitcoin&logoColor=black" alt="Backtest" /><br><br>
|
||||
<div align="left">
|
||||
• A株、HK/US株式、暗号資産、先物、FXに対応<br>
|
||||
• 7つの市場エンジン: A株、US/HK株、暗号資産、中国先物、グローバル先物、FX<br>
|
||||
• 7つの市場エンジン + クロスマーケット複合エンジン(共有資金プール)<br>
|
||||
• 統計的検証: モンテカルロ、ブートストラップCI、ウォークフォワード<br>
|
||||
• 15以上のパフォーマンス指標と4種類のオプティマイザー
|
||||
</div>
|
||||
@@ -123,21 +148,22 @@ Vibe-Tradingは、自然言語リクエストをグローバル市場向けの
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 7カテゴリにわたる68スキル
|
||||
## 8カテゴリにわたる74スキル
|
||||
|
||||
- 📊 68の金融特化スキルを7カテゴリに整理
|
||||
- 📊 74の金融特化スキルを8カテゴリに整理
|
||||
- 🌐 伝統的市場から暗号・DeFiまで完全カバー
|
||||
- 🔬 データ取得からクオンツリサーチまでの包括的能力
|
||||
|
||||
| Category | Skills | Examples |
|
||||
|----------|--------|----------|
|
||||
| Data Source | 6 | `data-routing`, `tushare`, `yfinance`, `okx-market`, `akshare`, `ccxt` |
|
||||
| Strategy | 16 | `strategy-generate`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
|
||||
| Analysis | 15 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis` |
|
||||
| Strategy | 17 | `strategy-generate`, `cross-market-strategy`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
|
||||
| Analysis | 17 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis`, `dividend-analysis` |
|
||||
| Asset Class | 9 | `options-strategy`, `options-advanced`, `convertible-bond`, `etf-analysis`, `asset-allocation`, `sector-rotation` |
|
||||
| Crypto | 7 | `perp-funding-basis`, `liquidation-heatmap`, `stablecoin-flow`, `defi-yield`, `onchain-analysis` |
|
||||
| Flow | 7 | `hk-connect-flow`, `us-etf-flow`, `edgar-sec-filings`, `financial-statement`, `adr-hshare` |
|
||||
| Tool | 8 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader` |
|
||||
| Tool | 10 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader`, `vnpy-export` |
|
||||
| Risk Analysis | 1 | `ashare-pre-st-filter` |
|
||||
|
||||
## 29種のエージェントスウォームチームプリセット
|
||||
|
||||
@@ -221,7 +247,7 @@ vibe-trading-mcp # MCPサーバー(stdio)起動
|
||||
- Path Bでは**Python 3.11+**
|
||||
- Path Aでは**Docker**
|
||||
|
||||
> **サポートされるLLMプロバイダー:** OpenRouter, OpenAI, DeepSeek, Gemini, Groq, DashScope/Qwen, Zhipu, Moonshot/Kimi, MiniMax, Xiaomi MIMO, Ollama(ローカル)。設定は`.env.example`を参照。
|
||||
> **サポートされるLLMプロバイダー:** OpenRouter, OpenAI, DeepSeek, Gemini, Groq, DashScope/Qwen, Zhipu, Moonshot/Kimi, MiniMax, Xiaomi MIMO, Z.ai, Ollama(ローカル)。設定は`.env.example`を参照。
|
||||
|
||||
> **Tip:** すべての市場でAPIキーなしでも動作(自動フォールバック)。yfinance(HK/US)、OKX(暗号)、AKShare(A株・US・HK・先物・FX)は無料。Tushareトークンは任意 — A株はAKShareで無料フォールバック。
|
||||
|
||||
@@ -237,6 +263,8 @@ docker compose up --build
|
||||
|
||||
`http://localhost:8899`を開きます。バックエンドとフロントエンドを1コンテナで提供。
|
||||
|
||||
Dockerは既定でバックエンドを`127.0.0.1:8899`にのみ公開し、非rootコンテナユーザーでアプリを実行します。APIを自分のマシン外へ意図的に公開する場合は、強い`API_AUTH_KEY`を設定し、クライアントから`Authorization: Bearer <key>`を送ってください。
|
||||
|
||||
### Path B: ローカルインストール
|
||||
|
||||
```bash
|
||||
@@ -301,11 +329,27 @@ npx clawhub@latest install vibe-trading --force
|
||||
| `LANGCHAIN_MODEL_NAME` | Yes | モデル名(例: `deepseek/deepseek-v3.2`) |
|
||||
| `TUSHARE_TOKEN` | No | A株データ用Tushare Proトークン(AKShareにフォールバック) |
|
||||
| `TIMEOUT_SECONDS` | No | LLM呼び出しタイムアウト(既定120s) |
|
||||
| `API_AUTH_KEY` | ネットワークデプロイでは推奨 | APIが非ローカルクライアントから到達可能な場合に必要なBearer token |
|
||||
| `VIBE_TRADING_ENABLE_SHELL_TOOLS` | No | リモートAPI / MCP-SSE系デプロイでshell系ツールを明示的に有効化 |
|
||||
| `VIBE_TRADING_ALLOWED_FILE_ROOTS` | No | 文書・ブローカー取引明細インポート用の追加ルート(カンマ区切り) |
|
||||
| `VIBE_TRADING_ALLOWED_RUN_ROOTS` | No | 生成コードrunディレクトリ用の追加ルート(カンマ区切り) |
|
||||
|
||||
<sub>* OllamaはAPIキー不要。</sub>
|
||||
|
||||
**無料データ(キー不要):** AKShare経由のA株、yfinance経由のHK/US株式、OKX経由の暗号、CCXT経由の100+暗号取引所。市場ごとに最適なソースを自動選択。
|
||||
|
||||
### 🎯 推奨モデル
|
||||
|
||||
Vibe-Tradingはツール呼び出しに大きく依存するエージェントです — skill・バックテスト・メモリ・swarmはすべてtool callで動作します。モデル選択が、エージェントが**実際にツールを使う**か、訓練データから答えを捏造するかを直接決定します。
|
||||
|
||||
| 層 | 例 | 用途 |
|
||||
|----|-----|------|
|
||||
| **ベスト** | `anthropic/claude-opus-4.7`、`anthropic/claude-sonnet-4.6`、`openai/gpt-5.4`、`google/gemini-3.1-pro-preview` | 複雑なswarm(3+エージェント)、長い研究セッション、論文レベル分析 |
|
||||
| **コスパ**(デフォルト) | `deepseek/deepseek-v3.2`、`x-ai/grok-4.20`、`z-ai/glm-5.1`、`moonshotai/kimi-k2.5`、`qwen/qwen3-max-thinking` | 日常利用 — tool-callingが安定、コスト約1/10 |
|
||||
| **エージェント用途では非推奨** | `*-nano`、`*-flash-lite`、`*-coder-next`、小型 / 蒸留版 | tool-callingが不安定 — skillのロードやbacktest実行をせず「記憶で回答」してしまう |
|
||||
|
||||
デフォルトの `agent/.env.example` は `deepseek/deepseek-v3.2` — コスパ層の最安オプション。
|
||||
|
||||
---
|
||||
|
||||
## 🖥 CLI リファレンス
|
||||
@@ -322,7 +366,7 @@ vibe-trading serve # APIサーバー
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/help` | すべてのコマンドを表示 |
|
||||
| `/skills` | 68の金融スキルを一覧表示 |
|
||||
| `/skills` | 74の金融スキルを一覧表示 |
|
||||
| `/swarm` | 29のスウォームチームプリセットを一覧表示 |
|
||||
| `/swarm run <preset> [vars_json]` | スウォームチームをライブストリーミングで実行 |
|
||||
| `/swarm list` | スウォーム実行履歴 |
|
||||
@@ -331,7 +375,7 @@ vibe-trading serve # APIサーバー
|
||||
| `/list` | 直近の実行 |
|
||||
| `/show <run_id>` | 実行詳細と指標 |
|
||||
| `/code <run_id>` | 生成された戦略コード |
|
||||
| `/pine <run_id>` | TradingView用Pine Script |
|
||||
| `/pine <run_id>` | インジケーターエクスポート(TradingView + TDX + MT5)|
|
||||
| `/trace <run_id>` | 実行リプレイ |
|
||||
| `/continue <run_id> <prompt>` | 実行を新指示で継続 |
|
||||
| `/sessions` | チャットセッション一覧 |
|
||||
@@ -358,7 +402,7 @@ vibe-trading --swarm-run investment_committee '{"topic":"BTC outlook"}'
|
||||
vibe-trading --list
|
||||
vibe-trading --show <run_id>
|
||||
vibe-trading --code <run_id>
|
||||
vibe-trading --pine <run_id> # TradingView用Pine Script
|
||||
vibe-trading --pine <run_id> # インジケーターエクスポート(TradingView + TDX + MT5)
|
||||
vibe-trading --trace <run_id>
|
||||
vibe-trading --continue <run_id> "refine the strategy"
|
||||
vibe-trading --upload report.pdf
|
||||
@@ -378,7 +422,7 @@ vibe-trading serve --port 8899
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/runs` | 実行一覧 |
|
||||
| `GET` | `/runs/{run_id}` | 実行詳細 |
|
||||
| `GET` | `/runs/{run_id}/pine` | Pine Scriptエクスポート |
|
||||
| `GET` | `/runs/{run_id}/pine` | マルチプラットフォームインジケーターエクスポート |
|
||||
| `POST` | `/sessions` | セッション作成 |
|
||||
| `POST` | `/sessions/{id}/messages` | メッセージ送信 |
|
||||
| `GET` | `/sessions/{id}/events` | SSEイベントストリーム |
|
||||
@@ -386,14 +430,30 @@ vibe-trading serve --port 8899
|
||||
| `GET` | `/swarm/presets` | スウォームプリセット一覧 |
|
||||
| `POST` | `/swarm/runs` | スウォーム実行開始 |
|
||||
| `GET` | `/swarm/runs/{id}/events` | スウォームSSEストリーム |
|
||||
| `GET` | `/settings/llm` | Web UIのLLM設定を読み取り |
|
||||
| `PUT` | `/settings/llm` | ローカルLLM設定を更新 |
|
||||
| `GET` | `/settings/data-sources` | ローカルデータソース設定を読み取り |
|
||||
| `PUT` | `/settings/data-sources` | ローカルデータソース設定を更新 |
|
||||
|
||||
インタラクティブドキュメント: `http://localhost:8899/docs`
|
||||
|
||||
### セキュリティ既定値
|
||||
|
||||
localhost開発では、`vibe-trading serve`はブラウザワークフローをシンプルに保ちます。非ローカルクライアントから機密APIへアクセスする場合は`API_AUTH_KEY`が必要です。JSON/アップロードリクエストでは`Authorization: Bearer <key>`を使ってください。ブラウザEventSourceストリームは、Web UIのSettingsで同じキーを一度入力すると処理されます。
|
||||
|
||||
shell系ツールはローカルCLIと信頼済みlocalhostワークフローでは利用できますが、リモートAPIセッションには既定で公開されません。必要な場合のみ`VIBE_TRADING_ENABLE_SHELL_TOOLS=1`を明示的に設定してください。文書・取引明細リーダーは既定でアップロード/インポートルートに制限されます。ファイルは`agent/uploads`、`agent/runs`、`./uploads`、`./data`、`~/.vibe-trading/uploads`、`~/.vibe-trading/imports`へ置くか、`VIBE_TRADING_ALLOWED_FILE_ROOTS`で専用ディレクトリを追加してください。
|
||||
|
||||
### Web UI Settings
|
||||
|
||||
Web UI Settingsページでは、ローカルユーザーがLLM provider/model、Base URL、生成パラメータ、reasoning effort、Tushare tokenなどの任意の市場データ認証情報を更新できます。設定は`agent/.env`に保存され、providerのデフォルト値は`agent/src/providers/llm_providers.json`から読み込まれます。
|
||||
|
||||
Settingsの読み取りは副作用なしです。`GET /settings/llm`と`GET /settings/data-sources`は`agent/.env`を作成せず、プロジェクト相対パスのみを返します。Settingsの読み取り/書き込みは認証情報の状態を公開したり認証情報・実行時環境を更新したりする可能性があるため、`API_AUTH_KEY`が設定されている場合は認証が必要です。開発モードで`API_AUTH_KEY`が未設定の場合、settingsアクセスはloopbackローカルクライアントのみに許可されます。
|
||||
|
||||
---
|
||||
|
||||
## 🔌 MCP プラグイン
|
||||
|
||||
Vibe-Tradingは、あらゆるMCP互換クライアント向けに17のMCPツールを提供します。stdioサブプロセスとして実行 — サーバーセットアップ不要。**17中16ツールはAPIキー不要**(HK/US/暗号)。`run_swarm`のみLLMキーが必要。
|
||||
Vibe-Tradingは、あらゆるMCP互換クライアント向けに22のMCPツールを提供します。stdioサブプロセスとして実行 — サーバーセットアップ不要。**22中21ツールはAPIキー不要**(HK/US/暗号)。`run_swarm`のみLLMキーが必要。
|
||||
|
||||
<details>
|
||||
<summary><b>Claude Desktop</b></summary>
|
||||
@@ -435,7 +495,7 @@ vibe-trading-mcp --transport sse # Webクライアント向けSSE
|
||||
|
||||
</details>
|
||||
|
||||
**公開MCPツール(17):** `list_skills`, `load_skill`, `backtest`, `factor_analysis`, `analyze_options`, `pattern_recognition`, `get_market_data`, `web_search`, `read_url`, `read_document`, `read_file`, `write_file`, `list_swarm_presets`, `run_swarm`, `get_swarm_status`, `get_run_result`, `list_runs`。
|
||||
**公開MCPツール(22):** `list_skills`, `load_skill`, `backtest`, `factor_analysis`, `analyze_options`, `pattern_recognition`, `get_market_data`, `web_search`, `read_url`, `read_document`, `read_file`, `write_file`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `list_swarm_presets`, `run_swarm`, `get_swarm_status`, `get_run_result`, `list_runs`。
|
||||
|
||||
<details>
|
||||
<summary><b>ClawHubからインストール(ワンコマンド)</b></summary>
|
||||
@@ -455,7 +515,7 @@ ClawHubで閲覧: [clawhub.ai/skills/vibe-trading](https://clawhub.ai/skills/vib
|
||||
<details>
|
||||
<summary><b>OpenSpace — 自己進化スキル</b></summary>
|
||||
|
||||
全68金融スキルは[open-space.cloud](https://open-space.cloud)に公開され、OpenSpaceの自己進化エンジンで自律的に進化します。
|
||||
全74金融スキルは[open-space.cloud](https://open-space.cloud)に公開され、OpenSpaceの自己進化エンジンで自律的に進化します。
|
||||
|
||||
OpenSpaceで使うには、両方のMCPサーバーをエージェント設定に追加:
|
||||
|
||||
@@ -477,7 +537,7 @@ OpenSpaceで使うには、両方のMCPサーバーをエージェント設定
|
||||
}
|
||||
```
|
||||
|
||||
OpenSpaceが全68スキルを自動検出し、auto-fix/auto-improve/コミュニティ共有を有効化。`search_skills("finance backtest")`でVibe-Tradingスキルを検索可能。
|
||||
OpenSpaceが全74スキルを自動検出し、auto-fix/auto-improve/コミュニティ共有を有効化。`search_skills("finance backtest")`でVibe-Tradingスキルを検索可能。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -493,41 +553,42 @@ Vibe-Trading/
|
||||
├── agent/ # バックエンド (Python)
|
||||
│ ├── cli.py # CLIエントリーポイント — 対話型TUI + サブコマンド
|
||||
│ ├── api_server.py # FastAPIサーバー — runs, sessions, upload, swarm, SSE
|
||||
│ ├── mcp_server.py # MCPサーバー — OpenClaw / Claude Desktop向け17ツール
|
||||
│ ├── mcp_server.py # MCPサーバー — OpenClaw / Claude Desktop向け22ツール
|
||||
│ │
|
||||
│ ├── src/
|
||||
│ │ ├── agent/ # ReActエージェントコア
|
||||
│ │ │ ├── loop.py # メイン推論ループ
|
||||
│ │ │ ├── skills.py # スキルローダー(68 SKILL.md, 7カテゴリ)
|
||||
│ │ │ ├── tools.py # ツールオーケストレーション
|
||||
│ │ │ ├── context.py # システムプロンプトビルダー
|
||||
│ │ │ ├── memory.py # 実行メモリ / アーティファクトストア
|
||||
│ │ │ ├── loop.py # 5層圧縮 + 読み書きツールバッチ処理
|
||||
│ │ │ ├── context.py # システムプロンプト + 永続メモリからの自動リコール
|
||||
│ │ │ ├── skills.py # スキルローダー(74バンドル + ユーザーCRUD作成)
|
||||
│ │ │ ├── tools.py # ツール基底クラス + レジストリ
|
||||
│ │ │ ├── memory.py # 実行ごとの軽量ワークスペースステート
|
||||
│ │ │ ├── frontmatter.py # 共有YAMLフロントマターパーサー
|
||||
│ │ │ └── trace.py # 実行トレースライター
|
||||
│ │ │
|
||||
│ │ ├── tools/ # 21のエージェントツール
|
||||
│ │ │ ├── backtest_tool.py # バックテスト実行
|
||||
│ │ │ ├── factor_analysis_tool.py
|
||||
│ │ │ ├── options_pricing_tool.py
|
||||
│ │ │ ├── pattern_tool.py # チャートパターン検出
|
||||
│ │ │ ├── doc_reader_tool.py # PDFリーダー(OCRフォールバック)
|
||||
│ │ │ ├── web_reader_tool.py # webページリーダー(Jina)
|
||||
│ │ │ ├── web_search_tool.py # DuckDuckGoウェブ検索
|
||||
│ │ │ ├── swarm_tool.py # スウォームチーム起動
|
||||
│ │ │ └── ... # ファイルI/O, bash, tasks など
|
||||
│ │ ├── memory/ # クロスセッション永続メモリ
|
||||
│ │ │ └── persistent.py # ファイルベースメモリ (~/.vibe-trading/memory/)
|
||||
│ │ │
|
||||
│ │ ├── skills/ # 7カテゴリに渡る68金融スキル (各SKILL.md)
|
||||
│ │ ├── tools/ # 27の自動検出エージェントツール
|
||||
│ │ │ ├── backtest_tool.py # バックテスト実行
|
||||
│ │ │ ├── remember_tool.py # クロスセッションメモリ (保存/リコール/削除)
|
||||
│ │ │ ├── skill_writer_tool.py # スキルCRUD (保存/パッチ/削除/ファイル)
|
||||
│ │ │ ├── session_search_tool.py # FTS5クロスセッション検索
|
||||
│ │ │ ├── swarm_tool.py # スウォームチーム起動
|
||||
│ │ │ ├── web_search_tool.py # DuckDuckGoウェブ検索
|
||||
│ │ │ └── ... # bash, ファイルI/O, ファクター分析, オプション等
|
||||
│ │ │
|
||||
│ │ ├── skills/ # 8カテゴリに渡る74金融スキル (各SKILL.md)
|
||||
│ │ ├── swarm/ # スウォームDAG実行エンジン
|
||||
│ │ ├── session/ # マルチターンチャットセッション管理
|
||||
│ │ │ └── presets/ # 29のスウォームプリセットYAML定義
|
||||
│ │ ├── session/ # マルチターンチャット + FTS5セッション検索
|
||||
│ │ └── providers/ # LLMプロバイダー抽象化
|
||||
│ │
|
||||
│ ├── backtest/ # バックテストエンジン
|
||||
│ │ ├── engines/ # 7エンジン: china_a, global_equity, crypto, china_futures, global_futures, forex + options_portfolio
|
||||
│ │ ├── loaders/ # 5ソース: tushare, okx, yfinance, akshare, ccxt
|
||||
│ │ │ ├── base.py # DataLoader Protocol
|
||||
│ │ │ └── registry.py # レジストリ + 自動フォールバックチェーン
|
||||
│ │ └── optimizers/ # MVO, equal vol, max div, risk parity
|
||||
│ │
|
||||
│ └── config/swarm/ # 29のスウォームプリセットYAML定義
|
||||
│ └── backtest/ # バックテストエンジン
|
||||
│ ├── engines/ # 7エンジン + クロスマーケット複合エンジン + options_portfolio
|
||||
│ ├── loaders/ # 6ソース: tushare, okx, yfinance, akshare, ccxt, futu
|
||||
│ │ ├── base.py # DataLoader Protocol
|
||||
│ │ └── registry.py # レジストリ + 自動フォールバックチェーン
|
||||
│ └── optimizers/ # MVO, equal vol, max div, risk parity
|
||||
│
|
||||
├── frontend/ # Web UI (React 19 + Vite + TypeScript)
|
||||
│ └── src/
|
||||
@@ -578,17 +639,14 @@ Vibe-Tradingは**[HKUDS](https://github.com/HKUDS)**エージェントエコシ
|
||||
|
||||
| Phase | Feature | Status |
|
||||
|-------|---------|--------|
|
||||
| **Analysis & Viz** | オプションボラティリティサーフェスとギリシャ値3D可視化 | Planned |
|
||||
| | ローリングウィンドウ+クラスタリングによるクロスアセット相関ヒートマップ | Planned |
|
||||
| | CLIバックテスト出力でのベンチマーク比較 | Planned |
|
||||
| | バックテスト指標へのCalmar Ratio & Omega Ratio追加 | Planned |
|
||||
| **Skills & Presets** | 配当分析スキル | Planned |
|
||||
| | ESG/サステナブル投資スウォームプリセット | Planned |
|
||||
| | 新興市場リサーチデスクスウォームプリセット | Planned |
|
||||
| **Portfolio & Optimization** | レバレッジ・セクター上限・回転率制約付き高度ポートフォリオオプティマイザー | Planned |
|
||||
| **Future** | 初心者チュートリアル: 「5分で自然言語バックテスト」 | Planned |
|
||||
| | WebSocket経由のライブデータストリーミング | Exploring |
|
||||
| | ストラテジーマーケットプレイス(共有・発見) | Exploring |
|
||||
| **Research Autopilot** | 夜間リサーチループ: 仮説 → データ取得 → バックテスト → 証拠レポート | In Progress |
|
||||
| **Data Bridge** | 持ち込みデータ: ローカル CSV/Parquet/SQL コネクタ + schema mapping | Planned |
|
||||
| **Options Lab** | ボラティリティサーフェス、Greeks ダッシュボード、損益/シナリオ探索 | Planned |
|
||||
| **Portfolio Studio** | リスクX線、制約、回転率考慮オプティマイザー、リバランスノート | Planned |
|
||||
| **Alpha Zoo** | Alpha101 / Alpha158 / Alpha191 因子ライブラリ、スクリーニング + IC テスト | Planned |
|
||||
| **Research Delivery** | Slack / Telegram / メール型チャネルへの定期ブリーフ配信 | Planned |
|
||||
| **Trust Layer** | 再現可能な run card: ツール履歴、データソース、仮定、引用 | Planned |
|
||||
| **Community** | 共有可能な skills、presets、strategy cards | Exploring |
|
||||
|
||||
---
|
||||
|
||||
@@ -622,6 +680,12 @@ MIT License — [LICENSE](LICENSE)を参照
|
||||
|
||||
---
|
||||
|
||||
## スター履歴
|
||||
|
||||
[](https://star-history.com/#HKUDS/Vibe-Trading&Date)
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<b>Vibe-Trading</b>への訪問に感謝 ✨
|
||||
</p>
|
||||
|
||||
+139
-75
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | <a href="README_zh.md">中文</a> | <a href="README_ja.md">日本語</a> | <b>한국어</b>
|
||||
<a href="README.md">English</a> | <a href="README_zh.md">中文</a> | <a href="README_ja.md">日本語</a> | <b>한국어</b> | <a href="README_ar.md">العربية</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -19,10 +19,10 @@
|
||||
<a href="https://pypi.org/project/vibe-trading-ai/"><img src="https://img.shields.io/pypi/v/vibe-trading-ai?style=flat&logo=pypi&logoColor=white" alt="PyPI"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow?style=flat" alt="License"></a>
|
||||
<br>
|
||||
<img src="https://img.shields.io/badge/Skills-68-orange" alt="Skills">
|
||||
<img src="https://img.shields.io/badge/Skills-74-orange" alt="Skills">
|
||||
<img src="https://img.shields.io/badge/Swarm_Presets-29-7C3AED" alt="Swarm">
|
||||
<img src="https://img.shields.io/badge/Tools-21-0F766E" alt="Tools">
|
||||
<img src="https://img.shields.io/badge/Data_Sources-5-2563EB" alt="Data Sources">
|
||||
<img src="https://img.shields.io/badge/Tools-27-0F766E" alt="Tools">
|
||||
<img src="https://img.shields.io/badge/Data_Sources-6-2563EB" alt="Data Sources">
|
||||
<br>
|
||||
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/Feishu-Group-E9DBFC?style=flat-square&logo=feishu&logoColor=white" alt="Feishu"></a>
|
||||
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat-square&logo=wechat&logoColor=white" alt="WeChat"></a>
|
||||
@@ -51,10 +51,35 @@
|
||||
|
||||
## 📰 뉴스
|
||||
|
||||
- **2026-04-10** 📦 **v0.1.4**: Docker 빌드 수정([#8](https://github.com/HKUDS/Vibe-Trading/issues/8)), `web_search` MCP 도구 추가(총 17개), 의존성 및 MCP에 `akshare`/`ccxt` 추가. 11개 LLM 제공자(DeepSeek, Groq, Gemini, Ollama 등), 모든 튜닝 파라미터를 `.env`로 설정. `ml-strategy` 스킬 하드닝. PyPI와 ClawHub에 게시.
|
||||
- **2026-04-09** 📊 **Backtest Wave 2 — 멀티자산 엔진**: ChinaFutures(CFFEX/SHFE/DCE/ZCE, 50+ 종목), GlobalFutures(CME/ICE/Eurex, 30+ 종목), Forex(24 페어, 스프레드 + 스왑), Options v2(미국형 행사, IV 스마일) 추가. 통계 검증: 몬테카를로 순열 테스트, Bootstrap 샤프 CI, 워크포워드 분석.
|
||||
- **2026-04-08** 🔧 **다중 시장 백테스트**: 시장별 규칙, TradingView용 **Pine Script v6 내보내기**. **데이터 소스 확장**: 자동 폴백 포함 5개 소스, `web_search` 도구, 스킬 7개 카테고리화.
|
||||
- **2026-04-01** 🚀 **v0.1.0** — 초기 릴리스: ReAct 에이전트, 64 스킬, 29 스웜 프리셋, 크로스마켓 백테스트, CLI + Web UI + MCP 서버.
|
||||
- **2026-05-05** 🛡️ **보안 경계 후속 강화**: 명시적 CORS origin, Settings 자격 증명 상태 표시, 웹 URL 읽기, Shadow Account 코드 생성 주변의 남은 보안 경계를 보강하고 각 경로에 회귀 테스트를 추가했습니다. localhost CLI/Web UI 흐름은 그대로 유지됩니다. 원격 배포에서는 계속 `API_AUTH_KEY`와 명시적인 신뢰 origin을 사용하세요.
|
||||
- **2026-05-04** 🖥️ **대화형 CLI UX + CI 정리**: 대화형 모드에 provider/model, 세션 시간, 직전 실행 시간, 누적 도구 호출 통계를 보여주는 실시간 하단 상태 표시줄이 추가되었습니다. 또한 `prompt_toolkit`을 통해 위/아래 방향키 히스토리 탐색과 좌/우 방향키 커서 편집을 지원합니다([#69](https://github.com/HKUDS/Vibe-Trading/pull/69)). `prompt_toolkit` 또는 TTY를 사용할 수 없으면 기존 Rich prompt로 자동 폴백합니다. CI 경로 기대값도 강화된 파일 import 샌드박스와 크로스플랫폼 `/tmp` 해석에 맞춰 정리되어 main이 다시 green 상태가 되었습니다([`bb67dc7`](https://github.com/HKUDS/Vibe-Trading/commit/bb67dc7cfcc11553c57d8962bee56381dca43758)).
|
||||
- **2026-05-03** 🛡️ **보안 강화 패치**: 비로컬 배포의 기본 API 인증을 강화하고, 민감한 run/session/swarm 읽기 API를 보호하며, 업로드와 로컬 파일 읽기 경계를 제한하고, shell 가능 도구를 진입점별로 제어합니다. 생성된 전략은 import 전에 검증되며 Docker 이미지는 기본적으로 비root 사용자와 localhost 전용 포트 공개로 실행됩니다. CLI와 localhost Web UI 흐름은 낮은 마찰을 유지합니다. 원격 API/Web 배포에서는 `API_AUTH_KEY`를 설정하세요.
|
||||
|
||||
<details>
|
||||
<summary>이전 뉴스</summary>
|
||||
|
||||
- **2026-05-02** 🧭 **배당 분석 + 더 선명한 로드맵**: 인컴 주식, 배당 지속 가능성, 배당 성장, 주주환원 수익률, 배당락 메커니즘, 고배당 함정 점검을 다루는 `dividend-analysis` 스킬을 추가하고 bundled skill 회귀 테스트로 고정했습니다. 공개 로드맵은 Research Autopilot, Data Bridge, Options Lab, Portfolio Studio, Alpha Zoo, Research Delivery, Trust Layer, Community 공유에 집중하도록 정리했습니다.
|
||||
- **2026-05-01** 🔥 **상관관계 히트맵 + OpenAI Codex OAuth + A주 pre-ST 필터**: 새 상관관계 대시보드/API가 롤링 수익률 상관관계를 계산하고, 포트폴리오 및 종목 분석용 ECharts 히트맵으로 렌더링합니다([#64](https://github.com/HKUDS/Vibe-Trading/pull/64)). OpenAI Codex provider는 이제 `vibe-trading provider login openai-codex`로 ChatGPT OAuth를 사용할 수 있으며, Settings 메타데이터와 어댑터 회귀 테스트도 추가되었습니다([#65](https://github.com/HKUDS/Vibe-Trading/pull/65)). A주 ST/*ST 리스크 스크리닝을 위한 `ashare-pre-st-filter` 스킬을 추가하고 강화했으며, Sina 제재 공시 관련성 필터링으로 증권 계좌 목록 언급이 E2 횟수를 부풀리지 않도록 했습니다([#63](https://github.com/HKUDS/Vibe-Trading/pull/63)).
|
||||
- **2026-04-30** ⚙️ **Web UI 설정 + validation CLI 강화**: LLM provider/model, Base URL, reasoning effort, 데이터 소스 자격 증명을 로컬에서 설정할 수 있는 Settings 페이지를 추가했습니다. settings API는 local/auth로 보호되며 provider 메타데이터도 데이터 기반 설정으로 분리되었습니다([#57](https://github.com/HKUDS/Vibe-Trading/pull/57)). 또한 `python -m backtest.validation <run_dir>`가 인자 없음, 빈 경로, 잘못된 경로, 존재하지 않는 경로, 디렉터리가 아닌 경로를 검증 시작 전에 명확한 메시지로 실패하도록 강화했습니다([#60](https://github.com/HKUDS/Vibe-Trading/pull/60)).
|
||||
- **2026-04-28** 🚀 **v0.1.6 릴리스**(`pip install -U vibe-trading-ai`): `pip install` / `uv tool install` 설치 후 `vibe-trading --swarm-presets`가 비어 있는 문제 수정([#55](https://github.com/HKUDS/Vibe-Trading/issues/55)) — 프리셋 YAML을 `src.swarm` 패키지 내부에 번들링, 6개 회귀 테스트로 고정. 또한 AKShare 로더가 ETF(`510300.SH`)와 외환(`USDCNH`)을 올바른 엔드포인트로 라우팅하고 레지스트리 폴백 강화. v0.1.5 이후 업데이트 종합: 벤치마크 비교 패널, `/upload` 스트리밍 + 크기 제한, Futu 로더(HK + A주), vnpy 내보내기 스킬, 보안 강화, 프론트엔드 지연 로딩(688KB → 262KB).
|
||||
- **2026-04-27** 📊 **벤치마크 비교 패널 + 업로드 안전성**: 백테스트 출력에 벤치마크 비교 패널(티커 / 벤치마크 수익률 / 초과 수익률 / 정보 비율) 추가, yfinance로 SPY · CSI 300 등 자동 해석([#48](https://github.com/HKUDS/Vibe-Trading/issues/48)). 또한 `/upload` 엔드포인트를 1MB 청크 스트리밍으로 전환, `MAX_UPLOAD_SIZE` 초과 시 즉시 중단 + 부분 파일 정리. 50MB 상한이 악성/초대형 요청에도 실효화([#53](https://github.com/HKUDS/Vibe-Trading/pull/53)) — 4개 회귀 테스트로 고정.
|
||||
- **2026-04-22** 🛡️ **하드닝 + 신규 연동**: `safe_path`에 경로 컨테인먼트 강제 + 거래 명세서/섀도우 계정 도구 샌드박스화, `MANIFEST.in` 추가로 sdist에 `.env.example` / 테스트 / Docker 파일 포함, 프론트엔드 라우트 단위 지연 로딩으로 초기 번들 688KB → 262KB. 또한 Futu 홍콩/A주 데이터 로더([#47](https://github.com/HKUDS/Vibe-Trading/pull/47))와 vnpy CtaTemplate 내보내기 스킬([#46](https://github.com/HKUDS/Vibe-Trading/pull/46)) 추가.
|
||||
- **2026-04-21** 🛡️ **워크스페이스 + 문서**: 상대 `run_dir`을 활성 run 디렉토리로 정규화([#43](https://github.com/HKUDS/Vibe-Trading/pull/43)). README 사용 예제 추가([#45](https://github.com/HKUDS/Vibe-Trading/pull/45)).
|
||||
- **2026-04-20** 🔌 **추론 모델 + Swarm 수정**: `reasoning_content`을 모든 `ChatOpenAI` 직렬화 경로에서 보존 — Kimi / DeepSeek / Qwen thinking 엔드투엔드 작동([#39](https://github.com/HKUDS/Vibe-Trading/issues/39)). Swarm 스트리밍 + 깔끔한 Ctrl+C 종료([#42](https://github.com/HKUDS/Vibe-Trading/issues/42)).
|
||||
- **2026-04-19** 📦 **v0.1.5**: PyPI 및 ClawHub에 게시. `python-multipart` CVE 하한 버전 업데이트, 5개 신규 MCP 도구 연결(`analyze_trade_journal` + 4개 섀도우 계정 도구), `pattern_recognition` → `pattern` 레지스트리 이름 불일치 수정, Docker 의존성 동기화, SKILL 매니페스트 동기화(22개 MCP 도구 / 71개 스킬).
|
||||
- **2026-04-18** 👥 **섀도우 계정 Shadow Account**: 증권사 거래 명세서에서 자신의 전략 규칙을 추출 → 여러 시장에서 섀도우 백테스트 실행 → 8개 섹션 HTML/PDF 리포트가 어디에서 얼마를 놓쳤는지(규칙 위반, 조기 익절, 놓친 시그널, 역방향 거래) 정확히 보여줌. 신규 도구 4개, 신규 스킬 1개, 총 32개 도구. Trade Journal / Shadow Account 샘플 프롬프트가 Web UI 웰컴 화면에 추가.
|
||||
- **2026-04-17** 📊 **거래 명세서 분석기 + 유니버설 파일 리더**: 증권사 거래 명세서(同花顺/东财/富途/일반 CSV) 업로드 → 거래 프로필(보유 일수, 승률, 손익비, 최대 드로다운) + 4가지 행동 편향 진단(처분 효과, 과잉 거래, 추격 매수, 앵커링) 자동 생성. `read_document`는 이제 PDF, Word, Excel, PowerPoint, 이미지(OCR), 40+ 텍스트 형식을 하나의 호출로 통합 처리.
|
||||
- **2026-04-16** 🧠 **에이전트 하네스**: 크로스세션 영구 메모리, FTS5 세션 검색, 자가 진화 스킬(전체 CRUD), 5계층 컨텍스트 압축, 읽기/쓰기 도구 배치 처리. 27개 도구, 107개 신규 테스트.
|
||||
- **2026-04-15** 🤖 **Z.ai + MiniMax**: Z.ai 제공자 추가([#35](https://github.com/HKUDS/Vibe-Trading/pull/35)), MiniMax temperature 수정 + 모델 업데이트([#33](https://github.com/HKUDS/Vibe-Trading/pull/33)). 13개 제공자.
|
||||
- **2026-04-14** 🔧 **MCP 안정성**: 백테스트 도구의 stdio 전송에서 `Connection closed` 오류 수정([#32](https://github.com/HKUDS/Vibe-Trading/pull/32)).
|
||||
- **2026-04-13** 🌐 **크로스마켓 복합 백테스트**: 새 `CompositeEngine`으로 서로 다른 시장 종목(예: A주 + 암호화폐)을 공유 자금 풀로 동시 백테스트, 시장 규칙은 종목별 적용. Swarm 템플릿 변수 폴백 및 프론트엔드 타임아웃도 수정.
|
||||
- **2026-04-12** 🌍 **멀티 플랫폼 내보내기**: `/pine`으로 TradingView (Pine Script v6), TDX (통달신/동화순/동방재부), MetaTrader 5 (MQL5) 한 번에 내보내기.
|
||||
- **2026-04-11** 🛡️ **안정성 및 DX**: `vibe-trading init` .env 부트스트랩([#19](https://github.com/HKUDS/Vibe-Trading/pull/19)), 프리플라이트 체크, 데이터소스 폴백, 백테스트 엔진 강화. 다국어 README([#21](https://github.com/HKUDS/Vibe-Trading/pull/21)).
|
||||
- **2026-04-10** 📦 **v0.1.4**: Docker 수정([#8](https://github.com/HKUDS/Vibe-Trading/issues/8)), `web_search` MCP 도구, 12개 LLM 제공자, `akshare`/`ccxt` 의존성. PyPI와 ClawHub에 게시.
|
||||
- **2026-04-09** 📊 **Backtest Wave 2**: ChinaFutures, GlobalFutures, Forex, Options v2 엔진. 몬테카를로, Bootstrap CI, 워크포워드 검증.
|
||||
- **2026-04-08** 🔧 **다중 시장 백테스트**: 시장별 규칙, Pine Script v6 내보내기, 자동 폴백 5개 데이터 소스.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@@ -63,12 +88,12 @@
|
||||
Vibe-Trading은 AI 기반 멀티 에이전트 금융 워크스페이스로, 자연어 요청을 전 세계 시장의 실행 가능한 트레이딩 전략, 리서치 인사이트, 포트폴리오 분석으로 전환합니다.
|
||||
|
||||
### 핵심 역량:
|
||||
• **전략 생성** — 아이디어를 자동으로 트레이딩 코드로 작성<br>
|
||||
• **스마트 데이터 접근** — 자동 폴백이 있는 5개 데이터 소스; 모든 시장 무설정<br>
|
||||
• **성능 테스트** — 전략을 과거 시장 데이터로 검증<br>
|
||||
• **TradingView 내보내기** — 클릭 한 번으로 전략을 Pine Script v6로 변환<br>
|
||||
• **전문 팀** — 복잡한 리서치 작업에 특화 에이전트 팀 배치<br>
|
||||
• **실시간 업데이트** — 전체 분석 과정을 실시간 스트리밍
|
||||
• **자연어 → 전략** — 아이디어를 설명하면 에이전트가 코드 작성, 테스트, 내보내기까지 실행<br>
|
||||
• **6개 데이터 소스, 무설정** — A주, HK/US, 크립토, 선물, FX 자동 폴백<br>
|
||||
• **29개 전문 팀** — 투자, 트레이딩, 리스크를 위한 멀티 에이전트 스웜 워크플로우<br>
|
||||
• **크로스세션 메모리** — 선호도와 인사이트를 기억하고 재사용 가능한 스킬을 자동 생성·진화<br>
|
||||
• **7개 백테스트 엔진** — 크로스마켓 복합 테스트 + 통계 검증 + 4개 옵티마이저<br>
|
||||
• **멀티 플랫폼 내보내기** — 클릭 한 번으로 TradingView, TDX(통달신/동화순), MetaTrader 5
|
||||
|
||||
---
|
||||
|
||||
@@ -79,12 +104,12 @@ Vibe-Trading은 AI 기반 멀티 에이전트 금융 워크스페이스로, 자
|
||||
<td align="center" width="25%" valign="top">
|
||||
<img src="assets/scene-research.png" height="150" alt="Research"/><br>
|
||||
<h3>🔍 트레이딩용 DeepResearch</h3>
|
||||
<img src="https://img.shields.io/badge/68_Skills-FF6B6B?style=for-the-badge&logo=bookstack&logoColor=white" alt="Skills" /><br><br>
|
||||
<img src="https://img.shields.io/badge/74_Skills-FF6B6B?style=for-the-badge&logo=bookstack&logoColor=white" alt="Skills" /><br><br>
|
||||
<div align="left" style="font-size: 4px;">
|
||||
• 시장 전반을 아우르는 다중 도메인 분석<br>
|
||||
• 자동 전략 및 시그널 생성<br>
|
||||
• 거시경제 리서치와 인사이트<br>
|
||||
• 대화형 자연어 태스크 라우팅
|
||||
• 74개 전문 스킬 + 크로스세션 영구 메모리<br>
|
||||
• 자가 진화: 에이전트가 경험으로부터 워크플로우를 생성·개선<br>
|
||||
• 5계층 컨텍스트 압축 — 긴 대화에서도 정보 손실 없음<br>
|
||||
• 전 금융 도메인에 걸친 자연어 태스크 라우팅
|
||||
</div>
|
||||
</td>
|
||||
<td align="center" width="25%" valign="top">
|
||||
@@ -94,17 +119,17 @@ Vibe-Trading은 AI 기반 멀티 에이전트 금융 워크스페이스로, 자
|
||||
<div align="left">
|
||||
• 29개 즉시 사용 가능한 트레이딩 팀 프리셋<br>
|
||||
• DAG 기반 멀티 에이전트 오케스트레이션<br>
|
||||
• 실시간 의사결정 스트리밍 대시보드<br>
|
||||
• YAML을 통한 커스텀 팀 구성
|
||||
• 실시간 스트리밍 대시보드(에이전트 상태 표시)<br>
|
||||
• FTS5 크로스세션 검색으로 모든 과거 대화 검색
|
||||
</div>
|
||||
</td>
|
||||
<td align="center" width="25%" valign="top">
|
||||
<img src="assets/scene-backtest.png" height="150" alt="Backtest"/><br>
|
||||
<h3>📊 크로스마켓 백테스트</h3>
|
||||
<img src="https://img.shields.io/badge/5_Data_Sources-FFD93D?style=for-the-badge&logo=bitcoin&logoColor=black" alt="Backtest" /><br><br>
|
||||
<img src="https://img.shields.io/badge/6_Data_Sources-FFD93D?style=for-the-badge&logo=bitcoin&logoColor=black" alt="Backtest" /><br><br>
|
||||
<div align="left">
|
||||
• A주, 홍콩/미국 주식, 크립토, 선물 및 FX<br>
|
||||
• 7개 시장 엔진: A주, 미/홍콩 주식, 크립토, 중국 선물, 글로벌 선물, FX<br>
|
||||
• 7개 시장 엔진 + 크로스마켓 복합 엔진(공유 자금 풀)<br>
|
||||
• 통계 검증: 몬테카를로, Bootstrap CI, 워크포워드<br>
|
||||
• 15+ 성과 지표 및 4개 옵티마이저
|
||||
</div>
|
||||
@@ -123,21 +148,22 @@ Vibe-Trading은 AI 기반 멀티 에이전트 금융 워크스페이스로, 자
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 7개 카테고리에 걸친 68개 스킬
|
||||
## 8개 카테고리에 걸친 74개 스킬
|
||||
|
||||
- 📊 7개 카테고리에 조직된 68개 금융 스킬
|
||||
- 📊 8개 카테고리에 조직된 74개 금융 스킬
|
||||
- 🌐 전통 시장부터 크립토·DeFi까지 완전 커버리지
|
||||
- 🔬 데이터 소싱부터 정량 리서치까지 포괄적 기능
|
||||
|
||||
| 카테고리 | 스킬 | 예시 |
|
||||
|----------|------|------|
|
||||
| Data Source | 6 | `data-routing`, `tushare`, `yfinance`, `okx-market`, `akshare`, `ccxt` |
|
||||
| Strategy | 16 | `strategy-generate`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
|
||||
| Analysis | 15 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis` |
|
||||
| Strategy | 17 | `strategy-generate`, `cross-market-strategy`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
|
||||
| Analysis | 17 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis`, `dividend-analysis` |
|
||||
| Asset Class | 9 | `options-strategy`, `options-advanced`, `convertible-bond`, `etf-analysis`, `asset-allocation`, `sector-rotation` |
|
||||
| Crypto | 7 | `perp-funding-basis`, `liquidation-heatmap`, `stablecoin-flow`, `defi-yield`, `onchain-analysis` |
|
||||
| Flow | 7 | `hk-connect-flow`, `us-etf-flow`, `edgar-sec-filings`, `financial-statement`, `adr-hshare` |
|
||||
| Tool | 8 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader` |
|
||||
| Tool | 10 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader`, `vnpy-export` |
|
||||
| Risk Analysis | 1 | `ashare-pre-st-filter` |
|
||||
|
||||
## 29개 에이전트 스웜 팀 프리셋
|
||||
|
||||
@@ -221,7 +247,7 @@ vibe-trading-mcp # MCP 서버 시작(stdio)
|
||||
- 경로 B용 **Python 3.11+**
|
||||
- 경로 A용 **Docker**
|
||||
|
||||
> **지원 LLM 제공자:** OpenRouter, OpenAI, DeepSeek, Gemini, Groq, DashScope/Qwen, Zhipu, Moonshot/Kimi, MiniMax, Xiaomi MIMO, Ollama(로컬). 설정은 `.env.example` 참고.
|
||||
> **지원 LLM 제공자:** OpenRouter, OpenAI, DeepSeek, Gemini, Groq, DashScope/Qwen, Zhipu, Moonshot/Kimi, MiniMax, Xiaomi MIMO, Z.ai, Ollama(로컬). 설정은 `.env.example` 참고.
|
||||
|
||||
> **팁:** 모든 시장은 자동 폴백 덕분에 API 키 없이도 작동합니다. yfinance(HK/US), OKX(크립토), AKShare(A주, 미국, HK, 선물, FX)는 모두 무료입니다. Tushare 토큰은 선택 사항 — AKShare가 A주 무료 폴백을 제공합니다.
|
||||
|
||||
@@ -237,6 +263,8 @@ docker compose up --build
|
||||
|
||||
`http://localhost:8899`를 엽니다. 백엔드 + 프런트엔드가 하나의 컨테이너에 있습니다.
|
||||
|
||||
Docker는 기본적으로 백엔드를 `127.0.0.1:8899`에만 게시하고, 비root 컨테이너 사용자로 앱을 실행합니다. API를 자신의 머신 밖으로 의도적으로 노출하는 경우 강한 `API_AUTH_KEY`를 설정하고 클라이언트에서 `Authorization: Bearer <key>`를 보내세요.
|
||||
|
||||
### 경로 B: 로컬 설치
|
||||
|
||||
```bash
|
||||
@@ -295,17 +323,33 @@ npx clawhub@latest install vibe-trading --force
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|:--------:|-------------|
|
||||
| `LANGCHAIN_PROVIDER` | Yes | 제공자 이름(`openrouter`, `deepseek`, `groq`, `ollama` 등) |
|
||||
| `LANGCHAIN_PROVIDER` | Yes | 제공자 이름(`openrouter`, `deepseek`, `groq`, `z.ai`, `ollama` 등) |
|
||||
| `<PROVIDER>_API_KEY` | Yes* | API 키(`OPENROUTER_API_KEY`, `DEEPSEEK_API_KEY` 등) |
|
||||
| `<PROVIDER>_BASE_URL` | Yes | API 엔드포인트 URL |
|
||||
| `LANGCHAIN_MODEL_NAME` | Yes | 모델 이름(예: `deepseek/deepseek-v3.2`) |
|
||||
| `TUSHARE_TOKEN` | No | A주 데이터용 Tushare Pro 토큰(AKShare 폴백) |
|
||||
| `TIMEOUT_SECONDS` | No | LLM 호출 타임아웃, 기본 120초 |
|
||||
| `API_AUTH_KEY` | 네트워크 배포 권장 | API가 비로컬 클라이언트에서 접근 가능한 경우 필요한 Bearer token |
|
||||
| `VIBE_TRADING_ENABLE_SHELL_TOOLS` | No | 원격 API / MCP-SSE 유형 배포에서 shell 가능 도구를 명시적으로 활성화 |
|
||||
| `VIBE_TRADING_ALLOWED_FILE_ROOTS` | No | 문서와 브로커 거래 명세서 import용 추가 루트(쉼표 구분) |
|
||||
| `VIBE_TRADING_ALLOWED_RUN_ROOTS` | No | 생성 코드 run 디렉터리용 추가 루트(쉼표 구분) |
|
||||
|
||||
<sub>* Ollama는 API 키가 필요 없습니다.</sub>
|
||||
|
||||
**무료 데이터(키 불필요):** AKShare의 A주, yfinance의 HK/US 주식, OKX의 크립토, CCXT의 100+ 크립토 거래소. 시스템이 시장별로 최적 소스를 자동 선택합니다.
|
||||
|
||||
### 🎯 권장 모델
|
||||
|
||||
Vibe-Trading은 툴 호출에 크게 의존하는 에이전트입니다 — skill, 백테스트, 메모리, swarm이 모두 tool call을 통해 실행됩니다. 모델 선택이 에이전트가 **실제로 툴을 사용하는지**, 아니면 학습 데이터에서 답을 꾸며내는지를 결정합니다.
|
||||
|
||||
| 등급 | 예시 | 용도 |
|
||||
|------|------|------|
|
||||
| **최상** | `anthropic/claude-opus-4.7`, `anthropic/claude-sonnet-4.6`, `openai/gpt-5.4`, `google/gemini-3.1-pro-preview` | 복잡한 swarm(3+ 에이전트), 긴 연구 세션, 논문급 분석 |
|
||||
| **가성비**(기본값) | `deepseek/deepseek-v3.2`, `x-ai/grok-4.20`, `z-ai/glm-5.1`, `moonshotai/kimi-k2.5`, `qwen/qwen3-max-thinking` | 일상 사용 — 안정적인 tool-calling, 비용 약 1/10 |
|
||||
| **에이전트용으로 피할 것** | `*-nano`, `*-flash-lite`, `*-coder-next`, 소형 / 증류 버전 | tool-calling 불안정 — skill 로드나 backtest 실행 대신 "기억으로 답변" |
|
||||
|
||||
기본 `agent/.env.example`은 `deepseek/deepseek-v3.2` 사용 — 가성비 등급에서 가장 저렴한 옵션.
|
||||
|
||||
---
|
||||
|
||||
## 🖥 CLI 참조
|
||||
@@ -322,7 +366,7 @@ vibe-trading serve # API 서버
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/help` | 모든 명령 표시 |
|
||||
| `/skills` | 68개 금융 스킬 목록 |
|
||||
| `/skills` | 74개 금융 스킬 목록 |
|
||||
| `/swarm` | 29개 스웜 팀 프리셋 목록 |
|
||||
| `/swarm run <preset> [vars_json]` | 라이브 스트리밍으로 스웜 팀 실행 |
|
||||
| `/swarm list` | 스웜 실행 이력 |
|
||||
@@ -331,7 +375,7 @@ vibe-trading serve # API 서버
|
||||
| `/list` | 최근 실행 |
|
||||
| `/show <run_id>` | 실행 상세 + 지표 |
|
||||
| `/code <run_id>` | 생성된 전략 코드 |
|
||||
| `/pine <run_id>` | TradingView용 Pine Script |
|
||||
| `/pine <run_id>` | 인디케이터 내보내기 (TradingView + TDX + MT5) |
|
||||
| `/trace <run_id>` | 전체 실행 리플레이 |
|
||||
| `/continue <run_id> <prompt>` | 새 지시로 실행 계속 |
|
||||
| `/sessions` | 채팅 세션 목록 |
|
||||
@@ -359,7 +403,7 @@ vibe-trading --swarm-run investment_committee '{"topic":"BTC outlook"}'
|
||||
vibe-trading --list
|
||||
vibe-trading --show <run_id>
|
||||
vibe-trading --code <run_id>
|
||||
vibe-trading --pine <run_id> # Pine Script for TradingView
|
||||
vibe-trading --pine <run_id> # 인디케이터 내보내기 (TradingView + TDX + MT5)
|
||||
vibe-trading --trace <run_id>
|
||||
vibe-trading --continue <run_id> "refine the strategy"
|
||||
vibe-trading --upload report.pdf
|
||||
@@ -379,7 +423,7 @@ vibe-trading serve --port 8899
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/runs` | 실행 목록 |
|
||||
| `GET` | `/runs/{run_id}` | 실행 상세 |
|
||||
| `GET` | `/runs/{run_id}/pine` | Pine Script 내보내기 |
|
||||
| `GET` | `/runs/{run_id}/pine` | 멀티 플랫폼 인디케이터 내보내기 |
|
||||
| `POST` | `/sessions` | 세션 생성 |
|
||||
| `POST` | `/sessions/{id}/messages` | 메시지 전송 |
|
||||
| `GET` | `/sessions/{id}/events` | SSE 이벤트 스트림 |
|
||||
@@ -387,14 +431,30 @@ vibe-trading serve --port 8899
|
||||
| `GET` | `/swarm/presets` | 스웜 프리셋 목록 |
|
||||
| `POST` | `/swarm/runs` | 스웜 실행 시작 |
|
||||
| `GET` | `/swarm/runs/{id}/events` | 스웜 SSE 스트림 |
|
||||
| `GET` | `/settings/llm` | Web UI LLM 설정 읽기 |
|
||||
| `PUT` | `/settings/llm` | 로컬 LLM 설정 업데이트 |
|
||||
| `GET` | `/settings/data-sources` | 로컬 데이터 소스 설정 읽기 |
|
||||
| `PUT` | `/settings/data-sources` | 로컬 데이터 소스 설정 업데이트 |
|
||||
|
||||
인터랙티브 문서: `http://localhost:8899/docs`
|
||||
|
||||
### 보안 기본값
|
||||
|
||||
localhost 개발에서는 `vibe-trading serve`가 브라우저 워크플로를 단순하게 유지합니다. 비로컬 클라이언트가 민감한 API에 접근하려면 `API_AUTH_KEY`가 필요합니다. JSON/업로드 요청에는 `Authorization: Bearer <key>`를 사용하세요. 브라우저 EventSource 스트림은 Web UI Settings에 같은 키를 한 번 입력하면 처리됩니다.
|
||||
|
||||
shell 가능 도구는 로컬 CLI와 신뢰된 localhost 워크플로에서 사용할 수 있지만, 원격 API 세션에는 기본적으로 노출되지 않습니다. 필요한 경우에만 `VIBE_TRADING_ENABLE_SHELL_TOOLS=1`을 명시적으로 설정하세요. 문서와 거래 명세서 리더는 기본적으로 업로드/import 루트로 제한됩니다. 파일은 `agent/uploads`, `agent/runs`, `./uploads`, `./data`, `~/.vibe-trading/uploads`, `~/.vibe-trading/imports`에 두거나, `VIBE_TRADING_ALLOWED_FILE_ROOTS`로 전용 디렉터리를 추가하세요.
|
||||
|
||||
### Web UI Settings
|
||||
|
||||
Web UI Settings 페이지에서는 로컬 사용자가 LLM provider/model, Base URL, 생성 파라미터, reasoning effort, Tushare token 같은 선택적 시장 데이터 자격 증명을 업데이트할 수 있습니다. 설정은 `agent/.env`에 저장되며 provider 기본값은 `agent/src/providers/llm_providers.json`에서 로드됩니다.
|
||||
|
||||
Settings 읽기는 부작용이 없습니다. `GET /settings/llm`과 `GET /settings/data-sources`는 `agent/.env`를 만들지 않고 프로젝트 상대 경로만 반환합니다. Settings 읽기와 쓰기는 자격 증명 상태를 노출하거나 자격 증명/런타임 환경을 업데이트할 수 있으므로 `API_AUTH_KEY`가 설정되어 있으면 인증이 필요합니다. 개발 모드에서 `API_AUTH_KEY`가 설정되지 않은 경우 settings 접근은 loopback 로컬 클라이언트에만 허용됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 MCP 플러그인
|
||||
|
||||
Vibe-Trading은 MCP 호환 클라이언트용 17개 MCP 도구를 제공합니다. stdio 서브프로세스로 실행 — 서버 설정 불필요. **17개 중 16개 도구는 API 키 없이 작동**(HK/US/크립토). `run_swarm`만 LLM 키가 필요합니다.
|
||||
Vibe-Trading은 MCP 호환 클라이언트용 22개 MCP 도구를 제공합니다. stdio 서브프로세스로 실행 — 서버 설정 불필요. **22개 중 21개 도구는 API 키 없이 작동**(HK/US/크립토). `run_swarm`만 LLM 키가 필요합니다.
|
||||
|
||||
<details>
|
||||
<summary><b>Claude Desktop</b></summary>
|
||||
@@ -436,7 +496,7 @@ vibe-trading-mcp --transport sse # 웹 클라이언트용 SSE
|
||||
|
||||
</details>
|
||||
|
||||
**제공 MCP 도구(17):** `list_skills`, `load_skill`, `backtest`, `factor_analysis`, `analyze_options`, `pattern_recognition`, `get_market_data`, `web_search`, `read_url`, `read_document`, `read_file`, `write_file`, `list_swarm_presets`, `run_swarm`, `get_swarm_status`, `get_run_result`, `list_runs`.
|
||||
**제공 MCP 도구(22):** `list_skills`, `load_skill`, `backtest`, `factor_analysis`, `analyze_options`, `pattern_recognition`, `get_market_data`, `web_search`, `read_url`, `read_document`, `read_file`, `write_file`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `list_swarm_presets`, `run_swarm`, `get_swarm_status`, `get_run_result`, `list_runs`.
|
||||
|
||||
<details>
|
||||
<summary><b>ClawHub에서 설치(한 줄)</b></summary>
|
||||
@@ -456,7 +516,7 @@ ClawHub에서 보기: [clawhub.ai/skills/vibe-trading](https://clawhub.ai/skills
|
||||
<details>
|
||||
<summary><b>OpenSpace — 자가 진화 스킬</b></summary>
|
||||
|
||||
모든 68개 금융 스킬은 [open-space.cloud](https://open-space.cloud)에 게시되어 OpenSpace의 자가 진화 엔진을 통해 스스로 발전합니다.
|
||||
모든 74개 금융 스킬은 [open-space.cloud](https://open-space.cloud)에 게시되어 OpenSpace의 자가 진화 엔진을 통해 스스로 발전합니다.
|
||||
|
||||
OpenSpace와 함께 사용하려면 두 MCP 서버를 에이전트 설정에 추가하세요:
|
||||
|
||||
@@ -478,7 +538,7 @@ OpenSpace와 함께 사용하려면 두 MCP 서버를 에이전트 설정에 추
|
||||
}
|
||||
```
|
||||
|
||||
OpenSpace는 모든 68개 스킬을 자동으로 탐지하여 자동 수정, 자동 개선, 커뮤니티 공유를 활성화합니다. OpenSpace 연결 에이전트에서 `search_skills("finance backtest")`로 Vibe-Trading 스킬을 검색하세요.
|
||||
OpenSpace는 모든 74개 스킬을 자동으로 탐지하여 자동 수정, 자동 개선, 커뮤니티 공유를 활성화합니다. OpenSpace 연결 에이전트에서 `search_skills("finance backtest")`로 Vibe-Trading 스킬을 검색하세요.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -494,41 +554,42 @@ Vibe-Trading/
|
||||
├── agent/ # Backend (Python)
|
||||
│ ├── cli.py # CLI 엔트리포인트 — 인터랙티브 TUI + 서브커맨드
|
||||
│ ├── api_server.py # FastAPI 서버 — 실행, 세션, 업로드, 스웜, SSE
|
||||
│ ├── mcp_server.py # MCP 서버 — OpenClaw / Claude Desktop용 17개 도구
|
||||
│ ├── mcp_server.py # MCP 서버 — OpenClaw / Claude Desktop용 22개 도구
|
||||
│ │
|
||||
│ ├── src/
|
||||
│ │ ├── agent/ # ReAct 에이전트 코어
|
||||
│ │ │ ├── loop.py # 메인 추론 루프
|
||||
│ │ │ ├── skills.py # 스킬 로더(68 SKILL.md, 7 카테고리)
|
||||
│ │ │ ├── tools.py # 도구 오케스트레이션
|
||||
│ │ │ ├── context.py # 시스템 프롬프트 빌더
|
||||
│ │ │ ├── memory.py # 실행 메모리 / 아티팩트 저장소
|
||||
│ │ │ ├── loop.py # 5계층 압축 + 읽기/쓰기 도구 배치 처리
|
||||
│ │ │ ├── context.py # 시스템 프롬프트 + 영구 메모리 자동 리콜
|
||||
│ │ │ ├── skills.py # 스킬 로더(74 번들 + 사용자 CRUD 생성)
|
||||
│ │ │ ├── tools.py # 도구 기본 클래스 + 레지스트리
|
||||
│ │ │ ├── memory.py # 실행별 경량 워크스페이스 상태
|
||||
│ │ │ ├── frontmatter.py # 공유 YAML frontmatter 파서
|
||||
│ │ │ └── trace.py # 실행 트레이스 기록기
|
||||
│ │ │
|
||||
│ │ ├── tools/ # 21개 에이전트 도구
|
||||
│ │ │ ├── backtest_tool.py # 백테스트 실행
|
||||
│ │ │ ├── factor_analysis_tool.py
|
||||
│ │ │ ├── options_pricing_tool.py
|
||||
│ │ │ ├── pattern_tool.py # 차트 패턴 감지
|
||||
│ │ │ ├── doc_reader_tool.py # PDF 리더(OCR 폴백)
|
||||
│ │ │ ├── web_reader_tool.py # 웹 페이지 리더(Jina)
|
||||
│ │ │ ├── web_search_tool.py # DuckDuckGo 웹 검색
|
||||
│ │ │ ├── swarm_tool.py # 스웜 팀 실행
|
||||
│ │ │ └── ... # 파일 I/O, bash, 태스크 등
|
||||
│ │ ├── memory/ # 크로스세션 영구 메모리
|
||||
│ │ │ └── persistent.py # 파일 기반 메모리 (~/.vibe-trading/memory/)
|
||||
│ │ │
|
||||
│ │ ├── skills/ # 7개 카테고리의 68개 금융 스킬(SKILL.md 각각)
|
||||
│ │ ├── tools/ # 27개 자동 탐지 에이전트 도구
|
||||
│ │ │ ├── backtest_tool.py # 백테스트 실행
|
||||
│ │ │ ├── remember_tool.py # 크로스세션 메모리 (저장/리콜/삭제)
|
||||
│ │ │ ├── skill_writer_tool.py # 스킬 CRUD (저장/패치/삭제/파일)
|
||||
│ │ │ ├── session_search_tool.py # FTS5 크로스세션 검색
|
||||
│ │ │ ├── swarm_tool.py # 스웜 팀 실행
|
||||
│ │ │ ├── web_search_tool.py # DuckDuckGo 웹 검색
|
||||
│ │ │ └── ... # bash, 파일 I/O, 팩터 분석, 옵션 등
|
||||
│ │ │
|
||||
│ │ ├── skills/ # 8개 카테고리의 74개 금융 스킬(SKILL.md 각각)
|
||||
│ │ ├── swarm/ # 스웜 DAG 실행 엔진
|
||||
│ │ ├── session/ # 멀티턴 채팅 세션 관리
|
||||
│ │ │ └── presets/ # 29개 스웜 프리셋 YAML 정의
|
||||
│ │ ├── session/ # 멀티턴 채팅 + FTS5 세션 검색
|
||||
│ │ └── providers/ # LLM 제공자 추상화
|
||||
│ │
|
||||
│ ├── backtest/ # 백테스트 엔진
|
||||
│ │ ├── engines/ # 7개 엔진: china_a, global_equity, crypto, china_futures, global_futures, forex + options_portfolio
|
||||
│ │ ├── loaders/ # 5개 소스: tushare, okx, yfinance, akshare, ccxt
|
||||
│ │ │ ├── base.py # DataLoader Protocol
|
||||
│ │ │ └── registry.py # 레지스트리 + 자동 폴백 체인
|
||||
│ │ └── optimizers/ # MVO, equal vol, max div, risk parity
|
||||
│ │
|
||||
│ └── config/swarm/ # 29개 스웜 프리셋 YAML 정의
|
||||
│ └── backtest/ # 백테스트 엔진
|
||||
│ ├── engines/ # 7개 엔진 + 크로스마켓 복합 엔진 + options_portfolio
|
||||
│ ├── loaders/ # 6개 소스: tushare, okx, yfinance, akshare, ccxt, futu
|
||||
│ │ ├── base.py # DataLoader Protocol
|
||||
│ │ └── registry.py # 레지스트리 + 자동 폴백 체인
|
||||
│ └── optimizers/ # MVO, equal vol, max div, risk parity
|
||||
│
|
||||
├── frontend/ # Web UI (React 19 + Vite + TypeScript)
|
||||
│ └── src/
|
||||
@@ -579,17 +640,14 @@ Vibe-Trading은 **[HKUDS](https://github.com/HKUDS)** 에이전트 생태계의
|
||||
|
||||
| Phase | Feature | Status |
|
||||
|-------|---------|--------|
|
||||
| **Analysis & Viz** | 옵션 변동성 서피스 및 그릭스 3D 시각화 | Planned |
|
||||
| | 롤링 윈도우 + 클러스터링 기반 크로스자산 상관 히트맵 | Planned |
|
||||
| | CLI 백테스트 출력에 벤치마크 비교 | Planned |
|
||||
| | 백테스트 지표에 Calmar Ratio & Omega Ratio | Planned |
|
||||
| **Skills & Presets** | 배당 분석 스킬 | Planned |
|
||||
| | ESG / 지속가능 투자 스웜 프리셋 | Planned |
|
||||
| | 이머징 마켓 리서치 데스크 스웜 프리셋 | Planned |
|
||||
| **Portfolio & Optimization** | 레버리지, 섹터 캡, 턴오버 제약을 포함한 고급 포트폴리오 옵티마이저 | Planned |
|
||||
| **Future** | 초보자 튜토리얼: "5분 자연어 백테스트" | Planned |
|
||||
| | WebSocket 기반 실시간 데이터 스트리밍 | Exploring |
|
||||
| | 전략 마켓플레이스(공유 & 발견) | Exploring |
|
||||
| **Research Autopilot** | 야간 리서치 루프: 가설 → 데이터 수집 → 백테스트 → 근거 리포트 | In Progress |
|
||||
| **Data Bridge** | 사용자 데이터 연결: 로컬 CSV/Parquet/SQL 커넥터 + schema mapping | Planned |
|
||||
| **Options Lab** | 변동성 서피스, 그릭스 대시보드, 페이오프/시나리오 탐색기 | Planned |
|
||||
| **Portfolio Studio** | 리스크 엑스레이, 제약 조건, 턴오버 고려 옵티마이저, 리밸런싱 노트 | Planned |
|
||||
| **Alpha Zoo** | Alpha101 / Alpha158 / Alpha191 팩터 라이브러리와 스크리닝 + IC 테스트 | Planned |
|
||||
| **Research Delivery** | Slack / Telegram / 이메일형 채널로 예약 브리프 전달 | Planned |
|
||||
| **Trust Layer** | 재현 가능한 run card: 도구 추적, 데이터 소스, 가정, 인용 | Planned |
|
||||
| **Community** | 공유 가능한 skills, presets, strategy cards | Exploring |
|
||||
|
||||
---
|
||||
|
||||
@@ -623,6 +681,12 @@ MIT License — [LICENSE](LICENSE) 참조
|
||||
|
||||
---
|
||||
|
||||
## 스타 히스토리
|
||||
|
||||
[](https://star-history.com/#HKUDS/Vibe-Trading&Date)
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
방문해 주셔서 감사합니다 <b>Vibe-Trading</b> ✨
|
||||
</p>
|
||||
|
||||
+139
-75
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | <b>中文</b> | <a href="README_ja.md">日本語</a> | <a href="README_ko.md">한국어</a>
|
||||
<a href="README.md">English</a> | <b>中文</b> | <a href="README_ja.md">日本語</a> | <a href="README_ko.md">한국어</a> | <a href="README_ar.md">العربية</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -19,10 +19,10 @@
|
||||
<a href="https://pypi.org/project/vibe-trading-ai/"><img src="https://img.shields.io/pypi/v/vibe-trading-ai?style=flat&logo=pypi&logoColor=white" alt="PyPI"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow?style=flat" alt="License"></a>
|
||||
<br>
|
||||
<img src="https://img.shields.io/badge/Skills-68-orange" alt="Skills">
|
||||
<img src="https://img.shields.io/badge/Skills-74-orange" alt="Skills">
|
||||
<img src="https://img.shields.io/badge/Swarm_Presets-29-7C3AED" alt="Swarm">
|
||||
<img src="https://img.shields.io/badge/Tools-21-0F766E" alt="Tools">
|
||||
<img src="https://img.shields.io/badge/Data_Sources-5-2563EB" alt="Data Sources">
|
||||
<img src="https://img.shields.io/badge/Tools-27-0F766E" alt="Tools">
|
||||
<img src="https://img.shields.io/badge/Data_Sources-6-2563EB" alt="Data Sources">
|
||||
<br>
|
||||
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/Feishu-Group-E9DBFC?style=flat-square&logo=feishu&logoColor=white" alt="Feishu"></a>
|
||||
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat-square&logo=wechat&logoColor=white" alt="WeChat"></a>
|
||||
@@ -51,10 +51,35 @@
|
||||
|
||||
## 📰 新闻
|
||||
|
||||
- **2026-04-10** 📦 **v0.1.4**:修复 Docker 构建([#8](https://github.com/HKUDS/Vibe-Trading/issues/8)),新增 `web_search` MCP 工具(共 17 个),在依赖与 MCP 中加入 `akshare`/`ccxt`。支持 11 家 LLM 提供商(DeepSeek、Groq、Gemini、Ollama 等),全部调优参数可通过 `.env` 配置。加固 `ml-strategy` 技能。已发布至 PyPI 和 ClawHub。
|
||||
- **2026-04-09** 📊 **回测 Wave 2 —— 多资产引擎**:新增 ChinaFutures(CFFEX/SHFE/DCE/ZCE,50+ 合约)、GlobalFutures(CME/ICE/Eurex,30+ 合约)、Forex(24 货币对,点差 + 掉期)、Options v2(美式行权、IV 微笑)。统计验证:蒙特卡洛置换检验、Bootstrap 夏普区间、Walk-Forward 分析。
|
||||
- **2026-04-08** 🔧 **多市场回测** 支持分市场规则;**TradingView Pine Script v6 导出**。**数据源扩展**:5 源自动回退,`web_search` 工具,技能分 7 类。
|
||||
- **2026-04-01** 🚀 **v0.1.0** —— 初始发布:ReAct 代理,64 技能,29 个 swarm 预设,跨市场回测,CLI + Web UI + MCP 服务器。
|
||||
- **2026-05-05** 🛡️ **安全边界补充加固**:补齐显式 CORS origin、Settings 凭据状态提示、网页 URL 读取与 Shadow Account 代码生成相关的安全边界,并为这些路径加入回归测试。本机 CLI/Web UI 使用方式不变;远程部署仍应使用 `API_AUTH_KEY` 和显式可信 origin。
|
||||
- **2026-05-04** 🖥️ **交互式 CLI 体验 + CI 清理**:交互模式新增实时底部状态栏,可显示 provider/model、会话时长、上次运行耗时和累计工具调用统计;同时通过 `prompt_toolkit` 支持方向键历史浏览与光标编辑([#69](https://github.com/HKUDS/Vibe-Trading/pull/69))。当 `prompt_toolkit` 或 TTY 不可用时,CLI 仍会回退到 Rich prompt。CI 路径断言也已对齐新的文件导入沙箱和跨平台 `/tmp` 解析,main 已恢复绿色([`bb67dc7`](https://github.com/HKUDS/Vibe-Trading/commit/bb67dc7cfcc11553c57d8962bee56381dca43758))。
|
||||
- **2026-05-03** 🛡️ **安全加固补丁**:收紧非本地部署的默认 API 鉴权,保护敏感的 run/session/swarm 读取接口,限制上传与本地文件读取边界,按入口类型控制 shell 能力工具,在导入前校验生成策略,并让 Docker 镜像默认以非 root 用户运行且只发布到 localhost。CLI 与本机 Web UI 仍保持低摩擦;远程 API/Web 部署应配置 `API_AUTH_KEY`。
|
||||
|
||||
<details>
|
||||
<summary>更早的新闻</summary>
|
||||
|
||||
- **2026-05-02** 🧭 **分红分析 + 更清晰的路线图**:新增 `dividend-analysis` 技能,覆盖收益型股票、分红可持续性、股息增长、股东回报率、除息机制与高息陷阱检查,并用 bundled skill 回归测试固定。公开路线图现在聚焦未来工作:Research Autopilot、Data Bridge、Options Lab、Portfolio Studio、Alpha Zoo、Research Delivery、Trust Layer 和 Community 分享。
|
||||
- **2026-05-01** 🔥 **相关性热力图 + OpenAI Codex OAuth + A 股 pre-ST 过滤器**:新增相关性仪表盘/API,可计算滚动收益相关性,并用 ECharts 热力图展示组合与标的相关结构([#64](https://github.com/HKUDS/Vibe-Trading/pull/64))。OpenAI Codex provider 现支持通过 `vibe-trading provider login openai-codex` 使用 ChatGPT OAuth,并补齐 Settings 元数据与适配器回归测试([#65](https://github.com/HKUDS/Vibe-Trading/pull/65))。新增并加固 `ashare-pre-st-filter` 技能,用于 A 股 ST/*ST 风险筛查;Sina 处罚公告相关性过滤会避免证券账户名单提及误计入 E2 频次([#63](https://github.com/HKUDS/Vibe-Trading/pull/63))。
|
||||
- **2026-04-30** ⚙️ **Web UI 设置页 + validation CLI 加固**:新增 Settings 页面,可在本地配置 LLM provider/model、Base URL、reasoning effort 以及数据源凭据;对应 settings API 已加本地/鉴权保护,并把 provider 元数据改为数据驱动配置([#57](https://github.com/HKUDS/Vibe-Trading/pull/57))。同时加固 `python -m backtest.validation <run_dir>`:缺参、空路径、非法路径、不存在路径、非目录路径都会在验证开始前给出明确错误([#60](https://github.com/HKUDS/Vibe-Trading/pull/60))。
|
||||
- **2026-04-28** 🚀 **v0.1.6 发布**(`pip install -U vibe-trading-ai`):修复 `pip install` / `uv tool install` 安装后 `vibe-trading --swarm-presets` 返回空的问题([#55](https://github.com/HKUDS/Vibe-Trading/issues/55))—— 预设 YAML 现已打包进 `src.swarm` 包内,配套 6 个回归测试。同时 AKShare 加载器正确路由 ETF(`510300.SH`)和外汇(`USDCNH`)到对应端点,并加固注册表回退链。汇总自 v0.1.5 以来全部更新:基准对比面板、`/upload` 流式上传 + 大小限制、富途数据源(港股/A 股)、vnpy 导出技能、安全加固、前端懒加载(688KB → 262KB)。
|
||||
- **2026-04-27** 📊 **基准对比面板 + 上传安全**:回测输出新增基准对比面板(标的 / 基准收益 / 超额收益 / 信息比率),通过 yfinance 解析 SPY、沪深 300 等基准([#48](https://github.com/HKUDS/Vibe-Trading/issues/48))。同时 `/upload` 端点改为 1MB 分块流式落盘,超过 `MAX_UPLOAD_SIZE` 立即中断并清理半截文件,让 50MB 上限在恶意/超大请求下真正生效([#53](https://github.com/HKUDS/Vibe-Trading/pull/53))—— 配套 4 个回归测试。
|
||||
- **2026-04-22** 🛡️ **加固 + 新接入**:`safe_path` 强制路径包含校验 + 交割单/影子账户工具沙箱化,新增 `MANIFEST.in` 让 sdist 打包 `.env.example` / 测试 / Docker 文件,前端按路由懒加载把首屏包从 688KB 压到 262KB。同时新增富途港股/A 股数据加载器([#47](https://github.com/HKUDS/Vibe-Trading/pull/47))和 vnpy CtaTemplate 导出技能([#46](https://github.com/HKUDS/Vibe-Trading/pull/46))。
|
||||
- **2026-04-21** 🛡️ **工作区与文档**:相对路径 `run_dir` 归一化到当前 run 目录([#43](https://github.com/HKUDS/Vibe-Trading/pull/43))。README 加入使用示例([#45](https://github.com/HKUDS/Vibe-Trading/pull/45))。
|
||||
- **2026-04-20** 🔌 **推理模型与 Swarm 修复**:`reasoning_content` 在所有 `ChatOpenAI` 序列化路径上保留 —— Kimi / DeepSeek / Qwen thinking 端到端可用([#39](https://github.com/HKUDS/Vibe-Trading/issues/39))。Swarm 切流式调用 + 干净的 Ctrl+C 退出([#42](https://github.com/HKUDS/Vibe-Trading/issues/42))。
|
||||
- **2026-04-19** 📦 **v0.1.5**:发布至 PyPI 与 ClawHub。`python-multipart` CVE 版本下限升级,5 个新 MCP 工具接入(`analyze_trade_journal` + 4 个影子账户工具),修复 `pattern_recognition` → `pattern` 工具注册名不一致,Docker 依赖对齐,SKILL 清单同步(22 MCP 工具 / 71 技能)。
|
||||
- **2026-04-18** 👥 **影子账户 Shadow Account**:从券商交割单提取你自己的策略规则 → 跨市场回测这个"影子" → 8 节 HTML/PDF 报告精确告诉你每一块钱是怎么错过的(规则违反、过早止盈、漏掉信号、反向操作)。4 个新工具、1 个新技能、共 32 工具。Trade Journal / Shadow Account 例句已进 Web UI 欢迎屏。
|
||||
- **2026-04-17** 📊 **交割单分析器 + 通用文件阅读器**:上传券商交割单(同花顺/东财/富途/通用 CSV)→ 自动生成交易画像(持仓天数、胜率、盈亏比、最大回撤)+ 4 项行为偏差诊断(处置效应、过度交易、追涨杀跌、锚定效应)。`read_document` 现统一分发 PDF、Word、Excel、PowerPoint、图片(OCR)及 40+ 文本格式,一个调用走全部类型。
|
||||
- **2026-04-16** 🧠 **Agent Harness**:跨会话持久记忆、FTS5 会话搜索、自进化技能(完整 CRUD)、5 层上下文压缩、读写工具批处理。27 工具,107 新测试。
|
||||
- **2026-04-15** 🤖 **Z.ai + MiniMax**:新增 Z.ai 提供商([#35](https://github.com/HKUDS/Vibe-Trading/pull/35)),修复 MiniMax temperature 及模型更新([#33](https://github.com/HKUDS/Vibe-Trading/pull/33))。共 13 家提供商。
|
||||
- **2026-04-14** 🔧 **MCP 稳定性**:修复回测工具在 stdio 传输中的 `Connection closed` 错误([#32](https://github.com/HKUDS/Vibe-Trading/pull/32))。
|
||||
- **2026-04-13** 🌐 **跨市场复合回测**:新增 `CompositeEngine`,混合不同市场标的(如 A 股 + 加密货币)共享资金池回测,各市场规则按标的独立执行。同时修复 swarm 模板变量回退和前端超时问题。
|
||||
- **2026-04-12** 🌍 **多平台指标导出**:`/pine` 一次性导出 TradingView (Pine Script v6)、通达信/同花顺/东方财富 (TDX)、MetaTrader 5 (MQL5) 三大平台。
|
||||
- **2026-04-11** 🛡️ **可靠性与 DX**:`vibe-trading init` .env 引导([#19](https://github.com/HKUDS/Vibe-Trading/pull/19)),启动预检、数据源自动回退、回测引擎加固。多语言 README([#21](https://github.com/HKUDS/Vibe-Trading/pull/21))。
|
||||
- **2026-04-10** 📦 **v0.1.4**:Docker 修复([#8](https://github.com/HKUDS/Vibe-Trading/issues/8)),`web_search` MCP 工具,12 家 LLM 提供商,`akshare`/`ccxt` 依赖。已发布至 PyPI 和 ClawHub。
|
||||
- **2026-04-09** 📊 **回测 Wave 2**:新增 ChinaFutures、GlobalFutures、Forex、Options v2 引擎。蒙特卡洛、Bootstrap CI、Walk-Forward 统计验证。
|
||||
- **2026-04-08** 🔧 **多市场回测**:分市场规则,Pine Script v6 导出,5 数据源自动回退。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@@ -63,12 +88,12 @@
|
||||
Vibe-Trading 是一个由 AI 驱动的多代理金融工作台,将自然语言请求转化为可执行的交易策略、研究洞见和跨全球市场的投资组合分析。
|
||||
|
||||
### 核心能力:
|
||||
• **Strategy Generation** —— 从你的想法自动生成交易代码<br>
|
||||
• **Smart Data Access** —— 5 大数据源自动回退;所有市场零配置<br>
|
||||
• **Performance Testing** —— 使用历史行情测试你的策略<br>
|
||||
• **TradingView Export** —— 一键转换为 TradingView 的 Pine Script v6<br>
|
||||
• **Expert Teams** —— 部署专职 AI 代理处理复杂研究任务<br>
|
||||
• **Live Updates** —— 全程实时查看分析过程
|
||||
• **自然语言 → 策略** —— 描述想法,代理自动编写、测试、导出交易代码<br>
|
||||
• **6 大数据源,零配置** —— A 股、港美股、加密、期货、外汇自动回退<br>
|
||||
• **29 支专家团队** —— 预构建的多代理 swarm 工作流,覆盖投资、交易与风控<br>
|
||||
• **跨会话记忆** —— 记住偏好与洞察;自动创建、进化可复用技能<br>
|
||||
• **7 大回测引擎** —— 跨市场复合测试 + 统计验证 + 4 种优化器<br>
|
||||
• **多平台导出** —— 一键到 TradingView、TDX(通达信/同花顺)和 MetaTrader 5
|
||||
|
||||
---
|
||||
|
||||
@@ -79,12 +104,12 @@ Vibe-Trading 是一个由 AI 驱动的多代理金融工作台,将自然语言
|
||||
<td align="center" width="25%" valign="top">
|
||||
<img src="assets/scene-research.png" height="150" alt="Research"/><br>
|
||||
<h3>🔍 面向交易的深度研究</h3>
|
||||
<img src="https://img.shields.io/badge/68_Skills-FF6B6B?style=for-the-badge&logo=bookstack&logoColor=white" alt="Skills" /><br><br>
|
||||
<img src="https://img.shields.io/badge/74_Skills-FF6B6B?style=for-the-badge&logo=bookstack&logoColor=white" alt="Skills" /><br><br>
|
||||
<div align="left" style="font-size: 4px;">
|
||||
• 覆盖多市场的多领域分析<br>
|
||||
• 自动生成策略与信号<br>
|
||||
• 宏观经济研究与洞察<br>
|
||||
• 基于聊天的自然语言任务路由
|
||||
• 74 个专长技能 + 跨会话持久记忆<br>
|
||||
• 自进化:代理从经验中创建并优化工作流<br>
|
||||
• 5 层上下文压缩——长对话不丢失信息<br>
|
||||
• 覆盖全金融领域的自然语言任务路由
|
||||
</div>
|
||||
</td>
|
||||
<td align="center" width="25%" valign="top">
|
||||
@@ -94,17 +119,17 @@ Vibe-Trading 是一个由 AI 驱动的多代理金融工作台,将自然语言
|
||||
<div align="left">
|
||||
• 29 个开箱即用的交易团队预设<br>
|
||||
• 基于 DAG 的多代理编排<br>
|
||||
• 实时决策流式仪表盘<br>
|
||||
• 通过 YAML 自定义团队
|
||||
• 实时流式仪表盘,显示代理运行状态<br>
|
||||
• FTS5 跨会话搜索全部历史对话
|
||||
</div>
|
||||
</td>
|
||||
<td align="center" width="25%" valign="top">
|
||||
<img src="assets/scene-backtest.png" height="150" alt="Backtest"/><br>
|
||||
<h3>📊 跨市场回测</h3>
|
||||
<img src="https://img.shields.io/badge/5_Data_Sources-FFD93D?style=for-the-badge&logo=bitcoin&logoColor=black" alt="Backtest" /><br><br>
|
||||
<img src="https://img.shields.io/badge/6_Data_Sources-FFD93D?style=for-the-badge&logo=bitcoin&logoColor=black" alt="Backtest" /><br><br>
|
||||
<div align="left">
|
||||
• A 股、港美股、加密、期货与外汇<br>
|
||||
• 7 个市场引擎:A 股、美港股、加密、中国期货、全球期货、外汇<br>
|
||||
• 7 个市场引擎 + 跨市场复合引擎(共享资金池)<br>
|
||||
• 统计验证:蒙特卡洛、Bootstrap 置信区间、Walk-Forward<br>
|
||||
• 15+ 绩效指标与 4 种优化器
|
||||
</div>
|
||||
@@ -123,21 +148,22 @@ Vibe-Trading 是一个由 AI 驱动的多代理金融工作台,将自然语言
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 7 大类别中的 68 个技能
|
||||
## 8 大类别中的 74 个技能
|
||||
|
||||
- 📊 68 个金融专长技能,划分 7 大类
|
||||
- 📊 74 个金融专长技能,划分 8 大类
|
||||
- 🌐 覆盖传统市场到加密与 DeFi
|
||||
- 🔬 覆盖数据获取到量化研究的全链路能力
|
||||
|
||||
| Category | Skills | Examples |
|
||||
|----------|--------|----------|
|
||||
| Data Source | 6 | `data-routing`, `tushare`, `yfinance`, `okx-market`, `akshare`, `ccxt` |
|
||||
| Strategy | 16 | `strategy-generate`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
|
||||
| Analysis | 15 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis` |
|
||||
| Strategy | 17 | `strategy-generate`, `cross-market-strategy`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
|
||||
| Analysis | 17 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis`, `dividend-analysis` |
|
||||
| Asset Class | 9 | `options-strategy`, `options-advanced`, `convertible-bond`, `etf-analysis`, `asset-allocation`, `sector-rotation` |
|
||||
| Crypto | 7 | `perp-funding-basis`, `liquidation-heatmap`, `stablecoin-flow`, `defi-yield`, `onchain-analysis` |
|
||||
| Flow | 7 | `hk-connect-flow`, `us-etf-flow`, `edgar-sec-filings`, `financial-statement`, `adr-hshare` |
|
||||
| Tool | 8 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader` |
|
||||
| Tool | 10 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader`, `vnpy-export` |
|
||||
| Risk Analysis | 1 | `ashare-pre-st-filter` |
|
||||
|
||||
## 29 个 Agent Swarm 团队预设
|
||||
|
||||
@@ -221,7 +247,7 @@ vibe-trading-mcp # 启动 MCP 服务器(stdio)
|
||||
- Path B 需 **Python 3.11+**
|
||||
- Path A 需 **Docker**
|
||||
|
||||
> **支持的 LLM 提供商:** OpenRouter、OpenAI、DeepSeek、Gemini、Groq、DashScope/Qwen、智谱、Moonshot/Kimi、MiniMax、小米 MIMO、Ollama(本地)。参见 `.env.example` 配置。
|
||||
> **支持的 LLM 提供商:** OpenRouter、OpenAI、DeepSeek、Gemini、Groq、DashScope/Qwen、智谱、Moonshot/Kimi、MiniMax、小米 MIMO、Z.ai、Ollama(本地)。参见 `.env.example` 配置。
|
||||
|
||||
> **提示:** 所有市场都可在无 API key 情况下运行,因自动回退。yfinance(港美股)、OKX(加密)、AKShare(A 股、美股、港股、期货、外汇)均免费。Tushare token 可选——A 股可回退到 AKShare 免费获取。
|
||||
|
||||
@@ -237,6 +263,8 @@ docker compose up --build
|
||||
|
||||
打开 `http://localhost:8899`。后端与前端同一容器。
|
||||
|
||||
Docker 默认只把后端发布到 `127.0.0.1:8899`,并以非 root 容器用户运行应用。如果你有意把 API 暴露到本机之外,请设置强 `API_AUTH_KEY`,客户端通过 `Authorization: Bearer <key>` 调用。
|
||||
|
||||
### Path B: 本地安装
|
||||
|
||||
```bash
|
||||
@@ -295,17 +323,33 @@ npx clawhub@latest install vibe-trading --force
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|:--------:|-------------|
|
||||
| `LANGCHAIN_PROVIDER` | Yes | 提供商名称(`openrouter`、`deepseek`、`groq`、`ollama` 等) |
|
||||
| `LANGCHAIN_PROVIDER` | Yes | 提供商名称(`openrouter`、`deepseek`、`groq`、`z.ai`、`ollama` 等) |
|
||||
| `<PROVIDER>_API_KEY` | Yes* | API key(`OPENROUTER_API_KEY`、`DEEPSEEK_API_KEY` 等) |
|
||||
| `<PROVIDER>_BASE_URL` | Yes | API 端点 URL |
|
||||
| `LANGCHAIN_MODEL_NAME` | Yes | 模型名(如 `deepseek/deepseek-v3.2`) |
|
||||
| `TUSHARE_TOKEN` | No | A 股数据的 Tushare Pro token(可回退 AKShare) |
|
||||
| `TIMEOUT_SECONDS` | No | LLM 调用超时,默认 120s |
|
||||
| `API_AUTH_KEY` | 网络部署建议设置 | API 可被非本地客户端访问时所需的 Bearer token |
|
||||
| `VIBE_TRADING_ENABLE_SHELL_TOOLS` | No | 远程 API / MCP-SSE 类部署显式启用 shell 能力工具 |
|
||||
| `VIBE_TRADING_ALLOWED_FILE_ROOTS` | No | 文档和券商交割单导入的额外逗号分隔目录 |
|
||||
| `VIBE_TRADING_ALLOWED_RUN_ROOTS` | No | 生成代码 run 目录的额外逗号分隔目录 |
|
||||
|
||||
<sub>* Ollama 不需要 API key。</sub>
|
||||
|
||||
**免费数据(无需 key):** A 股经 AKShare,港美股经 yfinance,加密经 OKX,100+ 加密交易所经 CCXT。系统会为每个市场自动选择最佳可用数据源。
|
||||
|
||||
### 🎯 推荐模型
|
||||
|
||||
Vibe-Trading 是重度依赖工具调用的 agent — skills、回测、记忆、swarm 全部通过 tool call 完成。模型选择直接决定 agent 是**真的在用工具**,还是从训练数据里编答案。
|
||||
|
||||
| 等级 | 示例 | 适用场景 |
|
||||
|------|------|---------|
|
||||
| **最佳** | `anthropic/claude-opus-4.7`、`anthropic/claude-sonnet-4.6`、`openai/gpt-5.4`、`google/gemini-3.1-pro-preview` | 复杂 swarm(3+ agent)、长研究会话、论文级分析 |
|
||||
| **性价比**(默认) | `deepseek/deepseek-v3.2`、`x-ai/grok-4.20`、`z-ai/glm-5.1`、`moonshotai/kimi-k2.5`、`qwen/qwen3-max-thinking` | 日常使用 — tool-calling 稳定,成本约 1/10 |
|
||||
| **不建议用于 agent** | `*-nano`、`*-flash-lite`、`*-coder-next`、小参数 / 蒸馏版 | tool-calling 不可靠 — agent 会"凭记忆回答"而不加载 skill 或跑回测 |
|
||||
|
||||
默认 `agent/.env.example` 使用 `deepseek/deepseek-v3.2` — 性价比档里最便宜的选项。
|
||||
|
||||
---
|
||||
|
||||
## 🖥 CLI 参考
|
||||
@@ -322,7 +366,7 @@ vibe-trading serve # API 服务器
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/help` | 显示全部命令 |
|
||||
| `/skills` | 列出 68 个金融技能 |
|
||||
| `/skills` | 列出 74 个金融技能 |
|
||||
| `/swarm` | 列出 29 个 swarm 团队预设 |
|
||||
| `/swarm run <preset> [vars_json]` | 以流式输出运行一个 swarm 团队 |
|
||||
| `/swarm list` | Swarm 运行历史 |
|
||||
@@ -331,7 +375,7 @@ vibe-trading serve # API 服务器
|
||||
| `/list` | 最近的运行 |
|
||||
| `/show <run_id>` | 运行详情与指标 |
|
||||
| `/code <run_id>` | 生成的策略代码 |
|
||||
| `/pine <run_id>` | TradingView 的 Pine Script |
|
||||
| `/pine <run_id>` | 导出指标代码(TradingView + TDX + MT5)|
|
||||
| `/trace <run_id>` | 完整执行回放 |
|
||||
| `/continue <run_id> <prompt>` | 带新指令继续运行 |
|
||||
| `/sessions` | 列出聊天会话 |
|
||||
@@ -358,7 +402,7 @@ vibe-trading --swarm-run investment_committee '{"topic":"BTC outlook"}'
|
||||
vibe-trading --list
|
||||
vibe-trading --show <run_id>
|
||||
vibe-trading --code <run_id>
|
||||
vibe-trading --pine <run_id> # TradingView 的 Pine Script
|
||||
vibe-trading --pine <run_id> # 导出指标代码(TradingView + TDX + MT5)
|
||||
vibe-trading --trace <run_id>
|
||||
vibe-trading --continue <run_id> "refine the strategy"
|
||||
vibe-trading --upload report.pdf
|
||||
@@ -378,7 +422,7 @@ vibe-trading serve --port 8899
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/runs` | 列出运行 |
|
||||
| `GET` | `/runs/{run_id}` | 运行详情 |
|
||||
| `GET` | `/runs/{run_id}/pine` | Pine Script 导出 |
|
||||
| `GET` | `/runs/{run_id}/pine` | 多平台指标导出 |
|
||||
| `POST` | `/sessions` | 创建会话 |
|
||||
| `POST` | `/sessions/{id}/messages` | 发送消息 |
|
||||
| `GET` | `/sessions/{id}/events` | SSE 事件流 |
|
||||
@@ -386,14 +430,30 @@ vibe-trading serve --port 8899
|
||||
| `GET` | `/swarm/presets` | 列出 swarm 预设 |
|
||||
| `POST` | `/swarm/runs` | 启动 swarm 运行 |
|
||||
| `GET` | `/swarm/runs/{id}/events` | Swarm SSE 流 |
|
||||
| `GET` | `/settings/llm` | 读取 Web UI LLM 设置 |
|
||||
| `PUT` | `/settings/llm` | 更新本地 LLM 设置 |
|
||||
| `GET` | `/settings/data-sources` | 读取本地数据源设置 |
|
||||
| `PUT` | `/settings/data-sources` | 更新本地数据源设置 |
|
||||
|
||||
交互式文档:`http://localhost:8899/docs`
|
||||
|
||||
### 安全默认值
|
||||
|
||||
本机开发时,`vibe-trading serve` 会尽量保持浏览器工作流简单。任何非本地客户端访问敏感 API 时都需要 `API_AUTH_KEY`;JSON/上传请求请使用 `Authorization: Bearer <key>`。浏览器 EventSource 流由 Web UI 在 Settings 中保存同一个 key 后处理。
|
||||
|
||||
Shell 能力工具对本地 CLI 和可信 localhost 工作流可用,但远程 API session 默认不会暴露,除非显式设置 `VIBE_TRADING_ENABLE_SHELL_TOOLS=1`。文档和交割单读取默认限制在上传/导入目录中;请把文件放到 `agent/uploads`、`agent/runs`、`./uploads`、`./data`、`~/.vibe-trading/uploads` 或 `~/.vibe-trading/imports`,也可以通过 `VIBE_TRADING_ALLOWED_FILE_ROOTS` 添加专用目录。
|
||||
|
||||
### Web UI 设置
|
||||
|
||||
Web UI Settings 页面允许本地用户更新 LLM provider/model、Base URL、生成参数、reasoning effort,以及 Tushare token 等可选市场数据凭据。设置会保存到 `agent/.env`;provider 默认值来自 `agent/src/providers/llm_providers.json`。
|
||||
|
||||
Settings 读取是无副作用的:`GET /settings/llm` 和 `GET /settings/data-sources` 不会创建 `agent/.env`,并且只返回项目相对路径。Settings 读取和写入可能暴露凭据状态或更新凭据/运行时环境,因此配置 `API_AUTH_KEY` 时必须携带认证;开发模式未配置 `API_AUTH_KEY` 时,仅允许 loopback 本地客户端访问。
|
||||
|
||||
---
|
||||
|
||||
## 🔌 MCP 插件
|
||||
|
||||
Vibe-Trading 为任意 MCP 兼容客户端提供 17 个 MCP 工具。以 stdio 子进程运行——无需服务器部署。**17 个工具中有 16 个无需任何 API key**(港美股/加密)。仅 `run_swarm` 需要 LLM key。
|
||||
Vibe-Trading 为任意 MCP 兼容客户端提供 22 个 MCP 工具。以 stdio 子进程运行——无需服务器部署。**22 个工具中有 21 个无需任何 API key**(港美股/加密)。仅 `run_swarm` 需要 LLM key。
|
||||
|
||||
<details>
|
||||
<summary><b>Claude Desktop</b></summary>
|
||||
@@ -435,7 +495,7 @@ vibe-trading-mcp --transport sse # 供 Web 客户端的 SSE
|
||||
|
||||
</details>
|
||||
|
||||
**已暴露的 MCP 工具(17):** `list_skills`, `load_skill`, `backtest`, `factor_analysis`, `analyze_options`, `pattern_recognition`, `get_market_data`, `web_search`, `read_url`, `read_document`, `read_file`, `write_file`, `list_swarm_presets`, `run_swarm`, `get_swarm_status`, `get_run_result`, `list_runs`。
|
||||
**已暴露的 MCP 工具(22):** `list_skills`, `load_skill`, `backtest`, `factor_analysis`, `analyze_options`, `pattern_recognition`, `get_market_data`, `web_search`, `read_url`, `read_document`, `read_file`, `write_file`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `list_swarm_presets`, `run_swarm`, `get_swarm_status`, `get_run_result`, `list_runs`。
|
||||
|
||||
<details>
|
||||
<summary><b>ClawHub 一键安装</b></summary>
|
||||
@@ -455,7 +515,7 @@ npx clawhub@latest install vibe-trading --force
|
||||
<details>
|
||||
<summary><b>OpenSpace — 自进化技能</b></summary>
|
||||
|
||||
全部 68 个金融技能已发布在 [open-space.cloud](https://open-space.cloud),并通过 OpenSpace 的自进化引擎自动演进。
|
||||
全部 74 个金融技能已发布在 [open-space.cloud](https://open-space.cloud),并通过 OpenSpace 的自进化引擎自动演进。
|
||||
|
||||
要在 OpenSpace 中使用,在代理配置中添加两个 MCP 服务器:
|
||||
|
||||
@@ -477,7 +537,7 @@ npx clawhub@latest install vibe-trading --force
|
||||
}
|
||||
```
|
||||
|
||||
OpenSpace 会自动发现全部 68 个技能,支持自动修复、自动改进与社区共享。在任意连接 OpenSpace 的代理中通过 `search_skills("finance backtest")` 搜索 Vibe-Trading 技能。
|
||||
OpenSpace 会自动发现全部 74 个技能,支持自动修复、自动改进与社区共享。在任意连接 OpenSpace 的代理中通过 `search_skills("finance backtest")` 搜索 Vibe-Trading 技能。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -493,41 +553,42 @@ Vibe-Trading/
|
||||
├── agent/ # 后端(Python)
|
||||
│ ├── cli.py # CLI 入口——交互式 TUI + 子命令
|
||||
│ ├── api_server.py # FastAPI 服务器——运行、会话、上传、swarm、SSE
|
||||
│ ├── mcp_server.py # MCP 服务器——为 OpenClaw / Claude Desktop 提供 17 个工具
|
||||
│ ├── mcp_server.py # MCP 服务器——为 OpenClaw / Claude Desktop 提供 22 个工具
|
||||
│ │
|
||||
│ ├── src/
|
||||
│ │ ├── agent/ # ReAct 代理核心
|
||||
│ │ │ ├── loop.py # 主推理循环
|
||||
│ │ │ ├── skills.py # 技能加载器(68 个 SKILL.md,7 类)
|
||||
│ │ │ ├── tools.py # 工具编排
|
||||
│ │ │ ├── context.py # 系统提示构建器
|
||||
│ │ │ ├── memory.py # 运行记忆 / artifact 存储
|
||||
│ │ │ ├── loop.py # 5 层压缩 + 读写工具批处理
|
||||
│ │ │ ├── context.py # 系统提示 + 持久记忆自动召回
|
||||
│ │ │ ├── skills.py # 技能加载器(74 内置 + 用户 CRUD 创建)
|
||||
│ │ │ ├── tools.py # 工具基类 + 注册表
|
||||
│ │ │ ├── memory.py # 单次运行轻量工作区状态
|
||||
│ │ │ ├── frontmatter.py # 共享 YAML frontmatter 解析器
|
||||
│ │ │ └── trace.py # 执行轨迹写入
|
||||
│ │ │
|
||||
│ │ ├── tools/ # 21 个代理工具
|
||||
│ │ │ ├── backtest_tool.py # 运行回测
|
||||
│ │ │ ├── factor_analysis_tool.py
|
||||
│ │ │ ├── options_pricing_tool.py
|
||||
│ │ │ ├── pattern_tool.py # 图表形态检测
|
||||
│ │ │ ├── doc_reader_tool.py # PDF 阅读(OCR 回退)
|
||||
│ │ │ ├── web_reader_tool.py # 网页读取(Jina)
|
||||
│ │ │ ├── web_search_tool.py # DuckDuckGo 搜索
|
||||
│ │ │ ├── swarm_tool.py # 启动 swarm 团队
|
||||
│ │ │ └── ... # 文件 I/O、bash、任务等
|
||||
│ │ ├── memory/ # 跨会话持久记忆
|
||||
│ │ │ └── persistent.py # 基于文件的记忆(~/.vibe-trading/memory/)
|
||||
│ │ │
|
||||
│ │ ├── skills/ # 68 个金融技能(7 类,每个 SKILL.md)
|
||||
│ │ ├── tools/ # 27 个自动发现的代理工具
|
||||
│ │ │ ├── backtest_tool.py # 运行回测
|
||||
│ │ │ ├── remember_tool.py # 跨会话记忆(保存/召回/遗忘)
|
||||
│ │ │ ├── skill_writer_tool.py # 技能 CRUD(保存/修补/删除/文件)
|
||||
│ │ │ ├── session_search_tool.py # FTS5 跨会话搜索
|
||||
│ │ │ ├── swarm_tool.py # 启动 swarm 团队
|
||||
│ │ │ ├── web_search_tool.py # DuckDuckGo 搜索
|
||||
│ │ │ └── ... # bash、文件 I/O、因子分析、期权等
|
||||
│ │ │
|
||||
│ │ ├── skills/ # 74 个金融技能(8 类,每个 SKILL.md)
|
||||
│ │ ├── swarm/ # Swarm DAG 执行引擎
|
||||
│ │ ├── session/ # 多轮对话管理
|
||||
│ │ │ └── presets/ # 29 个 swarm 预设 YAML 定义
|
||||
│ │ ├── session/ # 多轮对话 + FTS5 会话搜索
|
||||
│ │ └── providers/ # LLM 提供商抽象
|
||||
│ │
|
||||
│ ├── backtest/ # 回测引擎
|
||||
│ │ ├── engines/ # 7 个引擎:china_a、global_equity、crypto、china_futures、global_futures、forex + options_portfolio
|
||||
│ │ ├── loaders/ # 5 个数据源:tushare、okx、yfinance、akshare、ccxt
|
||||
│ │ │ ├── base.py # DataLoader Protocol
|
||||
│ │ │ └── registry.py # 注册表 + 自动回退链
|
||||
│ │ └── optimizers/ # MVO、等波动、最大分散、风险平价
|
||||
│ │
|
||||
│ └── config/swarm/ # 29 个 swarm 预设 YAML 定义
|
||||
│ └── backtest/ # 回测引擎
|
||||
│ ├── engines/ # 7 个引擎 + 跨市场复合引擎 + options_portfolio
|
||||
│ ├── loaders/ # 6 个数据源:tushare、okx、yfinance、akshare、ccxt、futu
|
||||
│ │ ├── base.py # DataLoader Protocol
|
||||
│ │ └── registry.py # 注册表 + 自动回退链
|
||||
│ └── optimizers/ # MVO、等波动、最大分散、风险平价
|
||||
│
|
||||
├── frontend/ # Web UI(React 19 + Vite + TypeScript)
|
||||
│ └── src/
|
||||
@@ -578,17 +639,14 @@ Vibe-Trading 属于 **[HKUDS](https://github.com/HKUDS)** 代理生态的一部
|
||||
|
||||
| Phase | Feature | Status |
|
||||
|-------|---------|--------|
|
||||
| **Analysis & Viz** | 期权波动率曲面与 Greeks 三维可视化 | Planned |
|
||||
| | 跨资产相关性热力图(滚动窗口 + 聚类) | Planned |
|
||||
| | CLI 回测输出中的基准对比 | Planned |
|
||||
| | 回测指标新增 Calmar Ratio 与 Omega Ratio | Planned |
|
||||
| **Skills & Presets** | 分红分析技能 | Planned |
|
||||
| | ESG / 可持续投资 swarm 预设 | Planned |
|
||||
| | 新兴市场研究台 swarm 预设 | Planned |
|
||||
| **Portfolio & Optimization** | 高级组合优化器:杠杆、行业上限、换手约束 | Planned |
|
||||
| **Future** | 新手教程:“5 分钟自然语言回测” | Planned |
|
||||
| | 通过 WebSocket 的实时数据流 | Exploring |
|
||||
| | 策略市场(分享与发现) | Exploring |
|
||||
| **Research Autopilot** | 通宵研究循环:假设 → 拉取数据 → 回测 → 证据报告 | In Progress |
|
||||
| **Data Bridge** | 自带数据接入:本地 CSV/Parquet/SQL 连接器 + schema 映射 | Planned |
|
||||
| **Options Lab** | 波动率曲面、Greeks 仪表盘、收益结构/情景探索器 | Planned |
|
||||
| **Portfolio Studio** | 风险透视、约束优化、换手感知优化器、调仓说明 | Planned |
|
||||
| **Alpha Zoo** | Alpha101 / Alpha158 / Alpha191 因子库,支持筛选与 IC 测试 | Planned |
|
||||
| **Research Delivery** | 定时研究简报推送到 Slack / Telegram / 邮件式渠道 | Planned |
|
||||
| **Trust Layer** | 可复现实验卡片:工具轨迹、数据来源、假设与引用 | Planned |
|
||||
| **Community** | 可分享的 skills、presets 与 strategy cards | Exploring |
|
||||
|
||||
---
|
||||
|
||||
@@ -622,6 +680,12 @@ MIT 许可证——参见 [LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
## Star 历史
|
||||
|
||||
[](https://star-history.com/#HKUDS/Vibe-Trading&Date)
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
感谢关注 <b>Vibe-Trading</b> ✨
|
||||
</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).
|
||||
+79
-32
@@ -1,89 +1,136 @@
|
||||
# ============================================================================
|
||||
# LLM Provider — uncomment ONE block below
|
||||
# LLM Provider — uncomment ONE provider block below
|
||||
# ============================================================================
|
||||
LANGCHAIN_TEMPERATURE=0.0
|
||||
|
||||
# --- OpenRouter ---
|
||||
# --- OpenRouter (recommended, multi-model gateway) ---
|
||||
LANGCHAIN_PROVIDER=openrouter
|
||||
LANGCHAIN_MODEL_NAME=deepseek/deepseek-v3.2
|
||||
OPENROUTER_API_KEY=sk-or-v1-your-key-here
|
||||
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
LANGCHAIN_MODEL_NAME=deepseek/deepseek-v3.2
|
||||
|
||||
# --- DeepSeek ---
|
||||
# LANGCHAIN_PROVIDER=deepseek
|
||||
# DEEPSEEK_API_KEY=sk-xxx
|
||||
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||
# LANGCHAIN_MODEL_NAME=deepseek-chat
|
||||
|
||||
# --- OpenAI ---
|
||||
# LANGCHAIN_PROVIDER=openai
|
||||
# LANGCHAIN_MODEL_NAME=gpt-4o
|
||||
# OPENAI_API_KEY=sk-xxx
|
||||
# OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
# LANGCHAIN_MODEL_NAME=gpt-4o
|
||||
|
||||
# --- OpenAI Codex (ChatGPT OAuth; not the OpenAI API key path) ---
|
||||
# Login first: vibe-trading provider login openai-codex
|
||||
# Requires a ChatGPT account with Codex access. OAuth tokens are stored by oauth-cli-kit.
|
||||
# LANGCHAIN_PROVIDER=openai-codex
|
||||
# LANGCHAIN_MODEL_NAME=openai-codex/gpt-5.3-codex
|
||||
# OPENAI_CODEX_BASE_URL=https://chatgpt.com/backend-api/codex/responses
|
||||
|
||||
# --- DeepSeek ---
|
||||
# LANGCHAIN_PROVIDER=deepseek
|
||||
# LANGCHAIN_MODEL_NAME=deepseek-chat
|
||||
# DEEPSEEK_API_KEY=sk-xxx
|
||||
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||
|
||||
# --- Gemini ---
|
||||
# LANGCHAIN_PROVIDER=gemini
|
||||
# LANGCHAIN_MODEL_NAME=gemini-2.5-flash
|
||||
# GEMINI_API_KEY=xxx
|
||||
# GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/
|
||||
# LANGCHAIN_MODEL_NAME=gemini-2.5-flash
|
||||
|
||||
# --- Groq ---
|
||||
# LANGCHAIN_PROVIDER=groq
|
||||
# LANGCHAIN_MODEL_NAME=llama-3.3-70b-versatile
|
||||
# GROQ_API_KEY=gsk_xxx
|
||||
# GROQ_BASE_URL=https://api.groq.com/openai/v1
|
||||
# LANGCHAIN_MODEL_NAME=llama-3.3-70b-versatile
|
||||
|
||||
# --- DashScope / Qwen ---
|
||||
# LANGCHAIN_PROVIDER=dashscope
|
||||
# LANGCHAIN_MODEL_NAME=qwen-plus
|
||||
# DASHSCOPE_API_KEY=sk-xxx
|
||||
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
# LANGCHAIN_MODEL_NAME=qwen-plus
|
||||
|
||||
# --- Zhipu ---
|
||||
# LANGCHAIN_PROVIDER=zhipu
|
||||
# LANGCHAIN_MODEL_NAME=glm-4-plus
|
||||
# ZHIPU_API_KEY=xxx
|
||||
# ZHIPU_BASE_URL=https://open.bigmodel.cn/api/paas/v4
|
||||
# LANGCHAIN_MODEL_NAME=glm-4-plus
|
||||
|
||||
# --- Moonshot / Kimi ---
|
||||
# LANGCHAIN_PROVIDER=moonshot
|
||||
# LANGCHAIN_MODEL_NAME=kimi-k2.5
|
||||
# MOONSHOT_API_KEY=sk-xxx
|
||||
# MOONSHOT_BASE_URL=https://api.moonshot.ai/v1
|
||||
# LANGCHAIN_MODEL_NAME=kimi-k2.5
|
||||
|
||||
# --- MiniMax ---
|
||||
# LANGCHAIN_PROVIDER=minimax
|
||||
# LANGCHAIN_MODEL_NAME=MiniMax-M2.7 # or MiniMax-M2.7-highspeed (faster)
|
||||
# MINIMAX_API_KEY=xxx
|
||||
# MINIMAX_BASE_URL=https://api.minimax.io/v1
|
||||
# LANGCHAIN_MODEL_NAME=MiniMax-Text-01
|
||||
# Note: MiniMax requires temperature > 0. Set LANGCHAIN_TEMPERATURE=1.0 (default when using MiniMax)
|
||||
|
||||
# --- Xiaomi MIMO ---
|
||||
# LANGCHAIN_PROVIDER=mimo
|
||||
# LANGCHAIN_MODEL_NAME=MiMo-72B-A27B
|
||||
# MIMO_API_KEY=xxx
|
||||
# MIMO_BASE_URL=https://api.xiaomimimo.com/v1
|
||||
# LANGCHAIN_MODEL_NAME=MiMo-72B-A27B
|
||||
|
||||
# --- Ollama ---
|
||||
# --- Z.ai (for Coding Plan) ---
|
||||
# LANGCHAIN_PROVIDER=zai
|
||||
# LANGCHAIN_MODEL_NAME=glm-5.1
|
||||
# ZAI_API_KEY=xxx
|
||||
# ZAI_BASE_URL=https://api.z.ai/api/coding/paas/v4
|
||||
|
||||
# --- Ollama (local) ---
|
||||
# LANGCHAIN_PROVIDER=ollama
|
||||
# OLLAMA_BASE_URL=http://localhost:11434/v1
|
||||
# LANGCHAIN_MODEL_NAME=qwen2.5:32b
|
||||
# OLLAMA_BASE_URL=http://localhost:11434/v1
|
||||
|
||||
# ============================================================================
|
||||
# Data Providers
|
||||
# ============================================================================
|
||||
# A-shares: Tushare Pro (requires token, https://tushare.pro)
|
||||
TUSHARE_TOKEN=your-tushare-token
|
||||
# HK/US equities: yfinance (free, no key needed)
|
||||
# Crypto: OKX public API (free, no key needed)
|
||||
|
||||
# ============================================================================
|
||||
# Runtime
|
||||
# ============================================================================
|
||||
# LLM parameters
|
||||
LANGCHAIN_TEMPERATURE=0.0
|
||||
TIMEOUT_SECONDS=120
|
||||
MAX_RETRIES=2
|
||||
|
||||
# Enable reasoning on relays that require opt-in (e.g. OpenRouter thinking models).
|
||||
# Forwarded as extra_body.reasoning.effort = low / medium / high / max.
|
||||
# Leave unset for Moonshot / DeepSeek official APIs — they return reasoning_content by default.
|
||||
# LANGCHAIN_REASONING_EFFORT=medium
|
||||
|
||||
# ============================================================================
|
||||
# Swarm / Subagent tuning (optional, defaults are sensible)
|
||||
# Data Sources
|
||||
# ============================================================================
|
||||
# A-shares: Tushare Pro token (https://tushare.pro)
|
||||
TUSHARE_TOKEN=your-tushare-token
|
||||
# HK/US equities: yfinance (free, no config needed)
|
||||
# Crypto: OKX public API (free, no config needed)
|
||||
# Crypto fallback exchange (default: binance). Change if OKX is blocked:
|
||||
# CCXT_EXCHANGE=binance
|
||||
# HK / A-share equities via Futu OpenAPI (optional, requires FutuOpenD running locally)
|
||||
# Download FutuOpenD: https://www.futunn.com/download/openAPI
|
||||
# FUTU_HOST=127.0.0.1
|
||||
# FUTU_PORT=11111
|
||||
|
||||
# ============================================================================
|
||||
# API Server (optional)
|
||||
# ============================================================================
|
||||
# Bearer token for API auth.
|
||||
# Empty dev mode is loopback-only. Set this before exposing port 8899 beyond localhost.
|
||||
# API_AUTH_KEY=
|
||||
# CORS allowed origins (comma-separated)
|
||||
# CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://localhost:8000
|
||||
# Enable session runtime
|
||||
# ENABLE_SESSION_RUNTIME=true
|
||||
# Docker compose sets this together with a 127.0.0.1 port bind so browser
|
||||
# requests from the host machine still count as local dev-mode access.
|
||||
# Do not enable this when publishing port 8899 on 0.0.0.0 without API_AUTH_KEY.
|
||||
# VIBE_TRADING_TRUST_DOCKER_LOOPBACK=0
|
||||
# Advanced local-only opt-in: expose shell execution tools to the agent.
|
||||
# VIBE_TRADING_ENABLE_SHELL_TOOLS=0
|
||||
# Optional import roots for read_document/analyze_trade_journal.
|
||||
# Use comma-separated absolute paths. Defaults include agent/uploads, agent/runs,
|
||||
# ./uploads, ./data, ~/.vibe-trading/uploads, and ~/.vibe-trading/imports.
|
||||
# VIBE_TRADING_ALLOWED_FILE_ROOTS=
|
||||
# Optional run roots for generated-code tools such as write_file/backtest.
|
||||
# Defaults include agent/runs, ./runs, and ~/.vibe-trading/shadow_runs.
|
||||
# VIBE_TRADING_ALLOWED_RUN_ROOTS=
|
||||
|
||||
# ============================================================================
|
||||
# Agent Tuning (optional, defaults are sensible)
|
||||
# ============================================================================
|
||||
# SWARM_WORKER_TIMEOUT=300
|
||||
# SWARM_WORKER_MAX_ITER=50
|
||||
|
||||
+34
-15
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: vibe-trading
|
||||
version: 0.1.4
|
||||
description: Professional finance research toolkit — backtesting (6 engines), factor analysis, options pricing, 68 finance skills, and 29 multi-agent swarm teams across 5 data sources (tushare, yfinance, okx, akshare, ccxt).
|
||||
version: 0.1.7
|
||||
description: Professional finance research toolkit — backtesting (7 engines + benchmark comparison panel), factor analysis, options pricing, 74 finance skills, 29 multi-agent swarm teams, Trade Journal analyzer, and Shadow Account (extract → backtest → render) across 6 data sources (tushare, yfinance, okx, akshare, ccxt, futu).
|
||||
dependencies:
|
||||
python: ">=3.11"
|
||||
pip:
|
||||
@@ -11,7 +11,7 @@ env:
|
||||
description: "Tushare API token for China A-share data (optional — HK/US/crypto work without any key)"
|
||||
required: false
|
||||
- name: OPENAI_API_KEY
|
||||
description: "OpenAI-compatible API key — only needed for run_swarm (multi-agent teams). All other 16 tools work without it."
|
||||
description: "OpenAI-compatible API key — only needed for run_swarm (multi-agent teams). All other tools work without it."
|
||||
required: false
|
||||
- name: LANGCHAIN_MODEL_NAME
|
||||
description: "LLM model name for run_swarm (e.g. deepseek/deepseek-v3.2). Only needed if using run_swarm."
|
||||
@@ -23,7 +23,7 @@ mcp:
|
||||
|
||||
# Vibe-Trading
|
||||
|
||||
Professional finance research toolkit with AI-powered backtesting (6 engines), multi-agent teams, and 68 specialized skills.
|
||||
Professional finance research toolkit with AI-powered backtesting (7 engines), multi-agent teams, 74 specialized skills, and the Shadow Account loop — extract your implicit trading rules from a journal, backtest them across A股/港股/美股/crypto, then see where they would have served you better.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -53,7 +53,7 @@ Add to your agent's MCP config:
|
||||
|
||||
### API Key Requirements
|
||||
|
||||
**16 of 17 MCP tools work with zero API keys.** After `pip install`, backtesting, market data, factor analysis, options pricing, chart patterns, web search, and all 68 skills are ready to use for HK/US equities and crypto.
|
||||
**21 of 22 MCP tools work with zero API keys.** After `pip install`, backtesting, market data, factor analysis, options pricing, chart patterns, web search, document reading, trade journal analysis, shadow-account extraction/backtest/report, and all 74 skills are ready to use for HK/US equities and crypto.
|
||||
|
||||
| Feature | Key needed | When |
|
||||
|---------|-----------|------|
|
||||
@@ -63,12 +63,22 @@ Add to your agent's MCP config:
|
||||
|
||||
## What You Can Do
|
||||
|
||||
### Shadow Account — flagship loop
|
||||
|
||||
Feed a CSV broker export (同花顺 / 东财 / 富途 / generic), and the agent will:
|
||||
1. `analyze_trade_journal` — profile your behavior (holding period, win rate, disposition effect, chasing, overtrading, anchoring).
|
||||
2. `extract_shadow_strategy` — distill 3-5 if-then rules that describe your profitable roundtrips.
|
||||
3. `run_shadow_backtest` — backtest those rules across A/HK/US/crypto and compute delta-PnL vs your realized trades.
|
||||
4. `render_shadow_report` — produce an HTML/PDF report (8 sections + charts) with today's matching signals.
|
||||
5. `scan_shadow_signals` — list today's symbols that match your shadow's entry cadence (research only).
|
||||
|
||||
### Backtesting
|
||||
Create and run quantitative strategies across 6 engines (ChinaA, GlobalEquity, Crypto, ChinaFutures, GlobalFutures, Forex + options) with 5 data sources:
|
||||
Create and run quantitative strategies across 7 engines (ChinaA, GlobalEquity, Crypto, ChinaFutures, GlobalFutures, Forex + options) with 6 data sources:
|
||||
- **HK/US equities** via yfinance (free, no API key)
|
||||
- **Cryptocurrency** via OKX or CCXT/100+ exchanges (free, no API key)
|
||||
- **China A-shares** via Tushare (token) or AKShare (free fallback)
|
||||
- **Futures, forex, macro** via AKShare (free, no API key)
|
||||
- **HK & A-share equities** via Futu (broker login required, optional)
|
||||
|
||||
Example workflow:
|
||||
1. Use `list_skills()` to discover strategy patterns
|
||||
@@ -89,34 +99,40 @@ Example workflow:
|
||||
|
||||
Use `list_swarm_presets()` to see all teams, then `run_swarm()` to execute.
|
||||
|
||||
### Finance Skills (68)
|
||||
### Finance Skills (74)
|
||||
Comprehensive knowledge base covering:
|
||||
- Technical analysis (candlestick, Elliott wave, Ichimoku, SMC)
|
||||
- Quantitative methods (factor research, ML strategy, pair trading)
|
||||
- Technical analysis (candlestick, Elliott wave, Ichimoku, SMC, harmonic, chanlun)
|
||||
- Quantitative methods (factor research, ML strategy, pair trading, multi-factor)
|
||||
- Risk management (VaR/CVaR, stress testing, hedging)
|
||||
- Options (Black-Scholes, Greeks, multi-leg strategies)
|
||||
- Options (Black-Scholes, Greeks, multi-leg strategies, payoff diagrams)
|
||||
- HK/US equities (SEC filings, earnings revisions, ETF flows, ADR/H-share arbitrage)
|
||||
- Crypto trading desk (funding rates, liquidation heatmaps, stablecoin flows, token unlocks, DeFi yields)
|
||||
- Behavioral finance, trade journal diagnostics, shadow account
|
||||
- Macro analysis, credit research, sector rotation, and more
|
||||
|
||||
Use `load_skill(name)` to access full methodology docs with code templates.
|
||||
|
||||
## Available MCP Tools (17)
|
||||
## Available MCP Tools (22)
|
||||
|
||||
| Tool | Description | API Key |
|
||||
|------|-------------|---------|
|
||||
| `list_skills` | List all 68 finance skills | None |
|
||||
| `list_skills` | List all 74 finance skills | None |
|
||||
| `load_skill` | Load full skill documentation | None |
|
||||
| `backtest` | Run vectorized backtest engine | None* |
|
||||
| `factor_analysis` | IC/IR analysis + layered backtest | None* |
|
||||
| `analyze_options` | Black-Scholes price + Greeks | None |
|
||||
| `pattern_recognition` | Detect chart patterns (H&S, double top, etc.) | None |
|
||||
| `get_market_data` | Fetch OHLCV data across 5 sources (auto-detect + fallback) | None* |
|
||||
| `get_market_data` | Fetch OHLCV data across 6 sources (auto-detect + fallback) | None* |
|
||||
| `web_search` | Search the web via DuckDuckGo | None |
|
||||
| `read_url` | Fetch web page as Markdown | None |
|
||||
| `read_document` | Extract text from PDF (with OCR) | None |
|
||||
| `read_document` | Extract text from PDF/DOCX/XLSX/PPTX/images | None |
|
||||
| `write_file` | Write files (config, strategy code) | None |
|
||||
| `read_file` | Read file contents | None |
|
||||
| `analyze_trade_journal` | Parse broker CSV → profile + behavior diagnostics | None |
|
||||
| `extract_shadow_strategy` | Distill 3-5 if-then rules from profitable roundtrips | None |
|
||||
| `run_shadow_backtest` | Multi-market backtest + delta-PnL attribution | None* |
|
||||
| `render_shadow_report` | HTML/PDF shadow report (8 sections + charts) | None |
|
||||
| `scan_shadow_signals` | Today's symbols matching the shadow's cadence | None |
|
||||
| `list_swarm_presets` | List multi-agent team presets | None |
|
||||
| `run_swarm` | Execute a multi-agent research team | LLM key |
|
||||
| `get_swarm_status` | Poll swarm run status without blocking | None |
|
||||
@@ -131,13 +147,16 @@ Use `load_skill(name)` to access full methodology docs with code templates.
|
||||
pip install vibe-trading-ai
|
||||
```
|
||||
|
||||
That's it — no API keys needed for HK/US/crypto markets. Start using `backtest`, `get_market_data`, `analyze_options`, `web_search`, and all 68 skills immediately.
|
||||
That's it — no API keys needed for HK/US/crypto markets. Start using `backtest`, `get_market_data`, `analyze_options`, `analyze_trade_journal`, `extract_shadow_strategy`, `web_search`, and all 74 skills immediately.
|
||||
|
||||
## Examples
|
||||
|
||||
**Backtest a MACD strategy on Apple:**
|
||||
> Backtest AAPL with MACD crossover strategy (fast=12, slow=26, signal=9) for 2024
|
||||
|
||||
**Analyze my trade journal and build a Shadow Account:**
|
||||
> Call analyze_trade_journal on ~/Downloads/tonghuashun.csv, then extract_shadow_strategy with min_support=3, then run_shadow_backtest for the last year, then render_shadow_report.
|
||||
|
||||
**Run an investment committee review:**
|
||||
> Use run_swarm with investment_committee preset to evaluate NVDA. Variables: target=NVDA.US, market=US
|
||||
|
||||
|
||||
+760
-45
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,175 @@
|
||||
"""Cross-asset correlation matrix computation.
|
||||
|
||||
Computes pairwise Pearson or Spearman correlation of daily returns
|
||||
over a configurable lookback window. Used by the /correlation API endpoint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Literal
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from scipy.stats import spearmanr
|
||||
|
||||
|
||||
def infer_market(code: str) -> str:
|
||||
"""Infer market key from a ticker symbol."""
|
||||
code_upper = code.upper()
|
||||
crypto_suffixes = ("USDT", "BTC", "ETH", "BNB", "SOL", "ADA", "DOGE")
|
||||
if any(code_upper.endswith(s) for s in crypto_suffixes) or "/" in code:
|
||||
return "crypto"
|
||||
# Check .HK suffix FIRST so leading-zero tickers like 0700.HK / 0005.HK
|
||||
# are correctly classified before the A-share prefix checks
|
||||
if code_upper.endswith(".HK"):
|
||||
return "hk_equity"
|
||||
if code_upper.startswith(("6", "000", "001", "002")):
|
||||
return "a_share"
|
||||
if code_upper.startswith(("0", "399")):
|
||||
return "a_share"
|
||||
if code_upper.startswith(("0", "1", "2", "3", "4")):
|
||||
return "hk_equity"
|
||||
return "us_equity"
|
||||
|
||||
|
||||
def _rolling_correlation_matrix(
|
||||
price_series: Dict[str, pd.DataFrame],
|
||||
window: int,
|
||||
method: Literal["pearson", "spearman"],
|
||||
) -> tuple[list[str], list[list[float]]]:
|
||||
"""Compute correlation matrix for multiple price series.
|
||||
|
||||
Args:
|
||||
price_series: Mapping of asset code -> DataFrame with a ``close`` column.
|
||||
window: Rolling window size in days.
|
||||
method: "pearson" or "spearman".
|
||||
|
||||
Returns:
|
||||
(labels, matrix) where labels is the sorted list of codes and matrix
|
||||
is a symmetric NxN matrix of correlation coefficients.
|
||||
"""
|
||||
if not price_series:
|
||||
return [], []
|
||||
|
||||
codes = sorted(price_series.keys())
|
||||
|
||||
# Build a aligned returns DataFrame (row index = date)
|
||||
returns_frames = []
|
||||
closes = {}
|
||||
for code, df in price_series.items():
|
||||
if df.empty:
|
||||
raise ValueError(f"Price series for '{code}' is empty")
|
||||
if "close" not in df.columns and "close" not in df.index.names:
|
||||
raise ValueError(f"No 'close' column in price series for '{code}'")
|
||||
# Support both column-based and index-based trade_date
|
||||
if "trade_date" in df.index.names and "trade_date" not in df.columns:
|
||||
ts = df["close"]
|
||||
else:
|
||||
ts = df.set_index("trade_date")["close"]
|
||||
closes[code] = ts.sort_index()
|
||||
|
||||
for code in codes:
|
||||
ts = closes[code]
|
||||
rets = ts.pct_change().dropna()
|
||||
rets.name = code
|
||||
returns_frames.append(rets)
|
||||
|
||||
# Align all series to a common index (inner join)
|
||||
aligned = pd.concat(returns_frames, axis=1).dropna()
|
||||
if aligned.empty:
|
||||
raise ValueError("No overlapping return data between assets")
|
||||
|
||||
# Apply the trailing window — only use the last `window` rows of aligned data
|
||||
if len(aligned) > window:
|
||||
aligned = aligned.iloc[-window:]
|
||||
|
||||
n = len(aligned)
|
||||
if n < 2:
|
||||
raise ValueError("Not enough data points to compute correlation")
|
||||
|
||||
labels = codes
|
||||
n_assets = len(labels)
|
||||
matrix = [[1.0] * n_assets for _ in range(n_assets)]
|
||||
|
||||
for i in range(n_assets):
|
||||
for j in range(i + 1, n_assets):
|
||||
xi = aligned.iloc[:, i].values
|
||||
xj = aligned.iloc[:, j].values
|
||||
if method == "spearman":
|
||||
corr, _ = spearmanr(xi, xj)
|
||||
else:
|
||||
corr = np.corrcoef(xi, xj)[0, 1]
|
||||
if np.isnan(corr):
|
||||
corr = 0.0
|
||||
matrix[i][j] = round(corr, 4)
|
||||
matrix[j][i] = round(corr, 4)
|
||||
|
||||
return labels, matrix
|
||||
|
||||
|
||||
def compute_correlation_matrix(
|
||||
codes: list[str],
|
||||
days: int = 90,
|
||||
method: Literal["pearson", "spearman"] = "pearson",
|
||||
) -> Dict[str, object]:
|
||||
"""Fetch price data and compute correlation matrix for a list of assets.
|
||||
|
||||
Args:
|
||||
codes: List of asset codes (e.g. ["BTC-USDT", "ETH-USDT", "SPY"]).
|
||||
days: Lookback window in days (default 90).
|
||||
method: Correlation method.
|
||||
|
||||
Returns:
|
||||
Dict with keys: labels, matrix, window, method.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
end_date = datetime.now().strftime("%Y-%m-%d")
|
||||
start_date = (datetime.now() - timedelta(days=days + 60)).strftime("%Y-%m-%d")
|
||||
|
||||
# Import here to avoid circular
|
||||
from backtest.loaders.registry import resolve_loader
|
||||
|
||||
price_series: Dict[str, pd.DataFrame] = {}
|
||||
|
||||
for code in codes:
|
||||
market = infer_market(code)
|
||||
try:
|
||||
loader = resolve_loader(market)
|
||||
except Exception:
|
||||
# Fall back to yfinance for us_equity / hk_equity
|
||||
try:
|
||||
from backtest.loaders.registry import LOADER_REGISTRY
|
||||
if "yfinance" in LOADER_REGISTRY:
|
||||
loader = LOADER_REGISTRY["yfinance"]()
|
||||
else:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
try:
|
||||
result = loader.fetch(
|
||||
codes=[code],
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
interval="1D",
|
||||
fields=["trade_date", "open", "high", "low", "close", "volume"],
|
||||
)
|
||||
if code in result and not result[code].empty:
|
||||
price_series[code] = result[code]
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if len(price_series) < 2:
|
||||
raise ValueError(
|
||||
f"Could not fetch price data for at least 2 assets. "
|
||||
f"Fetched: {list(price_series.keys())}"
|
||||
)
|
||||
|
||||
labels, matrix = _rolling_correlation_matrix(price_series, days, method)
|
||||
return {
|
||||
"labels": labels,
|
||||
"matrix": matrix,
|
||||
"window": days,
|
||||
"method": method,
|
||||
}
|
||||
@@ -13,12 +13,17 @@ Wave 2:
|
||||
- GlobalFuturesEngine: International futures (CME/ICE/Eurex)
|
||||
- ForexEngine: FX spot/CFD (spread, swap, high leverage)
|
||||
|
||||
Wave 3:
|
||||
- CompositeEngine: Cross-market engine with shared capital pool
|
||||
- _market_hooks: Extracted on_bar logic (funding, liquidation, swap)
|
||||
|
||||
Inheritance:
|
||||
BaseEngine
|
||||
├── ChinaAEngine
|
||||
├── GlobalEquityEngine
|
||||
├── CryptoEngine
|
||||
├── ForexEngine
|
||||
├── CompositeEngine (delegates to sub-engines as rule providers)
|
||||
└── FuturesBaseEngine
|
||||
├── ChinaFuturesEngine
|
||||
└── GlobalFuturesEngine
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Extracted per-bar market hooks as pure functions.
|
||||
|
||||
Both the original engines (CryptoEngine, ForexEngine) and CompositeEngine
|
||||
call these same functions. Zero duplication — one source of truth.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.models import Position
|
||||
|
||||
# ── Crypto: OKX tiered maintenance margin table (simplified) ──
|
||||
|
||||
_TIER_TABLE = [
|
||||
(100_000, 0.004),
|
||||
(500_000, 0.006),
|
||||
(1_000_000, 0.01),
|
||||
(5_000_000, 0.02),
|
||||
(10_000_000, 0.05),
|
||||
(float("inf"), 0.10),
|
||||
]
|
||||
|
||||
FUNDING_HOURS = {0, 8, 16}
|
||||
|
||||
|
||||
def _maintenance_rate(notional_usd: float) -> float:
|
||||
"""Look up tiered maintenance margin rate."""
|
||||
for tier_max, rate in _TIER_TABLE:
|
||||
if notional_usd <= tier_max:
|
||||
return rate
|
||||
return _TIER_TABLE[-1][1]
|
||||
|
||||
|
||||
def calc_crypto_funding_fee(
|
||||
symbol: str,
|
||||
bar: pd.Series,
|
||||
timestamp: pd.Timestamp,
|
||||
positions: Dict[str, Position],
|
||||
funding_rate: float,
|
||||
applied_set: set,
|
||||
daily_done_set: set,
|
||||
) -> float:
|
||||
"""Calculate crypto funding fee for one symbol.
|
||||
|
||||
Args:
|
||||
symbol: Instrument code.
|
||||
bar: Current bar data.
|
||||
timestamp: Bar timestamp.
|
||||
positions: Shared positions dict.
|
||||
funding_rate: Fixed rate per settlement.
|
||||
applied_set: (symbol, date, hour) dedup set — mutated.
|
||||
daily_done_set: (symbol, date) dedup set — mutated.
|
||||
|
||||
Returns:
|
||||
Fee amount (positive = longs pay, negative = longs receive).
|
||||
"""
|
||||
if not hasattr(timestamp, "date"):
|
||||
return 0.0
|
||||
|
||||
current_date = timestamp.date()
|
||||
hour = timestamp.hour if hasattr(timestamp, "hour") else 0
|
||||
|
||||
if hour in FUNDING_HOURS:
|
||||
key = (symbol, current_date, hour)
|
||||
if key in applied_set:
|
||||
return 0.0
|
||||
applied_set.add(key)
|
||||
else:
|
||||
day_key = (symbol, current_date)
|
||||
if day_key in daily_done_set:
|
||||
return 0.0
|
||||
daily_done_set.add(day_key)
|
||||
|
||||
pos = positions.get(symbol)
|
||||
if pos is None:
|
||||
return 0.0
|
||||
|
||||
mark_price = float(bar.get("close", pos.entry_price))
|
||||
notional = pos.size * mark_price
|
||||
return notional * funding_rate * pos.direction
|
||||
|
||||
|
||||
def check_crypto_liquidation(
|
||||
symbol: str,
|
||||
bar: pd.Series,
|
||||
positions: Dict[str, Position],
|
||||
) -> bool:
|
||||
"""Check if a crypto position should be liquidated.
|
||||
|
||||
Args:
|
||||
symbol: Instrument code.
|
||||
bar: Current bar data.
|
||||
positions: Shared positions dict.
|
||||
|
||||
Returns:
|
||||
True if liquidation should be triggered.
|
||||
Does NOT execute the liquidation -- caller handles that.
|
||||
"""
|
||||
pos = positions.get(symbol)
|
||||
if pos is None or pos.leverage <= 1.0:
|
||||
return False
|
||||
|
||||
mark_price = float(bar.get("close", pos.entry_price))
|
||||
margin = pos.size * pos.entry_price / pos.leverage
|
||||
unrealized = pos.direction * pos.size * (mark_price - pos.entry_price)
|
||||
|
||||
notional = pos.size * mark_price
|
||||
maint_rate = _maintenance_rate(notional)
|
||||
maint_margin = notional * maint_rate
|
||||
|
||||
return (margin + unrealized) <= maint_margin
|
||||
|
||||
|
||||
# ── Forex: swap tables ──
|
||||
|
||||
_SWAP_LONG: dict[str, float] = {
|
||||
"EUR/USD": -6.5, "GBP/USD": -3.0, "USD/JPY": 8.0, "USD/CHF": 4.0,
|
||||
"AUD/USD": -2.0, "USD/CAD": 2.0, "NZD/USD": -1.5,
|
||||
}
|
||||
_SWAP_SHORT: dict[str, float] = {
|
||||
"EUR/USD": 3.5, "GBP/USD": -1.0, "USD/JPY": -12.0, "USD/CHF": -8.0,
|
||||
"AUD/USD": -1.0, "USD/CAD": -5.0, "NZD/USD": -2.0,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_symbol(symbol: str) -> str:
|
||||
"""Normalize forex symbol to 'XXX/YYY' format."""
|
||||
s = symbol.replace(".FX", "").replace(".", "").strip()
|
||||
if "/" in s:
|
||||
return s.upper()
|
||||
if len(s) == 6:
|
||||
return f"{s[:3]}/{s[3:]}".upper()
|
||||
return s.upper()
|
||||
|
||||
|
||||
def calc_forex_swap(
|
||||
symbol: str,
|
||||
timestamp: pd.Timestamp,
|
||||
positions: Dict[str, Position],
|
||||
lot_size: float,
|
||||
last_swap_dates: dict,
|
||||
) -> float:
|
||||
"""Calculate forex swap for one symbol.
|
||||
|
||||
Args:
|
||||
symbol: Forex pair.
|
||||
timestamp: Bar timestamp.
|
||||
positions: Shared positions dict.
|
||||
lot_size: Standard lot size (e.g. 100_000).
|
||||
last_swap_dates: Per-symbol date tracking dict -- mutated.
|
||||
|
||||
Returns:
|
||||
Swap amount (positive = credit, negative = debit).
|
||||
"""
|
||||
if not hasattr(timestamp, "date"):
|
||||
return 0.0
|
||||
|
||||
current_date = timestamp.date()
|
||||
if last_swap_dates.get(symbol) == current_date:
|
||||
return 0.0
|
||||
last_swap_dates[symbol] = current_date
|
||||
|
||||
pos = positions.get(symbol)
|
||||
if pos is None:
|
||||
return 0.0
|
||||
|
||||
pair = _normalize_symbol(symbol)
|
||||
lots = pos.size / lot_size
|
||||
|
||||
if pos.direction == 1:
|
||||
swap_per_lot = _SWAP_LONG.get(pair, -1.0)
|
||||
else:
|
||||
swap_per_lot = _SWAP_SHORT.get(pair, -1.0)
|
||||
|
||||
# Wednesday = triple swap (covers Sat+Sun)
|
||||
multiplier = 3.0 if timestamp.weekday() == 2 else 1.0
|
||||
return lots * swap_per_lot * multiplier
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
@@ -18,6 +19,8 @@ from typing import Any, Callable, Dict, List, Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from backtest.metrics import (
|
||||
by_exit_reason_stats,
|
||||
by_symbol_stats,
|
||||
@@ -26,6 +29,23 @@ from backtest.metrics import (
|
||||
from backtest.models import EquitySnapshot, Position, TradeRecord
|
||||
|
||||
|
||||
# ─── Market detection (lightweight, for signal alignment only) ───
|
||||
|
||||
import re as _re
|
||||
|
||||
_CRYPTO_RE = _re.compile(r"^[A-Z]+-USDT$|^[A-Z]+/USDT$", _re.I)
|
||||
_FOREX_RE = _re.compile(r"^[A-Z]{3}/[A-Z]{3}$|^[A-Z]{6}\.FX$")
|
||||
|
||||
|
||||
def _detect_market_for_align(code: str) -> str:
|
||||
"""Lightweight market detection for ffill_limit calculation."""
|
||||
if _CRYPTO_RE.match(code):
|
||||
return "crypto"
|
||||
if _FOREX_RE.match(code):
|
||||
return "forex"
|
||||
return "equity"
|
||||
|
||||
|
||||
# ─── Signal alignment (reused from daily_portfolio logic) ───
|
||||
|
||||
|
||||
@@ -57,12 +77,28 @@ def _align(
|
||||
close = pd.DataFrame(index=dates, columns=codes, dtype=float)
|
||||
for c in codes:
|
||||
close[c] = data_map[c]["close"].reindex(dates)
|
||||
close = close.ffill().bfill()
|
||||
|
||||
# ffill with limit to avoid masking long suspensions (e.g. 3-week halt)
|
||||
# Cross-market needs larger limit (Chinese New Year can be 9-10 bars)
|
||||
ffill_limit = 10 if len({_detect_market_for_align(c) for c in codes}) > 1 else 5
|
||||
close = close.ffill(limit=ffill_limit)
|
||||
|
||||
# Drop symbols that are entirely NaN (no data overlap with date range)
|
||||
all_nan_cols = [c for c in codes if close[c].isna().all()]
|
||||
if all_nan_cols:
|
||||
logger.warning("Symbols dropped (no usable price data): %s", all_nan_cols)
|
||||
codes = [c for c in codes if c not in all_nan_cols]
|
||||
if not codes:
|
||||
raise ValueError(f"All symbols have no data in the requested date range")
|
||||
close = close[codes]
|
||||
|
||||
pos = pd.DataFrame(0.0, index=dates, columns=codes)
|
||||
for c in codes:
|
||||
raw = signal_map[c].reindex(dates).fillna(0.0).clip(-1.0, 1.0)
|
||||
pos[c] = raw.shift(1).fillna(0.0)
|
||||
# Shift on each symbol's OWN trading calendar, then ffill to unified
|
||||
own_dates = data_map[c].index
|
||||
raw = signal_map[c].reindex(own_dates).fillna(0.0).clip(-1.0, 1.0)
|
||||
shifted = raw.shift(1).fillna(0.0)
|
||||
pos[c] = shifted.reindex(dates).ffill(limit=ffill_limit).fillna(0.0)
|
||||
|
||||
ret = close.pct_change().fillna(0.0)
|
||||
|
||||
@@ -255,6 +291,9 @@ class BaseEngine(ABC):
|
||||
data_map, signal_map, valid_codes, optimizer=opt_fn,
|
||||
)
|
||||
|
||||
# Sync codes after _align may have dropped all-NaN symbols
|
||||
valid_codes = [c for c in valid_codes if c in target_pos.columns]
|
||||
|
||||
# 4. Bar-by-bar execution
|
||||
self._execute_bars(dates, data_map, close_df, target_pos, valid_codes)
|
||||
|
||||
@@ -264,6 +303,26 @@ class BaseEngine(ABC):
|
||||
index=[s.timestamp for s in self.equity_snapshots],
|
||||
)
|
||||
bench_ret = ret_df.mean(axis=1) if ret_df.shape[1] > 0 else pd.Series(0.0, index=dates)
|
||||
|
||||
# ── External benchmark fetch ──────────────────────────────────────────
|
||||
bench_ticker = config.get("benchmark")
|
||||
if bench_ticker and bench_ticker != "auto":
|
||||
from backtest.benchmark import resolve_benchmark
|
||||
bench_result = resolve_benchmark(
|
||||
strategy_codes=codes,
|
||||
source=config.get("source", "yfinance"),
|
||||
start_date=config.get("start_date", ""),
|
||||
end_date=config.get("end_date", ""),
|
||||
interval=interval,
|
||||
explicit=bench_ticker,
|
||||
)
|
||||
if bench_result is not None:
|
||||
bench_ret = bench_result.ret_series.reindex(dates).fillna(0.0)
|
||||
bench_equity = self.initial_capital * (1 + bench_ret).cumprod()
|
||||
m["benchmark_ticker"] = bench_result.ticker
|
||||
m["benchmark_return"] = bench_result.total_ret
|
||||
# ── External benchmark fetch ──────────────────────────────────────────
|
||||
|
||||
bench_equity = self.initial_capital * (1 + bench_ret).cumprod()
|
||||
|
||||
# 6. Metrics
|
||||
@@ -314,8 +373,11 @@ class BaseEngine(ABC):
|
||||
# b. Rebalance each symbol to target weight
|
||||
equity = self._calc_equity(close_df, ts)
|
||||
for c in codes:
|
||||
target_w = float(target_pos.at[ts, c]) if ts in target_pos.index else 0.0
|
||||
self._rebalance(c, target_w, data_map.get(c), ts, equity)
|
||||
try:
|
||||
target_w = float(target_pos.at[ts, c]) if ts in target_pos.index else 0.0
|
||||
self._rebalance(c, target_w, data_map.get(c), ts, equity)
|
||||
except Exception as exc:
|
||||
logger.warning("Rebalance failed for %s at %s: %s", c, ts, exc)
|
||||
|
||||
# c. Record equity snapshot
|
||||
snap_equity = self._calc_equity(close_df, ts)
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Composite cross-market backtest engine.
|
||||
|
||||
Manages a shared capital pool across multiple market engines.
|
||||
Sub-engines are used as stateless "rule books" for market-specific
|
||||
calculations (commission, slippage, lot rounding, etc.).
|
||||
All state (capital, positions, trades) lives in CompositeEngine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Dict, List
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.engines.base import BaseEngine
|
||||
from backtest.engines._market_hooks import (
|
||||
calc_crypto_funding_fee,
|
||||
check_crypto_liquidation,
|
||||
calc_forex_swap,
|
||||
)
|
||||
|
||||
|
||||
# ── Market detection (same patterns as runner.py) ──
|
||||
|
||||
_MARKET_PATTERNS = [
|
||||
(re.compile(r"^\d{6}\.(SZ|SH|BJ)$", re.I), "a_share"),
|
||||
(re.compile(r"^(51|15|56)\d{4}\.(SZ|SH)$", re.I), "a_share"),
|
||||
(re.compile(r"^[A-Z]+\.US$", re.I), "us_equity"),
|
||||
(re.compile(r"^\d{3,5}\.HK$", re.I), "hk_equity"),
|
||||
(re.compile(r"^[A-Z]+-USDT$", re.I), "crypto"),
|
||||
(re.compile(r"^[A-Z]+/USDT$", re.I), "crypto"),
|
||||
(re.compile(r"^[A-Za-z]{1,2}\d{3,4}\.(ZCE|DCE|SHFE|INE|CFFEX|GFEX)$", re.I), "futures"),
|
||||
(re.compile(r"^[A-Z]{2,4}[FGHJKMNQUVXZ]\d{1,2}$", re.I), "futures"),
|
||||
(re.compile(r"^[A-Z]{2,4}\d{4}$", re.I), "futures"),
|
||||
(re.compile(r"^[A-Z]{2,4}\.(CME|CBOT|NYMEX|COMEX|ICE|EUREX)$", re.I), "futures"),
|
||||
(re.compile(r"^[A-Z]{3}/[A-Z]{3}$"), "forex"),
|
||||
(re.compile(r"^[A-Z]{6}\.FX$"), "forex"),
|
||||
]
|
||||
|
||||
_CHINA_EXCHANGES = {"CFFEX", "SHFE", "DCE", "ZCE", "INE", "GFEX"}
|
||||
|
||||
|
||||
def _detect_market(code: str) -> str:
|
||||
"""Infer market type from symbol format."""
|
||||
for pattern, market in _MARKET_PATTERNS:
|
||||
if pattern.match(code):
|
||||
return market
|
||||
return "a_share"
|
||||
|
||||
|
||||
def _is_china_futures(code: str) -> bool:
|
||||
"""Check if futures code belongs to a Chinese exchange."""
|
||||
parts = code.upper().split(".")
|
||||
return len(parts) == 2 and parts[1] in _CHINA_EXCHANGES
|
||||
|
||||
|
||||
def _build_rule_engines(config: dict, codes: List[str]) -> Dict[str, BaseEngine]:
|
||||
"""Instantiate one sub-engine per market type detected in codes."""
|
||||
markets = {_detect_market(c) for c in codes}
|
||||
engines: Dict[str, BaseEngine] = {}
|
||||
|
||||
for market in markets:
|
||||
if market == "a_share":
|
||||
from backtest.engines.china_a import ChinaAEngine
|
||||
engines["a_share"] = ChinaAEngine(config)
|
||||
elif market == "us_equity":
|
||||
from backtest.engines.global_equity import GlobalEquityEngine
|
||||
engines["us_equity"] = GlobalEquityEngine(config, market="us")
|
||||
elif market == "hk_equity":
|
||||
from backtest.engines.global_equity import GlobalEquityEngine
|
||||
engines["hk_equity"] = GlobalEquityEngine(config, market="hk")
|
||||
elif market == "crypto":
|
||||
from backtest.engines.crypto import CryptoEngine
|
||||
engines["crypto"] = CryptoEngine(config)
|
||||
elif market == "forex":
|
||||
from backtest.engines.forex import ForexEngine
|
||||
engines["forex"] = ForexEngine(config)
|
||||
elif market == "futures":
|
||||
china = any(
|
||||
_is_china_futures(c) for c in codes if _detect_market(c) == "futures"
|
||||
)
|
||||
if china:
|
||||
from backtest.engines.china_futures import ChinaFuturesEngine
|
||||
engines["futures"] = ChinaFuturesEngine(config)
|
||||
else:
|
||||
from backtest.engines.global_futures import GlobalFuturesEngine
|
||||
engines["futures"] = GlobalFuturesEngine(config)
|
||||
|
||||
return engines
|
||||
|
||||
|
||||
class CompositeEngine(BaseEngine):
|
||||
"""Cross-market engine with shared capital pool.
|
||||
|
||||
Sub-engines are stateless rule providers. All positions, capital,
|
||||
and trades live here (inherited from BaseEngine).
|
||||
|
||||
Args:
|
||||
config: Backtest configuration dict.
|
||||
codes: List of instrument codes spanning multiple markets.
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict, codes: List[str]):
|
||||
super().__init__(config)
|
||||
|
||||
# Build symbol -> market mapping
|
||||
self._symbol_market: Dict[str, str] = {c: _detect_market(c) for c in codes}
|
||||
|
||||
# Build sub-engines (one per market type)
|
||||
self._rule_engines = _build_rule_engines(config, codes)
|
||||
|
||||
# Crypto dedup state (owned by CompositeEngine, not sub-engine)
|
||||
self._funding_applied: set = set()
|
||||
self._funding_daily_done: set = set()
|
||||
|
||||
# Forex dedup state
|
||||
self._last_swap_dates: dict = {}
|
||||
|
||||
def _rule_for(self, symbol: str) -> BaseEngine:
|
||||
"""Get the sub-engine that provides rules for this symbol."""
|
||||
market = self._symbol_market.get(symbol, "a_share")
|
||||
return self._rule_engines[market]
|
||||
|
||||
# ── Stateless method dispatch ──
|
||||
|
||||
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
|
||||
"""Market-rule check with T+1 interceptor for A-shares."""
|
||||
market = self._symbol_market.get(symbol, "a_share")
|
||||
|
||||
# T+1: intercept here because sub-engine has no access to shared positions
|
||||
if market == "a_share" and direction == 0:
|
||||
pos = self.positions.get(symbol)
|
||||
if pos is not None:
|
||||
bar_date = None
|
||||
if hasattr(bar, "name") and hasattr(bar.name, "date"):
|
||||
bar_date = bar.name.date()
|
||||
entry_date = (
|
||||
pos.entry_time.date()
|
||||
if hasattr(pos.entry_time, "date")
|
||||
else None
|
||||
)
|
||||
if bar_date and entry_date and bar_date == entry_date:
|
||||
return False
|
||||
|
||||
# Delegate remaining checks (price limits, short-sell block, etc.)
|
||||
return self._rule_for(symbol).can_execute(symbol, direction, bar)
|
||||
|
||||
def round_size(self, raw_size: float, price: float) -> float:
|
||||
"""Delegate to active symbol's sub-engine."""
|
||||
return self._rule_for(self._active_symbol).round_size(raw_size, price)
|
||||
|
||||
def calc_commission(
|
||||
self, size: float, price: float, direction: int, is_open: bool,
|
||||
) -> float:
|
||||
"""Delegate to active symbol's sub-engine."""
|
||||
return self._rule_for(self._active_symbol).calc_commission(
|
||||
size, price, direction, is_open,
|
||||
)
|
||||
|
||||
def apply_slippage(self, price: float, direction: int) -> float:
|
||||
"""Delegate to active symbol's sub-engine."""
|
||||
sub = self._rule_for(self._active_symbol)
|
||||
# ForexEngine needs _active_symbol set on the sub-engine
|
||||
sub._active_symbol = self._active_symbol
|
||||
return sub.apply_slippage(price, direction)
|
||||
|
||||
# ── PnL / margin dispatch (route by symbol, not _active_symbol) ──
|
||||
|
||||
def _calc_pnl(
|
||||
self, symbol: str, direction: int, size: float,
|
||||
entry_price: float, exit_price: float,
|
||||
) -> float:
|
||||
return self._rule_for(symbol)._calc_pnl(
|
||||
symbol, direction, size, entry_price, exit_price,
|
||||
)
|
||||
|
||||
def _calc_margin(
|
||||
self, symbol: str, size: float, price: float, leverage: float,
|
||||
) -> float:
|
||||
return self._rule_for(symbol)._calc_margin(symbol, size, price, leverage)
|
||||
|
||||
def _calc_raw_size(
|
||||
self, symbol: str, target_notional: float, price: float,
|
||||
) -> float:
|
||||
return self._rule_for(symbol)._calc_raw_size(symbol, target_notional, price)
|
||||
|
||||
# ── Stateful hooks (implemented directly, NO delegation) ──
|
||||
|
||||
def on_bar(self, symbol: str, bar: pd.Series, timestamp: pd.Timestamp) -> None:
|
||||
"""Per-bar hooks dispatched by market type."""
|
||||
market = self._symbol_market.get(symbol)
|
||||
|
||||
if market == "crypto":
|
||||
crypto_sub = self._rule_engines["crypto"]
|
||||
fee = calc_crypto_funding_fee(
|
||||
symbol, bar, timestamp, self.positions,
|
||||
crypto_sub.funding_rate,
|
||||
self._funding_applied, self._funding_daily_done,
|
||||
)
|
||||
self.capital -= fee
|
||||
|
||||
if check_crypto_liquidation(symbol, bar, self.positions):
|
||||
pos = self.positions.get(symbol)
|
||||
if pos is not None:
|
||||
mark_price = float(bar.get("close", pos.entry_price))
|
||||
liq_price = crypto_sub.apply_slippage(mark_price, -pos.direction)
|
||||
self._close_position(symbol, liq_price, timestamp, "liquidation")
|
||||
|
||||
elif market == "forex":
|
||||
forex_sub = self._rule_engines["forex"]
|
||||
if forex_sub.swap_enabled:
|
||||
swap = calc_forex_swap(
|
||||
symbol, timestamp, self.positions,
|
||||
forex_sub.lot_size, self._last_swap_dates,
|
||||
)
|
||||
self.capital += swap
|
||||
@@ -13,21 +13,10 @@ from __future__ import annotations
|
||||
import pandas as pd
|
||||
|
||||
from backtest.engines.base import BaseEngine
|
||||
|
||||
|
||||
# OKX tiered maintenance margin table (simplified)
|
||||
# (max_notional_usd, maintenance_margin_rate)
|
||||
_TIER_TABLE = [
|
||||
(100_000, 0.004),
|
||||
(500_000, 0.006),
|
||||
(1_000_000, 0.01),
|
||||
(5_000_000, 0.02),
|
||||
(10_000_000, 0.05),
|
||||
(float("inf"), 0.10),
|
||||
]
|
||||
|
||||
# Funding fee settlement hours (UTC)
|
||||
_FUNDING_HOURS = {0, 8, 16}
|
||||
from backtest.engines._market_hooks import (
|
||||
calc_crypto_funding_fee,
|
||||
check_crypto_liquidation,
|
||||
)
|
||||
|
||||
|
||||
class CryptoEngine(BaseEngine):
|
||||
@@ -70,80 +59,15 @@ class CryptoEngine(BaseEngine):
|
||||
|
||||
def on_bar(self, symbol: str, bar: pd.Series, timestamp: pd.Timestamp) -> None:
|
||||
"""Crypto per-bar hooks: funding fee + liquidation check."""
|
||||
self._apply_funding_fee(symbol, bar, timestamp)
|
||||
self._check_liquidation(symbol, bar, timestamp)
|
||||
|
||||
# ── Funding fee (exchange-enforced, every 8h) ──
|
||||
|
||||
def _apply_funding_fee(
|
||||
self, symbol: str, bar: pd.Series, timestamp: pd.Timestamp,
|
||||
) -> None:
|
||||
"""Deduct/credit funding fee at settlement hours.
|
||||
|
||||
Positive rate: longs pay shorts. Negative rate: shorts pay longs.
|
||||
|
||||
For intraday bars: applies at each 8h settlement (0/8/16 UTC).
|
||||
For daily bars: applies once per calendar day regardless of hour.
|
||||
Dedup is per-(symbol, date, hour) so multi-symbol portfolios work.
|
||||
"""
|
||||
if not hasattr(timestamp, "date"):
|
||||
return
|
||||
|
||||
current_date = timestamp.date()
|
||||
hour = timestamp.hour if hasattr(timestamp, "hour") else 0
|
||||
|
||||
if hour in _FUNDING_HOURS:
|
||||
key = (symbol, current_date, hour)
|
||||
if key in self._funding_applied:
|
||||
return
|
||||
self._funding_applied.add(key)
|
||||
else:
|
||||
# Non-settlement hour (e.g. daily bar at 12:00 UTC)
|
||||
# Apply once per day as approximation
|
||||
day_key = (symbol, current_date)
|
||||
if day_key in self._funding_daily_done:
|
||||
return
|
||||
self._funding_daily_done.add(day_key)
|
||||
|
||||
pos = self.positions.get(symbol)
|
||||
if pos is None:
|
||||
return
|
||||
|
||||
mark_price = float(bar.get("close", pos.entry_price))
|
||||
notional = pos.size * mark_price
|
||||
fee = notional * self.funding_rate * pos.direction
|
||||
fee = calc_crypto_funding_fee(
|
||||
symbol, bar, timestamp, self.positions,
|
||||
self.funding_rate, self._funding_applied, self._funding_daily_done,
|
||||
)
|
||||
self.capital -= fee
|
||||
|
||||
# ── Liquidation (exchange-enforced) ──
|
||||
|
||||
def _check_liquidation(
|
||||
self, symbol: str, bar: pd.Series, timestamp: pd.Timestamp,
|
||||
) -> None:
|
||||
"""Force-close when maintenance margin ratio drops to / below 100%."""
|
||||
pos = self.positions.get(symbol)
|
||||
if pos is None or pos.leverage <= 1.0:
|
||||
return # spot has no liquidation
|
||||
|
||||
mark_price = float(bar.get("close", pos.entry_price))
|
||||
margin = pos.size * pos.entry_price / pos.leverage
|
||||
unrealized = pos.direction * pos.size * (mark_price - pos.entry_price)
|
||||
|
||||
# Maintenance margin rate (tiered)
|
||||
notional = pos.size * mark_price
|
||||
maint_rate = self._maintenance_rate(notional)
|
||||
maint_margin = notional * maint_rate
|
||||
|
||||
# Margin ratio = (margin + unrealized) / maint_margin
|
||||
equity_in_pos = margin + unrealized
|
||||
if equity_in_pos <= maint_margin:
|
||||
# Liquidation: close at mark price with taker fee
|
||||
liq_price = self.apply_slippage(mark_price, -pos.direction)
|
||||
self._close_position(symbol, liq_price, timestamp, "liquidation")
|
||||
|
||||
@staticmethod
|
||||
def _maintenance_rate(notional_usd: float) -> float:
|
||||
"""Look up tiered maintenance margin rate."""
|
||||
for tier_max, rate in _TIER_TABLE:
|
||||
if notional_usd <= tier_max:
|
||||
return rate
|
||||
return _TIER_TABLE[-1][1]
|
||||
if check_crypto_liquidation(symbol, bar, self.positions):
|
||||
pos = self.positions.get(symbol)
|
||||
if pos is not None:
|
||||
mark_price = float(bar.get("close", pos.entry_price))
|
||||
liq_price = self.apply_slippage(mark_price, -pos.direction)
|
||||
self._close_position(symbol, liq_price, timestamp, "liquidation")
|
||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
import pandas as pd
|
||||
|
||||
from backtest.engines.base import BaseEngine
|
||||
from backtest.engines._market_hooks import calc_forex_swap
|
||||
|
||||
|
||||
# ── Typical spreads in pips (1 pip = 0.0001 for most pairs, 0.01 for JPY) ──
|
||||
@@ -33,18 +34,6 @@ _SPREAD_PIPS: dict[str, float] = {
|
||||
}
|
||||
_DEFAULT_SPREAD_PIPS = 2.0
|
||||
|
||||
# ── Daily swap points (per standard lot, in account currency) ──
|
||||
# Positive = receive, negative = pay. Varies by broker/rates; simplified table.
|
||||
|
||||
_SWAP_LONG: dict[str, float] = {
|
||||
"EUR/USD": -6.5, "GBP/USD": -3.0, "USD/JPY": 8.0, "USD/CHF": 4.0,
|
||||
"AUD/USD": -2.0, "USD/CAD": 2.0, "NZD/USD": -1.5,
|
||||
}
|
||||
_SWAP_SHORT: dict[str, float] = {
|
||||
"EUR/USD": 3.5, "GBP/USD": -1.0, "USD/JPY": -12.0, "USD/CHF": -8.0,
|
||||
"AUD/USD": -1.0, "USD/CAD": -5.0, "NZD/USD": -2.0,
|
||||
}
|
||||
|
||||
# Standard lot size
|
||||
STANDARD_LOT = 100_000
|
||||
|
||||
@@ -149,41 +138,12 @@ class ForexEngine(BaseEngine):
|
||||
"""Apply daily swap/rollover at end of trading day."""
|
||||
if not self.swap_enabled:
|
||||
return
|
||||
self._apply_swap(symbol, timestamp)
|
||||
swap = calc_forex_swap(
|
||||
symbol, timestamp, self.positions,
|
||||
self.lot_size, self._last_swap_dates,
|
||||
)
|
||||
self.capital += swap
|
||||
|
||||
def get_contract_multiplier(self, symbol: str) -> float:
|
||||
"""Forex: multiplier is 1.0 (size is in currency units)."""
|
||||
return 1.0
|
||||
|
||||
# ── Swap (overnight rollover) ──
|
||||
|
||||
def _apply_swap(self, symbol: str, timestamp: pd.Timestamp) -> None:
|
||||
"""Deduct/credit swap at daily rollover (once per calendar day).
|
||||
|
||||
Triple swap on Wednesday (covers weekend).
|
||||
"""
|
||||
if not hasattr(timestamp, "date"):
|
||||
return
|
||||
|
||||
current_date = timestamp.date()
|
||||
if self._last_swap_dates.get(symbol) == current_date:
|
||||
return
|
||||
self._last_swap_dates[symbol] = current_date
|
||||
|
||||
pos = self.positions.get(symbol)
|
||||
if pos is None:
|
||||
return
|
||||
|
||||
pair = _normalize_symbol(symbol)
|
||||
lots = pos.size / self.lot_size
|
||||
|
||||
if pos.direction == 1:
|
||||
swap_per_lot = _SWAP_LONG.get(pair, -1.0)
|
||||
else:
|
||||
swap_per_lot = _SWAP_SHORT.get(pair, -1.0)
|
||||
|
||||
# Wednesday = triple swap (covers Sat+Sun)
|
||||
multiplier = 3.0 if timestamp.weekday() == 2 else 1.0
|
||||
swap = lots * swap_per_lot * multiplier
|
||||
|
||||
self.capital += swap
|
||||
@@ -11,6 +11,7 @@ from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders.base import validate_date_range
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -38,6 +39,38 @@ def _is_crypto(code: str) -> bool:
|
||||
return "-USDT" in code.upper() or "/USDT" in code.upper()
|
||||
|
||||
|
||||
# Exchange-listed ETF / LOF prefix codes:
|
||||
# SH: 50/51/52/56/58 (ETFs), SZ: 15/16 (ETFs + LOFs).
|
||||
# Issue #50 — these symbols look like A-shares (.SH / .SZ) but stock_zh_a_hist
|
||||
# can't price them; route through fund_etf_hist_sina instead.
|
||||
_ETF_PREFIXES = frozenset({"15", "16", "50", "51", "52", "56", "58"})
|
||||
|
||||
|
||||
def _is_etf_listed(code: str) -> bool:
|
||||
"""Detect exchange-listed ETF / LOF symbols (e.g. 518880.SH, 159915.SZ)."""
|
||||
upper = code.upper()
|
||||
if not upper.endswith((".SH", ".SZ")):
|
||||
return False
|
||||
digits = upper.split(".")[0]
|
||||
if len(digits) != 6 or not digits.isdigit():
|
||||
return False
|
||||
return digits[:2] in _ETF_PREFIXES
|
||||
|
||||
|
||||
def _is_forex(code: str) -> bool:
|
||||
"""Detect forex pairs by matching against AKShare's symbol_market_map.
|
||||
|
||||
Issue #54 — forex symbols (EURUSD, GBPUSD, etc.) have no exchange suffix
|
||||
and previously fell through to the A-share endpoint.
|
||||
"""
|
||||
upper = code.upper().removesuffix(".FX")
|
||||
try:
|
||||
from akshare.forex.cons import symbol_market_map
|
||||
except Exception:
|
||||
return False
|
||||
return upper in symbol_market_map
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""AKShare universal OHLCV loader (free, no auth)."""
|
||||
@@ -78,6 +111,8 @@ class DataLoader:
|
||||
Returns:
|
||||
Mapping symbol -> OHLCV DataFrame.
|
||||
"""
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
result: Dict[str, pd.DataFrame] = {}
|
||||
for code in codes:
|
||||
try:
|
||||
@@ -94,12 +129,17 @@ class DataLoader:
|
||||
"""Fetch a single symbol."""
|
||||
import akshare as ak
|
||||
|
||||
# ETF check must precede A-share — 518880.SH ends with .SH but is an ETF.
|
||||
if _is_etf_listed(code):
|
||||
return self._fetch_etf(ak, code, start_date, end_date)
|
||||
if _is_a_share(code):
|
||||
return self._fetch_a_share(ak, code, start_date, end_date, interval)
|
||||
if _is_us(code):
|
||||
return self._fetch_us(ak, code, start_date, end_date)
|
||||
if _is_hk(code):
|
||||
return self._fetch_hk(ak, code, start_date, end_date)
|
||||
if _is_forex(code):
|
||||
return self._fetch_forex(ak, code, start_date, end_date)
|
||||
# Default: try A-share
|
||||
return self._fetch_a_share(ak, code, start_date, end_date, interval)
|
||||
|
||||
@@ -142,6 +182,49 @@ class DataLoader:
|
||||
continue
|
||||
return None
|
||||
|
||||
def _fetch_etf(self, ak, code: str, start_date: str, end_date: str) -> Optional[pd.DataFrame]:
|
||||
"""Fetch exchange-listed ETF / LOF via fund_etf_hist_sina.
|
||||
|
||||
Sina symbol format is ``sh518880`` / ``sz159915``. The endpoint returns
|
||||
the full history; we filter to the requested window after fetching.
|
||||
"""
|
||||
digits, _, suffix = code.upper().partition(".")
|
||||
symbol = f"{suffix.lower()}{digits}"
|
||||
df = ak.fund_etf_hist_sina(symbol=symbol)
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
df = self._normalize(df, date_col="date")
|
||||
# fund_etf_hist_sina returns full history — clip to window.
|
||||
return df.loc[start_date:end_date]
|
||||
|
||||
def _fetch_forex(self, ak, code: str, start_date: str, end_date: str) -> Optional[pd.DataFrame]:
|
||||
"""Fetch forex pair via forex_hist_em.
|
||||
|
||||
Columns returned are 日期 / 代码 / 名称 / 今开 / 最新价 / 最高 / 最低 / 振幅
|
||||
— note ``最新价`` (latest) plays the role of close. Volume isn't reported,
|
||||
so we synthesize a zero column to satisfy the OHLCV contract.
|
||||
"""
|
||||
symbol = code.upper().removesuffix(".FX")
|
||||
df = ak.forex_hist_em(symbol=symbol)
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
df = df.rename(columns={
|
||||
"日期": "trade_date",
|
||||
"今开": "open",
|
||||
"最新价": "close",
|
||||
"最高": "high",
|
||||
"最低": "low",
|
||||
})
|
||||
df["trade_date"] = pd.to_datetime(df["trade_date"])
|
||||
df = df.set_index("trade_date").sort_index()
|
||||
df["volume"] = 0.0
|
||||
for col in ("open", "high", "low", "close"):
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
df = df[["open", "high", "low", "close", "volume"]].dropna(
|
||||
subset=["open", "high", "low", "close"]
|
||||
)
|
||||
return df.loc[start_date:end_date]
|
||||
|
||||
def _fetch_hk(self, ak, code: str, start_date: str, end_date: str) -> Optional[pd.DataFrame]:
|
||||
"""Fetch HK stock via stock_hk_hist."""
|
||||
symbol = code.replace(".HK", "").zfill(5)
|
||||
|
||||
@@ -11,6 +11,25 @@ class NoAvailableSourceError(Exception):
|
||||
"""Raised when no data source is available for a given market."""
|
||||
|
||||
|
||||
def validate_date_range(start_date: str, end_date: str) -> None:
|
||||
"""Validate that start_date <= end_date.
|
||||
|
||||
Args:
|
||||
start_date: Start date string (YYYY-MM-DD).
|
||||
end_date: End date string (YYYY-MM-DD).
|
||||
|
||||
Raises:
|
||||
ValueError: If dates are invalid or start > end.
|
||||
"""
|
||||
try:
|
||||
start = pd.Timestamp(start_date)
|
||||
end = pd.Timestamp(end_date)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Invalid date format: start={start_date!r}, end={end_date!r}") from exc
|
||||
if start > end:
|
||||
raise ValueError(f"start_date ({start_date}) > end_date ({end_date})")
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DataLoaderProtocol(Protocol):
|
||||
"""Interface that every data source loader must satisfy."""
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders.base import validate_date_range
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -73,6 +74,8 @@ class DataLoader:
|
||||
Returns:
|
||||
Mapping symbol -> OHLCV DataFrame.
|
||||
"""
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
exchange = self._get_exchange()
|
||||
timeframe = _INTERVAL_MAP.get(interval, "1d")
|
||||
since_ms = int(pd.Timestamp(start_date).timestamp() * 1000)
|
||||
|
||||
@@ -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
|
||||
@@ -10,6 +10,7 @@ from typing import Dict, List, Optional
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
from backtest.loaders.base import validate_date_range
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
BASE_URL = "https://www.okx.com/api/v5"
|
||||
@@ -52,6 +53,8 @@ class DataLoader:
|
||||
Returns:
|
||||
Mapping symbol -> DataFrame.
|
||||
"""
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
if fields:
|
||||
print(f"[WARN] OKX ignores extra fields: {fields}")
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ def _ensure_registered() -> None:
|
||||
"backtest.loaders.yfinance_loader",
|
||||
"backtest.loaders.akshare_loader",
|
||||
"backtest.loaders.ccxt_loader",
|
||||
"backtest.loaders.futu",
|
||||
]
|
||||
import importlib
|
||||
for mod in _loader_modules:
|
||||
@@ -68,7 +69,7 @@ def _ensure_registered() -> None:
|
||||
FALLBACK_CHAINS: dict[str, list[str]] = {
|
||||
"a_share": ["tushare", "akshare"],
|
||||
"us_equity": ["yfinance", "akshare"],
|
||||
"hk_equity": ["yfinance", "akshare"],
|
||||
"hk_equity": ["yfinance", "futu", "akshare"],
|
||||
"crypto": ["okx", "ccxt"],
|
||||
"futures": ["tushare", "akshare"],
|
||||
"fund": ["tushare", "akshare"],
|
||||
@@ -98,8 +99,15 @@ def resolve_loader(market: str) -> Any:
|
||||
for name in chain:
|
||||
if name not in LOADER_REGISTRY:
|
||||
continue
|
||||
loader = LOADER_REGISTRY[name]()
|
||||
tried.append(name)
|
||||
# Issue #50 — some loaders (e.g. Tushare) call into the SDK during
|
||||
# __init__ and raise on missing credentials. Treat that the same as
|
||||
# is_available()=False so the fallback chain keeps walking.
|
||||
try:
|
||||
loader = LOADER_REGISTRY[name]()
|
||||
except Exception as exc:
|
||||
logger.debug("loader %s failed to construct: %s", name, exc)
|
||||
continue
|
||||
if loader.is_available():
|
||||
return loader
|
||||
raise NoAvailableSourceError(
|
||||
@@ -125,8 +133,12 @@ def get_loader_cls_with_fallback(source: str) -> Type[Any]:
|
||||
raise NoAvailableSourceError(f"Unknown data source: {source}")
|
||||
|
||||
loader_cls = LOADER_REGISTRY[source]
|
||||
instance = loader_cls()
|
||||
if instance.is_available():
|
||||
try:
|
||||
instance = loader_cls()
|
||||
except Exception as exc:
|
||||
logger.debug("loader %s failed to construct: %s", source, exc)
|
||||
instance = None
|
||||
if instance is not None and instance.is_available():
|
||||
return loader_cls
|
||||
|
||||
# Source unavailable — try same-market fallback
|
||||
|
||||
@@ -9,9 +9,13 @@ from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders.base import validate_date_range
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
|
||||
TUSHARE_TOKEN_PLACEHOLDERS = {"", "your-tushare-token"}
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""Tushare-backed OHLCV loader."""
|
||||
@@ -22,7 +26,7 @@ class DataLoader:
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Available when TUSHARE_TOKEN is set."""
|
||||
return bool(os.getenv("TUSHARE_TOKEN", ""))
|
||||
return os.getenv("TUSHARE_TOKEN", "").strip() not in TUSHARE_TOKEN_PLACEHOLDERS
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize Tushare pro API."""
|
||||
@@ -51,6 +55,8 @@ class DataLoader:
|
||||
Returns:
|
||||
Mapping code -> OHLCV DataFrame.
|
||||
"""
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
if interval != "1D":
|
||||
return self._fetch_minutes(codes, start_date, end_date, interval)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Dict, List, Optional, Union
|
||||
import pandas as pd
|
||||
import yfinance as yf
|
||||
|
||||
from backtest.loaders.base import validate_date_range
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
_OHLCV_COLUMNS = ["open", "high", "low", "close", "volume"]
|
||||
@@ -230,6 +231,7 @@ class DataLoader:
|
||||
del fields
|
||||
if not codes:
|
||||
return {}
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
requested_interval = str(interval or "1D").strip()
|
||||
yf_interval = _to_yfinance_interval(requested_interval)
|
||||
|
||||
@@ -147,7 +147,7 @@ def calc_metrics(
|
||||
equity_curve: pd.Series,
|
||||
trades: List[TradeRecord],
|
||||
initial_cash: float,
|
||||
bars_per_year: int = 252,
|
||||
bars_per_year: Optional[int] = 252,
|
||||
bench_ret: Optional[pd.Series] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Full set of performance metrics.
|
||||
@@ -156,7 +156,8 @@ def calc_metrics(
|
||||
equity_curve: Equity time series (index=timestamp, values=equity).
|
||||
trades: Completed round-trip trades.
|
||||
initial_cash: Starting capital.
|
||||
bars_per_year: Bars per year for annualisation.
|
||||
bars_per_year: Bars per year for annualisation. None = auto-detect
|
||||
from equity curve dates (calendar-day method, for cross-market).
|
||||
bench_ret: Benchmark per-bar return series (optional).
|
||||
|
||||
Returns:
|
||||
@@ -166,7 +167,15 @@ def calc_metrics(
|
||||
return _empty_metrics(initial_cash)
|
||||
|
||||
n = len(equity_curve)
|
||||
bpy = bars_per_year
|
||||
|
||||
# Calendar-day annualization for cross-market (bars_per_year=None)
|
||||
if bars_per_year is None:
|
||||
first, last = equity_curve.index[0], equity_curve.index[-1]
|
||||
calendar_days = (last - first).days
|
||||
years = calendar_days / 365.25 if calendar_days > 0 else 1.0
|
||||
bpy = int(n / years) if years > 0 else 252
|
||||
else:
|
||||
bpy = bars_per_year
|
||||
|
||||
port_ret = equity_curve.pct_change().fillna(0.0)
|
||||
|
||||
|
||||
+232
-3
@@ -7,13 +7,17 @@ Supports ``engine`` for backtest engine (daily/options, default daily).
|
||||
Usage: ``python -m backtest.runner <run_dir>``
|
||||
"""
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
from pydantic import BaseModel, ConfigDict, model_validator, field_validator
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
@@ -22,6 +26,7 @@ except ImportError:
|
||||
pass
|
||||
|
||||
from backtest.loaders.registry import (
|
||||
FALLBACK_CHAINS,
|
||||
LOADER_REGISTRY,
|
||||
get_loader_cls_with_fallback,
|
||||
resolve_loader,
|
||||
@@ -30,6 +35,70 @@ from backtest.loaders.base import NoAvailableSourceError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_VALID_INTERVALS = {"1m", "5m", "15m", "30m", "1H", "4H", "1D"}
|
||||
_VALID_ENGINES = {"daily", "options"}
|
||||
_VALID_SOURCES = {"tushare", "okx", "yfinance", "akshare", "ccxt", "auto"}
|
||||
|
||||
|
||||
class BacktestConfigSchema(BaseModel):
|
||||
"""Validates backtest config.json before execution."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
codes: List[str]
|
||||
start_date: str
|
||||
end_date: str
|
||||
source: str = "tushare"
|
||||
interval: str = "1D"
|
||||
engine: str = "daily"
|
||||
|
||||
@field_validator("codes")
|
||||
@classmethod
|
||||
def codes_not_empty(cls, v: List[str]) -> List[str]:
|
||||
if not v:
|
||||
raise ValueError("codes must be a non-empty list")
|
||||
if any(not c.strip() for c in v):
|
||||
raise ValueError("codes must not contain empty strings")
|
||||
return v
|
||||
|
||||
@field_validator("start_date", "end_date")
|
||||
@classmethod
|
||||
def valid_date(cls, v: str) -> str:
|
||||
try:
|
||||
pd.Timestamp(v)
|
||||
except Exception:
|
||||
raise ValueError(f"invalid date format: {v!r} (expected YYYY-MM-DD)")
|
||||
return v
|
||||
|
||||
@field_validator("interval")
|
||||
@classmethod
|
||||
def valid_interval(cls, v: str) -> str:
|
||||
if v not in _VALID_INTERVALS:
|
||||
raise ValueError(f"unsupported interval {v!r}, must be one of {_VALID_INTERVALS}")
|
||||
return v
|
||||
|
||||
@field_validator("engine")
|
||||
@classmethod
|
||||
def valid_engine(cls, v: str) -> str:
|
||||
if v not in _VALID_ENGINES:
|
||||
raise ValueError(f"unsupported engine {v!r}, must be one of {_VALID_ENGINES}")
|
||||
return v
|
||||
|
||||
@field_validator("source")
|
||||
@classmethod
|
||||
def valid_source(cls, v: str) -> str:
|
||||
if v not in _VALID_SOURCES:
|
||||
raise ValueError(f"unsupported source {v!r}, must be one of {_VALID_SOURCES}")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def start_before_end(self) -> "BacktestConfigSchema":
|
||||
if pd.Timestamp(self.start_date) > pd.Timestamp(self.end_date):
|
||||
raise ValueError(
|
||||
f"start_date ({self.start_date}) must be <= end_date ({self.end_date})"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
def _load_module_from_file(file_path: Path, module_name: str):
|
||||
"""Load a Python module from a file path via importlib.
|
||||
@@ -41,6 +110,7 @@ def _load_module_from_file(file_path: Path, module_name: str):
|
||||
Returns:
|
||||
Loaded module object.
|
||||
"""
|
||||
_validate_signal_engine_source(file_path)
|
||||
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
@@ -48,6 +118,111 @@ def _load_module_from_file(file_path: Path, module_name: str):
|
||||
return module
|
||||
|
||||
|
||||
def _is_literal_node(node: ast.AST) -> bool:
|
||||
"""Return whether an AST node is made only from literal values."""
|
||||
if isinstance(node, ast.Constant):
|
||||
return True
|
||||
if isinstance(node, (ast.Tuple, ast.List, ast.Set)):
|
||||
return all(_is_literal_node(item) for item in node.elts)
|
||||
if isinstance(node, ast.Dict):
|
||||
return all(
|
||||
(key is None or _is_literal_node(key)) and _is_literal_node(value)
|
||||
for key, value in zip(node.keys, node.values)
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _is_safe_constant_assignment(node: ast.AST) -> bool:
|
||||
"""Return whether a top-level assignment is literal-only."""
|
||||
if isinstance(node, ast.Assign):
|
||||
return _is_literal_node(node.value)
|
||||
if isinstance(node, ast.AnnAssign):
|
||||
return node.value is None or _is_literal_node(node.value)
|
||||
return False
|
||||
|
||||
|
||||
def _is_safe_reference(node: ast.AST | None) -> bool:
|
||||
"""Return whether an annotation/base expression cannot call code."""
|
||||
if node is None:
|
||||
return True
|
||||
if isinstance(node, (ast.Name, ast.Attribute, ast.Constant)):
|
||||
return True
|
||||
if isinstance(node, ast.Subscript):
|
||||
return _is_safe_reference(node.value) and _is_safe_reference(node.slice)
|
||||
if isinstance(node, ast.Tuple):
|
||||
return all(_is_safe_reference(item) for item in node.elts)
|
||||
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
|
||||
return _is_safe_reference(node.left) and _is_safe_reference(node.right)
|
||||
return False
|
||||
|
||||
|
||||
def _validate_function_def(node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
||||
"""Reject import-time execution in function definitions."""
|
||||
if node.decorator_list:
|
||||
raise ValueError(f"Decorators are not allowed on function {node.name!r}")
|
||||
for default in [*node.args.defaults, *[d for d in node.args.kw_defaults if d]]:
|
||||
if not _is_literal_node(default):
|
||||
raise ValueError(f"Non-literal default is not allowed on function {node.name!r}")
|
||||
annotations = [node.returns]
|
||||
annotations.extend(arg.annotation for arg in node.args.posonlyargs)
|
||||
annotations.extend(arg.annotation for arg in node.args.args)
|
||||
annotations.extend(arg.annotation for arg in node.args.kwonlyargs)
|
||||
annotations.append(node.args.vararg.annotation if node.args.vararg else None)
|
||||
annotations.append(node.args.kwarg.annotation if node.args.kwarg else None)
|
||||
for annotation in annotations:
|
||||
if not _is_safe_reference(annotation):
|
||||
raise ValueError(f"Unsafe annotation is not allowed on function {node.name!r}")
|
||||
|
||||
|
||||
def _validate_class_body(node: ast.ClassDef) -> None:
|
||||
"""Reject import-time execution inside class bodies."""
|
||||
if node.decorator_list:
|
||||
raise ValueError(f"Decorators are not allowed on class {node.name!r}")
|
||||
for base in node.bases:
|
||||
if not _is_safe_reference(base):
|
||||
raise ValueError(f"Unsafe base class is not allowed on class {node.name!r}")
|
||||
if node.keywords:
|
||||
raise ValueError(f"Class keywords are not allowed on class {node.name!r}")
|
||||
for child in node.body:
|
||||
if isinstance(child, ast.Expr) and isinstance(child.value, ast.Constant):
|
||||
continue
|
||||
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
_validate_function_def(child)
|
||||
continue
|
||||
if _is_safe_constant_assignment(child):
|
||||
continue
|
||||
if isinstance(child, ast.Pass):
|
||||
continue
|
||||
raise ValueError(
|
||||
f"Executable class-level statement {type(child).__name__} is not allowed"
|
||||
)
|
||||
|
||||
|
||||
def _validate_signal_engine_source(file_path: Path) -> None:
|
||||
"""Reject import-time executable statements before loading signal_engine.py."""
|
||||
try:
|
||||
tree = ast.parse(file_path.read_text(encoding="utf-8"), filename=str(file_path))
|
||||
except SyntaxError as exc:
|
||||
raise ValueError(f"Invalid signal_engine.py syntax: {exc}") from exc
|
||||
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant):
|
||||
continue
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||
continue
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
_validate_function_def(node)
|
||||
continue
|
||||
if isinstance(node, ast.ClassDef):
|
||||
_validate_class_body(node)
|
||||
continue
|
||||
if _is_safe_constant_assignment(node):
|
||||
continue
|
||||
raise ValueError(
|
||||
f"Executable top-level statement {type(node).__name__} is not allowed"
|
||||
)
|
||||
|
||||
|
||||
# --- Market detection (returns market type, NOT source name) ---
|
||||
|
||||
_MARKET_PATTERNS = [
|
||||
@@ -192,7 +367,17 @@ def main(run_dir: Path) -> None:
|
||||
print(json.dumps({"error": "config.json not found"}))
|
||||
sys.exit(1)
|
||||
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
raw_config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
|
||||
# Validate config schema
|
||||
try:
|
||||
BacktestConfigSchema(**raw_config)
|
||||
except Exception as exc:
|
||||
errors = str(exc)
|
||||
print(json.dumps({"error": f"Invalid config: {errors}"}))
|
||||
sys.exit(1)
|
||||
|
||||
config = raw_config
|
||||
source = config.get("source", "tushare")
|
||||
codes = config.get("codes", [])
|
||||
|
||||
@@ -225,6 +410,25 @@ def main(run_dir: Path) -> None:
|
||||
fields=config.get("extra_fields") or None,
|
||||
interval=interval,
|
||||
)
|
||||
# Runtime fallback: try next sources in chain when primary returns empty
|
||||
if not data_map and codes:
|
||||
market = _detect_market(codes[0])
|
||||
for fb_name in FALLBACK_CHAINS.get(market, []):
|
||||
if fb_name == source or fb_name not in LOADER_REGISTRY:
|
||||
continue
|
||||
fb_loader = LOADER_REGISTRY[fb_name]()
|
||||
if not fb_loader.is_available():
|
||||
continue
|
||||
fb_codes = _normalize_codes(codes, fb_name)
|
||||
data_map = fb_loader.fetch(
|
||||
fb_codes, config.get("start_date", ""),
|
||||
config.get("end_date", ""), interval=interval,
|
||||
)
|
||||
if data_map:
|
||||
logger.info("Runtime fallback: %s -> %s", source, fb_name)
|
||||
source = fb_name
|
||||
loader = fb_loader
|
||||
break
|
||||
if not data_map:
|
||||
print(json.dumps({"error": "No data fetched"}))
|
||||
sys.exit(1)
|
||||
@@ -236,7 +440,12 @@ def main(run_dir: Path) -> None:
|
||||
# Annualization bars
|
||||
effective_source = _detect_primary_source(codes, source)
|
||||
from backtest.metrics import calc_bars_per_year
|
||||
bars_per_year = calc_bars_per_year(interval, effective_source)
|
||||
# Cross-market: use calendar-day annualization (bars_per_year=None)
|
||||
market_types = {_detect_market(c) for c in codes}
|
||||
if len(market_types) > 1:
|
||||
bars_per_year = None
|
||||
else:
|
||||
bars_per_year = calc_bars_per_year(interval, effective_source)
|
||||
|
||||
# Auto mode: wrap preloaded data in a dummy loader
|
||||
if source == "auto":
|
||||
@@ -268,6 +477,11 @@ def _create_market_engine(source: str, config: dict, codes: List[str]):
|
||||
# Detect dominant market type from codes
|
||||
markets = {_detect_market(c) for c in codes} if codes else set()
|
||||
|
||||
# Cross-market -> CompositeEngine
|
||||
if len(markets) > 1:
|
||||
from backtest.engines.composite import CompositeEngine
|
||||
return CompositeEngine(config, codes)
|
||||
|
||||
# Futures routing (Wave 2)
|
||||
if "futures" in markets:
|
||||
# Distinguish China vs global futures by exchange suffix
|
||||
@@ -399,6 +613,21 @@ def _fetch_auto(codes: List[str], config: dict, interval: str = "1D") -> dict:
|
||||
normalized_codes = _normalize_codes(market_codes, src_name)
|
||||
fields = config.get("extra_fields") if src_name == "tushare" else None
|
||||
result = loader.fetch(normalized_codes, start_date, end_date, fields=fields, interval=interval)
|
||||
|
||||
# Runtime fallback: try remaining sources when primary returns empty
|
||||
if not result:
|
||||
for fb_name in FALLBACK_CHAINS.get(market, []):
|
||||
if fb_name == src_name or fb_name not in LOADER_REGISTRY:
|
||||
continue
|
||||
fb_loader = LOADER_REGISTRY[fb_name]()
|
||||
if not fb_loader.is_available():
|
||||
continue
|
||||
fb_codes = _normalize_codes(market_codes, fb_name)
|
||||
result = fb_loader.fetch(fb_codes, start_date, end_date, interval=interval)
|
||||
if result:
|
||||
logger.info("Runtime fallback: %s -> %s for %s", src_name, fb_name, market)
|
||||
break
|
||||
|
||||
merged.update(result)
|
||||
|
||||
return merged
|
||||
|
||||
@@ -5,7 +5,7 @@ Three independent tools:
|
||||
- Bootstrap Sharpe CI: how stable is the risk-adjusted return?
|
||||
- Walk-Forward analysis: is performance consistent across time windows?
|
||||
|
||||
Usage: called automatically by BaseEngine.run_backtest when config["validation"]
|
||||
Usage: called automatically by BaseEngine.run_backtest when config[\"validation\"]
|
||||
is present, or invoked directly on backtest outputs.
|
||||
"""
|
||||
|
||||
@@ -330,6 +330,31 @@ def _load_trades(run_dir: Path) -> List[TradeRecord]:
|
||||
return trades
|
||||
|
||||
|
||||
def _parse_run_dir(argv: List[str]) -> Path:
|
||||
"""Validate CLI input and return a usable run directory path."""
|
||||
if len(argv) < 2:
|
||||
raise SystemExit("Usage: python -m backtest.validation <run_dir>")
|
||||
|
||||
raw_run_dir = argv[1]
|
||||
if not raw_run_dir.strip():
|
||||
raise SystemExit("run_dir must be a non-empty path")
|
||||
if "\0" in raw_run_dir:
|
||||
raise SystemExit("Invalid run_dir path: embedded NUL byte")
|
||||
|
||||
try:
|
||||
run_dir = Path(raw_run_dir).expanduser()
|
||||
exists = run_dir.exists()
|
||||
is_dir = run_dir.is_dir() if exists else False
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
raise SystemExit(f"Invalid run_dir path: {exc}") from exc
|
||||
|
||||
if not exists:
|
||||
raise SystemExit(f"run_dir does not exist: {run_dir}")
|
||||
if not is_dir:
|
||||
raise SystemExit(f"run_dir is not a directory: {run_dir}")
|
||||
return run_dir
|
||||
|
||||
|
||||
def main(run_dir: Path) -> Dict[str, Any]:
|
||||
"""Run all three validations on existing backtest artifacts.
|
||||
|
||||
@@ -370,9 +395,5 @@ def main(run_dir: Path) -> Dict[str, Any]:
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python -m backtest.validation <run_dir>")
|
||||
sys.exit(1)
|
||||
main(Path(sys.argv[1]))
|
||||
main(_parse_run_dir(sys.argv))
|
||||
+491
-42
@@ -19,6 +19,7 @@ import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
@@ -51,12 +52,135 @@ EXIT_RUN_FAILED = 1
|
||||
EXIT_USAGE_ERROR = 2
|
||||
RICH_TAG_PATTERN = re.compile(r"\[/?[^\]]+\]")
|
||||
|
||||
_VERSION = "0.1.0"
|
||||
_VERSION = "0.1.7"
|
||||
|
||||
# Agent color assignments for swarm display
|
||||
_AGENT_STYLES = ["cyan", "magenta", "green", "yellow", "blue", "bright_red", "bright_cyan", "bright_magenta"]
|
||||
_agent_color_map: dict[str, str] = {}
|
||||
|
||||
_HAS_PROMPT_TOOLKIT = False
|
||||
try:
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
from prompt_toolkit.history import InMemoryHistory
|
||||
|
||||
_HAS_PROMPT_TOOLKIT = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
class _SessionStats:
|
||||
"""Mutable container for interactive session statistics.
|
||||
|
||||
Shared between the status bar renderer and the agent loop so that
|
||||
tool callbacks can update counters in-place.
|
||||
"""
|
||||
|
||||
__slots__ = ("session_start", "last_elapsed", "total_tool_ms", "tool_count")
|
||||
|
||||
def __init__(self, session_start: float) -> None:
|
||||
self.session_start = session_start
|
||||
self.last_elapsed: Optional[float] = None
|
||||
self.total_tool_ms = 0
|
||||
self.tool_count = 0
|
||||
|
||||
|
||||
def _build_status_parts(stats: _SessionStats) -> list[str]:
|
||||
"""Build plain-text status bar segments.
|
||||
|
||||
Args:
|
||||
stats: Session statistics.
|
||||
|
||||
Returns:
|
||||
List of status text segments.
|
||||
"""
|
||||
provider = os.getenv("LANGCHAIN_PROVIDER", "")
|
||||
model = os.getenv("LANGCHAIN_MODEL_NAME", "")
|
||||
model_short = model.split("/")[-1] if "/" in model else model
|
||||
label = f"{provider}/{model_short}" if provider else model_short or "unknown"
|
||||
|
||||
session_s = int(time.monotonic() - stats.session_start)
|
||||
mins, secs = divmod(session_s, 60)
|
||||
session_str = f"{mins}m{secs:02d}s" if mins else f"{secs}s"
|
||||
|
||||
parts = [label, session_str]
|
||||
|
||||
if stats.last_elapsed is not None:
|
||||
parts.append(f"last {stats.last_elapsed:.1f}s")
|
||||
|
||||
if stats.tool_count > 0:
|
||||
total_s = stats.total_tool_ms / 1000
|
||||
parts.append(f"{stats.tool_count} tools ({total_s:.1f}s)")
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
def _ptk_toolbar(stats: _SessionStats) -> FormattedText:
|
||||
"""prompt_toolkit bottom_toolbar callback — called on every render.
|
||||
|
||||
Args:
|
||||
stats: Session statistics.
|
||||
|
||||
Returns:
|
||||
FormattedText for the toolbar.
|
||||
"""
|
||||
segments = _build_status_parts(stats)
|
||||
text = " │ ".join(segments)
|
||||
return FormattedText([("class:bottom-toolbar.text", f" {text} ")])
|
||||
|
||||
|
||||
def _print_status_bar(stats: _SessionStats) -> None:
|
||||
"""Print a static status bar using Rich (fallback without prompt_toolkit).
|
||||
|
||||
Args:
|
||||
stats: Session statistics.
|
||||
"""
|
||||
parts = _build_status_parts(stats)
|
||||
bar = "[dim] │ [/dim]".join(
|
||||
f"[bold]{parts[0]}[/bold]" if i == 0 else p for i, p in enumerate(parts)
|
||||
)
|
||||
console.print(bar)
|
||||
|
||||
|
||||
def _create_prompt_session(stats: _SessionStats) -> Any:
|
||||
"""Create a prompt_toolkit PromptSession with history and live toolbar.
|
||||
|
||||
Args:
|
||||
stats: Session statistics for the live bottom toolbar.
|
||||
|
||||
Returns:
|
||||
A PromptSession instance, or None if prompt_toolkit is not available.
|
||||
"""
|
||||
if not _HAS_PROMPT_TOOLKIT:
|
||||
return None
|
||||
return PromptSession(
|
||||
history=InMemoryHistory(),
|
||||
bottom_toolbar=lambda: _ptk_toolbar(stats),
|
||||
refresh_interval=1.0,
|
||||
)
|
||||
|
||||
|
||||
def _read_input(prompt_session: Any, prompt_str: str = "> ") -> str:
|
||||
"""Read user input with arrow key support if prompt_toolkit is available.
|
||||
|
||||
Falls back to Rich Prompt.ask() when prompt_toolkit is not installed or
|
||||
when stdin is not a tty.
|
||||
|
||||
Args:
|
||||
prompt_session: A prompt_toolkit PromptSession, or None.
|
||||
prompt_str: Prompt text to display.
|
||||
|
||||
Returns:
|
||||
User input string (not stripped).
|
||||
|
||||
Raises:
|
||||
EOFError: When the user presses Ctrl-D.
|
||||
KeyboardInterrupt: When the user presses Ctrl-C.
|
||||
"""
|
||||
if prompt_session is not None and sys.stdin.isatty():
|
||||
return prompt_session.prompt(prompt_str)
|
||||
return Prompt.ask(f"[bold]{prompt_str}[/bold]")
|
||||
|
||||
|
||||
def serve_main(argv: list[str] | None = None) -> int:
|
||||
"""Delegate server startup to api_server."""
|
||||
@@ -172,17 +296,10 @@ def _format_tool_call_args(tool: str, args: Dict[str, str]) -> str:
|
||||
if tool in ("bash", "background_run"):
|
||||
cmd = args.get("command", "")[:80]
|
||||
return f' [yellow]{cmd}[/yellow]'
|
||||
if tool == "subagent":
|
||||
desc = args.get("description", args.get("prompt", "")[:60])
|
||||
return f' [magenta]{desc}[/magenta]'
|
||||
if tool == "task_create":
|
||||
return f' "{args.get("subject", "")}"'
|
||||
if tool == "task_update":
|
||||
return f' #{args.get("task_id", "")} -> {args.get("status", "")}'
|
||||
if tool == "check_background":
|
||||
tid = args.get("task_id", "")
|
||||
return f' {tid}' if tid else ""
|
||||
if tool in ("backtest", "compact", "task_list"):
|
||||
if tool in ("backtest", "compact"):
|
||||
return ""
|
||||
for v in args.values():
|
||||
if v and v != "None":
|
||||
@@ -203,15 +320,18 @@ def _format_tool_result_preview(tool: str, status: str, preview: str) -> str:
|
||||
if ret:
|
||||
parts.append(f"return={float(ret.group(1))*100:.1f}%")
|
||||
return ", ".join(parts) if parts else ""
|
||||
if tool == "render_shadow_report":
|
||||
url = re.search(r'"report_url":\s*"([^"]+)"', preview)
|
||||
if url:
|
||||
return f"[bold cyan]report:[/bold cyan] [link]{url.group(1)}[/link]"
|
||||
return ""
|
||||
if tool in ("extract_shadow_strategy", "run_shadow_backtest"):
|
||||
sid = re.search(r'"shadow_id":\s*"([^"]+)"', preview)
|
||||
return f"shadow_id={sid.group(1)}" if sid else ""
|
||||
if tool in ("bash", "background_run"):
|
||||
if "OK" in preview[:50]:
|
||||
return "OK"
|
||||
return preview[:60].replace("\n", " ")
|
||||
if tool == "subagent":
|
||||
m = re.search(r'"summary":\s*"(.{0,80})', preview)
|
||||
return m.group(1).rstrip('"') if m else ""
|
||||
if tool in ("task_create", "task_update"):
|
||||
return preview[:60].replace("\n", " ")
|
||||
if tool in ("read_file", "load_skill", "compact"):
|
||||
return ""
|
||||
return ""
|
||||
@@ -283,11 +403,15 @@ def _run_agent(
|
||||
tokens = data.get("tokens_before", "?")
|
||||
console.print(f"\n [yellow]\u27f3 context compressed[/yellow] [dim]({tokens} tokens \u2192 summary)[/dim]\n")
|
||||
|
||||
from src.memory.persistent import PersistentMemory
|
||||
|
||||
pm = PersistentMemory()
|
||||
agent = AgentLoop(
|
||||
registry=build_registry(),
|
||||
registry=build_registry(persistent_memory=pm, include_shell_tools=True),
|
||||
llm=ChatLLM(),
|
||||
event_callback=on_event,
|
||||
max_iterations=max_iter,
|
||||
persistent_memory=pm,
|
||||
)
|
||||
if run_dir_override:
|
||||
agent.memory.run_dir = run_dir_override
|
||||
@@ -295,23 +419,80 @@ def _run_agent(
|
||||
return agent.run(user_message=prompt, history=history)
|
||||
|
||||
|
||||
def _build_benchmark_table(m: dict) -> Optional[Table]:
|
||||
"""Build a benchmark comparison table from metrics dict.
|
||||
|
||||
Args:
|
||||
m: Metrics dictionary (from _read_metrics or result dict).
|
||||
|
||||
Returns:
|
||||
Rich Table, or None if no benchmark data is present.
|
||||
"""
|
||||
bench_ticker = m.get("benchmark_ticker")
|
||||
bench_ret_str = m.get("benchmark_return")
|
||||
bench_ret_raw = m.get("_benchmark_return_raw")
|
||||
|
||||
# Fall back to equity.csv if benchmark cols not in metrics.csv yet
|
||||
if not bench_ticker:
|
||||
return None
|
||||
|
||||
# Parse benchmark return
|
||||
if bench_ret_raw is not None:
|
||||
bench_ret = bench_ret_raw
|
||||
elif bench_ret_str is not None:
|
||||
try:
|
||||
bench_ret = float(bench_ret_str)
|
||||
except (ValueError, TypeError):
|
||||
bench_ret = None
|
||||
else:
|
||||
bench_ret = None
|
||||
|
||||
strategy_ret_str = m.get("total_return")
|
||||
strategy_ret = float(strategy_ret_str) if strategy_ret_str else None
|
||||
|
||||
table = Table(show_header=False, padding=(0, 2))
|
||||
table.add_column("Label", style="dim", width=20)
|
||||
table.add_column("Value", style="white no_wrap")
|
||||
|
||||
table.add_row("[dim]Benchmark[/dim]", bench_ticker)
|
||||
|
||||
if bench_ret is not None:
|
||||
table.add_row("[dim]Benchmark Return[/dim]", f"{bench_ret * 100:+.2f}%")
|
||||
|
||||
if strategy_ret is not None and bench_ret is not None:
|
||||
excess = strategy_ret - bench_ret
|
||||
sign = "+" if excess >= 0 else ""
|
||||
style = "green" if excess >= 0 else "red"
|
||||
table.add_row(
|
||||
"[dim]vs Benchmark[/dim]",
|
||||
f"[{style}]{sign}{excess * 100:+.2f}%[/{style}]",
|
||||
)
|
||||
|
||||
ir_str = m.get("information_ratio")
|
||||
if ir_str:
|
||||
table.add_row("[dim]Info Ratio[/dim]", ir_str)
|
||||
|
||||
excess_str = m.get("excess_return")
|
||||
if excess_str and excess_str != "0" and excess_str != "0.0000":
|
||||
table.add_row("[dim]Excess Return[/dim]", f"{float(excess_str) * 100:+.2f}%")
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def _print_result(result: dict, elapsed: float, *, no_rich: bool = False) -> None:
|
||||
"""Print execution result panel."""
|
||||
status = result.get("status", "unknown")
|
||||
ok = status == "success"
|
||||
style = "green" if ok else "red"
|
||||
|
||||
lines = [f"Status: [bold {style}]{status.upper()}[/bold {style}] Time: {elapsed:.1f}s"]
|
||||
|
||||
if result.get("run_id"):
|
||||
lines.append(f"ID: {result['run_id']}")
|
||||
|
||||
review = result.get("review")
|
||||
if review and review.get("overall_score") is not None:
|
||||
check = "\u2713" if review.get("passed") else "\u2717"
|
||||
lines.append(f"Review: {review['overall_score']}pts {check}")
|
||||
|
||||
run_dir = result.get("run_dir")
|
||||
m = {}
|
||||
if run_dir:
|
||||
m = _read_metrics(Path(run_dir) / "artifacts" / "metrics.csv")
|
||||
parts = [f"{k}={m[k]}" for k in ("total_return", "sharpe", "max_drawdown", "trade_count") if k in m]
|
||||
@@ -323,6 +504,17 @@ def _print_result(result: dict, elapsed: float, *, no_rich: bool = False) -> Non
|
||||
|
||||
console.print(Panel("\n".join(lines), border_style=style, title="Result"))
|
||||
|
||||
# ── Benchmark comparison panel ─────────────────────────────────────────────
|
||||
bench_table = _build_benchmark_table(m)
|
||||
if bench_table:
|
||||
console.print(Panel(
|
||||
bench_table,
|
||||
border_style="cyan",
|
||||
title="Benchmark Comparison",
|
||||
padding=(0, 1),
|
||||
))
|
||||
# ── Benchmark comparison panel ─────────────────────────────────────────
|
||||
|
||||
content = result.get("content", "").strip()
|
||||
if content:
|
||||
console.print(f"\n{content}")
|
||||
@@ -455,7 +647,9 @@ def _print_welcome() -> None:
|
||||
"\n"
|
||||
" [cyan]/skills[/cyan] List available skills [cyan]/swarm[/cyan] Agent team presets\n"
|
||||
" [cyan]/list[/cyan] Recent runs [cyan]/settings[/cyan] Runtime config\n"
|
||||
" [cyan]/help[/cyan] All commands [cyan]/quit[/cyan] Exit"
|
||||
" [cyan]/help[/cyan] All commands [cyan]/quit[/cyan] Exit\n"
|
||||
"\n"
|
||||
"[dim]Try: \"analyze my trade journal\" or \"train my shadow account\"[/dim]"
|
||||
)
|
||||
console.print(Panel(welcome, border_style="cyan", padding=(1, 2)))
|
||||
|
||||
@@ -477,6 +671,7 @@ def _print_help() -> None:
|
||||
("/continue <run_id> <prompt>", "Continue an existing run"),
|
||||
("/swarm", "List swarm team presets"),
|
||||
("/swarm run <preset> {vars}", "Run a swarm team"),
|
||||
("/swarm inspect <preset>", "Inspect preset DAG and validation"),
|
||||
("/swarm list", "List swarm run history"),
|
||||
("/swarm show <run_id>", "Show swarm run details"),
|
||||
("/swarm cancel <run_id>", "Cancel a swarm run"),
|
||||
@@ -484,6 +679,10 @@ def _print_help() -> None:
|
||||
("/settings", "Show runtime settings"),
|
||||
("/clear", "Clear screen"),
|
||||
("/quit", "Exit"),
|
||||
("", ""),
|
||||
("[dim]--- Natural language ---[/dim]", ""),
|
||||
('"analyze journal.csv"', "Parse broker export → profile + 4 behavior diagnostics"),
|
||||
('"train my shadow"', "Extract strategy → backtest → HTML/PDF report"),
|
||||
]
|
||||
for cmd, desc in cmds:
|
||||
table.add_row(cmd, desc)
|
||||
@@ -581,6 +780,11 @@ def _handle_swarm_command(arg: str) -> None:
|
||||
preset = run_parts[0]
|
||||
vars_json = run_parts[1] if len(run_parts) > 1 else None
|
||||
cmd_swarm_run_live(preset, vars_json)
|
||||
elif sub == "inspect":
|
||||
if sub_arg:
|
||||
cmd_swarm_inspect(sub_arg)
|
||||
else:
|
||||
console.print("[red]Usage: /swarm inspect <preset>[/red]")
|
||||
elif sub == "list":
|
||||
cmd_swarm_list()
|
||||
elif sub == "show":
|
||||
@@ -607,10 +811,14 @@ def cmd_interactive(max_iter: int) -> None:
|
||||
return
|
||||
|
||||
history: List[Dict[str, str]] = []
|
||||
stats = _SessionStats(session_start=time.monotonic())
|
||||
prompt_session = _create_prompt_session(stats)
|
||||
|
||||
while True:
|
||||
if prompt_session is None:
|
||||
_print_status_bar(stats)
|
||||
try:
|
||||
user_input = Prompt.ask("\n[bold]>[/bold]").strip()
|
||||
user_input = _read_input(prompt_session).strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
break
|
||||
|
||||
@@ -628,14 +836,90 @@ def cmd_interactive(max_iter: int) -> None:
|
||||
continue
|
||||
|
||||
# Natural language -> agent
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
result = _run_agent(user_input, history=history[-6:], max_iter=max_iter)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]Interrupted[/yellow]")
|
||||
continue
|
||||
from src.tools import build_registry
|
||||
from src.providers.chat import ChatLLM
|
||||
from src.agent.loop import AgentLoop
|
||||
from src.memory.persistent import PersistentMemory
|
||||
|
||||
_print_result(result, time.perf_counter() - start)
|
||||
run_start = time.perf_counter()
|
||||
run_state = {"label": "connecting"}
|
||||
pending_tool = {"name": "", "args_preview": ""}
|
||||
stop_timer = threading.Event()
|
||||
|
||||
def _status_event_callback(event_type: str, data: Dict[str, Any]) -> None:
|
||||
if event_type == "tool_result":
|
||||
stats.total_tool_ms += data.get("elapsed_ms", 0)
|
||||
stats.tool_count += 1
|
||||
run_state["label"] = "thinking"
|
||||
elif event_type == "tool_call":
|
||||
run_state["label"] = f"running {data.get('tool', '?')}"
|
||||
elif event_type == "text_delta":
|
||||
run_state["label"] = "streaming"
|
||||
elif event_type == "compact":
|
||||
run_state["label"] = "compressing context"
|
||||
_on_event_interactive(event_type, data)
|
||||
|
||||
def _on_event_interactive(event_type: str, data: Dict[str, Any]) -> None:
|
||||
if event_type == "text_delta":
|
||||
console.print(data.get("delta", ""), end="", style="dim")
|
||||
elif event_type == "thinking_done":
|
||||
console.print()
|
||||
elif event_type == "tool_call":
|
||||
pending_tool["name"] = data.get("tool", "")
|
||||
pending_tool["args_preview"] = _format_tool_call_args(
|
||||
data.get("tool", ""), data.get("arguments", {})
|
||||
)
|
||||
elif event_type == "tool_result":
|
||||
tool = data.get("tool", "") or pending_tool["name"]
|
||||
name = tool
|
||||
args_preview = pending_tool["args_preview"]
|
||||
status_str = data.get("status", "ok")
|
||||
elapsed_ms = data.get("elapsed_ms", 0)
|
||||
elapsed_s = elapsed_ms / 1000
|
||||
ok = status_str == "ok"
|
||||
mark = "[green]\u2713[/green]" if ok else "[red]\u2717[/red]"
|
||||
preview = _format_tool_result_preview(tool, status_str, data.get("preview", ""))
|
||||
suffix = f" {preview}" if preview else ""
|
||||
console.print(f" [cyan]\u25b6 {name}[/cyan]{args_preview} {mark} [dim]{elapsed_s:.1f}s[/dim]{suffix}")
|
||||
pending_tool["name"] = ""
|
||||
pending_tool["args_preview"] = ""
|
||||
elif event_type == "compact":
|
||||
tokens = data.get("tokens_before", "?")
|
||||
console.print(f"\n [yellow]\u27f3 context compressed[/yellow] [dim]({tokens} tokens \u2192 summary)[/dim]\n")
|
||||
|
||||
def _timer_loop(status_ref: Any) -> None:
|
||||
while not stop_timer.is_set():
|
||||
elapsed = time.perf_counter() - run_start
|
||||
label = run_state["label"]
|
||||
try:
|
||||
status_ref.update(f"[bold cyan]\u23f3 {label}... {elapsed:.1f}s[/bold cyan]")
|
||||
except Exception:
|
||||
pass
|
||||
stop_timer.wait(1.0)
|
||||
|
||||
with console.status("[bold cyan]\u23f3 Connecting...[/bold cyan]") as spinner:
|
||||
timer_thread = threading.Thread(target=_timer_loop, args=(spinner,), daemon=True)
|
||||
timer_thread.start()
|
||||
|
||||
try:
|
||||
pm = PersistentMemory()
|
||||
agent = AgentLoop(
|
||||
registry=build_registry(persistent_memory=pm),
|
||||
llm=ChatLLM(),
|
||||
event_callback=_status_event_callback,
|
||||
max_iterations=max_iter,
|
||||
persistent_memory=pm,
|
||||
)
|
||||
result = agent.run(user_message=user_input, history=history[-6:])
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]Interrupted[/yellow]")
|
||||
continue
|
||||
finally:
|
||||
stop_timer.set()
|
||||
timer_thread.join(timeout=1)
|
||||
|
||||
stats.last_elapsed = time.perf_counter() - run_start
|
||||
_print_result(result, stats.last_elapsed)
|
||||
history.append({"role": "user", "content": user_input})
|
||||
if result.get("content"):
|
||||
history.append({"role": "assistant", "content": result["content"]})
|
||||
@@ -849,7 +1133,12 @@ def cmd_swarm_run_live(preset: str, vars_json: Optional[str] = None) -> None:
|
||||
dashboard = _SwarmDashboard(preset, "")
|
||||
|
||||
try:
|
||||
run = runtime.start_run(preset, user_vars, live_callback=dashboard.handle_event)
|
||||
run = runtime.start_run(
|
||||
preset,
|
||||
user_vars,
|
||||
live_callback=dashboard.handle_event,
|
||||
include_shell_tools=True,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
console.print(f"[red]{exc}[/red]")
|
||||
return
|
||||
@@ -946,7 +1235,7 @@ def cmd_list(limit: int = 20) -> None:
|
||||
st = _read_json(d / "state.json").get("status", "?")
|
||||
m = _read_metrics(d / "artifacts" / "metrics.csv")
|
||||
c = "green" if st == "success" else "red" if st == "failed" else "dim"
|
||||
table.add_row(d.name, f"[{c}]{st}[/{c}]", m.get("total_return", ""), m.get("sharpe", ""), (_read_json(d / "req.json").get("prompt") or "")[:40])
|
||||
table.add_row(d.name, f"[{c}]{st.upper()}[/{c}]", m.get("total_return", ""), m.get("sharpe", ""), (_read_json(d / "req.json").get("prompt") or "")[:40])
|
||||
|
||||
console.print(table)
|
||||
|
||||
@@ -964,8 +1253,9 @@ def cmd_show(run_id: str) -> None:
|
||||
|
||||
st = state.get("status", "unknown")
|
||||
c = "green" if st == "success" else "red"
|
||||
lines = [f"[bold]Status:[/bold] [{c}]{st.upper()}[/{c}]", f"[bold]Prompt:[/bold] {req.get('prompt', '?')}"]
|
||||
|
||||
lines = [f"[bold]Status:[/bold] [{c}]{st.upper()}[/{c}]"]
|
||||
if req.get("prompt"):
|
||||
lines.append(f"[bold]Prompt:[/bold] {req['prompt'][:500]}{'...' if len(req['prompt']) > 500 else ''}")
|
||||
if metrics:
|
||||
lines.append("\n[bold]Metrics:[/bold]")
|
||||
lines.extend(f" {k}: {v}" for k, v in metrics.items())
|
||||
@@ -1125,6 +1415,78 @@ def cmd_swarm_run(preset: str, vars_json: Optional[str] = None) -> None:
|
||||
cmd_swarm_run_live(preset, vars_json)
|
||||
|
||||
|
||||
def cmd_swarm_inspect(preset: str) -> int:
|
||||
"""Inspect a swarm preset without starting workers."""
|
||||
from src.swarm.presets import inspect_preset
|
||||
|
||||
try:
|
||||
report = inspect_preset(preset)
|
||||
except FileNotFoundError as exc:
|
||||
console.print(f"[red]{exc}[/red]")
|
||||
return EXIT_USAGE_ERROR
|
||||
except Exception as exc:
|
||||
console.print(f"[red]Failed to inspect preset:[/red] {exc}")
|
||||
return EXIT_RUN_FAILED
|
||||
|
||||
status = "OK" if report["valid"] else "INVALID"
|
||||
status_color = "green" if report["valid"] else "red"
|
||||
lines = [
|
||||
f"[bold]Preset:[/bold] {report['name']}",
|
||||
f"[bold]Title:[/bold] {report.get('title') or '-'}",
|
||||
f"[bold]Status:[/bold] [{status_color}]{status}[/{status_color}]",
|
||||
f"[bold]Agents:[/bold] {len(report['agents'])}",
|
||||
f"[bold]Tasks:[/bold] {len(report['tasks'])}",
|
||||
f"[bold]Variables:[/bold] {', '.join(report['variables']) or '-'}",
|
||||
]
|
||||
if report.get("description"):
|
||||
lines.append(f"[bold]Description:[/bold] {report['description']}")
|
||||
console.print(Panel("\n".join(lines), border_style=status_color, title="Swarm Preset Inspect"))
|
||||
|
||||
agent_table = Table(title="Agents", show_lines=False)
|
||||
agent_table.add_column("ID", style="cyan", no_wrap=True)
|
||||
agent_table.add_column("Role")
|
||||
agent_table.add_column("Tools", max_width=40)
|
||||
for agent in report["agents"]:
|
||||
agent_table.add_row(
|
||||
agent["id"],
|
||||
agent.get("role", ""),
|
||||
", ".join(agent.get("tools", [])),
|
||||
)
|
||||
console.print(agent_table)
|
||||
|
||||
dag_table = Table(title="DAG Execution Plan", show_lines=False)
|
||||
dag_table.add_column("Layer", justify="right", width=6)
|
||||
dag_table.add_column("Task", style="cyan")
|
||||
dag_table.add_column("Agent")
|
||||
dag_table.add_column("Depends On")
|
||||
task_details = {task["id"]: task for task in report["tasks"]}
|
||||
for idx, layer in enumerate(report["layers"], start=1):
|
||||
for item in layer:
|
||||
task = task_details[item["task_id"]]
|
||||
dag_table.add_row(
|
||||
str(idx),
|
||||
item["task_id"],
|
||||
item["agent_id"],
|
||||
", ".join(task.get("depends_on", [])) or "-",
|
||||
)
|
||||
console.print(dag_table)
|
||||
|
||||
validation_table = Table(title="Validation", show_lines=False)
|
||||
validation_table.add_column("Level", width=8)
|
||||
validation_table.add_column("Message")
|
||||
if report["errors"]:
|
||||
for error in report["errors"]:
|
||||
validation_table.add_row("[red]ERROR[/red]", error)
|
||||
if report["warnings"]:
|
||||
for warning in report["warnings"]:
|
||||
validation_table.add_row("[yellow]WARN[/yellow]", warning)
|
||||
if not report["errors"] and not report["warnings"]:
|
||||
validation_table.add_row("[green]OK[/green]", "No issues found")
|
||||
console.print(validation_table)
|
||||
|
||||
return EXIT_SUCCESS if report["valid"] else EXIT_RUN_FAILED
|
||||
|
||||
|
||||
def cmd_swarm_list() -> None:
|
||||
"""List swarm run history."""
|
||||
from src.swarm.store import SwarmStore
|
||||
@@ -1285,22 +1647,47 @@ def cmd_session_chat(session_id: str, max_iter: int) -> None:
|
||||
border_style="cyan",
|
||||
))
|
||||
|
||||
stats = _SessionStats(session_start=time.monotonic())
|
||||
prompt_session = _create_prompt_session(stats)
|
||||
|
||||
while True:
|
||||
if prompt_session is None:
|
||||
_print_status_bar(stats)
|
||||
try:
|
||||
prompt = Prompt.ask("\n[bold]>[/bold]").strip()
|
||||
prompt = _read_input(prompt_session).strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
break
|
||||
if not prompt or prompt.lower() in ("q", "quit", "exit"):
|
||||
break
|
||||
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
result = _run_agent(prompt, history=history[-6:], max_iter=max_iter)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]Interrupted[/yellow]")
|
||||
continue
|
||||
run_start = time.perf_counter()
|
||||
_run_state = {"label": "running"}
|
||||
_stop_timer = threading.Event()
|
||||
|
||||
_print_result(result, time.perf_counter() - start)
|
||||
def _session_event_timer(status_ref: Any) -> None:
|
||||
while not _stop_timer.is_set():
|
||||
elapsed = time.perf_counter() - run_start
|
||||
label = _run_state["label"]
|
||||
try:
|
||||
status_ref.update(f"[bold cyan]\u23f3 {label}... {elapsed:.1f}s[/bold cyan]")
|
||||
except Exception:
|
||||
pass
|
||||
_stop_timer.wait(1.0)
|
||||
|
||||
with console.status("[bold cyan]\u23f3 Running...[/bold cyan]") as spinner:
|
||||
_timer = threading.Thread(target=_session_event_timer, args=(spinner,), daemon=True)
|
||||
_timer.start()
|
||||
try:
|
||||
result = _run_agent(prompt, history=history[-6:], max_iter=max_iter)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]Interrupted[/yellow]")
|
||||
continue
|
||||
finally:
|
||||
_stop_timer.set()
|
||||
_timer.join(timeout=1)
|
||||
|
||||
stats.last_elapsed = time.perf_counter() - run_start
|
||||
_print_result(result, stats.last_elapsed)
|
||||
history.append({"role": "user", "content": prompt})
|
||||
if result.get("content"):
|
||||
history.append({"role": "assistant", "content": result["content"]})
|
||||
@@ -1331,6 +1718,28 @@ def cmd_upload(file_path: str) -> None:
|
||||
console.print(f"[green]Uploaded:[/green] {dest}")
|
||||
|
||||
|
||||
def cmd_provider_login(provider: str) -> int:
|
||||
"""Authenticate OAuth-backed LLM providers."""
|
||||
normalized = provider.strip().lower().replace("_", "-")
|
||||
if normalized != "openai-codex":
|
||||
console.print("[red]Unknown OAuth provider.[/red] Supported: openai-codex")
|
||||
return EXIT_USAGE_ERROR
|
||||
try:
|
||||
from src.providers.openai_codex import login_openai_codex
|
||||
|
||||
console.print("[cyan]Starting OpenAI Codex OAuth login...[/cyan]\n")
|
||||
token = login_openai_codex(
|
||||
print_fn=lambda text: console.print(text),
|
||||
prompt_fn=lambda text: Prompt.ask(text),
|
||||
)
|
||||
account = getattr(token, "account_id", None) or "ChatGPT"
|
||||
console.print(f"[green]Authenticated with OpenAI Codex[/green] [dim]{account}[/dim]")
|
||||
return EXIT_SUCCESS
|
||||
except Exception as exc:
|
||||
console.print(f"[red]Authentication error:[/red] {exc}")
|
||||
return EXIT_RUN_FAILED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI entrypoint
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1338,6 +1747,7 @@ def cmd_upload(file_path: str) -> None:
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
"""Build the CLI parser with subcommands and compatibility flags."""
|
||||
parser = argparse.ArgumentParser(description="Vibe-Trading CLI")
|
||||
parser.add_argument("--version", action="version", version=f"vibe-trading {_VERSION}")
|
||||
parser.add_argument("-p", "--prompt", type=str, help="Prompt text")
|
||||
parser.add_argument("-f", "--prompt-file", type=Path, help="Read prompt text from a file")
|
||||
parser.add_argument("--json", action="store_true", help="Print machine-readable JSON output")
|
||||
@@ -1353,6 +1763,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument("--max-iter", type=int, default=50, help="Maximum agent iterations")
|
||||
|
||||
parser.add_argument("--swarm-presets", action="store_true", help="List swarm presets")
|
||||
parser.add_argument("--swarm-inspect", metavar="PRESET", help="Inspect a swarm preset without running it")
|
||||
parser.add_argument("--swarm-run", nargs="+", metavar=("PRESET", "VARS"), help="Run a swarm preset")
|
||||
parser.add_argument("--swarm-list", action="store_true", help="List swarm runs")
|
||||
parser.add_argument("--swarm-show", metavar="RUN_ID", help="Show a swarm run")
|
||||
@@ -1376,6 +1787,11 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
serve_parser.add_argument("--port", type=int, default=8000, help="Listen port")
|
||||
serve_parser.add_argument("--dev", action="store_true", help="Start the Vite dev server")
|
||||
|
||||
provider_parser = subparsers.add_parser("provider", help="Manage OAuth providers")
|
||||
provider_subparsers = provider_parser.add_subparsers(dest="provider_command")
|
||||
login_parser = provider_subparsers.add_parser("login", help="Authenticate with an OAuth provider")
|
||||
login_parser.add_argument("provider", help="OAuth provider name, e.g. openai-codex")
|
||||
|
||||
list_parser = subparsers.add_parser("list", help="List runs")
|
||||
list_parser.add_argument("--limit", dest="list_limit", type=int, default=20, help="Maximum number of runs")
|
||||
|
||||
@@ -1519,6 +1935,16 @@ _PROVIDER_CHOICES: list[dict[str, str | None]] = [
|
||||
"key_prefix": None,
|
||||
"key_placeholder": "api-key...",
|
||||
},
|
||||
{
|
||||
"label": "Z.ai (Coding platform)",
|
||||
"provider": "zai",
|
||||
"key_env": "ZAI_API_KEY",
|
||||
"base_env": "ZAI_BASE_URL",
|
||||
"base_url": "https://api.z.ai/api/coding/paas/v4",
|
||||
"model": "glm-5.1",
|
||||
"key_prefix": None,
|
||||
"key_placeholder": "api-key...",
|
||||
},
|
||||
{
|
||||
"label": "Ollama (local, free)",
|
||||
"provider": "ollama",
|
||||
@@ -1529,6 +1955,16 @@ _PROVIDER_CHOICES: list[dict[str, str | None]] = [
|
||||
"key_prefix": None,
|
||||
"key_placeholder": None,
|
||||
},
|
||||
{
|
||||
"label": "OpenAI Codex (ChatGPT OAuth)",
|
||||
"provider": "openai-codex",
|
||||
"key_env": None,
|
||||
"base_env": "OPENAI_CODEX_BASE_URL",
|
||||
"base_url": "https://chatgpt.com/backend-api/codex/responses",
|
||||
"model": "openai-codex/gpt-5.3-codex",
|
||||
"key_prefix": None,
|
||||
"key_placeholder": None,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -1550,6 +1986,7 @@ def _render_env_content(config: dict[str, str]) -> str:
|
||||
"DEEPSEEK_BASE_URL",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_BASE_URL",
|
||||
"OPENAI_CODEX_BASE_URL",
|
||||
"GEMINI_API_KEY",
|
||||
"GEMINI_BASE_URL",
|
||||
"GROQ_API_KEY",
|
||||
@@ -1564,6 +2001,8 @@ def _render_env_content(config: dict[str, str]) -> str:
|
||||
"MINIMAX_BASE_URL",
|
||||
"MIMO_API_KEY",
|
||||
"MIMO_BASE_URL",
|
||||
"ZAI_API_KEY",
|
||||
"ZAI_BASE_URL",
|
||||
"OLLAMA_BASE_URL",
|
||||
"LANGCHAIN_MODEL_NAME",
|
||||
"TUSHARE_TOKEN",
|
||||
@@ -1630,6 +2069,9 @@ def cmd_init() -> int:
|
||||
console.print(
|
||||
f"[red]That key doesn't look right.[/red] Expected it to start with [bold]{key_prefix}[/bold]."
|
||||
)
|
||||
elif provider == "openai-codex":
|
||||
console.print("[dim]OpenAI Codex uses ChatGPT OAuth, not an API key.[/dim]")
|
||||
console.print("[dim]After setup, run: vibe-trading provider login openai-codex[/dim]")
|
||||
else:
|
||||
console.print("[dim]Ollama does not require an API key.[/dim]")
|
||||
|
||||
@@ -1673,6 +2115,11 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return cmd_init()
|
||||
if args.command == "serve":
|
||||
return serve_main(raw_argv[1:])
|
||||
if args.command == "provider":
|
||||
if args.provider_command == "login":
|
||||
return cmd_provider_login(args.provider)
|
||||
console.print("[red]provider requires a subcommand.[/red] Try: vibe-trading provider login openai-codex")
|
||||
return EXIT_USAGE_ERROR
|
||||
if args.command == "run":
|
||||
return _handle_prompt_command(
|
||||
args.run_prompt,
|
||||
@@ -1684,7 +2131,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if args.command == "list":
|
||||
return _coerce_exit_code(cmd_list(args.list_limit))
|
||||
if args.command == "show":
|
||||
return _coerce_exit_code(cmd_show(args.run_id))
|
||||
return _coerce_exit_code(cmd_show(args.show))
|
||||
if args.command == "chat":
|
||||
return _coerce_exit_code(cmd_interactive(args.chat_max_iter))
|
||||
|
||||
@@ -1703,6 +2150,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
if args.swarm_presets:
|
||||
return _coerce_exit_code(cmd_swarm_presets())
|
||||
if args.swarm_inspect:
|
||||
return _coerce_exit_code(cmd_swarm_inspect(args.swarm_inspect))
|
||||
if args.swarm_run:
|
||||
preset_name = args.swarm_run[0]
|
||||
vars_json = args.swarm_run[1] if len(args.swarm_run) > 1 else None
|
||||
@@ -1723,7 +2172,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if args.chat:
|
||||
return _coerce_exit_code(cmd_interactive(args.max_iter))
|
||||
if args.cont:
|
||||
return cmd_continue(args.cont[0], args.cont[1], args.max_iter, json_mode=args.json, no_rich=args.no_rich)
|
||||
return _coerce_exit_code(cmd_continue(args.cont[0], args.cont[1], args.max_iter, json_mode=args.json, no_rich=args.no_rich))
|
||||
|
||||
# No flags, no subcommand → check if prompt provided, otherwise interactive mode
|
||||
if args.prompt or args.prompt_file or not sys.stdin.isatty():
|
||||
|
||||
+163
-4
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Vibe-Trading MCP Server — expose 17 finance research tools to any MCP client.
|
||||
"""Vibe-Trading MCP Server — expose 22 finance research tools to any MCP client.
|
||||
|
||||
Works with OpenClaw, Claude Desktop, Cursor, and any MCP-compatible client.
|
||||
Zero API key required for HK/US/crypto markets (yfinance, OKX, AKShare are free).
|
||||
@@ -49,6 +49,12 @@ mcp = FastMCP("Vibe-Trading")
|
||||
|
||||
_skills_loader = None
|
||||
_registry = None
|
||||
_include_shell_tools = True
|
||||
|
||||
|
||||
def _env_shell_tools_enabled() -> bool:
|
||||
"""Return whether shell tools were explicitly enabled for network MCP."""
|
||||
return os.getenv("VIBE_TRADING_ENABLE_SHELL_TOOLS", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _get_skills_loader():
|
||||
@@ -63,7 +69,7 @@ def _get_registry():
|
||||
global _registry
|
||||
if _registry is None:
|
||||
from src.tools import build_registry
|
||||
_registry = build_registry()
|
||||
_registry = build_registry(include_shell_tools=_include_shell_tools)
|
||||
return _registry
|
||||
|
||||
|
||||
@@ -75,7 +81,7 @@ def _get_registry():
|
||||
def list_skills() -> str:
|
||||
"""List all available finance skills with names and descriptions.
|
||||
|
||||
Returns a JSON array of {name, description} for all 68 skills.
|
||||
Returns a JSON array of {name, description} for all loaded skills.
|
||||
Use load_skill(name) to get the full documentation for any skill.
|
||||
"""
|
||||
loader = _get_skills_loader()
|
||||
@@ -213,7 +219,7 @@ def pattern_recognition(run_dir: str) -> str:
|
||||
run_dir: Path to run directory containing artifacts/ohlcv_*.csv.
|
||||
"""
|
||||
registry = _get_registry()
|
||||
return registry.execute("pattern_recognition", {"run_dir": run_dir})
|
||||
return registry.execute("pattern", {"run_dir": run_dir})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -537,12 +543,163 @@ def list_runs(limit: int = 20) -> str:
|
||||
return json.dumps(items, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trade journal tool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@mcp.tool
|
||||
def analyze_trade_journal(
|
||||
file_path: str,
|
||||
analysis_type: str = "full",
|
||||
filter_expr: str = "",
|
||||
) -> str:
|
||||
"""Analyze a user's trade journal (CSV/Excel broker export) and return
|
||||
a trading profile plus behavior diagnostics.
|
||||
|
||||
Parses 同花顺 / 东方财富 / 富途 / generic formats (encoding auto-detected).
|
||||
Output (JSON):
|
||||
- profile: holding days, frequency, win rate, PnL ratio, top symbols,
|
||||
market distribution, hourly distribution
|
||||
- behaviors: disposition effect, overtrading, chasing momentum,
|
||||
anchoring (each with severity + numeric evidence)
|
||||
|
||||
Args:
|
||||
file_path: Absolute path to the uploaded CSV/Excel file.
|
||||
analysis_type: "full" | "profile" | "behavior" | "strategy".
|
||||
filter_expr: Optional filter (e.g. "2026-01 to 2026-03",
|
||||
"symbol=600519.SH", "market=china_a").
|
||||
"""
|
||||
registry = _get_registry()
|
||||
return registry.execute("analyze_trade_journal", {
|
||||
"file_path": file_path,
|
||||
"analysis_type": analysis_type,
|
||||
"filter_expr": filter_expr,
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shadow Account tools (4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@mcp.tool
|
||||
def extract_shadow_strategy(
|
||||
journal_path: str,
|
||||
min_support: int = 3,
|
||||
max_rules: int = 5,
|
||||
) -> str:
|
||||
"""Extract a Shadow Account profile (3-5 human-readable if-then rules)
|
||||
from the user's profitable roundtrips in a trade journal.
|
||||
|
||||
Run `analyze_trade_journal` first if the journal hasn't been parsed.
|
||||
Returns shadow_id + rules preview. Profile persists to
|
||||
~/.vibe-trading/shadow_accounts/.
|
||||
|
||||
Args:
|
||||
journal_path: Path to the CSV/Excel broker export.
|
||||
min_support: Minimum profitable roundtrips required to back one rule.
|
||||
max_rules: Maximum rules to return (typically 3-5).
|
||||
"""
|
||||
registry = _get_registry()
|
||||
return registry.execute("extract_shadow_strategy", {
|
||||
"journal_path": journal_path,
|
||||
"min_support": min_support,
|
||||
"max_rules": max_rules,
|
||||
})
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def run_shadow_backtest(
|
||||
shadow_id: str,
|
||||
window_start: str = "",
|
||||
window_end: str = "",
|
||||
markets: list[str] | None = None,
|
||||
journal_path: str = "",
|
||||
) -> str:
|
||||
"""Run a multi-market backtest (A股/港股/美股/crypto) on a Shadow Account
|
||||
profile and compute delta-PnL attribution vs the user's realized trades.
|
||||
|
||||
Requires `extract_shadow_strategy` to have run first.
|
||||
|
||||
Args:
|
||||
shadow_id: ID returned by extract_shadow_strategy.
|
||||
window_start: ISO date, default today-1y.
|
||||
window_end: ISO date, default today.
|
||||
markets: Subset of ["china_a", "hk", "us", "crypto"], default all four.
|
||||
journal_path: Original journal path (enables attribution), optional.
|
||||
"""
|
||||
registry = _get_registry()
|
||||
params: dict[str, Any] = {"shadow_id": shadow_id}
|
||||
if window_start:
|
||||
params["window_start"] = window_start
|
||||
if window_end:
|
||||
params["window_end"] = window_end
|
||||
if markets:
|
||||
params["markets"] = markets
|
||||
if journal_path:
|
||||
params["journal_path"] = journal_path
|
||||
return registry.execute("run_shadow_backtest", params)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def render_shadow_report(
|
||||
shadow_id: str,
|
||||
include_today_signals: bool = True,
|
||||
window_start: str = "",
|
||||
window_end: str = "",
|
||||
journal_path: str = "",
|
||||
) -> str:
|
||||
"""Render the Shadow Account HTML/PDF report (8 sections + charts) for
|
||||
a shadow_id. If no cached backtest, one is run automatically.
|
||||
|
||||
Args:
|
||||
shadow_id: Shadow Account ID.
|
||||
include_today_signals: Include today's market scan section.
|
||||
window_start: Optional backtest window override.
|
||||
window_end: Optional backtest window override.
|
||||
journal_path: Original journal path (for attribution), optional.
|
||||
"""
|
||||
registry = _get_registry()
|
||||
params: dict[str, Any] = {
|
||||
"shadow_id": shadow_id,
|
||||
"include_today_signals": include_today_signals,
|
||||
}
|
||||
if window_start:
|
||||
params["window_start"] = window_start
|
||||
if window_end:
|
||||
params["window_end"] = window_end
|
||||
if journal_path:
|
||||
params["journal_path"] = journal_path
|
||||
return registry.execute("render_shadow_report", params)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def scan_shadow_signals(
|
||||
shadow_id: str,
|
||||
date: str = "",
|
||||
per_market: int = 3,
|
||||
) -> str:
|
||||
"""List today's symbols that match the Shadow Account's entry cadence
|
||||
(research use only — not a trade recommendation).
|
||||
|
||||
Args:
|
||||
shadow_id: Shadow Account ID.
|
||||
date: ISO YYYY-MM-DD target date, default today.
|
||||
per_market: Max signals per market.
|
||||
"""
|
||||
registry = _get_registry()
|
||||
params: dict[str, Any] = {"shadow_id": shadow_id, "per_market": per_market}
|
||||
if date:
|
||||
params["date"] = date
|
||||
return registry.execute("scan_shadow_signals", params)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
"""Entry point for `vibe-trading-mcp` CLI command."""
|
||||
global _include_shell_tools, _registry
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Vibe-Trading MCP Server")
|
||||
@@ -551,6 +708,8 @@ def main():
|
||||
parser.add_argument("--port", type=int, default=8900,
|
||||
help="SSE port (only used with --transport sse)")
|
||||
args = parser.parse_args()
|
||||
_include_shell_tools = True if args.transport == "stdio" else _env_shell_tools_enabled()
|
||||
_registry = None
|
||||
|
||||
if args.transport == "sse":
|
||||
mcp.run(transport="sse", port=args.port)
|
||||
|
||||
+18
-1
@@ -7,6 +7,8 @@ langchain-openai>=0.0.5
|
||||
langgraph>=0.2.50,<0.3
|
||||
langgraph-checkpoint>=2.0.0
|
||||
python-dotenv>=1.0.0
|
||||
httpx>=0.28.0
|
||||
oauth-cli-kit>=0.1.3
|
||||
|
||||
# Data & Computation
|
||||
pandas>=2.0.0
|
||||
@@ -14,6 +16,13 @@ numpy>=1.24.0
|
||||
scipy>=1.10.0
|
||||
duckdb>=1.2.0
|
||||
|
||||
# Document Readers (doc_reader_tool)
|
||||
openpyxl>=3.1.0
|
||||
python-docx>=1.1.0
|
||||
python-pptx>=0.6.23
|
||||
pypdfium2>=4.0.0
|
||||
Pillow>=10.0.0
|
||||
|
||||
# ML Strategy
|
||||
scikit-learn>=1.3.0
|
||||
joblib>=1.3.0
|
||||
@@ -33,8 +42,16 @@ ccxt>=4.0.0
|
||||
fastapi>=0.104.0
|
||||
uvicorn[standard]>=0.24.0
|
||||
pydantic>=2.0.0
|
||||
python-multipart>=0.0.6
|
||||
python-multipart>=0.0.18
|
||||
sse-starlette>=1.6.0
|
||||
|
||||
# MCP Server
|
||||
fastmcp>=2.0.0
|
||||
|
||||
# Web Search
|
||||
ddgs>=6.0.0
|
||||
|
||||
# Shadow Account HTML/PDF Report
|
||||
jinja2>=3.1.0
|
||||
matplotlib>=3.7.0
|
||||
weasyprint>=60.0
|
||||
+92
-10
@@ -3,13 +3,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
from src.agent.memory import WorkspaceMemory
|
||||
from src.agent.skills import SkillsLoader
|
||||
from src.agent.tools import ToolRegistry
|
||||
|
||||
_SYSTEM_PROMPT = """You are a finance research agent with 68 specialist skills, 21 tools, 5 data sources (with auto-fallback), and 29 multi-agent swarm teams.
|
||||
if TYPE_CHECKING:
|
||||
from src.memory.persistent import PersistentMemory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SYSTEM_PROMPT = """You are a finance research agent with {skill_count} specialist skills, {tool_count} tools, 5 data sources (with auto-fallback), and 29 multi-agent swarm teams.
|
||||
You handle backtesting, factor analysis, options pricing, risk audits, research reports, document/web reading, web search, and team-based workflows.
|
||||
|
||||
## Tools
|
||||
@@ -35,10 +42,9 @@ Decide which workflow to use based on the request:
|
||||
4. Syntax check → `backtest(run_dir=...)` → `read_file("artifacts/metrics.csv")`
|
||||
5. Do NOT write run_backtest.py. The engine is built-in.
|
||||
|
||||
**Swarm team** — user wants multi-agent analysis, risk audit, committee review, or any task with "team/committee/panel/desk":
|
||||
**Swarm team** — ONLY when the user explicitly requests team/committee/swarm analysis:
|
||||
- Call `run_swarm(prompt="<user's full request>")` — it auto-selects the right preset.
|
||||
- Examples: risk audit, investment committee, equity research, technical panel, cross-market allocation.
|
||||
- Do NOT attempt multi-dimensional analysis solo. Use swarm.
|
||||
- Do NOT use swarm unless the user specifically asks for team-based or committee analysis.
|
||||
|
||||
**Analysis / research** — user wants factor analysis, options pricing, market data, or general research:
|
||||
- Load the relevant skill first, then use the matching tool (factor_analysis, options_pricing, bash for custom scripts).
|
||||
@@ -46,6 +52,21 @@ Decide which workflow to use based on the request:
|
||||
**Document / web** — user provides a PDF or URL:
|
||||
- `read_document(path=...)` for PDFs, `read_url(url=...)` for web pages.
|
||||
|
||||
**Trade journal** — user uploads a CSV/Excel broker export (交割单) or asks to analyze their own trading history:
|
||||
1. `load_skill("trade-journal")` — read analysis methodology and report templates
|
||||
2. `analyze_trade_journal(file_path=..., analysis_type="full")` — parse + profile + behavior diagnostics
|
||||
3. Present results as the markdown report in the skill. Offer follow-ups: time-slice, symbol deep-dive, market split.
|
||||
4. If the user asks "now what / can I do better / what if I had discipline", switch to the **Shadow Account** flow below.
|
||||
|
||||
**Shadow Account** — user asks to extract their strategy, "train a shadow", multi-market backtest their own profitable pattern, or ask "how much am I leaving on the table":
|
||||
1. **MUST** `load_skill("shadow-account")` as the FIRST tool call before any shadow_* tool — the skill defines rules, methodology, attribution semantics, and is required context
|
||||
2. Confirm the journal has been parsed (same session or known `journal_path`). If not, run `analyze_trade_journal` first.
|
||||
3. `extract_shadow_strategy(journal_path=...)` → show rules, ask user to confirm they look like their own behavior
|
||||
4. `run_shadow_backtest(shadow_id=..., journal_path=...)` → multi-market metrics + delta attribution
|
||||
5. `render_shadow_report(shadow_id=...)` → share html/pdf path, lead with the Section 5 "you vs shadow" delta
|
||||
6. Optional: `scan_shadow_signals(shadow_id=...)` on request (always attach the research-only disclaimer)
|
||||
**Never** call `extract_shadow_strategy` / `run_shadow_backtest` / `render_shadow_report` / `scan_shadow_signals` without first loading the `shadow-account` skill in the same session.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Load the relevant skill BEFORE starting any task. Skills contain the exact API contracts and examples.
|
||||
@@ -53,6 +74,19 @@ Decide which workflow to use based on the request:
|
||||
- Output results as markdown tables. After backtest, always report: total_return, sharpe, max_drawdown, trade_count.
|
||||
- All file paths are relative to run_dir (auto-injected).
|
||||
- Respond in the same language the user used.
|
||||
- You have persistent cross-session memory (`remember` tool). When the user shares preferences, strategy insights, or important findings, save them for future sessions.
|
||||
- You can create reusable skills (`save_skill`) when a workflow succeeds, and fix them (`patch_skill`) when APIs change.
|
||||
{memory_section}
|
||||
## Current Date & Time
|
||||
|
||||
Today is {current_datetime}.
|
||||
"""
|
||||
|
||||
_MEMORY_SECTION = """
|
||||
## Persistent Memory (cross-session)
|
||||
|
||||
{snapshot}
|
||||
|
||||
"""
|
||||
|
||||
|
||||
@@ -66,22 +100,26 @@ class ContextBuilder:
|
||||
"""
|
||||
|
||||
def __init__(self, registry: ToolRegistry, memory: WorkspaceMemory,
|
||||
skills_loader: Optional[SkillsLoader] = None) -> None:
|
||||
skills_loader: Optional[SkillsLoader] = None,
|
||||
persistent_memory: Optional[PersistentMemory] = None) -> None:
|
||||
"""Initialize ContextBuilder.
|
||||
|
||||
Args:
|
||||
registry: Tool registry.
|
||||
memory: Workspace memory.
|
||||
skills_loader: Skills loader (auto-created if not provided).
|
||||
persistent_memory: PersistentMemory instance for cross-session recall.
|
||||
"""
|
||||
self.registry = registry
|
||||
self.memory = memory
|
||||
self.skills_loader = skills_loader or SkillsLoader()
|
||||
self._persistent_memory = persistent_memory
|
||||
|
||||
def build_system_prompt(self, user_message: str = "") -> str:
|
||||
"""Build system prompt.
|
||||
|
||||
Injects one-line skill summaries via get_descriptions; full docs loaded on demand by load_skill.
|
||||
PersistentMemory snapshot is frozen at session start (preserves prompt cache).
|
||||
|
||||
Args:
|
||||
user_message: User message (kept for API compatibility).
|
||||
@@ -89,15 +127,32 @@ class ContextBuilder:
|
||||
Returns:
|
||||
System prompt text.
|
||||
"""
|
||||
now = datetime.now()
|
||||
|
||||
# Build memory section only if there are saved memories
|
||||
memory_section = ""
|
||||
if self._persistent_memory and self._persistent_memory.snapshot:
|
||||
memory_section = _MEMORY_SECTION.format(
|
||||
snapshot=self._persistent_memory.snapshot,
|
||||
)
|
||||
|
||||
return _SYSTEM_PROMPT.format(
|
||||
tool_count=len(self.registry._tools),
|
||||
skill_count=len(self.skills_loader.skills),
|
||||
tool_descriptions=self._format_tool_descriptions(),
|
||||
skill_descriptions=self.skills_loader.get_descriptions(),
|
||||
memory_summary=self.memory.to_summary(),
|
||||
memory_section=memory_section,
|
||||
current_datetime=now.strftime("%A, %B %d, %Y %H:%M (local)"),
|
||||
)
|
||||
|
||||
def build_messages(self, user_message: str, history: Optional[List[Dict[str, Any]]] = None) -> List[Dict[str, Any]]:
|
||||
"""Build full message list.
|
||||
|
||||
Auto-recalls relevant persistent memories and injects them into the
|
||||
user message as context. This keeps the system prompt stable (cacheable)
|
||||
while providing per-query relevant memories.
|
||||
|
||||
Args:
|
||||
user_message: User message.
|
||||
history: Prior conversation messages.
|
||||
@@ -110,7 +165,23 @@ class ContextBuilder:
|
||||
]
|
||||
if history:
|
||||
messages.extend(history)
|
||||
messages.append({"role": "user", "content": user_message})
|
||||
|
||||
# Auto-recall: inject relevant memories into user message
|
||||
enriched = user_message
|
||||
if self._persistent_memory:
|
||||
try:
|
||||
recalls = self._persistent_memory.find_relevant(user_message, max_results=3)
|
||||
if recalls:
|
||||
lines = [f"- **{r.title}** ({r.memory_type}): {r.body[:500]}" for r in recalls]
|
||||
recall_block = "\n".join(lines)
|
||||
enriched = (
|
||||
f"<recalled-memories>\n{recall_block}\n</recalled-memories>\n\n"
|
||||
f"{user_message}"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Auto-recall failed: %s", exc)
|
||||
|
||||
messages.append({"role": "user", "content": enriched})
|
||||
return messages
|
||||
|
||||
def _format_tool_descriptions(self) -> str:
|
||||
@@ -138,17 +209,25 @@ class ContextBuilder:
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def format_assistant_tool_calls(tool_calls: list, content: Optional[str] = None) -> Dict[str, Any]:
|
||||
def format_assistant_tool_calls(
|
||||
tool_calls: list,
|
||||
content: Optional[str] = None,
|
||||
reasoning_content: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Format an assistant tool_calls message, preserving thinking text.
|
||||
|
||||
Args:
|
||||
tool_calls: List of tool call objects.
|
||||
content: Model reasoning/thinking text.
|
||||
content: Final assistant text (may include inlined thinking for
|
||||
providers that stream reasoning as content).
|
||||
reasoning_content: Provider-specific reasoning field (Kimi K2.5,
|
||||
DeepSeek reasoner, Qwen thinking). Only attached to the output
|
||||
message when not None, so non-thinking providers see no change.
|
||||
|
||||
Returns:
|
||||
OpenAI-format assistant message.
|
||||
"""
|
||||
return {
|
||||
message = {
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
"tool_calls": [
|
||||
@@ -163,3 +242,6 @@ class ContextBuilder:
|
||||
for tc in tool_calls
|
||||
],
|
||||
}
|
||||
if reasoning_content is not None:
|
||||
message["reasoning_content"] = reasoning_content
|
||||
return message
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Shared YAML-like frontmatter parser for skills and memory files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
def parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
|
||||
"""Parse YAML-like frontmatter and body from a markdown file.
|
||||
|
||||
Supports string, list (``[a, b]``), and boolean values.
|
||||
|
||||
Args:
|
||||
text: Markdown text with optional ``---`` delimited frontmatter.
|
||||
|
||||
Returns:
|
||||
Tuple of (metadata dict, body text).
|
||||
"""
|
||||
match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)", text, re.DOTALL)
|
||||
if not match:
|
||||
return {}, text.strip()
|
||||
|
||||
meta: Dict[str, Any] = {}
|
||||
for line in match.group(1).strip().split("\n"):
|
||||
line = line.strip()
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if value.startswith("[") and value.endswith("]"):
|
||||
items = [item.strip().strip("'\"") for item in value[1:-1].split(",")]
|
||||
meta[key] = [i for i in items if i]
|
||||
elif value.lower() in ("true", "false"):
|
||||
meta[key] = value.lower() == "true"
|
||||
else:
|
||||
meta[key] = value
|
||||
|
||||
return meta, match.group(2).strip()
|
||||
+533
-84
@@ -1,13 +1,19 @@
|
||||
"""AgentLoop: ReAct core loop.
|
||||
|
||||
Three-layer context management:
|
||||
Layer 1 (microcompact) — silently prunes old tool results each iteration, keeping the most recent 3
|
||||
Layer 2 (auto_compact) — LLM summarises and compresses when token count exceeds threshold, saves transcript
|
||||
Layer 3 (compact tool) — model explicitly calls the compact tool to trigger compression
|
||||
Five-layer context management:
|
||||
Layer 1 (microcompact) — silently prunes old tool results each iteration
|
||||
Layer 2 (context_collapse) — folds long text blocks without LLM call (zero cost)
|
||||
Layer 3 (auto_compact) — LLM structured summary with token-budget tail protection
|
||||
Layer 4 (compact tool) — model explicitly calls the compact tool to trigger L3
|
||||
Layer 5 (iterative update) — Nth compression updates previous summary instead of starting fresh
|
||||
|
||||
Tool execution:
|
||||
- Read/write batching: consecutive readonly tools run in parallel via threads
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -28,6 +34,16 @@ TOKEN_THRESHOLD = int(os.getenv("TOKEN_THRESHOLD", "40000"))
|
||||
KEEP_RECENT = 3
|
||||
TOOL_RESULT_LIMIT = 10_000
|
||||
|
||||
# Layer 2: Context collapse thresholds
|
||||
COLLAPSE_THRESHOLD = int(TOKEN_THRESHOLD * 0.7)
|
||||
COLLAPSE_PRESERVE_RECENT = 6
|
||||
COLLAPSE_TEXT_MIN = 2400
|
||||
COLLAPSE_HEAD = 900
|
||||
COLLAPSE_TAIL = 500
|
||||
|
||||
# Layer 3: Token-budget tail protection
|
||||
TAIL_TOKEN_BUDGET = 20_000
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -58,9 +74,192 @@ def _microcompact(messages: list) -> None:
|
||||
msg["content"] = "[cleared]"
|
||||
|
||||
|
||||
def _context_collapse(messages: list) -> None:
|
||||
"""Layer 2: fold long text blocks in older messages without LLM call.
|
||||
|
||||
Preserves head + tail of large text, collapses the middle.
|
||||
Zero API cost — pure string operation.
|
||||
|
||||
Args:
|
||||
messages: Message list (mutated in place).
|
||||
"""
|
||||
if len(messages) <= COLLAPSE_PRESERVE_RECENT + 1:
|
||||
return
|
||||
for msg in messages[1:-COLLAPSE_PRESERVE_RECENT]:
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, str) or len(content) <= COLLAPSE_TEXT_MIN:
|
||||
continue
|
||||
if content == "[cleared]":
|
||||
continue
|
||||
head = content[:COLLAPSE_HEAD]
|
||||
tail = content[-COLLAPSE_TAIL:]
|
||||
trimmed = len(content) - COLLAPSE_HEAD - COLLAPSE_TAIL
|
||||
msg["content"] = f"{head}\n\n...[{trimmed} chars collapsed]...\n\n{tail}"
|
||||
|
||||
|
||||
def _fix_tool_pairs(messages: list) -> None:
|
||||
"""Repair orphaned tool_call / tool_result pairs after compression.
|
||||
|
||||
Two fixes:
|
||||
1. Remove tool results whose matching tool_call was compressed away.
|
||||
2. Insert stub results for tool_calls whose results were compressed away.
|
||||
|
||||
Args:
|
||||
messages: Message list (mutated in place).
|
||||
"""
|
||||
# Collect all tool_call IDs from assistant messages
|
||||
call_ids: set[str] = set()
|
||||
for msg in messages:
|
||||
if msg.get("role") == "assistant":
|
||||
for tc in msg.get("tool_calls", []):
|
||||
tc_id = tc.get("id", "")
|
||||
if tc_id:
|
||||
call_ids.add(tc_id)
|
||||
|
||||
# Remove orphaned tool results
|
||||
i = 0
|
||||
while i < len(messages):
|
||||
msg = messages[i]
|
||||
if msg.get("role") == "tool" and msg.get("tool_call_id") not in call_ids:
|
||||
messages.pop(i)
|
||||
else:
|
||||
i += 1
|
||||
|
||||
# Collect existing result IDs
|
||||
result_ids: set[str] = set()
|
||||
for msg in messages:
|
||||
if msg.get("role") == "tool":
|
||||
tcid = msg.get("tool_call_id", "")
|
||||
if tcid:
|
||||
result_ids.add(tcid)
|
||||
|
||||
# Insert stub results for orphaned tool_calls
|
||||
inserts: list[tuple[int, dict]] = []
|
||||
for idx, msg in enumerate(messages):
|
||||
if msg.get("role") != "assistant":
|
||||
continue
|
||||
for tc in msg.get("tool_calls", []):
|
||||
tc_id = tc.get("id", "")
|
||||
if tc_id and tc_id not in result_ids:
|
||||
stub = {
|
||||
"role": "tool",
|
||||
"tool_call_id": tc_id,
|
||||
"name": tc.get("function", {}).get("name", "unknown"),
|
||||
"content": "[Result from earlier context — see summary above]",
|
||||
}
|
||||
inserts.append((idx + 1, stub))
|
||||
result_ids.add(tc_id)
|
||||
|
||||
for pos, stub in reversed(inserts):
|
||||
messages.insert(pos, stub)
|
||||
|
||||
|
||||
# -- Structured summary templates ------------------------------------------
|
||||
|
||||
_STRUCTURED_SUMMARY_PROMPT = """\
|
||||
Summarize this conversation for handoff to a fresh context window.
|
||||
This summary is the ONLY context available — omitted information is lost.
|
||||
|
||||
Use EXACTLY this structure:
|
||||
|
||||
## Goal
|
||||
What the user is trying to accomplish.
|
||||
|
||||
## Constraints & Preferences
|
||||
User-stated requirements: risk tolerance, strategy parameters, asset preferences.
|
||||
|
||||
## Progress
|
||||
### Done
|
||||
- Completed steps with key results and specific numbers.
|
||||
### In Progress
|
||||
- Current work when compression triggered.
|
||||
|
||||
## Key Decisions
|
||||
Choices made and rationale.
|
||||
|
||||
## Resolved Questions
|
||||
Questions already answered — do NOT re-answer these.
|
||||
|
||||
## Pending User Asks
|
||||
Unfinished requests still needing action.
|
||||
|
||||
## Relevant Files
|
||||
File paths, run_dir, signal engines, artifact locations.
|
||||
|
||||
## Remaining Work
|
||||
What still needs to be done (background reference, NOT active instructions).
|
||||
|
||||
## Critical Context
|
||||
Specific numbers, parameters, error messages, configuration values.
|
||||
|
||||
## Tools & Patterns
|
||||
Which tools worked, what failed, effective approaches.
|
||||
|
||||
IMPORTANT: This is a handoff — background reference, NOT active instructions.
|
||||
Preserve ALL specific numbers, file paths, and parameter values.
|
||||
{focus_section}
|
||||
Conversation to summarize:
|
||||
"""
|
||||
|
||||
_FOCUS_SECTION = """
|
||||
FOCUS TOPIC: {topic}
|
||||
Allocate 60-70% of the summary budget to content related to this topic.
|
||||
Aggressively compress unrelated content to make room.
|
||||
"""
|
||||
|
||||
_ITERATIVE_UPDATE_PROMPT = """\
|
||||
Update the existing summary with new conversation turns.
|
||||
|
||||
PREVIOUS SUMMARY:
|
||||
{previous_summary}
|
||||
|
||||
NEW TURNS TO INCORPORATE:
|
||||
{new_turns}
|
||||
|
||||
Rules:
|
||||
- PRESERVE all existing information from the previous summary.
|
||||
- ADD new progress, decisions, and findings.
|
||||
- Move "In Progress" items to "Done" when completed.
|
||||
- Move answered questions to "Resolved Questions".
|
||||
- Keep the same section structure.
|
||||
- Do NOT drop any critical context from the previous summary.
|
||||
{focus_section}"""
|
||||
|
||||
|
||||
def _is_tool_success(result: str) -> bool:
|
||||
"""Return True if the tool result does not contain an error."""
|
||||
return '"error"' not in result[:200]
|
||||
"""Return True if the tool result does not look like an error response."""
|
||||
try:
|
||||
data = json.loads(result)
|
||||
if isinstance(data, dict) and data.get("status") == "error":
|
||||
return False
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def _normalize_tool_run_dir(args: dict[str, Any], memory_run_dir: str | None) -> dict[str, Any]:
|
||||
"""Normalize ``run_dir`` in tool args to an absolute path when possible.
|
||||
|
||||
If the model supplies a relative ``run_dir`` (for example ``"."`` or
|
||||
``"risk_parity_run"``), resolve it against the active run directory.
|
||||
"""
|
||||
normalized = dict(args)
|
||||
if not memory_run_dir:
|
||||
return normalized
|
||||
|
||||
if "run_dir" not in normalized:
|
||||
normalized["run_dir"] = memory_run_dir
|
||||
return normalized
|
||||
|
||||
run_dir_value = str(normalized["run_dir"]).strip()
|
||||
if not run_dir_value:
|
||||
normalized["run_dir"] = memory_run_dir
|
||||
return normalized
|
||||
|
||||
candidate = Path(run_dir_value)
|
||||
if not candidate.is_absolute():
|
||||
normalized["run_dir"] = str((Path(memory_run_dir) / candidate).resolve())
|
||||
return normalized
|
||||
|
||||
|
||||
class AgentLoop:
|
||||
@@ -80,6 +279,7 @@ class AgentLoop:
|
||||
memory: Optional[WorkspaceMemory] = None,
|
||||
event_callback: Optional[Callable[[str, Dict[str, Any]], None]] = None,
|
||||
max_iterations: int = 50,
|
||||
persistent_memory: Optional[Any] = None,
|
||||
) -> None:
|
||||
"""Initialize AgentLoop.
|
||||
|
||||
@@ -89,6 +289,7 @@ class AgentLoop:
|
||||
memory: Workspace memory (created fresh if not provided).
|
||||
event_callback: Event callback (event_type, data).
|
||||
max_iterations: Maximum number of loop iterations.
|
||||
persistent_memory: PersistentMemory for cross-session recall.
|
||||
"""
|
||||
self.registry = registry
|
||||
self.llm = llm
|
||||
@@ -97,6 +298,8 @@ class AgentLoop:
|
||||
self.max_iterations = max_iterations
|
||||
self._called_ok: set[str] = set()
|
||||
self._cancelled: bool = False
|
||||
self._previous_summary: str = ""
|
||||
self._persistent_memory = persistent_memory
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Cancel the current loop.
|
||||
@@ -116,6 +319,11 @@ class AgentLoop:
|
||||
Returns:
|
||||
Execution result dict.
|
||||
"""
|
||||
# Reset per-run state (safe for reuse across multiple run() calls)
|
||||
self._cancelled = False
|
||||
self._called_ok = set()
|
||||
self._previous_summary = ""
|
||||
|
||||
state_store = RunStateStore()
|
||||
RUNS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -127,7 +335,8 @@ class AgentLoop:
|
||||
|
||||
state_store.save_request(run_dir, user_message, {"session_id": session_id})
|
||||
|
||||
context = ContextBuilder(self.registry, self.memory)
|
||||
context = ContextBuilder(self.registry, self.memory,
|
||||
persistent_memory=self._persistent_memory)
|
||||
messages = context.build_messages(user_message, history)
|
||||
react_trace: List[Dict[str, Any]] = []
|
||||
|
||||
@@ -157,8 +366,13 @@ class AgentLoop:
|
||||
# Layer 1: microcompact (every iteration)
|
||||
_microcompact(messages)
|
||||
|
||||
# Layer 2: auto_compact (token threshold exceeded)
|
||||
# Layer 2: context collapse (fold long text, zero API cost)
|
||||
tokens = estimate_tokens(messages)
|
||||
if tokens > COLLAPSE_THRESHOLD:
|
||||
_context_collapse(messages)
|
||||
tokens = estimate_tokens(messages)
|
||||
|
||||
# Layer 3: auto_compact (token threshold exceeded)
|
||||
if tokens > TOKEN_THRESHOLD:
|
||||
logger.info(f"Auto compact triggered: {tokens} tokens > {TOKEN_THRESHOLD}")
|
||||
self._auto_compact(messages, run_dir, trace)
|
||||
@@ -189,58 +403,23 @@ class AgentLoop:
|
||||
react_trace.append({"type": "answer", "content": final_content[:500]})
|
||||
break
|
||||
|
||||
messages.append(context.format_assistant_tool_calls(response.tool_calls, content=response.content))
|
||||
messages.append(
|
||||
context.format_assistant_tool_calls(
|
||||
response.tool_calls,
|
||||
content=response.content,
|
||||
reasoning_content=response.reasoning_content or thinking_text or None,
|
||||
)
|
||||
)
|
||||
|
||||
compact_requested = False
|
||||
|
||||
for tc in response.tool_calls:
|
||||
tool_def = self.registry.get(tc.name)
|
||||
|
||||
# Layer 3: compact tool — mark then defer execution
|
||||
if tc.name == "compact":
|
||||
compact_requested = True
|
||||
messages.append(context.format_tool_result(tc.id, "compact", '{"status":"ok","message":"Compressing..."}'))
|
||||
trace.write({"type": "compact_requested", "iter": iteration})
|
||||
continue
|
||||
|
||||
is_repeatable = tool_def.repeatable if tool_def else False
|
||||
if tc.name in self._called_ok and not is_repeatable:
|
||||
logger.warning(f"Blocked duplicate call: {tc.name} (already succeeded)")
|
||||
skip_msg = json.dumps({"skipped": True, "reason": f"{tc.name} already completed successfully. Use the previous result."})
|
||||
messages.append(context.format_tool_result(tc.id, tc.name, skip_msg))
|
||||
trace.write({"type": "tool_skipped", "iter": iteration, "tool": tc.name})
|
||||
react_trace.append({"type": "tool_skipped", "tool": tc.name})
|
||||
continue
|
||||
|
||||
self._emit("tool_call", {"tool": tc.name, "arguments": {k: str(v)[:200] for k, v in tc.arguments.items()}, "iter": iteration})
|
||||
trace.write({"type": "tool_call", "iter": iteration, "tool": tc.name, "args": {k: str(v)[:200] for k, v in tc.arguments.items()}})
|
||||
logger.info(f"Tool call: {tc.name}({list(tc.arguments.keys())})")
|
||||
|
||||
if "run_dir" not in tc.arguments and self.memory.run_dir:
|
||||
tc.arguments["run_dir"] = self.memory.run_dir
|
||||
|
||||
t0 = _time.perf_counter()
|
||||
result = self.registry.execute(tc.name, tc.arguments)
|
||||
elapsed_ms = int((_time.perf_counter() - t0) * 1000)
|
||||
|
||||
self._update_memory(tc.name, result, state_store, run_dir)
|
||||
|
||||
if _is_tool_success(result):
|
||||
self._called_ok.add(tc.name)
|
||||
|
||||
status = "ok" if _is_tool_success(result) else "error"
|
||||
truncated = result[:TOOL_RESULT_LIMIT]
|
||||
messages.append(context.format_tool_result(tc.id, tc.name, truncated))
|
||||
|
||||
trace.write({"type": "tool_result", "iter": iteration, "tool": tc.name, "status": status, "elapsed_ms": elapsed_ms, "preview": result[:200]})
|
||||
react_trace.append({"type": "tool_call", "tool": tc.name, "result_preview": result[:200]})
|
||||
|
||||
self._emit("tool_result", {"tool": tc.name, "status": status, "elapsed_ms": elapsed_ms, "preview": result[:200]})
|
||||
# Execute tools with read/write batching
|
||||
compact_requested, focus_topic = self._process_tool_calls(
|
||||
response.tool_calls, context, messages, trace, react_trace, iteration,
|
||||
)
|
||||
|
||||
# Layer 3: compress after all tools have executed
|
||||
if compact_requested:
|
||||
logger.info("Manual compact triggered by model")
|
||||
self._auto_compact(messages, run_dir, trace)
|
||||
self._auto_compact(messages, run_dir, trace, focus_topic=focus_topic)
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception(f"AgentLoop error: {exc}")
|
||||
@@ -278,47 +457,323 @@ class AgentLoop:
|
||||
"react_trace": react_trace,
|
||||
}
|
||||
|
||||
def _auto_compact(self, messages: list, run_dir: Path, trace: TraceWriter) -> None:
|
||||
"""Layer 2/3: LLM summarise-and-compress; saves transcript before compressing.
|
||||
# -- Tool execution with read/write batching --------------------------------
|
||||
|
||||
def _process_tool_calls(
|
||||
self,
|
||||
tool_calls: list,
|
||||
context: ContextBuilder,
|
||||
messages: list,
|
||||
trace: TraceWriter,
|
||||
react_trace: list,
|
||||
iteration: int,
|
||||
) -> tuple[bool, str]:
|
||||
"""Pre-process tool calls: handle compact, filter duplicates, batch execute.
|
||||
|
||||
Args:
|
||||
tool_calls: Raw tool calls from LLM response.
|
||||
context: ContextBuilder for formatting messages.
|
||||
messages: Conversation messages (appended in place).
|
||||
trace: TraceWriter.
|
||||
react_trace: React trace list.
|
||||
iteration: Current iteration number.
|
||||
|
||||
Returns:
|
||||
Tuple of (compact_requested, focus_topic).
|
||||
"""
|
||||
compact_requested = False
|
||||
focus_topic = ""
|
||||
to_execute = []
|
||||
|
||||
for tc in tool_calls:
|
||||
# Layer 4: compact tool — mark then defer execution
|
||||
if tc.name == "compact":
|
||||
compact_requested = True
|
||||
focus_topic = tc.arguments.get("focus_topic", "")
|
||||
messages.append(context.format_tool_result(tc.id, "compact", '{"status":"ok","message":"Compressing..."}'))
|
||||
trace.write({"type": "compact_requested", "iter": iteration})
|
||||
continue
|
||||
|
||||
tool_def = self.registry.get(tc.name)
|
||||
is_repeatable = tool_def.repeatable if tool_def else False
|
||||
if tc.name in self._called_ok and not is_repeatable:
|
||||
logger.warning(f"Blocked duplicate call: {tc.name} (already succeeded)")
|
||||
skip_msg = json.dumps({"skipped": True, "reason": f"{tc.name} already completed successfully. Use the previous result."})
|
||||
messages.append(context.format_tool_result(tc.id, tc.name, skip_msg))
|
||||
trace.write({"type": "tool_skipped", "iter": iteration, "tool": tc.name})
|
||||
react_trace.append({"type": "tool_skipped", "tool": tc.name})
|
||||
continue
|
||||
|
||||
to_execute.append(tc)
|
||||
|
||||
if not to_execute:
|
||||
return compact_requested, focus_topic
|
||||
|
||||
# Batch execute: consecutive readonly → parallel, write → serial
|
||||
if len(to_execute) == 1:
|
||||
self._execute_single(to_execute[0], context, messages, trace, react_trace, iteration)
|
||||
else:
|
||||
self._batch_execute(to_execute, context, messages, trace, react_trace, iteration)
|
||||
|
||||
return compact_requested, focus_topic
|
||||
|
||||
def _batch_execute(
|
||||
self,
|
||||
tool_calls: list,
|
||||
context: ContextBuilder,
|
||||
messages: list,
|
||||
trace: TraceWriter,
|
||||
react_trace: list,
|
||||
iteration: int,
|
||||
) -> None:
|
||||
"""Execute tools with read/write batching.
|
||||
|
||||
Consecutive readonly tools run in parallel via ThreadPoolExecutor.
|
||||
Write tools run serially between readonly batches.
|
||||
|
||||
Args:
|
||||
tool_calls: Tool calls to execute.
|
||||
context: ContextBuilder.
|
||||
messages: Conversation messages.
|
||||
trace: TraceWriter.
|
||||
react_trace: React trace list.
|
||||
iteration: Current iteration.
|
||||
"""
|
||||
# Split into batches: consecutive readonly → parallel, write → serial
|
||||
batches: list[tuple[str, list]] = []
|
||||
current_ro: list = []
|
||||
|
||||
for tc in tool_calls:
|
||||
tool_def = self.registry.get(tc.name)
|
||||
if tool_def and tool_def.is_readonly:
|
||||
current_ro.append(tc)
|
||||
else:
|
||||
if current_ro:
|
||||
batches.append(("parallel", current_ro))
|
||||
current_ro = []
|
||||
batches.append(("serial", [tc]))
|
||||
if current_ro:
|
||||
batches.append(("parallel", current_ro))
|
||||
|
||||
for mode, batch in batches:
|
||||
if mode == "parallel" and len(batch) > 1:
|
||||
self._execute_parallel(batch, context, messages, trace, react_trace, iteration)
|
||||
else:
|
||||
for tc in batch:
|
||||
self._execute_single(tc, context, messages, trace, react_trace, iteration)
|
||||
|
||||
def _execute_parallel(
|
||||
self,
|
||||
tool_calls: list,
|
||||
context: ContextBuilder,
|
||||
messages: list,
|
||||
trace: TraceWriter,
|
||||
react_trace: list,
|
||||
iteration: int,
|
||||
) -> None:
|
||||
"""Execute readonly tools in parallel using threads.
|
||||
|
||||
Args:
|
||||
tool_calls: Readonly tool calls to execute in parallel.
|
||||
context: ContextBuilder.
|
||||
messages: Conversation messages.
|
||||
trace: TraceWriter.
|
||||
react_trace: React trace list.
|
||||
iteration: Current iteration.
|
||||
"""
|
||||
# Prepare args + emit events
|
||||
runnable: list[tuple] = []
|
||||
for tc in tool_calls:
|
||||
args = _normalize_tool_run_dir(tc.arguments, self.memory.run_dir)
|
||||
self._emit("tool_call", {"tool": tc.name, "arguments": {k: str(v)[:200] for k, v in args.items()}, "iter": iteration})
|
||||
trace.write({"type": "tool_call", "iter": iteration, "tool": tc.name, "args": {k: str(v)[:200] for k, v in args.items()}})
|
||||
runnable.append((tc, args))
|
||||
|
||||
# Execute in parallel
|
||||
def _run(tc_args: tuple) -> tuple:
|
||||
tc, args = tc_args
|
||||
t0 = _time.perf_counter()
|
||||
result = self.registry.execute(tc.name, args)
|
||||
elapsed_ms = int((_time.perf_counter() - t0) * 1000)
|
||||
return tc, result, elapsed_ms
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(runnable), 8)) as pool:
|
||||
futures = [pool.submit(_run, item) for item in runnable]
|
||||
results = []
|
||||
for i, f in enumerate(futures):
|
||||
try:
|
||||
results.append(f.result())
|
||||
except Exception as exc:
|
||||
tc = runnable[i][0]
|
||||
results.append((tc, json.dumps({"status": "error", "error": str(exc)}), 0))
|
||||
|
||||
# Process results in order
|
||||
for tc, result, elapsed_ms in results:
|
||||
self._finalize_tool_result(tc, result, elapsed_ms, context, messages, trace, react_trace, iteration)
|
||||
|
||||
def _execute_single(
|
||||
self,
|
||||
tc: Any,
|
||||
context: ContextBuilder,
|
||||
messages: list,
|
||||
trace: TraceWriter,
|
||||
react_trace: list,
|
||||
iteration: int,
|
||||
) -> None:
|
||||
"""Execute a single tool call.
|
||||
|
||||
Args:
|
||||
tc: Tool call object.
|
||||
context: ContextBuilder.
|
||||
messages: Conversation messages.
|
||||
trace: TraceWriter.
|
||||
react_trace: React trace list.
|
||||
iteration: Current iteration.
|
||||
"""
|
||||
args = _normalize_tool_run_dir(tc.arguments, self.memory.run_dir)
|
||||
|
||||
self._emit("tool_call", {"tool": tc.name, "arguments": {k: str(v)[:200] for k, v in args.items()}, "iter": iteration})
|
||||
trace.write({"type": "tool_call", "iter": iteration, "tool": tc.name, "args": {k: str(v)[:200] for k, v in args.items()}})
|
||||
logger.info(f"Tool call: {tc.name}({list(args.keys())})")
|
||||
|
||||
t0 = _time.perf_counter()
|
||||
result = self.registry.execute(tc.name, args)
|
||||
elapsed_ms = int((_time.perf_counter() - t0) * 1000)
|
||||
|
||||
self._finalize_tool_result(tc, result, elapsed_ms, context, messages, trace, react_trace, iteration)
|
||||
|
||||
def _finalize_tool_result(
|
||||
self,
|
||||
tc: Any,
|
||||
result: str,
|
||||
elapsed_ms: int,
|
||||
context: ContextBuilder,
|
||||
messages: list,
|
||||
trace: TraceWriter,
|
||||
react_trace: list,
|
||||
iteration: int,
|
||||
) -> None:
|
||||
"""Record a tool result: update memory, append message, write trace, emit event.
|
||||
|
||||
Args:
|
||||
tc: Tool call object.
|
||||
result: Raw tool result string.
|
||||
elapsed_ms: Execution time in milliseconds.
|
||||
context: ContextBuilder.
|
||||
messages: Conversation messages.
|
||||
trace: TraceWriter.
|
||||
react_trace: React trace list.
|
||||
iteration: Current iteration.
|
||||
"""
|
||||
self._update_memory(tc.name)
|
||||
|
||||
success = _is_tool_success(result)
|
||||
if success:
|
||||
self._called_ok.add(tc.name)
|
||||
|
||||
status = "ok" if success else "error"
|
||||
truncated = result[:TOOL_RESULT_LIMIT]
|
||||
messages.append(context.format_tool_result(tc.id, tc.name, truncated))
|
||||
|
||||
trace.write({"type": "tool_result", "iter": iteration, "tool": tc.name, "status": status, "elapsed_ms": elapsed_ms, "preview": result[:200]})
|
||||
react_trace.append({"type": "tool_call", "tool": tc.name, "result_preview": result[:200]})
|
||||
self._emit("tool_result", {"tool": tc.name, "status": status, "elapsed_ms": elapsed_ms, "preview": result[:200]})
|
||||
|
||||
# -- Context compression ---------------------------------------------------
|
||||
|
||||
def _auto_compact(self, messages: list, run_dir: Path, trace: TraceWriter,
|
||||
focus_topic: str = "") -> None:
|
||||
"""Layer 3/4/5: structured LLM summary with token-budget tail protection.
|
||||
|
||||
Upgrades over the original:
|
||||
- Token-budget tail: keeps ~20K tokens of recent messages (not a fixed count).
|
||||
- Structured summary template: preserves goal, progress, decisions, files, etc.
|
||||
- Iterative update: Nth compression updates previous summary, zero info decay.
|
||||
- Tool pair fix: repairs orphaned tool_call/tool_result after compression.
|
||||
- Focus-topic: optionally prioritize specific topic in summary.
|
||||
|
||||
Args:
|
||||
messages: Message list (replaced in place).
|
||||
run_dir: Run directory.
|
||||
trace: TraceWriter.
|
||||
focus_topic: Optional topic to prioritize in the summary.
|
||||
"""
|
||||
# Save full transcript (timestamped, one file per compression)
|
||||
# Save full transcript before compressing
|
||||
transcript_path = run_dir / f"transcript_{int(_time.time())}.jsonl"
|
||||
with open(transcript_path, "w", encoding="utf-8") as f:
|
||||
for msg in messages:
|
||||
f.write(json.dumps(msg, default=str, ensure_ascii=False) + "\n")
|
||||
|
||||
# LLM summary (no tools, plain text)
|
||||
conv_text = json.dumps(messages[1:], default=str, ensure_ascii=False)[:80000]
|
||||
summary_resp = self.llm.chat([
|
||||
{"role": "user", "content": (
|
||||
"Summarize this conversation for continuity. Include: "
|
||||
"1) What was accomplished, 2) Current state, 3) Key decisions made. "
|
||||
"Be concise but preserve critical details.\n\n" + conv_text
|
||||
)},
|
||||
])
|
||||
system_msg = messages[0]
|
||||
body = messages[1:]
|
||||
|
||||
# Token-budget tail: walk backward to find how many recent messages to preserve
|
||||
accumulated = 0
|
||||
cut_idx = len(body)
|
||||
for i in range(len(body) - 1, -1, -1):
|
||||
content = body[i].get("content", "")
|
||||
msg_tokens = (len(str(content)) // 4) + 10
|
||||
if accumulated + msg_tokens > TAIL_TOKEN_BUDGET:
|
||||
cut_idx = i + 1
|
||||
break
|
||||
accumulated += msg_tokens
|
||||
cut_idx = i
|
||||
|
||||
# Don't split in the middle of a tool_call/tool_result pair
|
||||
while 0 < cut_idx < len(body) and body[cut_idx].get("role") == "tool":
|
||||
cut_idx += 1
|
||||
|
||||
head = body[:cut_idx]
|
||||
tail = body[cut_idx:]
|
||||
|
||||
if not head:
|
||||
# All body fits in tail budget — force a split to avoid infinite loop
|
||||
if len(body) > 2:
|
||||
cut_idx = max(1, len(body) // 2)
|
||||
head = body[:cut_idx]
|
||||
tail = body[cut_idx:]
|
||||
else:
|
||||
logger.warning("Auto compact: nothing to compress (body too small)")
|
||||
return
|
||||
|
||||
# Build focus section
|
||||
focus_section = _FOCUS_SECTION.format(topic=focus_topic) if focus_topic else ""
|
||||
|
||||
# Build summary prompt (structured template or iterative update)
|
||||
conv_text = json.dumps(head, default=str, ensure_ascii=False)[:80000]
|
||||
|
||||
if self._previous_summary:
|
||||
prompt = _ITERATIVE_UPDATE_PROMPT.format(
|
||||
previous_summary=self._previous_summary,
|
||||
new_turns=conv_text,
|
||||
focus_section=focus_section,
|
||||
)
|
||||
else:
|
||||
prompt = _STRUCTURED_SUMMARY_PROMPT.format(focus_section=focus_section) + conv_text
|
||||
|
||||
summary_resp = self.llm.chat([{"role": "user", "content": prompt}])
|
||||
summary = summary_resp.content or ""
|
||||
self._previous_summary = summary
|
||||
|
||||
tokens_before = estimate_tokens(messages)
|
||||
trace.write({"type": "compact", "tokens_before": tokens_before, "summary": summary[:500]})
|
||||
trace.write({"type": "compact", "tokens_before": tokens_before, "summary": summary[:500],
|
||||
"focus_topic": focus_topic or "(none)"})
|
||||
self._emit("compact", {"tokens_before": tokens_before, "summary": summary[:200]})
|
||||
|
||||
# Replace: keep system prompt + inject compressed summary + restore identity
|
||||
system_msg = messages[0]
|
||||
# Reconstruct: system + summary + acknowledge + preserved tail
|
||||
state_summary = self.memory.to_summary()
|
||||
compressed = f"[Conversation compressed. Transcript: {transcript_path}]\n\n{summary}"
|
||||
compressed = f"[Conversation compressed — handoff summary. Transcript: {transcript_path}]\n\n{summary}"
|
||||
if state_summary and state_summary != "(empty state)":
|
||||
compressed += f"\n\nCurrent agent state:\n{state_summary}"
|
||||
|
||||
messages.clear()
|
||||
messages.extend([
|
||||
system_msg,
|
||||
{"role": "user", "content": compressed},
|
||||
{"role": "assistant", "content": "Understood. I have the context from the summary. Continuing."},
|
||||
])
|
||||
messages.append(system_msg)
|
||||
messages.append({"role": "user", "content": compressed})
|
||||
messages.append({"role": "assistant", "content": "Understood. Continuing from the summary."})
|
||||
messages.extend(tail)
|
||||
|
||||
# Fix orphaned tool pairs in the reconstructed message list
|
||||
_fix_tool_pairs(messages)
|
||||
|
||||
def _emit(self, event_type: str, data: Dict[str, Any]) -> None:
|
||||
"""Fire an event via the callback."""
|
||||
@@ -328,12 +783,6 @@ class AgentLoop:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _update_memory(self, tool_name: str, result: str, state_store: RunStateStore, run_dir: Path) -> None:
|
||||
"""Update memory and persist tool result."""
|
||||
def _update_memory(self, tool_name: str) -> None:
|
||||
"""Update workspace memory counters after tool execution."""
|
||||
self.memory.increment(tool_name)
|
||||
try:
|
||||
data = json.loads(result)
|
||||
if isinstance(data, dict):
|
||||
state_store.persist_tool_result(tool_name, data, run_dir)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
+12
-85
@@ -1,7 +1,7 @@
|
||||
"""Workspace memory: shared state across tool calls.
|
||||
"""Workspace memory: shared state across tool calls within a single run.
|
||||
|
||||
WorkspaceMemory passes intermediate results between tools during an agent run.
|
||||
Generic KV store + counters; no tool-specific fields.
|
||||
Lightweight runtime state — survives within one AgentLoop.run() invocation only.
|
||||
Cross-session persistence is handled by src.memory.persistent.PersistentMemory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,53 +12,21 @@ from typing import Any, Dict, Optional
|
||||
|
||||
@dataclass
|
||||
class WorkspaceMemory:
|
||||
"""Shared workspace state between tools.
|
||||
"""Shared workspace state between tools during a single agent run.
|
||||
|
||||
Attributes:
|
||||
run_dir: Current run directory.
|
||||
store: Generic result store (tool memory_key -> JSON string).
|
||||
run_dir: Current run directory path.
|
||||
counters: Tool invocation counters.
|
||||
extra: Generic KV store for derived values passed between tools.
|
||||
"""
|
||||
|
||||
run_dir: Optional[str] = None
|
||||
store: Dict[str, str] = field(default_factory=dict)
|
||||
counters: Dict[str, int] = field(default_factory=dict)
|
||||
extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset all state."""
|
||||
self.run_dir = None
|
||||
self.store.clear()
|
||||
self.counters.clear()
|
||||
self.extra.clear()
|
||||
|
||||
def set_result(self, key: str, value: str) -> None:
|
||||
"""Store a tool result.
|
||||
|
||||
Args:
|
||||
key: Memory key (e.g. "plan_output").
|
||||
value: JSON string.
|
||||
"""
|
||||
self.store[key] = value
|
||||
|
||||
def get_result(self, key: str, default: Optional[str] = None) -> Optional[str]:
|
||||
"""Retrieve a tool result.
|
||||
|
||||
Args:
|
||||
key: Memory key.
|
||||
default: Default value.
|
||||
|
||||
Returns:
|
||||
JSON string or default.
|
||||
"""
|
||||
return self.store.get(key, default)
|
||||
|
||||
def increment(self, key: str) -> int:
|
||||
"""Increment a counter and return the new value.
|
||||
|
||||
Args:
|
||||
key: Counter key.
|
||||
key: Counter key (typically tool name).
|
||||
|
||||
Returns:
|
||||
Updated counter value.
|
||||
@@ -66,60 +34,19 @@ class WorkspaceMemory:
|
||||
self.counters[key] = self.counters.get(key, 0) + 1
|
||||
return self.counters[key]
|
||||
|
||||
def set_extra(self, key: str, value: Any) -> None:
|
||||
"""Set an extra KV entry.
|
||||
|
||||
Args:
|
||||
key: Key name.
|
||||
value: Value.
|
||||
"""
|
||||
self.extra[key] = value
|
||||
|
||||
def get_extra(self, key: str, default: Any = None) -> Any:
|
||||
"""Get an extra KV entry.
|
||||
|
||||
Args:
|
||||
key: Key name.
|
||||
default: Default value.
|
||||
|
||||
Returns:
|
||||
Stored value or default.
|
||||
"""
|
||||
return self.extra.get(key, default)
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""Set a KV entry (alias for set_extra).
|
||||
|
||||
Args:
|
||||
key: Key name.
|
||||
value: Value.
|
||||
"""
|
||||
self.extra[key] = value
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a KV entry (alias for get_extra).
|
||||
|
||||
Args:
|
||||
key: Key name.
|
||||
default: Default value.
|
||||
|
||||
Returns:
|
||||
Stored value or default.
|
||||
"""
|
||||
return self.extra.get(key, default)
|
||||
|
||||
def to_summary(self) -> str:
|
||||
"""Generate a state summary for the LLM indicating completed steps.
|
||||
"""Generate a state summary for the LLM.
|
||||
|
||||
Includes run_dir and tool counters.
|
||||
This summary survives context compression and helps the LLM
|
||||
remember what it was working on.
|
||||
|
||||
Returns:
|
||||
State summary text.
|
||||
"""
|
||||
lines = []
|
||||
lines: list[str] = []
|
||||
if self.run_dir:
|
||||
lines.append(f"- run_dir: {self.run_dir}")
|
||||
if self.store:
|
||||
completed = list(self.store.keys())
|
||||
lines.append(f"- completed: {', '.join(completed)}")
|
||||
if self.counters:
|
||||
counter_parts = [f"{k}={v}" for k, v in self.counters.items()]
|
||||
lines.append(f"- counters: {', '.join(counter_parts)}")
|
||||
|
||||
+36
-42
@@ -53,36 +53,7 @@ class Skill:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
|
||||
"""Parse YAML frontmatter and body.
|
||||
|
||||
Args:
|
||||
text: Markdown text.
|
||||
|
||||
Returns:
|
||||
Tuple of (metadata dict, body text).
|
||||
"""
|
||||
match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)", text, re.DOTALL)
|
||||
if not match:
|
||||
return {}, text.strip()
|
||||
|
||||
meta: Dict[str, Any] = {}
|
||||
for line in match.group(1).strip().split("\n"):
|
||||
line = line.strip()
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if value.startswith("[") and value.endswith("]"):
|
||||
items = [item.strip().strip("'\"") for item in value[1:-1].split(",")]
|
||||
meta[key] = [i for i in items if i]
|
||||
elif value.lower() in ("true", "false"):
|
||||
meta[key] = value.lower() == "true"
|
||||
else:
|
||||
meta[key] = value
|
||||
|
||||
return meta, match.group(2).strip()
|
||||
from src.agent.frontmatter import parse_frontmatter as _parse_frontmatter # shared util
|
||||
|
||||
|
||||
def _load_skill_dir(dir_path: Path) -> Optional[Skill]:
|
||||
@@ -117,32 +88,45 @@ def _load_skill_dir(dir_path: Path) -> Optional[Skill]:
|
||||
)
|
||||
|
||||
|
||||
USER_SKILLS_DIR = Path.home() / ".vibe-trading" / "skills" / "user"
|
||||
|
||||
|
||||
class SkillsLoader:
|
||||
"""Load skills from the skills/ directory.
|
||||
"""Load skills from bundled skills/ directory and user skills directory.
|
||||
|
||||
Attributes:
|
||||
skills: Loaded skill list.
|
||||
skills: Loaded skill list (bundled + user-created).
|
||||
"""
|
||||
|
||||
def __init__(self, skills_dir: Optional[Path] = None) -> None:
|
||||
def __init__(self, skills_dir: Optional[Path] = None,
|
||||
user_skills_dir: Optional[Path] = None) -> None:
|
||||
"""Initialize SkillsLoader.
|
||||
|
||||
Args:
|
||||
skills_dir: Skills directory path; defaults to agent/skills/.
|
||||
skills_dir: Bundled skills directory path; defaults to agent/skills/.
|
||||
user_skills_dir: User-created skills directory; defaults to ~/.vibe-trading/skills/user/.
|
||||
"""
|
||||
self.skills_dir = skills_dir or Path(__file__).resolve().parents[1] / "skills"
|
||||
self._user_skills_dir = user_skills_dir or USER_SKILLS_DIR
|
||||
self.skills: List[Skill] = []
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
"""Load all skill subdirectories from the skills directory."""
|
||||
if not self.skills_dir.exists():
|
||||
return
|
||||
for path in sorted(self.skills_dir.iterdir()):
|
||||
if path.is_dir() and (path / "SKILL.md").exists():
|
||||
skill = _load_skill_dir(path)
|
||||
if skill:
|
||||
self.skills.append(skill)
|
||||
"""Load all skill subdirectories from user and bundled directories.
|
||||
|
||||
User skills are loaded first so they override bundled skills with the same name
|
||||
(e.g. after patch_skill copies and modifies a bundled skill).
|
||||
"""
|
||||
seen_names: set[str] = set()
|
||||
for directory in (self._user_skills_dir, self.skills_dir):
|
||||
if not directory or not directory.exists():
|
||||
continue
|
||||
for path in sorted(directory.iterdir()):
|
||||
if path.is_dir() and (path / "SKILL.md").exists():
|
||||
skill = _load_skill_dir(path)
|
||||
if skill and skill.name not in seen_names:
|
||||
self.skills.append(skill)
|
||||
seen_names.add(skill.name)
|
||||
|
||||
# Display order for categories (unlisted categories appear at the end).
|
||||
_CATEGORY_ORDER = [
|
||||
@@ -176,6 +160,8 @@ class SkillsLoader:
|
||||
def get_content(self, name: str) -> str:
|
||||
"""Return the full documentation for a skill (used by the load_skill tool).
|
||||
|
||||
Falls back to disk lookup for user skills created mid-session.
|
||||
|
||||
Args:
|
||||
name: Skill name.
|
||||
|
||||
@@ -185,5 +171,13 @@ class SkillsLoader:
|
||||
for skill in self.skills:
|
||||
if skill.name == name:
|
||||
return f'<skill name="{name}">\n{skill.body}\n</skill>'
|
||||
|
||||
# Fallback: check user skills directory on disk (mid-session created skills)
|
||||
if self._user_skills_dir:
|
||||
skill = _load_skill_dir(self._user_skills_dir / name)
|
||||
if skill:
|
||||
self.skills.append(skill)
|
||||
return f'<skill name="{name}">\n{skill.body}\n</skill>'
|
||||
|
||||
available = ", ".join(s.name for s in self.skills)
|
||||
return f"Error: Unknown skill '{name}'. Available: {available}"
|
||||
@@ -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
|
||||
|
||||
@@ -13,7 +13,7 @@ from typing import Any, Dict, Optional
|
||||
from rich.console import Console
|
||||
|
||||
|
||||
console = Console()
|
||||
console = Console(stderr=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -109,19 +109,6 @@ def _expand_artifacts_spec(spec: Dict[str, Any] | None) -> Dict[str, Dict[str, A
|
||||
class Runner:
|
||||
"""Execute entry scripts inside a run directory and collect outputs."""
|
||||
|
||||
_PROXY_ENV_KEYS = (
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"all_proxy",
|
||||
"GIT_HTTP_PROXY",
|
||||
"GIT_HTTPS_PROXY",
|
||||
"git_http_proxy",
|
||||
"git_https_proxy",
|
||||
)
|
||||
|
||||
def __init__(self, timeout: int = 300, artifacts_spec: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""Initialize runner.
|
||||
|
||||
@@ -147,6 +134,7 @@ class Runner:
|
||||
try:
|
||||
probe = subprocess.run(
|
||||
[python_cmd, "-c", "import pandas,numpy; print('ok')"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
@@ -201,13 +189,9 @@ class Runner:
|
||||
existing = env.get("PYTHONPATH", "")
|
||||
env["PYTHONPATH"] = str(pythonpath_extra) + (os.pathsep + existing if existing else "")
|
||||
|
||||
runtime_home = run_dir / ".home"
|
||||
runtime_home.mkdir(parents=True, exist_ok=True)
|
||||
env["HOME"] = str(runtime_home)
|
||||
if os.name == "nt":
|
||||
env["USERPROFILE"] = str(runtime_home)
|
||||
|
||||
# Preserve system proxy settings; data sources (OKX/yfinance) need network access
|
||||
# NOTE: do NOT override HOME/USERPROFILE — data libraries (yfinance, akshare)
|
||||
# cache downloads under ~/; overriding HOME causes full re-download every run.
|
||||
|
||||
return env
|
||||
|
||||
@@ -252,6 +236,7 @@ class Runner:
|
||||
process = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(effective_cwd),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
|
||||
+3
-107
@@ -1,19 +1,16 @@
|
||||
"""Run state persistence: lightweight, no dependency on legacy agent types."""
|
||||
"""Run state persistence: creates run directories and records status."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
class RunStateStore:
|
||||
"""Run state store: records key files such as req/design/code/logs/review."""
|
||||
"""Run state store: manages run directories and their lifecycle status."""
|
||||
|
||||
def create_run_dir(self, workspace: Path) -> Path:
|
||||
"""Create a unique run directory.
|
||||
@@ -48,59 +45,6 @@ class RunStateStore:
|
||||
self._write_json(run_dir / "req.json", payload)
|
||||
return payload
|
||||
|
||||
def save_planner_output(self, run_dir: Path, planner_output: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Save planner output.
|
||||
|
||||
Args:
|
||||
run_dir: Run directory.
|
||||
planner_output: Planner output dict.
|
||||
|
||||
Returns:
|
||||
Saved payload.
|
||||
"""
|
||||
self._write_json(run_dir / "planner_output.json", planner_output)
|
||||
return planner_output
|
||||
|
||||
def save_design(self, run_dir: Path, spec: Dict[str, Any], decision: Dict[str, Any]) -> None:
|
||||
"""Save design spec and judge decision.
|
||||
|
||||
Args:
|
||||
run_dir: Run directory.
|
||||
spec: Strategy design spec.
|
||||
decision: Judge decision result.
|
||||
"""
|
||||
self._write_json(run_dir / "design_spec.json", spec)
|
||||
self._write_json(run_dir / "judge_decision.json", decision)
|
||||
|
||||
def save_rag_spec(
|
||||
self,
|
||||
run_dir: Path,
|
||||
selection: Dict[str, Any],
|
||||
spec: Dict[str, Any],
|
||||
*,
|
||||
candidates: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
"""Save RAG results.
|
||||
|
||||
Args:
|
||||
run_dir: Run directory.
|
||||
selection: Selected API metadata.
|
||||
spec: Enriched data API spec.
|
||||
candidates: Candidate API list.
|
||||
"""
|
||||
enriched_spec = copy.deepcopy(spec) if spec else {}
|
||||
enriched_spec["_rag_metadata"] = selection
|
||||
spec_yaml = yaml.safe_dump(enriched_spec, allow_unicode=True, sort_keys=False)
|
||||
(run_dir / "data_api_spec.yaml").write_text(spec_yaml, encoding="utf-8")
|
||||
self._write_json(run_dir / "rag_metadata.json", selection)
|
||||
if candidates:
|
||||
candidate_yaml = yaml.safe_dump(
|
||||
{"generated_at": datetime.now().isoformat(), "candidates": candidates},
|
||||
allow_unicode=True,
|
||||
sort_keys=False,
|
||||
)
|
||||
(run_dir / "rag_candidates.yaml").write_text(candidate_yaml, encoding="utf-8")
|
||||
|
||||
def mark_success(self, run_dir: Path) -> None:
|
||||
"""Mark the run as successful.
|
||||
|
||||
@@ -118,54 +62,6 @@ class RunStateStore:
|
||||
"""
|
||||
self._write_json(run_dir / "state.json", {"status": "failed", "reason": reason})
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Generic persistence: extract and save key artifacts by tool name
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
_PERSIST_MAP: Dict[str, str] = {
|
||||
"plan": "_persist_plan",
|
||||
"search": "_persist_search",
|
||||
"resolve": "_persist_resolve",
|
||||
"design": "_persist_design",
|
||||
}
|
||||
|
||||
def persist_tool_result(self, tool_name: str, result_data: Dict[str, Any], run_dir: Path) -> None:
|
||||
"""Persist key artifacts by tool name.
|
||||
|
||||
Args:
|
||||
tool_name: Tool name.
|
||||
result_data: Parsed tool result dict.
|
||||
run_dir: Run directory.
|
||||
"""
|
||||
method_name = self._PERSIST_MAP.get(tool_name)
|
||||
if method_name:
|
||||
method = getattr(self, method_name, None)
|
||||
if method:
|
||||
try:
|
||||
method(result_data, run_dir)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _persist_plan(self, data: Dict[str, Any], run_dir: Path) -> None:
|
||||
if data and "error" not in data:
|
||||
self.save_planner_output(run_dir, data)
|
||||
|
||||
def _persist_search(self, data: Dict[str, Any], run_dir: Path) -> None:
|
||||
if data.get("selections"):
|
||||
sel = data["selections"][0] if data["selections"] else {}
|
||||
spec = data.get("data_api_spec") or {}
|
||||
self.save_rag_spec(run_dir, sel, spec, candidates=data.get("candidates"))
|
||||
|
||||
def _persist_resolve(self, data: Dict[str, Any], run_dir: Path) -> None:
|
||||
if data and data.get("status") == "ok":
|
||||
self._write_json(run_dir / "data_config.json", data)
|
||||
|
||||
def _persist_design(self, data: Dict[str, Any], run_dir: Path) -> None:
|
||||
spec = data.get("spec") or {}
|
||||
decision = data.get("judge_decision") or {}
|
||||
if spec:
|
||||
self.save_design(run_dir, spec, decision)
|
||||
|
||||
@staticmethod
|
||||
def _write_json(path: Path, data: Any) -> None:
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
@@ -0,0 +1 @@
|
||||
"""Persistent cross-session memory system."""
|
||||
@@ -0,0 +1,224 @@
|
||||
"""PersistentMemory: file-based cross-session memory, zero external dependencies.
|
||||
|
||||
Storage layout:
|
||||
~/.vibe-trading/memory/
|
||||
+-- MEMORY.md # Index (< 200 lines)
|
||||
+-- user_prefs.md # Individual memory entries with YAML frontmatter
|
||||
+-- project_btc.md
|
||||
+-- ...
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from src.agent.frontmatter import parse_frontmatter as _parse_frontmatter
|
||||
from typing import List, Optional
|
||||
|
||||
MEMORY_BASE = Path.home() / ".vibe-trading" / "memory"
|
||||
MAX_INDEX_LINES = 200
|
||||
MAX_ENTRY_CHARS = 8000
|
||||
MAX_RESULTS = 5
|
||||
METADATA_WEIGHT = 2.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MemoryEntry:
|
||||
"""A single memory entry on disk.
|
||||
|
||||
Attributes:
|
||||
path: File path.
|
||||
title: Memory title.
|
||||
description: One-line description (used for retrieval scoring).
|
||||
memory_type: Category (user/feedback/project/reference).
|
||||
body: Body text content.
|
||||
modified_at: File modification timestamp.
|
||||
"""
|
||||
|
||||
path: Path
|
||||
title: str
|
||||
description: str
|
||||
memory_type: str
|
||||
body: str
|
||||
modified_at: float
|
||||
|
||||
|
||||
def _tokenize(text: str) -> set[str]:
|
||||
"""Split text into searchable tokens.
|
||||
|
||||
ASCII words >= 3 chars + CJK individual characters.
|
||||
|
||||
Args:
|
||||
text: Input text.
|
||||
|
||||
Returns:
|
||||
Set of tokens.
|
||||
"""
|
||||
ascii_tokens = set(re.findall(r"[a-zA-Z0-9_]{3,}", text.lower()))
|
||||
cjk_tokens = set(re.findall(r"[\u4e00-\u9fff\u3400-\u4dbf]", text))
|
||||
return ascii_tokens | cjk_tokens
|
||||
|
||||
|
||||
class PersistentMemory:
|
||||
"""File-based persistent memory that survives across sessions.
|
||||
|
||||
Design:
|
||||
- Frozen snapshot injected into system prompt at session start (preserves prompt cache).
|
||||
- Disk writes via add()/remove() update files immediately but do NOT change the snapshot.
|
||||
- Next session picks up the updated state.
|
||||
|
||||
Attributes:
|
||||
snapshot: Frozen memory index text for system prompt injection.
|
||||
"""
|
||||
|
||||
def __init__(self, memory_dir: Optional[Path] = None) -> None:
|
||||
"""Initialize PersistentMemory.
|
||||
|
||||
Args:
|
||||
memory_dir: Override memory directory (default: ~/.vibe-trading/memory/).
|
||||
"""
|
||||
self._dir = memory_dir or MEMORY_BASE
|
||||
self._dir.mkdir(parents=True, exist_ok=True)
|
||||
self._index_path = self._dir / "MEMORY.md"
|
||||
self._snapshot: str = ""
|
||||
self._load_snapshot()
|
||||
|
||||
def _load_snapshot(self) -> None:
|
||||
"""Load index as frozen snapshot. Called once at init."""
|
||||
if self._index_path.exists():
|
||||
try:
|
||||
text = self._index_path.read_text(encoding="utf-8")
|
||||
lines = text.split("\n")[:MAX_INDEX_LINES]
|
||||
self._snapshot = "\n".join(lines)
|
||||
except OSError:
|
||||
self._snapshot = ""
|
||||
|
||||
@property
|
||||
def snapshot(self) -> str:
|
||||
"""Frozen memory index for system prompt injection."""
|
||||
return self._snapshot
|
||||
|
||||
def _scan_entries(self) -> List[MemoryEntry]:
|
||||
"""Scan all .md files (except MEMORY.md) and parse frontmatter.
|
||||
|
||||
Returns:
|
||||
List of parsed memory entries.
|
||||
"""
|
||||
entries: List[MemoryEntry] = []
|
||||
for path in sorted(self._dir.glob("*.md")):
|
||||
if path.name == "MEMORY.md":
|
||||
continue
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
meta, body = _parse_frontmatter(text)
|
||||
entries.append(MemoryEntry(
|
||||
path=path,
|
||||
title=meta.get("name", path.stem),
|
||||
description=meta.get("description", ""),
|
||||
memory_type=meta.get("type", "project"),
|
||||
body=body[:MAX_ENTRY_CHARS],
|
||||
modified_at=path.stat().st_mtime,
|
||||
))
|
||||
return entries
|
||||
|
||||
def find_relevant(self, query: str, max_results: int = MAX_RESULTS) -> List[MemoryEntry]:
|
||||
"""Keyword search across all memory entries.
|
||||
|
||||
Scoring: metadata_hits * 2.0 + body_hits * 1.0.
|
||||
|
||||
Args:
|
||||
query: Search query.
|
||||
max_results: Maximum entries to return.
|
||||
|
||||
Returns:
|
||||
Top-scoring memory entries.
|
||||
"""
|
||||
query_tokens = _tokenize(query)
|
||||
if not query_tokens:
|
||||
return []
|
||||
|
||||
scored: list[tuple[float, MemoryEntry]] = []
|
||||
for entry in self._scan_entries():
|
||||
meta_tokens = _tokenize(f"{entry.title} {entry.description}")
|
||||
body_tokens = _tokenize(entry.body)
|
||||
score = len(query_tokens & meta_tokens) * METADATA_WEIGHT + len(query_tokens & body_tokens)
|
||||
if score > 0:
|
||||
scored.append((score, entry))
|
||||
|
||||
scored.sort(key=lambda x: (-x[0], -x[1].modified_at))
|
||||
return [entry for _, entry in scored[:max_results]]
|
||||
|
||||
def add(self, name: str, content: str, memory_type: str = "project",
|
||||
description: str = "") -> Path:
|
||||
"""Save a new memory entry and update the index.
|
||||
|
||||
Args:
|
||||
name: Memory name (used as filename slug).
|
||||
content: Memory body text.
|
||||
memory_type: One of user/feedback/project/reference.
|
||||
description: One-line description for retrieval scoring.
|
||||
|
||||
Returns:
|
||||
Path to the created memory file.
|
||||
"""
|
||||
slug = re.sub(r"[^a-z0-9_-]", "_", name.lower().strip())[:60]
|
||||
filename = f"{memory_type}_{slug}.md"
|
||||
path = self._dir / filename
|
||||
|
||||
safe_name = name.replace("\n", " ").replace("\r", " ")
|
||||
safe_desc = (description or name).replace("\n", " ").replace("\r", " ")
|
||||
frontmatter = (
|
||||
f"---\nname: {safe_name}\n"
|
||||
f"description: {safe_desc}\n"
|
||||
f"type: {memory_type}\n---\n\n"
|
||||
f"{content}"
|
||||
)
|
||||
path.write_text(frontmatter, encoding="utf-8")
|
||||
self._update_index(name, filename, description or name)
|
||||
return path
|
||||
|
||||
def remove(self, name: str) -> bool:
|
||||
"""Remove a memory entry by name.
|
||||
|
||||
Args:
|
||||
name: Memory name to remove.
|
||||
|
||||
Returns:
|
||||
True if found and removed.
|
||||
"""
|
||||
for entry in self._scan_entries():
|
||||
if entry.title == name:
|
||||
entry.path.unlink(missing_ok=True)
|
||||
self._rebuild_index()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _update_index(self, title: str, filename: str, description: str) -> None:
|
||||
"""Append or update an entry in MEMORY.md."""
|
||||
new_line = f"- [{title}]({filename}) — {description}"
|
||||
|
||||
if self._index_path.exists():
|
||||
lines = self._index_path.read_text(encoding="utf-8").split("\n")
|
||||
updated = False
|
||||
for i, line in enumerate(lines):
|
||||
if f"[{title}]" in line:
|
||||
lines[i] = new_line
|
||||
updated = True
|
||||
break
|
||||
if not updated:
|
||||
lines.append(new_line)
|
||||
text = "\n".join(lines[:MAX_INDEX_LINES])
|
||||
else:
|
||||
text = new_line
|
||||
|
||||
self._index_path.write_text(text, encoding="utf-8")
|
||||
|
||||
def _rebuild_index(self) -> None:
|
||||
"""Rebuild MEMORY.md from all existing entry files."""
|
||||
entries = self._scan_entries()
|
||||
lines = [f"- [{e.title}]({e.path.name}) — {e.description}" for e in entries]
|
||||
self._index_path.write_text("\n".join(lines[:MAX_INDEX_LINES]), encoding="utf-8")
|
||||
@@ -54,6 +54,35 @@ def _check_llm_provider() -> CheckResult:
|
||||
_sync_provider_env()
|
||||
base_url = os.getenv("OPENAI_BASE_URL", "") or os.getenv("OPENAI_API_BASE", "")
|
||||
|
||||
if provider.lower() in {"openai-codex", "openai_codex"}:
|
||||
try:
|
||||
from src.providers.openai_codex import get_openai_codex_login_status
|
||||
|
||||
token = get_openai_codex_login_status()
|
||||
except Exception as exc:
|
||||
return CheckResult(
|
||||
name=f"LLM ({provider})",
|
||||
status="error",
|
||||
message=f"OAuth status unavailable: {exc}",
|
||||
impact="run `vibe-trading provider login openai-codex`",
|
||||
critical=True,
|
||||
)
|
||||
if not token:
|
||||
return CheckResult(
|
||||
name=f"LLM ({provider})",
|
||||
status="not_configured",
|
||||
message="ChatGPT OAuth login not found",
|
||||
impact="run `vibe-trading provider login openai-codex`",
|
||||
critical=True,
|
||||
)
|
||||
account = getattr(token, "account_id", None) or "authenticated account"
|
||||
return CheckResult(
|
||||
name=f"LLM ({provider})",
|
||||
status="ready",
|
||||
message=f"{model} via ChatGPT OAuth ({account})",
|
||||
impact="",
|
||||
)
|
||||
|
||||
if not base_url:
|
||||
return CheckResult(
|
||||
name=f"LLM ({provider})",
|
||||
|
||||
+27
-17
@@ -11,6 +11,18 @@ from typing import Any, Dict, List, Optional
|
||||
from src.providers.llm import build_llm
|
||||
|
||||
|
||||
def _dedupe_finish_reason(raw: str) -> str:
|
||||
"""Relays (OpenRouter) emit finish_reason per chunk; AIMessageChunk.__add__
|
||||
concatenates into 'stopstop', 'tool_callstool_calls', etc. Return the
|
||||
canonical suffix so ReAct equality checks survive.
|
||||
"""
|
||||
return next(
|
||||
(m for m in ("tool_calls", "function_call", "content_filter", "length", "stop")
|
||||
if raw.endswith(m)),
|
||||
raw,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCallRequest:
|
||||
"""Tool call request returned by the LLM.
|
||||
@@ -38,6 +50,7 @@ class LLMResponse:
|
||||
|
||||
content: Optional[str] = None
|
||||
tool_calls: List[ToolCallRequest] = field(default_factory=list)
|
||||
reasoning_content: Optional[str] = None
|
||||
finish_reason: str = "stop"
|
||||
|
||||
@property
|
||||
@@ -133,22 +146,19 @@ class ChatLLM:
|
||||
|
||||
@staticmethod
|
||||
def _parse_response(ai_message: Any) -> LLMResponse:
|
||||
"""Convert a LangChain AIMessage to ``LLMResponse``.
|
||||
"""Convert a LangChain AIMessage (or AIMessageChunk) to ``LLMResponse``.
|
||||
|
||||
Args:
|
||||
ai_message: LangChain AIMessage instance.
|
||||
|
||||
Returns:
|
||||
``LLMResponse``.
|
||||
Single source for reasoning: ``additional_kwargs["reasoning_content"]``,
|
||||
populated by ``ChatOpenAIWithReasoning`` on both stream and non-stream paths.
|
||||
"""
|
||||
content = ai_message.content if hasattr(ai_message, "content") else None
|
||||
raw_calls = getattr(ai_message, "tool_calls", None) or []
|
||||
tool_calls = []
|
||||
for tc in raw_calls:
|
||||
tool_calls.append(ToolCallRequest(
|
||||
id=tc.get("id", ""),
|
||||
name=tc.get("name", ""),
|
||||
arguments=tc.get("args", {}),
|
||||
))
|
||||
finish = getattr(ai_message, "response_metadata", {}).get("finish_reason", "stop")
|
||||
return LLMResponse(content=content, tool_calls=tool_calls, finish_reason=finish)
|
||||
return LLMResponse(
|
||||
content=ai_message.content,
|
||||
tool_calls=[
|
||||
ToolCallRequest(id=tc["id"], name=tc["name"], arguments=tc["args"])
|
||||
for tc in ai_message.tool_calls
|
||||
],
|
||||
reasoning_content=ai_message.additional_kwargs.get("reasoning_content"),
|
||||
finish_reason=_dedupe_finish_reason(
|
||||
ai_message.response_metadata.get("finish_reason", "stop")
|
||||
),
|
||||
)
|
||||
+102
-7
@@ -17,6 +17,75 @@ try:
|
||||
except ImportError:
|
||||
ChatOpenAI = None # type: ignore
|
||||
|
||||
|
||||
if ChatOpenAI is not None:
|
||||
class ChatOpenAIWithReasoning(ChatOpenAI): # type: ignore[misc,valid-type]
|
||||
"""ChatOpenAI that preserves provider reasoning across invoke + stream.
|
||||
|
||||
langchain-openai 0.3.x drops non-standard fields in three paths:
|
||||
* _convert_dict_to_message — invoke / ainvoke (inbound)
|
||||
* _convert_delta_to_message_chunk — stream / astream (inbound)
|
||||
* _convert_message_to_dict — request serialization (outbound)
|
||||
Moonshot/DeepSeek emit `reasoning_content`; OpenRouter relays as
|
||||
`reasoning`. Inbound paths normalize to additional_kwargs["reasoning_content"];
|
||||
outbound path re-injects it so strict providers (kimi-k2.5) accept
|
||||
multi-turn continuations.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _capture(src: Any, msg: Any) -> None:
|
||||
if value := src.get("reasoning_content") or src.get("reasoning"):
|
||||
msg.additional_kwargs["reasoning_content"] = value
|
||||
|
||||
def _create_chat_result(self, response, generation_info=None): # type: ignore[override]
|
||||
result = super()._create_chat_result(response, generation_info)
|
||||
raw = response if isinstance(response, dict) else response.model_dump()
|
||||
for gen, choice in zip(result.generations, raw["choices"]):
|
||||
self._capture(choice["message"], gen.message)
|
||||
return result
|
||||
|
||||
def _convert_chunk_to_generation_chunk( # type: ignore[override]
|
||||
self,
|
||||
chunk: dict,
|
||||
default_chunk_class: type,
|
||||
base_generation_info: Optional[dict],
|
||||
):
|
||||
gen = super()._convert_chunk_to_generation_chunk(
|
||||
chunk, default_chunk_class, base_generation_info
|
||||
)
|
||||
if gen is None:
|
||||
return None
|
||||
choices = chunk.get("choices") or chunk.get("chunk", {}).get("choices")
|
||||
if choices:
|
||||
self._capture(choices[0]["delta"], gen.message)
|
||||
return gen
|
||||
|
||||
def _get_request_payload( # type: ignore[override]
|
||||
self,
|
||||
input_: Any,
|
||||
*,
|
||||
stop: Optional[list[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> dict:
|
||||
"""Re-inject reasoning_content and normalize assistant content.
|
||||
|
||||
LangChain strips ``reasoning_content`` when serializing AIMessages
|
||||
back to OpenAI wire format. Moonshot kimi-k2.5 also rejects
|
||||
assistant turns where ``content`` is null or ``reasoning_content``
|
||||
is absent, breaking ReAct continuations after a tool call (#39).
|
||||
"""
|
||||
payload = super()._get_request_payload(input_, stop=stop, **kwargs)
|
||||
messages = super()._convert_input(input_).to_messages()
|
||||
for i, m in enumerate(payload["messages"]):
|
||||
if m.get("role") != "assistant":
|
||||
continue
|
||||
if m.get("content") is None:
|
||||
m["content"] = ""
|
||||
m["reasoning_content"] = messages[i].additional_kwargs.get("reasoning_content", "")
|
||||
return payload
|
||||
else:
|
||||
ChatOpenAIWithReasoning = None # type: ignore
|
||||
|
||||
AGENT_DIR = Path(__file__).resolve().parents[2]
|
||||
|
||||
# .env search order: ~/.vibe-trading/.env → agent/.env → $CWD/.env
|
||||
@@ -66,6 +135,13 @@ def _sync_provider_env() -> None:
|
||||
_ensure_dotenv()
|
||||
provider = os.getenv("LANGCHAIN_PROVIDER", "openai").lower()
|
||||
|
||||
if provider in {"openai-codex", "openai_codex"}:
|
||||
codex_url = os.getenv("OPENAI_CODEX_BASE_URL", "https://chatgpt.com/backend-api/codex/responses")
|
||||
os.environ["OPENAI_API_BASE"] = codex_url
|
||||
os.environ["OPENAI_BASE_URL"] = codex_url
|
||||
os.environ.pop("OPENAI_API_KEY", None)
|
||||
return
|
||||
|
||||
# (api_key_env, base_url_env)
|
||||
_PROVIDER_MAP: dict[str, tuple[str | None, str]] = {
|
||||
"openai": ("OPENAI_API_KEY", "OPENAI_BASE_URL"),
|
||||
@@ -79,6 +155,7 @@ def _sync_provider_env() -> None:
|
||||
"moonshot": ("MOONSHOT_API_KEY", "MOONSHOT_BASE_URL"),
|
||||
"minimax": ("MINIMAX_API_KEY", "MINIMAX_BASE_URL"),
|
||||
"mimo": ("MIMO_API_KEY", "MIMO_BASE_URL"),
|
||||
"zai": ("ZAI_API_KEY", "ZAI_BASE_URL"),
|
||||
"ollama": (None, "OLLAMA_BASE_URL"),
|
||||
}
|
||||
|
||||
@@ -114,21 +191,39 @@ def build_llm(*, model_name: Optional[str] = None, callbacks: Any = None) -> Any
|
||||
Raises:
|
||||
RuntimeError: If langchain-openai is missing or LANGCHAIN_MODEL_NAME is unset.
|
||||
"""
|
||||
if ChatOpenAI is None:
|
||||
raise RuntimeError("langchain-openai is not installed")
|
||||
_sync_provider_env()
|
||||
name = model_name or os.getenv("LANGCHAIN_MODEL_NAME", "").strip()
|
||||
if not name:
|
||||
raise RuntimeError("LANGCHAIN_MODEL_NAME is not set")
|
||||
temperature = float(os.getenv("LANGCHAIN_TEMPERATURE", "0.0"))
|
||||
timeout = int(os.getenv("TIMEOUT_SECONDS", "120"))
|
||||
max_retries = int(os.getenv("MAX_RETRIES", "2"))
|
||||
return ChatOpenAI(
|
||||
provider = os.getenv("LANGCHAIN_PROVIDER", "openai").lower()
|
||||
if provider in {"openai-codex", "openai_codex"}:
|
||||
from src.providers.openai_codex import OpenAICodexLLM
|
||||
|
||||
effort = os.getenv("LANGCHAIN_REASONING_EFFORT", "").strip().lower()
|
||||
return OpenAICodexLLM(
|
||||
model=name,
|
||||
temperature=temperature,
|
||||
timeout=int(os.getenv("TIMEOUT_SECONDS", "120")),
|
||||
reasoning_effort=effort or None,
|
||||
)
|
||||
|
||||
if ChatOpenAI is None:
|
||||
raise RuntimeError("langchain-openai is not installed")
|
||||
# MiniMax requires temperature in (0.0, 1.0] — clamp to 0.01 when the
|
||||
# default 0.0 is used to avoid an API validation error.
|
||||
if provider == "minimax" and temperature <= 0.0:
|
||||
temperature = 0.01
|
||||
# Optional reasoning activation for relays requiring opt-in (e.g. OpenRouter).
|
||||
# Moonshot/DeepSeek official APIs emit reasoning by default and ignore this field.
|
||||
effort = os.getenv("LANGCHAIN_REASONING_EFFORT", "").strip().lower()
|
||||
return ChatOpenAIWithReasoning(
|
||||
model=name,
|
||||
temperature=temperature,
|
||||
timeout=timeout,
|
||||
max_retries=max_retries,
|
||||
timeout=int(os.getenv("TIMEOUT_SECONDS", "120")),
|
||||
max_retries=int(os.getenv("MAX_RETRIES", "2")),
|
||||
callbacks=callbacks,
|
||||
extra_body={"reasoning": {"effort": effort}} if effort else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
[
|
||||
{
|
||||
"name": "openrouter",
|
||||
"label": "OpenRouter",
|
||||
"api_key_env": "OPENROUTER_API_KEY",
|
||||
"base_url_env": "OPENROUTER_BASE_URL",
|
||||
"default_model": "deepseek/deepseek-v3.2",
|
||||
"default_base_url": "https://openrouter.ai/api/v1",
|
||||
"api_key_required": true
|
||||
},
|
||||
{
|
||||
"name": "openai",
|
||||
"label": "OpenAI",
|
||||
"api_key_env": "OPENAI_API_KEY",
|
||||
"base_url_env": "OPENAI_BASE_URL",
|
||||
"default_model": "gpt-4o",
|
||||
"default_base_url": "https://api.openai.com/v1",
|
||||
"api_key_required": true
|
||||
},
|
||||
{
|
||||
"name": "openai-codex",
|
||||
"label": "OpenAI Codex (ChatGPT OAuth)",
|
||||
"api_key_env": null,
|
||||
"base_url_env": "OPENAI_CODEX_BASE_URL",
|
||||
"default_model": "openai-codex/gpt-5.3-codex",
|
||||
"default_base_url": "https://chatgpt.com/backend-api/codex/responses",
|
||||
"api_key_required": false,
|
||||
"auth_type": "oauth",
|
||||
"login_command": "vibe-trading provider login openai-codex"
|
||||
},
|
||||
{
|
||||
"name": "deepseek",
|
||||
"label": "DeepSeek",
|
||||
"api_key_env": "DEEPSEEK_API_KEY",
|
||||
"base_url_env": "DEEPSEEK_BASE_URL",
|
||||
"default_model": "deepseek-chat",
|
||||
"default_base_url": "https://api.deepseek.com/v1",
|
||||
"api_key_required": true
|
||||
},
|
||||
{
|
||||
"name": "gemini",
|
||||
"label": "Gemini",
|
||||
"api_key_env": "GEMINI_API_KEY",
|
||||
"base_url_env": "GEMINI_BASE_URL",
|
||||
"default_model": "gemini-2.5-flash",
|
||||
"default_base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
"api_key_required": true
|
||||
},
|
||||
{
|
||||
"name": "groq",
|
||||
"label": "Groq",
|
||||
"api_key_env": "GROQ_API_KEY",
|
||||
"base_url_env": "GROQ_BASE_URL",
|
||||
"default_model": "llama-3.3-70b-versatile",
|
||||
"default_base_url": "https://api.groq.com/openai/v1",
|
||||
"api_key_required": true
|
||||
},
|
||||
{
|
||||
"name": "dashscope",
|
||||
"label": "DashScope / Qwen",
|
||||
"api_key_env": "DASHSCOPE_API_KEY",
|
||||
"base_url_env": "DASHSCOPE_BASE_URL",
|
||||
"default_model": "qwen-plus",
|
||||
"default_base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"api_key_required": true
|
||||
},
|
||||
{
|
||||
"name": "qwen",
|
||||
"label": "Qwen",
|
||||
"api_key_env": "DASHSCOPE_API_KEY",
|
||||
"base_url_env": "DASHSCOPE_BASE_URL",
|
||||
"default_model": "qwen-plus",
|
||||
"default_base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"api_key_required": true
|
||||
},
|
||||
{
|
||||
"name": "zhipu",
|
||||
"label": "Zhipu",
|
||||
"api_key_env": "ZHIPU_API_KEY",
|
||||
"base_url_env": "ZHIPU_BASE_URL",
|
||||
"default_model": "glm-4-plus",
|
||||
"default_base_url": "https://open.bigmodel.cn/api/paas/v4",
|
||||
"api_key_required": true
|
||||
},
|
||||
{
|
||||
"name": "moonshot",
|
||||
"label": "Moonshot / Kimi",
|
||||
"api_key_env": "MOONSHOT_API_KEY",
|
||||
"base_url_env": "MOONSHOT_BASE_URL",
|
||||
"default_model": "kimi-k2.5",
|
||||
"default_base_url": "https://api.moonshot.ai/v1",
|
||||
"api_key_required": true
|
||||
},
|
||||
{
|
||||
"name": "minimax",
|
||||
"label": "MiniMax",
|
||||
"api_key_env": "MINIMAX_API_KEY",
|
||||
"base_url_env": "MINIMAX_BASE_URL",
|
||||
"default_model": "MiniMax-M2.7",
|
||||
"default_base_url": "https://api.minimax.io/v1",
|
||||
"api_key_required": true
|
||||
},
|
||||
{
|
||||
"name": "mimo",
|
||||
"label": "Xiaomi MIMO",
|
||||
"api_key_env": "MIMO_API_KEY",
|
||||
"base_url_env": "MIMO_BASE_URL",
|
||||
"default_model": "MiMo-72B-A27B",
|
||||
"default_base_url": "https://api.xiaomimimo.com/v1",
|
||||
"api_key_required": true
|
||||
},
|
||||
{
|
||||
"name": "zai",
|
||||
"label": "Z.ai",
|
||||
"api_key_env": "ZAI_API_KEY",
|
||||
"base_url_env": "ZAI_BASE_URL",
|
||||
"default_model": "glm-5.1",
|
||||
"default_base_url": "https://api.z.ai/api/coding/paas/v4",
|
||||
"api_key_required": true
|
||||
},
|
||||
{
|
||||
"name": "ollama",
|
||||
"label": "Ollama",
|
||||
"api_key_env": null,
|
||||
"base_url_env": "OLLAMA_BASE_URL",
|
||||
"default_model": "qwen2.5:32b",
|
||||
"default_base_url": "http://localhost:11434/v1",
|
||||
"api_key_required": false
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,412 @@
|
||||
"""OpenAI Codex OAuth provider.
|
||||
|
||||
This provider follows nanobot's OpenAI Codex OAuth path: a ChatGPT account is
|
||||
authenticated by oauth-cli-kit, then requests are sent to the ChatGPT Codex
|
||||
Responses endpoint. It is intentionally separate from the standard OpenAI API
|
||||
key path because ChatGPT OAuth tokens are not OpenAI API keys.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Iterable, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
httpx = None # type: ignore
|
||||
|
||||
DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
|
||||
DEFAULT_ORIGINATOR = "vibe-trading"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodexToolCall:
|
||||
"""Internal tool-call representation compatible with ChatLLM parsing."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
arguments: dict[str, Any]
|
||||
|
||||
def as_langchain_tool_call(self) -> dict[str, Any]:
|
||||
return {"id": self.id, "name": self.name, "args": self.arguments}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodexAIMessage:
|
||||
"""Small LangChain-like message object used by ChatLLM."""
|
||||
|
||||
content: str = ""
|
||||
tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
||||
additional_kwargs: dict[str, Any] = field(default_factory=dict)
|
||||
response_metadata: dict[str, Any] = field(default_factory=lambda: {"finish_reason": "stop"})
|
||||
|
||||
def __add__(self, other: "CodexAIMessage") -> "CodexAIMessage":
|
||||
finish_reason = other.response_metadata.get(
|
||||
"finish_reason",
|
||||
self.response_metadata.get("finish_reason", "stop"),
|
||||
)
|
||||
reasoning = (
|
||||
self.additional_kwargs.get("reasoning_content", "")
|
||||
+ other.additional_kwargs.get("reasoning_content", "")
|
||||
)
|
||||
return CodexAIMessage(
|
||||
content=(self.content or "") + (other.content or ""),
|
||||
tool_calls=[*self.tool_calls, *other.tool_calls],
|
||||
additional_kwargs={"reasoning_content": reasoning} if reasoning else {},
|
||||
response_metadata={"finish_reason": finish_reason},
|
||||
)
|
||||
|
||||
|
||||
def login_openai_codex(
|
||||
print_fn: Callable[[str], None] | None = None,
|
||||
prompt_fn: Callable[[str], str] | None = None,
|
||||
) -> Any:
|
||||
"""Run interactive ChatGPT/Codex OAuth login and persist the token."""
|
||||
try:
|
||||
from oauth_cli_kit import get_token, login_oauth_interactive
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("oauth-cli-kit is not installed. Run: pip install oauth-cli-kit") from exc
|
||||
|
||||
token = None
|
||||
try:
|
||||
token = get_token()
|
||||
except Exception:
|
||||
pass
|
||||
if token and getattr(token, "access", None):
|
||||
return token
|
||||
return login_oauth_interactive(print_fn=print_fn or print, prompt_fn=prompt_fn or input)
|
||||
|
||||
|
||||
def get_openai_codex_login_status() -> Any | None:
|
||||
"""Return the persisted OAuth token, if available."""
|
||||
try:
|
||||
from oauth_cli_kit import get_token
|
||||
except ImportError:
|
||||
return None
|
||||
try:
|
||||
token = get_token()
|
||||
except Exception:
|
||||
return None
|
||||
if token and getattr(token, "access", None):
|
||||
return token
|
||||
return None
|
||||
|
||||
|
||||
def _get_codex_token() -> Any:
|
||||
try:
|
||||
from oauth_cli_kit import get_token
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"OpenAI Codex OAuth requires oauth-cli-kit. Install dependencies, then run: "
|
||||
"vibe-trading provider login openai-codex"
|
||||
) from exc
|
||||
try:
|
||||
token = get_token()
|
||||
except Exception as exc:
|
||||
raise RuntimeError("OpenAI Codex is not logged in. Run: vibe-trading provider login openai-codex") from exc
|
||||
if not (token and getattr(token, "access", None) and getattr(token, "account_id", None)):
|
||||
raise RuntimeError("OpenAI Codex is not logged in. Run: vibe-trading provider login openai-codex")
|
||||
return token
|
||||
|
||||
|
||||
def validate_codex_base_url(url: str) -> str:
|
||||
"""Validate the only supported ChatGPT Codex OAuth endpoint.
|
||||
|
||||
ChatGPT OAuth tokens must not be sent to arbitrary OpenAI-compatible base
|
||||
URLs. The standard OpenAI API remains API-key authenticated; this provider
|
||||
is limited to the ChatGPT Codex backend endpoint used by Codex OAuth.
|
||||
"""
|
||||
value = (url or DEFAULT_CODEX_URL).strip().rstrip("/")
|
||||
parsed = urlparse(value)
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or parsed.netloc != "chatgpt.com"
|
||||
or parsed.path != "/backend-api/codex/responses"
|
||||
):
|
||||
raise ValueError(
|
||||
"OpenAI Codex OAuth only supports https://chatgpt.com/backend-api/codex/responses"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _build_headers(account_id: str, access_token: str) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"chatgpt-account-id": account_id,
|
||||
"OpenAI-Beta": "responses=experimental",
|
||||
"originator": DEFAULT_ORIGINATOR,
|
||||
"User-Agent": "vibe-trading (python)",
|
||||
"accept": "text/event-stream",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _strip_model_prefix(model: str) -> str:
|
||||
if model.startswith("openai-codex/") or model.startswith("openai_codex/"):
|
||||
return model.split("/", 1)[1]
|
||||
return model
|
||||
|
||||
|
||||
def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
|
||||
raw = json.dumps(messages, ensure_ascii=True, sort_keys=True)
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _convert_user_message(content: Any) -> dict[str, Any]:
|
||||
if isinstance(content, str):
|
||||
return {"role": "user", "content": [{"type": "input_text", "text": content}]}
|
||||
if isinstance(content, list):
|
||||
converted: list[dict[str, Any]] = []
|
||||
for item in content:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get("type") == "text":
|
||||
converted.append({"type": "input_text", "text": item.get("text", "")})
|
||||
elif item.get("type") == "image_url":
|
||||
url = (item.get("image_url") or {}).get("url")
|
||||
if url:
|
||||
converted.append({"type": "input_image", "image_url": url, "detail": "auto"})
|
||||
if converted:
|
||||
return {"role": "user", "content": converted}
|
||||
return {"role": "user", "content": [{"type": "input_text", "text": ""}]}
|
||||
|
||||
|
||||
def _split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]:
|
||||
if isinstance(tool_call_id, str) and tool_call_id:
|
||||
if "|" in tool_call_id:
|
||||
call_id, item_id = tool_call_id.split("|", 1)
|
||||
return call_id, item_id or None
|
||||
return tool_call_id, None
|
||||
return "call_0", None
|
||||
|
||||
|
||||
def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
|
||||
system_prompt = ""
|
||||
input_items: list[dict[str, Any]] = []
|
||||
for idx, msg in enumerate(messages):
|
||||
role = msg.get("role")
|
||||
content = msg.get("content")
|
||||
if role == "system":
|
||||
system_prompt = content if isinstance(content, str) else ""
|
||||
elif role == "user":
|
||||
input_items.append(_convert_user_message(content))
|
||||
elif role == "assistant":
|
||||
if isinstance(content, str) and content:
|
||||
input_items.append({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": content}],
|
||||
"status": "completed",
|
||||
"id": f"msg_{idx}",
|
||||
})
|
||||
for tool_call in msg.get("tool_calls", []) or []:
|
||||
fn = tool_call.get("function") or {}
|
||||
call_id, item_id = _split_tool_call_id(tool_call.get("id"))
|
||||
input_items.append({
|
||||
"type": "function_call",
|
||||
"id": item_id or f"fc_{idx}",
|
||||
"call_id": call_id or f"call_{idx}",
|
||||
"name": fn.get("name"),
|
||||
"arguments": fn.get("arguments") or "{}",
|
||||
})
|
||||
elif role == "tool":
|
||||
call_id, _ = _split_tool_call_id(msg.get("tool_call_id"))
|
||||
output = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False)
|
||||
input_items.append({"type": "function_call_output", "call_id": call_id, "output": output})
|
||||
return system_prompt, input_items
|
||||
|
||||
|
||||
def _convert_tools(tools: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
||||
converted: list[dict[str, Any]] = []
|
||||
for tool in tools or []:
|
||||
fn = (tool.get("function") or {}) if tool.get("type") == "function" else tool
|
||||
name = fn.get("name")
|
||||
if not name:
|
||||
continue
|
||||
params = fn.get("parameters") or {}
|
||||
converted.append({
|
||||
"type": "function",
|
||||
"name": name,
|
||||
"description": fn.get("description") or "",
|
||||
"parameters": params if isinstance(params, dict) else {},
|
||||
})
|
||||
return converted
|
||||
|
||||
|
||||
def _decode_tool_args(raw: Any) -> dict[str, Any]:
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if not isinstance(raw, str) or not raw:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return {"raw": raw}
|
||||
return parsed if isinstance(parsed, dict) else {"raw": raw}
|
||||
|
||||
|
||||
def _map_finish_reason(status: str | None) -> str:
|
||||
return {
|
||||
"completed": "stop",
|
||||
"incomplete": "length",
|
||||
"failed": "error",
|
||||
"cancelled": "error",
|
||||
}.get(status or "completed", "stop")
|
||||
|
||||
|
||||
def _events_from_lines(lines: Iterable[str]) -> Iterable[dict[str, Any]]:
|
||||
buffer: list[str] = []
|
||||
|
||||
def flush() -> dict[str, Any] | None:
|
||||
data_lines = [line[5:].strip() for line in buffer if line.startswith("data:")]
|
||||
buffer.clear()
|
||||
if not data_lines:
|
||||
return None
|
||||
data = "\n".join(data_lines).strip()
|
||||
if not data or data == "[DONE]":
|
||||
return None
|
||||
try:
|
||||
return json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
for line in lines:
|
||||
if line == "":
|
||||
if buffer:
|
||||
event = flush()
|
||||
if event is not None:
|
||||
yield event
|
||||
continue
|
||||
buffer.append(line)
|
||||
if buffer:
|
||||
event = flush()
|
||||
if event is not None:
|
||||
yield event
|
||||
|
||||
|
||||
def _message_chunks_from_events(events: Iterable[dict[str, Any]]) -> Iterable[CodexAIMessage]:
|
||||
tool_buffers: dict[str, dict[str, Any]] = {}
|
||||
for event in events:
|
||||
event_type = event.get("type")
|
||||
if event_type == "response.output_item.added":
|
||||
item = event.get("item") or {}
|
||||
if item.get("type") == "function_call" and item.get("call_id"):
|
||||
tool_buffers[item["call_id"]] = {
|
||||
"id": item.get("id") or "fc_0",
|
||||
"name": item.get("name") or "",
|
||||
"arguments": item.get("arguments") or "",
|
||||
}
|
||||
elif event_type == "response.output_text.delta":
|
||||
delta = event.get("delta") or ""
|
||||
if delta:
|
||||
yield CodexAIMessage(content=delta)
|
||||
elif event_type == "response.function_call_arguments.delta":
|
||||
call_id = event.get("call_id")
|
||||
if call_id in tool_buffers:
|
||||
tool_buffers[call_id]["arguments"] += event.get("delta") or ""
|
||||
elif event_type == "response.function_call_arguments.done":
|
||||
call_id = event.get("call_id")
|
||||
if call_id in tool_buffers:
|
||||
tool_buffers[call_id]["arguments"] = event.get("arguments") or ""
|
||||
elif event_type == "response.output_item.done":
|
||||
item = event.get("item") or {}
|
||||
if item.get("type") == "function_call" and item.get("call_id"):
|
||||
call_id = item["call_id"]
|
||||
buf = tool_buffers.get(call_id) or {}
|
||||
args_raw = buf.get("arguments") or item.get("arguments") or "{}"
|
||||
tool = CodexToolCall(
|
||||
id=f"{call_id}|{buf.get('id') or item.get('id') or 'fc_0'}",
|
||||
name=buf.get("name") or item.get("name") or "",
|
||||
arguments=_decode_tool_args(args_raw),
|
||||
)
|
||||
yield CodexAIMessage(tool_calls=[tool.as_langchain_tool_call()])
|
||||
elif event_type == "response.completed":
|
||||
status = (event.get("response") or {}).get("status")
|
||||
yield CodexAIMessage(response_metadata={"finish_reason": _map_finish_reason(status)})
|
||||
elif event_type in {"error", "response.failed"}:
|
||||
detail = event.get("error") or event.get("message") or event
|
||||
raise RuntimeError(f"OpenAI Codex response failed: {str(detail)[:500]}")
|
||||
|
||||
|
||||
class OpenAICodexLLM:
|
||||
"""Minimal LangChain-compatible adapter for Vibe-Trading's ChatLLM."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
temperature: float = 0.0,
|
||||
timeout: int = 120,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
reasoning_effort: str | None = None,
|
||||
codex_url: str | None = None,
|
||||
) -> None:
|
||||
if httpx is None:
|
||||
raise RuntimeError("OpenAI Codex OAuth requires httpx. Install dependencies first.")
|
||||
self.model = model
|
||||
self.temperature = temperature
|
||||
self.timeout = timeout
|
||||
self.tools = tools or []
|
||||
self.reasoning_effort = reasoning_effort
|
||||
self.codex_url = validate_codex_base_url(
|
||||
codex_url or os.getenv("OPENAI_CODEX_BASE_URL", DEFAULT_CODEX_URL)
|
||||
)
|
||||
|
||||
def bind_tools(self, tools: list[dict[str, Any]]) -> "OpenAICodexLLM":
|
||||
return OpenAICodexLLM(
|
||||
model=self.model,
|
||||
temperature=self.temperature,
|
||||
timeout=self.timeout,
|
||||
tools=tools,
|
||||
reasoning_effort=self.reasoning_effort,
|
||||
codex_url=self.codex_url,
|
||||
)
|
||||
|
||||
def _body(self, messages: list[dict[str, Any]], *, stream: bool) -> dict[str, Any]:
|
||||
system_prompt, input_items = _convert_messages(messages)
|
||||
body: dict[str, Any] = {
|
||||
"model": _strip_model_prefix(self.model),
|
||||
"store": False,
|
||||
"stream": stream,
|
||||
"instructions": system_prompt,
|
||||
"input": input_items,
|
||||
"text": {"verbosity": "medium"},
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"prompt_cache_key": _prompt_cache_key(messages),
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": True,
|
||||
}
|
||||
tools = _convert_tools(self.tools)
|
||||
if tools:
|
||||
body["tools"] = tools
|
||||
if self.reasoning_effort and self.reasoning_effort.lower() != "none":
|
||||
body["reasoning"] = {"effort": self.reasoning_effort.lower()}
|
||||
return body
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
token = _get_codex_token()
|
||||
return _build_headers(str(token.account_id), str(token.access))
|
||||
|
||||
def stream(self, messages: list[dict[str, Any]], config: Optional[dict[str, Any]] = None) -> Iterable[CodexAIMessage]:
|
||||
timeout = (config or {}).get("timeout") or self.timeout
|
||||
with httpx.Client(timeout=timeout, follow_redirects=True, trust_env=True) as client:
|
||||
with client.stream("POST", self.codex_url, headers=self._headers(), json=self._body(messages, stream=True)) as response:
|
||||
if response.status_code != 200:
|
||||
raw = response.read().decode("utf-8", "ignore")
|
||||
raise RuntimeError(f"OpenAI Codex HTTP {response.status_code}: {raw[:500]}")
|
||||
yield from _message_chunks_from_events(_events_from_lines(response.iter_lines()))
|
||||
|
||||
def invoke(self, messages: list[dict[str, Any]], config: Optional[dict[str, Any]] = None) -> CodexAIMessage:
|
||||
accumulated: CodexAIMessage | None = None
|
||||
for chunk in self.stream(messages, config=config):
|
||||
accumulated = chunk if accumulated is None else accumulated + chunk
|
||||
return accumulated or CodexAIMessage()
|
||||
|
||||
async def ainvoke(self, messages: list[dict[str, Any]], config: Optional[dict[str, Any]] = None) -> CodexAIMessage:
|
||||
return await asyncio.to_thread(self.invoke, messages, config)
|
||||
@@ -0,0 +1,343 @@
|
||||
"""SQLite FTS5 session search index for cross-session full-text search.
|
||||
|
||||
Stores an inverted index of all conversation messages. The primary data
|
||||
remains in the file-based SessionStore; this module provides a fast search
|
||||
layer on top.
|
||||
|
||||
Database location: ~/.vibe-trading/sessions.db (WAL mode for concurrent reads).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DB_PATH = Path.home() / ".vibe-trading" / "sessions.db"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchMatch:
|
||||
"""A single search result from the FTS5 index.
|
||||
|
||||
Attributes:
|
||||
session_id: Session ID.
|
||||
title: Session title.
|
||||
started_at: Human-readable start time.
|
||||
message_count: Total messages in the session.
|
||||
snippet: FTS5 snippet with match highlights (>>> <<<).
|
||||
rank: FTS5 relevance rank (lower is better).
|
||||
"""
|
||||
|
||||
session_id: str
|
||||
title: str
|
||||
started_at: str
|
||||
message_count: int
|
||||
snippet: str
|
||||
rank: float
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize to JSON-safe dict."""
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"title": self.title,
|
||||
"started_at": self.started_at,
|
||||
"message_count": self.message_count,
|
||||
"snippet": self.snippet,
|
||||
}
|
||||
|
||||
|
||||
class SessionSearchIndex:
|
||||
"""SQLite FTS5 index for cross-session search.
|
||||
|
||||
Supports:
|
||||
- Indexing individual messages as they arrive
|
||||
- Full-text search with relevance ranking
|
||||
- Bulk reindex from the file-based SessionStore
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path = _DB_PATH) -> None:
|
||||
"""Initialize the search index.
|
||||
|
||||
Args:
|
||||
db_path: Path to SQLite database file.
|
||||
"""
|
||||
self.db_path = db_path
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._init_db()
|
||||
|
||||
def _get_conn(self) -> sqlite3.Connection:
|
||||
"""Get or create the SQLite connection (WAL mode)."""
|
||||
if self._conn is None:
|
||||
self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA synchronous=NORMAL")
|
||||
return self._conn
|
||||
|
||||
def _init_db(self) -> None:
|
||||
"""Create tables and FTS5 virtual table if they don't exist."""
|
||||
conn = self._get_conn()
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
started_at REAL NOT NULL,
|
||||
message_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
tool_name TEXT,
|
||||
timestamp REAL NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session
|
||||
ON messages(session_id);
|
||||
""")
|
||||
# FTS5 virtual table — create separately (not inside executescript with IF NOT EXISTS
|
||||
# because FTS5 syntax varies across SQLite versions)
|
||||
try:
|
||||
conn.execute("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts
|
||||
USING fts5(content, content=messages, content_rowid=id)
|
||||
""")
|
||||
except sqlite3.OperationalError:
|
||||
pass # already exists or FTS5 not available
|
||||
|
||||
# Auto-sync triggers
|
||||
for trigger_sql in [
|
||||
"""CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
|
||||
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
|
||||
END""",
|
||||
"""CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
|
||||
INSERT INTO messages_fts(messages_fts, rowid, content)
|
||||
VALUES ('delete', old.id, old.content);
|
||||
END""",
|
||||
]:
|
||||
try:
|
||||
conn.execute(trigger_sql)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.commit()
|
||||
|
||||
def index_session(self, session_id: str, title: str = "") -> None:
|
||||
"""Upsert a session record.
|
||||
|
||||
Args:
|
||||
session_id: Session ID.
|
||||
title: Session title.
|
||||
"""
|
||||
conn = self._get_conn()
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO sessions (id, title, started_at, message_count) "
|
||||
"VALUES (?, ?, ?, COALESCE((SELECT message_count FROM sessions WHERE id = ?), 0))",
|
||||
(session_id, title, time.time(), session_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def index_message(self, session_id: str, role: str, content: str,
|
||||
tool_name: Optional[str] = None) -> None:
|
||||
"""Index a single message.
|
||||
|
||||
Args:
|
||||
session_id: Session ID.
|
||||
role: Message role (user/assistant/tool).
|
||||
content: Message text.
|
||||
tool_name: Tool name if this is a tool result.
|
||||
"""
|
||||
if not content or not content.strip():
|
||||
return
|
||||
conn = self._get_conn()
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, role, content, tool_name, timestamp) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(session_id, role, content[:50_000], tool_name, time.time()),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE sessions SET message_count = message_count + 1 WHERE id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_fts_query(query: str) -> str:
|
||||
"""Sanitize a user query for FTS5 MATCH syntax.
|
||||
|
||||
- Splits on non-alphanumeric/CJK to extract tokens
|
||||
- Joins with OR so any-word-matches (not all-words-required)
|
||||
- Quotes each token to prevent FTS5 operator interpretation
|
||||
|
||||
Args:
|
||||
query: Raw user query string.
|
||||
|
||||
Returns:
|
||||
FTS5-safe MATCH expression.
|
||||
"""
|
||||
import re as _re
|
||||
# Extract alphanumeric tokens (3+ chars) and CJK characters
|
||||
tokens = _re.findall(r"[a-zA-Z0-9_]{2,}|[\u4e00-\u9fff\u3400-\u4dbf]", query)
|
||||
if not tokens:
|
||||
return '""'
|
||||
# Quote each token and join with OR for broader matching
|
||||
return " OR ".join(f'"{t}"' for t in tokens)
|
||||
|
||||
def search(self, query: str, max_sessions: int = 3) -> List[SearchMatch]:
|
||||
"""Full-text search across all sessions.
|
||||
|
||||
Args:
|
||||
query: Search query (keywords or phrase).
|
||||
max_sessions: Maximum number of distinct sessions to return.
|
||||
|
||||
Returns:
|
||||
List of SearchMatch results, grouped by session, ranked by relevance.
|
||||
"""
|
||||
conn = self._get_conn()
|
||||
fts_query = self._sanitize_fts_query(query)
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
m.session_id,
|
||||
s.title,
|
||||
s.started_at,
|
||||
s.message_count,
|
||||
snippet(messages_fts, 0, '>>>', '<<<', '...', 64) AS snippet,
|
||||
rank
|
||||
FROM messages_fts
|
||||
JOIN messages m ON m.id = messages_fts.rowid
|
||||
JOIN sessions s ON s.id = m.session_id
|
||||
WHERE messages_fts MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT ?
|
||||
""",
|
||||
(fts_query, max_sessions * 5),
|
||||
)
|
||||
except sqlite3.OperationalError as exc:
|
||||
logger.warning("FTS5 search failed: %s", exc)
|
||||
return []
|
||||
|
||||
seen: dict[str, SearchMatch] = {}
|
||||
for row in cursor.fetchall():
|
||||
sid = row[0]
|
||||
if sid in seen:
|
||||
continue
|
||||
seen[sid] = SearchMatch(
|
||||
session_id=row[0],
|
||||
title=row[1] or "(untitled)",
|
||||
started_at=self._format_time(row[2]),
|
||||
message_count=row[3],
|
||||
snippet=row[4],
|
||||
rank=row[5],
|
||||
)
|
||||
if len(seen) >= max_sessions:
|
||||
break
|
||||
|
||||
return list(seen.values())
|
||||
|
||||
def reindex_from_store(self, store_base_dir: Path) -> int:
|
||||
"""Rebuild the entire index from file-based session store.
|
||||
|
||||
Args:
|
||||
store_base_dir: Root directory of the SessionStore (contains session subdirs).
|
||||
|
||||
Returns:
|
||||
Number of messages indexed.
|
||||
"""
|
||||
if not store_base_dir.exists():
|
||||
return 0
|
||||
|
||||
conn = self._get_conn()
|
||||
conn.execute("DELETE FROM messages")
|
||||
conn.execute("DELETE FROM sessions")
|
||||
try:
|
||||
conn.execute("INSERT INTO messages_fts(messages_fts) VALUES ('rebuild')")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.commit()
|
||||
|
||||
count = 0
|
||||
for session_dir in store_base_dir.iterdir():
|
||||
if not session_dir.is_dir():
|
||||
continue
|
||||
|
||||
session_file = session_dir / "session.json"
|
||||
messages_file = session_dir / "messages.jsonl"
|
||||
|
||||
if not session_file.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
session_data = json.loads(session_file.read_text(encoding="utf-8"))
|
||||
sid = session_data.get("session_id", session_dir.name)
|
||||
title = session_data.get("title", "")
|
||||
|
||||
try:
|
||||
ts = datetime.fromisoformat(session_data.get("created_at", "")).timestamp()
|
||||
except (ValueError, TypeError):
|
||||
ts = time.time()
|
||||
|
||||
self.index_session(sid, title)
|
||||
|
||||
if messages_file.exists():
|
||||
for line in messages_file.read_text(encoding="utf-8").strip().splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
role = msg.get("role", "")
|
||||
content = msg.get("content", "")
|
||||
if content and role in ("user", "assistant"):
|
||||
self.index_message(sid, role, content)
|
||||
count += 1
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to index session %s: %s", session_dir.name, exc)
|
||||
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
def _format_time(epoch: float) -> str:
|
||||
"""Format epoch timestamp to human-readable string."""
|
||||
try:
|
||||
return datetime.fromtimestamp(epoch).strftime("%Y-%m-%d %H:%M")
|
||||
except (OSError, ValueError):
|
||||
return "unknown"
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the database connection."""
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
|
||||
import threading as _threading
|
||||
|
||||
_shared_index: Optional[SessionSearchIndex] = None
|
||||
_shared_lock = _threading.Lock()
|
||||
|
||||
|
||||
def get_shared_index() -> SessionSearchIndex:
|
||||
"""Return a process-wide singleton SessionSearchIndex.
|
||||
|
||||
Thread-safe via double-checked locking. Used by both
|
||||
SessionService (indexing) and SessionSearchTool (searching)
|
||||
so they share one SQLite connection.
|
||||
"""
|
||||
global _shared_index
|
||||
if _shared_index is None:
|
||||
with _shared_lock:
|
||||
if _shared_index is None:
|
||||
_shared_index = SessionSearchIndex()
|
||||
return _shared_index
|
||||
@@ -21,6 +21,7 @@ from src.session.models import (
|
||||
Message,
|
||||
Session,
|
||||
)
|
||||
from src.session.search import get_shared_index
|
||||
from src.session.store import SessionStore
|
||||
|
||||
|
||||
@@ -50,6 +51,7 @@ class SessionService:
|
||||
self.event_bus = event_bus
|
||||
self.runs_dir = runs_dir
|
||||
self._active_loops: Dict[str, "AgentLoop"] = {}
|
||||
self._search_index = get_shared_index()
|
||||
|
||||
def create_session(self, title: str = "", config: Optional[Dict[str, Any]] = None) -> Session:
|
||||
"""Create a new session.
|
||||
@@ -63,6 +65,7 @@ class SessionService:
|
||||
"""
|
||||
session = Session(title=title, config=config or {})
|
||||
self.store.create_session(session)
|
||||
self._search_index.index_session(session.session_id, title)
|
||||
self.event_bus.emit(session.session_id, "session.created", {"session_id": session.session_id, "title": title})
|
||||
return session
|
||||
|
||||
@@ -79,13 +82,21 @@ class SessionService:
|
||||
self.event_bus.clear(session_id)
|
||||
return self.store.delete_session(session_id)
|
||||
|
||||
async def send_message(self, session_id: str, content: str, role: str = "user") -> Dict[str, Any]:
|
||||
async def send_message(
|
||||
self,
|
||||
session_id: str,
|
||||
content: str,
|
||||
role: str = "user",
|
||||
*,
|
||||
include_shell_tools: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Send a message to a session and trigger execution.
|
||||
|
||||
Args:
|
||||
session_id: Session ID.
|
||||
content: Message content.
|
||||
role: Message role.
|
||||
include_shell_tools: Whether this attempt may use shell tools.
|
||||
|
||||
Returns:
|
||||
Dictionary containing message_id and attempt_id.
|
||||
@@ -96,6 +107,7 @@ class SessionService:
|
||||
|
||||
message = Message(session_id=session_id, role=role, content=content)
|
||||
self.store.append_message(message)
|
||||
self._search_index.index_message(session_id, role, content)
|
||||
self.event_bus.emit(session_id, "message.received", {"message_id": message.message_id, "role": role, "content": content})
|
||||
|
||||
if role != "user":
|
||||
@@ -103,12 +115,13 @@ class SessionService:
|
||||
|
||||
attempt = Attempt(session_id=session_id, parent_attempt_id=session.last_attempt_id, prompt=content)
|
||||
self.store.create_attempt(attempt)
|
||||
session.config["include_shell_tools"] = include_shell_tools
|
||||
session.last_attempt_id = attempt.attempt_id
|
||||
session.updated_at = datetime.now().isoformat()
|
||||
self.store.update_session(session)
|
||||
self.event_bus.emit(session_id, "attempt.created", {"attempt_id": attempt.attempt_id, "prompt": content})
|
||||
|
||||
asyncio.create_task(self._run_attempt(session, attempt))
|
||||
asyncio.create_task(self._run_attempt(session, attempt, include_shell_tools=include_shell_tools))
|
||||
return {"message_id": message.message_id, "attempt_id": attempt.attempt_id}
|
||||
|
||||
async def resume_attempt(self, session_id: str, attempt_id: str, user_input: str) -> Dict[str, Any]:
|
||||
@@ -140,7 +153,8 @@ class SessionService:
|
||||
self.store.update_attempt(attempt)
|
||||
self.event_bus.emit(session_id, "attempt.resumed", {"attempt_id": attempt_id, "user_input": user_input})
|
||||
|
||||
asyncio.create_task(self._run_attempt(session, attempt))
|
||||
include_shell_tools = bool(session.config.get("include_shell_tools", False))
|
||||
asyncio.create_task(self._run_attempt(session, attempt, include_shell_tools=include_shell_tools))
|
||||
return {"status": "resumed", "attempt_id": attempt_id}
|
||||
|
||||
def get_messages(self, session_id: str, limit: int = 100) -> list[Message]:
|
||||
@@ -170,7 +184,7 @@ class SessionService:
|
||||
loop.cancel()
|
||||
return True
|
||||
|
||||
async def _run_attempt(self, session: Session, attempt: Attempt) -> None:
|
||||
async def _run_attempt(self, session: Session, attempt: Attempt, *, include_shell_tools: bool = False) -> None:
|
||||
"""Execute an Attempt in the background."""
|
||||
attempt.mark_running()
|
||||
self.store.update_attempt(attempt)
|
||||
@@ -178,7 +192,7 @@ class SessionService:
|
||||
|
||||
try:
|
||||
messages = self.store.get_messages(session.session_id)
|
||||
result = await self._run_with_agent(attempt, messages=messages)
|
||||
result = await self._run_with_agent(attempt, messages=messages, include_shell_tools=include_shell_tools)
|
||||
if result.get("status") == "success":
|
||||
attempt.mark_completed(summary=result.get("content", ""))
|
||||
else:
|
||||
@@ -200,6 +214,7 @@ class SessionService:
|
||||
metadata=reply_metadata,
|
||||
)
|
||||
self.store.append_message(reply)
|
||||
self._search_index.index_message(session.session_id, "assistant", reply.content)
|
||||
self.event_bus.emit(
|
||||
session.session_id,
|
||||
"attempt.completed" if attempt.status == AttemptStatus.COMPLETED else "attempt.failed",
|
||||
@@ -212,12 +227,19 @@ class SessionService:
|
||||
self.store.update_attempt(attempt)
|
||||
self.event_bus.emit(session.session_id, "attempt.failed", {"attempt_id": attempt.attempt_id, "error": str(exc)})
|
||||
|
||||
async def _run_with_agent(self, attempt: Attempt, messages: list = None) -> Dict[str, Any]:
|
||||
async def _run_with_agent(
|
||||
self,
|
||||
attempt: Attempt,
|
||||
messages: list = None,
|
||||
*,
|
||||
include_shell_tools: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute an attempt with the V5 AgentLoop.
|
||||
|
||||
Args:
|
||||
attempt: Current execution attempt.
|
||||
messages: Session message history.
|
||||
include_shell_tools: Whether the registry may include shell tools.
|
||||
|
||||
Returns:
|
||||
Result dictionary containing status, run_dir, run_id, metrics, and related fields.
|
||||
@@ -225,9 +247,10 @@ class SessionService:
|
||||
from src.tools import build_registry
|
||||
from src.providers.chat import ChatLLM
|
||||
from src.agent.loop import AgentLoop
|
||||
from src.memory.persistent import PersistentMemory
|
||||
|
||||
registry = build_registry()
|
||||
llm = ChatLLM()
|
||||
pm = PersistentMemory()
|
||||
|
||||
session_id = attempt.session_id
|
||||
attempt_id = attempt.attempt_id
|
||||
@@ -238,10 +261,11 @@ class SessionService:
|
||||
self.event_bus.emit(session_id, event_type, data)
|
||||
|
||||
agent = AgentLoop(
|
||||
registry=registry,
|
||||
registry=build_registry(persistent_memory=pm, include_shell_tools=include_shell_tools),
|
||||
llm=llm,
|
||||
event_callback=event_callback,
|
||||
max_iterations=50,
|
||||
persistent_memory=pm,
|
||||
)
|
||||
self._active_loops[session_id] = agent
|
||||
|
||||
@@ -249,7 +273,7 @@ class SessionService:
|
||||
history = self._convert_messages_to_history(messages) if messages else None
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(
|
||||
_AGENT_EXECUTOR,
|
||||
lambda: agent.run(
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Shadow Account — extract a user's profitable pattern as a re-runnable shadow.
|
||||
|
||||
Public API (for tools/tests/external callers):
|
||||
extract_shadow_profile: journal → ShadowProfile
|
||||
ShadowProfile / ShadowRule / ShadowBacktestResult / AttributionBreakdown
|
||||
storage: save_profile / load_profile / find_by_journal_hash
|
||||
"""
|
||||
|
||||
from src.shadow_account.backtester import (
|
||||
SUPPORTED_MARKETS,
|
||||
run_shadow_backtest,
|
||||
select_multi_market_codes,
|
||||
)
|
||||
from src.shadow_account.codegen import (
|
||||
render_config,
|
||||
render_signal_engine,
|
||||
validate_generated,
|
||||
write_run_dir,
|
||||
)
|
||||
from src.shadow_account.extractor import extract_shadow_profile
|
||||
from src.shadow_account.reporter import render_shadow_report
|
||||
from src.shadow_account.models import (
|
||||
AttributionBreakdown,
|
||||
ShadowBacktestResult,
|
||||
ShadowProfile,
|
||||
ShadowRule,
|
||||
)
|
||||
from src.shadow_account.storage import (
|
||||
find_by_journal_hash,
|
||||
load_profile,
|
||||
new_shadow_id,
|
||||
save_profile,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AttributionBreakdown",
|
||||
"SUPPORTED_MARKETS",
|
||||
"ShadowBacktestResult",
|
||||
"ShadowProfile",
|
||||
"ShadowRule",
|
||||
"extract_shadow_profile",
|
||||
"find_by_journal_hash",
|
||||
"load_profile",
|
||||
"new_shadow_id",
|
||||
"render_config",
|
||||
"render_shadow_report",
|
||||
"render_signal_engine",
|
||||
"run_shadow_backtest",
|
||||
"save_profile",
|
||||
"select_multi_market_codes",
|
||||
"validate_generated",
|
||||
"write_run_dir",
|
||||
]
|
||||
@@ -0,0 +1,529 @@
|
||||
"""Shadow Account — multi-market backtest driver + delta-PnL attribution.
|
||||
|
||||
Responsibilities:
|
||||
1. Pick representative symbols per market based on the user's preferred
|
||||
markets (with a liquid-basket fallback).
|
||||
2. Render a run_dir (via ``codegen.write_run_dir``) and call
|
||||
``src.tools.backtest_tool.run_backtest``.
|
||||
3. Parse the emitted artifacts (metrics JSON / equity CSV) back into a
|
||||
``ShadowBacktestResult``.
|
||||
4. Compute attribution: noise trades, missed signals, early/late exits,
|
||||
overtrading — each as signed PnL.
|
||||
|
||||
The attribution algorithm is deliberately arithmetic-only: no LLM, no
|
||||
simulation rebuild. This keeps the numbers auditable and reproducible.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from src.shadow_account.codegen import write_run_dir
|
||||
from src.shadow_account.models import (
|
||||
AttributionBreakdown,
|
||||
ShadowBacktestResult,
|
||||
ShadowProfile,
|
||||
)
|
||||
from src.shadow_account.storage import runs_dir
|
||||
from src.tools.trade_journal_parsers import parse_file, records_to_dataframe
|
||||
from src.tools.trade_journal_tool import pair_trades_fifo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUPPORTED_MARKETS: tuple[str, ...] = ("china_a", "hk", "us", "crypto")
|
||||
|
||||
_LIQUID_BASKETS: dict[str, list[str]] = {
|
||||
"china_a": ["600519.SH", "000858.SZ", "300750.SZ", "600036.SH", "000001.SZ"],
|
||||
"hk": ["00700.HK", "09988.HK", "03690.HK", "00388.HK", "01810.HK"],
|
||||
"us": ["AAPL", "MSFT", "NVDA", "AMZN", "GOOGL"],
|
||||
"crypto": ["BTC-USDT", "ETH-USDT", "SOL-USDT", "BNB-USDT", "XRP-USDT"],
|
||||
}
|
||||
|
||||
|
||||
# ---------------- Code selection ----------------
|
||||
|
||||
def select_multi_market_codes(
|
||||
profile: ShadowProfile,
|
||||
*,
|
||||
per_market_count: int = 5,
|
||||
markets: tuple[str, ...] = SUPPORTED_MARKETS,
|
||||
) -> dict[str, list[str]]:
|
||||
"""Pick representative tickers for each target market.
|
||||
|
||||
Priority:
|
||||
1. If the profile's source_market is in the target set and is in the
|
||||
liquid basket, surface it first.
|
||||
2. Fill remaining markets from their liquid basket.
|
||||
|
||||
Args:
|
||||
profile: Shadow profile (source_market guides prioritization).
|
||||
per_market_count: Cap per market (clamped to basket size).
|
||||
markets: Markets to include.
|
||||
|
||||
Returns:
|
||||
Dict market → list of codes, non-empty for every requested market
|
||||
that has a known basket.
|
||||
"""
|
||||
selection: dict[str, list[str]] = {}
|
||||
for market in markets:
|
||||
basket = _LIQUID_BASKETS.get(market)
|
||||
if not basket:
|
||||
continue
|
||||
selection[market] = basket[: max(1, per_market_count)]
|
||||
return selection
|
||||
|
||||
|
||||
def flatten_codes(selection: dict[str, list[str]]) -> list[str]:
|
||||
"""Flatten per-market selection into a unique, order-preserving code list."""
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for codes in selection.values():
|
||||
for c in codes:
|
||||
if c not in seen:
|
||||
out.append(c)
|
||||
seen.add(c)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------- Backtest execution ----------------
|
||||
|
||||
def run_shadow_backtest(
|
||||
profile: ShadowProfile,
|
||||
*,
|
||||
window_start: str,
|
||||
window_end: str,
|
||||
markets: tuple[str, ...] = SUPPORTED_MARKETS,
|
||||
per_market_count: int = 5,
|
||||
source: str = "auto",
|
||||
initial_capital: float = 1_000_000.0,
|
||||
journal_path: str | Path | None = None,
|
||||
run_backtest_fn: Any | None = None,
|
||||
) -> ShadowBacktestResult:
|
||||
"""Drive a multi-market backtest from a ShadowProfile.
|
||||
|
||||
Args:
|
||||
profile: ShadowProfile to replay.
|
||||
window_start / window_end: ISO dates.
|
||||
markets: Target market buckets.
|
||||
per_market_count: Codes per market.
|
||||
source: Loader source (``auto`` routes by suffix).
|
||||
initial_capital: Starting cash.
|
||||
journal_path: Original journal path (used to compute attribution
|
||||
against the user's realized trades). Attribution is skipped if
|
||||
None or the file is missing.
|
||||
run_backtest_fn: Injection point for tests — callable(run_dir_str)
|
||||
returning the same JSON payload as
|
||||
``src.tools.backtest_tool.run_backtest``. Defaults to the real
|
||||
entrypoint.
|
||||
|
||||
Returns:
|
||||
ShadowBacktestResult with per-market + combined metrics, equity
|
||||
curves (when emitted), and attribution (zeros when unavailable).
|
||||
"""
|
||||
selection = select_multi_market_codes(
|
||||
profile, per_market_count=per_market_count, markets=markets,
|
||||
)
|
||||
codes = flatten_codes(selection)
|
||||
if not codes:
|
||||
raise ValueError("No codes available for requested markets.")
|
||||
|
||||
run_dir = runs_dir(profile.shadow_id)
|
||||
write_run_dir(
|
||||
profile,
|
||||
run_dir,
|
||||
codes=codes,
|
||||
start_date=window_start,
|
||||
end_date=window_end,
|
||||
source=source,
|
||||
initial_capital=initial_capital,
|
||||
)
|
||||
|
||||
backtest_fn = run_backtest_fn or _default_run_backtest_fn()
|
||||
payload = json.loads(backtest_fn(str(run_dir)))
|
||||
|
||||
per_market, combined, equity_curves = _summarize_artifacts(
|
||||
payload=payload, run_dir=run_dir, selection=selection,
|
||||
)
|
||||
|
||||
attribution, shadow_pnl, real_pnl = _attribution_or_zero(
|
||||
profile=profile,
|
||||
journal_path=journal_path,
|
||||
combined=combined,
|
||||
)
|
||||
|
||||
result = ShadowBacktestResult(
|
||||
shadow_id=profile.shadow_id,
|
||||
per_market=per_market,
|
||||
combined=combined,
|
||||
equity_curves=equity_curves,
|
||||
attribution=attribution,
|
||||
shadow_total_pnl=shadow_pnl,
|
||||
real_total_pnl=real_pnl,
|
||||
delta_pnl=round(shadow_pnl - real_pnl, 2),
|
||||
)
|
||||
_cache_result(run_dir, result)
|
||||
return result
|
||||
|
||||
|
||||
def load_cached_result(shadow_id: str) -> ShadowBacktestResult | None:
|
||||
"""Load the last cached backtest result for a shadow, if any."""
|
||||
cache_path = runs_dir(shadow_id) / "shadow_result.json"
|
||||
if not cache_path.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
attr = data.get("attribution") or {}
|
||||
return ShadowBacktestResult(
|
||||
shadow_id=data["shadow_id"],
|
||||
per_market=data.get("per_market") or {},
|
||||
combined=data.get("combined") or {},
|
||||
equity_curves={
|
||||
k: [(str(pt[0]), float(pt[1])) for pt in v]
|
||||
for k, v in (data.get("equity_curves") or {}).items()
|
||||
},
|
||||
attribution=AttributionBreakdown(
|
||||
missed_signals_pnl=float(attr.get("missed_signals_pnl", 0.0)),
|
||||
noise_trades_pnl=float(attr.get("noise_trades_pnl", 0.0)),
|
||||
early_exit_pnl=float(attr.get("early_exit_pnl", 0.0)),
|
||||
late_exit_pnl=float(attr.get("late_exit_pnl", 0.0)),
|
||||
overtrading_pnl=float(attr.get("overtrading_pnl", 0.0)),
|
||||
counterfactual_trades=tuple(attr.get("counterfactual_trades") or ()),
|
||||
),
|
||||
shadow_total_pnl=float(data.get("shadow_total_pnl", 0.0)),
|
||||
real_total_pnl=float(data.get("real_total_pnl", 0.0)),
|
||||
delta_pnl=float(data.get("delta_pnl", 0.0)),
|
||||
)
|
||||
|
||||
|
||||
def _cache_result(run_dir: Path, result: ShadowBacktestResult) -> None:
|
||||
"""Persist a ShadowBacktestResult so downstream tools don't re-backtest."""
|
||||
from dataclasses import asdict as _asdict
|
||||
|
||||
payload = _asdict(result)
|
||||
try:
|
||||
(run_dir / "shadow_result.json").write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2, default=str),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError as exc: # pragma: no cover — disk failure is non-fatal
|
||||
logger.warning("Failed to cache shadow result: %s", exc)
|
||||
|
||||
|
||||
def _default_run_backtest_fn():
|
||||
from src.tools.backtest_tool import run_backtest
|
||||
return run_backtest
|
||||
|
||||
|
||||
# ---------------- Artifact parsing ----------------
|
||||
|
||||
def _summarize_artifacts(
|
||||
*,
|
||||
payload: dict[str, Any],
|
||||
run_dir: Path,
|
||||
selection: dict[str, list[str]],
|
||||
) -> tuple[dict[str, dict[str, float]], dict[str, float], dict[str, list[tuple[str, float]]]]:
|
||||
"""Turn raw backtest output into (per_market, combined, equity_curves).
|
||||
|
||||
Gracefully degrades when artifacts are missing (e.g. data fetch failed):
|
||||
returns empty dicts and a combined dict containing the error reason.
|
||||
"""
|
||||
artifacts = payload.get("artifacts") or {}
|
||||
status = payload.get("status", "error")
|
||||
|
||||
combined = _load_metrics(artifacts, run_dir)
|
||||
equity_points = _load_equity_curve(artifacts, run_dir)
|
||||
|
||||
# Only surface an error when we genuinely have no metrics. A non-ok
|
||||
# status with usable metrics typically means a transient data-source
|
||||
# warning (e.g. yfinance flaked on one market) — downgrading to ok is
|
||||
# more faithful to what the user actually has.
|
||||
if not combined and status != "ok":
|
||||
combined = {"error": payload.get("stderr", "")[-200:] or "backtest failed"}
|
||||
|
||||
per_market = _per_market_breakdown(combined, selection)
|
||||
equity_curves = {"combined": equity_points} if equity_points else {}
|
||||
return per_market, combined, equity_curves
|
||||
|
||||
|
||||
def _load_metrics(artifacts: dict[str, str], run_dir: Path) -> dict[str, float]:
|
||||
"""Pull a numeric metrics dict from the run_dir.
|
||||
|
||||
Looks for ``metrics.json`` first (preferred), then ``metrics.csv``.
|
||||
Unknown shape → empty dict (caller treats as failure).
|
||||
"""
|
||||
for key in ("metrics.json", "metrics", "metrics.csv"):
|
||||
path_str = artifacts.get(key)
|
||||
if not path_str:
|
||||
continue
|
||||
path = Path(path_str)
|
||||
if not path.exists():
|
||||
continue
|
||||
try:
|
||||
if path.suffix == ".json":
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return _coerce_numeric(data)
|
||||
if path.suffix == ".csv":
|
||||
df = pd.read_csv(path)
|
||||
if df.empty:
|
||||
return {}
|
||||
row = df.iloc[-1].to_dict()
|
||||
return _coerce_numeric(row)
|
||||
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
||||
logger.warning("Failed to parse metrics %s: %s", path, exc)
|
||||
|
||||
# Fallback: scan run_dir for a metrics file.
|
||||
for path in run_dir.glob("**/metrics.*"):
|
||||
try:
|
||||
if path.suffix == ".json":
|
||||
return _coerce_numeric(json.loads(path.read_text(encoding="utf-8")))
|
||||
if path.suffix == ".csv":
|
||||
df = pd.read_csv(path)
|
||||
if not df.empty:
|
||||
return _coerce_numeric(df.iloc[-1].to_dict())
|
||||
except Exception:
|
||||
continue
|
||||
return {}
|
||||
|
||||
|
||||
def _load_equity_curve(artifacts: dict[str, str], run_dir: Path) -> list[tuple[str, float]]:
|
||||
"""Load the equity curve as [(iso_date, equity), ...]."""
|
||||
candidates: list[Path] = []
|
||||
for key in ("equity.csv", "equity", "equity_curve.csv"):
|
||||
path_str = artifacts.get(key)
|
||||
if path_str:
|
||||
candidates.append(Path(path_str))
|
||||
candidates.extend(run_dir.glob("**/equity*.csv"))
|
||||
|
||||
seen: set[Path] = set()
|
||||
for path in candidates:
|
||||
if path in seen or not path.exists():
|
||||
continue
|
||||
seen.add(path)
|
||||
try:
|
||||
df = pd.read_csv(path)
|
||||
except (OSError, pd.errors.ParserError) as exc:
|
||||
logger.warning("Failed to read equity csv %s: %s", path, exc)
|
||||
continue
|
||||
if df.empty:
|
||||
continue
|
||||
date_col = next((c for c in df.columns if c.lower() in ("date", "datetime", "timestamp")), df.columns[0])
|
||||
equity_col = next(
|
||||
(c for c in df.columns if c.lower() in ("equity", "equity_curve", "value", "net_value")),
|
||||
df.columns[-1],
|
||||
)
|
||||
return [(str(row[date_col]), float(row[equity_col])) for _, row in df.iterrows()]
|
||||
return []
|
||||
|
||||
|
||||
def _coerce_numeric(data: dict[str, Any]) -> dict[str, float]:
|
||||
"""Keep only the scalar numeric fields from a metrics dict."""
|
||||
out: dict[str, float] = {}
|
||||
for key, value in data.items():
|
||||
if isinstance(value, bool):
|
||||
continue
|
||||
if isinstance(value, (int, float)):
|
||||
out[str(key)] = float(value)
|
||||
return out
|
||||
|
||||
|
||||
def _per_market_breakdown(
|
||||
combined: dict[str, float],
|
||||
selection: dict[str, list[str]],
|
||||
) -> dict[str, dict[str, float]]:
|
||||
"""Project the combined metrics into each requested market.
|
||||
|
||||
v1 limitation: the runner emits a single combined metrics file regardless
|
||||
of cross-market composition, so per-market rows reuse the combined
|
||||
metrics. This is faithful (same backtest) but intentionally lossy; a
|
||||
follow-up can split equity by market attribution.
|
||||
"""
|
||||
if not combined:
|
||||
return {market: {} for market in selection}
|
||||
return {market: dict(combined) for market in selection}
|
||||
|
||||
|
||||
# ---------------- Attribution ----------------
|
||||
|
||||
def _attribution_or_zero(
|
||||
*,
|
||||
profile: ShadowProfile,
|
||||
journal_path: str | Path | None,
|
||||
combined: dict[str, float],
|
||||
) -> tuple[AttributionBreakdown, float, float]:
|
||||
"""Compute attribution if the journal is available, else return zeros."""
|
||||
shadow_pnl = float(combined.get("total_return_abs") or combined.get("total_pnl") or 0.0)
|
||||
if not journal_path:
|
||||
return _zero_attribution(), shadow_pnl, 0.0
|
||||
path = Path(journal_path)
|
||||
if not path.exists():
|
||||
return _zero_attribution(), shadow_pnl, 0.0
|
||||
|
||||
try:
|
||||
_, records = parse_file(path)
|
||||
trades_df = records_to_dataframe(records)
|
||||
roundtrips = pair_trades_fifo(trades_df)
|
||||
except Exception as exc:
|
||||
logger.warning("Attribution skipped — journal parse failed: %s", exc)
|
||||
return _zero_attribution(), shadow_pnl, 0.0
|
||||
|
||||
if not roundtrips:
|
||||
return _zero_attribution(), shadow_pnl, 0.0
|
||||
|
||||
return _compute_attribution(profile=profile, roundtrips=roundtrips, shadow_pnl=shadow_pnl)
|
||||
|
||||
|
||||
def _zero_attribution() -> AttributionBreakdown:
|
||||
return AttributionBreakdown(
|
||||
missed_signals_pnl=0.0,
|
||||
noise_trades_pnl=0.0,
|
||||
early_exit_pnl=0.0,
|
||||
late_exit_pnl=0.0,
|
||||
overtrading_pnl=0.0,
|
||||
counterfactual_trades=(),
|
||||
)
|
||||
|
||||
|
||||
def _compute_attribution(
|
||||
*,
|
||||
profile: ShadowProfile,
|
||||
roundtrips: list[dict[str, Any]],
|
||||
shadow_pnl: float,
|
||||
) -> tuple[AttributionBreakdown, float, float]:
|
||||
"""Attribute the delta between user's real PnL and shadow PnL.
|
||||
|
||||
Decomposition (signed — positive means shadow would have earned more):
|
||||
|
||||
noise_trades_pnl = -Σ realized_pnl on rule-violating trades
|
||||
(user's unexplained losses that shadow avoids)
|
||||
early_exit_pnl = +Σ shortfall on winning trades exited before
|
||||
the median rule holding range
|
||||
late_exit_pnl = +Σ excess loss on losing trades held past the
|
||||
median rule holding range
|
||||
overtrading_pnl = -Σ realized_pnl on trades beyond the shadow's
|
||||
expected trade budget (1 trade per 2*hold_days)
|
||||
missed_signals_pnl = shadow_pnl - real_pnl - (noise+early+late+over)
|
||||
(residual — everything the above can't explain)
|
||||
|
||||
``counterfactual_trades`` lists the top-5 |impact| roundtrips for
|
||||
Section 6 of the report.
|
||||
"""
|
||||
rule_hold_lo, rule_hold_hi = _aggregate_holding_range(profile)
|
||||
noise = 0.0
|
||||
early = 0.0
|
||||
late = 0.0
|
||||
real_pnl = 0.0
|
||||
counterfactuals: list[dict[str, Any]] = []
|
||||
|
||||
for rt in roundtrips:
|
||||
pnl = float(rt["pnl"])
|
||||
real_pnl += pnl
|
||||
hold = float(rt["hold_days"])
|
||||
within_rule = rule_hold_lo <= hold <= rule_hold_hi
|
||||
impact = 0.0
|
||||
reason = ""
|
||||
if not within_rule:
|
||||
noise += -pnl
|
||||
impact += -pnl
|
||||
reason = "rule_violation"
|
||||
if pnl > 0 and hold < rule_hold_lo:
|
||||
shortfall = pnl * max(0.0, (rule_hold_lo - hold) / max(rule_hold_lo, 1))
|
||||
early += shortfall
|
||||
impact += shortfall
|
||||
reason = reason or "early_exit"
|
||||
if pnl < 0 and hold > rule_hold_hi:
|
||||
excess = -pnl * max(0.0, (hold - rule_hold_hi) / max(rule_hold_hi, 1))
|
||||
late += excess
|
||||
impact += excess
|
||||
reason = reason or "late_exit"
|
||||
if impact != 0.0:
|
||||
counterfactuals.append({
|
||||
"symbol": rt["symbol"],
|
||||
"buy_dt": str(rt["buy_dt"]),
|
||||
"sell_dt": str(rt["sell_dt"]),
|
||||
"hold_days": hold,
|
||||
"pnl": round(pnl, 2),
|
||||
"impact": round(impact, 2),
|
||||
"reason": reason,
|
||||
})
|
||||
|
||||
overtrading = _overtrading_pnl(profile=profile, roundtrips=roundtrips)
|
||||
explained = noise + early + late + overtrading
|
||||
missed = round(shadow_pnl - real_pnl - explained, 2)
|
||||
|
||||
counterfactuals.sort(key=lambda r: abs(r["impact"]), reverse=True)
|
||||
top5 = tuple(counterfactuals[:5])
|
||||
|
||||
return (
|
||||
AttributionBreakdown(
|
||||
missed_signals_pnl=round(missed, 2),
|
||||
noise_trades_pnl=round(noise, 2),
|
||||
early_exit_pnl=round(early, 2),
|
||||
late_exit_pnl=round(late, 2),
|
||||
overtrading_pnl=round(overtrading, 2),
|
||||
counterfactual_trades=top5,
|
||||
),
|
||||
round(shadow_pnl, 2),
|
||||
round(real_pnl, 2),
|
||||
)
|
||||
|
||||
|
||||
def _aggregate_holding_range(profile: ShadowProfile) -> tuple[float, float]:
|
||||
"""Union holding-day ranges across all rules (lo=min, hi=max)."""
|
||||
if not profile.rules:
|
||||
return (1.0, 30.0)
|
||||
los = [r.holding_days_range[0] for r in profile.rules]
|
||||
his = [r.holding_days_range[1] for r in profile.rules]
|
||||
return (float(min(los)), float(max(his)))
|
||||
|
||||
|
||||
def _overtrading_pnl(
|
||||
*,
|
||||
profile: ShadowProfile,
|
||||
roundtrips: list[dict[str, Any]],
|
||||
) -> float:
|
||||
"""Excess-frequency PnL: trades beyond the shadow's expected budget.
|
||||
|
||||
Shadow runs roughly 1 trade per ``2 * median_hold_days`` bars. We
|
||||
compare against the user's actual roundtrip count over the same span.
|
||||
Excess trades' PnL is totaled with a negative sign (shadow would've
|
||||
skipped them, so real PnL — positive or negative — is "noise").
|
||||
"""
|
||||
if not roundtrips:
|
||||
return 0.0
|
||||
median_hold, _ = profile.typical_holding_days
|
||||
if median_hold <= 0:
|
||||
return 0.0
|
||||
span_days = (
|
||||
pd.Timestamp(roundtrips[-1]["sell_dt"]) - pd.Timestamp(roundtrips[0]["buy_dt"])
|
||||
).total_seconds() / 86400.0
|
||||
expected = max(1.0, span_days / max(2 * median_hold, 1.0))
|
||||
actual = len(roundtrips)
|
||||
if actual <= expected:
|
||||
return 0.0
|
||||
# Penalize the cheapest (lowest |pnl|) extras — those look like noise.
|
||||
extras = sorted(roundtrips, key=lambda rt: abs(float(rt["pnl"])))
|
||||
extra_count = int(actual - expected)
|
||||
extra_pnl = sum(float(rt["pnl"]) for rt in extras[:extra_count])
|
||||
return -extra_pnl
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SUPPORTED_MARKETS",
|
||||
"flatten_codes",
|
||||
"load_cached_result",
|
||||
"run_shadow_backtest",
|
||||
"select_multi_market_codes",
|
||||
]
|
||||
|
||||
|
||||
# Re-export for convenience (see __init__.py).
|
||||
def _touch_replace_reexport(): # pragma: no cover
|
||||
return replace
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Shadow Account — code generation (rules → signal_engine.py + config.json).
|
||||
|
||||
Inputs are always a ``ShadowProfile`` (contract-stable). Outputs are:
|
||||
* ``signal_engine.py`` source, validated with ``ast.parse`` + a shape
|
||||
check on ``class SignalEngine.generate``.
|
||||
* ``config.json`` dict, ready to drop into a run_dir.
|
||||
|
||||
No external I/O here — callers (backtester) are responsible for writing
|
||||
files and launching ``run_backtest``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
from src.shadow_account.models import ShadowProfile, ShadowRule
|
||||
|
||||
_TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||
_SIGNAL_ENGINE_TEMPLATE = "signal_engine.py.j2"
|
||||
|
||||
|
||||
def _literal_safe_value(value: Any) -> Any:
|
||||
"""Return a value that can be faithfully represented as a Python literal."""
|
||||
if value is None or isinstance(value, (str, bool, int)):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
if not math.isfinite(value):
|
||||
raise ValueError("Non-finite floats cannot be rendered as Python literals")
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
return [_literal_safe_value(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(_literal_safe_value(item) for item in value)
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
_literal_safe_value(key): _literal_safe_value(item)
|
||||
for key, item in value.items()
|
||||
}
|
||||
raise TypeError(
|
||||
f"Unsupported generated Python literal type: {type(value).__name__}",
|
||||
)
|
||||
|
||||
|
||||
def _python_literal(value: Any) -> str:
|
||||
"""Render ``value`` as source text for a safe Python literal."""
|
||||
literal = repr(_literal_safe_value(value))
|
||||
try:
|
||||
ast.literal_eval(literal)
|
||||
except (SyntaxError, ValueError) as exc:
|
||||
raise ValueError(f"Invalid generated Python literal: {literal!r}") from exc
|
||||
return literal
|
||||
|
||||
|
||||
def _env() -> Environment:
|
||||
"""Jinja2 environment rooted at our templates directory."""
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
|
||||
autoescape=select_autoescape(enabled_extensions=("html", "xml")),
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
env.filters["py_literal"] = _python_literal
|
||||
return env
|
||||
|
||||
|
||||
def _rule_to_context(rule: ShadowRule) -> dict[str, Any]:
|
||||
"""Flatten a ShadowRule into the scalar fields the template consumes."""
|
||||
market = str(rule.entry_condition.get("market", "other"))
|
||||
hour = rule.entry_condition.get("entry_hour") or {}
|
||||
hold_lo, hold_hi = rule.holding_days_range
|
||||
hold_days = max(1, int(round((hold_lo + hold_hi) / 2)))
|
||||
return {
|
||||
"rule_id": rule.rule_id,
|
||||
"market": market,
|
||||
"entry_hour_min": int(hour.get("min", 0)),
|
||||
"entry_hour_max": int(hour.get("max", 23)),
|
||||
"hold_days": hold_days,
|
||||
"weight": float(rule.weight),
|
||||
}
|
||||
|
||||
|
||||
def render_signal_engine(profile: ShadowProfile) -> str:
|
||||
"""Render the SignalEngine Python source for a profile.
|
||||
|
||||
Args:
|
||||
profile: ShadowProfile carrying the rules to replay.
|
||||
|
||||
Returns:
|
||||
Python source string. The caller is expected to ``validate_generated``
|
||||
before writing to disk.
|
||||
"""
|
||||
template = _env().get_template(_SIGNAL_ENGINE_TEMPLATE)
|
||||
return template.render(
|
||||
shadow_id=profile.shadow_id,
|
||||
profitable_roundtrips=profile.profitable_roundtrips,
|
||||
date_range=list(profile.date_range),
|
||||
preferred_markets=list(profile.preferred_markets),
|
||||
rules=[_rule_to_context(r) for r in profile.rules],
|
||||
)
|
||||
|
||||
|
||||
def validate_generated(source: str) -> tuple[bool, str]:
|
||||
"""Static-check a generated signal_engine source.
|
||||
|
||||
Checks:
|
||||
1. ``ast.parse`` succeeds.
|
||||
2. Module defines ``class SignalEngine``.
|
||||
3. ``SignalEngine`` has a ``generate`` method with one positional
|
||||
arg besides self.
|
||||
|
||||
Returns:
|
||||
(ok, error_msg). ``error_msg`` is empty on success.
|
||||
"""
|
||||
try:
|
||||
tree = ast.parse(source)
|
||||
except SyntaxError as exc:
|
||||
return False, f"SyntaxError: {exc}"
|
||||
|
||||
signal_cls: ast.ClassDef | None = None
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ClassDef) and node.name == "SignalEngine":
|
||||
signal_cls = node
|
||||
break
|
||||
if signal_cls is None:
|
||||
return False, "class SignalEngine not found"
|
||||
|
||||
generate: ast.FunctionDef | None = None
|
||||
for node in signal_cls.body:
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "generate":
|
||||
generate = node
|
||||
break
|
||||
if generate is None:
|
||||
return False, "SignalEngine.generate method not found"
|
||||
|
||||
non_self_args = [a for a in generate.args.args if a.arg != "self"]
|
||||
if len(non_self_args) < 1:
|
||||
return False, "SignalEngine.generate must accept a data_map argument"
|
||||
return True, ""
|
||||
|
||||
|
||||
def render_config(
|
||||
profile: ShadowProfile,
|
||||
*,
|
||||
codes: list[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
source: str = "auto",
|
||||
initial_capital: float = 1_000_000.0,
|
||||
interval: str = "1D",
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the ``config.json`` dict for a shadow backtest run.
|
||||
|
||||
Args:
|
||||
profile: ShadowProfile (used only for metadata, not for engine logic).
|
||||
codes: Symbols to backtest across.
|
||||
start_date / end_date: ISO dates.
|
||||
source: Loader source; ``auto`` routes by symbol suffix.
|
||||
initial_capital: Starting cash.
|
||||
interval: Bar size (daily default).
|
||||
extra: Arbitrary overrides merged last.
|
||||
"""
|
||||
cfg: dict[str, Any] = {
|
||||
"source": source,
|
||||
"codes": codes,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"interval": interval,
|
||||
"initial_capital": initial_capital,
|
||||
"engine": "daily",
|
||||
"shadow_id": profile.shadow_id,
|
||||
}
|
||||
if extra:
|
||||
cfg.update(extra)
|
||||
return cfg
|
||||
|
||||
|
||||
def write_run_dir(
|
||||
profile: ShadowProfile,
|
||||
run_dir: Path,
|
||||
*,
|
||||
codes: list[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
source: str = "auto",
|
||||
initial_capital: float = 1_000_000.0,
|
||||
) -> Path:
|
||||
"""Materialize a full run_dir with ``code/signal_engine.py`` + ``config.json``.
|
||||
|
||||
Raises:
|
||||
ValueError: Generated signal_engine fails static validation.
|
||||
"""
|
||||
run_dir = Path(run_dir)
|
||||
(run_dir / "code").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
source_code = render_signal_engine(profile)
|
||||
ok, err = validate_generated(source_code)
|
||||
if not ok:
|
||||
raise ValueError(f"Generated signal_engine failed validation: {err}")
|
||||
|
||||
(run_dir / "code" / "signal_engine.py").write_text(source_code, encoding="utf-8")
|
||||
cfg = render_config(
|
||||
profile,
|
||||
codes=codes,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
source=source,
|
||||
initial_capital=initial_capital,
|
||||
)
|
||||
(run_dir / "config.json").write_text(
|
||||
json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8",
|
||||
)
|
||||
return run_dir
|
||||
@@ -0,0 +1,371 @@
|
||||
"""Shadow Account — strategy extraction from profitable roundtrips.
|
||||
|
||||
Pipeline:
|
||||
trades_df → FIFO pair → filter (pnl > 0) → feature engineer
|
||||
→ KMeans cluster (k auto 2-5) → per-cluster decision tree (max_depth=3)
|
||||
→ path extraction → structured entry_condition dict
|
||||
→ LLM-light natural-language translation (template fallback if no LLM)
|
||||
|
||||
Design constraints:
|
||||
* No external price-data calls in v1. All features are derivable from
|
||||
the journal itself (holding_days, pnl_pct, entry hour/weekday, market).
|
||||
Price-dependent features (prior_N_return, rsi) are stubbed with None
|
||||
and dropped from the feature matrix if unavailable.
|
||||
* Must survive tiny samples: <5 profitable roundtrips → explicit error.
|
||||
<2 clusters → degrade to a single-cluster heuristic rule.
|
||||
* Rules are immutable ShadowRule objects — codegen's only input.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.shadow_account.models import ShadowProfile, ShadowRule
|
||||
from src.shadow_account.storage import hash_journal, new_shadow_id, now_iso
|
||||
from src.tools.trade_journal_parsers import parse_file, records_to_dataframe
|
||||
from src.tools.trade_journal_tool import pair_trades_fifo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MIN_PROFITABLE_ROUNDTRIPS = 5
|
||||
DEFAULT_MAX_RULES = 5
|
||||
DEFAULT_MIN_SUPPORT = 3
|
||||
_NUMERIC_FEATURES = ("holding_days", "pnl_pct", "entry_hour", "entry_weekday")
|
||||
_CATEGORICAL_FEATURES = ("market",)
|
||||
|
||||
|
||||
# ---------------- Public API ----------------
|
||||
|
||||
def extract_shadow_profile(
|
||||
journal_path: str | Path,
|
||||
*,
|
||||
min_support: int = DEFAULT_MIN_SUPPORT,
|
||||
max_rules: int = DEFAULT_MAX_RULES,
|
||||
llm_translator: Any | None = None,
|
||||
) -> ShadowProfile:
|
||||
"""Extract a ShadowProfile from a broker journal file.
|
||||
|
||||
Args:
|
||||
journal_path: CSV/Excel exported from a supported broker.
|
||||
min_support: Minimum profitable roundtrips backing any single rule.
|
||||
max_rules: Cap on the number of rules returned.
|
||||
llm_translator: Optional callable (dict) -> str for translating
|
||||
structured entry_condition into natural-language text. If None,
|
||||
a deterministic f-string fallback is used.
|
||||
|
||||
Returns:
|
||||
ShadowProfile (not yet persisted — caller decides whether to save).
|
||||
|
||||
Raises:
|
||||
ValueError: Fewer than MIN_PROFITABLE_ROUNDTRIPS profitable roundtrips.
|
||||
"""
|
||||
path = Path(journal_path)
|
||||
fmt, records = parse_file(path)
|
||||
if not records:
|
||||
raise ValueError(f"No trade records parsed from {path} (format={fmt})")
|
||||
trades_df = records_to_dataframe(records)
|
||||
|
||||
roundtrips = pair_trades_fifo(trades_df)
|
||||
total = len(roundtrips)
|
||||
if total == 0:
|
||||
raise ValueError("No complete buy→sell roundtrips found in journal.")
|
||||
|
||||
profitable = [rt for rt in roundtrips if rt["pnl"] > 0]
|
||||
if len(profitable) < MIN_PROFITABLE_ROUNDTRIPS:
|
||||
raise ValueError(
|
||||
f"Insufficient profitable roundtrips: {len(profitable)} "
|
||||
f"(need ≥{MIN_PROFITABLE_ROUNDTRIPS}).",
|
||||
)
|
||||
|
||||
features_df = _compute_features(profitable, trades_df)
|
||||
rules = _extract_rules(
|
||||
features_df,
|
||||
min_support=min_support,
|
||||
max_rules=max_rules,
|
||||
llm_translator=llm_translator,
|
||||
)
|
||||
|
||||
source_market = _dominant(trades_df["market"])
|
||||
preferred_markets = tuple(trades_df["market"].value_counts().index.tolist())
|
||||
hold = features_df["holding_days"].dropna()
|
||||
typical_holding = (
|
||||
round(float(hold.median()), 2) if len(hold) else 0.0,
|
||||
round(float(hold.quantile(0.75)), 2) if len(hold) else 0.0,
|
||||
)
|
||||
date_range = (
|
||||
str(trades_df["datetime"].min()),
|
||||
str(trades_df["datetime"].max()),
|
||||
)
|
||||
profile_text = _render_profile_text(
|
||||
total_profitable=len(profitable),
|
||||
total_all=total,
|
||||
typical_holding=typical_holding,
|
||||
source_market=source_market,
|
||||
preferred_markets=preferred_markets,
|
||||
)
|
||||
|
||||
return ShadowProfile(
|
||||
shadow_id=new_shadow_id(),
|
||||
created_at=now_iso(),
|
||||
journal_hash=hash_journal(path),
|
||||
source_market=source_market,
|
||||
profitable_roundtrips=len(profitable),
|
||||
total_roundtrips=total,
|
||||
date_range=date_range,
|
||||
profile_text=profile_text,
|
||||
rules=tuple(rules),
|
||||
preferred_markets=preferred_markets,
|
||||
typical_holding_days=typical_holding,
|
||||
)
|
||||
|
||||
|
||||
# ---------------- Feature engineering ----------------
|
||||
|
||||
def _compute_features(
|
||||
roundtrips: list[dict[str, Any]],
|
||||
trades_df: pd.DataFrame,
|
||||
) -> pd.DataFrame:
|
||||
"""Compute a features row per profitable roundtrip.
|
||||
|
||||
Columns: symbol, market, holding_days, pnl, pnl_pct, entry_hour,
|
||||
entry_weekday, buy_dt, sell_dt.
|
||||
"""
|
||||
market_by_symbol = (
|
||||
trades_df.drop_duplicates("symbol").set_index("symbol")["market"].to_dict()
|
||||
)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for rt in roundtrips:
|
||||
buy_dt = pd.Timestamp(rt["buy_dt"])
|
||||
sell_dt = pd.Timestamp(rt["sell_dt"])
|
||||
rows.append({
|
||||
"symbol": rt["symbol"],
|
||||
"market": market_by_symbol.get(rt["symbol"], "other"),
|
||||
"holding_days": float(rt["hold_days"]),
|
||||
"pnl": float(rt["pnl"]),
|
||||
"pnl_pct": float(rt["pnl_pct"]),
|
||||
"entry_hour": int(buy_dt.hour),
|
||||
"entry_weekday": int(buy_dt.weekday()),
|
||||
"buy_dt": buy_dt,
|
||||
"sell_dt": sell_dt,
|
||||
})
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
# ---------------- Cluster + decision-tree rule extraction ----------------
|
||||
|
||||
def _extract_rules(
|
||||
features_df: pd.DataFrame,
|
||||
*,
|
||||
min_support: int,
|
||||
max_rules: int,
|
||||
llm_translator: Any | None,
|
||||
) -> list[ShadowRule]:
|
||||
"""Cluster profitable roundtrips, derive one rule per dense cluster."""
|
||||
if len(features_df) < min_support:
|
||||
return [_heuristic_single_rule(features_df, min_support, llm_translator)]
|
||||
|
||||
cluster_labels = _auto_cluster(features_df, max_k=min(max_rules, 5))
|
||||
rules: list[ShadowRule] = []
|
||||
total_profitable = len(features_df)
|
||||
used_markets: set[str] = set()
|
||||
|
||||
for cluster_id in sorted(set(cluster_labels)):
|
||||
cluster_mask = cluster_labels == cluster_id
|
||||
cluster_df = features_df[cluster_mask]
|
||||
if len(cluster_df) < min_support:
|
||||
continue
|
||||
rule = _cluster_to_rule(
|
||||
cluster_df=cluster_df,
|
||||
rule_index=len(rules) + 1,
|
||||
total_profitable=total_profitable,
|
||||
llm_translator=llm_translator,
|
||||
)
|
||||
# Deduplicate near-identical rules (same market + same holding band)
|
||||
key = (rule.entry_condition.get("market"), rule.holding_days_range)
|
||||
if key in used_markets:
|
||||
continue
|
||||
used_markets.add(key)
|
||||
rules.append(rule)
|
||||
if len(rules) >= max_rules:
|
||||
break
|
||||
|
||||
if not rules:
|
||||
rules = [_heuristic_single_rule(features_df, min_support, llm_translator)]
|
||||
return rules
|
||||
|
||||
|
||||
def _auto_cluster(features_df: pd.DataFrame, *, max_k: int) -> np.ndarray:
|
||||
"""Pick a cluster count via simple silhouette heuristic (fallback k=2).
|
||||
|
||||
Uses only numeric features; scales by z-score to avoid holding_days
|
||||
dominating pnl_pct.
|
||||
"""
|
||||
from sklearn.cluster import KMeans
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
|
||||
numeric = features_df[list(_NUMERIC_FEATURES)].astype(float).to_numpy()
|
||||
if len(numeric) <= 2 or max_k < 2:
|
||||
return np.zeros(len(numeric), dtype=int)
|
||||
scaled = StandardScaler().fit_transform(numeric)
|
||||
|
||||
best_k, best_score = 2, -1.0
|
||||
try:
|
||||
from sklearn.metrics import silhouette_score
|
||||
for k in range(2, min(max_k, len(numeric) - 1) + 1):
|
||||
labels = KMeans(n_clusters=k, n_init=5, random_state=42).fit_predict(scaled)
|
||||
if len(set(labels)) < 2:
|
||||
continue
|
||||
score = silhouette_score(scaled, labels)
|
||||
if score > best_score:
|
||||
best_k, best_score = k, score
|
||||
except Exception as exc: # pragma: no cover — sklearn edge cases
|
||||
logger.debug("silhouette selection failed, fallback k=2: %s", exc)
|
||||
|
||||
return KMeans(n_clusters=best_k, n_init=5, random_state=42).fit_predict(scaled)
|
||||
|
||||
|
||||
def _cluster_to_rule(
|
||||
*,
|
||||
cluster_df: pd.DataFrame,
|
||||
rule_index: int,
|
||||
total_profitable: int,
|
||||
llm_translator: Any | None,
|
||||
) -> ShadowRule:
|
||||
"""Summarize a cluster as one ShadowRule.
|
||||
|
||||
Entry condition uses p10–p90 numeric bounds + dominant market. This is
|
||||
lighter than a decision tree and stays interpretable with tiny samples;
|
||||
we can swap to DecisionTreeClassifier in v2 when features widen.
|
||||
"""
|
||||
market = _dominant(cluster_df["market"])
|
||||
hold_days = cluster_df["holding_days"]
|
||||
hold_lo = max(1, int(round(float(hold_days.quantile(0.10)))))
|
||||
hold_hi = max(hold_lo, int(round(float(hold_days.quantile(0.90)))))
|
||||
hours = cluster_df["entry_hour"]
|
||||
hour_lo = int(round(float(hours.quantile(0.10))))
|
||||
hour_hi = int(round(float(hours.quantile(0.90))))
|
||||
|
||||
entry_condition: dict[str, Any] = {
|
||||
"market": market,
|
||||
"entry_hour": {"min": hour_lo, "max": hour_hi},
|
||||
}
|
||||
exit_condition: dict[str, Any] = {
|
||||
"holding_days": {"min": hold_lo, "max": hold_hi},
|
||||
}
|
||||
|
||||
samples = tuple(
|
||||
f"{row.symbol}@{pd.Timestamp(row.buy_dt).date().isoformat()}"
|
||||
for row in cluster_df.head(3).itertuples(index=False)
|
||||
)
|
||||
support = int(len(cluster_df))
|
||||
coverage = round(support / max(total_profitable, 1), 3)
|
||||
|
||||
human = _translate_rule(
|
||||
entry_condition=entry_condition,
|
||||
exit_condition=exit_condition,
|
||||
holding_range=(hold_lo, hold_hi),
|
||||
translator=llm_translator,
|
||||
)
|
||||
|
||||
return ShadowRule(
|
||||
rule_id=f"R{rule_index}",
|
||||
human_text=human,
|
||||
entry_condition=entry_condition,
|
||||
exit_condition=exit_condition,
|
||||
holding_days_range=(hold_lo, hold_hi),
|
||||
support_count=support,
|
||||
coverage_rate=coverage,
|
||||
sample_trades=samples,
|
||||
)
|
||||
|
||||
|
||||
def _heuristic_single_rule(
|
||||
features_df: pd.DataFrame,
|
||||
min_support: int,
|
||||
llm_translator: Any | None,
|
||||
) -> ShadowRule:
|
||||
"""Degenerate fallback when clustering/tree yield nothing usable."""
|
||||
return _cluster_to_rule(
|
||||
cluster_df=features_df,
|
||||
rule_index=1,
|
||||
total_profitable=max(len(features_df), min_support),
|
||||
llm_translator=llm_translator,
|
||||
)
|
||||
|
||||
|
||||
# ---------------- Natural-language translation ----------------
|
||||
|
||||
_MARKET_LABELS = {
|
||||
"china_a": "China A-share",
|
||||
"us": "US equity",
|
||||
"hk": "HK equity",
|
||||
"crypto": "Crypto",
|
||||
"other": "Other",
|
||||
}
|
||||
|
||||
RULE_TEXT_MAX = 80
|
||||
|
||||
|
||||
def _translate_rule(
|
||||
*,
|
||||
entry_condition: dict[str, Any],
|
||||
exit_condition: dict[str, Any],
|
||||
holding_range: tuple[int, int],
|
||||
translator: Any | None,
|
||||
) -> str:
|
||||
"""Turn a structured rule dict into a concise English sentence (<=80 chars)."""
|
||||
if translator is not None:
|
||||
try:
|
||||
text = translator({
|
||||
"entry_condition": entry_condition,
|
||||
"exit_condition": exit_condition,
|
||||
"holding_range": holding_range,
|
||||
})
|
||||
if isinstance(text, str) and text.strip():
|
||||
return text.strip()[:RULE_TEXT_MAX]
|
||||
except Exception as exc: # pragma: no cover — LLM failure, fallback
|
||||
logger.warning("LLM rule translator failed, falling back: %s", exc)
|
||||
|
||||
market_label = _MARKET_LABELS.get(entry_condition.get("market", "other"), "Other")
|
||||
hour_range = entry_condition.get("entry_hour", {})
|
||||
hour_text = ""
|
||||
if hour_range:
|
||||
lo, hi = hour_range.get("min"), hour_range.get("max")
|
||||
hour_text = f" at {lo}:00" if lo == hi else f" between {lo}:00-{hi}:00"
|
||||
hold_lo, hold_hi = holding_range
|
||||
hold_text = f"hold {hold_lo}-{hold_hi}d" if hold_lo != hold_hi else f"hold {hold_lo}d"
|
||||
entry_text = f"Enter {market_label}{hour_text}"
|
||||
return f"{entry_text}, {hold_text}"[:RULE_TEXT_MAX]
|
||||
|
||||
|
||||
# ---------------- Utilities ----------------
|
||||
|
||||
def _dominant(series: pd.Series) -> str:
|
||||
"""Most frequent value in a series, or the first if tied."""
|
||||
if series.empty:
|
||||
return "other"
|
||||
return str(series.value_counts().idxmax())
|
||||
|
||||
|
||||
def _render_profile_text(
|
||||
*,
|
||||
total_profitable: int,
|
||||
total_all: int,
|
||||
typical_holding: tuple[float, float],
|
||||
source_market: str,
|
||||
preferred_markets: tuple[str, ...],
|
||||
) -> str:
|
||||
"""Build the Section 1 one-paragraph portrait (English)."""
|
||||
median, p75 = typical_holding
|
||||
markets_label = ", ".join(_MARKET_LABELS.get(m, m) for m in preferred_markets[:3])
|
||||
source_label = _MARKET_LABELS.get(source_market, source_market)
|
||||
return (
|
||||
f"{total_profitable} of your {total_all} closed roundtrips were profitable. "
|
||||
f"Primary market: {source_label} (also active in {markets_label}). "
|
||||
f"Median holding period {median:.1f}d; most positions closed within {p75:.1f}d."
|
||||
)
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Shadow Account — font handling for PDF rendering.
|
||||
|
||||
Tries to guarantee that Chinese characters render; falls back to DejaVu Sans
|
||||
(English-only, logs a warning) when CJK resources are unavailable. Matplotlib
|
||||
and weasyprint both consume ``cjk_font_path()`` or at least see the family
|
||||
name via ``apply_matplotlib_cjk_font``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_NOTO_URL = (
|
||||
"https://github.com/notofonts/noto-cjk/raw/main/Sans/OTF/SimplifiedChinese/"
|
||||
"NotoSansCJKsc-Regular.otf"
|
||||
)
|
||||
_FONT_NAME = "NotoSansCJKsc-Regular.otf"
|
||||
_FALLBACK_FAMILY = "DejaVu Sans"
|
||||
|
||||
|
||||
def fonts_dir() -> Path:
|
||||
"""Return the Shadow Account fonts cache dir (auto-created)."""
|
||||
d = Path.home() / ".vibe-trading" / "fonts"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _system_cjk_candidates() -> list[Path]:
|
||||
"""Enumerate locally-installed CJK fonts we can reuse without downloading."""
|
||||
candidates: list[Path] = []
|
||||
env = os.environ
|
||||
windir = env.get("WINDIR") or env.get("SystemRoot")
|
||||
if windir:
|
||||
win_fonts = Path(windir) / "Fonts"
|
||||
for name in (
|
||||
"msyh.ttc", "msyh.ttf", "msyhbd.ttc",
|
||||
"simhei.ttf", "simsun.ttc", "simkai.ttf",
|
||||
"NotoSansCJK-Regular.ttc",
|
||||
):
|
||||
candidates.append(win_fonts / name)
|
||||
for p in (
|
||||
"/System/Library/Fonts/PingFang.ttc",
|
||||
"/System/Library/Fonts/STHeiti Light.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
||||
):
|
||||
candidates.append(Path(p))
|
||||
return candidates
|
||||
|
||||
|
||||
def cjk_font_path(*, allow_download: bool = True, timeout: float = 10.0) -> Optional[Path]:
|
||||
"""Resolve a CJK font file path.
|
||||
|
||||
Resolution order:
|
||||
1. Cached copy in ``fonts_dir()``.
|
||||
2. First available system font from ``_system_cjk_candidates``.
|
||||
3. Download Noto CJK (optional).
|
||||
|
||||
Returns ``None`` when every option fails — callers should then fall
|
||||
back to the DejaVu family.
|
||||
"""
|
||||
cached = fonts_dir() / _FONT_NAME
|
||||
if cached.exists() and cached.stat().st_size > 0:
|
||||
return cached
|
||||
|
||||
for candidate in _system_cjk_candidates():
|
||||
if candidate.exists():
|
||||
try:
|
||||
shutil.copy(candidate, cached)
|
||||
return cached
|
||||
except OSError as exc:
|
||||
logger.debug("Failed to cache system font %s: %s", candidate, exc)
|
||||
|
||||
if not allow_download:
|
||||
return None
|
||||
|
||||
try:
|
||||
import urllib.request
|
||||
req = urllib.request.Request(_NOTO_URL, headers={"User-Agent": "vibe-trading"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
cached.write_bytes(resp.read())
|
||||
if cached.stat().st_size > 0:
|
||||
return cached
|
||||
except Exception as exc: # pragma: no cover — network-dependent
|
||||
logger.warning("CJK font download failed (%s); falling back to DejaVu.", exc)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def apply_matplotlib_cjk_font() -> str:
|
||||
"""Configure matplotlib's default font family.
|
||||
|
||||
Returns the family name actually installed (``"sans-serif"`` meaning the
|
||||
CJK face is resolved, or ``_FALLBACK_FAMILY`` when we gave up).
|
||||
"""
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use("Agg") # safe for headless rendering
|
||||
from matplotlib import font_manager as fm
|
||||
import matplotlib.pyplot as plt
|
||||
except Exception as exc: # pragma: no cover — matplotlib optional
|
||||
logger.warning("matplotlib unavailable: %s", exc)
|
||||
return _FALLBACK_FAMILY
|
||||
|
||||
path = cjk_font_path()
|
||||
if path is None:
|
||||
plt.rcParams["font.family"] = _FALLBACK_FAMILY
|
||||
return _FALLBACK_FAMILY
|
||||
|
||||
try:
|
||||
fm.fontManager.addfont(str(path))
|
||||
prop = fm.FontProperties(fname=str(path))
|
||||
family = prop.get_name()
|
||||
plt.rcParams["font.family"] = family
|
||||
plt.rcParams["axes.unicode_minus"] = False
|
||||
return family
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to register %s with matplotlib: %s", path, exc)
|
||||
plt.rcParams["font.family"] = _FALLBACK_FAMILY
|
||||
return _FALLBACK_FAMILY
|
||||
|
||||
|
||||
def cjk_css_font_face() -> str:
|
||||
"""Emit an ``@font-face`` CSS block weasyprint can bundle.
|
||||
|
||||
Returns an empty string if no CJK font is resolvable.
|
||||
"""
|
||||
path = cjk_font_path()
|
||||
if path is None:
|
||||
return ""
|
||||
uri = path.resolve().as_uri()
|
||||
return (
|
||||
"@font-face {\n"
|
||||
" font-family: 'Shadow CJK';\n"
|
||||
f" src: url('{uri}');\n"
|
||||
"}\n"
|
||||
)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Shadow Account data contracts (frozen dataclasses).
|
||||
|
||||
See `docs/shadow-account-spec.md` for the full contract. These types are the
|
||||
stable boundary between extractor / codegen / backtester / reporter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ShadowRule:
|
||||
"""One human-readable if-then rule distilled from profitable roundtrips.
|
||||
|
||||
Attributes:
|
||||
rule_id: Stable ID like "R1", "R2".
|
||||
human_text: Chinese natural-language description (<=30 chars).
|
||||
entry_condition: Structured condition dict for codegen. Keys are
|
||||
feature names (e.g. "prior_5d_return", "market"); values are
|
||||
either scalars or (op, value) tuples (e.g. ("<=", -0.08)).
|
||||
exit_condition: Structured exit condition dict.
|
||||
holding_days_range: (min, max) integer days.
|
||||
support_count: Number of profitable roundtrips supporting this rule.
|
||||
coverage_rate: Fraction of all profitable roundtrips this rule covers.
|
||||
sample_trades: Representative "<symbol>@<date>" strings.
|
||||
weight: Signal weight for codegen (default 1.0).
|
||||
"""
|
||||
|
||||
rule_id: str
|
||||
human_text: str
|
||||
entry_condition: dict[str, Any]
|
||||
exit_condition: dict[str, Any]
|
||||
holding_days_range: tuple[int, int]
|
||||
support_count: int
|
||||
coverage_rate: float
|
||||
sample_trades: tuple[str, ...]
|
||||
weight: float = 1.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ShadowProfile:
|
||||
"""User's extracted trading shadow.
|
||||
|
||||
Attributes:
|
||||
shadow_id: "shadow_<8-hex>" unique ID.
|
||||
created_at: ISO8601 UTC timestamp.
|
||||
journal_hash: SHA1 of the source journal content for idempotency.
|
||||
source_market: Primary market from the journal ("china_a" etc.).
|
||||
profitable_roundtrips: Number of roundtrips used for extraction.
|
||||
total_roundtrips: Total roundtrips in journal.
|
||||
date_range: (start_iso, end_iso) of the journal.
|
||||
profile_text: One-paragraph Chinese portrait for Section 1.
|
||||
rules: Tuple of 3-5 ShadowRule.
|
||||
preferred_markets: Markets the user actually traded, frequency-sorted.
|
||||
typical_holding_days: (median, p75) in days.
|
||||
"""
|
||||
|
||||
shadow_id: str
|
||||
created_at: str
|
||||
journal_hash: str
|
||||
source_market: str
|
||||
profitable_roundtrips: int
|
||||
total_roundtrips: int
|
||||
date_range: tuple[str, str]
|
||||
profile_text: str
|
||||
rules: tuple[ShadowRule, ...]
|
||||
preferred_markets: tuple[str, ...]
|
||||
typical_holding_days: tuple[float, float]
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize to JSON-safe dict."""
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AttributionBreakdown:
|
||||
"""Delta PnL attribution between user's realized trades and shadow.
|
||||
|
||||
All PnL fields are in the journal's account currency.
|
||||
"""
|
||||
|
||||
missed_signals_pnl: float
|
||||
noise_trades_pnl: float
|
||||
early_exit_pnl: float
|
||||
late_exit_pnl: float
|
||||
overtrading_pnl: float
|
||||
counterfactual_trades: tuple[dict[str, Any], ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ShadowBacktestResult:
|
||||
"""Output of multi-market shadow backtest + attribution."""
|
||||
|
||||
shadow_id: str
|
||||
per_market: dict[str, dict[str, float]]
|
||||
combined: dict[str, float]
|
||||
equity_curves: dict[str, list[tuple[str, float]]]
|
||||
attribution: AttributionBreakdown
|
||||
shadow_total_pnl: float
|
||||
real_total_pnl: float
|
||||
delta_pnl: float
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Shadow Account — 8-section report rendering (HTML + optional PDF).
|
||||
|
||||
The pipeline:
|
||||
ShadowProfile + ShadowBacktestResult (+ optional today signals)
|
||||
→ `_build_sections` build a strict dict for the template
|
||||
→ `_render_charts` matplotlib PNG files on disk
|
||||
→ Jinja2 render HTML
|
||||
→ weasyprint → PDF (or HTML-only if weasyprint unusable)
|
||||
|
||||
Design:
|
||||
* No hard dependency on weasyprint at import time — if importing or
|
||||
rendering fails, we keep the HTML artifact and return its path.
|
||||
* Charts are optional — any matplotlib failure downgrades gracefully
|
||||
(that section just omits its `<img>`).
|
||||
* Layout/style live in `templates/shadow_report.{html,css}`; this module
|
||||
only decides *what* data to feed them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from base64 import b64encode
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
from src.shadow_account.fonts import apply_matplotlib_cjk_font, cjk_css_font_face
|
||||
from src.shadow_account.models import ShadowBacktestResult, ShadowProfile
|
||||
from src.shadow_account.storage import reports_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||
_HTML_TEMPLATE = "shadow_report.html"
|
||||
_CSS_TEMPLATE = "shadow_report.css"
|
||||
|
||||
_MARKET_LABELS = {
|
||||
"china_a": "China A-share",
|
||||
"us": "US equity",
|
||||
"hk": "HK equity",
|
||||
"crypto": "Crypto",
|
||||
"other": "Other",
|
||||
}
|
||||
|
||||
_REASON_LABELS = {
|
||||
"rule_violation": "Outside any shadow rule",
|
||||
"early_exit": "Exited winner too early",
|
||||
"late_exit": "Held loser too long",
|
||||
}
|
||||
|
||||
|
||||
# ---------------- Public API ----------------
|
||||
|
||||
def render_shadow_report(
|
||||
profile: ShadowProfile,
|
||||
backtest_result: ShadowBacktestResult,
|
||||
*,
|
||||
today_signals: list[dict[str, Any]] | None = None,
|
||||
output_dir: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Render the full Shadow Account report.
|
||||
|
||||
Returns:
|
||||
Dict with keys:
|
||||
``html_path`` : rendered HTML (always present)
|
||||
``pdf_path`` : PDF path (present iff weasyprint succeeded)
|
||||
``sections`` : structured payload (for frontend preview)
|
||||
``engine`` : "weasyprint" | "html-only"
|
||||
"""
|
||||
output_dir = Path(output_dir) if output_dir else reports_dir()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Matplotlib setup must happen before any chart renders.
|
||||
apply_matplotlib_cjk_font()
|
||||
|
||||
assets_dir = output_dir / f"{profile.shadow_id}_assets"
|
||||
assets_dir.mkdir(parents=True, exist_ok=True)
|
||||
charts = _render_charts(profile, backtest_result, assets_dir)
|
||||
|
||||
sections = _build_sections(profile, backtest_result, today_signals or [])
|
||||
css = _load_css()
|
||||
|
||||
html = _env().get_template(_HTML_TEMPLATE).render(
|
||||
css=css,
|
||||
charts=charts,
|
||||
market_labels=_MARKET_LABELS,
|
||||
reason_labels=_REASON_LABELS,
|
||||
**sections,
|
||||
)
|
||||
html_path = output_dir / f"{profile.shadow_id}.html"
|
||||
html_path.write_text(html, encoding="utf-8")
|
||||
|
||||
pdf_path, engine = _try_render_pdf(html, output_dir, profile.shadow_id)
|
||||
|
||||
return {
|
||||
"html_path": str(html_path),
|
||||
"pdf_path": str(pdf_path) if pdf_path else None,
|
||||
"sections": sections,
|
||||
"engine": engine,
|
||||
}
|
||||
|
||||
|
||||
# ---------------- Section data ----------------
|
||||
|
||||
def _build_sections(
|
||||
profile: ShadowProfile,
|
||||
result: ShadowBacktestResult,
|
||||
today_signals: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
"""Assemble the strict payload the Jinja template consumes.
|
||||
|
||||
The template expects numeric values for any metrics it formats with
|
||||
``%f`` — so we strip out non-numeric leak-throughs (e.g. ``error``
|
||||
strings inserted when the backtest runner failed). The error is
|
||||
preserved separately under ``combined_error`` so the template can
|
||||
surface it explicitly.
|
||||
"""
|
||||
combined_numeric, combined_error = _split_metrics(result.combined or {})
|
||||
per_market_numeric = {
|
||||
market: _split_metrics(metrics or {})[0]
|
||||
for market, metrics in (result.per_market or {}).items()
|
||||
}
|
||||
return {
|
||||
"profile": profile,
|
||||
"combined_metrics": combined_numeric,
|
||||
"combined_error": combined_error,
|
||||
"per_market_metrics": per_market_numeric,
|
||||
"attribution": result.attribution,
|
||||
"shadow_pnl": result.shadow_total_pnl,
|
||||
"real_pnl": result.real_total_pnl,
|
||||
"delta_pnl": result.delta_pnl,
|
||||
"today_signals": today_signals,
|
||||
}
|
||||
|
||||
|
||||
def _split_metrics(metrics: dict[str, Any]) -> tuple[dict[str, float], str]:
|
||||
"""Partition a metrics dict into (numeric, error_message)."""
|
||||
numeric: dict[str, float] = {}
|
||||
error_parts: list[str] = []
|
||||
for key, value in metrics.items():
|
||||
if isinstance(value, bool):
|
||||
continue
|
||||
if isinstance(value, (int, float)):
|
||||
numeric[key] = float(value)
|
||||
elif isinstance(value, str):
|
||||
error_parts.append(f"{key}: {value}")
|
||||
return numeric, " | ".join(error_parts)
|
||||
|
||||
|
||||
# ---------------- Charts ----------------
|
||||
|
||||
def _render_charts(
|
||||
profile: ShadowProfile,
|
||||
result: ShadowBacktestResult,
|
||||
assets_dir: Path,
|
||||
) -> dict[str, str]:
|
||||
"""Render all charts and return a map section → file URI.
|
||||
|
||||
Any failure is logged and the affected chart is dropped from the map.
|
||||
"""
|
||||
charts: dict[str, str] = {}
|
||||
for name, renderer in (
|
||||
("equity_curve", lambda p: _render_equity_curve(result, p)),
|
||||
("per_market_bar", lambda p: _render_per_market_bar(result, p)),
|
||||
("attribution_waterfall", lambda p: _render_attribution_waterfall(result, p)),
|
||||
):
|
||||
path = assets_dir / f"{name}.png"
|
||||
try:
|
||||
renderer(path)
|
||||
except Exception as exc:
|
||||
logger.warning("Chart %s failed to render: %s", name, exc)
|
||||
continue
|
||||
if path.exists() and path.stat().st_size > 0:
|
||||
charts[name] = path.resolve().as_uri()
|
||||
return charts
|
||||
|
||||
|
||||
def _render_equity_curve(result: ShadowBacktestResult, path: Path) -> None:
|
||||
curve = result.equity_curves.get("combined") or []
|
||||
if not curve:
|
||||
return
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 3), dpi=150)
|
||||
dates = [pt[0] for pt in curve]
|
||||
values = [pt[1] for pt in curve]
|
||||
ax.plot(dates, values, color="#1f7a3a", linewidth=1.3)
|
||||
ax.fill_between(dates, values, color="#1f7a3a", alpha=0.08)
|
||||
ax.set_title("Shadow — Portfolio Equity Curve")
|
||||
ax.set_xlabel("")
|
||||
ax.set_ylabel("Equity")
|
||||
ax.grid(True, linestyle=":", alpha=0.4)
|
||||
# Too many x-ticks looks cluttered; sample down.
|
||||
if len(dates) > 8:
|
||||
step = max(1, len(dates) // 8)
|
||||
ax.set_xticks([dates[i] for i in range(0, len(dates), step)])
|
||||
fig.tight_layout()
|
||||
fig.savefig(path, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def _render_per_market_bar(result: ShadowBacktestResult, path: Path) -> None:
|
||||
if not result.per_market:
|
||||
return
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
markets = list(result.per_market.keys())
|
||||
sharpes = [result.per_market[m].get("sharpe", 0.0) for m in markets]
|
||||
labels = [_MARKET_LABELS.get(m, m) for m in markets]
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 3), dpi=150)
|
||||
bars = ax.bar(labels, sharpes, color="#4a5fb0")
|
||||
ax.axhline(0, color="#8a8f99", linewidth=0.8)
|
||||
ax.set_title("Sharpe by Market")
|
||||
ax.grid(True, axis="y", linestyle=":", alpha=0.4)
|
||||
for bar, value in zip(bars, sharpes):
|
||||
ax.text(
|
||||
bar.get_x() + bar.get_width() / 2,
|
||||
bar.get_height(),
|
||||
f"{value:.2f}",
|
||||
ha="center",
|
||||
va="bottom",
|
||||
fontsize=9,
|
||||
)
|
||||
fig.tight_layout()
|
||||
fig.savefig(path, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def _render_attribution_waterfall(result: ShadowBacktestResult, path: Path) -> None:
|
||||
attr = result.attribution
|
||||
components = [
|
||||
("Real PnL", result.real_total_pnl),
|
||||
("Noise Trades", attr.noise_trades_pnl),
|
||||
("Early Exit", attr.early_exit_pnl),
|
||||
("Late Exit", attr.late_exit_pnl),
|
||||
("Overtrading", attr.overtrading_pnl),
|
||||
("Missed Signals", attr.missed_signals_pnl),
|
||||
("Shadow PnL", result.shadow_total_pnl),
|
||||
]
|
||||
if all(value == 0.0 for _, value in components):
|
||||
return
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
labels = [c[0] for c in components]
|
||||
values = [c[1] for c in components]
|
||||
# Waterfall bases: cumulative start for the intermediate bars.
|
||||
bases = [0.0]
|
||||
running = values[0]
|
||||
for v in values[1:-1]:
|
||||
bases.append(running)
|
||||
running += v
|
||||
bases.append(0.0)
|
||||
|
||||
accent = "#4a5fb0"
|
||||
pos = "#1f7a3a"
|
||||
neg = "#c1392b"
|
||||
grid = "#8a8f99"
|
||||
bg = "#ffffff"
|
||||
|
||||
colors = []
|
||||
for idx, v in enumerate(values):
|
||||
if idx in (0, len(values) - 1):
|
||||
colors.append(accent)
|
||||
else:
|
||||
colors.append(pos if v > 0 else neg)
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 3.2), dpi=150, facecolor=bg)
|
||||
ax.bar(labels, values, bottom=bases, color=colors, edgecolor="none", width=0.55)
|
||||
ax.axhline(0, color=grid, linewidth=0.8)
|
||||
ax.set_title("Delta Attribution (positive = shadow would outperform)")
|
||||
ax.grid(True, axis="y", linestyle=":", alpha=0.4)
|
||||
plt.setp(ax.get_xticklabels(), rotation=18, ha="right")
|
||||
fig.tight_layout()
|
||||
fig.savefig(path, bbox_inches="tight", facecolor=bg)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
# ---------------- Templating ----------------
|
||||
|
||||
def _env() -> Environment:
|
||||
return Environment(
|
||||
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
|
||||
autoescape=select_autoescape(enabled_extensions=("html", "xml")),
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
|
||||
|
||||
def _load_css() -> str:
|
||||
"""Load CSS, prepending a CJK ``@font-face`` if we have a font."""
|
||||
css = (_TEMPLATES_DIR / _CSS_TEMPLATE).read_text(encoding="utf-8")
|
||||
font_face = cjk_css_font_face()
|
||||
return font_face + "\n" + css
|
||||
|
||||
|
||||
def _try_render_pdf(
|
||||
html: str, output_dir: Path, shadow_id: str,
|
||||
) -> tuple[Path | None, str]:
|
||||
"""Render PDF via weasyprint, returning (path|None, engine_name)."""
|
||||
try:
|
||||
from weasyprint import HTML # type: ignore[import-not-found]
|
||||
except Exception as exc: # pragma: no cover — import-level failure
|
||||
logger.warning("weasyprint unavailable (%s); HTML-only output.", exc)
|
||||
return None, "html-only"
|
||||
|
||||
pdf_path = output_dir / f"{shadow_id}.pdf"
|
||||
try:
|
||||
HTML(string=html, base_url=str(_TEMPLATES_DIR)).write_pdf(str(pdf_path))
|
||||
except Exception as exc:
|
||||
logger.warning("weasyprint render failed (%s); HTML-only output.", exc)
|
||||
if pdf_path.exists():
|
||||
try:
|
||||
pdf_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return None, "html-only"
|
||||
return pdf_path, "weasyprint"
|
||||
|
||||
|
||||
# ---------------- Convenience ----------------
|
||||
|
||||
def embed_image_as_data_uri(path: Path) -> str:
|
||||
"""Inline an image as a data URI (useful when file URIs are rejected)."""
|
||||
blob = Path(path).read_bytes()
|
||||
encoded = b64encode(blob).decode("ascii")
|
||||
return f"data:image/png;base64,{encoded}"
|
||||
|
||||
|
||||
# Keep this helper exported to simplify downstream inspection.
|
||||
def result_as_dict(result: ShadowBacktestResult) -> dict[str, Any]:
|
||||
"""Dict-serialize a backtest result (handy for frontend debugging)."""
|
||||
return asdict(result)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Shadow Account — today signals scanner.
|
||||
|
||||
Deterministic, data-free MVP: for each rule in a profile, pick up to N
|
||||
symbols from the market's liquid basket that are "in phase" with the
|
||||
rule's holding cadence on the target date. No external price fetches.
|
||||
|
||||
Future work: swap the phase test for a real feature check (e.g. the
|
||||
rule's entry condition evaluated on the most recent bar).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from src.shadow_account.backtester import _LIQUID_BASKETS, SUPPORTED_MARKETS
|
||||
from src.shadow_account.models import ShadowProfile, ShadowRule
|
||||
|
||||
|
||||
def scan_today_signals(
|
||||
profile: ShadowProfile,
|
||||
*,
|
||||
target_date: str | date | None = None,
|
||||
per_market: int = 3,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return a list of {symbol, market, rule_id, reason} matches.
|
||||
|
||||
Args:
|
||||
profile: ShadowProfile holding the rules.
|
||||
target_date: ISO "YYYY-MM-DD" string, date object, or None (today).
|
||||
per_market: Cap per market bucket.
|
||||
|
||||
Returns:
|
||||
Possibly-empty list of matched candidates. Always research-only —
|
||||
callers should surface the disclaimer from the skill.
|
||||
"""
|
||||
if target_date is None:
|
||||
d = date.today()
|
||||
elif isinstance(target_date, date):
|
||||
d = target_date
|
||||
else:
|
||||
d = datetime.strptime(str(target_date), "%Y-%m-%d").date()
|
||||
|
||||
matches: list[dict[str, Any]] = []
|
||||
for rule in profile.rules:
|
||||
market = rule.entry_condition.get("market", "other")
|
||||
if market not in SUPPORTED_MARKETS:
|
||||
continue
|
||||
basket = _LIQUID_BASKETS.get(market, [])
|
||||
if not _date_in_phase(d, rule):
|
||||
continue
|
||||
lo, hi = rule.holding_days_range
|
||||
reason = f"Within {rule.rule_id} entry window (hold {lo}-{hi}d)"
|
||||
for symbol in basket[:per_market]:
|
||||
matches.append({
|
||||
"symbol": symbol,
|
||||
"market": market,
|
||||
"rule_id": rule.rule_id,
|
||||
"reason": reason,
|
||||
})
|
||||
return matches
|
||||
|
||||
|
||||
def _date_in_phase(target: date, rule: ShadowRule) -> bool:
|
||||
"""True when `target`'s ordinal mod `2 * hold_median` is inside the entry band.
|
||||
|
||||
This keeps the scanner deterministic and reviewable without needing
|
||||
real-time market data.
|
||||
"""
|
||||
lo, hi = rule.holding_days_range
|
||||
hold = max(1, int((lo + hi) / 2))
|
||||
period = max(2, 2 * hold)
|
||||
phase = target.toordinal() % period
|
||||
return phase < hold
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Shadow Account persistence (~/.vibe-trading/shadow_accounts/).
|
||||
|
||||
Layout:
|
||||
~/.vibe-trading/shadow_accounts/<shadow_id>.json ShadowProfile
|
||||
~/.vibe-trading/shadow_runs/<shadow_id>/ backtest run dir
|
||||
~/.vibe-trading/shadow_reports/<shadow_id>.pdf rendered report
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from src.shadow_account.models import ShadowProfile, ShadowRule
|
||||
|
||||
|
||||
def _root() -> Path:
|
||||
"""Return the Shadow Account root directory (auto-created)."""
|
||||
root = Path.home() / ".vibe-trading"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def profiles_dir() -> Path:
|
||||
d = _root() / "shadow_accounts"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def runs_dir(shadow_id: str) -> Path:
|
||||
d = _root() / "shadow_runs" / shadow_id
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def reports_dir() -> Path:
|
||||
d = _root() / "shadow_reports"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def new_shadow_id() -> str:
|
||||
"""Mint a fresh shadow_id."""
|
||||
return f"shadow_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def hash_journal(journal_path: Path | str) -> str:
|
||||
"""SHA1 over the raw journal bytes for idempotent extraction."""
|
||||
p = Path(journal_path)
|
||||
h = hashlib.sha1()
|
||||
with p.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
"""UTC ISO8601 timestamp (seconds precision)."""
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def save_profile(profile: ShadowProfile) -> Path:
|
||||
"""Persist a ShadowProfile to disk as JSON."""
|
||||
path = profiles_dir() / f"{profile.shadow_id}.json"
|
||||
path.write_text(
|
||||
json.dumps(profile.to_dict(), ensure_ascii=False, indent=2, default=str),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def load_profile(shadow_id: str) -> ShadowProfile:
|
||||
"""Load a ShadowProfile back from disk.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: No profile with that id.
|
||||
"""
|
||||
path = profiles_dir() / f"{shadow_id}.json"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Shadow profile not found: {shadow_id}")
|
||||
data: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
|
||||
rules = tuple(
|
||||
ShadowRule(
|
||||
rule_id=r["rule_id"],
|
||||
human_text=r["human_text"],
|
||||
entry_condition=r["entry_condition"],
|
||||
exit_condition=r["exit_condition"],
|
||||
holding_days_range=tuple(r["holding_days_range"]),
|
||||
support_count=r["support_count"],
|
||||
coverage_rate=r["coverage_rate"],
|
||||
sample_trades=tuple(r["sample_trades"]),
|
||||
weight=r.get("weight", 1.0),
|
||||
)
|
||||
for r in data["rules"]
|
||||
)
|
||||
return ShadowProfile(
|
||||
shadow_id=data["shadow_id"],
|
||||
created_at=data["created_at"],
|
||||
journal_hash=data["journal_hash"],
|
||||
source_market=data["source_market"],
|
||||
profitable_roundtrips=data["profitable_roundtrips"],
|
||||
total_roundtrips=data["total_roundtrips"],
|
||||
date_range=tuple(data["date_range"]),
|
||||
profile_text=data["profile_text"],
|
||||
rules=rules,
|
||||
preferred_markets=tuple(data["preferred_markets"]),
|
||||
typical_holding_days=tuple(data["typical_holding_days"]),
|
||||
)
|
||||
|
||||
|
||||
def find_by_journal_hash(journal_hash: str) -> ShadowProfile | None:
|
||||
"""Return the most recent profile sharing this journal_hash, else None."""
|
||||
latest: ShadowProfile | None = None
|
||||
for path in profiles_dir().glob("*.json"):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
if data.get("journal_hash") != journal_hash:
|
||||
continue
|
||||
candidate = load_profile(data["shadow_id"])
|
||||
if latest is None or candidate.created_at > latest.created_at:
|
||||
latest = candidate
|
||||
return latest
|
||||
@@ -0,0 +1,339 @@
|
||||
:root {
|
||||
--bg: #0b0d12;
|
||||
--surface: #12151c;
|
||||
--surface2: #1a1f29;
|
||||
--border: #242a36;
|
||||
--text: #e6e9ef;
|
||||
--text-dim: #9099a8;
|
||||
--text-mute:#5f6878;
|
||||
--accent: #6aa8ff;
|
||||
--pos: #3dd68c;
|
||||
--neg: #ff7b6b;
|
||||
--warn: #f4b454;
|
||||
--mono: 'JetBrains Mono', 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--sans: 'Inter', 'SF Pro Text', -apple-system, 'Segoe UI', 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 14mm 12mm;
|
||||
@bottom-right {
|
||||
content: "Shadow Account " counter(page) " / " counter(pages);
|
||||
font-size: 7.5pt;
|
||||
color: var(--text-mute);
|
||||
font-family: var(--mono);
|
||||
letter-spacing: 0.3pt;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-size: 10pt;
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 9.5pt;
|
||||
}
|
||||
|
||||
/* ============ Cover ============ */
|
||||
|
||||
header.cover {
|
||||
padding: 20mm 14mm 18mm;
|
||||
margin: -14mm -12mm 0 -12mm;
|
||||
background:
|
||||
radial-gradient(1200px 400px at 15% 0%, rgba(106,168,255,0.18), transparent 60%),
|
||||
radial-gradient(900px 350px at 100% 0%, rgba(255,123,107,0.10), transparent 55%),
|
||||
linear-gradient(180deg, #0e1119 0%, #0b0d12 100%);
|
||||
border-bottom: 1pt solid var(--border);
|
||||
page-break-after: always;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
header.cover::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 2pt;
|
||||
background: linear-gradient(90deg, var(--accent), #b46aff 50%, var(--neg));
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-family: var(--mono);
|
||||
font-size: 8.5pt;
|
||||
letter-spacing: 4pt;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin: 0 0 6mm;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 38pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1pt;
|
||||
margin: 0 0 4mm;
|
||||
line-height: 1.05;
|
||||
color: #fafbfd;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-family: var(--mono);
|
||||
font-size: 9pt;
|
||||
color: var(--text-mute);
|
||||
margin: 0 0 14mm;
|
||||
letter-spacing: 0.3pt;
|
||||
}
|
||||
|
||||
.cover-delta {
|
||||
padding: 14mm 12mm;
|
||||
background: var(--surface);
|
||||
border: 1pt solid var(--border);
|
||||
border-radius: 4pt;
|
||||
text-align: center;
|
||||
margin-bottom: 10mm;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-delta::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(600px 200px at 50% 120%, rgba(61,214,140,0.08), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.delta-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 8.5pt;
|
||||
color: var(--text-mute);
|
||||
letter-spacing: 3pt;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4mm;
|
||||
}
|
||||
|
||||
.delta-value {
|
||||
font-family: var(--mono);
|
||||
font-size: 52pt;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -2pt;
|
||||
margin: 2mm 0 4mm;
|
||||
}
|
||||
|
||||
.delta-value.positive { color: var(--pos); }
|
||||
.delta-value.negative { color: var(--neg); }
|
||||
|
||||
.delta-caption {
|
||||
font-family: var(--mono);
|
||||
font-size: 9pt;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.3pt;
|
||||
}
|
||||
|
||||
header.cover .disclaimer {
|
||||
font-size: 8pt;
|
||||
color: var(--warn);
|
||||
margin-top: 10mm;
|
||||
padding: 3mm 5mm;
|
||||
background: rgba(244,180,84,0.08);
|
||||
border-left: 2pt solid var(--warn);
|
||||
border-radius: 2pt;
|
||||
}
|
||||
|
||||
/* ============ Panels ============ */
|
||||
|
||||
section.panel {
|
||||
page-break-inside: avoid;
|
||||
padding: 8mm 0 6mm;
|
||||
border-bottom: 1pt solid var(--border);
|
||||
}
|
||||
|
||||
section.panel:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
section.panel.gut-punch {
|
||||
background: linear-gradient(180deg, rgba(255,123,107,0.06), rgba(255,123,107,0.02));
|
||||
border-left: 2pt solid var(--neg);
|
||||
padding: 7mm 7mm 6mm;
|
||||
border-radius: 2pt;
|
||||
margin: 3mm -3mm;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 13pt;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4mm;
|
||||
letter-spacing: -0.2pt;
|
||||
color: #f0f3f8;
|
||||
}
|
||||
|
||||
h2::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 3pt;
|
||||
height: 10pt;
|
||||
background: var(--accent);
|
||||
margin-right: 3mm;
|
||||
vertical-align: -1pt;
|
||||
border-radius: 1pt;
|
||||
}
|
||||
|
||||
.panel.gut-punch h2::before { background: var(--neg); }
|
||||
|
||||
.lede {
|
||||
font-size: 10.5pt;
|
||||
color: var(--text);
|
||||
margin: 0 0 4mm;
|
||||
max-width: 165mm;
|
||||
}
|
||||
|
||||
.lede strong {
|
||||
font-family: var(--mono);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lede strong.positive { color: var(--pos); }
|
||||
.lede strong.negative { color: var(--neg); }
|
||||
|
||||
/* ============ Fact list ============ */
|
||||
|
||||
dl.facts {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
column-gap: 8mm;
|
||||
row-gap: 2mm;
|
||||
margin: 3mm 0 0;
|
||||
padding: 4mm 5mm;
|
||||
background: var(--surface);
|
||||
border: 1pt solid var(--border);
|
||||
border-radius: 3pt;
|
||||
}
|
||||
|
||||
dl.facts dt {
|
||||
font-family: var(--mono);
|
||||
font-size: 8.5pt;
|
||||
color: var(--text-mute);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5pt;
|
||||
}
|
||||
|
||||
dl.facts dd {
|
||||
margin: 0;
|
||||
font-size: 9.5pt;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ============ Tables ============ */
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 9pt;
|
||||
margin-top: 2mm;
|
||||
background: var(--surface);
|
||||
border: 1pt solid var(--border);
|
||||
border-radius: 3pt;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
thead th {
|
||||
background: var(--surface2);
|
||||
font-family: var(--mono);
|
||||
font-size: 7.5pt;
|
||||
font-weight: 500;
|
||||
color: var(--text-mute);
|
||||
letter-spacing: 1pt;
|
||||
text-transform: uppercase;
|
||||
padding: 2.5mm 3mm;
|
||||
text-align: left;
|
||||
border-bottom: 1pt solid var(--border);
|
||||
}
|
||||
|
||||
tbody td, tbody th {
|
||||
padding: 2mm 3mm;
|
||||
border-bottom: 1pt solid rgba(36,42,54,0.6);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tbody tr:last-child td, tbody tr:last-child th {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
table.metrics tbody th {
|
||||
font-family: var(--mono);
|
||||
font-size: 8.5pt;
|
||||
color: var(--text-mute);
|
||||
font-weight: 500;
|
||||
width: 45%;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
tbody td.mono {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
tbody td.pos, .pos { color: var(--pos); }
|
||||
tbody td.neg, .neg { color: var(--neg); }
|
||||
|
||||
/* ============ Charts ============ */
|
||||
|
||||
img.chart {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 78mm;
|
||||
object-fit: contain;
|
||||
margin: 3mm 0;
|
||||
background: var(--surface);
|
||||
border: 1pt solid var(--border);
|
||||
border-radius: 3pt;
|
||||
padding: 2mm;
|
||||
}
|
||||
|
||||
/* ============ Misc ============ */
|
||||
|
||||
.muted {
|
||||
color: var(--text-mute);
|
||||
font-size: 9pt;
|
||||
font-style: italic;
|
||||
padding: 2mm 3mm;
|
||||
background: rgba(95,104,120,0.08);
|
||||
border-radius: 2pt;
|
||||
}
|
||||
|
||||
.panel .disclaimer {
|
||||
font-size: 8pt;
|
||||
color: var(--warn);
|
||||
font-family: var(--mono);
|
||||
margin: 3mm 0 0;
|
||||
padding: 2mm 3mm;
|
||||
background: rgba(244,180,84,0.07);
|
||||
border-left: 2pt solid var(--warn);
|
||||
letter-spacing: 0.2pt;
|
||||
}
|
||||
|
||||
.caveats {
|
||||
margin: 3mm 0 0;
|
||||
padding-left: 6mm;
|
||||
}
|
||||
|
||||
.caveats li {
|
||||
margin-bottom: 2mm;
|
||||
color: var(--text-dim);
|
||||
font-size: 9.5pt;
|
||||
}
|
||||
|
||||
.caveats li::marker {
|
||||
color: var(--accent);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Shadow Account Report - {{ profile.shadow_id }}</title>
|
||||
<style>{{ css }}</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="cover">
|
||||
<p class="eyebrow">Shadow Account Report</p>
|
||||
<h1>You vs Your Shadow</h1>
|
||||
<p class="subtitle">{{ profile.shadow_id }} | {{ profile.created_at }}</p>
|
||||
<div class="cover-delta">
|
||||
<div class="delta-label">Delta PnL</div>
|
||||
<div class="delta-value {{ 'positive' if delta_pnl > 0 else 'negative' }}">
|
||||
{{ '%+.2f' | format(delta_pnl) }}
|
||||
</div>
|
||||
<div class="delta-caption">Shadow {{ '%.2f' | format(shadow_pnl) }} | Real {{ '%.2f' | format(real_pnl) }}</div>
|
||||
</div>
|
||||
<p class="disclaimer">Research use only - not investment advice. Data window: {{ profile.date_range[0] }} -> {{ profile.date_range[1] }}</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<h2>1. Your Shadow Profile</h2>
|
||||
<p class="lede">{{ profile.profile_text }}</p>
|
||||
<dl class="facts">
|
||||
<dt>Profitable roundtrips</dt><dd>{{ profile.profitable_roundtrips }} / {{ profile.total_roundtrips }}</dd>
|
||||
<dt>Primary market</dt><dd>{{ market_labels[profile.source_market] | default(profile.source_market) }}</dd>
|
||||
<dt>Holding median / p75</dt><dd>{{ '%.1f' | format(profile.typical_holding_days[0]) }}d / {{ '%.1f' | format(profile.typical_holding_days[1]) }}d</dd>
|
||||
<dt>Preferred markets</dt><dd>{% for m in profile.preferred_markets %}{{ market_labels[m] | default(m) }}{% if not loop.last %}, {% endif %}{% endfor %}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>2. Shadow Rules</h2>
|
||||
<table class="rules">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Rule</th><th>Support</th><th>Coverage</th><th>Holding</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rule in profile.rules %}
|
||||
<tr>
|
||||
<td class="mono">{{ rule.rule_id }}</td>
|
||||
<td>{{ rule.human_text }}</td>
|
||||
<td class="mono">{{ rule.support_count }}</td>
|
||||
<td class="mono">{{ '%.0f%%' | format(rule.coverage_rate * 100) }}</td>
|
||||
<td class="mono">{{ rule.holding_days_range[0] }}-{{ rule.holding_days_range[1] }}d</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>3. Shadow Backtest - Combined</h2>
|
||||
{% if charts.equity_curve %}
|
||||
<img class="chart" src="{{ charts.equity_curve }}" alt="equity curve"/>
|
||||
{% endif %}
|
||||
{% if combined_error %}
|
||||
<p class="muted">Backtest warning: {{ combined_error }}</p>
|
||||
{% endif %}
|
||||
{% if combined_metrics %}
|
||||
<table class="metrics">
|
||||
{% for key, value in combined_metrics.items() %}
|
||||
<tr><th>{{ key }}</th><td class="mono">{{ '%.4f' | format(value) }}</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">Backtest produced no usable metrics (see warning above).</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>4. Shadow Backtest - By Market</h2>
|
||||
{% if charts.per_market_bar %}
|
||||
<img class="chart" src="{{ charts.per_market_bar }}" alt="per-market bar"/>
|
||||
{% endif %}
|
||||
<table class="metrics">
|
||||
<thead>
|
||||
<tr><th>Market</th><th>Sharpe</th><th>Annual</th><th>Max Drawdown</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for market, metrics in per_market_metrics.items() %}
|
||||
<tr>
|
||||
<td>{{ market_labels[market] | default(market) }}</td>
|
||||
<td class="mono">{{ '%.2f' | format(metrics.get('sharpe', 0.0)) }}</td>
|
||||
<td class="mono">{{ '%.2f%%' | format(metrics.get('annual_return', 0.0) * 100) }}</td>
|
||||
<td class="mono">{{ '%.2f%%' | format(metrics.get('max_drawdown', 0.0) * 100) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel gut-punch">
|
||||
<h2>5. You vs Shadow - Delta Attribution</h2>
|
||||
<p class="lede">Shadow PnL <strong class="{{ 'pos' if shadow_pnl > 0 else 'neg' if shadow_pnl < 0 else '' }}">{{ '%+.2f' | format(shadow_pnl) }}</strong>, your real PnL <strong class="{{ 'pos' if real_pnl > 0 else 'neg' if real_pnl < 0 else '' }}">{{ '%+.2f' | format(real_pnl) }}</strong>, delta <strong class="{{ 'pos' if delta_pnl > 0 else 'neg' if delta_pnl < 0 else '' }}">{{ '%+.2f' | format(delta_pnl) }}</strong>.</p>
|
||||
{% if charts.attribution_waterfall %}
|
||||
<img class="chart" src="{{ charts.attribution_waterfall }}" alt="attribution waterfall"/>
|
||||
{% endif %}
|
||||
<table class="metrics">
|
||||
<tr><th>Noise trades</th><td class="mono {{ 'pos' if attribution.noise_trades_pnl > 0 else 'neg' if attribution.noise_trades_pnl < 0 else '' }}">{{ '%+.2f' | format(attribution.noise_trades_pnl) }}</td></tr>
|
||||
<tr><th>Early exit (winners)</th><td class="mono {{ 'pos' if attribution.early_exit_pnl > 0 else 'neg' if attribution.early_exit_pnl < 0 else '' }}">{{ '%+.2f' | format(attribution.early_exit_pnl) }}</td></tr>
|
||||
<tr><th>Late exit (losers)</th><td class="mono {{ 'pos' if attribution.late_exit_pnl > 0 else 'neg' if attribution.late_exit_pnl < 0 else '' }}">{{ '%+.2f' | format(attribution.late_exit_pnl) }}</td></tr>
|
||||
<tr><th>Overtrading</th><td class="mono {{ 'pos' if attribution.overtrading_pnl > 0 else 'neg' if attribution.overtrading_pnl < 0 else '' }}">{{ '%+.2f' | format(attribution.overtrading_pnl) }}</td></tr>
|
||||
<tr><th>Missed signals (residual)</th><td class="mono {{ 'pos' if attribution.missed_signals_pnl > 0 else 'neg' if attribution.missed_signals_pnl < 0 else '' }}">{{ '%+.2f' | format(attribution.missed_signals_pnl) }}</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>6. Counterfactual Top 5</h2>
|
||||
{% if attribution.counterfactual_trades %}
|
||||
<table class="rules">
|
||||
<thead>
|
||||
<tr><th>#</th><th>Symbol</th><th>Buy</th><th>Sell</th><th>Hold</th><th>Real PnL</th><th>Impact</th><th>Reason</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in attribution.counterfactual_trades %}
|
||||
<tr>
|
||||
<td class="mono">{{ loop.index }}</td>
|
||||
<td>{{ t.symbol }}</td>
|
||||
<td class="mono">{{ t.buy_dt[:10] }}</td>
|
||||
<td class="mono">{{ t.sell_dt[:10] }}</td>
|
||||
<td class="mono">{{ '%.1f' | format(t.hold_days) }}d</td>
|
||||
<td class="mono {{ 'pos' if t.pnl > 0 else 'neg' if t.pnl < 0 else '' }}">{{ '%+.2f' | format(t.pnl) }}</td>
|
||||
<td class="mono {{ 'pos' if t.impact > 0 else 'neg' if t.impact < 0 else '' }}">{{ '%+.2f' | format(t.impact) }}</td>
|
||||
<td>{{ reason_labels.get(t.reason, t.reason) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">No material counterfactual trades - every roundtrip falls inside the shadow rules.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>7. Today's Signal Scan</h2>
|
||||
{% if today_signals %}
|
||||
<table class="rules">
|
||||
<thead>
|
||||
<tr><th>Symbol</th><th>Market</th><th>Rule</th><th>Reason</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in today_signals %}
|
||||
<tr>
|
||||
<td class="mono">{{ s.symbol }}</td>
|
||||
<td>{{ market_labels[s.market] | default(s.market) }}</td>
|
||||
<td class="mono">{{ s.rule_id }}</td>
|
||||
<td>{{ s.reason }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">No shadow-matching symbols today (or scan skipped).</p>
|
||||
{% endif %}
|
||||
<p class="disclaimer">For research reference only - not a buy/sell recommendation.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>8. Confidence & Caveats</h2>
|
||||
<ul class="caveats">
|
||||
<li>Sample size: {{ profile.profitable_roundtrips }} profitable roundtrips support {{ profile.rules | length }} rule(s).</li>
|
||||
<li>Backtest assumptions: slippage/fees use engine defaults; cross-market annualization uses calendar days.</li>
|
||||
<li>Style decay: rules reflect the user's historical window and may not hold in future regimes.</li>
|
||||
<li>Red line: this report is research-only and never routes to any live trading system.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Shadow Account generated signal engine.
|
||||
|
||||
This file is auto-generated; do not hand-edit. Regenerate via
|
||||
``src.shadow_account.codegen.render_signal_engine``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
SHADOW_ID = {{ shadow_id | py_literal }}
|
||||
PROFITABLE_ROUNDTRIPS = {{ profitable_roundtrips | py_literal }}
|
||||
DATE_RANGE = {{ date_range | py_literal }}
|
||||
PREFERRED_MARKETS = {{ preferred_markets | py_literal }}
|
||||
|
||||
RULES = {{ rules | py_literal }}
|
||||
|
||||
|
||||
def _market_of(code: str) -> str:
|
||||
"""Infer a coarse market bucket from a symbol (matches backtest.runner)."""
|
||||
c = (code or "").upper()
|
||||
if c.endswith(".SH") or c.endswith(".SZ") or c.endswith(".BJ"):
|
||||
return "china_a"
|
||||
if c.endswith(".HK"):
|
||||
return "hk"
|
||||
if "-USDT" in c or "-USD" in c or "/" in c:
|
||||
return "crypto"
|
||||
if c and c[0].isalpha():
|
||||
return "us"
|
||||
return "other"
|
||||
|
||||
|
||||
class SignalEngine:
|
||||
"""Shadow Account signal engine (rule-replay, daily timeframe).
|
||||
|
||||
For each code we pick the first rule whose market matches, then emit
|
||||
a long signal (= rule.weight) for ``hold_days`` bars every
|
||||
``2 * hold_days`` bars — a deliberate period-replay of the user's
|
||||
profitable holding cadence. Non-matching codes yield a zero series.
|
||||
"""
|
||||
|
||||
def generate(self, data_map: dict) -> dict:
|
||||
signals: dict[str, pd.Series] = {}
|
||||
for code, df in data_map.items():
|
||||
sig = pd.Series(0.0, index=df.index)
|
||||
rule = self._pick_rule(code)
|
||||
if rule is not None:
|
||||
sig = self._replay_rule(df.index, rule)
|
||||
signals[code] = sig
|
||||
return signals
|
||||
|
||||
def _pick_rule(self, code: str) -> dict | None:
|
||||
market = _market_of(code)
|
||||
for rule in RULES:
|
||||
if rule["market"] == market or rule["market"] == "other":
|
||||
return rule
|
||||
# Fallback: first rule, regardless of market match.
|
||||
return RULES[0] if RULES else None
|
||||
|
||||
@staticmethod
|
||||
def _replay_rule(index: pd.Index, rule: dict) -> pd.Series:
|
||||
hold = max(1, int(rule["hold_days"]))
|
||||
period = max(2, hold * 2)
|
||||
weight = float(rule["weight"])
|
||||
values = [0.0] * len(index)
|
||||
i = 0
|
||||
while i < len(index):
|
||||
for j in range(i, min(i + hold, len(index))):
|
||||
values[j] = weight
|
||||
i += period
|
||||
return pd.Series(values, index=index)
|
||||
@@ -0,0 +1,514 @@
|
||||
---
|
||||
name: ashare-pre-st-filter
|
||||
description: A 股 ST/*ST 风险预测框架 — 基于最新中报/三季报或业绩预告/快报,预测下一财年是否会因营收、利润、净资产、分红不达标而被风险警示,并将新浪监管处罚记录作为独立证据面纳入风险等级。仅适用于 A 股,不预测财务造假。
|
||||
category: risk-analysis
|
||||
---
|
||||
|
||||
> **依赖**:本 skill 必须配合 [tushare skill](../tushare/SKILL.md) 使用,缺数据时回退到 [akshare skill](../akshare/SKILL.md)。
|
||||
|
||||
# A 股 ST/*ST 风险预测
|
||||
|
||||
## 适用范围
|
||||
|
||||
- 仅 A 股**主板 / 创业板 / 科创板**。港股、美股、加密、商品、**北交所**(`8xxxxx.BJ` 监管规则与本 skill 阈值不一致)均不适用,本 skill 直接拒绝。
|
||||
- 仅做"下一财年是否会被 ST/*ST"的前瞻预测,不做财务造假预测。
|
||||
- 输出双轴结果:**触线风险等级** + **预测可信度**。
|
||||
|
||||
## 触发条件
|
||||
|
||||
用户提问形如:
|
||||
- "分析 000729.SZ 的 ST 风险"
|
||||
- "600xxx 下个年报会不会被 ST"
|
||||
- "帮我看一下 300xxx 是否有退市风险"
|
||||
|
||||
收到此类请求时,按下面"分析流程"执行;其他场景不要主动启用本 skill。
|
||||
|
||||
## 数据获取规范(tushare 优先 → akshare 兜底)
|
||||
|
||||
**铁律**:
|
||||
|
||||
1. **所有财务/基本面/分红/审计/ST 状态数据,必须先调用 [tushare skill](../tushare/SKILL.md)**,按下表指定的接口名、`ts_code`、`period` 拉取。
|
||||
2. 当 tushare 接口因积分不足、token 缺失或返回空时,**回退到 [akshare skill](../akshare/SKILL.md)** 对应函数。
|
||||
3. **akshare 兜底数据必须在最终输出"备注"中显式标注**:`数据源:akshare(非官方聚合,不保证完整性,建议核对原始公告)`。**不允许只用 akshare 给出"高可信度"预测**——akshare 兜底的红线项可信度强制降一档(高→中高,中高→中,中→低)。
|
||||
4. 监管处罚(E2)走本 skill 自带的 [scripts/fetch_sina_penalties.py](scripts/fetch_sina_penalties.py),不经过 tushare/akshare。
|
||||
|
||||
### 数据需求映射表
|
||||
|
||||
| 数据需求 | tushare 接口(首选) | 抓取参数 / 时间窗口 | akshare 兜底 | akshare 注意事项 |
|
||||
|---------|---------------------|-------------------|-------------|----------------|
|
||||
| 当前 ST 状态 | `stock_st` | 最近一个交易日 | `stock_zh_a_st_em()` | 仅返回当前 ST/退市整理板列表,历史不可查 |
|
||||
| 历史改名 | `namechange` | `ts_code` 全历史 | `stock_zh_a_new_em()`(仅新股,无改名) | akshare 无完整改名接口,必要时跳过 |
|
||||
| 股票基础信息(板块判断) | `stock_basic` | `list_status='L'` | `stock_individual_info_em(symbol)` | 字段名不同,需自行映射 |
|
||||
| 业绩预告 | `forecast` | `ts_code` + 最近 2 个 period(`{Y}0630`/`{Y}0930`/`{Y}1231`) | `stock_yjyg_em(date='YYYYMMDD')` | 按报告期日期反查,需先按 period 拉全市场再筛 ts_code |
|
||||
| 业绩快报 | `express` | 同上,最近 2 个 period | `stock_yjbb_em(date='YYYYMMDD')` | 同上,全市场快照需筛 |
|
||||
| 利润表 | `income` | `ts_code` + 最近 5 个 period(过去 4 年年报 `{Y}1231` + 最新中报/三季报) | `stock_financial_report_sina(stock='sh600000', symbol='利润表')` | 单位不一致(万元 vs 元),需做归一;季报缺失常见 |
|
||||
| 资产负债表 | `balancesheet` | 同上 | `stock_financial_report_sina(stock='sh600000', symbol='资产负债表')` | 同上 |
|
||||
| 现金流量表 | `cashflow` | 同上 | `stock_financial_report_sina(stock='sh600000', symbol='现金流量表')` | 同上 |
|
||||
| 财务指标(扣非净利润等) | `fina_indicator` | `ts_code` + 最近 5 个 period | `stock_financial_analysis_indicator(symbol='600000')` | 字段中文,扣非字段名为"扣除非经常性损益后的净利润" |
|
||||
| 审计意见 | `fina_audit` | `ts_code` + 最近 2 个年报 period | **akshare 无对应接口** | 缺失时在输出中标注"审计意见数据缺失",**不得跳过**——必须提示用户去官网查 |
|
||||
| 分红 | `dividend` | `ts_code` + 近 5 年 `div_proc='实施'` | `stock_history_dividend_detail(symbol='600000', indicator='分红')` | 字段中文,需把"派息"金额×总股本得到现金分红总额 |
|
||||
| 日线行情(1 元退市预警) | `daily` | `ts_code` + 最近 30 个交易日 | `stock_zh_a_hist(symbol='600000', period='daily', adjust='')` | 必须用**不复权**价格判断 1 元退市线 |
|
||||
| 每日指标(市值) | `daily_basic` | `ts_code` + 最近一个交易日 | `stock_zh_a_spot_em()` 全市场快照筛 `代码=600000` | 字段名为"总市值"(**单位:元**,东方财富 push2 原始口径,akshare 不做缩放),换算成亿元需 `/1e8`。⚠️ 不要与 tushare `daily_basic.total_mv`(万元)混淆——本列是 akshare 兜底口径 |
|
||||
| 财报披露日期 | `disclosure_date` | `ts_code` + 当前年度 | **akshare 无对应接口** | 缺失时按经验估算(4/30 年报、8/31 中报、10/31 三季报) |
|
||||
| 监管处罚 | — | — | **走本 skill 自带 sina 脚本** | 见 E2 章节 |
|
||||
|
||||
### 时间窗口口径统一
|
||||
|
||||
按数据类型分别处理,**禁止用单一 60 天经验值统一对齐**:
|
||||
|
||||
- **财务报表(income / balancesheet / cashflow / fina_indicator)**:以 `disclosure_date` 实际公告日为准;该接口不可用时按法定截止日(年报 4/30、中报 8/31、三季报 10/31、一季报 4/30)做兜底。在公告日 +1 天起方可视该 period 为"最新可得"。
|
||||
- **"过去 N 年年报"**:一律 `period = {Y}1231`,不要用日历年起止。
|
||||
- **业绩预告 forecast / 业绩快报 express**:只取 `ann_date ≥ 当前年度 1 月 1 日` 的记录;按 `ann_date` 倒序取最新一条,老 period 的预告对预测无效。
|
||||
- **dividend**:按 `(end_date, ann_date, cash_div_tax)` 三元组**强制去重**——tushare 同一笔分红会出现 3-4 行(预案/股东大会通过/实施),直接累加会三倍误算。
|
||||
|
||||
## 第一步:当前状态核查(M0)
|
||||
|
||||
**判断公司当前是否已被风险警示,并据此调整分析方向(不一刀切结束)**:
|
||||
|
||||
调用顺序:
|
||||
1. 优先调用 `stock_st`(tushare skill,3000 积分),按当前最近交易日查询;记录 `type` / `type_name`。
|
||||
2. 如积分不足,回退到 `namechange`(免费):拉取 `ts_code` 全部历史改名记录,取按 `start_date` 排序的最新一条;判断 `name` 前缀。
|
||||
3. 兜底用 `stock_basic.name` 做名称确认。
|
||||
|
||||
**分支处理**:
|
||||
|
||||
| 当前状态 | 分析方向 | 是否继续 |
|
||||
|---------|---------|---------|
|
||||
| 正常股票 | 预测下一财年是否会被 ST/*ST | **是**(默认流程) |
|
||||
| 已 ST(普通) | 改为预测:是否会进一步转 *ST 或退市;R1-R4 阈值需更严格(亏损链门槛降一年) | **是** |
|
||||
| 已 *ST | 改为预测:是否会被强制退市;重点看 R1 营收 + R2 净资产 + E1 审计意见 | **是** |
|
||||
| 已退市整理期 / 已摘牌 | 直接结束 | **否** |
|
||||
|
||||
## 第二步:板块阈值表(决定红线)
|
||||
|
||||
**先用 `ts_code` 前缀判断板块,再套用对应阈值。不要混用。**
|
||||
|
||||
| 板块 | 代码识别 | 营收红线 | 市值退市线 | 三年累计分红阈值 |
|
||||
|------|---------|---------|-----------|----------------|
|
||||
| 主板 | `60xxxx.SH` / `00xxxx.SZ`(除 30 / 688 开头外) | 净利润为负 且 营收 < 3 亿 | 市值 < 5 亿 | 累计 < 5000 万 且 < 年均净利润 30% |
|
||||
| 创业板 | `30xxxx.SZ` | 净利润为负 且 营收 < 1 亿 | 市值 < 3 亿 | 累计 < 3000 万 且 < 年均净利润 30%(研发豁免:研发投入占营收 ≥ 5% 或近三年累计 ≥ 6000 万) |
|
||||
| 科创板 | `688xxx.SH` | 净利润为负 且 营收 < 1 亿 | 市值 < 3 亿 | 累计 < 3000 万 且 < 年均净利润 30%(研发豁免:研发投入占营收 ≥ 5% 或近三年累计 ≥ 6000 万) |
|
||||
|
||||
## 第三步:预测时点选择
|
||||
|
||||
按"最新可得证据"原则确定基准时点,从下到上择优:
|
||||
|
||||
| 基准时点 | 数据接口 | 可信度基线 | 适用季节 |
|
||||
|---------|---------|-----------|---------|
|
||||
| **业绩预告 forecast** | `forecast` | **高** | 1 月底 / 7 月中旬 / 10 月底高发期 |
|
||||
| **业绩快报 express** | `express` | **高** | 1-4 月年报前夕 |
|
||||
| **三季报** | `income/balancesheet/cashflow` (`period=YYYYMMDD`,9 月末) | **中高** | 10 月底披露后到次年年报前 |
|
||||
| **中报** | 同上,6 月末 period | **中** | 8 月底披露后到三季报前 |
|
||||
| **去年年报** | 同上,12 月末 period | **低** | 仅作为基准对照 |
|
||||
|
||||
**证据优先级**(同一指标多个来源时,高的覆盖低的):
|
||||
|
||||
```
|
||||
forecast / express > 三季报 > 中报 > 去年年报 > 经验外推
|
||||
```
|
||||
|
||||
输出时**必须在每个预测项明示采用了哪一级证据**。如果存在 forecast 给出的全年净利润区间,**禁止再用三季报机械年化覆盖**。
|
||||
|
||||
## 第四步:四项可预测红线
|
||||
|
||||
### R1 营收 + 净利润红线
|
||||
|
||||
**目标**:预测全年营收、归母净利润和**扣非净利润**,按监管口径"扣非前后孰低者"判定。
|
||||
|
||||
**口径关键**:监管原文是 `min(n_income, profit_dedt) < 0` 且 `revenue < 板块阈值` —— **必须同时预测扣非净利润**,不能只用归母。仅看归母会漏掉"归母为正但扣非为负"的造壳公司。
|
||||
|
||||
**预测方法(按基准时点选用,至少一种)**:
|
||||
|
||||
- **forecast 直采法**:若 `forecast` 给出 `net_profit_min` / `net_profit_max`,直接取区间作为全年归母预测;扣非用过去两年扣非率(`profit_dedt / n_income` 中位数)折算;可信度=高。营收红线仍需结合最新报表外推。
|
||||
- **express 直采法**:若 `express` 已披露,`revenue` / `n_income` 即为全年快报值;扣非同样按过去两年率折算(express 一般无扣非字段);可信度=高。
|
||||
- **Q4 单季回补法**(三季报基准):
|
||||
- `Q1Q3_revenue` = 三季报营业收入
|
||||
- `Q4_revenue_est` = 过去两年 Q4 营收占全年比例的中位数 × 当前年化预估值
|
||||
- `revenue_full_year_est = Q1Q3_revenue + Q4_revenue_est`
|
||||
- `n_income` / `profit_dedt` 同法处理
|
||||
- 可信度=中高
|
||||
- **下半年情景外推法**(中报基准,强季节性公司必须用):
|
||||
- 取过去两年 H2 营收占全年比例均值,给出悲观/基准/乐观三情景
|
||||
- `revenue_full_year_est = H1_revenue / H1_share_avg`
|
||||
- 可信度=中
|
||||
- **同比延续法**(中报基准,弱季节性公司可用):
|
||||
- `revenue_full_year_est = H1_revenue × (last_year_full_revenue / last_year_H1_revenue)`
|
||||
- 可信度=中
|
||||
|
||||
**禁止**:H1 直接乘 2、Q1-Q3 直接乘 4/3。任何机械年化**强制降到"低"可信度**并在输出中注明。
|
||||
|
||||
**触线判定**(口径:`worst_profit = min(n_income_est, profit_dedt_est)`):
|
||||
- 高风险:`worst_profit < 0` 且 `revenue_est < 板块阈值`
|
||||
- 中风险:`worst_profit < 0` 但营收达标;或营收逼近阈值(< 阈值 × 1.2)
|
||||
- 低风险:上述均不满足
|
||||
|
||||
> **系数说明**:R1 中风险的 1.2 是预测项预警 buffer(预测本身有误差,需窄 buffer);R4 高风险的 1.5 是双年联动判定 buffer(两年都亏且营收接近阈值才是真退市信号,需宽 buffer)。两个系数不同是有意为之,**不要错误地统一**。
|
||||
|
||||
### R2 年末净资产红线
|
||||
|
||||
**目标**:判断年末归母股东权益是否可能转负。
|
||||
|
||||
**方法**:
|
||||
1. 取最新一期 `balancesheet.total_hldr_eqy_exc_min_int`(最新中报或三季报),记为 `current_eq`。
|
||||
2. 计算 `eq_year_end_est = current_eq + 剩余季度净利润预测 − 当前年度已实施现金分红总额`。
|
||||
- 剩余季度净利润:中报基准用 R1 的 H2 预测,三季报基准用 R1 的 Q4 预测。
|
||||
- 已实施分红总额:见 R3 的去重 + 量纲公式 `cash_div_tax / 10 × total_share`。
|
||||
|
||||
**触线判定**(统一相对阈值,单位:元):
|
||||
- 高风险:`current_eq < 0` 或 `eq_year_end_est < 0`
|
||||
- 中风险:`0 ≤ eq_year_end_est < 1e8`(1 亿元)
|
||||
- 低风险:`eq_year_end_est ≥ 1e8`
|
||||
|
||||
**注意**:商誉减值、长期股权投资减值在 Q3/Q4 集中确认,应在备注提示"若计提大额减值,净资产可能进一步下行",但**不得在没有公告依据时擅自减值**。
|
||||
|
||||
### R3 分红达标前瞻
|
||||
|
||||
**目标**:判断"近三年累计现金分红 < 年均净利润 30% 且 < 板块累计阈值"是否会在下一次年度考核时同时满足(监管原文:两条件**同时**触发才警示)。
|
||||
|
||||
**单笔分红量纲公式**(**必须用此公式,不要用 `cash_div_tax × base_share`**):
|
||||
|
||||
```
|
||||
cash_dividend_total = cash_div_tax / 10 × total_share
|
||||
```
|
||||
|
||||
其中 `cash_div_tax` 来自 `dividend`(每 10 股派现金额,含税),`total_share` 来自 `daily_basic.total_share`(最新总股本,单位万股 → 需 ×1e4 换成股)或 `stock_basic`。tushare `dividend.base_share` 字段对很多公司为 None,**禁止直接使用**。
|
||||
|
||||
**方法**:
|
||||
1. 用 `dividend` 拉取该 `ts_code` 近五年记录,按 `(end_date, ann_date, cash_div_tax)` 三元组**强制去重**——同一笔分红 tushare 会返回"预案/股东大会通过/实施"3-4 行。
|
||||
2. 折算系数(按 `div_proc` 状态):`实施=1.0` / `股东大会通过=0.7` / `预案=0.5`;同一 `end_date` 取系数最高那条。
|
||||
3. 用 `income` 拉取近三年完整年度归母净利润(period=YYYY1231)。
|
||||
4. 计算考核窗口 = `[当前年-2, 当前年]`,即"过去两年已落地分红 + 本年度预计分红"。
|
||||
5. 本年度预计分红:
|
||||
- 若 `dividend` 已查到本年度记录:按上面折算系数计入。
|
||||
- 否则用"过去两年平均分红率"× R1 预测全年净利润(基准情景)做估算,可信度降一档。
|
||||
6. 与年均净利润比较:
|
||||
- 条件 A:`cumulative_div_3y < 30% × avg_n_income_3y`
|
||||
- 条件 B:`cumulative_div_3y < 板块累计阈值`
|
||||
7. 创业板 / 科创板若研发投入占比满足豁免条件,备注提示"研发达标可能豁免"。
|
||||
|
||||
**触线判定**:
|
||||
- 高风险:A 和 B 同时满足 **且** 过去三年至少一年净利润为正(即"盈利年份未达标"——监管以盈利年为基准)。
|
||||
- 中风险:仅一个条件满足;或本年度分红尚未公告。
|
||||
- 低风险:累计分红已稳定达标;或过去三年累计净利润 ≤ 0(无分红基础,按 R1 联动)。
|
||||
|
||||
**禁止**:本年度尚未公告分红时,不得断言"必然不达标",只能给出概率。
|
||||
|
||||
### R4 连续亏损 / 扣非亏损链
|
||||
|
||||
**目标**:判断是否会触发"连续两年扣非前后净利润孰低者为负" → *ST。
|
||||
|
||||
**口径关键**:监管原文是**连续两年**(不是三年),且 *ST 财务类强制退市的法定条件是 **"亏损 + 营收<板块阈值" 必须同时满足**——只看亏损链会系统性高估风险(实证:闻泰科技 2024 年亏损但营收超千亿,不会被 *ST)。
|
||||
|
||||
**方法**:
|
||||
1. 取过去一年完整年报的 `n_income`、`fina_indicator.profit_dedt`,记 `worst_last = min(n_income, profit_dedt)`、`revenue_last`。
|
||||
2. 用 R1 已经预测好的 `worst_profit_est` 和 `revenue_full_year_est`(基准情景),对应当前年度。
|
||||
3. 检查两年的 `worst < 0` 组合,**并叠加营收联动条件**。
|
||||
|
||||
**触线判定**(双条件,亏损链 + 营收联动):
|
||||
- 高风险:`worst_last < 0` 且 `worst_profit_est < 0`,**且**(R1 当前年命中高风险 **或** `revenue_full_year_est < 板块阈值 × 1.5`)——即两年均亏 + 营收处于退市风险区间。
|
||||
- 中风险:(a) 连续两年均亏但营收远超阈值(≥ 阈值 × 1.5)——亏损链成立但不会触发财务类 *ST,仍可能因经营恶化转 ST;(b) 仅 `worst_profit_est < 0`(当前年预测亏损,去年盈利,未形成连续);(c) 仅 `worst_last < 0` 但当前年预测为正且接近 0(< 板块阈值 × 0.5)。
|
||||
- 低风险:`worst_last ≥ 0` 且 `worst_profit_est ≥ 0`。
|
||||
|
||||
**为什么要叠加营收**:A 股 *ST 财务类强制退市的法定条件是"扣非前后净利润孰低 < 0 **且** 营收 < 板块阈值",二者必须同时满足。仅亏损不达营收阈值的公司(如大型周期股短期亏损)会被市场和监管视作"周期性亏损"而非"持续经营存疑"。
|
||||
|
||||
## 第五步:三项事实/临界证据面(不参与预测可信度)
|
||||
|
||||
### E1 审计意见(事实)
|
||||
|
||||
调用 `fina_audit`,取最近 2 个完整年报:
|
||||
- 高风险:最新年报 `audit_result` 含"无法表示意见"或"否定意见";**或单次出现"保留意见"**(实证支持:上交所近年规范类 *ST 案例多为单次保留即触发)。
|
||||
- 中风险:连续两年同一负面结论但本期已修正;或带强调事项段的无保留意见涉及持续经营。
|
||||
- 低风险:标准无保留意见。
|
||||
|
||||
**禁止预测下一份审计意见**——审计是事后行为。
|
||||
|
||||
**akshare 兜底缺失时的人工核查路径**(`fina_audit` 无 akshare 替代接口,必须给出可点击 URL):
|
||||
|
||||
| 来源 | URL 模板 | 查询方式 |
|
||||
|------|---------|---------|
|
||||
| 巨潮资讯网(公告原文,权威) | `http://www.cninfo.com.cn/new/disclosure/stock?orgId=&stockCode={code6}` | 进入页面后筛选"定期报告 → 年度报告",下载 PDF 在第十节"财务报告 → 审计报告"查 `审计意见类型` |
|
||||
| 巨潮高级搜索(按关键词) | `http://www.cninfo.com.cn/new/fulltextSearch/full?searchkey={code6}+审计报告&sdate=&edate=&isfulltext=false&sortName=time&sortType=desc` | 直接命中含"审计报告"标题的公告 |
|
||||
| 上交所信息披露 | `http://www.sse.com.cn/disclosure/listedinfo/regular/?stockCode={code6}` | 仅限 60xxxx / 688xxx |
|
||||
| 深交所信息披露 | `http://www.szse.cn/disclosure/listed/notice/index.html?stock={code6}` | 仅限 00xxxx / 30xxxx |
|
||||
|
||||
其中 `{code6}` 为 6 位股票代码(去掉 `.SH` / `.SZ` 后缀)。**强制要求**:当 `fina_audit` 返回空时,输出报告的"备注"必须列出上面巨潮的两条 URL 之一,便于用户人工核查;不得只写一句"审计意见数据缺失"了事。
|
||||
|
||||
### E2 监管处罚(事实,双窗口:上一财年 + 过去 12 个月)
|
||||
|
||||
**这是本 skill 的关键证据面。**
|
||||
|
||||
**窗口规则(双窗口策略,单次抓取在内存中切片)**:
|
||||
|
||||
- 窗口 A — `[当前年-1 的 1 月 1 日, 当前年-1 的 12 月 31 日]`(上一个完整财年):用于 reason_normalized 单条评级。
|
||||
- 窗口 B — `[分析日 - 365 天, 分析日]`(过去 12 个月滚动):用于**频次增强规则**。
|
||||
- 若分析时点已经在 5 月之后且最新年报已披露完毕,窗口 A 可改为 `[最新年报 end_date - 365 天, 最新年报 end_date]`。
|
||||
|
||||
**调用方式(推荐:单次拉全量 + 内存切片)**:
|
||||
|
||||
```bash
|
||||
python agent/src/skills/ashare-pre-st-filter/scripts/fetch_sina_penalties.py \
|
||||
--ts-code 000729.SZ --stock-name 燕京啤酒 --no-filter
|
||||
```
|
||||
|
||||
返回页面全量记录后,由 LLM 在内存中按窗口 A、窗口 B 切两份。也可以用 `--start-date / --end-date` 单独抓取,但同一次分析**避免发起两次 HTTP**。
|
||||
|
||||
输出 JSON 列表,字段:`ann_date / event_type / title / reason / reason_normalized / content / issuer / issuer_normalized / subject_normalized / target_relevance / e2_countable / source_url`。
|
||||
|
||||
**相关性铁律**:调用脚本时必须传入 `--stock-name {股票简称}`(来自 `stock_basic.name`),必要时用 `--alias` 补充曾用名/简称。新浪页面可能收录“某个人证券账户交易列表里出现目标股票”的记录,这类记录只说明目标股票被交易过,**不是上市公司/股东/董监高处罚**。脚本会将其标为 `target_relevance=security_mention_only` 且 `e2_countable=false`,E2 频次统计必须排除。
|
||||
|
||||
**`subject_normalized` 字段**(用于频次权重折算):
|
||||
- `company` — 处罚主体是公司本身(默认;不含个人身份关键词时归此类)
|
||||
- `shareholder` — 控股股东 / 实控人 / 5% 以上大股东
|
||||
- `officer` — 董事 / 监事 / 高管(董事长、总经理、董秘、财务总监等)
|
||||
|
||||
**风险等级判定**:
|
||||
|
||||
| 命中 `reason_normalized` | 等级贡献 |
|
||||
|---|---|
|
||||
| 财务造假 / 虚假陈述 / 信息披露违规 | 高(强烈预警) |
|
||||
| 违规担保 / 占用资金 | 高(直接触及 ST 红线) |
|
||||
| 内幕交易 / 市场操纵 / 违规减持 | 中 |
|
||||
| 其他/unknown | 低(仅作背景) |
|
||||
|
||||
**频次增强规则(治理质量预警,窗口 B)**:
|
||||
|
||||
监管函本身反映公司治理与内控质量,密集出现是"规范类 ST"(保留意见、内控非标、信披重大缺陷)的强领先指标。但**处罚主体不同,治理含义不同**:股东减持违规、董监高短线交易反映的是个人合规问题,不应等同于"公司治理失序"。
|
||||
|
||||
**主体加权计数**(按 `subject_normalized` 折算后再查表):
|
||||
|
||||
```
|
||||
加权条数 = Σ 单条权重
|
||||
其中:subject_normalized == "company" → 权重 1.0
|
||||
subject_normalized == "officer" → 权重 0.5
|
||||
subject_normalized == "shareholder" → 权重 0.5
|
||||
```
|
||||
|
||||
| 过去 12 个月监管函/问询/警示函**加权条数** | 频次等级 |
|
||||
|---|---|
|
||||
| ≥ 5.0 | **极高** |
|
||||
| 3.0 - 4.5 | 高 |
|
||||
| 2.0 - 2.5 | 中 |
|
||||
| < 2.0 | 低 |
|
||||
|
||||
**判定来源**:仅当 `e2_countable=true` 时,脚本输出的 `event_type ∈ {警示, 问讯, 监管关注, 监管函}` 或 `issuer_normalized ∈ {上交所, 深交所, 北交所, 证监会, 地方证监局}` 才计入条数;条数按 `subject_normalized` 折算后再查表。`target_relevance=security_mention_only` 的记录必须排除。
|
||||
|
||||
**输出要求**:必须同时给出"原始条数"和"加权条数"两个数字,便于用户审阅折算逻辑。例如:`窗口 B 命中 6 条(公司 2 条 + 股东 3 条 + 高管 1 条 → 加权 4.0 条),频次等级:高`。
|
||||
|
||||
**E2 综合等级合成规则**(解决单条评级与频次叠加的问题):
|
||||
|
||||
```
|
||||
E2_单条等级 = 窗口 A 内所有 reason_normalized 单条等级的最大值
|
||||
E2_频次等级 = 窗口 B 频次表查得
|
||||
E2_综合等级 = max(E2_单条等级, E2_频次等级)
|
||||
```
|
||||
|
||||
**叠加加成**:若同时满足 `E2_频次等级 ≥ 高` 且 `E2_单条等级 ≥ 高`(含财务造假/虚假陈述/违规担保/占用资金)→ **直升极高**。
|
||||
|
||||
**为什么要这条规则**:纯量化红线只能捕捉"财务类 ST",无法预警"规范类 ST"。频次规则把"治理失序"作为前瞻信号纳入。
|
||||
|
||||
**注意**:处罚证据**只抬升风险等级,不影响预测可信度**——这是事实证据,不是预测项。
|
||||
|
||||
### E3 交易类临界预警(事实)
|
||||
|
||||
调用 `daily`(近 30 个交易日,**必须 `adjust=''` 不复权**)和 `daily_basic`(最新一日,单位:万元):
|
||||
|
||||
**1 元退市预警**(用 `daily.close`,**不复权**):
|
||||
- 高风险:近 20 个交易日中收盘价 < 1 元的天数 ≥ 10
|
||||
- 中风险:≥ 1 但 < 10
|
||||
- 低风险:0 天
|
||||
|
||||
**市值退市预警**(用 `daily_basic.total_mv`,单位万元 → ×1e4 换算成元 → /1e8 换算成亿元):
|
||||
|
||||
| 板块 | 高风险阈值 | 中风险阈值 |
|
||||
|---|---|---|
|
||||
| 主板 | < 5 亿 | < 5 × 1.5 = 7.5 亿 |
|
||||
| 创业板 / 科创板 | < 3 亿 | < 3 × 1.5 = 4.5 亿 |
|
||||
|
||||
## 第六步:双轴输出模板
|
||||
|
||||
每次分析必须按下面模板输出。**风险等级 + 可信度两个维度都要给**。
|
||||
|
||||
```markdown
|
||||
## ST 风险预测:{name}({ts_code})
|
||||
|
||||
### 当前状态
|
||||
- 是否已 ST/*ST:{是 / 否}
|
||||
- 板块:{主板 / 创业板 / 科创板}
|
||||
- 基准预测时点:{forecast / express / 三季报(period) / 中报(period)}
|
||||
|
||||
### 量化红线预测(双轴)
|
||||
|
||||
| 红线 | 基准情景结论 | 风险等级 | 可信度 | 主要证据 |
|
||||
|------|-------------|---------|-------|---------|
|
||||
| R1 营收+净利润 | 全年营收预测 X 亿,净利润 Y 亿 | 高/中/低 | 高/中高/中/低 | forecast/三季报/... |
|
||||
| R2 年末净资产 | 当前 X 亿,年末预计 Y 亿 | 高/中/低 | 高/中高/中/低 | balancesheet + R1 |
|
||||
| R3 分红达标 | 三年累计预计 X,对应阈值 Y | 高/中/低 | 中/低 | dividend + income |
|
||||
| R4 连续亏损链 | 过去两年 + 当前年是否均亏 | 高/中/低 | 同 R1 | income + fina_indicator |
|
||||
|
||||
### 事实证据面(不参与预测可信度)
|
||||
|
||||
| 证据 | 结论 | 影响 |
|
||||
|------|------|------|
|
||||
| E1 审计意见 | 最新结论 | 抬升/无影响 |
|
||||
| E2 监管处罚(窗口 A 上一财年 / 窗口 B 过去 12 个月) | A 命中 Na 条(最高单条等级:…),B 原始 Nb_raw 条 / 加权 Nb_w 条(公司 a + 股东 b + 高管 c → a×1.0+b×0.5+c×0.5),频次等级:…,E2_综合等级:… | 抬升/无影响 |
|
||||
| E3 交易类临界 | 收盘价/市值情况 | 抬升/无影响 |
|
||||
|
||||
### 综合结论
|
||||
- **下一财年被 ST/*ST 风险等级**:{极高 / 高 / 中 / 低}
|
||||
- **预测综合可信度**:{高 / 中高 / 中 / 低}
|
||||
- **关键风险点**(最多 3 条):...
|
||||
- **缓解信号**(如有):...
|
||||
|
||||
### 备注
|
||||
- 数据时点:{所用最新报告期}
|
||||
- 已忽略的不可预测项:财务造假、未来审计意见
|
||||
- 季节性提醒(如适用):...
|
||||
```
|
||||
|
||||
## 禁止事项
|
||||
|
||||
1. 不预测财务造假(事前不可知)。
|
||||
2. 不预测下一份审计意见(事后才出)。
|
||||
3. 不在没有公告依据时擅自计提减值。
|
||||
4. 不混用板块阈值(主板 vs 创业板/科创板的营收/市值/分红阈值不同)。
|
||||
5. 不把机械年化(H1×2、Q1Q3×4/3)伪装成"高可信度"。
|
||||
6. 不在港股 / 美股 / 加密 / **北交所**(`8xxxxx.BJ`,监管规则与本 skill 阈值不一致)上启用本 skill。
|
||||
7. 不把"违规处罚记录"计入预测可信度(仅抬升风险等级)。
|
||||
8. 不在 R3 分红计算中使用 `cash_div_tax × base_share`(base_share 经常为 None,会得 0);必须用 `cash_div_tax / 10 × total_share`,且按 `(end_date, ann_date, cash_div_tax)` 三元组去重。
|
||||
9. 不在 R1 触线判定中只用 `n_income`;必须用 `min(n_income, profit_dedt)`,否则会漏掉造壳公司。
|
||||
10. 不把 R4 写成"连续三年";监管原文是"连续两年"。**且 R4 高风险必须叠加营收联动条件**——仅"连续两年亏损"不构成 *ST 财务类强制退市,必须同时满足"营收 < 板块阈值 × 1.5"或 R1 已命中高风险,否则只能给中风险。
|
||||
11. 不把 E2 监管处罚"按主体一刀切"计数——必须用 `subject_normalized` 加权(公司 ×1.0 / 股东 ×0.5 / 董监高 ×0.5),否则股东动荡的公司会被系统性高估。
|
||||
|
||||
## 依赖的 tushare 接口清单
|
||||
|
||||
| 用途 | 接口 | 备注 |
|
||||
|------|------|------|
|
||||
| 当前 ST 状态 | `stock_st` | 3000 积分;不可用时回退 `namechange` |
|
||||
| 历史改名 | `namechange` | 免费 |
|
||||
| 板块判断 | `stock_basic` | 免费 |
|
||||
| 利润表 | `income` | 2000 积分,多 period 拉取 |
|
||||
| 资产负债表 | `balancesheet` | 2000 积分 |
|
||||
| 财务指标(扣非等) | `fina_indicator` | 2000 积分 |
|
||||
| 业绩预告 | `forecast` | 2000 积分 |
|
||||
| 业绩快报 | `express` | 2000 积分 |
|
||||
| 审计意见 | `fina_audit` | 500 积分 |
|
||||
| 分红 | `dividend` | 2000 积分 |
|
||||
| 日线 | `daily` | 免费 |
|
||||
| 每日指标(市值) | `daily_basic` | 免费 |
|
||||
| 财报披露日期 | `disclosure_date` | 500 积分 |
|
||||
|
||||
## 自带脚本
|
||||
|
||||
- [scripts/fetch_sina_penalties.py](scripts/fetch_sina_penalties.py) — 抓取新浪财经 vGP_GetOutOfLine 处罚页,stdlib 实现,无外部依赖;reason / issuer 标准化。
|
||||
|
||||
调用方式:
|
||||
|
||||
```bash
|
||||
# 单次拉全量(推荐:本 skill 双窗口策略由 LLM 在内存中切片)
|
||||
python agent/src/skills/ashare-pre-st-filter/scripts/fetch_sina_penalties.py \
|
||||
--ts-code 000729.SZ --stock-name 燕京啤酒 --no-filter
|
||||
|
||||
# 或指定单一窗口
|
||||
python agent/src/skills/ashare-pre-st-filter/scripts/fetch_sina_penalties.py \
|
||||
--ts-code 000729.SZ --stock-name 燕京啤酒 --start-date 2025-01-01 --end-date 2025-12-31
|
||||
```
|
||||
|
||||
正常情况下输出 JSON 到 stdout;网络/解析失败或参数非法时返回 `{"source": "unavailable", "error": "..."}` 到 stdout 并以非零退出码结束(不抛 traceback),便于 LLM 兜底处理。
|
||||
|
||||
## 端到端调用伪代码
|
||||
|
||||
用于减少 LLM 调用顺序漂移,按下列骨架组织:
|
||||
|
||||
```python
|
||||
import os, json, subprocess
|
||||
import tushare as ts
|
||||
|
||||
ts_code = '000729.SZ'
|
||||
pro = ts.pro_api(os.environ['TUSHARE_TOKEN'])
|
||||
|
||||
# === Step 0: M0 当前状态 ===
|
||||
basic = pro.stock_basic(ts_code=ts_code, fields='ts_code,name,market').to_dict('records')[0]
|
||||
st_hit = pro.stock_st() # 失败则回退 namechange
|
||||
name_chg = pro.namechange(ts_code=ts_code).sort_values('start_date', ascending=False)
|
||||
|
||||
# === Step 1: 板块判断 ===
|
||||
board = '科创板' if ts_code.startswith('688') else '创业板' if ts_code.startswith('30') else '主板'
|
||||
threshold_revenue = 1e8 if board != '主板' else 3e8
|
||||
threshold_mv = 3e8 if board != '主板' else 5e8
|
||||
|
||||
# === Step 2: 拉财务(最新 5 期 + 过去 4 年年报) ===
|
||||
income = pro.income(ts_code=ts_code).sort_values('end_date', ascending=False).drop_duplicates('end_date')
|
||||
balance = pro.balancesheet(ts_code=ts_code).sort_values('end_date', ascending=False).drop_duplicates('end_date')
|
||||
fina_ind = pro.fina_indicator(ts_code=ts_code).sort_values('end_date', ascending=False).drop_duplicates('end_date')
|
||||
forecast = pro.forecast(ts_code=ts_code).sort_values('ann_date', ascending=False)
|
||||
express = pro.express(ts_code=ts_code).sort_values('ann_date', ascending=False)
|
||||
|
||||
# === Step 3: R1-R4 计算(按各章节口径,注意 min(n_income, profit_dedt)) ===
|
||||
# ... 略
|
||||
|
||||
# === Step 4: E1 审计 ===
|
||||
audit = pro.fina_audit(ts_code=ts_code).sort_values('end_date', ascending=False).head(2)
|
||||
|
||||
# === Step 5: E2 监管处罚(双窗口 + 主体加权)===
|
||||
from datetime import date, timedelta
|
||||
result = subprocess.run([
|
||||
'python', 'agent/src/skills/ashare-pre-st-filter/scripts/fetch_sina_penalties.py',
|
||||
'--ts-code', ts_code, '--stock-name', basic['name'], '--no-filter',
|
||||
], capture_output=True, text=True)
|
||||
penalties = json.loads(result.stdout).get('records', [])
|
||||
|
||||
# 在内存中切双窗口(避免两次 HTTP)
|
||||
this_year = date.today().year
|
||||
win_a_start, win_a_end = f'{this_year-1}-01-01', f'{this_year-1}-12-31'
|
||||
win_b_start = (date.today() - timedelta(days=365)).isoformat()
|
||||
win_b_end = date.today().isoformat()
|
||||
|
||||
def _in(rec, s, e):
|
||||
d = rec.get('ann_date') or ''
|
||||
return bool(d) and s <= d <= e
|
||||
|
||||
win_a = [r for r in penalties if _in(r, win_a_start, win_a_end)]
|
||||
win_b = [r for r in penalties if _in(r, win_b_start, win_b_end)]
|
||||
|
||||
# 窗口 A:单条等级取最高
|
||||
REASON_LEVEL = {
|
||||
'财务造假': 3, '虚假陈述': 3, '信息披露违规': 3,
|
||||
'违规担保': 3, '占用资金': 3,
|
||||
'内幕交易': 2, '市场操纵': 2, '违规减持': 2,
|
||||
}
|
||||
levels_a = [REASON_LEVEL.get(r['reason_normalized'], 1) for r in win_a]
|
||||
e2_single = max(levels_a) if levels_a else 0
|
||||
|
||||
# 窗口 B:原始条数 + 主体加权条数
|
||||
FREQ_EVENT_TYPES = {'警示', '问讯', '监管关注', '监管函', '警示函'}
|
||||
FREQ_ISSUERS = {'上交所', '深交所', '北交所', '证监会', '地方证监局'}
|
||||
SUBJECT_WEIGHT = {'company': 1.0, 'shareholder': 0.5, 'officer': 0.5}
|
||||
|
||||
freq_pool = [r for r in win_b
|
||||
if r.get('e2_countable', True)
|
||||
and (r['event_type'] in FREQ_EVENT_TYPES
|
||||
or r['issuer_normalized'] in FREQ_ISSUERS)]
|
||||
nb_raw = len(freq_pool)
|
||||
nb_weighted = round(sum(SUBJECT_WEIGHT.get(r['subject_normalized'], 1.0)
|
||||
for r in freq_pool), 1)
|
||||
|
||||
# 频次等级(基于加权条数)
|
||||
if nb_weighted >= 5.0: e2_freq = 4 # 极高
|
||||
elif nb_weighted >= 3.0: e2_freq = 3 # 高
|
||||
elif nb_weighted >= 2.0: e2_freq = 2 # 中
|
||||
else: e2_freq = 1 # 低
|
||||
|
||||
e2_overall = max(e2_single, e2_freq)
|
||||
# 叠加加成:频次高 + 单条高同时命中 → 直升极高
|
||||
if e2_freq >= 3 and e2_single >= 3:
|
||||
e2_overall = 4
|
||||
|
||||
# === Step 6: E3 交易临界 ===
|
||||
daily = pro.daily(ts_code=ts_code).sort_values('trade_date', ascending=False).head(30)
|
||||
daily_basic = pro.daily_basic(ts_code=ts_code).sort_values('trade_date', ascending=False).head(1).iloc[0]
|
||||
total_mv_yi = daily_basic['total_mv'] * 1e4 / 1e8 # 万元 → 亿元
|
||||
|
||||
# === Step 7: 双轴合成输出 ===
|
||||
# 风险等级 = max(R1, R2, R3, R4, E1, E2_综合, E3)
|
||||
# 可信度 = 加权平均(R1, R2, R3, R4 各项可信度),akshare 兜底项降一档
|
||||
```
|
||||
@@ -0,0 +1,627 @@
|
||||
#!/usr/bin/env python3
|
||||
"""新浪财经 A 股监管处罚记录抓取脚本(独立 stdlib 实现)。
|
||||
|
||||
用途:为 ashare-pre-st-filter skill 的 E2 证据面提供输入。
|
||||
|
||||
数据源:vip.stock.finance.sina.com.cn/corp/go.php/vGP_GetOutOfLine/stockid/<6位>.phtml
|
||||
解析目标:HTML 表格 <table id="collectFund_1">,每条记录由 <thead>(类型/公告日期) + 4 行 (标题/批复原因/批复内容/处理人) 构成。
|
||||
|
||||
安全约束:
|
||||
- SSRF 防护:仅允许 host == vip.stock.finance.sina.com.cn
|
||||
- GBK 解码(新浪页面)
|
||||
- 节流 + 指数退避重试
|
||||
|
||||
输出:JSON 到 stdout;失败时返回 {"source": "unavailable", "error": "..."}。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import unicodedata
|
||||
from html.parser import HTMLParser
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
ALLOWED_HOST = "vip.stock.finance.sina.com.cn"
|
||||
URL_TEMPLATE = (
|
||||
"https://vip.stock.finance.sina.com.cn/corp/go.php/vGP_GetOutOfLine/stockid/{code6}.phtml"
|
||||
)
|
||||
USER_AGENT = (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0 Safari/537.36"
|
||||
)
|
||||
DEFAULT_TIMEOUT = 15
|
||||
MAX_RETRIES = 3
|
||||
THROTTLE_SECONDS = 0.4
|
||||
|
||||
REASON_KEYWORDS = [
|
||||
("信息披露违规", ["信息披露", "未及时披露", "未披露", "披露不", "披露违规"]),
|
||||
("内幕交易", ["内幕交易", "内幕信息"]),
|
||||
("市场操纵", ["操纵市场", "操纵股价", "操纵证券"]),
|
||||
("财务造假", ["财务造假", "虚增收入", "虚增利润", "财务舞弊"]),
|
||||
("虚假陈述", ["虚假记载", "虚假陈述", "误导性陈述"]),
|
||||
("违规减持", ["违规减持", "短线交易", "违规增减持"]),
|
||||
("占用资金", ["占用资金", "资金占用", "非经营性占用"]),
|
||||
("违规担保", ["违规担保", "对外担保"]),
|
||||
]
|
||||
|
||||
ISSUER_KEYWORDS = [
|
||||
("上交所", ["上海证券交易所", "上交所"]),
|
||||
("深交所", ["深圳证券交易所", "深交所"]),
|
||||
("北交所", ["北京证券交易所", "北交所"]),
|
||||
# 地方证监局必须优先于证监会,否则"中国证监会北京监管局"会被误归为证监会。
|
||||
("地方证监局", ["证监局"]),
|
||||
("证监会", ["中国证券监督管理委员会", "证监会"]),
|
||||
]
|
||||
|
||||
# 处罚主体识别。匹配优先级:shareholder > officer > company(默认)。
|
||||
# 用于 E2 频次规则的权重折算——非公司主体(股东/董监高)权重 ×0.5,
|
||||
# 因为这类处罚反映个人行为,不等同于"公司治理失序"。
|
||||
#
|
||||
# 优先级决策依据(CR P1-2):身份叠加(如"董事长 X 兼控股股东")时,
|
||||
# 监管语境取最重责任主体——shareholder 责任高于 officer,故先扫 shareholder。
|
||||
SUBJECT_KEYWORDS = [
|
||||
("shareholder", [
|
||||
"控股股东", "实际控制人", "实控人", "原实际控制人", "原实控人",
|
||||
"持股5%以上", "持股 5%以上", "5%以上股东", "5%以上的股东",
|
||||
"第一大股东", "二股东", "大股东", "股东减持",
|
||||
"股东收到", "股东因", "原股东", "前股东", "一致行动人",
|
||||
]),
|
||||
("officer", [
|
||||
"董事长", "副董事长", "总经理", "副总经理", "总裁",
|
||||
"财务总监", "董事会秘书", "董秘", "证券事务代表",
|
||||
"监事会主席", "独立董事",
|
||||
"时任董事", "时任监事", "时任高管", "时任董秘",
|
||||
"高级管理人员", "高管人员", "聘任的高级管理人员",
|
||||
]),
|
||||
]
|
||||
|
||||
DATE_RE = re.compile(r"公告日期[::]\s*(\d{4}-\d{1,2}-\d{1,2})")
|
||||
|
||||
SECURITY_MENTION_ONLY_KEYWORDS = [
|
||||
"证券从业",
|
||||
"证券账户",
|
||||
"控制使用",
|
||||
"持有并交易",
|
||||
"交易股票",
|
||||
"买入",
|
||||
"卖出",
|
||||
"账户交易",
|
||||
]
|
||||
|
||||
|
||||
def _norm_text(text: str) -> str:
|
||||
"""文本归一化:NFKC 折叠全角/半角 + 去除内部空白。
|
||||
|
||||
应用场景(CR P2):新浪页面会出现“5%以上股东”、“控股 股东”等变体,
|
||||
原吝始“kw in text”会漏命中。归一化后“5%”→“5%”,中文间空格被吃掉。
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
s = unicodedata.normalize("NFKC", text)
|
||||
return re.sub(r"\s+", "", s)
|
||||
|
||||
|
||||
def _validate_stockid(ts_code: str) -> str:
|
||||
"""从 600000.SH / 000001.SZ / 600000 中抽取 6 位股票代码。"""
|
||||
if not ts_code:
|
||||
raise ValueError("ts_code is required")
|
||||
s = ts_code.strip().upper()
|
||||
s = re.sub(r"\.(SH|SZ|BJ)$", "", s)
|
||||
if not re.fullmatch(r"\d{6}", s):
|
||||
raise ValueError(f"invalid ts_code: {ts_code!r} (expect 6-digit code)")
|
||||
return s
|
||||
|
||||
|
||||
def _normalize_reason(text: str) -> str:
|
||||
if not text:
|
||||
return "unknown"
|
||||
norm = _norm_text(text)
|
||||
for label, kws in REASON_KEYWORDS:
|
||||
for kw in kws:
|
||||
if _norm_text(kw) in norm:
|
||||
return label
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _normalize_issuer(text: str) -> str:
|
||||
if not text:
|
||||
return "unknown"
|
||||
norm = _norm_text(text)
|
||||
for label, kws in ISSUER_KEYWORDS:
|
||||
for kw in kws:
|
||||
if _norm_text(kw) in norm:
|
||||
return label
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _normalize_subject(text: str) -> str:
|
||||
"""从标题/原因/内容文本推断处罚主体。
|
||||
|
||||
返回值:
|
||||
- "shareholder" 控股股东 / 实控人 / 5% 以上大股东
|
||||
- "officer" 董事 / 监事 / 高管(董秘、财务总监等)
|
||||
- "company" 默认;当文本无任何上述关键词时归为公司本身
|
||||
|
||||
供 E2 频次规则做主体权重折算(非公司主体 ×0.5)。
|
||||
"""
|
||||
if not text:
|
||||
return "company"
|
||||
norm = _norm_text(text)
|
||||
for label, kws in SUBJECT_KEYWORDS:
|
||||
for kw in kws:
|
||||
if _norm_text(kw) in norm:
|
||||
return label
|
||||
return "company"
|
||||
|
||||
|
||||
def _build_target_aliases(stock_name: str | None, aliases: list[str] | None = None) -> list[str]:
|
||||
"""Build normalized target-name aliases for relevance checks.
|
||||
|
||||
Args:
|
||||
stock_name: Official short name from ``stock_basic.name``.
|
||||
aliases: Extra caller-provided aliases.
|
||||
|
||||
Returns:
|
||||
Deduplicated normalized aliases.
|
||||
"""
|
||||
candidates = [stock_name or "", *(aliases or [])]
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for candidate in candidates:
|
||||
norm = _norm_text(candidate)
|
||||
if not norm or norm in seen:
|
||||
continue
|
||||
seen.add(norm)
|
||||
out.append(norm)
|
||||
return out
|
||||
|
||||
|
||||
def _contains_any_alias(text: str, aliases: list[str]) -> bool:
|
||||
norm = _norm_text(text)
|
||||
return bool(norm and any(alias in norm for alias in aliases))
|
||||
|
||||
|
||||
def _classify_target_relevance(
|
||||
record: dict[str, Any],
|
||||
target_aliases: list[str],
|
||||
) -> tuple[str, bool, str]:
|
||||
"""Classify whether a Sina penalty record is countable for this stock.
|
||||
|
||||
Args:
|
||||
record: Parsed penalty record.
|
||||
target_aliases: Normalized stock-name aliases.
|
||||
|
||||
Returns:
|
||||
``(target_relevance, e2_countable, relevance_reason)``.
|
||||
"""
|
||||
if not target_aliases:
|
||||
return "unknown", True, "stock_name_not_provided"
|
||||
|
||||
title = str(record.get("title") or "")
|
||||
reason = str(record.get("reason") or "")
|
||||
content = str(record.get("content") or "")
|
||||
combined = " ".join([title, reason, content])
|
||||
if not _contains_any_alias(combined, target_aliases):
|
||||
return "unknown", False, "target_alias_not_found"
|
||||
|
||||
title_hits_target = _contains_any_alias(title, target_aliases)
|
||||
subject = str(record.get("subject_normalized") or "company")
|
||||
looks_like_security_mention = any(
|
||||
_norm_text(keyword) in _norm_text(combined)
|
||||
for keyword in SECURITY_MENTION_ONLY_KEYWORDS
|
||||
)
|
||||
|
||||
if not title_hits_target and looks_like_security_mention:
|
||||
return "security_mention_only", False, "target_only_appears_in_security_trade_list"
|
||||
if subject in {"shareholder", "officer"}:
|
||||
return "related_party", True, f"target_named_with_{subject}"
|
||||
return "issuer_company", True, "target_named_as_company_record"
|
||||
|
||||
|
||||
def _annotate_relevance(
|
||||
records: list[dict[str, Any]],
|
||||
target_aliases: list[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Add target relevance fields used by E2 frequency counting."""
|
||||
for rec in records:
|
||||
relevance, countable, reason = _classify_target_relevance(rec, target_aliases)
|
||||
rec["target_relevance"] = relevance
|
||||
rec["e2_countable"] = countable
|
||||
rec["relevance_reason"] = reason
|
||||
if relevance == "security_mention_only" and rec.get("subject_normalized") == "company":
|
||||
rec["subject_normalized"] = "unknown"
|
||||
return records
|
||||
|
||||
|
||||
def _http_get_gbk(url: str, *, timeout: int = DEFAULT_TIMEOUT) -> str:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
raise ValueError(f"disallowed scheme: {parsed.scheme}")
|
||||
if parsed.hostname != ALLOWED_HOST:
|
||||
raise ValueError(f"disallowed host: {parsed.hostname}")
|
||||
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(1, MAX_RETRIES + 1):
|
||||
try:
|
||||
req = Request(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": USER_AGENT,
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
"Accept-Encoding": "gzip",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
},
|
||||
)
|
||||
with urlopen(req, timeout=timeout) as resp: # noqa: S310 — host is whitelisted
|
||||
raw = resp.read()
|
||||
if resp.headers.get("Content-Encoding", "").lower() == "gzip":
|
||||
raw = gzip.decompress(raw)
|
||||
try:
|
||||
return raw.decode("gbk", errors="replace")
|
||||
except LookupError:
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
except (HTTPError, URLError, TimeoutError) as exc:
|
||||
last_err = exc
|
||||
time.sleep(THROTTLE_SECONDS * (2 ** (attempt - 1)))
|
||||
raise RuntimeError(f"http get failed after {MAX_RETRIES} retries: {last_err}")
|
||||
|
||||
|
||||
class _TableExtractor(HTMLParser):
|
||||
"""提取 <table id="collectFund_1"> 的内部 HTML 文本(包含子标签)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=False)
|
||||
self._depth = 0
|
||||
self._capture = False
|
||||
self._buf: list[str] = []
|
||||
self.found: str | None = None
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
if tag == "table":
|
||||
attrs_d = dict(attrs)
|
||||
if not self._capture and attrs_d.get("id") == "collectFund_1":
|
||||
self._capture = True
|
||||
self._depth = 1
|
||||
return
|
||||
if self._capture:
|
||||
self._depth += 1
|
||||
if self._capture:
|
||||
attr_str = "".join(f' {k}="{v}"' if v is not None else f" {k}" for k, v in attrs)
|
||||
self._buf.append(f"<{tag}{attr_str}>")
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if not self._capture:
|
||||
return
|
||||
if tag == "table":
|
||||
self._depth -= 1
|
||||
if self._depth <= 0:
|
||||
self.found = "".join(self._buf)
|
||||
self._capture = False
|
||||
return
|
||||
self._buf.append(f"</{tag}>")
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self._capture:
|
||||
self._buf.append(data)
|
||||
|
||||
def handle_entityref(self, name: str) -> None:
|
||||
if self._capture:
|
||||
self._buf.append(f"&{name};")
|
||||
|
||||
def handle_charref(self, name: str) -> None:
|
||||
if self._capture:
|
||||
self._buf.append(f"&#{name};")
|
||||
|
||||
|
||||
# event_type 黑名单:head 文本首个中文片段如果落在这些"结构性字面量"上,
|
||||
# 说明该 thead 没有显式标注事件类型,应回退到"未分类"。
|
||||
# (CR P0-3:避免把"公告日期"误识别为 event_type,导致 E2 频次规则漏命中。)
|
||||
_EVENT_TYPE_BLOCKLIST = {
|
||||
"公告日期", "日期", "批复", "批复内容", "批复原因",
|
||||
"处罚日期", "处罚原因", "处罚内容", "处罚机关",
|
||||
"标题", "处理人", "类型",
|
||||
}
|
||||
|
||||
|
||||
def _extract_event_type(head_text: str) -> str:
|
||||
"""从 thead 文本中提取事件类型,跳过结构性字面量。"""
|
||||
for m in re.finditer(r"[\u4e00-\u9fa5]+", head_text):
|
||||
token = m.group(0)
|
||||
if token in _EVENT_TYPE_BLOCKLIST:
|
||||
continue
|
||||
return token
|
||||
return "未分类"
|
||||
|
||||
|
||||
class _RecordParser(HTMLParser):
|
||||
"""以 HTMLParser 切分 collectFund_1 表格中的单条记录。
|
||||
|
||||
替代原 `_DATA_ROW_RE` + `_RECORD_HEAD_RE` 正则方案——后者
|
||||
在 `re.DOTALL + (?:(?!</tr>).)*?` 下属于灾难性回溯模式,
|
||||
遇到未闭合 `<tr>` 的脏 HTML 会指数级回溯(CR P0-1)。
|
||||
|
||||
解析模型:
|
||||
- 状态机以 `<thead>` 为记录起点;遇到下一个 `<thead>` 落盘上一条。
|
||||
- 记录内的 `<tr>` 抓取 `<strong>KEY</strong>` 与同行第一个 `<td>VALUE</td>`。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.records: list[dict[str, str]] = []
|
||||
self._cur: dict[str, Any] | None = None
|
||||
self._mode: str | None = None # 'thead' | 'tr_key' | 'tr_val' | None
|
||||
self._buf: list[str] = []
|
||||
self._cur_key: str = ""
|
||||
self._val_captured: bool = False # 同一 tr 内只取第一个 value td
|
||||
|
||||
def _flush(self) -> None:
|
||||
if self._cur is not None:
|
||||
self.records.append(self._cur)
|
||||
self._cur = {"_head": "", "_fields": {}}
|
||||
|
||||
def _capture(self, data: str) -> None:
|
||||
if self._mode in ("thead", "tr_key", "tr_val"):
|
||||
self._buf.append(data)
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: # noqa: ARG002
|
||||
if tag == "thead":
|
||||
self._flush()
|
||||
self._mode = "thead"
|
||||
self._buf = []
|
||||
elif tag == "tr":
|
||||
self._cur_key = ""
|
||||
self._val_captured = False
|
||||
elif tag == "strong" and self._mode not in ("thead", "tr_val"):
|
||||
# 仅在 row 起始(尚未进入 value td)时把 strong 视为 key;
|
||||
# 否则可能是 value 内的 <strong> 嵌套,应保留为 value 文本。
|
||||
self._mode = "tr_key"
|
||||
self._buf = []
|
||||
elif tag == "td":
|
||||
# 进入 td:仅当已读到 key 且本行尚未捕获 value 时切到 tr_val
|
||||
if self._mode != "thead" and self._cur_key and not self._val_captured:
|
||||
self._mode = "tr_val"
|
||||
self._buf = []
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag == "thead" and self._mode == "thead":
|
||||
head_text = re.sub(r"\s+", " ", "".join(self._buf)).strip()
|
||||
if self._cur is None:
|
||||
self._cur = {"_head": "", "_fields": {}}
|
||||
self._cur["_head"] = head_text
|
||||
self._mode = None
|
||||
self._buf = []
|
||||
elif tag == "strong" and self._mode == "tr_key":
|
||||
self._cur_key = "".join(self._buf).strip().rstrip("::")
|
||||
self._mode = None
|
||||
self._buf = []
|
||||
elif tag == "td" and self._mode == "tr_val":
|
||||
val = re.sub(r"\s+", " ", "".join(self._buf)).strip()
|
||||
if self._cur is not None and self._cur_key:
|
||||
self._cur["_fields"][self._cur_key] = val
|
||||
self._val_captured = True
|
||||
self._cur_key = ""
|
||||
self._mode = None
|
||||
self._buf = []
|
||||
elif tag == "tr":
|
||||
self._cur_key = ""
|
||||
self._val_captured = False
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
self._capture(data)
|
||||
|
||||
def close(self) -> None: # type: ignore[override]
|
||||
super().close()
|
||||
# 落盘最后一条
|
||||
if self._cur is not None and (self._cur.get("_head") or self._cur.get("_fields")):
|
||||
self.records.append(self._cur)
|
||||
self._cur = None
|
||||
|
||||
|
||||
def _build_record(raw: dict[str, Any]) -> dict[str, Any] | None:
|
||||
head = raw.get("_head") or ""
|
||||
fields: dict[str, str] = raw.get("_fields") or {}
|
||||
|
||||
date_m = DATE_RE.search(head)
|
||||
ann_date = ""
|
||||
if date_m:
|
||||
try:
|
||||
y, mo, d = date_m.group(1).split("-")
|
||||
ann_date = f"{int(y):04d}-{int(mo):02d}-{int(d):02d}"
|
||||
except Exception:
|
||||
ann_date = date_m.group(1)
|
||||
|
||||
# event_type:先剥掉"公告日期: YYYY-MM-DD"再扫剩余首个中文片段;
|
||||
# 双重防御——剥不干净时再用黑名单兜底(CR P0-3)。
|
||||
head_wo_date = DATE_RE.sub("", head)
|
||||
head_wo_date = re.sub(r"公告日期[::]?", "", head_wo_date)
|
||||
event_type = _extract_event_type(head_wo_date) or _extract_event_type(head)
|
||||
|
||||
title = fields.get("标题", "")
|
||||
reason = fields.get("批复原因", "") or fields.get("处罚原因", "")
|
||||
content = fields.get("批复内容", "") or fields.get("处罚内容", "")
|
||||
issuer = fields.get("处理人", "") or fields.get("处罚机关", "")
|
||||
|
||||
if not (title or reason or content):
|
||||
return None
|
||||
|
||||
combined_for_reason = " ".join([title, reason, content])
|
||||
return {
|
||||
"ann_date": ann_date,
|
||||
"event_type": event_type,
|
||||
"title": title,
|
||||
"reason": reason,
|
||||
"reason_normalized": _normalize_reason(combined_for_reason),
|
||||
"content": content,
|
||||
"issuer": issuer,
|
||||
"issuer_normalized": _normalize_issuer(issuer),
|
||||
"subject_normalized": _normalize_subject(combined_for_reason),
|
||||
}
|
||||
|
||||
|
||||
def _parse_penalty_list(html: str) -> list[dict[str, Any]]:
|
||||
extractor = _TableExtractor()
|
||||
extractor.feed(html)
|
||||
if not extractor.found:
|
||||
return []
|
||||
parser = _RecordParser()
|
||||
parser.feed(extractor.found)
|
||||
parser.close()
|
||||
out: list[dict[str, Any]] = []
|
||||
for raw in parser.records:
|
||||
rec = _build_record(raw)
|
||||
if rec is not None:
|
||||
out.append(rec)
|
||||
return out
|
||||
|
||||
|
||||
def _apply_date_filter(
|
||||
records: list[dict[str, Any]],
|
||||
start_date: str | None,
|
||||
end_date: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
if not start_date and not end_date:
|
||||
return records
|
||||
out: list[dict[str, Any]] = []
|
||||
for rec in records:
|
||||
d = rec.get("ann_date") or ""
|
||||
if not d:
|
||||
# 公告日缺失时保留记录,但添加标记供 LLM 识别。
|
||||
rec.setdefault("_warning", "missing_ann_date")
|
||||
out.append(rec)
|
||||
continue
|
||||
if start_date and d < start_date:
|
||||
continue
|
||||
if end_date and d > end_date:
|
||||
continue
|
||||
out.append(rec)
|
||||
return out
|
||||
|
||||
|
||||
def fetch_penalty_list(
|
||||
ts_code: str,
|
||||
*,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
stock_name: str | None = None,
|
||||
aliases: list[str] | None = None,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
) -> dict[str, Any]:
|
||||
code6 = _validate_stockid(ts_code)
|
||||
url = URL_TEMPLATE.format(code6=code6)
|
||||
try:
|
||||
html = _http_get_gbk(url, timeout=timeout)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"source": "unavailable",
|
||||
"ts_code": ts_code,
|
||||
"url": url,
|
||||
"error": f"http_failed: {exc}",
|
||||
"records": [],
|
||||
}
|
||||
try:
|
||||
records = _parse_penalty_list(html)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"source": "unavailable",
|
||||
"ts_code": ts_code,
|
||||
"url": url,
|
||||
"error": f"parse_failed: {exc}",
|
||||
"records": [],
|
||||
}
|
||||
target_aliases = _build_target_aliases(stock_name, aliases)
|
||||
filtered = _apply_date_filter(_annotate_relevance(records, target_aliases), start_date, end_date)
|
||||
for rec in filtered:
|
||||
rec["source_url"] = url
|
||||
return {
|
||||
"source": "sina",
|
||||
"ts_code": ts_code,
|
||||
"url": url,
|
||||
"target_aliases": target_aliases,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"total_in_page": len(records),
|
||||
"matched": len(filtered),
|
||||
"records": filtered,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_date_arg(s: str | None) -> str | None:
|
||||
if not s:
|
||||
return None
|
||||
s = s.strip()
|
||||
if not s:
|
||||
return None
|
||||
m = re.fullmatch(r"(\d{4})[-/]?(\d{1,2})[-/]?(\d{1,2})", s)
|
||||
if not m:
|
||||
raise ValueError(f"invalid date: {s!r}")
|
||||
y, mo, d = m.groups()
|
||||
return f"{int(y):04d}-{int(mo):02d}-{int(d):02d}"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--ts-code", required=True, help="例如 600000.SH / 000001.SZ / 688001")
|
||||
parser.add_argument("--stock-name", default=None, help="股票简称,例如 闻泰科技;用于过滤误召处罚记录")
|
||||
parser.add_argument(
|
||||
"--alias",
|
||||
action="append",
|
||||
default=[],
|
||||
help="额外目标别名;可重复传入",
|
||||
)
|
||||
parser.add_argument("--start-date", default=None, help="YYYY-MM-DD,含端点")
|
||||
parser.add_argument("--end-date", default=None, help="YYYY-MM-DD,含端点")
|
||||
parser.add_argument(
|
||||
"--no-filter",
|
||||
action="store_true",
|
||||
help="返回页面全量记录、忽略 --start-date / --end-date(推荐:由调用方在内存中切双窗口,避免两次 HTTP)",
|
||||
)
|
||||
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
|
||||
parser.add_argument("--indent", type=int, default=2)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
if args.no_filter:
|
||||
start_date = None
|
||||
end_date = None
|
||||
else:
|
||||
start_date = _normalize_date_arg(args.start_date)
|
||||
end_date = _normalize_date_arg(args.end_date)
|
||||
except ValueError as exc:
|
||||
print(json.dumps({"source": "unavailable", "error": str(exc)}, ensure_ascii=False))
|
||||
return 2
|
||||
|
||||
try:
|
||||
result = fetch_penalty_list(
|
||||
args.ts_code,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
stock_name=args.stock_name,
|
||||
aliases=args.alias,
|
||||
timeout=args.timeout,
|
||||
)
|
||||
except ValueError as exc:
|
||||
# _validate_stockid 等参数校验异常:保持脚本契约
|
||||
# (永远输出 JSON,不抛 traceback),返回非零退出码。
|
||||
json.dump(
|
||||
{"source": "unavailable", "ts_code": args.ts_code, "error": f"invalid_input: {exc}", "records": []},
|
||||
sys.stdout,
|
||||
ensure_ascii=False,
|
||||
indent=args.indent,
|
||||
)
|
||||
sys.stdout.write("\n")
|
||||
return 2
|
||||
json.dump(result, sys.stdout, ensure_ascii=False, indent=args.indent)
|
||||
sys.stdout.write("\n")
|
||||
return 0 if result.get("source") == "sina" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
name: cross-market-strategy
|
||||
description: Write signal_engine.py for portfolios spanning multiple markets (A-shares + crypto, equity + forex, etc.)
|
||||
category: strategy
|
||||
---
|
||||
|
||||
## When to Use
|
||||
|
||||
When the user requests a backtest with codes from **different markets** — e.g. `["000001.SZ", "BTC-USDT"]` or `["AAPL.US", "EUR/USD", "600519.SH"]`.
|
||||
|
||||
The `CompositeEngine` handles calendar alignment, shared capital, and market rules automatically. The strategy only needs to output per-symbol signals.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### 1. Market Classification in generate()
|
||||
|
||||
Group symbols by market type and apply market-specific indicator parameters:
|
||||
|
||||
```python
|
||||
def generate(self, data_map):
|
||||
groups = {}
|
||||
for code, df in data_map.items():
|
||||
market = self._detect_market(code)
|
||||
groups.setdefault(market, {})[code] = df
|
||||
|
||||
signals = {}
|
||||
for market, market_data in groups.items():
|
||||
params = MARKET_PARAMS[market]
|
||||
for code, df in market_data.items():
|
||||
signals[code] = self._market_signal(df, params)
|
||||
return signals
|
||||
```
|
||||
|
||||
### 2. Per-Market Parameter Tables
|
||||
|
||||
Different markets have very different dynamics. Using the same parameters everywhere produces poor results.
|
||||
|
||||
| Parameter | A-Share | Crypto | US Equity | Forex |
|
||||
|-----------|---------|--------|-----------|-------|
|
||||
| MA fast | 5 | 7 | 10 | 10 |
|
||||
| MA slow | 20 | 25 | 50 | 30 |
|
||||
| RSI period | 14 | 10 | 14 | 14 |
|
||||
| Vol lookback | 20 | 14 | 20 | 20 |
|
||||
| Typical daily vol | 1-2% | 3-8% | 1-2% | 0.3-0.8% |
|
||||
|
||||
### 3. Volatility-Adjusted Weights (Critical)
|
||||
|
||||
BTC daily vol ~ 5%, A-share daily vol ~ 1.5%. Without vol-adjustment, crypto eats the entire risk budget.
|
||||
|
||||
```python
|
||||
def _vol_adjust(self, signals, data_map):
|
||||
vols = {}
|
||||
for code, df in data_map.items():
|
||||
ret = df["close"].pct_change().dropna()
|
||||
vols[code] = ret.rolling(20).std().iloc[-1] if len(ret) > 20 else ret.std()
|
||||
|
||||
inv_vols = {c: 1.0 / (v + 1e-10) for c, v in vols.items()}
|
||||
total_inv = sum(inv_vols.values())
|
||||
|
||||
adjusted = {}
|
||||
for code, sig in signals.items():
|
||||
weight = inv_vols[code] / total_inv * len(signals)
|
||||
adjusted[code] = (sig * weight).clip(-1.0, 1.0)
|
||||
return adjusted
|
||||
```
|
||||
|
||||
### 4. Cross-Market Signal Patterns
|
||||
|
||||
1. **Momentum spillover**: BTC 7-day momentum as overlay for A-share tech sectors
|
||||
2. **Risk-on/Risk-off**: USD/CNH rate + VIX proxy to reduce equity exposure
|
||||
3. **Hedging**: Long A-shares + short crypto delta as tail hedge
|
||||
4. **Correlation regime**: When rolling correlation > 0.6, reduce to single-market exposure; when < 0.2, maximize diversification
|
||||
|
||||
### 5. What the Engine Handles (Don't Worry About)
|
||||
|
||||
- **Trading calendar alignment**: signals are shifted on each symbol's own calendar, then ffill'd to unified dates
|
||||
- **Market rules**: T+1 for A-shares, funding fees for crypto, swap for forex — all per-symbol
|
||||
- **Capital allocation**: shared pool, strategy just sets target weights via signals
|
||||
- **Commission/slippage**: dispatched to correct sub-engine per symbol
|
||||
|
||||
## config.json for Cross-Market
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "auto",
|
||||
"codes": ["000001.SZ", "BTC-USDT"],
|
||||
"start_date": "2024-01-01",
|
||||
"end_date": "2025-03-31",
|
||||
"interval": "1D",
|
||||
"initial_cash": 1000000,
|
||||
"engine": "daily"
|
||||
}
|
||||
```
|
||||
|
||||
- `source` **must** be `"auto"` for cross-market (routes each symbol to its loader)
|
||||
- `extra_fields` should be `null` (not all markets support fundamentals)
|
||||
- `leverage` defaults to 1.0 (CompositeEngine inherits from config)
|
||||
|
||||
## Market Detection Heuristics
|
||||
|
||||
| Pattern | Market |
|
||||
|---------|--------|
|
||||
| `000001.SZ`, `600519.SH` | A-share |
|
||||
| `AAPL.US` | US equity |
|
||||
| `700.HK` | HK equity |
|
||||
| `BTC-USDT` | Crypto |
|
||||
| `IF2406.CFFEX` | China futures |
|
||||
| `ESZ4` | Global futures |
|
||||
| `EUR/USD` | Forex |
|
||||
|
||||
## Supporting Files
|
||||
|
||||
- [example_signal_engine.py](example_signal_engine.py) — complete cross-market strategy example
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Cross-market strategy example: vol-adjusted dual-MA with per-market parameters.
|
||||
|
||||
Supports any combination of A-shares, crypto, US/HK equity, forex, and futures.
|
||||
The CompositeEngine handles calendar alignment, market rules, and shared capital.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
# Per-market indicator parameters
|
||||
MARKET_PARAMS = {
|
||||
"a_share": {"ma_fast": 5, "ma_slow": 20, "vol_lookback": 20},
|
||||
"crypto": {"ma_fast": 7, "ma_slow": 25, "vol_lookback": 14},
|
||||
"us_equity": {"ma_fast": 10, "ma_slow": 50, "vol_lookback": 20},
|
||||
"hk_equity": {"ma_fast": 10, "ma_slow": 50, "vol_lookback": 20},
|
||||
"forex": {"ma_fast": 10, "ma_slow": 30, "vol_lookback": 20},
|
||||
"futures": {"ma_fast": 5, "ma_slow": 20, "vol_lookback": 20},
|
||||
}
|
||||
|
||||
_MARKET_PATTERNS = [
|
||||
(re.compile(r"^\d{6}\.(SZ|SH|BJ)$", re.I), "a_share"),
|
||||
(re.compile(r"^[A-Z]+\.US$", re.I), "us_equity"),
|
||||
(re.compile(r"^\d{3,5}\.HK$", re.I), "hk_equity"),
|
||||
(re.compile(r"^[A-Z]+-USDT$", re.I), "crypto"),
|
||||
(re.compile(r"^[A-Z]+/USDT$", re.I), "crypto"),
|
||||
(re.compile(r"^[A-Z]{3}/[A-Z]{3}$"), "forex"),
|
||||
(re.compile(r"^[A-Z]{6}\.FX$"), "forex"),
|
||||
]
|
||||
|
||||
|
||||
class SignalEngine:
|
||||
def generate(self, data_map: dict) -> dict:
|
||||
# Step 1: raw signals per market
|
||||
raw_signals = {}
|
||||
for code, df in data_map.items():
|
||||
market = self._detect_market(code)
|
||||
params = MARKET_PARAMS.get(market, MARKET_PARAMS["a_share"])
|
||||
raw_signals[code] = self._market_signal(df, params)
|
||||
|
||||
# Step 2: volatility-adjusted weights
|
||||
return self._vol_adjust(raw_signals, data_map)
|
||||
|
||||
def _detect_market(self, code: str) -> str:
|
||||
for pattern, market in _MARKET_PATTERNS:
|
||||
if pattern.match(code):
|
||||
return market
|
||||
return "a_share"
|
||||
|
||||
def _market_signal(self, df: pd.DataFrame, params: dict) -> pd.Series:
|
||||
close = df["close"]
|
||||
ma_fast = close.rolling(params["ma_fast"]).mean()
|
||||
ma_slow = close.rolling(params["ma_slow"]).mean()
|
||||
|
||||
sig = pd.Series(0.0, index=df.index)
|
||||
sig[ma_fast > ma_slow] = 1.0
|
||||
sig[ma_fast < ma_slow] = -1.0
|
||||
return sig
|
||||
|
||||
def _vol_adjust(self, signals: dict, data_map: dict) -> dict:
|
||||
vols = {}
|
||||
for code, df in data_map.items():
|
||||
ret = df["close"].pct_change().dropna()
|
||||
vols[code] = (
|
||||
ret.rolling(20).std().iloc[-1]
|
||||
if len(ret) > 20
|
||||
else ret.std()
|
||||
)
|
||||
|
||||
inv_vols = {c: 1.0 / (v + 1e-10) for c, v in vols.items()}
|
||||
total_inv = sum(inv_vols.values())
|
||||
|
||||
adjusted = {}
|
||||
n = len(signals)
|
||||
for code, sig in signals.items():
|
||||
weight = inv_vols[code] / total_inv * n
|
||||
adjusted[code] = (sig * weight).clip(-1.0, 1.0)
|
||||
return adjusted
|
||||
@@ -0,0 +1,196 @@
|
||||
---
|
||||
name: dividend-analysis
|
||||
description: Dividend stock analysis for income, dividend-growth, and shareholder-return strategies, including yield quality, payout sustainability, ex-dividend mechanics, and yield-trap checks.
|
||||
category: analysis
|
||||
---
|
||||
|
||||
# Dividend Analysis
|
||||
|
||||
## Purpose
|
||||
|
||||
Use this skill when the user asks about dividend stocks, income portfolios, dividend growth, high-yield screening, payout safety, ex-dividend dates, or whether a dividend is sustainable. The goal is to separate durable shareholder returns from yield traps.
|
||||
|
||||
Dividend analysis should never stop at headline yield. A good answer explains how the dividend is funded, how stable the underlying business is, whether management has room to keep paying, and how valuation changes the expected total return.
|
||||
|
||||
## Core Questions
|
||||
|
||||
1. What is the current cash yield, and is it normal for this company or sector?
|
||||
2. Is the payout covered by earnings, operating cash flow, and free cash flow?
|
||||
3. Is the balance sheet strong enough to absorb a down cycle?
|
||||
4. Has management grown, held, cut, or suspended the dividend across cycles?
|
||||
5. Does the valuation still leave room for total return after taxes and reinvestment assumptions?
|
||||
|
||||
## Key Metrics
|
||||
|
||||
| Metric | Formula | Healthy Signal | Warning Signal |
|
||||
|--------|---------|----------------|----------------|
|
||||
| Dividend yield | annual DPS / current price | Above peer median with stable coverage | Extremely high vs history or peers |
|
||||
| Earnings payout ratio | dividends / net income, or DPS / EPS | 30-70% for mature non-financials | Above 90%, negative earnings |
|
||||
| Free-cash-flow payout | dividends / FCF | Below 70% through a cycle | Dividend exceeds FCF for 2+ years |
|
||||
| CFO coverage | operating cash flow / dividends paid | Above 1.5x | Below 1.0x |
|
||||
| Dividend CAGR | DPS growth over 3/5/10 years | Positive and below EPS/FCF growth | Growth funded by leverage |
|
||||
| Net debt / EBITDA | net debt / EBITDA | Sector-appropriate leverage | Leverage rising while payout rises |
|
||||
| Buyback plus dividend yield | (dividends + net buybacks) / market cap | Balanced capital return | Buybacks funded by debt at high valuation |
|
||||
|
||||
For REITs, utilities, banks, MLPs, and insurers, adapt the payout metric to the sector. For example, use AFFO payout for REITs, distributable cash flow for MLPs, and regulatory capital ratios for banks and insurers.
|
||||
|
||||
## Analysis Workflow
|
||||
|
||||
### Step 1: Normalize the Dividend
|
||||
|
||||
- Use forward indicated dividend for recurring payments.
|
||||
- Separate ordinary dividends from special dividends.
|
||||
- Check whether the latest declared dividend is annual, semiannual, quarterly, monthly, or irregular.
|
||||
- For ADRs and cross-listed shares, account for depositary ratios, withholding tax, and FX conversion.
|
||||
|
||||
```python
|
||||
annual_dividend = regular_dividend_per_period * payments_per_year
|
||||
dividend_yield = annual_dividend / current_price
|
||||
```
|
||||
|
||||
### Step 2: Check Coverage
|
||||
|
||||
Start with earnings coverage, then confirm with cash coverage.
|
||||
|
||||
```python
|
||||
earnings_payout = dividends_paid / net_income
|
||||
fcf_payout = dividends_paid / free_cash_flow
|
||||
cfo_coverage = operating_cash_flow / dividends_paid
|
||||
```
|
||||
|
||||
Interpretation:
|
||||
|
||||
- Good: net income, CFO, and FCF all cover dividends across multiple years.
|
||||
- Watch: earnings cover dividends but FCF does not, especially during capex-heavy periods.
|
||||
- Avoid: dividends are paid while both earnings and FCF are negative, unless there is a clear one-time reason and a strong balance sheet.
|
||||
|
||||
### Step 3: Diagnose Dividend Growth Quality
|
||||
|
||||
Dividend growth is high quality when it follows business growth.
|
||||
|
||||
```python
|
||||
dividend_cagr = (dps_end / dps_start) ** (1 / years) - 1
|
||||
eps_cagr = (eps_end / eps_start) ** (1 / years) - 1
|
||||
fcf_cagr = (fcf_end / fcf_start) ** (1 / years) - 1
|
||||
```
|
||||
|
||||
Quality rules:
|
||||
|
||||
- Dividend CAGR below EPS and FCF CAGR usually leaves room for future increases.
|
||||
- Dividend CAGR above EPS/FCF CAGR means payout ratio is expanding.
|
||||
- Flat dividend with rising FCF may imply hidden capacity or conservative management.
|
||||
- Repeated small increases can still be fragile if leverage is rising.
|
||||
|
||||
### Step 4: Check Balance Sheet Flexibility
|
||||
|
||||
Look for the ability to maintain dividends during stress.
|
||||
|
||||
| Item | Why It Matters |
|
||||
|------|----------------|
|
||||
| Cash and short-term investments | Near-term cushion |
|
||||
| Net debt / EBITDA | Debt burden against operating earnings |
|
||||
| Interest coverage | Ability to service debt before shareholder returns |
|
||||
| Debt maturity wall | Refinancing risk in high-rate environments |
|
||||
| Credit rating or covenant language | External constraints on payout policy |
|
||||
|
||||
### Step 5: Separate Dividend Yield from Total Return
|
||||
|
||||
Dividend stocks can underperform if the yield comes from a falling price. Always connect income to valuation and growth.
|
||||
|
||||
```python
|
||||
expected_total_return = dividend_yield + expected_eps_growth + valuation_rerating
|
||||
```
|
||||
|
||||
Do not present this as a guarantee. Use it as a scenario framework.
|
||||
|
||||
## Yield-Trap Checklist
|
||||
|
||||
Flag a potential yield trap when several of these are true:
|
||||
|
||||
- Dividend yield is more than 2x the company's 5-year median or sector median.
|
||||
- Payout ratio is above 90%, or FCF payout is above 100%.
|
||||
- Revenue, EPS, or FCF has declined for 2+ years.
|
||||
- Net debt / EBITDA is rising while interest coverage is falling.
|
||||
- Management has recently issued equity or debt while maintaining dividends.
|
||||
- The stock price fell before the yield became attractive.
|
||||
- Dividend history includes cuts, suspensions, or frequent special dividends labeled as ordinary income.
|
||||
- Sector faces structural pressure, regulation risk, or commodity down-cycle exposure.
|
||||
|
||||
## Strategy Types
|
||||
|
||||
### Dividend Growth
|
||||
|
||||
Prioritize moderate yield, strong dividend CAGR, low payout ratio, and durable business quality.
|
||||
|
||||
Good for users seeking compounding and lower cut risk.
|
||||
|
||||
### High-Yield Quality
|
||||
|
||||
Prioritize yield, but require cash coverage, balance sheet resilience, and sector-aware payout norms.
|
||||
|
||||
Good for users seeking current income, but the answer must discuss cut risk.
|
||||
|
||||
### Shareholder Yield
|
||||
|
||||
Combine dividends, net buybacks, and debt reduction.
|
||||
|
||||
Useful when companies return capital mostly through buybacks rather than cash dividends.
|
||||
|
||||
```python
|
||||
shareholder_yield = dividend_yield + net_buyback_yield + debt_paydown_yield
|
||||
```
|
||||
|
||||
### Dividend Capture
|
||||
|
||||
Buying before the ex-dividend date only to collect the dividend is not a free-money strategy. Prices usually adjust around the ex-dividend date, and taxes, spreads, and slippage can erase the gross dividend.
|
||||
|
||||
Use this only as an event-risk analysis, not as a default recommendation.
|
||||
|
||||
## Data Sources
|
||||
|
||||
| Market | Useful Fields |
|
||||
|--------|---------------|
|
||||
| A-shares | Tushare `dividend`, `daily_basic.dv_ttm`, `fina_indicator`, `cashflow` |
|
||||
| US/HK | yfinance `Ticker.dividends`, `Ticker.info`, financial statements, cash flow |
|
||||
| ETFs | distribution yield, SEC yield, holdings yield, expense ratio, distribution history |
|
||||
| REITs | FFO, AFFO, occupancy, debt maturities, AFFO payout |
|
||||
|
||||
When live data is unavailable, state the limitation and provide the analysis template instead of inventing dividend figures.
|
||||
|
||||
## Output Template
|
||||
|
||||
```markdown
|
||||
### Dividend Analysis: [ticker/company]
|
||||
|
||||
**Verdict:** [sustainable / watchlist / yield-trap risk]
|
||||
|
||||
| Metric | Value | Interpretation |
|
||||
|--------|-------|----------------|
|
||||
| Dividend yield | ... | ... |
|
||||
| Earnings payout | ... | ... |
|
||||
| FCF payout | ... | ... |
|
||||
| Dividend growth | ... | ... |
|
||||
| Balance sheet | ... | ... |
|
||||
|
||||
**What supports the dividend**
|
||||
- ...
|
||||
|
||||
**What could break the dividend**
|
||||
- ...
|
||||
|
||||
**Scenario view**
|
||||
- Base: ...
|
||||
- Downside: ...
|
||||
- Upside: ...
|
||||
|
||||
**Research note:** This is investment research, not live trading advice.
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- Treating high yield as cheap valuation without checking why the price fell.
|
||||
- Mixing special dividends with regular dividends.
|
||||
- Comparing REIT payout ratios to ordinary industrial companies.
|
||||
- Ignoring withholding tax, ADR ratios, currency conversion, or ETF expense drag.
|
||||
- Forgetting that ex-dividend capture is usually offset by price adjustment and transaction costs.
|
||||
- Recommending a dividend stock without discussing total return and dividend-cut risk.
|
||||
@@ -1,75 +1,114 @@
|
||||
---
|
||||
name: doc-reader
|
||||
description: Read PDF documents (papers, annual reports, research reports), automatically extracting text pages and applying OCR to image/scanned pages. Use the `read_document` tool.
|
||||
description: Read any common document/data file — PDF, Word (.docx), Excel (.xlsx/.xls), PowerPoint (.pptx), images (OCR), CSV/TSV, plain text, JSON/YAML/TOML, HTML/XML, and most source-code files. Use the `read_document` tool.
|
||||
category: tool
|
||||
---
|
||||
# PDF Document Reading
|
||||
# Universal Document Reader
|
||||
|
||||
## Purpose
|
||||
|
||||
Read the full text of PDF documents and automatically handle two page types:
|
||||
- **Text pages** (most papers and digital reports) → extracted directly in milliseconds
|
||||
- **Image / scanned pages** (annual report charts, scanned research reports) → OCR recognition with Chinese and English support
|
||||
Return extracted text from any supported file in a single unified JSON
|
||||
envelope. The tool dispatches by file extension — you always call the same
|
||||
tool regardless of format.
|
||||
|
||||
Applicable to PDF documents such as papers, annual reports, research reports, announcements, and contracts.
|
||||
### Supported formats
|
||||
|
||||
| Category | Extensions | Notes |
|
||||
|---|---|---|
|
||||
| PDF | `.pdf` | Text pages extracted in ms; scanned/image pages fall back to OCR |
|
||||
| Word | `.docx` | Paragraphs + table cells |
|
||||
| Excel | `.xlsx`, `.xls` | All sheets, first 100 rows per sheet as preview |
|
||||
| PowerPoint | `.pptx` | Slide text content |
|
||||
| Images | `.png/.jpg/.jpeg/.gif/.bmp/.webp/.tiff` | OCR only |
|
||||
| CSV / TSV | `.csv`, `.tsv` | Raw text with encoding fallback |
|
||||
| Plain text | `.txt/.md/.log/.rst` | Encoding fallback |
|
||||
| Config | `.json/.yaml/.yml/.toml/.ini/.cfg/.env` | Raw text |
|
||||
| Markup | `.html/.htm/.xml` | Raw text (no HTML stripping) |
|
||||
| Source code | `.py/.js/.ts/.tsx/.go/.rs/.java/.cpp/.c/.sql/.sh/...` | Raw text |
|
||||
| Unknown extension | anything else | Best-effort read as UTF-8/GBK text |
|
||||
|
||||
**Blocked** (rejected at `/upload`): executables (`.exe/.dll/.so/...`) and
|
||||
archives (`.zip/.tar/...`). Ask the user to unpack archives locally first.
|
||||
|
||||
## Usage
|
||||
|
||||
**Call the `read_document` tool directly (do not use bash to write a Python script):**
|
||||
**Always call the tool directly — do not run Python from bash.**
|
||||
|
||||
```
|
||||
read_document(file_path="uploads/paper.pdf")
|
||||
read_document(file_path="uploads/annual_report.pdf", pages="1-10")
|
||||
read_document(file_path="uploads/research.pdf", pages="1,3,15-20")
|
||||
read_document(file_path="uploads/contract.docx")
|
||||
read_document(file_path="uploads/sales.xlsx")
|
||||
read_document(file_path="uploads/deck.pptx")
|
||||
read_document(file_path="uploads/chart.png") # image → OCR
|
||||
read_document(file_path="uploads/config.yaml")
|
||||
read_document(file_path="uploads/notes.md")
|
||||
```
|
||||
|
||||
**Forbidden**: do not run a Python script from bash to read PDFs. Call the tool directly.
|
||||
The `pages` parameter only applies to PDF; other formats ignore it.
|
||||
|
||||
## Return Format
|
||||
## Return envelope
|
||||
|
||||
All formats share this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"file": "paper.pdf",
|
||||
"total_pages": 45,
|
||||
"pages_read": 45,
|
||||
"ocr_pages": 3,
|
||||
"format": "pdf",
|
||||
"char_count": 52000,
|
||||
"truncated": true,
|
||||
"text": "--- Page 1 ---\n...\n--- Page 5 [OCR] ---\n..."
|
||||
"text": "..."
|
||||
}
|
||||
```
|
||||
|
||||
- `ocr_pages`: number of pages recognized via OCR (image / scanned pages)
|
||||
- `truncated`: content is truncated when it exceeds 15000 characters
|
||||
- `[OCR]` indicates that the page content was obtained via image recognition
|
||||
Format-specific extra fields:
|
||||
|
||||
## Typical Workflows
|
||||
| Format | Extra keys |
|
||||
|---|---|
|
||||
| `pdf` | `total_pages`, `pages_read`, `ocr_pages` |
|
||||
| `docx` | `paragraphs`, `tables` |
|
||||
| `excel` | `sheets` (array of `{name, rows, cols}`) |
|
||||
| `pptx` | `slides` |
|
||||
| `text` | `encoding`, `size` |
|
||||
|
||||
### Paper Summary
|
||||
Content longer than 15000 chars is truncated; for PDFs use the `pages`
|
||||
parameter to read slices.
|
||||
|
||||
## Workflows
|
||||
|
||||
### Paper / report summary
|
||||
```
|
||||
1. read_document(file_path="paper.pdf") → get the full text
|
||||
2. Analyze the text and extract the abstract, methodology, and conclusion
|
||||
3. Output the summary
|
||||
1. read_document(file_path="paper.pdf") → full text
|
||||
2. Extract abstract, methodology, conclusion → summarize
|
||||
```
|
||||
|
||||
### Annual Report Analysis
|
||||
### Contract review
|
||||
```
|
||||
1. read_document(file_path="annual_report.pdf", pages="1-5") → read the summary first
|
||||
2. Determine the key sections from the summary
|
||||
3. read_document(file_path="...", pages="15-25") → read the financial-data section
|
||||
4. Extract key metrics
|
||||
1. read_document(file_path="contract.docx") → paragraphs + tables
|
||||
2. Flag key clauses (termination, liability, payment, IP)
|
||||
```
|
||||
|
||||
### Research Report Review
|
||||
### Spreadsheet quick-look
|
||||
```
|
||||
1. read_document(file_path="research.pdf") → full text
|
||||
2. Extract the core thesis, target price, and risk factors
|
||||
1. read_document(file_path="sales.xlsx") → all sheet previews
|
||||
2. If user wants trade journal analysis specifically, pivot to
|
||||
`analyze_trade_journal` tool instead (see trade-journal skill).
|
||||
```
|
||||
|
||||
### Chart / screenshot / scanned PDF
|
||||
```
|
||||
1. read_document(file_path="scan.png") → OCR text
|
||||
2. If OCR returns empty, tell the user; don't fabricate.
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Content longer than 15000 characters will be truncated. For long documents, read them in chunks with the `pages` parameter
|
||||
- OCR pages are slower (about 1-3 seconds per page), while pure text pages are processed in milliseconds
|
||||
- OCR for charts and tables inside images may be imperfect, so complex tables should be checked manually
|
||||
- Only PDF format is supported
|
||||
- **Encoding fallback** order for text: utf-8 → utf-8-sig → gbk → gb2312 → big5 → latin-1.
|
||||
- **OCR** uses RapidOCR; if the package is missing, image/scanned files
|
||||
return empty `text` with a `note` field — tell the user to install
|
||||
`rapidocr-onnxruntime`.
|
||||
- **Excel previews** are limited to 100 rows per sheet to stay in budget.
|
||||
If the user needs full data (e.g. trade journals), call
|
||||
`analyze_trade_journal` instead.
|
||||
- **Source-code files** are returned raw; do not re-format or re-indent.
|
||||
@@ -1,34 +1,73 @@
|
||||
---
|
||||
name: pine-script
|
||||
description: Convert Python backtest strategies to TradingView Pine Script v6, or generate Pine Script from natural language descriptions.
|
||||
description: Export backtest strategies to indicator/strategy code for major trading platforms — TradingView, 通达信, 同花顺, 东方财富, MT5.
|
||||
category: tool
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This skill enables two workflows:
|
||||
1. **Export**: convert an existing `signal_engine.py` (from a completed backtest run) into a TradingView Pine Script v6 strategy
|
||||
2. **Generate**: write Pine Script v6 directly from a natural-language strategy description
|
||||
This skill exports a Vibe-Trading strategy to **all major trading platforms** in one go.
|
||||
Output file: `artifacts/strategy.pine` (inside the run directory).
|
||||
|
||||
Output file: `artifacts/strategy.pine` (inside the run directory, or a new run directory).
|
||||
Supported platforms (always generate ALL):
|
||||
|
||||
| Group | Platforms | Language |
|
||||
|-------|-----------|----------|
|
||||
| International Charts | TradingView | Pine Script v6 |
|
||||
| China Equities | 通达信 / 同花顺 / 东方财富 | TDX Formula |
|
||||
| Forex / CFD | MetaTrader 5 | MQL5 |
|
||||
|
||||
## Workflow: Export from Backtest
|
||||
|
||||
1. `load_skill("pine-script")` — read this conversion guide
|
||||
1. `load_skill("pine-script")` — read this guide
|
||||
2. `read_file("config.json")` — understand instruments, dates, parameters
|
||||
3. `read_file("code/signal_engine.py")` — understand the Python strategy logic
|
||||
4. **Translate** the strategy to Pine Script v6 using the mapping table below
|
||||
5. `write_file("artifacts/strategy.pine")` — save the output
|
||||
6. Return the Pine Script in a code block with usage instructions
|
||||
4. **Translate** the strategy to ALL platforms using the references below
|
||||
5. `write_file("artifacts/strategy.pine")` — save the combined output
|
||||
6. Return the code in a code block with usage instructions per platform
|
||||
|
||||
## Workflow: Generate from Description
|
||||
|
||||
1. `load_skill("pine-script")` — read this guide
|
||||
2. Write Pine Script v6 directly based on the user's description
|
||||
3. `write_file("artifacts/strategy.pine")` — save the output
|
||||
2. Write indicator/strategy code for ALL platforms based on the user's description
|
||||
3. `write_file("artifacts/strategy.pine")` — save the combined output
|
||||
4. Return the code with usage instructions
|
||||
|
||||
## Pine Script v6 Strategy Template
|
||||
## Output Format
|
||||
|
||||
The output file uses this structure (all platforms in one file):
|
||||
|
||||
```
|
||||
================================================================================
|
||||
TRADINGVIEW — Pine Script v6
|
||||
Paste into: Pine Editor → New blank indicator → Add to Chart
|
||||
================================================================================
|
||||
|
||||
[Pine Script code here]
|
||||
|
||||
================================================================================
|
||||
通达信 / 同花顺 / 东方财富 (TDX Formula)
|
||||
Paste into: 功能 → 公式管理器 → 新建指标公式
|
||||
================================================================================
|
||||
|
||||
[TDX formula code here]
|
||||
|
||||
================================================================================
|
||||
MT5 — MQL5
|
||||
Save as: .mq5 file → MetaEditor → Compile → Navigator → Attach to Chart
|
||||
================================================================================
|
||||
|
||||
[MQL5 code here]
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Platform Reference
|
||||
|
||||
## 1. TradingView — Pine Script v6
|
||||
|
||||
### Template
|
||||
|
||||
```pinescript
|
||||
// This strategy was generated by Vibe-Trading
|
||||
@@ -78,9 +117,7 @@ alertcondition(longCondition, title="Long Signal", message="Long entry signal tr
|
||||
alertcondition(shortCondition, title="Short Signal", message="Short entry signal triggered")
|
||||
```
|
||||
|
||||
## Python → Pine Script Mapping Table
|
||||
|
||||
### Technical Indicators
|
||||
### Python → Pine Script Mapping
|
||||
|
||||
| Python (pandas/numpy) | Pine Script v6 |
|
||||
|------------------------|----------------|
|
||||
@@ -94,13 +131,13 @@ alertcondition(shortCondition, title="Short Signal", message="Short entry signal
|
||||
| `df['close'].pct_change()` | `(close - close[1]) / close[1]` |
|
||||
| `df['volume'].rolling(n).mean()` | `ta.sma(volume, n)` |
|
||||
| `df['close'] > df['close'].shift(1)` | `close > close[1]` |
|
||||
| Bollinger Bands (manual) | `[mid, upper, lower] = ta.bb(close, length, mult)` |
|
||||
| ATR (manual or ta-lib) | `ta.atr(length)` |
|
||||
| ADX | `ta.adx(high, low, close, length)` (returns adx value) |
|
||||
| Bollinger Bands | `[mid, upper, lower] = ta.bb(close, length, mult)` |
|
||||
| ATR | `ta.atr(length)` |
|
||||
| ADX | `ta.adx(high, low, close, length)` |
|
||||
| Stochastic | `ta.stoch(close, high, low, length, smoothK, smoothD)` |
|
||||
| CCI | `ta.cci(close, length)` (Pine v6: `ta.cci(close, length)`) |
|
||||
| CCI | `ta.cci(close, length)` |
|
||||
| Williams %R | `ta.wpr(length)` |
|
||||
| MFI | `ta.mfi(hlc3, volume, length)` — Pine v6: `ta.mfi(close, length)` |
|
||||
| MFI | `ta.mfi(close, length)` |
|
||||
| OBV | `ta.obv` |
|
||||
| VWAP | `ta.vwap` |
|
||||
|
||||
@@ -138,94 +175,212 @@ alertcondition(shortCondition, title="Short Signal", message="Short entry signal
|
||||
| Stop-loss | `strategy.exit("Exit", stop=entryPrice * (1 - stopPct))` |
|
||||
| Take-profit | `strategy.exit("Exit", limit=entryPrice * (1 + tpPct))` |
|
||||
|
||||
## Pine Script v6 Syntax Rules
|
||||
|
||||
### Critical Rules (common errors)
|
||||
### Syntax Rules (Critical)
|
||||
|
||||
1. **Version declaration must be first line**: `//@version=6`
|
||||
2. **Ternary operators MUST stay on one line** or use intermediate variables:
|
||||
```pinescript
|
||||
// WRONG — causes "end of line without line continuation"
|
||||
text = condition ? "value1" :
|
||||
"value2"
|
||||
|
||||
// CORRECT
|
||||
text = condition ? "value1" : "value2"
|
||||
```
|
||||
2. **Ternary operators MUST stay on one line**: `text = condition ? "a" : "b"`
|
||||
3. **Line continuation**: continuation lines must be indented MORE than the starting line
|
||||
4. **No plot() in local scope** (if/for/function):
|
||||
```pinescript
|
||||
// WRONG
|
||||
if condition
|
||||
plot(value)
|
||||
4. **No plot() in local scope** (if/for/function) — use `plot(condition ? value : na)`
|
||||
5. **var**: persistent state across bars; regular assignment recalculates each bar
|
||||
6. **Avoid repainting**: use `barstate.isconfirmed`, `lookahead=barmerge.lookahead_off`
|
||||
7. **Limits**: max 500 bars lookback, 500 plot calls, 64 entry/exit per bar, 40 request.security()
|
||||
|
||||
// CORRECT
|
||||
plot(condition ? value : na)
|
||||
```
|
||||
5. **var vs regular variables**: use `var` for persistent state across bars, regular assignment recalculates each bar
|
||||
6. **varip**: persistent even on real-time bar updates (intrabar persistence)
|
||||
---
|
||||
|
||||
### Script Types
|
||||
## 2. 通达信 / 同花顺 / 东方财富 — TDX Formula
|
||||
|
||||
| Type | Declaration | Use Case |
|
||||
|------|-------------|----------|
|
||||
| `strategy` | `strategy("Name", ...)` | Backtestable with entry/exit orders |
|
||||
| `indicator` | `indicator("Name", ...)` | Visual indicators, no backtesting |
|
||||
| `library` | `library("Name")` | Reusable functions |
|
||||
These platforms share 95%+ identical formula syntax. Write ONE version that works on all three.
|
||||
|
||||
**Default output: `strategy`** (matches Vibe-Trading's backtest-first philosophy).
|
||||
|
||||
### Avoid Repainting
|
||||
|
||||
- Use `barstate.isconfirmed` for signals on confirmed bars only
|
||||
- Use `request.security()` with `lookahead=barmerge.lookahead_off` (default in v6)
|
||||
- Do NOT use `security()` on lower timeframes without understanding implications
|
||||
|
||||
### Platform Limits
|
||||
|
||||
- Max 500 bars lookback for `[]` operator
|
||||
- Max 500 plot calls
|
||||
- Max 64 `strategy.entry/exit` calls per bar
|
||||
- Max 40 `request.security()` calls
|
||||
|
||||
## TradingView Symbol Format
|
||||
|
||||
When generating Pine Script, map Vibe-Trading codes to TradingView symbols:
|
||||
|
||||
| Vibe-Trading Format | TradingView Format | Exchange |
|
||||
|---------------------|-------------------|----------|
|
||||
| `000001.SZ` | `SZSE:000001` | Shenzhen |
|
||||
| `600519.SH` | `SSE:600519` | Shanghai |
|
||||
| `AAPL.US` | `NASDAQ:AAPL` | US (auto-detect) |
|
||||
| `700.HK` | `HKEX:0700` | Hong Kong |
|
||||
| `BTC-USDT` | `OKX:BTCUSDT.P` or `BINANCE:BTCUSDT` | Crypto |
|
||||
|
||||
**Note**: Pine Script strategies are chart-independent — the user applies them to whichever symbol they want on TradingView. Include a comment noting the original instrument.
|
||||
|
||||
## Usage Instructions (include in response)
|
||||
|
||||
After generating the Pine Script, always include:
|
||||
### Template
|
||||
|
||||
```
|
||||
How to use in TradingView:
|
||||
1. Open TradingView → Pine Editor (bottom panel)
|
||||
2. Click "Open" → "New blank indicator"
|
||||
3. Delete all existing code
|
||||
4. Paste the Pine Script above
|
||||
5. Click "Add to Chart"
|
||||
6. The strategy will appear on your chart with entry/exit markers
|
||||
7. Open "Strategy Tester" tab to see backtest results
|
||||
{Vibe-Trading 策略导出}
|
||||
{策略名称: XXX}
|
||||
|
||||
{——————— 参数 ———————}
|
||||
N:=14;
|
||||
M:=6;
|
||||
|
||||
{——————— 指标计算 ———————}
|
||||
RSI_VAL:=RSI(CLOSE,N);
|
||||
MA_FAST:=MA(CLOSE,5);
|
||||
MA_SLOW:=MA(CLOSE,20);
|
||||
|
||||
{——————— 买卖信号 ———————}
|
||||
BUY:CROSS(MA_FAST,MA_SLOW) AND RSI_VAL<40,COLORRED;
|
||||
SELL:CROSS(MA_SLOW,MA_FAST) AND RSI_VAL>60,COLORGREEN;
|
||||
|
||||
DRAWTEXT(BUY,LOW,'B'),COLORYELLOW;
|
||||
DRAWTEXT(SELL,HIGH,'S'),COLORWHITE;
|
||||
```
|
||||
|
||||
### Python → TDX Mapping
|
||||
|
||||
| Python | TDX Formula |
|
||||
|--------|-------------|
|
||||
| `df['close'].rolling(n).mean()` | `MA(CLOSE,N)` |
|
||||
| `df['close'].ewm(span=n).mean()` | `EMA(CLOSE,N)` |
|
||||
| RSI | `RSI(CLOSE,N)` (returns 0-100) |
|
||||
| MACD | `MACD.DIF`, `MACD.DEA`, `MACD.MACD` or manual: `DIF:=EMA(CLOSE,12)-EMA(CLOSE,26); DEA:=EMA(DIF,9); MACD:=(DIF-DEA)*2;` |
|
||||
| Bollinger Bands | `BOLL(N,M)` → `BOLL.UPPER`, `BOLL.MID`, `BOLL.LOWER` or manual |
|
||||
| ATR | `ATR:=MA(MAX(MAX(HIGH-LOW,ABS(HIGH-REF(CLOSE,1))),ABS(LOW-REF(CLOSE,1))),N);` |
|
||||
| `df['close'].shift(n)` | `REF(CLOSE,N)` |
|
||||
| `df['high'].rolling(n).max()` | `HHV(HIGH,N)` |
|
||||
| `df['low'].rolling(n).min()` | `LLV(LOW,N)` |
|
||||
| crossover(fast, slow) | `CROSS(FAST,SLOW)` |
|
||||
| crossunder(fast, slow) | `CROSS(SLOW,FAST)` |
|
||||
| `df['volume']` | `VOL` |
|
||||
| `abs(x)` | `ABS(X)` |
|
||||
| `max(a,b)` | `MAX(A,B)` |
|
||||
| `min(a,b)` | `MIN(A,B)` |
|
||||
| conditional | `IF(COND,A,B)` |
|
||||
| `df['close'].pct_change()` | `(CLOSE-REF(CLOSE,1))/REF(CLOSE,1)` |
|
||||
| count true in N bars | `COUNT(COND,N)` |
|
||||
| sum over N bars | `SUM(X,N)` |
|
||||
| std over N bars | `STD(CLOSE,N)` |
|
||||
| slope / linear regression | `SLOPE(CLOSE,N)` |
|
||||
|
||||
### Syntax Rules
|
||||
|
||||
1. **Assignment**: `:=` for intermediate variables, `:` for output (plotted) lines
|
||||
2. **Comments**: `{comment}` — curly braces, NOT `//`
|
||||
3. **No semicolons optional**: each statement ends with `;`
|
||||
4. **Colors**: `COLORRED`, `COLORGREEN`, `COLORYELLOW`, `COLORWHITE`, `COLORBLUE`, `COLORCYAN`, `COLORMAGENTA`
|
||||
5. **Line styles**: `LINETHICK2`, `POINTDOT`, `STICK`, `VOLSTICK`
|
||||
6. **Draw text**: `DRAWTEXT(COND, PRICE, 'TEXT'), COLOR;`
|
||||
7. **Draw icon**: `DRAWICON(COND, PRICE, ICON_ID);`
|
||||
8. **All function/variable names UPPERCASE**
|
||||
9. **No loops / no arrays** — everything is vectorized bar-by-bar
|
||||
10. **Max formula length**: ~10,000 characters per formula
|
||||
|
||||
### Platform Differences
|
||||
|
||||
| Feature | 通达信 | 同花顺 | 东方财富 |
|
||||
|---------|--------|--------|----------|
|
||||
| MACD built-in | `MACD(12,26,9)` | `MACD(12,26,9)` | same |
|
||||
| Stochastic | `KDJ(N,M1,M2)` | same | same |
|
||||
| Custom color | `COLOR+RRGGBB` | `COLOR+RRGGBB` | limited |
|
||||
| Strategy backtest | 条件选股 only | 条件选股 only | 条件选股 only |
|
||||
|
||||
For maximum compatibility, avoid platform-specific extensions. Stick to core functions.
|
||||
|
||||
---
|
||||
|
||||
## 3. MetaTrader 5 — MQL5
|
||||
|
||||
### Template (Custom Indicator)
|
||||
|
||||
```mql5
|
||||
//+------------------------------------------------------------------+
|
||||
//| Generated by Vibe-Trading |
|
||||
//+------------------------------------------------------------------+
|
||||
#property copyright "Vibe-Trading"
|
||||
#property indicator_chart_window // or indicator_separate_window
|
||||
#property indicator_buffers 2
|
||||
#property indicator_plots 2
|
||||
#property indicator_color1 clrDodgerBlue
|
||||
#property indicator_color2 clrRed
|
||||
|
||||
input int InpPeriod = 14; // Period
|
||||
|
||||
double BuyBuffer[];
|
||||
double SellBuffer[];
|
||||
|
||||
int OnInit()
|
||||
{
|
||||
SetIndexBuffer(0, BuyBuffer, INDICATOR_DATA);
|
||||
SetIndexBuffer(1, SellBuffer, INDICATOR_DATA);
|
||||
PlotIndexSetInteger(0, PLOT_ARROW, 233); // up arrow
|
||||
PlotIndexSetInteger(1, PLOT_ARROW, 234); // down arrow
|
||||
PlotIndexSetInteger(0, PLOT_DRAW_TYPE, DRAW_ARROW);
|
||||
PlotIndexSetInteger(1, PLOT_DRAW_TYPE, DRAW_ARROW);
|
||||
return(INIT_SUCCEEDED);
|
||||
}
|
||||
|
||||
int OnCalculate(const int rates_total,
|
||||
const int prev_calculated,
|
||||
const datetime &time[],
|
||||
const double &open[],
|
||||
const double &high[],
|
||||
const double &low[],
|
||||
const double &close[],
|
||||
const long &tick_volume[],
|
||||
const long &volume[],
|
||||
const int &spread[])
|
||||
{
|
||||
int start = MathMax(prev_calculated - 1, InpPeriod);
|
||||
for(int i = start; i < rates_total; i++)
|
||||
{
|
||||
BuyBuffer[i] = EMPTY_VALUE;
|
||||
SellBuffer[i] = EMPTY_VALUE;
|
||||
|
||||
// === YOUR LOGIC HERE ===
|
||||
// Example: if(buyCondition) BuyBuffer[i] = low[i];
|
||||
// if(sellCondition) SellBuffer[i] = high[i];
|
||||
}
|
||||
return(rates_total);
|
||||
}
|
||||
```
|
||||
|
||||
### Python → MQL5 Mapping
|
||||
|
||||
| Python | MQL5 |
|
||||
|--------|------|
|
||||
| `df['close'].rolling(n).mean()` | `iMA(_Symbol, PERIOD_CURRENT, n, 0, MODE_SMA, PRICE_CLOSE)` or manual loop |
|
||||
| EMA | `iMA(..., MODE_EMA, ...)` |
|
||||
| RSI | `iRSI(_Symbol, PERIOD_CURRENT, n, PRICE_CLOSE)` |
|
||||
| MACD | `iMACD(_Symbol, PERIOD_CURRENT, 12, 26, 9, PRICE_CLOSE)` |
|
||||
| Bollinger | `iBands(_Symbol, PERIOD_CURRENT, n, 0, mult, PRICE_CLOSE)` |
|
||||
| ATR | `iATR(_Symbol, PERIOD_CURRENT, n)` |
|
||||
| Stochastic | `iStochastic(_Symbol, PERIOD_CURRENT, K, D, slowing, MODE_SMA, STO_LOWHIGH)` |
|
||||
| `df['close'].shift(n)` | `close[i-n]` (in OnCalculate loop) |
|
||||
| crossover | `buf[i] > ref[i] && buf[i-1] <= ref[i-1]` |
|
||||
|
||||
### Syntax Rules
|
||||
|
||||
1. **Indicator handles**: call `iMA()` etc. in `OnInit()`, use `CopyBuffer()` to get values
|
||||
2. **Buffer direction**: MQL5 buffers are indexed 0=oldest by default; use `ArraySetAsSeries()` to reverse
|
||||
3. **EMPTY_VALUE**: use for "no signal" on arrow plots
|
||||
4. **Indicator vs EA**: generate indicator (`.mq5`), not Expert Advisor, to match "indicator export" purpose
|
||||
5. **Handle-based API**: MQL5 uses handles — create in `OnInit`, read in `OnCalculate`
|
||||
|
||||
---
|
||||
|
||||
## Symbol Format Mapping
|
||||
|
||||
When generating code, map Vibe-Trading instrument codes appropriately:
|
||||
|
||||
| Vibe-Trading | TradingView | 通达信/同花顺 | MT5 |
|
||||
|-------------|-------------|---------------|-----|
|
||||
| `000001.SZ` | `SZSE:000001` | `000001` | N/A |
|
||||
| `600519.SH` | `SSE:600519` | `600519` | N/A |
|
||||
| `AAPL.US` | `NASDAQ:AAPL` | N/A | `AAPL` |
|
||||
| `BTC-USDT` | `BINANCE:BTCUSDT` | N/A | `BTCUSD` |
|
||||
|
||||
**Note**: Most indicator code is instrument-agnostic — the user applies it to whatever chart they're viewing. Include a comment noting the original instrument for reference only.
|
||||
|
||||
## Limitations & Transparency
|
||||
|
||||
When a Python strategy uses features that can't be directly translated, clearly note it:
|
||||
|
||||
| Python Feature | Platform Limitation |
|
||||
|---------------|-------------------|
|
||||
| ML models (sklearn, etc.) | None — flag as "manual implementation required" |
|
||||
| Custom pandas operations | TDX — limited to built-in functions |
|
||||
| Multi-timeframe logic | TDX — no native MTF; Pine/MQL5 — supported |
|
||||
| Dynamic position sizing | TDX — indicator only, no position control |
|
||||
| External data (API calls) | All — indicators run offline on chart data only |
|
||||
|
||||
Always add a comment block at the top listing any features that could not be translated.
|
||||
|
||||
## Quality Checklist
|
||||
|
||||
Before outputting the Pine Script:
|
||||
- [ ] `//@version=6` is the first line
|
||||
- [ ] `strategy()` declaration includes commission and initial_capital matching config.json
|
||||
- [ ] All indicator calculations use Pine built-in `ta.*` functions (not manual reimplementation)
|
||||
- [ ] Entry/exit conditions match the Python signal logic semantically
|
||||
- [ ] No `plot()` inside local scopes (if/for/function)
|
||||
- [ ] Ternary operators are on single lines
|
||||
- [ ] Inputs use `input.int()` / `input.float()` so users can tune parameters in TradingView UI
|
||||
- [ ] Alert conditions are included for key signals
|
||||
Before outputting:
|
||||
- [ ] ALL 3 platform sections are included (Pine Script, TDX, MQL5)
|
||||
- [ ] Each platform section has proper header with usage instructions
|
||||
- [ ] Pine Script: `//@version=6` is first line, no plot() in local scope, ternary on single lines
|
||||
- [ ] TDX: all uppercase functions, `:=` for intermediate, `:` for output, `{comments}`
|
||||
- [ ] MQL5: proper handle-based API, `EMPTY_VALUE` for no-signal
|
||||
- [ ] Entry/exit conditions match the Python signal logic semantically across ALL platforms
|
||||
- [ ] Untranslatable features are clearly documented at the top of each section
|
||||
- [ ] Comment header notes the original Vibe-Trading run_id and instrument
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: shadow-account
|
||||
description: "Shadow Account — 从用户交割单提炼盈利模式(3-5 条人话规则)→ 跨 A股/港股/美股/crypto 多市场回测 → 差值归因 → 8-section PDF 报告。叙事:你的影子,没有情绪噪音。"
|
||||
category: analysis
|
||||
---
|
||||
|
||||
# Shadow Account — 影子账户
|
||||
|
||||
## 何时触发
|
||||
|
||||
当用户说 "提炼我的策略" / "训练影子" / "我的打法回测一下" / "我能多赚多少" / "我的盈利模式" 时,加载此 skill。
|
||||
|
||||
**前提**:用户已上传交割单且 `analyze_trade_journal` 已跑过。若没有,先跑 Phase 4a 工具。
|
||||
|
||||
## 工作流(四步)
|
||||
|
||||
1. `extract_shadow_strategy(journal_path=...)`
|
||||
- 返回 `shadow_id` + 3-5 条人话规则
|
||||
- 向用户 confirm:"这些规则像你本人吗?" 如果用户说"不像",提高 `min_support` 重跑
|
||||
2. `run_shadow_backtest(shadow_id=..., journal_path=...)`
|
||||
- 返回 per-market 指标 + `delta_pnl` + attribution breakdown
|
||||
- 默认四市场并跑(china_a/hk/us/crypto)
|
||||
3. `render_shadow_report(shadow_id=...)`
|
||||
- 生成 HTML + PDF(weasyprint 失败时自动降级成 HTML-only)
|
||||
- 返回 `html_path` / `pdf_path` / `delta_pnl`
|
||||
4. (可选)`scan_shadow_signals(shadow_id=...)` — 今日落在影子入场窗口的标的列表(研究用)
|
||||
|
||||
## 产出解读
|
||||
|
||||
### 规则卡
|
||||
每条规则含:`rule_id`、`human_text`(≤30 字)、`support_count`、`coverage_rate`、`holding_days_range`。规则不是"必赚公式",而是"用户盈利时的共性画像"。
|
||||
|
||||
### 回测矩阵
|
||||
- `per_market`:四市场的 Sharpe/年化/最大回撤
|
||||
- `combined`:合并池表现
|
||||
- `equity_curve`:净值时序(进入 PDF Section 3)
|
||||
|
||||
### 差值归因(PDF Section 5 — gut punch)
|
||||
所有数值 signed,正值=影子相对赚更多:
|
||||
- `noise_trades_pnl`:不命中任何规则的真实交易累计 PnL(用户的情绪单)
|
||||
- `early_exit_pnl`:赢单但持仓 < 规则下限,按不足比例折算的机会成本
|
||||
- `late_exit_pnl`:亏单但持仓 > 规则上限,按超额比例折算的放大损失
|
||||
- `overtrading_pnl`:超出规则频率的真实交易 PnL
|
||||
- `missed_signals_pnl`:残差(shadow_pnl − real_pnl − 上面四项之和)
|
||||
|
||||
### 反事实 Top 5
|
||||
按 `|impact|` 排序,列出 5 条"最该做没做 / 最不该做却做了"的交易,带具体日期、原因。
|
||||
|
||||
## 对话模板
|
||||
|
||||
**确认规则**:
|
||||
> 从你 {profitable_roundtrips} 笔盈利回合中提炼出这些规则:{rules}。这些看起来像你本人的打法吗?
|
||||
|
||||
**展示差值**(Section 5):
|
||||
> 影子 PnL **{shadow_pnl:+.0f}** / 你真实 **{real_pnl:+.0f}** / 差值 **{delta_pnl:+.0f}**。其中 **{noise_trades_pnl:+.0f}** 来自不符合你任何盈利规则的"情绪单"。
|
||||
|
||||
**今日扫描**(强制附带免责):
|
||||
> 今日落在你影子入场节奏的标的:{symbols}。**仅研究用,不是买入建议。**
|
||||
|
||||
## 规则翻译 Prompt 模板
|
||||
|
||||
当 `extract_shadow_strategy` 被调用时,可以注入一个 `llm_translator` callable 以把结构化 entry_condition 翻译成中文自然语言:
|
||||
|
||||
```
|
||||
[上下文] 一位散户的盈利回合中,{N} 笔满足同一组条件:
|
||||
market = {market}
|
||||
entry_hour ∈ [{hour_min}, {hour_max}]
|
||||
持有 {hold_lo}-{hold_hi} 天
|
||||
[任务] 用 ≤30 字的中文写一条规则,口吻像用户自述的交易习惯,不要堆术语。
|
||||
[输出] 只返回一行规则文本,不要解释。
|
||||
```
|
||||
|
||||
不注入时走 f-string 模板(见 `extractor._translate_rule`)。
|
||||
|
||||
## 红线
|
||||
|
||||
- **不落单**:这些工具永远不会对接任何下单通道,仅研究输出
|
||||
- **不复制他人策略**:Shadow Account 是"用户自己"的影子,不从社区/公开策略提取规则
|
||||
- **样本不足必报错**:profitable roundtrips < 5 → 直接 raise,不编造
|
||||
@@ -183,6 +183,14 @@ If improvements are needed after evaluation, write `action_items`:
|
||||
- `"Add stop-loss: force close when loss exceeds 5%"`
|
||||
- `"Add volume filter in signal_engine.py: only trigger buy on high volume"`
|
||||
|
||||
## Cross-Market Strategies
|
||||
|
||||
When the user requests a backtest with codes from **different markets** (e.g. `["000001.SZ", "BTC-USDT"]`):
|
||||
- Set `source: "auto"` in `config.json`
|
||||
- The `CompositeEngine` handles calendar alignment, shared capital, and per-market rules automatically
|
||||
- Use volatility-adjusted weights so high-vol assets (crypto) don't dominate the risk budget
|
||||
- See the [cross-market-strategy](../cross-market-strategy/SKILL.md) skill for per-market parameters, vol-adjustment, and example code
|
||||
|
||||
## Supporting Files
|
||||
|
||||
- [examples.md](examples.md) — example call sequence
|
||||
@@ -0,0 +1,213 @@
|
||||
---
|
||||
name: trade-journal
|
||||
description: Analyze a user's trade journal (CSV/Excel broker export). Parses 同花顺/东方财富/富途/generic formats, produces a trading profile and 4 behavior diagnostics (disposition effect, overtrading, chasing, anchoring). Use the `analyze_trade_journal` tool.
|
||||
category: tool
|
||||
---
|
||||
# Trade Journal Analysis
|
||||
|
||||
## Purpose
|
||||
|
||||
Users upload broker exports (交割单) and get an honest, data-grounded portrait
|
||||
of their own trading. Two layers are live:
|
||||
|
||||
- **Profile** — holding days, frequency, win rate, PnL ratio, cumulative PnL,
|
||||
max drawdown, top symbols, market/hourly distribution.
|
||||
- **Behavior diagnostics** — 4 biases, each with severity (low/medium/high)
|
||||
and numeric evidence: disposition effect, overtrading, chasing momentum,
|
||||
anchoring.
|
||||
|
||||
Strategy extraction → backtest bridge lands in Phase 4c.
|
||||
|
||||
Supported formats (auto-detected):
|
||||
- **同花顺** (Tonghuashun) — A-share CSV, typically GBK-encoded
|
||||
- **东方财富** (Eastmoney) — A-share CSV, typically GBK-encoded
|
||||
- **富途** (Futu) — HK/US CSV, UTF-8
|
||||
- **Generic** — any CSV with columns like `datetime/symbol/side/qty/price`
|
||||
|
||||
## Usage
|
||||
|
||||
**Call the `analyze_trade_journal` tool directly. Never run Python from bash.**
|
||||
|
||||
```
|
||||
analyze_trade_journal(file_path="uploads/xxx.csv")
|
||||
analyze_trade_journal(file_path="uploads/xxx.csv", analysis_type="profile")
|
||||
analyze_trade_journal(file_path="uploads/xxx.csv", filter_expr="2026-01 to 2026-03")
|
||||
analyze_trade_journal(file_path="uploads/xxx.csv", filter_expr="symbol=600519.SH")
|
||||
analyze_trade_journal(file_path="uploads/xxx.csv", filter_expr="market=china_a")
|
||||
```
|
||||
|
||||
`analysis_type`:
|
||||
- `full` (default) — profile + behavior (strategy still placeholder)
|
||||
- `profile` — profile metrics only (fastest)
|
||||
- `behavior` — 4 behavior diagnostics only
|
||||
- `strategy` — Phase 4c placeholder
|
||||
|
||||
`filter_expr` (optional):
|
||||
- Date range: `"YYYY-MM to YYYY-MM"` or `"YYYY-MM-DD to YYYY-MM-DD"`
|
||||
- Symbol: `"symbol=600519.SH"` (exact match on qualified symbol)
|
||||
- Market: `"market=china_a|us|hk|crypto"`
|
||||
|
||||
## Return shape (profile subset)
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"file": "xxx.csv",
|
||||
"format_detected": "tonghuashun",
|
||||
"total_records": 326,
|
||||
"date_range": "2026-01-06 ~ 2026-03-28",
|
||||
"symbols_count": 42,
|
||||
"market": "china_a",
|
||||
"profile": {
|
||||
"total_trades": 326,
|
||||
"total_roundtrips": 118,
|
||||
"avg_holding_days": 3.2,
|
||||
"trade_frequency_per_week": 4.1,
|
||||
"win_rate": 0.48,
|
||||
"profit_loss_ratio": 1.35,
|
||||
"total_pnl": 18240.55,
|
||||
"max_drawdown": -9820.10,
|
||||
"top_symbols": [{"symbol": "600519.SH", "trades": 14, "total_amount": 1.02e6}, ...],
|
||||
"market_distribution": {"china_a": 326},
|
||||
"hourly_distribution": {9: 52, 10: 84, ...},
|
||||
"roundtrips_sample": [{"symbol": "600519.SH", "buy_dt": "...", "sell_dt": "...", "pnl": 3400.1, "pnl_pct": 0.021, "hold_days": 2.5}, ...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: PnL uses FIFO lot matching; unmatched open positions are excluded from
|
||||
win rate / PnL ratio (only closed round-trips count).
|
||||
|
||||
## Presenting results to the user
|
||||
|
||||
Produce a **single markdown report** in the user's language. Lead with the
|
||||
top-line numbers, then section-by-section. Keep it dense — this is retail
|
||||
readers skimming on a phone.
|
||||
|
||||
### Report template
|
||||
|
||||
```
|
||||
## 你的交易画像 — {date_range}
|
||||
|
||||
**总体**
|
||||
- 交易笔数:{total_trades}(完整来回 {total_roundtrips} 次)
|
||||
- 平均持仓:{avg_holding_days} 天
|
||||
- 交易频率:{trade_frequency_per_week} 次/周
|
||||
- 胜率:{win_rate:.0%}
|
||||
- 盈亏比:{profit_loss_ratio}
|
||||
- 累计盈亏:{total_pnl}
|
||||
- 最大回撤:{max_drawdown}
|
||||
|
||||
**最常交易的标的**(前 5 名)
|
||||
| 标的 | 笔数 | 成交额 |
|
||||
|------|------|--------|
|
||||
| ... | ... | ... |
|
||||
|
||||
**市场分布**
|
||||
{market_distribution}
|
||||
|
||||
**交易时段**
|
||||
{hourly_distribution — highlight peak hours}
|
||||
|
||||
**一句话观察**
|
||||
(根据数据写 1-2 句:过度交易?只做窄范围标的?集中在某时段?)
|
||||
```
|
||||
|
||||
Guidance:
|
||||
- If `win_rate < 0.4` AND `profit_loss_ratio < 1.0` → explicit warning: losing
|
||||
on both win rate and payoff. Ask whether they want behavior diagnostics
|
||||
(Phase 4b) or a cooling-off reality check.
|
||||
- If `avg_holding_days < 1` AND `trade_frequency_per_week > 15` → flag
|
||||
intraday-heavy pattern, note that minute-level backtest would be better.
|
||||
- If `symbols_count <= 3` → concentration risk; ask if they want a sector-
|
||||
diversification check.
|
||||
|
||||
## Follow-up dialogue
|
||||
|
||||
After the initial report, users typically ask:
|
||||
- **Time-slice**: "3 月份表现怎么样" → re-call with `filter_expr="2026-03-01 to 2026-03-31"`.
|
||||
- **Symbol deep-dive**: "茅台这只赚了多少" → `filter_expr="symbol=600519.SH"`.
|
||||
- **Market split**: "港股和美股分开看" → two calls, `market=hk` and `market=us`.
|
||||
- **Hypothetical** ("如果我严格止损 -5%") → Phase 4b feature; for now tell the
|
||||
user this is on the roadmap.
|
||||
|
||||
Do NOT re-upload — the file path is still valid for subsequent tool calls
|
||||
in the same session.
|
||||
|
||||
## Error handling
|
||||
|
||||
- `File not found` / `Unsupported extension` — ask user to re-upload.
|
||||
- `Unrecognized trade journal format` — share the detected columns back to
|
||||
the user and ask them to rename the key columns to: `datetime, symbol,
|
||||
side, quantity, price, amount, fee` (generic fallback).
|
||||
- `No trade records parsed` — likely empty file or header-only; ask user to
|
||||
confirm the export contains actual fills.
|
||||
|
||||
## Behavior diagnostics (shape)
|
||||
|
||||
Under `result["behavior"]`:
|
||||
|
||||
```json
|
||||
{
|
||||
"disposition_effect": {
|
||||
"severity": "high",
|
||||
"ratio_loss_to_win_hold": 1.69,
|
||||
"avg_winner_hold_days": 7.4,
|
||||
"avg_loser_hold_days": 12.5,
|
||||
"evidence": "Losing roundtrips held 12.5d vs winning 7.4d (ratio 1.69). Classic disposition pattern."
|
||||
},
|
||||
"overtrading": {
|
||||
"severity": "high",
|
||||
"busy_day_avg_pnl": -2632,
|
||||
"quiet_day_avg_pnl": 759,
|
||||
"evidence": "On busy days (≥3 trades) avg PnL -2632; on quiet days (≤1) avg PnL +759. High activity hurts returns."
|
||||
},
|
||||
"chasing_momentum": {
|
||||
"severity": "medium",
|
||||
"chase_ratio": 0.5,
|
||||
"buys_evaluated": 4,
|
||||
"evidence": "2/4 buys (50%) came after a >3% price run-up in the same symbol. Some chasing tendency."
|
||||
},
|
||||
"anchoring": {
|
||||
"severity": "high",
|
||||
"anchored_symbol_ratio": 0.83,
|
||||
"symbols_evaluated": 6,
|
||||
"anchored_symbols": [...],
|
||||
"evidence": "5/6 frequently-traded symbols stayed in a narrow price band (CV<5%). Strong anchoring."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Detection logic (for user-facing explanation)
|
||||
|
||||
| Bias | Metric | Medium | High |
|
||||
|------|--------|--------|------|
|
||||
| **Disposition effect** | avg_loser_hold / avg_winner_hold | ≥ 1.2 | ≥ 1.5 |
|
||||
| **Overtrading** | (quiet − busy) / \|quiet\| day-PnL gap | ≥ 0.3 | ≥ 1.0 |
|
||||
| **Chasing** | fraction of buys after 3-trade rolling +3% move | ≥ 40% | ≥ 60% |
|
||||
| **Anchoring** | fraction of ≥5-trade symbols with price CV < 5% | ≥ 33% | ≥ 66% |
|
||||
|
||||
### Report section (Chinese)
|
||||
|
||||
```
|
||||
## 行为偏差诊断
|
||||
|
||||
| 偏差 | 严重程度 | 核心证据 |
|
||||
|------|----------|----------|
|
||||
| 处置效应 | {high/medium/low} | {evidence} |
|
||||
| 过度交易 | {...} | {...} |
|
||||
| 追涨杀跌 | {...} | {...} |
|
||||
| 锚定效应 | {...} | {...} |
|
||||
|
||||
**改进建议**(根据检测到的 high/medium 项生成):
|
||||
- 处置效应 high → 写死止损(例如 -8%),盈利持仓不要过早兑现
|
||||
- 过度交易 high → 每日交易次数 <= N 的硬约束
|
||||
- 追涨杀跌 high → 改买回调而不是新高,设置"涨幅 X% 以上当日不追"规则
|
||||
- 锚定效应 high → 扩宽价格带,不要死守某个"心理价"
|
||||
```
|
||||
|
||||
## Phase 4c preview (not yet implemented)
|
||||
|
||||
Strategy extraction → SignalEngine code gen → auto-backtest lands in Phase 4c.
|
||||
When the user asks for it, respond honestly and offer the behavior diagnostics
|
||||
instead (they're live).
|
||||
@@ -0,0 +1,320 @@
|
||||
---
|
||||
name: vnpy-export
|
||||
description: Export a Vibe-Trading backtest strategy to a runnable vnpy CtaTemplate Python class — supports A-share equities, futures, and crypto via BarGenerator + ArrayManager.
|
||||
category: tool
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This skill translates a Vibe-Trading strategy into a **vnpy `CtaTemplate` subclass** `.py` file
|
||||
that can be loaded directly into the vnpy CTA Strategy App for live trading or vnpy backtesting.
|
||||
|
||||
Output file: `artifacts/vnpy_strategy/<StrategyName>Strategy.py` (inside the run directory).
|
||||
|
||||
vnpy is the most widely-used open-source quant framework in mainland China (39k+ GitHub stars).
|
||||
Use this skill when the user asks to export to vnpy, requests a `/vnpy` command, or wants to
|
||||
run a Vibe-Trading strategy inside vnpy's CTA backtester or live trading engine.
|
||||
|
||||
---
|
||||
|
||||
## Workflow: Export from Backtest Run
|
||||
|
||||
1. `load_skill("vnpy-export")` — read this guide
|
||||
2. `read_file("config.json")` — extract instrument, dates, parameters, interval
|
||||
3. `read_file("code/signal_engine.py")` — understand the Python signal logic
|
||||
4. Determine asset class from `config.json` → choose correct CtaTemplate convention (see below)
|
||||
5. Translate signal logic to CtaTemplate using the reference tables
|
||||
6. `write_file("artifacts/vnpy_strategy/<StrategyName>Strategy.py")` — save the output
|
||||
7. Return the class in a code block with setup instructions
|
||||
|
||||
## Workflow: Generate from Description
|
||||
|
||||
1. `load_skill("vnpy-export")` — read this guide
|
||||
2. Write a CtaTemplate class from the user's strategy description
|
||||
3. `write_file("artifacts/vnpy_strategy/<StrategyName>Strategy.py")` — save the output
|
||||
4. Return the class with setup and usage instructions
|
||||
|
||||
---
|
||||
|
||||
## Asset Class Conventions
|
||||
|
||||
vnpy uses the same `CtaTemplate` base class for all asset types, but parameter conventions differ:
|
||||
|
||||
| Asset Class | Instrument Example | `vt_symbol` Format | Position Unit |
|
||||
|-------------|-------------------|---------------------|---------------|
|
||||
| A-share stock | Ping An Bank | `000001.SZSE` | shares (整手, min 100) |
|
||||
| Futures | IF2406 | `IF2406.CFFEX` | lots |
|
||||
| Crypto | BTC/USDT | `BTC/USDT.BINANCE` | coin units |
|
||||
|
||||
For **stocks**: use `buy` / `sell` only (no short selling unless margin account).
|
||||
For **futures / crypto**: use all four directions — `buy`, `sell`, `short`, `cover`.
|
||||
|
||||
---
|
||||
|
||||
## CtaTemplate Structure
|
||||
|
||||
Every strategy must subclass `CtaTemplate` and implement these methods:
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `__init__` | Declare parameters, variables, BarGenerator, ArrayManager |
|
||||
| `on_init` | Called once at startup; call `load_bar(n)` to warm up indicators |
|
||||
| `on_start` | Called when strategy is started by user |
|
||||
| `on_stop` | Called when strategy is stopped |
|
||||
| `on_tick` | Receives live tick data; forward to BarGenerator |
|
||||
| `on_bar` | Main logic — called once per bar by BarGenerator |
|
||||
| `on_order` | Order status updates |
|
||||
| `on_trade` | Fill notifications |
|
||||
| `on_stop_order` | Stop-order status (if using stop orders) |
|
||||
|
||||
**Always call** `self.cancel_all()` at the start of `on_bar` to avoid stale orders.
|
||||
**Always call** `self.put_event()` at the end of `on_bar` to refresh the UI.
|
||||
|
||||
---
|
||||
|
||||
## Full Template
|
||||
|
||||
See `scripts/cta_template.py` for a complete, runnable example (MA crossover).
|
||||
The template below is the canonical skeleton — replace the `# SIGNAL LOGIC` section:
|
||||
|
||||
```python
|
||||
from vnpy.app.cta_strategy import (
|
||||
CtaTemplate,
|
||||
StopOrder,
|
||||
TickData,
|
||||
BarData,
|
||||
TradeData,
|
||||
OrderData,
|
||||
BarGenerator,
|
||||
ArrayManager,
|
||||
)
|
||||
|
||||
|
||||
class {{StrategyName}}Strategy(CtaTemplate):
|
||||
"""
|
||||
Vibe-Trading export — {{StrategyName}}
|
||||
Generated from run: {{run_id}}
|
||||
Instrument: {{vt_symbol}}
|
||||
"""
|
||||
|
||||
author = "Vibe-Trading"
|
||||
|
||||
# ── Parameters (editable in vnpy UI) ──────────────────────────────────
|
||||
{{param_name}} = {{param_default}} # add one line per parameter
|
||||
|
||||
parameters = [{{param_list_as_strings}}]
|
||||
|
||||
# ── Variables (displayed in vnpy UI, reset on strategy restart) ────────
|
||||
{{var_name}} = 0.0 # add one line per runtime variable
|
||||
|
||||
variables = [{{var_list_as_strings}}]
|
||||
|
||||
def __init__(self, cta_engine, strategy_name, vt_symbol, setting):
|
||||
super().__init__(cta_engine, strategy_name, vt_symbol, setting)
|
||||
self.bg = BarGenerator(self.on_bar)
|
||||
self.am = ArrayManager()
|
||||
|
||||
# initialise variable attributes to match class-level defaults
|
||||
# (vnpy requires instance attributes for variables declared above)
|
||||
|
||||
def on_init(self):
|
||||
self.write_log("Strategy initialised")
|
||||
self.load_bar({{warmup_bars}}) # load enough bars to warm up all indicators
|
||||
|
||||
def on_start(self):
|
||||
self.write_log("Strategy started")
|
||||
self.put_event()
|
||||
|
||||
def on_stop(self):
|
||||
self.write_log("Strategy stopped")
|
||||
|
||||
def on_tick(self, tick: TickData):
|
||||
self.bg.update_tick(tick)
|
||||
|
||||
def on_bar(self, bar: BarData):
|
||||
self.cancel_all()
|
||||
|
||||
am = self.am
|
||||
am.update_bar(bar)
|
||||
if not am.inited:
|
||||
return
|
||||
|
||||
# ── INDICATOR CALCULATIONS ──────────────────────────────────────────
|
||||
# translate indicators from signal_engine.py using the mapping table
|
||||
|
||||
# ── SIGNAL LOGIC ───────────────────────────────────────────────────
|
||||
# set cross_over / cross_under (or long_signal / short_signal) here
|
||||
|
||||
# ── ORDER EXECUTION ────────────────────────────────────────────────
|
||||
if cross_over:
|
||||
if self.pos == 0:
|
||||
self.buy(bar.close_price, 1)
|
||||
elif self.pos < 0:
|
||||
self.cover(bar.close_price, 1)
|
||||
self.buy(bar.close_price, 1)
|
||||
elif cross_under:
|
||||
if self.pos == 0:
|
||||
self.short(bar.close_price, 1)
|
||||
elif self.pos > 0:
|
||||
self.sell(bar.close_price, 1)
|
||||
self.short(bar.close_price, 1)
|
||||
|
||||
self.put_event()
|
||||
|
||||
def on_order(self, order: OrderData):
|
||||
pass
|
||||
|
||||
def on_trade(self, trade: TradeData):
|
||||
self.put_event()
|
||||
|
||||
def on_stop_order(self, stop_order: StopOrder):
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Python → ArrayManager Indicator Mapping
|
||||
|
||||
`ArrayManager` is vnpy's built-in vectorised indicator library. Always prefer it over pandas
|
||||
when the equivalent method exists — it is faster and avoids look-ahead bias.
|
||||
|
||||
| Python (Vibe-Trading / pandas / ta-lib) | vnpy ArrayManager |
|
||||
|----------------------------------------|-------------------|
|
||||
| `df['close'].rolling(n).mean()` | `am.sma(n)` |
|
||||
| `df['close'].ewm(span=n).mean()` | `am.ema(n)` |
|
||||
| `ta.RSI(close, n)` | `am.rsi(n)` |
|
||||
| `ta.MACD(close, 12, 26, 9)` | `am.macd(12, 26, 9)` → `(macd, signal, hist)` |
|
||||
| Bollinger Bands | `am.boll(n, dev)` → `(mid, upper, lower)` |
|
||||
| ATR | `am.atr(n)` |
|
||||
| ADX | `am.adx(n)` |
|
||||
| `df['close'].rolling(n).std()` | `am.std(n)` |
|
||||
| Stochastic K, D | `am.kd(n, m)` → `(k, d)` |
|
||||
| `df['high'].rolling(n).max()` | `am.high_array[-n:].max()` |
|
||||
| `df['low'].rolling(n).min()` | `am.low_array[-n:].min()` |
|
||||
| Donchian channel | `am.donchian(n)` → `(upper, lower)` |
|
||||
| `df['close'].shift(1)` (previous bar) | `am.close_array[-2]` |
|
||||
| Last N bars as array | `am.sma(n, array=True)` (returns full array) |
|
||||
|
||||
**Using arrays**: pass `array=True` to get the full history array (e.g. for crossover detection):
|
||||
|
||||
```python
|
||||
fast_ma = am.sma(self.fast_window, array=True)
|
||||
cross_over = fast_ma[-1] > slow_ma[-1] and fast_ma[-2] <= slow_ma[-2]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Signal → Order Mapping
|
||||
|
||||
| Vibe-Trading signal | Position check | vnpy call |
|
||||
|---------------------|---------------|-----------|
|
||||
| Long entry | `self.pos == 0` | `self.buy(price, volume)` |
|
||||
| Long entry (reverse from short) | `self.pos < 0` | `self.cover(price, vol); self.buy(price, vol)` |
|
||||
| Long exit | `self.pos > 0` | `self.sell(price, volume)` |
|
||||
| Short entry | `self.pos == 0` | `self.short(price, volume)` |
|
||||
| Short entry (reverse from long) | `self.pos > 0` | `self.sell(price, vol); self.short(price, vol)` |
|
||||
| Short exit | `self.pos < 0` | `self.cover(price, volume)` |
|
||||
| Close all (stop signal) | any | `self.cancel_all()` then `sell` / `cover` as needed |
|
||||
|
||||
**Price conventions**:
|
||||
- For backtesting: use `bar.close_price` (market order equivalent)
|
||||
- For live trading with limit orders: use `bar.close_price` ± a small offset (e.g. `* 1.001`)
|
||||
- For stop orders: use `self.buy_stop(trigger, volume)` / `self.short_stop(trigger, volume)`
|
||||
|
||||
**Volume conventions**:
|
||||
- Stocks: volume in shares; must be a multiple of 100 (round lots)
|
||||
- Futures: volume in lots (usually 1 for CtaTemplate strategies)
|
||||
- Crypto: volume in base-currency units (e.g., BTC)
|
||||
|
||||
---
|
||||
|
||||
## Multi-Timeframe Strategies
|
||||
|
||||
When the Vibe-Trading strategy uses multiple timeframes (e.g., daily signal, hourly entry):
|
||||
|
||||
```python
|
||||
def __init__(self, ...):
|
||||
super().__init__(...)
|
||||
self.bg = BarGenerator(self.on_bar, 5, self.on_5min_bar) # 5-min bars
|
||||
self.bg_d = BarGenerator(self.on_bar, window=1, on_window_bar=self.on_daily_bar,
|
||||
interval=Interval.DAILY) # daily bars
|
||||
self.am = ArrayManager()
|
||||
self.am_d = ArrayManager(size=100) # daily ArrayManager
|
||||
|
||||
def on_bar(self, bar: BarData):
|
||||
self.bg.update_bar(bar) # feeds 5-min generator
|
||||
|
||||
def on_5min_bar(self, bar: BarData):
|
||||
self.bg_d.update_bar(bar) # feeds daily generator
|
||||
# put intraday entry logic here
|
||||
|
||||
def on_daily_bar(self, bar: BarData):
|
||||
self.am_d.update_bar(bar)
|
||||
# put daily trend-filter logic here
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Output File Instructions
|
||||
|
||||
Save the generated file to: `artifacts/vnpy_strategy/<StrategyName>Strategy.py`
|
||||
|
||||
To load in vnpy:
|
||||
|
||||
1. Copy the file to your vnpy project's `strategies/` folder (or any folder on `sys.path`)
|
||||
2. Open the vnpy Trader → CTA Strategy App
|
||||
3. Click **Add Strategy** → select `<StrategyName>Strategy` from the dropdown
|
||||
4. Set `vt_symbol` (e.g. `IF2406.CFFEX`) and adjust parameters
|
||||
5. Click **Init** → **Start** to begin
|
||||
|
||||
To run the vnpy backtester:
|
||||
|
||||
```python
|
||||
from vnpy.app.cta_backtester import BacktestingEngine
|
||||
from vnpy.trader.constant import Interval
|
||||
|
||||
engine = BacktestingEngine()
|
||||
engine.set_parameters(
|
||||
vt_symbol="000001.SZSE",
|
||||
interval=Interval.DAILY,
|
||||
start=datetime(2020, 1, 1),
|
||||
end=datetime(2024, 1, 1),
|
||||
rate=0.0003,
|
||||
slippage=0.02,
|
||||
size=1,
|
||||
pricetick=0.01,
|
||||
capital=1_000_000,
|
||||
)
|
||||
engine.add_strategy({{StrategyName}}Strategy, {})
|
||||
engine.load_data()
|
||||
engine.run_backtesting()
|
||||
df = engine.calculate_result()
|
||||
engine.calculate_statistics()
|
||||
engine.show_chart()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quality Checklist
|
||||
|
||||
Before saving the output file:
|
||||
|
||||
- [ ] Class name ends with `Strategy` and matches the filename
|
||||
- [ ] All `parameters` entries have matching class-level defaults and `__init__` instance attributes
|
||||
- [ ] All `variables` entries have matching instance attributes initialised in `__init__`
|
||||
- [ ] `on_bar` calls `self.cancel_all()` at the start
|
||||
- [ ] `on_bar` calls `self.put_event()` at the end
|
||||
- [ ] `on_bar` returns early if `not am.inited`
|
||||
- [ ] Position direction checked with `self.pos` before every order call
|
||||
- [ ] Stocks: no `short` / `cover` calls unless margin trading is explicitly requested
|
||||
- [ ] `load_bar(n)` warmup in `on_init` is at least `max(all indicator windows) + 2`
|
||||
- [ ] Comment block at top of file notes the original Vibe-Trading `run_id` and instrument
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- vnpy CtaTemplate source: `vnpy/app/cta_strategy/template.py`
|
||||
- ArrayManager source: `vnpy/app/cta_strategy/base.py`
|
||||
- Official docs: https://www.vnpy.com/docs/cn/cta_strategy.html
|
||||
- Example strategies (official): `vnpy/app/cta_strategy/strategies/`
|
||||
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Vibe-Trading → vnpy export: MA crossover strategy template.
|
||||
|
||||
This file is the reference implementation for the vnpy-export skill.
|
||||
Copy it to your vnpy project's strategies/ folder and adjust parameters.
|
||||
|
||||
Requirements:
|
||||
pip install vnpy vnpy_ctp (or vnpy_tts for paper trading)
|
||||
|
||||
Usage in vnpy Trader:
|
||||
CTA Strategy App → Add Strategy → MaCrossStrategy
|
||||
Set vt_symbol, e.g. "IF2406.CFFEX" (futures) or "000001.SZSE" (A-share)
|
||||
"""
|
||||
|
||||
from vnpy.app.cta_strategy import (
|
||||
CtaTemplate,
|
||||
StopOrder,
|
||||
TickData,
|
||||
BarData,
|
||||
TradeData,
|
||||
OrderData,
|
||||
BarGenerator,
|
||||
ArrayManager,
|
||||
)
|
||||
|
||||
|
||||
class MaCrossStrategy(CtaTemplate):
|
||||
"""
|
||||
Dual moving-average crossover — generated by Vibe-Trading.
|
||||
|
||||
Signals:
|
||||
Long entry : fast MA crosses above slow MA (golden cross)
|
||||
Long exit : fast MA crosses below slow MA (death cross)
|
||||
Short entry : fast MA crosses below slow MA (futures / crypto only)
|
||||
Short exit : fast MA crosses above slow MA
|
||||
|
||||
Supports equity (long-only) and futures/crypto (long + short).
|
||||
Set `trade_long_only = True` for A-share stocks.
|
||||
"""
|
||||
|
||||
author = "Vibe-Trading"
|
||||
|
||||
# ── Parameters (editable in the vnpy UI) ─────────────────────────────
|
||||
fast_window: int = 10
|
||||
slow_window: int = 30
|
||||
trade_long_only: bool = False # True for A-share stocks (no short selling)
|
||||
|
||||
parameters = ["fast_window", "slow_window", "trade_long_only"]
|
||||
|
||||
# ── Variables (live state displayed in the vnpy UI) ───────────────────
|
||||
fast_ma0: float = 0.0 # fast MA, current bar
|
||||
fast_ma1: float = 0.0 # fast MA, previous bar
|
||||
slow_ma0: float = 0.0 # slow MA, current bar
|
||||
slow_ma1: float = 0.0 # slow MA, previous bar
|
||||
|
||||
variables = ["fast_ma0", "fast_ma1", "slow_ma0", "slow_ma1"]
|
||||
|
||||
def __init__(self, cta_engine, strategy_name, vt_symbol, setting):
|
||||
super().__init__(cta_engine, strategy_name, vt_symbol, setting)
|
||||
|
||||
self.bg = BarGenerator(self.on_bar)
|
||||
self.am = ArrayManager()
|
||||
|
||||
self.fast_ma0 = 0.0
|
||||
self.fast_ma1 = 0.0
|
||||
self.slow_ma0 = 0.0
|
||||
self.slow_ma1 = 0.0
|
||||
|
||||
def on_init(self):
|
||||
self.write_log("Strategy initialised")
|
||||
self.load_bar(self.slow_window + 2)
|
||||
|
||||
def on_start(self):
|
||||
self.write_log("Strategy started")
|
||||
self.put_event()
|
||||
|
||||
def on_stop(self):
|
||||
self.write_log("Strategy stopped")
|
||||
|
||||
def on_tick(self, tick: TickData):
|
||||
self.bg.update_tick(tick)
|
||||
|
||||
def on_bar(self, bar: BarData):
|
||||
self.cancel_all()
|
||||
|
||||
am = self.am
|
||||
am.update_bar(bar)
|
||||
if not am.inited:
|
||||
return
|
||||
|
||||
# ── Indicators ───────────────────────────────────────────────────
|
||||
fast_ma = am.sma(self.fast_window, array=True)
|
||||
slow_ma = am.sma(self.slow_window, array=True)
|
||||
|
||||
self.fast_ma0 = fast_ma[-1]
|
||||
self.fast_ma1 = fast_ma[-2]
|
||||
self.slow_ma0 = slow_ma[-1]
|
||||
self.slow_ma1 = slow_ma[-2]
|
||||
|
||||
# ── Crossover detection ──────────────────────────────────────────
|
||||
cross_over = self.fast_ma0 > self.slow_ma0 and self.fast_ma1 <= self.slow_ma1
|
||||
cross_under = self.fast_ma0 < self.slow_ma0 and self.fast_ma1 >= self.slow_ma1
|
||||
|
||||
# ── Order execution ──────────────────────────────────────────────
|
||||
if cross_over:
|
||||
if self.pos == 0:
|
||||
self.buy(bar.close_price, 1)
|
||||
elif self.pos < 0:
|
||||
self.cover(bar.close_price, 1)
|
||||
self.buy(bar.close_price, 1)
|
||||
|
||||
elif cross_under:
|
||||
if self.trade_long_only:
|
||||
if self.pos > 0:
|
||||
self.sell(bar.close_price, 1)
|
||||
else:
|
||||
if self.pos == 0:
|
||||
self.short(bar.close_price, 1)
|
||||
elif self.pos > 0:
|
||||
self.sell(bar.close_price, 1)
|
||||
self.short(bar.close_price, 1)
|
||||
|
||||
self.put_event()
|
||||
|
||||
def on_order(self, order: OrderData):
|
||||
pass
|
||||
|
||||
def on_trade(self, trade: TradeData):
|
||||
self.put_event()
|
||||
|
||||
def on_stop_order(self, stop_order: StopOrder):
|
||||
pass
|
||||
@@ -12,7 +12,7 @@ from src.swarm.models import (
|
||||
TaskStatus,
|
||||
WorkerResult,
|
||||
)
|
||||
from src.swarm.presets import build_run_from_preset, list_presets, load_preset
|
||||
from src.swarm.presets import build_run_from_preset, inspect_preset, list_presets, load_preset
|
||||
from src.swarm.runtime import SwarmRuntime
|
||||
from src.swarm.store import SwarmStore
|
||||
from src.swarm.worker import run_worker
|
||||
@@ -29,6 +29,7 @@ __all__ = [
|
||||
"TaskStatus",
|
||||
"WorkerResult",
|
||||
"build_run_from_preset",
|
||||
"inspect_preset",
|
||||
"list_presets",
|
||||
"load_preset",
|
||||
"run_worker",
|
||||
|
||||
+149
-3
@@ -1,20 +1,26 @@
|
||||
"""Swarm YAML preset loader.
|
||||
|
||||
Reads YAML preset files from agent/config/swarm/ and parses them into
|
||||
SwarmRun / SwarmAgentSpec / SwarmTask data models.
|
||||
Reads YAML preset files from the bundled ``presets/`` directory next to this
|
||||
module and parses them into SwarmRun / SwarmAgentSpec / SwarmTask data models.
|
||||
Keeping the YAMLs inside the ``src.swarm`` package guarantees identical
|
||||
behavior under editable installs and built wheels.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from string import Formatter
|
||||
|
||||
import yaml
|
||||
|
||||
from src.swarm.models import RunStatus, SwarmAgentSpec, SwarmRun, SwarmTask, TaskStatus
|
||||
from src.swarm.task_store import topological_layers, validate_dag
|
||||
|
||||
PRESETS_DIR = Path(__file__).resolve().parents[2] / "config" / "swarm"
|
||||
PRESETS_DIR = Path(__file__).resolve().parent / "presets"
|
||||
_INTERNAL_TEMPLATE_VARS = {"upstream_context"}
|
||||
|
||||
|
||||
def load_preset(name: str) -> dict:
|
||||
@@ -65,6 +71,146 @@ def list_presets() -> list[dict]:
|
||||
return results
|
||||
|
||||
|
||||
def _declared_variable_names(raw_variables: list) -> set[str]:
|
||||
"""Extract variable names from the YAML variables section."""
|
||||
names: set[str] = set()
|
||||
for item in raw_variables:
|
||||
if isinstance(item, dict):
|
||||
name = item.get("name")
|
||||
else:
|
||||
name = str(item)
|
||||
if name:
|
||||
names.add(str(name))
|
||||
return names
|
||||
|
||||
|
||||
def _template_variables(template: str) -> set[str]:
|
||||
"""Return Python format fields referenced by a prompt template."""
|
||||
variables: set[str] = set()
|
||||
for _, field_name, _, _ in Formatter().parse(template or ""):
|
||||
if not field_name:
|
||||
continue
|
||||
root = field_name.split(".", 1)[0].split("[", 1)[0]
|
||||
if root and root not in _INTERNAL_TEMPLATE_VARS:
|
||||
variables.add(root)
|
||||
return variables
|
||||
|
||||
|
||||
def inspect_preset(name: str) -> dict:
|
||||
"""Validate a swarm preset and return a dry-run execution plan.
|
||||
|
||||
This does not start workers or call an LLM. It catches common YAML/DAG
|
||||
mistakes early and exposes the topological task layers used by the runtime.
|
||||
"""
|
||||
data = load_preset(name)
|
||||
run = build_run_from_preset(name, {})
|
||||
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
agent_ids = [agent.id for agent in run.agents]
|
||||
task_ids = [task.id for task in run.tasks]
|
||||
agent_id_set = set(agent_ids)
|
||||
task_id_set = set(task_ids)
|
||||
|
||||
for duplicate in sorted(item for item, count in Counter(agent_ids).items() if count > 1):
|
||||
errors.append(f"Duplicate agent id: {duplicate}")
|
||||
for duplicate in sorted(item for item, count in Counter(task_ids).items() if count > 1):
|
||||
errors.append(f"Duplicate task id: {duplicate}")
|
||||
|
||||
for task in run.tasks:
|
||||
if task.agent_id not in agent_id_set:
|
||||
errors.append(f"Task '{task.id}' references unknown agent '{task.agent_id}'")
|
||||
for _, upstream_task_id in task.input_from.items():
|
||||
if upstream_task_id not in task_id_set:
|
||||
errors.append(
|
||||
f"Task '{task.id}' input_from references unknown task '{upstream_task_id}'"
|
||||
)
|
||||
|
||||
layers: list[list[str]] = []
|
||||
try:
|
||||
validate_dag(run.tasks)
|
||||
layers = topological_layers(run.tasks)
|
||||
except ValueError as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
dependents: dict[str, list[str]] = defaultdict(list)
|
||||
for task in run.tasks:
|
||||
for dep in task.depends_on:
|
||||
dependents[dep].append(task.id)
|
||||
|
||||
def is_upstream(candidate: str, task_id: str) -> bool:
|
||||
"""Return whether candidate can reach task_id through dependency edges."""
|
||||
seen: set[str] = set()
|
||||
stack = [candidate]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
if current == task_id:
|
||||
return True
|
||||
if current in seen:
|
||||
continue
|
||||
seen.add(current)
|
||||
stack.extend(dependents.get(current, []))
|
||||
return False
|
||||
|
||||
for task in run.tasks:
|
||||
for key, upstream_task_id in task.input_from.items():
|
||||
if upstream_task_id in task_id_set and not is_upstream(upstream_task_id, task.id):
|
||||
warnings.append(
|
||||
f"Task '{task.id}' input_from '{key}' references '{upstream_task_id}', "
|
||||
"which is not upstream in the DAG"
|
||||
)
|
||||
|
||||
declared_variables = _declared_variable_names(data.get("variables", []))
|
||||
used_variables: set[str] = set()
|
||||
for task in data.get("tasks", []):
|
||||
try:
|
||||
used_variables.update(_template_variables(task.get("prompt_template", "")))
|
||||
except ValueError as exc:
|
||||
errors.append(f"Task '{task.get('id', '?')}' has invalid prompt template: {exc}")
|
||||
|
||||
missing_declarations = sorted(used_variables - declared_variables)
|
||||
unused_declarations = sorted(declared_variables - used_variables)
|
||||
if missing_declarations:
|
||||
warnings.append(
|
||||
"Prompt templates use undeclared variables: " + ", ".join(missing_declarations)
|
||||
)
|
||||
if unused_declarations:
|
||||
warnings.append(
|
||||
"Declared variables are not used by task prompt templates: "
|
||||
+ ", ".join(unused_declarations)
|
||||
)
|
||||
|
||||
task_agent = {task.id: task.agent_id for task in run.tasks}
|
||||
return {
|
||||
"name": data.get("name", name),
|
||||
"title": data.get("title", ""),
|
||||
"description": data.get("description", ""),
|
||||
"valid": not errors,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"variables": sorted(declared_variables),
|
||||
"used_variables": sorted(used_variables),
|
||||
"agents": [
|
||||
{"id": agent.id, "role": agent.role, "tools": agent.tools, "skills": agent.skills}
|
||||
for agent in run.agents
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"id": task.id,
|
||||
"agent_id": task.agent_id,
|
||||
"depends_on": task.depends_on,
|
||||
"input_from": task.input_from,
|
||||
}
|
||||
for task in run.tasks
|
||||
],
|
||||
"layers": [
|
||||
[{"task_id": task_id, "agent_id": task_agent.get(task_id, "")} for task_id in layer]
|
||||
for layer in layers
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_run_from_preset(preset_name: str, user_vars: dict[str, str]) -> SwarmRun:
|
||||
"""Create a SwarmRun from a preset with user variables applied.
|
||||
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user