mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-07-29 16:37:43 +00:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 944af06a87 | |||
| 97e42d7a1a | |||
| 5481e83f03 | |||
| 88c4cc4a33 | |||
| 01889a6b64 | |||
| 443c6d47b2 | |||
| b10d3512df | |||
| d75cba934e | |||
| 38fa760429 | |||
| 0ce6f6ec6d | |||
| d17d424ee9 | |||
| 5d8e53d208 | |||
| 7880a9315a | |||
| 17bba1a920 | |||
| 360df4083a | |||
| e2c2fefe9a | |||
| 32f7d66e07 | |||
| 4d6ef04411 | |||
| a757eb4b79 | |||
| 1dc44d33ed | |||
| e672433305 | |||
| f875439105 | |||
| 4afd03dbaf | |||
| 90b36c174d | |||
| 5da3ba6752 | |||
| d8b5bc4237 | |||
| 5a6948d321 | |||
| c62d9c4efa | |||
| 1a1bd2c712 | |||
| 999bbac08d | |||
| ea87495a6a | |||
| 3a1adf7e0a | |||
| e819dcc3b9 | |||
| 016eed7df7 | |||
| 3f3a23bd38 | |||
| 6868002beb | |||
| 0302fbe66a | |||
| 89ef9fb33d | |||
| c533a0346d | |||
| d91263a70c | |||
| e650b01da0 | |||
| 670585f640 | |||
| bdb1d03357 | |||
| 8433795a4a | |||
| 210c4e8ad3 | |||
| 9e68c761b8 |
@@ -14,7 +14,7 @@ jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Run Bandit (Security Scan)
|
||||
uses: PyCQA/bandit-action@v1
|
||||
@@ -25,9 +25,9 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.10"
|
||||
cache: "pip"
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
pytest test/backtesting/ -v --tb=short
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
@@ -36,11 +36,11 @@ jobs:
|
||||
steps:
|
||||
# Checkout the repository to the GitHub Actions runner
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis
|
||||
- name: Run Codacy Analysis CLI
|
||||
uses: codacy/codacy-analysis-cli-action@d840f886c4bd4edc059706d09c6a1586111c540b
|
||||
uses: codacy/codacy-analysis-cli-action@562ee3e92b8e92df8b67e0a5ff8aa8e261919c08
|
||||
env:
|
||||
JAVA_TOOL_OPTIONS: "-Dfile.encoding=UTF-8"
|
||||
with:
|
||||
@@ -56,6 +56,6 @@ jobs:
|
||||
|
||||
# Upload the SARIF file generated in the previous step
|
||||
- name: Upload SARIF results file
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
name: Validate Commit Messages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -25,15 +25,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-docs-${{ hashFiles('**/pyproject.toml') }}
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
|
||||
- name: Upload docs artifact
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: docs/_build/html
|
||||
|
||||
@@ -83,4 +83,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
|
||||
@@ -16,15 +16,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-lint-${{ hashFiles('**/pyproject.toml') }}
|
||||
|
||||
@@ -14,5 +14,6 @@ jobs:
|
||||
steps:
|
||||
- uses: googleapis/release-please-action@v4
|
||||
with:
|
||||
release-type: python
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
|
||||
@@ -19,9 +19,9 @@ jobs:
|
||||
python-version: ["3.10", "3.11"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "pip"
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
- name: Upload results on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-py${{ matrix.python-version }}
|
||||
path: |
|
||||
@@ -49,9 +49,9 @@ jobs:
|
||||
name: Dependency Audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.10"
|
||||
cache: "pip"
|
||||
|
||||
@@ -19,15 +19,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-security-${{ hashFiles('**/requirements.txt') }}
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
bandit -c .bandit.yml -r rdagent/ -ll || true
|
||||
|
||||
- name: Upload Bandit report
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: bandit-security-report
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "1.3.3"
|
||||
}
|
||||
@@ -1,5 +1,78 @@
|
||||
# Changelog
|
||||
|
||||
## [1.3.3](https://github.com/TPTBusiness/Predix/compare/v1.3.2...v1.3.3) (2026-04-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backtest:** replace broken MC permutation test with binomial win-rate test ([c38d894](https://github.com/TPTBusiness/Predix/commit/c38d89478f586825bfca5715a96ca70ccd8791a3))
|
||||
* **factors:** detect and correct look-ahead bias in daily-constant factors ([eb490a4](https://github.com/TPTBusiness/Predix/commit/eb490a461b66cbd815ae53ac5205115754712432))
|
||||
* **factors:** extend look-ahead rules to session factors and add intraday-factor guidance ([c24c100](https://github.com/TPTBusiness/Predix/commit/c24c100442d6487686c0578de0b32d240fcbf215))
|
||||
* **loop:** compress old experiment history in proposal prompt to reduce context size ([4bf90a9](https://github.com/TPTBusiness/Predix/commit/4bf90a905ba8b2aba2a818191c19998088cccaaf))
|
||||
* **strategies:** guard against None IC in acceptance check, disable slow wf_rolling ([2197f52](https://github.com/TPTBusiness/Predix/commit/2197f52150a50ef38d9e70991d7e48c8c30caec4))
|
||||
* **strategies:** handle None ic/sharpe/dd in rejected strategy log output ([ad2ad3a](https://github.com/TPTBusiness/Predix/commit/ad2ad3ab3360ea75ed3bbc90c12098b9c5cc0114))
|
||||
|
||||
## [1.3.2](https://github.com/TPTBusiness/Predix/compare/v1.3.1...v1.3.2) (2026-04-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **strategies:** guard against None IC in acceptance check, disable slow wf_rolling ([2197f52](https://github.com/TPTBusiness/Predix/commit/2197f52150a50ef38d9e70991d7e48c8c30caec4))
|
||||
* **strategies:** handle None ic/sharpe/dd in rejected strategy log output ([ad2ad3a](https://github.com/TPTBusiness/Predix/commit/ad2ad3ab3360ea75ed3bbc90c12098b9c5cc0114))
|
||||
|
||||
## [1.3.1](https://github.com/TPTBusiness/Predix/compare/v1.3.0...v1.3.1) (2026-04-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** bump python-dotenv to >=1.2.2 (CVE symlink overwrite) ([126ae7d](https://github.com/TPTBusiness/Predix/commit/126ae7d5fb556b677d09d10221862a0d648d697a))
|
||||
|
||||
## [1.3.0](https://github.com/TPTBusiness/Predix/compare/v1.2.2...v1.3.0) (2026-04-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **backtest:** add rolling walk-forward validation and Monte Carlo trade permutation test ([637a94c](https://github.com/TPTBusiness/Predix/commit/637a94c1d987da763869f4f9b73372a3f37d873c))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** resolve all 30 Bandit security alerts (B301, B614, B104) ([ce5983d](https://github.com/TPTBusiness/Predix/commit/ce5983d9d59c4c34341fb1ec749e44bbcfc4a1c4))
|
||||
|
||||
## [1.2.2](https://github.com/TPTBusiness/Predix/compare/v1.2.1...v1.2.2) (2026-04-19)
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **claude:** auto-merge release-please PR after every push ([f500917](https://github.com/TPTBusiness/Predix/commit/f500917b699ee78dc676e84e01574d49bdc8e796))
|
||||
|
||||
## [2.2.0](https://github.com/TPTBusiness/Predix/compare/v2.1.0...v2.2.0) (2026-04-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add Kronos CLI commands, expand tests, document in README ([f911081](https://github.com/TPTBusiness/Predix/commit/f911081d1763d0dc4dd790b57dd97aae2dc62679))
|
||||
* **fin_quant:** auto-generate Kronos factor before loop start ([277063f](https://github.com/TPTBusiness/Predix/commit/277063f3e36cd071db859cdc77f69135c1f0763b))
|
||||
* integrate Kronos-mini OHLCV foundation model (Option A + B) ([4ae3b99](https://github.com/TPTBusiness/Predix/commit/4ae3b99f2450930f72e202a1a470c407bfde3328))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **kronos:** lazy torch import to fix CI ModuleNotFoundError ([ccc1d27](https://github.com/TPTBusiness/Predix/commit/ccc1d27dbe5ab06a57085a589d456ac7bf49cc08))
|
||||
* **kronos:** pass actual datetime Series to Kronos predictor timestamps ([dc6e7ce](https://github.com/TPTBusiness/Predix/commit/dc6e7ce207d21fbc21976f2af7691058530fac2f))
|
||||
* **kronos:** replace rdagent_logger with stdlib logging for CI compatibility ([b4558f2](https://github.com/TPTBusiness/Predix/commit/b4558f2456659c6109bd1b3cf100510491cd3e6c))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **kronos:** batch GPU inference via predict_batch — 75x faster ([74611d0](https://github.com/TPTBusiness/Predix/commit/74611d071ac123a655eb15d0737bb73b8c1bd2b0))
|
||||
* **kronos:** batch GPU inference via predict_batch — 75x faster ([2babeb9](https://github.com/TPTBusiness/Predix/commit/2babeb95f42828e13a37dc16166c75538f33fd4b))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* fix duplicate sections, add hardware requirements and data setup guide ([6c771b3](https://github.com/TPTBusiness/Predix/commit/6c771b37e6f88526a896499e86929cfca2c199eb))
|
||||
|
||||
## [2.1.0](https://github.com/TPTBusiness/Predix/compare/v2.0.0...v2.1.0) (2026-04-18)
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="#installation">Installation</a> •
|
||||
<a href="#no-gpu-use-openrouter">No GPU?</a> •
|
||||
<a href="#quick-start">Quick Start</a> •
|
||||
<a href="#configuration">Configuration</a> •
|
||||
<a href="#features">Features</a>
|
||||
@@ -101,11 +102,25 @@ All code in Predix is originally written and implemented independently. Predix e
|
||||
|
||||
## Installation
|
||||
|
||||
### System Requirements
|
||||
|
||||
| Component | Minimum | Recommended |
|
||||
|-----------|---------|-------------|
|
||||
| **GPU VRAM** | 8 GB | 16 GB (RTX 4080 / 5060 Ti) |
|
||||
| **RAM** | 16 GB | 32 GB |
|
||||
| **Storage** | 20 GB | 50 GB (models + data) |
|
||||
| **OS** | Linux (Ubuntu 22.04+) | Linux |
|
||||
| **CUDA** | 12.0+ | 12.4+ |
|
||||
|
||||
> Local LLMs require a CUDA-capable GPU. The default model (Qwen3.6-35B Q3) uses ~13.6 GB VRAM. CPU-only inference is possible but very slow (not recommended for production use).
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Conda** (Miniconda or Anaconda) - Required for environment management
|
||||
- **Docker** (required for sandboxed code execution)
|
||||
- **Linux** (officially supported; macOS/Windows may work with adjustments)
|
||||
- **Conda** (Miniconda or Anaconda) — required for environment management
|
||||
- **Docker** — required for sandboxed factor/model code execution (`docker run hello-world` to verify)
|
||||
- **llama.cpp** — for local LLM inference (see [llama.cpp build guide](https://github.com/ggml-org/llama.cpp))
|
||||
- **Ollama** — for embeddings (`nomic-embed-text`); install from [ollama.com](https://ollama.com) and run `ollama pull nomic-embed-text`
|
||||
- **Linux** — officially supported; macOS/Windows may work with adjustments
|
||||
|
||||
### Quick Install
|
||||
|
||||
@@ -120,14 +135,71 @@ conda activate predix
|
||||
|
||||
# Install in editable mode
|
||||
pip install -e .
|
||||
|
||||
# Verify Docker is accessible
|
||||
docker run --rm hello-world
|
||||
```
|
||||
|
||||
> **Important:** Predix requires a conda environment to manage dependencies properly.
|
||||
> Using plain Python or other environment managers may cause conflicts.
|
||||
|
||||
### Configuration
|
||||
---
|
||||
|
||||
## Data Setup
|
||||
|
||||
Predix requires **1-minute EUR/USD OHLCV data** in HDF5 format. This is a hard prerequisite — the system cannot run without it.
|
||||
|
||||
### Step 1: Get the data
|
||||
|
||||
Download 1-minute EUR/USD data (2020–present) from any of these free sources:
|
||||
|
||||
| Source | Cost | Notes |
|
||||
|--------|------|-------|
|
||||
| **[Dukascopy](https://www.dukascopy.com/swiss/english/marketfeed/historical/)** | Free | Best quality free EUR/USD tick data |
|
||||
| **[OANDA API](https://developer.oanda.com/)** | Free (demo) | Requires API key, programmatic access |
|
||||
| **[TrueFX](https://truefx.com/)** | Free | Institutional-quality tick data |
|
||||
| **[Kaggle](https://www.kaggle.com/datasets?search=EURUSD+1min)** | Free | Search "EURUSD 1 minute" |
|
||||
| **MetaTrader 5** | Free | Export via `copy_rates_range()` |
|
||||
|
||||
### Step 2: Convert to HDF5
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
|
||||
df = pd.read_csv('eurusd_1min.csv', parse_dates=['datetime'])
|
||||
df = df.rename(columns={'open': '$open', 'close': '$close',
|
||||
'high': '$high', 'low': '$low', 'volume': '$volume'})
|
||||
df['instrument'] = 'EURUSD'
|
||||
df = df.set_index(['datetime', 'instrument'])
|
||||
for col in ['$open', '$close', '$high', '$low', '$volume']:
|
||||
df[col] = df[col].astype('float32')
|
||||
|
||||
import os
|
||||
os.makedirs('git_ignore_folder/factor_implementation_source_data', exist_ok=True)
|
||||
df.to_hdf('git_ignore_folder/factor_implementation_source_data/intraday_pv.h5', key='data', mode='w')
|
||||
```
|
||||
|
||||
### Required HDF5 format
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| **Index** | MultiIndex `(datetime, instrument)` | Timestamp + currency pair |
|
||||
| **`$open`** | float32 | Open price |
|
||||
| **`$close`** | float32 | Close price |
|
||||
| **`$high`** | float32 | High price |
|
||||
| **`$low`** | float32 | Low price |
|
||||
| **`$volume`** | float32 | Tick volume |
|
||||
|
||||
**Save location:** `git_ignore_folder/factor_implementation_source_data/intraday_pv.h5`
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Setup
|
||||
|
||||
Create a `.env` file in the project root:
|
||||
|
||||
1. **Create `.env` file:**
|
||||
```bash
|
||||
# Local LLM (llama.cpp)
|
||||
OPENAI_API_KEY=local
|
||||
@@ -143,7 +215,8 @@ EMBEDDING_MODEL=nomic-embed-text
|
||||
QLIB_DATA_DIR=~/.qlib/qlib_data/eurusd_1min_data
|
||||
```
|
||||
|
||||
2. **Start LLM server (llama.cpp):**
|
||||
### LLM Server (llama.cpp)
|
||||
|
||||
```bash
|
||||
~/llama.cpp/build/bin/llama-server \
|
||||
--model ~/models/qwen3.6/Qwen3.6-35B-A3B-UD-Q3_K_XL.gguf \
|
||||
@@ -158,56 +231,110 @@ QLIB_DATA_DIR=~/.qlib/qlib_data/eurusd_1min_data
|
||||
--reasoning off
|
||||
```
|
||||
|
||||
> **Important flags and token budget:**
|
||||
> - `--ctx-size 240000 --parallel 2` — allocates **2 slots × 120,000 tokens each**. `fin_quant` prompts can reach 80k+ tokens with full factor history; a smaller slot causes silent overflow and empty/invalid responses.
|
||||
>
|
||||
> **Token budget breakdown per fin_quant request:**
|
||||
> | Component | Approx. tokens |
|
||||
> |---|---|
|
||||
> | System prompt + scenario description | ~3,000 |
|
||||
> | `MAX_FACTOR_HISTORY=5` past experiments × ~2,500 | ~12,500 |
|
||||
> | RAG context + instructions | ~2,000 |
|
||||
> | **Total** | **~17,500** (well within 120k slot) |
|
||||
>
|
||||
> Formula: `ctx_size / parallel` must satisfy `n_ctx_slot > MAX_FACTOR_HISTORY × 2500 + 5000`.
|
||||
>
|
||||
> - `--reasoning off` — **critical**: completely disables Qwen3 chain-of-thought. `--reasoning-budget 0` is not sufficient — it still starts and immediately aborts reasoning, producing empty JSON responses. Only `--reasoning off` prevents this entirely.
|
||||
> **Important flags:**
|
||||
> - `--ctx-size 240000 --parallel 2` — allocates **2 slots × 120,000 tokens each**. `fin_quant` prompts can reach 80k+ tokens with full factor history; a smaller slot causes silent overflow and empty responses.
|
||||
> - `--reasoning off` — **critical**: completely disables Qwen3 chain-of-thought. `--reasoning-budget 0` is not sufficient and produces empty JSON responses.
|
||||
> - `--n-gpu-layers 24` — 4 fewer than maximum on RTX 5060 Ti (16 GB), freeing ~500 MB VRAM for the larger KV cache.
|
||||
> - `-ctk q4_0 -ctv q4_0` — quantises the KV cache to 4-bit, reducing VRAM from ~5 GB to ~1.3 GB at 240k context.
|
||||
|
||||
### Data Configuration
|
||||
|
||||
Edit [`data_config.yaml`](data_config.yaml) to customize walk-forward splits:
|
||||
|
||||
```yaml
|
||||
instrument: EURUSD
|
||||
frequency: 1min
|
||||
data_path: ~/.qlib/qlib_data/eurusd_1min_data
|
||||
|
||||
train_start: "2022-03-14"
|
||||
train_end: "2024-06-30"
|
||||
valid_start: "2024-07-01"
|
||||
valid_end: "2024-12-31"
|
||||
test_start: "2025-01-01"
|
||||
test_end: "2026-03-20"
|
||||
|
||||
market_context:
|
||||
spread_bps: 1.5
|
||||
target_arr: 9.62
|
||||
max_drawdown: 20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## No GPU? Use OpenRouter
|
||||
|
||||
If you don't have a CUDA-capable GPU, you can run Predix using [OpenRouter](https://openrouter.ai) for LLM inference — no local model download required.
|
||||
|
||||
**1. Set up `.env` for OpenRouter:**
|
||||
|
||||
```bash
|
||||
# Chat (OpenRouter)
|
||||
OPENAI_API_KEY=sk-or-v1-<your-openrouter-key>
|
||||
OPENAI_API_BASE=https://openrouter.ai/api/v1
|
||||
CHAT_MODEL=qwen/qwen3-235b-a22b
|
||||
|
||||
# Embedding (Ollama — still required locally)
|
||||
LITELLM_PROXY_API_KEY=local
|
||||
LITELLM_PROXY_API_BASE=http://localhost:11434/v1
|
||||
EMBEDDING_MODEL=nomic-embed-text
|
||||
```
|
||||
|
||||
**2. Skip the llama-server step** — no local LLM server needed.
|
||||
|
||||
**3. Run with the OpenRouter backend:**
|
||||
|
||||
```bash
|
||||
rdagent fin_quant --model openrouter
|
||||
```
|
||||
|
||||
**4. Parallel runs** (uses API concurrency instead of GPU slots):
|
||||
|
||||
```bash
|
||||
python predix_parallel.py --runs 5 --api-keys 1 -m openrouter
|
||||
```
|
||||
|
||||
> Ollama is still required for embeddings even in the OpenRouter path. Install from [ollama.com](https://ollama.com) and run `ollama pull nomic-embed-text` once.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites checklist
|
||||
|
||||
```bash
|
||||
# 1. Docker running?
|
||||
docker run --rm hello-world
|
||||
|
||||
# 2. Data in place?
|
||||
ls git_ignore_folder/factor_implementation_source_data/intraday_pv.h5
|
||||
|
||||
# 3. LLM server running?
|
||||
curl http://localhost:8081/health
|
||||
```
|
||||
|
||||
### 1. Run Trading Loop
|
||||
|
||||
```bash
|
||||
# Activate conda environment
|
||||
conda activate predix
|
||||
|
||||
# Start EURUSD trading loop
|
||||
rdagent fin_quant
|
||||
|
||||
# With options
|
||||
# or with explicit options:
|
||||
rdagent fin_quant --loop-n 5 --step-n 2
|
||||
```
|
||||
|
||||
### 2. Monitor Results
|
||||
|
||||
```bash
|
||||
# Start the UI dashboard
|
||||
# Web dashboard
|
||||
rdagent server_ui --port 19899 --log-dir git_ignore_folder/RD-Agent_workspace/
|
||||
# then open http://127.0.0.1:19899
|
||||
|
||||
# Or open in browser
|
||||
# http://127.0.0.1:19899
|
||||
# Best strategies so far
|
||||
python predix.py best
|
||||
```
|
||||
|
||||
### 3. Loop Continuously
|
||||
|
||||
To run the trading loop continuously with auto-restart:
|
||||
### 3. Run Continuously
|
||||
|
||||
```bash
|
||||
# Simple loop
|
||||
while true; do
|
||||
rdagent fin_quant
|
||||
sleep 5
|
||||
@@ -218,39 +345,36 @@ done
|
||||
|
||||
## CLI Commands
|
||||
|
||||
### Trading Loop
|
||||
### Factor & Strategy Loop
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `rdagent fin_quant` | Start factor evolution loop |
|
||||
| `rdagent fin_quant --loop-n 5` | Run 5 evolution loops |
|
||||
| `rdagent fin_quant` | Start autonomous factor + model evolution loop |
|
||||
| `rdagent fin_quant --loop-n 5` | Run exactly 5 evolution loops |
|
||||
| `rdagent fin_quant --with-dashboard` | Start with web dashboard |
|
||||
| `rdagent fin_quant --cli-dashboard` | Start with CLI Rich dashboard |
|
||||
|
||||
### Parallel Execution
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `python predix_parallel.py --runs 5 --api-keys 1 -m openrouter` | Run 5 parallel factor evolutions |
|
||||
| `python predix_parallel.py --runs 20 --api-keys 2 -m openrouter` | Run 20 runs with 2 API keys |
|
||||
|
||||
### AI Strategy Generation (with REAL OHLCV Backtest)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `python predix_gen_strategies_real_bt.py` | Generate 10 strategies with LLM + real backtest |
|
||||
| `python predix_gen_strategies_real_bt.py 20` | Generate 20 strategies |
|
||||
| `python predix_gen_strategies_real_bt.py 5` | Generate 5 strategies (faster) |
|
||||
| `rdagent fin_factor` | Factor-only evolution |
|
||||
| `rdagent fin_model` | Model-only evolution |
|
||||
|
||||
### Strategy Reports
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `python predix.py best` | Show top strategies by composite score (Sharpe × DD × trade penalty) |
|
||||
| `python predix.py best` | Show top strategies by composite score |
|
||||
| `python predix.py best -n 20 -m sharpe` | Top 20 by Sharpe ratio |
|
||||
| `python predix.py best --show NAME` | Full metadata for one strategy |
|
||||
| `python predix_strategy_report.py` | Generate reports for ALL strategies |
|
||||
| `python predix_strategy_report.py results/strategies_new/123_MyStrategy.json` | Report for single strategy |
|
||||
| `python predix_gen_strategies_real_bt.py` | Generate 10 strategies with LLM + real backtest |
|
||||
| `python predix_gen_strategies_real_bt.py 20` | Generate 20 strategies |
|
||||
|
||||
### Kronos Foundation Model
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `python predix.py kronos-factor` | Generate Kronos predicted-return factor (daily stride, ~15 min GPU) |
|
||||
| `python predix.py kronos-factor --pred 30` | 30-bar prediction horizon |
|
||||
| `python predix.py kronos-factor --device cpu` | CPU inference (slower) |
|
||||
| `python predix.py kronos-eval` | Evaluate Kronos IC / hit rate vs LightGBM baseline |
|
||||
| `python predix.py kronos-eval --pred 96` | Daily horizon evaluation |
|
||||
|
||||
### Factor Evaluation
|
||||
|
||||
@@ -260,62 +384,21 @@ done
|
||||
| `python predix.py top -n 20` | Show top 20 factors by IC |
|
||||
| `python predix.py portfolio-simple` | Simple portfolio optimization |
|
||||
|
||||
### Other Utilities
|
||||
### Parallel Execution
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `python predix_parallel.py --runs 5 --api-keys 1 -m openrouter` | Run 5 parallel factor evolutions |
|
||||
| `python predix_parallel.py --runs 20 --api-keys 2 -m openrouter` | Run 20 runs with 2 API keys |
|
||||
|
||||
### Monitoring & Debug
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `rdagent server_ui --port 19899 --log-dir <path>` | Start web dashboard |
|
||||
| `rdagent health_check` | Validate environment setup |
|
||||
| `python predix_batch_backtest.py` | Batch backtest multiple factors |
|
||||
| `python predix_parallel.py` | Parallel factor evolution |
|
||||
| `python predix_rebacktest_strategies.py` | Re-backtest existing strategies |
|
||||
| `python debug_backtest.py` | Debug backtest alignment & IC |
|
||||
|
||||
### Environment Options
|
||||
|
||||
| Env Variable | Description | Example |
|
||||
|--------------|-------------|---------|
|
||||
| `OPENROUTER_API_KEY` | OpenRouter API key | `sk-or-v1-...` |
|
||||
| `OPENAI_API_KEY` | Alternative: OpenAI key | `sk-...` |
|
||||
| `CHAT_MODEL` | LLM model | `openrouter/qwen/qwen3.6-plus:free` |
|
||||
| `OPENROUTER_MODEL` | Specific OpenRouter model | `openrouter/qwen/qwen3.6-plus:free` |
|
||||
| `NO_COLOR` | Disable ANSI colors | `1` |
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Data Configuration
|
||||
|
||||
Edit [`data_config.yaml`](data_config.yaml) to customize:
|
||||
|
||||
```yaml
|
||||
instrument: EURUSD
|
||||
frequency: 1min
|
||||
data_path: ~/.qlib/qlib_data/eurusd_1min_data
|
||||
|
||||
# Walk-forward split
|
||||
train_start: "2022-03-14"
|
||||
train_end: "2024-06-30"
|
||||
valid_start: "2024-07-01"
|
||||
valid_end: "2024-12-31"
|
||||
test_start: "2025-01-01"
|
||||
test_end: "2026-03-20"
|
||||
|
||||
# Market context for LLM prompts
|
||||
market_context:
|
||||
spread_bps: 1.5
|
||||
target_arr: 9.62 # Target annual return (%)
|
||||
max_drawdown: 20 # Max drawdown (%)
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `CHAT_MODEL` | LLM for reasoning | `gpt-4o`, `deepseek-chat` |
|
||||
| `EMBEDDING_MODEL` | Embedding model | `text-embedding-3-small` |
|
||||
| `OPENAI_API_KEY` | API key for OpenAI | `sk-...` |
|
||||
| `DEEPSEEK_API_KEY` | API key for DeepSeek | `sk-...` |
|
||||
| `DS_LOCAL_DATA_PATH` | Local data directory | `./data` |
|
||||
|
||||
---
|
||||
|
||||
@@ -363,13 +446,32 @@ Real-time dashboard for monitoring:
|
||||
- Cumulative returns and drawdowns
|
||||
- Code diffs and implementation history
|
||||
|
||||
### 🤖 Kronos Foundation Model Integration
|
||||
|
||||
Predix integrates [Kronos-mini](https://github.com/shiyu-coder/Kronos) — a 4.1M parameter OHLCV foundation model pretrained on 12+ billion K-lines from 45 global exchanges (AAAI 2026, MIT):
|
||||
|
||||
- **Option A — Alpha Factor**: Rolling daily inference generates a `KronosPredReturn` factor. Every 96 bars (one trading day), Kronos predicts the next day's return from the previous 512 bars of EUR/USD OHLCV data. The factor is forward-filled to 1-min frequency and plugs directly into Predix's factor evaluation pipeline.
|
||||
|
||||
- **Option B — Model Evaluation**: Kronos runs alongside LightGBM as a standalone predictor. IC (Information Coefficient), IC IR, and directional hit rate are computed over the full dataset for direct comparison with LightGBM-generated models.
|
||||
|
||||
```bash
|
||||
# One-time setup
|
||||
git clone https://github.com/shiyu-coder/Kronos ~/Kronos
|
||||
|
||||
# Generate factor (Option A) — saves to results/factors/
|
||||
python predix.py kronos-factor
|
||||
|
||||
# Evaluate as model (Option B) — prints IC vs LightGBM reference
|
||||
python predix.py kronos-eval
|
||||
```
|
||||
|
||||
### 🔒 Security & Quality
|
||||
|
||||
Automated quality assurance:
|
||||
|
||||
- **60 Integration Tests** - All features tested automatically
|
||||
- **Bandit Security Scanner** - Pre-commit security checks
|
||||
- **Pre-commit Hooks** - Tests run before EVERY commit
|
||||
- **134+ Tests** — all features tested automatically on every commit
|
||||
- **Bandit Security Scanner** — pre-commit security checks
|
||||
- **Weekly Dependency Audit** — automated vulnerability scan via GitHub Actions
|
||||
|
||||
---
|
||||
|
||||
@@ -382,118 +484,23 @@ predix/
|
||||
│ ├── components/ # Reusable agent components
|
||||
│ │ ├── backtesting/ # Backtest engine & protections
|
||||
│ │ │ ├── backtest_engine.py
|
||||
│ │ │ ├── vbt_backtest.py # Unified backtest engine
|
||||
│ │ │ ├── results_db.py
|
||||
│ │ │ ├── risk_management.py
|
||||
│ │ │ └── protections/ # Trading protection system (NEW)
|
||||
│ │ │ ├── base.py
|
||||
│ │ │ ├── max_drawdown.py
|
||||
│ │ │ ├── cooldown.py
|
||||
│ │ │ ├── stoploss_guard.py
|
||||
│ │ │ ├── low_performance.py
|
||||
│ │ │ └── protection_manager.py
|
||||
│ │ ├── coder/ # Factor & model coding
|
||||
│ │ └── loader.py # Prompt & model loaders
|
||||
│ │ │ └── protections/ # Trading protection system
|
||||
│ │ └── coder/ # Factor & model coding (CoSTEER + Optuna)
|
||||
│ ├── core/ # Core abstractions
|
||||
│ ├── scenarios/ # Domain-specific scenarios
|
||||
│ └── utils/ # Utilities
|
||||
├── test/ # Test suite
|
||||
│ ├── integration/ # Integration tests (60 tests)
|
||||
│ │ └── test_all_features.py
|
||||
│ └── backtesting/ # Unit tests
|
||||
│ └── test_protections.py
|
||||
├── constraints/ # Constraint definitions
|
||||
├── docs/ # Documentation
|
||||
├── test/ # Test suite (134 tests)
|
||||
│ └── backtesting/ # Backtest unit tests
|
||||
├── web/ # Web UI frontend
|
||||
├── data_config.yaml # Data configuration
|
||||
├── data_config.yaml # Walk-forward split configuration
|
||||
├── pyproject.toml # Project metadata
|
||||
└── requirements.txt # Dependencies
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Setup
|
||||
|
||||
Predix requires **1-minute EUR/USD OHLCV data** in HDF5 format.
|
||||
|
||||
### Required Format
|
||||
|
||||
The data file must be saved as `intraday_pv.h5` with the following structure:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| **Index** | MultiIndex `(datetime, instrument)` | Timestamp + currency pair |
|
||||
| **`$open`** | float32 | Open price |
|
||||
| **`$close`** | float32 | Close price |
|
||||
| **`$high`** | float32 | High price |
|
||||
| **`$low`** | float32 | Low price |
|
||||
| **`$volume`** | float32 | Tick volume |
|
||||
|
||||
**Save location:** `git_ignore_folder/factor_implementation_source_data/intraday_pv.h5`
|
||||
|
||||
### Where to Get Data
|
||||
|
||||
| Source | Cost | Notes |
|
||||
|--------|------|-------|
|
||||
| **[Dukascopy](https://www.dukascopy.com/swiss/english/marketfeed/historical/)** | Free | Best free EUR/USD tick data |
|
||||
| **[OANDA API](https://developer.oanda.com/)** | Free (demo) | Requires API key |
|
||||
| **[TrueFX](https://truefx.com/)** | Free | Institutional-quality data |
|
||||
| **[Kaggle](https://www.kaggle.com/datasets?search=EURUSD+1min)** | Free | Search "EURUSD 1 minute" |
|
||||
| **MetaTrader 5** | Free | Export via `copy_rates_range()` |
|
||||
|
||||
### Quick CSV Conversion
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
|
||||
df = pd.read_csv('eurusd_1min.csv', parse_dates=['datetime'])
|
||||
df = df.rename(columns={'open': '$open', 'close': '$close',
|
||||
'high': '$high', 'low': '$low', 'volume': '$volume'})
|
||||
df['instrument'] = 'EURUSD'
|
||||
df = df.set_index(['datetime', 'instrument'])
|
||||
for col in ['$open', '$close', '$high', '$low', '$volume']:
|
||||
df[col] = df[col].astype('float32')
|
||||
df.to_hdf('intraday_pv.h5', key='data', mode='w')
|
||||
```
|
||||
|
||||
Expected data columns: `$open`, `$close`, `$high`, `$low`, `$volume`
|
||||
|
||||
---
|
||||
|
||||
## CLI Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `rdagent fin_quant` | Full factor & model co-evolution |
|
||||
| `rdagent fin_factor` | Factor-only evolution |
|
||||
| `rdagent fin_model` | Model-only evolution |
|
||||
| `rdagent fin_factor_report --report-folder=<path>` | Extract factors from financial reports |
|
||||
| `rdagent general_model <paper-url>` | Extract model from research paper |
|
||||
| `rdagent rl_trading --mode train --algorithm PPO` | Train RL trading agent |
|
||||
| `rdagent rl_trading --mode backtest --model-path <path>` | Backtest with trained RL model |
|
||||
| `rdagent data_science --competition <name>` | Kaggle/data science competition mode |
|
||||
| `rdagent ui --port 19899 --log-dir <path>` | Start monitoring dashboard |
|
||||
| `rdagent health_check` | Validate environment setup |
|
||||
|
||||
### RL Trading Examples
|
||||
|
||||
```bash
|
||||
# Train new RL agent with PPO
|
||||
rdagent rl_trading --mode train --algorithm PPO --total-timesteps 100000
|
||||
|
||||
# Backtest with trained model
|
||||
rdagent rl_trading --mode backtest --model-path models/rl_trader.zip
|
||||
|
||||
# Disable trading protections (not recommended)
|
||||
rdagent rl_trading --mode backtest --no-with-protections
|
||||
|
||||
# Get help
|
||||
rdagent rl_trading --help
|
||||
```
|
||||
|
||||
**Note:** RL Trading works without `stable-baselines3` (uses simple fallback strategy). For full RL features, install: `pip install -r requirements/rl.txt`
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
Core dependencies (see [`requirements.txt`](requirements.txt) for full list):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
azure-identity==1.17.1
|
||||
dill==0.3.9
|
||||
azure-identity==1.25.3
|
||||
dill==0.4.1
|
||||
pillow==10.4.0
|
||||
psutil==6.1.0
|
||||
scipy==1.14.1
|
||||
psutil==6.1.1
|
||||
scipy==1.15.3
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
azure-identity==1.17.1
|
||||
dill==0.3.9
|
||||
azure-identity==1.25.3
|
||||
dill==0.4.1
|
||||
pillow==10.4.0
|
||||
psutil==6.1.0
|
||||
scipy==1.14.1
|
||||
psutil==6.1.1
|
||||
scipy==1.15.3
|
||||
|
||||
@@ -22,6 +22,98 @@ app = typer.Typer(help="Predix - AI Quantitative Trading Agent")
|
||||
console = Console()
|
||||
|
||||
|
||||
def _ensure_kronos_factor_in_pool(con) -> None:
|
||||
"""Auto-generate Kronos factor and register it in the StrategyOrchestrator pool.
|
||||
|
||||
Runs before fin_quant starts. If the Kronos parquet already exists in
|
||||
results/factors/values/ and has a matching JSON with ic, it's a no-op.
|
||||
Otherwise, generates the factor (stride=500 for speed) and computes IC.
|
||||
"""
|
||||
import json as _json
|
||||
from datetime import datetime as _dt
|
||||
|
||||
data_path = Path("git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
|
||||
if not data_path.exists():
|
||||
return # No data — skip silently
|
||||
|
||||
factor_name = "KronosPredReturn_p96"
|
||||
factors_dir = Path("results/factors")
|
||||
values_dir = factors_dir / "values"
|
||||
json_path = factors_dir / f"{factor_name}.json"
|
||||
parquet_path = values_dir / f"{factor_name}.parquet"
|
||||
|
||||
# Already in pool with IC — nothing to do
|
||||
if json_path.exists() and parquet_path.exists():
|
||||
try:
|
||||
existing = _json.loads(json_path.read_text())
|
||||
if existing.get("ic") is not None:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
con.print("\n[bold yellow]Kronos Factor[/bold yellow] not in pool — generating automatically...")
|
||||
con.print(" [dim]stride=500 (~4500 windows), batch=32 — ~5-10 min on GPU[/dim]")
|
||||
|
||||
try:
|
||||
from rdagent.components.coder.kronos_adapter import _cuda_available, build_kronos_factor, evaluate_kronos_model
|
||||
|
||||
_device = "cuda" if _cuda_available() else "cpu"
|
||||
|
||||
# Generate factor values
|
||||
factor_df = build_kronos_factor(
|
||||
hdf5_path=data_path,
|
||||
context_bars=100,
|
||||
pred_bars=96,
|
||||
stride_bars=500,
|
||||
device=_device,
|
||||
batch_size=32,
|
||||
)
|
||||
|
||||
# Save parquet to values/ directory (where StrategyOrchestrator looks)
|
||||
values_dir.mkdir(parents=True, exist_ok=True)
|
||||
factor_df.to_parquet(parquet_path)
|
||||
|
||||
# Quick IC evaluation (stride=2000 → ~1100 windows, fast)
|
||||
con.print(" [dim]Computing IC...[/dim]")
|
||||
metrics = evaluate_kronos_model(
|
||||
hdf5_path=data_path,
|
||||
context_bars=100,
|
||||
pred_bars=96,
|
||||
stride_bars=2000,
|
||||
device=_device,
|
||||
batch_size=32,
|
||||
)
|
||||
ic = metrics.get("IC_mean", 0.0) or 0.0
|
||||
hit_rate = metrics.get("hit_rate", 0.5)
|
||||
|
||||
# Write JSON metadata compatible with StrategyOrchestrator
|
||||
factors_dir.mkdir(parents=True, exist_ok=True)
|
||||
meta = {
|
||||
"factor_name": factor_name,
|
||||
"status": "success",
|
||||
"ic": ic,
|
||||
"hit_rate": hit_rate,
|
||||
"model": "NeoQuasar/Kronos-mini",
|
||||
"context_bars": 100,
|
||||
"pred_bars": 96,
|
||||
"stride_bars": 500,
|
||||
"device": _device,
|
||||
"generated_at": _dt.now().isoformat(),
|
||||
"n_bars": len(factor_df),
|
||||
"n_non_nan": int(factor_df["KronosPredReturn"].notna().sum()),
|
||||
}
|
||||
json_path.write_text(_json.dumps(meta, indent=2))
|
||||
|
||||
color = "green" if abs(ic) > 0.01 else "yellow"
|
||||
con.print(
|
||||
f" [bold {color}]Kronos Factor ready:[/bold {color}] IC={ic:.4f}, "
|
||||
f"Hit-Rate={hit_rate:.1%} — added to strategy pool"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
con.print(f" [yellow]Kronos Factor generation failed ({e}) — continuing without it[/yellow]")
|
||||
|
||||
|
||||
@app.command()
|
||||
def quant(
|
||||
model: str = typer.Option(
|
||||
@@ -226,6 +318,9 @@ def quant(
|
||||
threading.Thread(target=start_cli_dash, daemon=True).start()
|
||||
time.sleep(1)
|
||||
|
||||
# ---- Kronos Factor: auto-generate if not in pool ----
|
||||
_ensure_kronos_factor_in_pool(console)
|
||||
|
||||
# ---- Start fin_quant ----
|
||||
from rdagent.app.qlib_rd_loop.quant import main as fin_quant
|
||||
from rdagent.log.daily_log import session as _daily_session
|
||||
@@ -1583,5 +1678,161 @@ def best(
|
||||
console.print(f"[green]Exported {len(top)} strategies (code stripped) → {export}[/green]")
|
||||
|
||||
|
||||
@app.command("kronos-factor")
|
||||
def kronos_factor(
|
||||
context: int = typer.Option(512, "--context", "-c", help="Context window in bars (max 512 for Kronos-mini)"),
|
||||
pred: int = typer.Option(96, "--pred", "-p", help="Prediction horizon in bars (default 96 = 1 trading day at 1-min)"),
|
||||
stride: int = typer.Option(None, "--stride", "-s", help="Stride between windows (default: same as --pred)"),
|
||||
device: str = typer.Option(None, "--device", "-d", help="Device: cuda or cpu (default: auto-detect)"),
|
||||
batch_size: int = typer.Option(32, "--batch-size", "-b", help="Windows per GPU batch (higher = faster on GPU, more VRAM)"),
|
||||
output: str = typer.Option(None, "--output", "-o", help="Output parquet path (default: results/factors/kronos_pred_return_p<pred>.parquet)"),
|
||||
):
|
||||
"""Generate Kronos-mini predicted-return alpha factor (Option A).
|
||||
|
||||
Runs Kronos-mini (4.1M params OHLCV foundation model, AAAI 2026) on rolling
|
||||
windows of EUR/USD 1-min data and saves a predicted-return factor in Predix's
|
||||
standard MultiIndex (datetime, instrument) format.
|
||||
|
||||
Strategy: every STRIDE bars, use the previous CONTEXT bars as input and
|
||||
predict the next PRED bars. Windows are processed in GPU batches of BATCH_SIZE
|
||||
for full GPU utilization (5-20x faster than sequential). Default (--pred 96) =
|
||||
one trading day at 1-min frequency, ~2 000 windows total.
|
||||
|
||||
Requires:
|
||||
~/Kronos repo (git clone https://github.com/shiyu-coder/Kronos ~/Kronos)
|
||||
git_ignore_folder/factor_implementation_source_data/intraday_pv.h5
|
||||
|
||||
Examples:
|
||||
$ predix kronos-factor # Default: daily stride, GPU
|
||||
$ predix kronos-factor --pred 30 --device cpu # 30-bar horizon, CPU
|
||||
$ predix kronos-factor --context 256 --pred 48
|
||||
|
||||
See Also:
|
||||
predix kronos-eval - Evaluate Kronos as model and compute IC vs LightGBM
|
||||
predix top - Show top factors by IC
|
||||
"""
|
||||
from rdagent.components.coder.kronos_adapter import _cuda_available
|
||||
_device = device or ("cuda" if _cuda_available() else "cpu")
|
||||
_stride = stride or pred
|
||||
|
||||
data_path = Path("git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
|
||||
if not data_path.exists():
|
||||
console.print(f"[red]ERROR: Data not found at {data_path}[/red]")
|
||||
console.print("Run data conversion first — see README Data Setup section.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"[bold]Kronos Factor Generator[/bold]")
|
||||
console.print(f" Context: [cyan]{context}[/cyan] bars | Pred: [cyan]{pred}[/cyan] bars | Device: [cyan]{_device}[/cyan]")
|
||||
|
||||
from rdagent.components.coder.kronos_adapter import build_kronos_factor
|
||||
|
||||
factor_df = build_kronos_factor(
|
||||
hdf5_path=data_path,
|
||||
context_bars=context,
|
||||
pred_bars=pred,
|
||||
stride_bars=_stride,
|
||||
device=_device,
|
||||
batch_size=batch_size,
|
||||
)
|
||||
|
||||
out_dir = Path("results/factors")
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = Path(output) if output else out_dir / f"kronos_pred_return_p{pred}.parquet"
|
||||
factor_df.to_parquet(out_path)
|
||||
|
||||
import json as _json
|
||||
from datetime import datetime as _dt
|
||||
meta = {
|
||||
"factor_name": f"KronosPredReturn_p{pred}",
|
||||
"description": f"Kronos-mini predicted return, {pred}-bar horizon",
|
||||
"model": "NeoQuasar/Kronos-mini",
|
||||
"context_bars": context,
|
||||
"pred_bars": pred,
|
||||
"stride_bars": _stride,
|
||||
"device": _device,
|
||||
"generated_at": _dt.now().isoformat(),
|
||||
"n_bars": len(factor_df),
|
||||
"n_non_nan": int(factor_df["KronosPredReturn"].notna().sum()),
|
||||
"parquet_path": str(out_path),
|
||||
}
|
||||
meta_path = out_path.with_suffix(".json")
|
||||
meta_path.write_text(_json.dumps(meta, indent=2))
|
||||
|
||||
console.print(f"\n[green]Factor saved:[/green] {out_path}")
|
||||
console.print(f" Shape: {factor_df.shape} | Non-NaN: {meta['n_non_nan']}")
|
||||
console.print(f" Metadata: {meta_path}")
|
||||
console.print("\n[dim]Use 'predix top' to compare with other factors.[/dim]")
|
||||
|
||||
|
||||
@app.command("kronos-eval")
|
||||
def kronos_eval(
|
||||
context: int = typer.Option(512, "--context", "-c", help="Context window in bars"),
|
||||
pred: int = typer.Option(30, "--pred", "-p", help="Prediction horizon in bars"),
|
||||
stride: int = typer.Option(None, "--stride", "-s", help="Stride between evaluations (default: same as --pred)"),
|
||||
device: str = typer.Option(None, "--device", "-d", help="Device: cuda or cpu (default: auto-detect)"),
|
||||
batch_size: int = typer.Option(32, "--batch-size", "-b", help="Windows per GPU batch (higher = faster on GPU, more VRAM)"),
|
||||
):
|
||||
"""Evaluate Kronos-mini as standalone model — IC and hit rate vs LightGBM (Option B).
|
||||
|
||||
Runs Kronos inference on the full EUR/USD dataset and computes:
|
||||
- IC (Information Coefficient): correlation between predicted and actual returns
|
||||
- IC IR: IC / std — risk-adjusted signal strength (>0.5 = good)
|
||||
- Hit Rate: directional accuracy (>50% = useful signal)
|
||||
|
||||
Results are printed and saved to results/kronos/ for comparison with LightGBM
|
||||
models generated by fin_quant.
|
||||
|
||||
Requires:
|
||||
~/Kronos repo (git clone https://github.com/shiyu-coder/Kronos ~/Kronos)
|
||||
git_ignore_folder/factor_implementation_source_data/intraday_pv.h5
|
||||
|
||||
Examples:
|
||||
$ predix kronos-eval # Default: 30-bar horizon
|
||||
$ predix kronos-eval --pred 96 --device cuda # Daily horizon, GPU
|
||||
$ predix kronos-eval --context 256 --pred 15 # Shorter horizon
|
||||
|
||||
See Also:
|
||||
predix kronos-factor - Generate Kronos factor for the factor pipeline
|
||||
predix best - Show top strategies
|
||||
"""
|
||||
from rdagent.components.coder.kronos_adapter import _cuda_available
|
||||
_device = device or ("cuda" if _cuda_available() else "cpu")
|
||||
_stride = stride or pred
|
||||
|
||||
data_path = Path("git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
|
||||
if not data_path.exists():
|
||||
console.print(f"[red]ERROR: Data not found at {data_path}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"[bold]Kronos Model Evaluator[/bold] (alongside LightGBM)")
|
||||
console.print(f" Context: [cyan]{context}[/cyan] bars | Pred: [cyan]{pred}[/cyan] bars | Device: [cyan]{_device}[/cyan]")
|
||||
console.print(" Running evaluation...")
|
||||
|
||||
from rdagent.components.coder.kronos_adapter import evaluate_kronos_model
|
||||
|
||||
metrics = evaluate_kronos_model(
|
||||
hdf5_path=data_path,
|
||||
context_bars=context,
|
||||
pred_bars=pred,
|
||||
stride_bars=_stride,
|
||||
device=_device,
|
||||
batch_size=batch_size,
|
||||
)
|
||||
|
||||
console.print(f"\n[bold]Kronos-mini Results[/bold]")
|
||||
console.print(f" Predictions: [cyan]{metrics['n_predictions']}[/cyan]")
|
||||
console.print(f" IC (mean): [{'green' if metrics['IC_mean'] > 0.02 else 'yellow'}]{metrics['IC_mean']:.4f}[/]")
|
||||
console.print(f" IC IR: [{'green' if metrics['IC_IR'] > 0.5 else 'yellow'}]{metrics['IC_IR']:.4f}[/] (>0.5 = strong signal)")
|
||||
console.print(f" Hit Rate: [{'green' if metrics['hit_rate'] > 0.52 else 'yellow'}]{metrics['hit_rate']:.2%}[/] (>50% = directionally useful)")
|
||||
console.print(f"\n[dim]Reference: LightGBM baseline IC typically 0.01–0.05 on 1-min EUR/USD[/dim]")
|
||||
|
||||
import json as _json
|
||||
out_dir = Path("results/kronos")
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = out_dir / f"kronos_eval_ctx{context}_pred{pred}.json"
|
||||
out_path.write_text(_json.dumps({**metrics, "context_bars": context, "pred_bars": pred}, indent=2))
|
||||
console.print(f"\n[green]Results saved:[/green] {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ ignore_missing_imports = true
|
||||
module = "llama"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-l -s --durations=0"
|
||||
addopts = "-l -s --durations=0 -m 'not slow'"
|
||||
log_cli = true
|
||||
log_cli_level = "info"
|
||||
log_date_format = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
@@ -5,13 +5,29 @@ from .risk_management import CorrelationAnalyzer, PortfolioOptimizer, AdvancedRi
|
||||
from .vbt_backtest import (
|
||||
DEFAULT_BARS_PER_YEAR,
|
||||
DEFAULT_TXN_COST_BPS,
|
||||
FTMO_INITIAL_CAPITAL,
|
||||
FTMO_MAX_DAILY_LOSS,
|
||||
FTMO_MAX_TOTAL_LOSS,
|
||||
FTMO_MAX_LEVERAGE,
|
||||
FTMO_RISK_PER_TRADE,
|
||||
OOS_START_DEFAULT,
|
||||
WF_IS_YEARS,
|
||||
WF_OOS_YEARS,
|
||||
WF_STEP_YEARS,
|
||||
backtest_from_forward_returns,
|
||||
backtest_signal,
|
||||
backtest_signal_ftmo,
|
||||
monte_carlo_trade_pvalue,
|
||||
walk_forward_rolling,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'BacktestMetrics', 'FactorBacktester', 'ResultsDatabase',
|
||||
'CorrelationAnalyzer', 'PortfolioOptimizer', 'AdvancedRiskManager',
|
||||
'backtest_signal', 'backtest_from_forward_returns',
|
||||
'backtest_signal', 'backtest_signal_ftmo', 'backtest_from_forward_returns',
|
||||
'monte_carlo_trade_pvalue', 'walk_forward_rolling',
|
||||
'DEFAULT_BARS_PER_YEAR', 'DEFAULT_TXN_COST_BPS',
|
||||
'FTMO_INITIAL_CAPITAL', 'FTMO_MAX_DAILY_LOSS', 'FTMO_MAX_TOTAL_LOSS',
|
||||
'FTMO_MAX_LEVERAGE', 'FTMO_RISK_PER_TRADE', 'OOS_START_DEFAULT',
|
||||
'WF_IS_YEARS', 'WF_OOS_YEARS', 'WF_STEP_YEARS',
|
||||
]
|
||||
|
||||
@@ -32,10 +32,22 @@ except ImportError:
|
||||
VBT_AVAILABLE = False
|
||||
|
||||
|
||||
DEFAULT_TXN_COST_BPS = 1.5
|
||||
# 2.35 pip realistic EUR/USD cost: 1.5 spread + 0.5 slippage + 0.35 commission
|
||||
# At EUR/USD ≈ 1.10: 2.35 pip * (0.0001/1.10) ≈ 2.14 bps of notional.
|
||||
DEFAULT_TXN_COST_BPS = 2.14
|
||||
DEFAULT_BARS_PER_YEAR = 252 * 1440 # 252 trading days * 1440 min/day = 362,880
|
||||
EXTREME_BAR_THRESHOLD = 0.05 # |ret| > 5% on a single 1-min bar → suspicious
|
||||
|
||||
# FTMO 100k account rules (enforced in backtest_signal when ftmo=True)
|
||||
FTMO_INITIAL_CAPITAL = 100_000.0
|
||||
FTMO_MAX_DAILY_LOSS = 0.05 # 5% of initial → block new trades rest of day
|
||||
FTMO_MAX_TOTAL_LOSS = 0.10 # 10% of initial → simulation ends
|
||||
# Risk-based position sizing: 0.5% equity risk per trade, 10-pip stop, max 1:30 leverage
|
||||
FTMO_RISK_PER_TRADE = 0.005
|
||||
FTMO_STOP_PIPS = 10
|
||||
FTMO_PIP = 0.0001
|
||||
FTMO_MAX_LEVERAGE = 30
|
||||
|
||||
|
||||
def _compute_trade_pnl(position: pd.Series, strategy_returns: pd.Series) -> pd.Series:
|
||||
"""
|
||||
@@ -259,6 +271,332 @@ def backtest_signal(
|
||||
return result
|
||||
|
||||
|
||||
def _apply_ftmo_mask(
|
||||
signal: pd.Series,
|
||||
close: pd.Series,
|
||||
leverage: float,
|
||||
txn_cost_bps: float,
|
||||
) -> tuple[pd.Series, dict]:
|
||||
"""
|
||||
Apply FTMO daily/total loss rules to a signal series.
|
||||
|
||||
Returns a masked signal (positions zeroed after each limit breach) and
|
||||
a dict of FTMO compliance metrics.
|
||||
"""
|
||||
txn_cost = txn_cost_bps / 10_000.0
|
||||
position = signal.shift(1).fillna(0) * leverage
|
||||
bar_ret = close.pct_change().fillna(0)
|
||||
|
||||
equity = FTMO_INITIAL_CAPITAL
|
||||
peak_day = FTMO_INITIAL_CAPITAL
|
||||
masked = signal.copy()
|
||||
|
||||
daily_breaches = 0
|
||||
total_breached = False
|
||||
total_breach_ts: Optional[pd.Timestamp] = None
|
||||
current_day = None
|
||||
day_start_eq = FTMO_INITIAL_CAPITAL
|
||||
|
||||
pos_prev = 0.0
|
||||
for ts, sig_i in signal.items():
|
||||
day = ts.date() if hasattr(ts, "date") else ts
|
||||
|
||||
if day != current_day:
|
||||
current_day = day
|
||||
day_start_eq = equity
|
||||
|
||||
pos_i = float(signal.at[ts]) * leverage
|
||||
ret_i = float(bar_ret.get(ts, 0.0))
|
||||
cost_i = abs(pos_i - pos_prev) * txn_cost
|
||||
ret_net = pos_prev * ret_i - cost_i
|
||||
equity = equity * (1.0 + ret_net / FTMO_INITIAL_CAPITAL * FTMO_INITIAL_CAPITAL / equity
|
||||
if equity > 0 else 1.0)
|
||||
# Simpler: track as fraction
|
||||
equity += FTMO_INITIAL_CAPITAL * ret_net
|
||||
pos_prev = pos_i
|
||||
|
||||
if total_breached:
|
||||
masked.at[ts] = 0
|
||||
continue
|
||||
|
||||
daily_loss = (equity - day_start_eq) / FTMO_INITIAL_CAPITAL
|
||||
total_loss = (equity - FTMO_INITIAL_CAPITAL) / FTMO_INITIAL_CAPITAL
|
||||
|
||||
if daily_loss < -FTMO_MAX_DAILY_LOSS:
|
||||
daily_breaches += 1
|
||||
day_start_eq = -999 # block rest of day
|
||||
masked.at[ts] = 0
|
||||
|
||||
if total_loss < -FTMO_MAX_TOTAL_LOSS:
|
||||
total_breached = True
|
||||
total_breach_ts = ts
|
||||
masked.at[ts] = 0
|
||||
|
||||
return masked, {
|
||||
"ftmo_daily_breaches": daily_breaches,
|
||||
"ftmo_total_breached": total_breached,
|
||||
"ftmo_total_breach_ts": str(total_breach_ts) if total_breach_ts else None,
|
||||
"ftmo_compliant": not total_breached and daily_breaches == 0,
|
||||
}
|
||||
|
||||
|
||||
OOS_START_DEFAULT = "2024-01-01"
|
||||
|
||||
# Rolling walk-forward default windows (IS years, OOS years, step years)
|
||||
WF_IS_YEARS = 3
|
||||
WF_OOS_YEARS = 1
|
||||
WF_STEP_YEARS = 1
|
||||
|
||||
|
||||
def monte_carlo_trade_pvalue(
|
||||
trade_pnl: pd.Series,
|
||||
n_permutations: int = 1000,
|
||||
seed: int = 0,
|
||||
) -> float:
|
||||
"""
|
||||
Monte Carlo permutation test on trade-level P&L.
|
||||
|
||||
Runs a one-sided binomial test on trade-level win rate.
|
||||
|
||||
Tests H0: win_rate = 0.5 (random trading) against H1: win_rate > 0.5.
|
||||
The ``n_permutations`` parameter is kept for API compatibility but is unused.
|
||||
|
||||
p < 0.05 → win rate is significantly above 50%, indicating a genuine per-trade edge.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
trade_pnl : pd.Series
|
||||
Per-trade net returns (output of ``_compute_trade_pnl``).
|
||||
n_permutations : int
|
||||
Number of random permutations (default 1000).
|
||||
seed : int
|
||||
RNG seed for reproducibility.
|
||||
|
||||
Returns
|
||||
-------
|
||||
float
|
||||
p-value in [0, 1]. Lower is better.
|
||||
"""
|
||||
if len(trade_pnl) < 2:
|
||||
return 1.0
|
||||
trades = trade_pnl.values.copy()
|
||||
# Binomial test: is the win rate significantly above 50%?
|
||||
# p = probability of observing >= n_wins out of n_trades under null (win_rate=0.5).
|
||||
# Low p → strategy has a significant positive edge per trade.
|
||||
from scipy.stats import binomtest
|
||||
n_wins = int((trades > 0).sum())
|
||||
n_total = len(trades)
|
||||
result = binomtest(n_wins, n_total, p=0.5, alternative="greater")
|
||||
return float(result.pvalue)
|
||||
|
||||
|
||||
def walk_forward_rolling(
|
||||
close: pd.Series,
|
||||
signal: pd.Series,
|
||||
leverage: float,
|
||||
txn_cost_bps: float = DEFAULT_TXN_COST_BPS,
|
||||
bars_per_year: int = DEFAULT_BARS_PER_YEAR,
|
||||
is_years: int = WF_IS_YEARS,
|
||||
oos_years: int = WF_OOS_YEARS,
|
||||
step_years: int = WF_STEP_YEARS,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Rolling walk-forward validation: multiple IS/OOS windows shifted by ``step_years``.
|
||||
|
||||
Each window runs an independent FTMO simulation on the IS and OOS slices.
|
||||
Produces aggregate OOS statistics to measure cross-time consistency.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict with keys:
|
||||
wf_n_windows, wf_oos_sharpe_mean, wf_oos_sharpe_std,
|
||||
wf_oos_monthly_return_mean, wf_oos_consistency (fraction of windows
|
||||
with OOS Sharpe > 0), wf_windows (list of per-window dicts)
|
||||
"""
|
||||
if not isinstance(close.index, pd.DatetimeIndex):
|
||||
return {"wf_n_windows": 0}
|
||||
|
||||
start_year = close.index[0].year
|
||||
end_year = close.index[-1].year
|
||||
|
||||
windows = []
|
||||
yr = start_year
|
||||
while True:
|
||||
is_start = pd.Timestamp(f"{yr}-01-01")
|
||||
is_end = pd.Timestamp(f"{yr + is_years}-01-01")
|
||||
oos_end = pd.Timestamp(f"{yr + is_years + oos_years}-01-01")
|
||||
if oos_end.year > end_year + 1:
|
||||
break
|
||||
is_mask = (close.index >= is_start) & (close.index < is_end)
|
||||
oos_mask = (close.index >= is_end) & (close.index < oos_end)
|
||||
if is_mask.sum() < 1000 or oos_mask.sum() < 1000:
|
||||
yr += step_years
|
||||
continue
|
||||
|
||||
window: Dict[str, Any] = {
|
||||
"is_start": str(is_start.date()),
|
||||
"is_end": str(is_end.date()),
|
||||
"oos_start": str(is_end.date()),
|
||||
"oos_end": str(oos_end.date()),
|
||||
}
|
||||
for mask, prefix in [(is_mask, "is"), (oos_mask, "oos")]:
|
||||
close_s = close.loc[mask]
|
||||
signal_s = signal.loc[mask]
|
||||
masked_s, _ = _apply_ftmo_mask(signal_s, close_s, leverage, txn_cost_bps)
|
||||
r = backtest_signal(close=close_s, signal=masked_s,
|
||||
txn_cost_bps=txn_cost_bps, bars_per_year=bars_per_year)
|
||||
window[f"{prefix}_sharpe"] = r.get("sharpe", 0.0)
|
||||
window[f"{prefix}_monthly_return_pct"] = r.get("monthly_return_pct", 0.0)
|
||||
window[f"{prefix}_n_trades"] = r.get("n_trades", 0)
|
||||
windows.append(window)
|
||||
yr += step_years
|
||||
|
||||
if not windows:
|
||||
return {"wf_n_windows": 0}
|
||||
|
||||
oos_sharpes = [w["oos_sharpe"] for w in windows]
|
||||
oos_monthly = [w["oos_monthly_return_pct"] for w in windows]
|
||||
return {
|
||||
"wf_n_windows": len(windows),
|
||||
"wf_oos_sharpe_mean": float(np.mean(oos_sharpes)),
|
||||
"wf_oos_sharpe_std": float(np.std(oos_sharpes)),
|
||||
"wf_oos_monthly_return_mean": float(np.mean(oos_monthly)),
|
||||
"wf_oos_consistency": float(np.mean([s > 0 for s in oos_sharpes])),
|
||||
"wf_windows": windows,
|
||||
}
|
||||
|
||||
|
||||
def backtest_signal_ftmo(
|
||||
close: pd.Series,
|
||||
signal: pd.Series,
|
||||
txn_cost_bps: float = DEFAULT_TXN_COST_BPS,
|
||||
eurusd_price: float = 1.10,
|
||||
risk_pct: float = FTMO_RISK_PER_TRADE,
|
||||
stop_pips: float = FTMO_STOP_PIPS,
|
||||
max_leverage: float = FTMO_MAX_LEVERAGE,
|
||||
bars_per_year: int = DEFAULT_BARS_PER_YEAR,
|
||||
forward_returns: Optional[pd.Series] = None,
|
||||
oos_start: Optional[str] = OOS_START_DEFAULT,
|
||||
wf_rolling: bool = False,
|
||||
mc_n_permutations: int = 0,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
FTMO-compliant backtest of a strategy signal on EUR/USD.
|
||||
|
||||
Applies on top of ``backtest_signal``:
|
||||
- Realistic costs: default 2.14 bps (≈ 2.35 pip spread+slippage+commission)
|
||||
- Risk-based position sizing: risk_pct equity per trade, stop_pips hard stop
|
||||
- Max leverage cap: max_leverage (default 1:30, FTMO standard)
|
||||
- FTMO daily loss limit (5%): positions zeroed rest of day after breach
|
||||
- FTMO total loss limit (10%): all positions zeroed after breach
|
||||
- FTMO-specific metrics added to result dict
|
||||
- Walk-forward OOS split: IS metrics (before oos_start) + OOS metrics (after)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : pd.Series
|
||||
1-min EUR/USD close prices.
|
||||
signal : pd.Series
|
||||
Raw strategy signal in {-1, 0, +1}.
|
||||
txn_cost_bps : float
|
||||
Transaction cost in bps (default 2.14 ≈ 2.35 pip on EUR/USD).
|
||||
eurusd_price : float
|
||||
Representative EUR/USD price for pip→bps conversion (default 1.10).
|
||||
risk_pct : float
|
||||
Fraction of equity risked per trade (default 0.005 = 0.5%).
|
||||
stop_pips : float
|
||||
Hard stop-loss distance in pips (default 10).
|
||||
max_leverage : float
|
||||
Maximum leverage (default 30 = FTMO 1:30).
|
||||
oos_start : str or None
|
||||
Start of out-of-sample period (ISO date). None disables OOS split.
|
||||
wf_rolling : bool
|
||||
If True, run rolling walk-forward validation (multiple IS/OOS windows).
|
||||
Results are stored under ``wf_*`` keys. Default False.
|
||||
mc_n_permutations : int
|
||||
Number of Monte Carlo trade permutations. 0 = disabled (default).
|
||||
When > 0, computes ``mc_pvalue``: fraction of permuted sequences whose
|
||||
total return >= real total return. p < 0.05 indicates a genuine edge.
|
||||
"""
|
||||
stop_price = stop_pips * FTMO_PIP
|
||||
leverage_by_risk = risk_pct / (stop_price / eurusd_price)
|
||||
leverage = min(leverage_by_risk, max_leverage)
|
||||
|
||||
masked_signal, ftmo_metrics = _apply_ftmo_mask(signal, close, leverage, txn_cost_bps)
|
||||
|
||||
result = backtest_signal(
|
||||
close=close,
|
||||
signal=masked_signal,
|
||||
txn_cost_bps=txn_cost_bps,
|
||||
bars_per_year=bars_per_year,
|
||||
forward_returns=forward_returns,
|
||||
)
|
||||
|
||||
result.update(ftmo_metrics)
|
||||
result["ftmo_leverage"] = round(leverage, 2)
|
||||
result["ftmo_risk_pct"] = risk_pct
|
||||
result["ftmo_stop_pips"] = stop_pips
|
||||
|
||||
# Re-scale reported equity metrics to FTMO_INITIAL_CAPITAL
|
||||
result["ftmo_end_equity"] = FTMO_INITIAL_CAPITAL * (1 + result.get("total_return", 0))
|
||||
result["ftmo_monthly_profit"] = FTMO_INITIAL_CAPITAL * result.get("monthly_return", 0)
|
||||
|
||||
# Walk-forward OOS split
|
||||
if oos_start is not None:
|
||||
oos_ts = pd.Timestamp(oos_start)
|
||||
is_mask = close.index < oos_ts
|
||||
oos_mask = close.index >= oos_ts
|
||||
|
||||
def _split_bt(mask: "pd.Series[bool]", prefix: str) -> None:
|
||||
if mask.sum() < 100:
|
||||
return
|
||||
close_s = close.loc[mask]
|
||||
signal_s = signal.loc[mask] # raw signal, not masked — fresh FTMO sim per period
|
||||
fwd_split = forward_returns.loc[mask] if forward_returns is not None else None
|
||||
masked_s, _ = _apply_ftmo_mask(signal_s, close_s, leverage, txn_cost_bps)
|
||||
split_result = backtest_signal(
|
||||
close=close_s,
|
||||
signal=masked_s,
|
||||
txn_cost_bps=txn_cost_bps,
|
||||
bars_per_year=bars_per_year,
|
||||
forward_returns=fwd_split,
|
||||
)
|
||||
for k, v in split_result.items():
|
||||
if k not in ("equity_curve", "status"):
|
||||
result[f"{prefix}_{k}"] = v
|
||||
|
||||
_split_bt(is_mask, "is")
|
||||
_split_bt(oos_mask, "oos")
|
||||
|
||||
result["oos_start"] = oos_start
|
||||
result["is_n_bars"] = int(is_mask.sum())
|
||||
result["oos_n_bars"] = int(oos_mask.sum())
|
||||
|
||||
# Rolling walk-forward validation
|
||||
if wf_rolling:
|
||||
wf = walk_forward_rolling(
|
||||
close=close,
|
||||
signal=signal,
|
||||
leverage=leverage,
|
||||
txn_cost_bps=txn_cost_bps,
|
||||
bars_per_year=bars_per_year,
|
||||
)
|
||||
result.update(wf)
|
||||
|
||||
# Monte Carlo trade permutation test
|
||||
if mc_n_permutations > 0:
|
||||
position = masked_signal.shift(1).fillna(0)
|
||||
bar_ret = close.pct_change().fillna(0)
|
||||
txn_cost = txn_cost_bps / 10_000.0
|
||||
position_change = position.diff().abs().fillna(position.abs())
|
||||
strat_ret = position * bar_ret - position_change * txn_cost
|
||||
trade_pnl = _compute_trade_pnl(position, strat_ret)
|
||||
result["mc_pvalue"] = monte_carlo_trade_pvalue(trade_pnl, mc_n_permutations)
|
||||
result["mc_n_permutations"] = mc_n_permutations
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def backtest_from_forward_returns(
|
||||
factor_values: pd.Series,
|
||||
forward_returns: pd.Series,
|
||||
|
||||
@@ -53,7 +53,7 @@ evolving_strategy_factor_implementation_v1_system: |-
|
||||
- ALWAYS use `min_periods=N` where N equals the window size in rolling calculations (e.g., `.rolling(20, min_periods=20)`)
|
||||
- ALWAYS handle infinite values after division: `.replace([np.inf, -np.inf], np.nan)` before saving results
|
||||
- ALWAYS use `groupby(level=1)` or `groupby('instrument')` before rolling operations on MultiIndex dataframes
|
||||
- Process the COMPLETE date range (2020-2026), do NOT filter by date
|
||||
- Process the COMPLETE date range available in the HDF5 file (do NOT filter by date — the file may contain 2024 debug data or full 2020-2026 data)
|
||||
- Use `groupby().transform()` instead of `groupby().apply()` for single-column assignments
|
||||
|
||||
Notice that you should not add any other text before or after the json format.
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
"""
|
||||
Kronos Foundation Model Adapter for Predix.
|
||||
|
||||
Wraps the Kronos-mini OHLCV foundation model (4.1M params, AAAI 2026, MIT)
|
||||
for use as:
|
||||
- Factor (Option A): predicted next-day return signal
|
||||
- Model alongside LightGBM (Option B): IC/Sharpe evaluation
|
||||
|
||||
Kronos repo: https://github.com/shiyu-coder/Kronos
|
||||
HuggingFace: NeoQuasar/Kronos-mini | NeoQuasar/Kronos-Tokenizer-2k
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _cuda_available() -> bool:
|
||||
try:
|
||||
import torch
|
||||
return torch.cuda.is_available()
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
KRONOS_REPO = Path.home() / "Kronos"
|
||||
_KRONOS_AVAILABLE: Optional[bool] = None
|
||||
|
||||
|
||||
def _ensure_kronos() -> bool:
|
||||
global _KRONOS_AVAILABLE
|
||||
if _KRONOS_AVAILABLE is not None:
|
||||
return _KRONOS_AVAILABLE
|
||||
if not KRONOS_REPO.exists():
|
||||
logger.warning(f"Kronos repo not found at {KRONOS_REPO}. Clone with: git clone https://github.com/shiyu-coder/Kronos ~/Kronos")
|
||||
_KRONOS_AVAILABLE = False
|
||||
return False
|
||||
repo_str = str(KRONOS_REPO)
|
||||
if repo_str not in sys.path:
|
||||
sys.path.insert(0, repo_str)
|
||||
try:
|
||||
import model as _ # noqa: F401
|
||||
_KRONOS_AVAILABLE = True
|
||||
except ImportError as e:
|
||||
logger.warning(f"Failed to import Kronos model: {e}")
|
||||
_KRONOS_AVAILABLE = False
|
||||
return _KRONOS_AVAILABLE
|
||||
|
||||
|
||||
def _ohlcv_from_predix(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Convert Predix HDF5 format ($open/$close/...) to Kronos format (open/close/...)."""
|
||||
col_map = {"$open": "open", "$high": "high", "$low": "low", "$close": "close", "$volume": "volume"}
|
||||
renamed = df.rename(columns=col_map)
|
||||
cols = [c for c in ["open", "high", "low", "close", "volume"] if c in renamed.columns]
|
||||
return renamed[cols].astype(float)
|
||||
|
||||
|
||||
def _build_window_inputs(
|
||||
ohlcv_df: pd.DataFrame,
|
||||
pred_bars: int,
|
||||
freq: str,
|
||||
) -> tuple[pd.DataFrame, pd.Series, pd.Series]:
|
||||
"""Prepare (ctx_df, x_timestamp, y_timestamp) for one Kronos window."""
|
||||
last_ts = ohlcv_df.index[-1]
|
||||
future_idx = pd.date_range(start=last_ts, periods=pred_bars + 1, freq=freq)[1:]
|
||||
x_timestamp = pd.Series(ohlcv_df.index.values)
|
||||
y_timestamp = pd.Series(future_idx)
|
||||
ctx = ohlcv_df.copy().reset_index(drop=True)
|
||||
return ctx, x_timestamp, y_timestamp
|
||||
|
||||
|
||||
class KronosAdapter:
|
||||
"""
|
||||
Loads Kronos-mini once and provides rolling-window OHLCV inference.
|
||||
|
||||
Usage:
|
||||
adapter = KronosAdapter(device="cuda")
|
||||
adapter.load()
|
||||
pred_return = adapter.predict_return(ohlcv_df, context_bars=512, pred_bars=96)
|
||||
"""
|
||||
|
||||
MODEL_ID = "NeoQuasar/Kronos-mini"
|
||||
TOKENIZER_ID = "NeoQuasar/Kronos-Tokenizer-2k"
|
||||
|
||||
def __init__(self, device: Optional[str] = None, max_context: int = 512):
|
||||
self.device = device or ("cuda" if _cuda_available() else "cpu")
|
||||
self.max_context = max_context
|
||||
self._predictor = None
|
||||
|
||||
def load(self) -> "KronosAdapter":
|
||||
if self._predictor is not None:
|
||||
return self
|
||||
if not _ensure_kronos():
|
||||
raise RuntimeError("Kronos not available — see warning above.")
|
||||
from model import Kronos, KronosTokenizer, KronosPredictor # type: ignore
|
||||
|
||||
logger.info(f"Loading Kronos-mini from HuggingFace ({self.MODEL_ID})...")
|
||||
tokenizer = KronosTokenizer.from_pretrained(self.TOKENIZER_ID)
|
||||
model = Kronos.from_pretrained(self.MODEL_ID)
|
||||
self._predictor = KronosPredictor(model, tokenizer, device=self.device, max_context=self.max_context)
|
||||
logger.info("Kronos-mini loaded.")
|
||||
return self
|
||||
|
||||
def predict_next_bars(
|
||||
self,
|
||||
ohlcv_df: pd.DataFrame,
|
||||
context_bars: int,
|
||||
pred_bars: int,
|
||||
temperature: float = 1.0,
|
||||
top_p: float = 0.9,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Run Kronos on `context_bars` of OHLCV data, returning `pred_bars` predicted bars.
|
||||
|
||||
Args:
|
||||
ohlcv_df: DataFrame with columns open/high/low/close[/volume], DatetimeIndex.
|
||||
context_bars: Number of history bars to feed as context.
|
||||
pred_bars: Number of future bars to predict.
|
||||
|
||||
Returns:
|
||||
DataFrame with predicted open/high/low/close/volume, indexed by future timestamps.
|
||||
"""
|
||||
if self._predictor is None:
|
||||
raise RuntimeError("Call .load() first.")
|
||||
if len(ohlcv_df) < context_bars:
|
||||
raise ValueError(f"Need at least {context_bars} bars, got {len(ohlcv_df)}")
|
||||
|
||||
freq = ohlcv_df.index.freq or pd.infer_freq(ohlcv_df.index[:100]) or "1min"
|
||||
ctx, x_timestamp, y_timestamp = _build_window_inputs(ohlcv_df.iloc[-context_bars:], pred_bars, freq)
|
||||
future_idx = pd.DatetimeIndex(y_timestamp)
|
||||
|
||||
pred_df = self._predictor.predict(
|
||||
df=ctx,
|
||||
x_timestamp=x_timestamp,
|
||||
y_timestamp=y_timestamp,
|
||||
pred_len=pred_bars,
|
||||
T=temperature,
|
||||
top_p=top_p,
|
||||
sample_count=1,
|
||||
verbose=False,
|
||||
)
|
||||
pred_df.index = future_idx
|
||||
return pred_df
|
||||
|
||||
def predict_next_bars_batch(
|
||||
self,
|
||||
ohlcv_windows: list,
|
||||
pred_bars: int,
|
||||
temperature: float = 1.0,
|
||||
top_p: float = 0.9,
|
||||
) -> list:
|
||||
"""
|
||||
Batch inference: run Kronos on multiple context windows simultaneously.
|
||||
|
||||
All windows must have the same number of bars. Processing them together
|
||||
saturates the GPU and is typically 5-20x faster than sequential calls.
|
||||
|
||||
Args:
|
||||
ohlcv_windows: List of OHLCV DataFrames, each with a DatetimeIndex.
|
||||
pred_bars: Number of future bars to predict per window.
|
||||
|
||||
Returns:
|
||||
List of prediction DataFrames (one per input window), same order.
|
||||
"""
|
||||
if self._predictor is None:
|
||||
raise RuntimeError("Call .load() first.")
|
||||
if not ohlcv_windows:
|
||||
return []
|
||||
|
||||
freq = ohlcv_windows[0].index.freq or pd.infer_freq(ohlcv_windows[0].index[:100]) or "1min"
|
||||
|
||||
df_list, x_ts_list, y_ts_list, future_idxs = [], [], [], []
|
||||
for win in ohlcv_windows:
|
||||
ctx, x_ts, y_ts = _build_window_inputs(win, pred_bars, freq)
|
||||
df_list.append(ctx)
|
||||
x_ts_list.append(x_ts)
|
||||
y_ts_list.append(y_ts)
|
||||
future_idxs.append(pd.DatetimeIndex(y_ts))
|
||||
|
||||
pred_dfs = self._predictor.predict_batch(
|
||||
df_list=df_list,
|
||||
x_timestamp_list=x_ts_list,
|
||||
y_timestamp_list=y_ts_list,
|
||||
pred_len=pred_bars,
|
||||
T=temperature,
|
||||
top_p=top_p,
|
||||
sample_count=1,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
for pred_df, future_idx in zip(pred_dfs, future_idxs):
|
||||
pred_df.index = future_idx
|
||||
return pred_dfs
|
||||
|
||||
def predict_return(
|
||||
self,
|
||||
ohlcv_df: pd.DataFrame,
|
||||
context_bars: int = 512,
|
||||
pred_bars: int = 1,
|
||||
) -> float:
|
||||
"""
|
||||
Predict the average return over the next `pred_bars` using the last `context_bars`.
|
||||
Returns the predicted log-return (predicted_close / last_close - 1).
|
||||
"""
|
||||
pred = self.predict_next_bars(ohlcv_df, context_bars=context_bars, pred_bars=pred_bars)
|
||||
last_close = float(ohlcv_df["close"].iloc[-1])
|
||||
pred_close = float(pred["close"].iloc[-1])
|
||||
return pred_close / last_close - 1.0
|
||||
|
||||
|
||||
def build_kronos_factor(
|
||||
hdf5_path,
|
||||
context_bars: int = 512,
|
||||
pred_bars: int = 96,
|
||||
stride_bars: int = 96,
|
||||
device: Optional[str] = None,
|
||||
batch_size: int = 32,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Generate the Kronos predicted-return factor for all EUR/USD 1-min bars.
|
||||
|
||||
Strategy:
|
||||
Every `stride_bars` bars, run Kronos on the previous `context_bars` and
|
||||
predict the next `pred_bars`. Windows are processed in GPU batches of
|
||||
`batch_size` for full GPU utilization. The predicted log-return is
|
||||
forward-filled across the predicted window.
|
||||
|
||||
Returns:
|
||||
MultiIndex (datetime, instrument) DataFrame with column "KronosPredReturn".
|
||||
"""
|
||||
device = device or ("cuda" if _cuda_available() else "cpu")
|
||||
logger.info(f"Loading data from {hdf5_path}...")
|
||||
raw = pd.read_hdf(hdf5_path, key="data")
|
||||
|
||||
instrument = raw.index.get_level_values("instrument").unique()[0]
|
||||
df = raw.xs(instrument, level="instrument")
|
||||
ohlcv = _ohlcv_from_predix(df)
|
||||
|
||||
adapter = KronosAdapter(device=device, max_context=min(context_bars, 512))
|
||||
adapter.load()
|
||||
|
||||
bar_indices = list(range(context_bars, len(ohlcv), stride_bars))
|
||||
n_windows = len(bar_indices)
|
||||
logger.info(
|
||||
f"Running Kronos batch inference: {n_windows} windows "
|
||||
f"(batch={batch_size}, stride={stride_bars}, ctx={context_bars}, pred={pred_bars}, device={device})"
|
||||
)
|
||||
|
||||
factor_values: dict = {}
|
||||
|
||||
for batch_start in range(0, n_windows, batch_size):
|
||||
batch_idx = bar_indices[batch_start : batch_start + batch_size]
|
||||
windows = [ohlcv.iloc[i - context_bars : i] for i in batch_idx]
|
||||
last_closes = [float(ohlcv["close"].iloc[i - 1]) for i in batch_idx]
|
||||
|
||||
try:
|
||||
pred_dfs = adapter.predict_next_bars_batch(windows, pred_bars=pred_bars)
|
||||
for pred_df, last_close in zip(pred_dfs, last_closes):
|
||||
for ts, row in pred_df.iterrows():
|
||||
factor_values[ts] = float(row["close"]) / last_close - 1.0
|
||||
except Exception as e:
|
||||
logger.warning(f"Batch {batch_start // batch_size + 1} failed ({e}), retrying individually...")
|
||||
for bar_idx, win, last_close in zip(batch_idx, windows, last_closes):
|
||||
try:
|
||||
pred = adapter.predict_next_bars(win, context_bars=context_bars, pred_bars=pred_bars)
|
||||
for ts, row in pred.iterrows():
|
||||
factor_values[ts] = float(row["close"]) / last_close - 1.0
|
||||
except Exception as e2:
|
||||
logger.warning(f" Single inference failed at bar {bar_idx}: {e2}")
|
||||
|
||||
done = min(batch_start + batch_size, n_windows)
|
||||
if done % max(batch_size, 100) < batch_size or done == n_windows:
|
||||
logger.info(f" {done}/{n_windows} windows done")
|
||||
|
||||
if not factor_values:
|
||||
raise RuntimeError("No Kronos predictions were generated.")
|
||||
|
||||
factor_series = pd.Series(factor_values, name="KronosPredReturn")
|
||||
factor_series = factor_series.reindex(ohlcv.index, method="ffill")
|
||||
|
||||
result = factor_series.to_frame()
|
||||
result.index = pd.MultiIndex.from_arrays(
|
||||
[ohlcv.index, [instrument] * len(ohlcv)],
|
||||
names=["datetime", "instrument"],
|
||||
)
|
||||
logger.info(f"Kronos factor built: {len(result)} bars, {result['KronosPredReturn'].notna().sum()} non-NaN")
|
||||
return result
|
||||
|
||||
|
||||
def evaluate_kronos_model(
|
||||
hdf5_path,
|
||||
context_bars: int = 512,
|
||||
pred_bars: int = 30,
|
||||
stride_bars: int = 30,
|
||||
device: Optional[str] = None,
|
||||
batch_size: int = 32,
|
||||
) -> dict:
|
||||
"""
|
||||
Evaluate Kronos as a standalone model (Option B, alongside LightGBM).
|
||||
|
||||
Computes IC (Information Coefficient) between Kronos predicted returns and
|
||||
actual realized returns on the test set.
|
||||
|
||||
Returns:
|
||||
dict with keys: IC_mean, IC_std, IC_IR (IC / std), hit_rate, n_predictions
|
||||
"""
|
||||
device = device or ("cuda" if _cuda_available() else "cpu")
|
||||
raw = pd.read_hdf(hdf5_path, key="data")
|
||||
instrument = raw.index.get_level_values("instrument").unique()[0]
|
||||
df = raw.xs(instrument, level="instrument")
|
||||
ohlcv = _ohlcv_from_predix(df)
|
||||
|
||||
adapter = KronosAdapter(device=device, max_context=min(context_bars, 512))
|
||||
adapter.load()
|
||||
|
||||
n = len(ohlcv)
|
||||
bar_indices = list(range(context_bars, n - pred_bars, stride_bars))
|
||||
logger.info(
|
||||
f"Evaluating Kronos: {len(bar_indices)} windows "
|
||||
f"(batch={batch_size}, ctx={context_bars}, pred={pred_bars}, device={device})"
|
||||
)
|
||||
|
||||
predicted_returns = []
|
||||
actual_returns = []
|
||||
|
||||
for batch_start in range(0, len(bar_indices), batch_size):
|
||||
batch_idx = bar_indices[batch_start : batch_start + batch_size]
|
||||
windows = [ohlcv.iloc[i - context_bars : i] for i in batch_idx]
|
||||
last_closes = [float(ohlcv["close"].iloc[i - 1]) for i in batch_idx]
|
||||
actuals = [
|
||||
float(ohlcv["close"].iloc[i + pred_bars - 1]) / float(ohlcv["close"].iloc[i - 1]) - 1.0
|
||||
for i in batch_idx
|
||||
]
|
||||
|
||||
try:
|
||||
pred_dfs = adapter.predict_next_bars_batch(windows, pred_bars=pred_bars)
|
||||
for pred_df, last_close, actual_ret in zip(pred_dfs, last_closes, actuals):
|
||||
pred_ret = float(pred_df["close"].iloc[-1]) / last_close - 1.0
|
||||
predicted_returns.append(pred_ret)
|
||||
actual_returns.append(actual_ret)
|
||||
except Exception as e:
|
||||
logger.warning(f"Batch {batch_start // batch_size + 1} failed ({e}), retrying individually...")
|
||||
for bar_idx, win, last_close, actual_ret in zip(batch_idx, windows, last_closes, actuals):
|
||||
try:
|
||||
pred = adapter.predict_next_bars(win, context_bars=context_bars, pred_bars=pred_bars)
|
||||
pred_ret = float(pred["close"].iloc[-1]) / last_close - 1.0
|
||||
predicted_returns.append(pred_ret)
|
||||
actual_returns.append(actual_ret)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
pred_arr = np.array(predicted_returns)
|
||||
actual_arr = np.array(actual_returns)
|
||||
|
||||
ic = np.corrcoef(pred_arr, actual_arr)[0, 1] if len(pred_arr) > 1 else float("nan")
|
||||
ic_std = float(
|
||||
np.std([
|
||||
np.corrcoef(pred_arr[i : i + 50], actual_arr[i : i + 50])[0, 1]
|
||||
for i in range(0, len(pred_arr) - 50, 10)
|
||||
])
|
||||
) if len(pred_arr) > 60 else float("nan")
|
||||
hit_rate = float(np.mean(np.sign(pred_arr) == np.sign(actual_arr)))
|
||||
|
||||
return {
|
||||
"IC_mean": float(ic),
|
||||
"IC_std": ic_std,
|
||||
"IC_IR": float(ic / ic_std) if ic_std and ic_std > 0 else float("nan"),
|
||||
"hit_rate": hit_rate,
|
||||
"n_predictions": len(pred_arr),
|
||||
}
|
||||
|
||||
# BATCH_INFERENCE_v2
|
||||
@@ -123,8 +123,8 @@ model_cls = AntiSymmetricConv
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
node_features = torch.load("node_features.pt")
|
||||
edge_index = torch.load("edge_index.pt")
|
||||
node_features = torch.load("node_features.pt", weights_only=True)
|
||||
edge_index = torch.load("edge_index.pt", weights_only=True)
|
||||
|
||||
# Model instantiation and forward pass
|
||||
model = AntiSymmetricConv(in_channels=node_features.size(-1))
|
||||
|
||||
@@ -78,8 +78,8 @@ model_cls = DirGNNConv
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
node_features = torch.load("node_features.pt")
|
||||
edge_index = torch.load("edge_index.pt")
|
||||
node_features = torch.load("node_features.pt", weights_only=True)
|
||||
edge_index = torch.load("edge_index.pt", weights_only=True)
|
||||
|
||||
# Model instantiation and forward pass
|
||||
model = DirGNNConv(MessagePassing())
|
||||
|
||||
@@ -187,8 +187,8 @@ model_cls = GPSConv
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
node_features = torch.load("node_features.pt")
|
||||
edge_index = torch.load("edge_index.pt")
|
||||
node_features = torch.load("node_features.pt", weights_only=True)
|
||||
edge_index = torch.load("edge_index.pt", weights_only=True)
|
||||
|
||||
# Model instantiation and forward pass
|
||||
model = GPSConv(channels=node_features.size(-1), conv=MessagePassing())
|
||||
|
||||
@@ -170,8 +170,8 @@ class LINKX(torch.nn.Module):
|
||||
model_cls = LINKX
|
||||
|
||||
if __name__ == "__main__":
|
||||
node_features = torch.load("node_features.pt")
|
||||
edge_index = torch.load("edge_index.pt")
|
||||
node_features = torch.load("node_features.pt", weights_only=True)
|
||||
edge_index = torch.load("edge_index.pt", weights_only=True)
|
||||
|
||||
# Model instantiation and forward pass
|
||||
model = LINKX(
|
||||
|
||||
@@ -102,8 +102,8 @@ class PMLP(torch.nn.Module):
|
||||
model_cls = PMLP
|
||||
|
||||
if __name__ == "__main__":
|
||||
node_features = torch.load("node_features.pt")
|
||||
edge_index = torch.load("edge_index.pt")
|
||||
node_features = torch.load("node_features.pt", weights_only=True)
|
||||
edge_index = torch.load("edge_index.pt", weights_only=True)
|
||||
|
||||
# Model instantiation and forward pass
|
||||
model = PMLP(
|
||||
|
||||
@@ -1180,8 +1180,8 @@ model_cls = ViSNet
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
node_features = torch.load("node_features.pt")
|
||||
edge_index = torch.load("edge_index.pt")
|
||||
node_features = torch.load("node_features.pt", weights_only=True)
|
||||
edge_index = torch.load("edge_index.pt", weights_only=True)
|
||||
|
||||
# Model instantiation and forward pass
|
||||
model = ViSNet()
|
||||
|
||||
@@ -125,8 +125,8 @@ class AntiSymmetricConv(torch.nn.Module):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
node_features = torch.load("node_features.pt")
|
||||
edge_index = torch.load("edge_index.pt")
|
||||
node_features = torch.load("node_features.pt", weights_only=True)
|
||||
edge_index = torch.load("edge_index.pt", weights_only=True)
|
||||
|
||||
# Model instantiation and forward pass
|
||||
model = AntiSymmetricConv(in_channels=node_features.size(-1))
|
||||
|
||||
@@ -605,16 +605,15 @@ class OptunaOptimizer:
|
||||
synthetic_close = (1 + combined_ret).cumprod() * 100.0
|
||||
|
||||
from rdagent.components.backtesting.vbt_backtest import (
|
||||
backtest_signal,
|
||||
backtest_signal_ftmo,
|
||||
DEFAULT_TXN_COST_BPS,
|
||||
)
|
||||
import os as _os
|
||||
|
||||
bt = backtest_signal(
|
||||
bt = backtest_signal_ftmo(
|
||||
close=synthetic_close,
|
||||
signal=signal,
|
||||
txn_cost_bps=float(_os.getenv("TXN_COST_BPS", DEFAULT_TXN_COST_BPS)),
|
||||
freq="1min",
|
||||
)
|
||||
if bt.get("status") != "success":
|
||||
return self._default_metrics()
|
||||
|
||||
@@ -854,7 +854,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
||||
# Delegate all metric computation to the single source of truth.
|
||||
# Same formulas as every other backtest path in the repo.
|
||||
from rdagent.components.backtesting.vbt_backtest import (
|
||||
backtest_signal,
|
||||
backtest_signal_ftmo,
|
||||
DEFAULT_TXN_COST_BPS,
|
||||
)
|
||||
|
||||
@@ -868,11 +868,10 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
||||
close_for_bt = close.reindex(signal.index).ffill()
|
||||
|
||||
txn_cost_bps = float(os.getenv("TXN_COST_BPS", DEFAULT_TXN_COST_BPS))
|
||||
bt = backtest_signal(
|
||||
bt = backtest_signal_ftmo(
|
||||
close=close_for_bt,
|
||||
signal=signal,
|
||||
txn_cost_bps=txn_cost_bps,
|
||||
freq="1min",
|
||||
)
|
||||
|
||||
if bt.get("status") != "success":
|
||||
@@ -1265,7 +1264,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
||||
signal = local_vars["signal"]
|
||||
|
||||
from rdagent.components.backtesting.vbt_backtest import (
|
||||
backtest_signal,
|
||||
backtest_signal_ftmo,
|
||||
DEFAULT_TXN_COST_BPS,
|
||||
)
|
||||
|
||||
@@ -1273,11 +1272,10 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
||||
if close_for_bt is None:
|
||||
return {"sharpe_ratio": float('-inf'), "status": "rejected"}
|
||||
|
||||
bt = backtest_signal(
|
||||
bt = backtest_signal_ftmo(
|
||||
close=close_for_bt,
|
||||
signal=signal,
|
||||
txn_cost_bps=float(os.getenv("TXN_COST_BPS", DEFAULT_TXN_COST_BPS)),
|
||||
freq="1min",
|
||||
)
|
||||
if bt.get("status") != "success":
|
||||
return {"sharpe_ratio": float('-inf'), "status": "rejected"}
|
||||
|
||||
+6
-6
@@ -85,12 +85,12 @@ def preprocess_script():
|
||||
This method applies the preprocessing steps to the training, validation, and test datasets.
|
||||
"""
|
||||
if os.path.exists("/kaggle/input/X_train.pkl"):
|
||||
X_train = pd.read_pickle("/kaggle/input/X_train.pkl")
|
||||
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl")
|
||||
y_train = pd.read_pickle("/kaggle/input/y_train.pkl")
|
||||
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl")
|
||||
X_test = pd.read_pickle("/kaggle/input/X_test.pkl")
|
||||
others = pd.read_pickle("/kaggle/input/others.pkl")
|
||||
X_train = pd.read_pickle("/kaggle/input/X_train.pkl") # nosec B301
|
||||
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl") # nosec B301
|
||||
y_train = pd.read_pickle("/kaggle/input/y_train.pkl") # nosec B301
|
||||
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl") # nosec B301
|
||||
X_test = pd.read_pickle("/kaggle/input/X_test.pkl") # nosec B301
|
||||
others = pd.read_pickle("/kaggle/input/others.pkl") # nosec B301
|
||||
|
||||
return X_train, X_valid, y_train, y_valid, X_test, *others
|
||||
X_train, X_valid, y_train, y_valid = prepreprocess()
|
||||
|
||||
+5
-5
@@ -82,11 +82,11 @@ def preprocess_script():
|
||||
This method applies the preprocessing steps to the training, validation, and test datasets.
|
||||
"""
|
||||
if os.path.exists("X_train.pkl"):
|
||||
X_train = pd.read_pickle("X_train.pkl")
|
||||
X_valid = pd.read_pickle("X_valid.pkl")
|
||||
y_train = pd.read_pickle("y_train.pkl")
|
||||
y_valid = pd.read_pickle("y_valid.pkl")
|
||||
X_test = pd.read_pickle("X_test.pkl")
|
||||
X_train = pd.read_pickle("X_train.pkl") # nosec B301
|
||||
X_valid = pd.read_pickle("X_valid.pkl") # nosec B301
|
||||
y_train = pd.read_pickle("y_train.pkl") # nosec B301
|
||||
y_valid = pd.read_pickle("y_valid.pkl") # nosec B301
|
||||
X_test = pd.read_pickle("X_test.pkl") # nosec B301
|
||||
return X_train, X_valid, y_train, y_valid, X_test
|
||||
|
||||
X_train, X_valid, y_train, y_valid, test, status_encoder, test_ids = prepreprocess()
|
||||
|
||||
+6
-6
@@ -73,12 +73,12 @@ def preprocess_script():
|
||||
This method applies the preprocessing steps to the training, validation, and test datasets.
|
||||
"""
|
||||
if os.path.exists("/kaggle/input/X_train.pkl"):
|
||||
X_train = pd.read_pickle("/kaggle/input/X_train.pkl")
|
||||
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl")
|
||||
y_train = pd.read_pickle("/kaggle/input/y_train.pkl")
|
||||
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl")
|
||||
X_test = pd.read_pickle("/kaggle/input/X_test.pkl")
|
||||
others = pd.read_pickle("/kaggle/input/others.pkl")
|
||||
X_train = pd.read_pickle("/kaggle/input/X_train.pkl") # nosec B301
|
||||
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl") # nosec B301
|
||||
y_train = pd.read_pickle("/kaggle/input/y_train.pkl") # nosec B301
|
||||
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl") # nosec B301
|
||||
X_test = pd.read_pickle("/kaggle/input/X_test.pkl") # nosec B301
|
||||
others = pd.read_pickle("/kaggle/input/others.pkl") # nosec B301
|
||||
y_train = pd.Series(y_train).reset_index(drop=True)
|
||||
y_valid = pd.Series(y_valid).reset_index(drop=True)
|
||||
|
||||
|
||||
@@ -29,6 +29,83 @@ from rdagent.scenarios.qlib.experiment.model_experiment import QlibModelExperime
|
||||
DIRNAME = Path(__file__).absolute().resolve().parent
|
||||
DIRNAME_local = Path.cwd()
|
||||
|
||||
|
||||
def _shift_daily_constant_factor_if_needed(factor_col: "pd.Series", factor_name: str) -> "pd.Series":
|
||||
"""Detect and fix look-ahead bias in daily-constant factors.
|
||||
|
||||
A factor is "daily-constant" when every minute bar within the same calendar
|
||||
day carries an identical value. This happens when LLM code computes a daily
|
||||
aggregate (e.g. today's log return) and forward-fills it across all intraday
|
||||
bars without shifting — meaning the end-of-day value is visible at 00:00.
|
||||
|
||||
Fix: shift by one trading day so that the value assigned to day T is the
|
||||
aggregate computed from day T-1, eliminating the forward-looking information.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
notnull = factor_col.dropna()
|
||||
if len(notnull) < 200:
|
||||
return factor_col
|
||||
|
||||
datetimes = notnull.index.get_level_values("datetime")
|
||||
dates = datetimes.normalize()
|
||||
|
||||
# Sample up to 50 random days and check intra-day uniqueness
|
||||
unique_dates = pd.Series(dates.unique())
|
||||
sample_dates = unique_dates.sample(min(50, len(unique_dates)), random_state=42)
|
||||
|
||||
daily_unique_counts = []
|
||||
for d in sample_dates:
|
||||
mask = dates == d
|
||||
vals = notnull.values[mask]
|
||||
if len(vals) > 1:
|
||||
daily_unique_counts.append(len(np.unique(vals[~np.isnan(vals)])))
|
||||
|
||||
if not daily_unique_counts:
|
||||
return factor_col
|
||||
|
||||
# If >90% of sampled days have exactly 1 unique value → daily-constant
|
||||
fraction_constant = sum(1 for c in daily_unique_counts if c == 1) / len(daily_unique_counts)
|
||||
if fraction_constant < 0.90:
|
||||
return factor_col # Intraday factor — no shift needed
|
||||
|
||||
logger.warning(
|
||||
f"[LookAheadFix] Factor '{factor_name}' is daily-constant "
|
||||
f"({fraction_constant:.0%} of days). Applying 1-day shift to remove look-ahead bias."
|
||||
)
|
||||
|
||||
# Shift: for each instrument, map daily values forward by 1 trading day
|
||||
instruments = factor_col.index.get_level_values("instrument").unique()
|
||||
shifted_parts = []
|
||||
for inst in instruments:
|
||||
inst_series = factor_col.xs(inst, level="instrument")
|
||||
# Get one value per calendar day (the first non-null bar)
|
||||
inst_dt = inst_series.index.normalize()
|
||||
daily_vals = inst_series.groupby(inst_dt).first()
|
||||
# Shift by 1 day
|
||||
daily_vals_shifted = daily_vals.shift(1)
|
||||
# Forward-fill back to minute bars
|
||||
minute_idx = inst_series.index
|
||||
minute_dates = minute_idx.normalize()
|
||||
shifted_minute = minute_dates.map(daily_vals_shifted)
|
||||
shifted_s = pd.Series(
|
||||
shifted_minute.values,
|
||||
index=pd.MultiIndex.from_arrays(
|
||||
[inst_series.index, [inst] * len(inst_series)],
|
||||
names=["datetime", "instrument"],
|
||||
),
|
||||
name=factor_col.name,
|
||||
)
|
||||
shifted_parts.append(shifted_s)
|
||||
|
||||
return pd.concat(shifted_parts).sort_index()
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"[LookAheadFix] Could not apply daily shift for '{factor_name}': {e}")
|
||||
return factor_col
|
||||
|
||||
|
||||
# TODO: supporting multiprocessing and keep previous results
|
||||
|
||||
|
||||
@@ -391,8 +468,19 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
# Get workspace path
|
||||
workspace_path = exp.experiment_workspace.workspace_path
|
||||
# Get workspace path — factor code and result.h5 live in sub_workspace_list[0],
|
||||
# not in experiment_workspace (which is the Qlib template workspace).
|
||||
workspace_path = None
|
||||
if exp.sub_workspace_list:
|
||||
for ws in exp.sub_workspace_list:
|
||||
if ws is not None and hasattr(ws, 'workspace_path'):
|
||||
candidate = ws.workspace_path / "result.h5"
|
||||
if candidate.exists():
|
||||
workspace_path = ws.workspace_path
|
||||
break
|
||||
if workspace_path is None:
|
||||
# Fallback to experiment_workspace
|
||||
workspace_path = exp.experiment_workspace.workspace_path
|
||||
if workspace_path is None:
|
||||
return None
|
||||
|
||||
@@ -409,6 +497,12 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
||||
factor_col = factor_values.iloc[:, 0]
|
||||
factor_name = factor_values.columns[0]
|
||||
|
||||
# Detect and fix look-ahead bias in daily-constant factors.
|
||||
# If a factor has the same value for all minute bars within each calendar day
|
||||
# it was computed from same-day data (e.g. today's close return at 00:00).
|
||||
# Fix: shift by 1 trading day so value at day T = aggregate of day T-1.
|
||||
factor_col = _shift_daily_constant_factor_if_needed(factor_col, factor_name)
|
||||
|
||||
# Load source data for forward returns
|
||||
data_path = (
|
||||
Path(__file__).parent.parent.parent.parent.parent
|
||||
@@ -587,10 +681,12 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
||||
from pathlib import Path
|
||||
from rdagent.components.backtesting import ResultsDatabase
|
||||
|
||||
# Get factor name from hypothesis
|
||||
# Get factor name: prefer hypothesis, fallback to result Series 'factor_name' key
|
||||
factor_name = "unknown"
|
||||
if hasattr(exp, 'hypothesis') and exp.hypothesis is not None:
|
||||
factor_name = getattr(exp.hypothesis, 'hypothesis', 'unknown')
|
||||
if factor_name == 'unknown' and isinstance(result, pd.Series) and 'factor_name' in result.index:
|
||||
factor_name = str(result['factor_name'])
|
||||
|
||||
# Check if already rejected by protection
|
||||
if getattr(exp, 'rejected_by_protection', False):
|
||||
@@ -824,41 +920,74 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
||||
"""
|
||||
Save factor time-series values as parquet for strategy building.
|
||||
|
||||
This is essential for walk-forward validation and strategy combination.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
factor_name : str
|
||||
Name of the factor
|
||||
exp : QlibFactorExperiment
|
||||
The experiment with factor values
|
||||
Reruns the factor code on the FULL 6-year dataset so the parquet covers
|
||||
the complete backtest range (not just the debug 2024 subset).
|
||||
"""
|
||||
import os as _os
|
||||
import subprocess
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
try:
|
||||
# Get workspace path
|
||||
workspace_path = exp.experiment_workspace.workspace_path
|
||||
# factor.py lives in sub_workspace_list[0], not experiment_workspace
|
||||
workspace_path = None
|
||||
if exp.sub_workspace_list:
|
||||
for ws in exp.sub_workspace_list:
|
||||
if ws is not None and hasattr(ws, 'workspace_path'):
|
||||
fp = ws.workspace_path / "factor.py"
|
||||
if fp.exists():
|
||||
workspace_path = ws.workspace_path
|
||||
break
|
||||
if workspace_path is None:
|
||||
workspace_path = exp.experiment_workspace.workspace_path
|
||||
if workspace_path is None:
|
||||
return
|
||||
|
||||
result_h5 = workspace_path / "result.h5"
|
||||
if not result_h5.exists():
|
||||
factor_py = workspace_path / "factor.py"
|
||||
if not factor_py.exists():
|
||||
return
|
||||
|
||||
# Read factor values
|
||||
project_root = Path(__file__).parent.parent.parent.parent.parent
|
||||
full_data = (
|
||||
project_root
|
||||
/ "git_ignore_folder"
|
||||
/ "factor_implementation_source_data"
|
||||
/ "intraday_pv.h5"
|
||||
)
|
||||
if not full_data.exists():
|
||||
return
|
||||
|
||||
# Run factor code on full data in a temp workspace
|
||||
import pandas as pd
|
||||
df = pd.read_hdf(str(result_h5), key="data")
|
||||
with tempfile.TemporaryDirectory(prefix="predix_fullval_") as tmp_dir:
|
||||
tmp = Path(tmp_dir)
|
||||
shutil.copy(str(factor_py), str(tmp / "factor.py"))
|
||||
shutil.copy(str(full_data), str(tmp / "intraday_pv.h5"))
|
||||
|
||||
ret = subprocess.run(
|
||||
["python", "factor.py"],
|
||||
cwd=str(tmp),
|
||||
capture_output=True,
|
||||
timeout=300,
|
||||
)
|
||||
if ret.returncode != 0:
|
||||
# Fall back to debug-data result if full-data run fails
|
||||
result_h5 = workspace_path / "result.h5"
|
||||
if not result_h5.exists():
|
||||
return
|
||||
df = pd.read_hdf(str(result_h5), key="data")
|
||||
else:
|
||||
result_h5_full = tmp / "result.h5"
|
||||
if not result_h5_full.exists():
|
||||
return
|
||||
df = pd.read_hdf(str(result_h5_full), key="data")
|
||||
|
||||
if df is None or df.empty:
|
||||
return
|
||||
|
||||
# Get the factor series (first column)
|
||||
series = df.iloc[:, 0]
|
||||
series.name = factor_name
|
||||
|
||||
# Save to results/factors/values/
|
||||
project_root = Path(__file__).parent.parent.parent.parent.parent
|
||||
|
||||
# Parallel run isolation
|
||||
parallel_run_id = _os.getenv("PARALLEL_RUN_ID", "0")
|
||||
if parallel_run_id != "0":
|
||||
values_dir = project_root / "results" / "runs" / f"run{parallel_run_id}" / "factors" / "values"
|
||||
@@ -866,16 +995,11 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
||||
values_dir = project_root / "results" / "factors" / "values"
|
||||
|
||||
values_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Safe filename
|
||||
safe_name = factor_name.replace("/", "_").replace("\\", "_").replace(" ", "_")[:100]
|
||||
parquet_path = values_dir / f"{safe_name}.parquet"
|
||||
series.to_frame().to_parquet(str(parquet_path))
|
||||
|
||||
# Save as parquet (with datetime index)
|
||||
series.to_parquet(str(parquet_path))
|
||||
|
||||
except Exception as e:
|
||||
# Don't let factor value saving break the main workflow
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _log_result_warnings(self, factor_name: str, result, metrics: dict) -> None:
|
||||
|
||||
@@ -23,14 +23,25 @@ $low: low price at 1-minute bar.
|
||||
$volume: volume at 1-minute bar (tick volume for FX).
|
||||
|
||||
## Important Notes for 1min Data
|
||||
- 96 bars = 1 trading day (24 hours for FX)
|
||||
- 1 bar = 1 minute (confirmed)
|
||||
- 16 bars = 16 minutes
|
||||
- 4 bars = 4 minutes
|
||||
- 1 bar = 1 minute
|
||||
- 60 bars = 1 hour
|
||||
- ~1440 bars = 1 full trading day (FX trades nearly 24h, Mon 00:00 - Fri 22:00 UTC approx.)
|
||||
- Typical bars per calendar day: ~1200-1440 (varies by weekday, holidays have fewer)
|
||||
- Do NOT assume 96 bars/day — the actual count depends on the date
|
||||
- Data range: 2020-01-01 to 2026-03-20
|
||||
- Instrument: EURUSD
|
||||
- Timezone: UTC
|
||||
|
||||
## IMPORTANT: Bars per Day Correction
|
||||
The dataset has approximately 1440 bars per full trading day (1 bar = 1 minute, ~24h of FX trading).
|
||||
Some older documentation incorrectly stated "96 bars = 1 day" — this is WRONG. Always use:
|
||||
- 60 bars = 1 hour
|
||||
- 480 bars = 8 hours (London session 08:00-16:00 UTC)
|
||||
- 180 bars = 3 hours (London/NY overlap 13:00-16:00 UTC)
|
||||
Use datetime hour filtering (e.g., `df[df.index.get_level_values('datetime').hour.between(8, 15)]`)
|
||||
to select session bars — do NOT use bar-count offsets to define sessions.
|
||||
|
||||
## Session Times (UTC)
|
||||
- Asian: 00:00-08:00 UTC (low volatility)
|
||||
- London: 08:00-16:00 UTC (high volatility)
|
||||
|
||||
@@ -104,7 +104,7 @@ qlib_factor_strategy: |-
|
||||
result_df.columns = ['daily_volume_price_divergence']
|
||||
```
|
||||
|
||||
4. **Process ALL data — do not filter dates**: The source HDF5 contains data from 2020-01-01 to 2026-03-20. Do NOT filter to a single year. If your output has only 314 entries (one year of daily data), the factor will be rejected. Expected output: ~1500+ daily entries for 2020-2026.
|
||||
4. **Process ALL data — do not filter dates**: The source HDF5 contains data from 2020-01-01 to 2026-03-20 (development runs may use a 2024-only debug dataset with ~300 entries, which is acceptable). Do NOT filter to a single year in your code. Write your code to process whatever date range is available in the HDF5 file — do not hardcode date filters. Expected output for production data: ~1500+ daily entries for 2020-2026. Expected output for debug data: ~300 daily entries for 2024. Both are valid.
|
||||
|
||||
5. **Use `transform()` instead of `apply()` for per-group calculations**: `transform()` preserves the original index while `apply()` may reduce the number of rows unexpectedly:
|
||||
```python
|
||||
@@ -121,6 +121,35 @@ qlib_factor_strategy: |-
|
||||
assert result_df.index.names == ['datetime', 'instrument'], f"Index names must be ['datetime', 'instrument'], got {result_df.index.names}"
|
||||
```
|
||||
|
||||
7. **NEVER use same-day aggregations as the factor value — always shift by 1 day**: If your factor computes a daily aggregate (e.g. daily close return, daily OHLC range, daily volume), that aggregate is only known at end-of-day. Using it at the start of the same day is look-ahead bias. You MUST shift the daily aggregate by 1 day before forward-filling to minute bars:
|
||||
```python
|
||||
# WRONG: look-ahead bias! Today's close return is not known at 00:00
|
||||
daily_ret = df['$close'].groupby(level='instrument').resample('1D', level='datetime').last().pct_change()
|
||||
result_df['my_factor'] = daily_ret.groupby(level='instrument').transform(lambda x: x.reindex(df.index.get_level_values('datetime'), method='ffill'))
|
||||
|
||||
# CORRECT: shift by 1 trading day so factor value at day T = aggregate of day T-1
|
||||
daily_close = df.groupby([df.index.get_level_values('datetime').normalize(), df.index.get_level_values('instrument')])['$close'].last()
|
||||
daily_close.index.names = ['date', 'instrument']
|
||||
daily_ret = daily_close.groupby(level='instrument').pct_change().shift(1) # <-- shift(1) is MANDATORY
|
||||
# then map back to minute bars via ffill
|
||||
```
|
||||
This rule applies to ALL daily aggregations: returns, OHLC stats, volume, momentum, slopes, etc.
|
||||
**Session-based aggregations (London, NY, Asian session returns) are also daily aggregations** — the London
|
||||
session (08:00-16:00 UTC) ends at 16:00, so its return must be shifted by 1 day before use.
|
||||
Intraday rolling factors (e.g. 30-min rolling std computed at bar t using only bars t-N..t-1) do NOT need this shift.
|
||||
|
||||
8. **PREFER pure intraday rolling factors**: Factors that use only a trailing window of recent bars (e.g.
|
||||
rolling(30).mean() of returns, RSI(14), Bollinger Band z-score) have NO look-ahead risk and vary every
|
||||
minute. These are the best candidates for short-horizon (60-180 bar) prediction. Examples:
|
||||
- Rolling 15-min / 30-min / 60-min return momentum (15, 30, 60 bars respectively)
|
||||
- Rolling volatility (std of returns over 20-60 bars)
|
||||
- Distance of close from N-bar moving average (z-score)
|
||||
- RSI or similar oscillators computed on 1-min bars
|
||||
- VWAP deviation (requires volume — use $volume column)
|
||||
Always use `.shift(1)` on the lagged window (e.g. `rolling(N).mean().shift(1)`) to avoid using the
|
||||
current bar's own price in its own feature value.
|
||||
NOTE: 1 bar = 1 minute. The data has ~1440 bars per full trading day. Do NOT use 96 as a day proxy.
|
||||
|
||||
qlib_factor_output_format: |-
|
||||
Your output should be a pandas dataframe similar to the following example information:
|
||||
<class 'pandas.core.frame.DataFrame'>
|
||||
|
||||
@@ -152,9 +152,41 @@ class QlibQuantHypothesisGen(FactorAndModelHypothesisGen):
|
||||
factor_inserted = True
|
||||
if len(specific_trace.hist) > 0:
|
||||
specific_trace.hist.reverse()
|
||||
hypothesis_and_feedback = T("scenarios.qlib.prompts:hypothesis_and_feedback").r(
|
||||
trace=specific_trace,
|
||||
)
|
||||
# Keep only the 2 most recent experiments in full detail; compress older ones
|
||||
# to brief bullet points to stay within the LLM context window.
|
||||
FULL_DETAIL_COUNT = 2
|
||||
old_hist = specific_trace.hist[:-FULL_DETAIL_COUNT] if len(specific_trace.hist) > FULL_DETAIL_COUNT else []
|
||||
recent_hist = specific_trace.hist[-FULL_DETAIL_COUNT:] if len(specific_trace.hist) > FULL_DETAIL_COUNT else specific_trace.hist
|
||||
|
||||
parts = []
|
||||
if old_hist:
|
||||
summary_lines = ["## Earlier experiments (summarized):"]
|
||||
for exp, fb in old_hist:
|
||||
factor_names = []
|
||||
for task in exp.sub_tasks:
|
||||
if task is not None and hasattr(task, "factor_name"):
|
||||
factor_names.append(task.factor_name)
|
||||
elif task is not None and hasattr(task, "model_type"):
|
||||
factor_names.append(getattr(task, "model_type", "model"))
|
||||
names_str = ", ".join(factor_names) if factor_names else "unknown"
|
||||
ic_str = ""
|
||||
try:
|
||||
if exp.result is not None:
|
||||
ic_val = exp.result.loc["IC"] if "IC" in exp.result.index else ""
|
||||
ic_str = f" IC={ic_val:.4f}" if ic_val != "" else ""
|
||||
except Exception:
|
||||
pass
|
||||
decision_str = "PASS" if fb.decision else "FAIL"
|
||||
obs_short = (fb.observations or "")[:120].replace("\n", " ")
|
||||
summary_lines.append(f"- [{decision_str}]{ic_str} {names_str}: {obs_short}")
|
||||
parts.append("\n".join(summary_lines))
|
||||
|
||||
if recent_hist:
|
||||
recent_trace = Trace(specific_trace.scen)
|
||||
recent_trace.hist = recent_hist
|
||||
parts.append(T("scenarios.qlib.prompts:hypothesis_and_feedback").r(trace=recent_trace))
|
||||
|
||||
hypothesis_and_feedback = "\n\n".join(parts)
|
||||
else:
|
||||
hypothesis_and_feedback = "No previous hypothesis and feedback available."
|
||||
|
||||
|
||||
@@ -391,7 +391,7 @@ def set_baseline():
|
||||
return jsonify({"baseline_score": score, "status": "set"})
|
||||
|
||||
|
||||
def run_server(task: str, base_model: str, workspace: str, host: str = "0.0.0.0", port: int = 5000):
|
||||
def run_server(task: str, base_model: str, workspace: str, host: str = "127.0.0.1", port: int = 5000):
|
||||
"""启动服务器"""
|
||||
init_server(task, base_model, workspace)
|
||||
logger.info(f"Grading Server | task={task} | {host}:{port}")
|
||||
@@ -435,7 +435,7 @@ class LocalServerContext(GradingServerContext):
|
||||
logger.info(f"[Local Mode] Starting evaluation server on port {self.port}...")
|
||||
self.server = init_server(self.task, self.base_model, self.workspace)
|
||||
|
||||
self._http_server = make_server("0.0.0.0", self.port, app, threaded=True)
|
||||
self._http_server = make_server("0.0.0.0", self.port, app, threaded=True) # nosec B104 — intentional: Docker sandbox requires all-interface binding
|
||||
self._thread = threading.Thread(target=self._http_server.serve_forever, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
@@ -488,7 +488,7 @@ if __name__ == "__main__":
|
||||
parser.add_argument("--base-model", type=str, default="")
|
||||
parser.add_argument("--workspace", type=str, default=".")
|
||||
parser.add_argument("--port", type=int, default=5000)
|
||||
parser.add_argument("--host", type=str, default="0.0.0.0")
|
||||
parser.add_argument("--host", type=str, default="127.0.0.1")
|
||||
args = parser.parse_args()
|
||||
|
||||
run_server(args.task, args.base_model, args.workspace, args.host, args.port)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"release-type": "python",
|
||||
"bump-minor-pre-major": true,
|
||||
"bump-patch-for-minor-pre-major": true,
|
||||
"packages": {
|
||||
".": {
|
||||
"release-type": "python",
|
||||
"bump-minor-pre-major": true,
|
||||
"bump-patch-for-minor-pre-major": true
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -5,6 +5,7 @@ python-Levenshtein
|
||||
scikit-learn
|
||||
filelock
|
||||
loguru
|
||||
psutil
|
||||
fire
|
||||
fuzzywuzzy
|
||||
openai
|
||||
@@ -36,7 +37,7 @@ tables
|
||||
tree-sitter-python
|
||||
tree-sitter
|
||||
|
||||
python-dotenv
|
||||
python-dotenv>=1.2.2 # CVE: symlink following allows arbitrary file overwrite
|
||||
|
||||
# infrastructure related.
|
||||
docker
|
||||
|
||||
@@ -13,4 +13,4 @@ sphinx-togglebutton
|
||||
sphinx_rtd_theme
|
||||
# snowballstemmer, a dependency of sphinx, was released on 2025-05-08 with version 3.0.0,
|
||||
# which causes errors in the build process. So we've limited the version for now.
|
||||
snowballstemmer<3.0
|
||||
snowballstemmer<4.0
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Option A: Generate Kronos predicted-return factor from EUR/USD 1-min data.
|
||||
|
||||
Runs Kronos-mini inference in daily strides (96 bars/day) over all available
|
||||
OHLCV data and saves the resulting factor for use in Predix's factor pipeline.
|
||||
|
||||
Usage:
|
||||
conda activate predix
|
||||
python scripts/kronos_factor_gen.py
|
||||
python scripts/kronos_factor_gen.py --context 512 --pred 96 --device cuda
|
||||
python scripts/kronos_factor_gen.py --device cpu # slower but no GPU needed
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(Path(__file__).parent.parent / ".env")
|
||||
|
||||
import pandas as pd
|
||||
import torch
|
||||
|
||||
DATA_PATH = Path("git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
|
||||
OUTPUT_DIR = Path("results/factors")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate Kronos predicted-return factor")
|
||||
parser.add_argument("--context", type=int, default=512, help="Context window in bars (max 512 for Kronos-mini)")
|
||||
parser.add_argument("--pred", type=int, default=96, help="Prediction horizon in bars (default: 96 = 1 trading day)")
|
||||
parser.add_argument("--stride", type=int, default=None, help="Stride between windows (default: same as --pred)")
|
||||
parser.add_argument("--device", type=str, default="cuda" if torch.cuda.is_available() else "cpu")
|
||||
parser.add_argument("--output", type=str, default=None, help="Output parquet path (default: auto)")
|
||||
args = parser.parse_args()
|
||||
|
||||
stride = args.stride or args.pred
|
||||
|
||||
print(f"Kronos Factor Generator")
|
||||
print(f" Data: {DATA_PATH}")
|
||||
print(f" Context: {args.context} bars")
|
||||
print(f" Pred: {args.pred} bars ({args.pred} min = {args.pred/96:.1f} trading days)")
|
||||
print(f" Stride: {stride} bars")
|
||||
print(f" Device: {args.device}")
|
||||
print()
|
||||
|
||||
if not DATA_PATH.exists():
|
||||
print(f"ERROR: Data not found at {DATA_PATH}")
|
||||
print("Run data conversion first — see README Data Setup section.")
|
||||
raise SystemExit(1)
|
||||
|
||||
from rdagent.components.coder.kronos_adapter import build_kronos_factor
|
||||
|
||||
factor_df = build_kronos_factor(
|
||||
hdf5_path=DATA_PATH,
|
||||
context_bars=args.context,
|
||||
pred_bars=args.pred,
|
||||
stride_bars=stride,
|
||||
device=args.device,
|
||||
)
|
||||
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
out_path = args.output or OUTPUT_DIR / f"kronos_pred_return_p{args.pred}.parquet"
|
||||
factor_df.to_parquet(out_path)
|
||||
print(f"\nFactor saved to: {out_path}")
|
||||
print(f"Shape: {factor_df.shape}")
|
||||
print(f"Non-NaN: {factor_df['KronosPredReturn'].notna().sum()}")
|
||||
print(f"\nSample (first 5):")
|
||||
print(factor_df.head())
|
||||
|
||||
# Save metadata for predix.py top / best integration
|
||||
meta = {
|
||||
"factor_name": f"KronosPredReturn_p{args.pred}",
|
||||
"description": f"Kronos-mini predicted return, {args.pred}-bar horizon",
|
||||
"model": "NeoQuasar/Kronos-mini",
|
||||
"context_bars": args.context,
|
||||
"pred_bars": args.pred,
|
||||
"stride_bars": stride,
|
||||
"device": args.device,
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"n_bars": len(factor_df),
|
||||
"n_non_nan": int(factor_df["KronosPredReturn"].notna().sum()),
|
||||
"parquet_path": str(out_path),
|
||||
}
|
||||
meta_path = out_path.with_suffix(".json")
|
||||
with open(meta_path, "w") as f:
|
||||
json.dump(meta, f, indent=2)
|
||||
print(f"Metadata saved to: {meta_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Option B: Evaluate Kronos-mini as a model alongside LightGBM.
|
||||
|
||||
Computes IC (Information Coefficient) and hit rate for Kronos predictions
|
||||
vs actual realized returns. Results are printed for comparison with LightGBM.
|
||||
|
||||
Usage:
|
||||
conda activate predix
|
||||
python scripts/kronos_model_eval.py
|
||||
python scripts/kronos_model_eval.py --pred 30 --context 512 --device cuda
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(Path(__file__).parent.parent / ".env")
|
||||
|
||||
import torch
|
||||
|
||||
DATA_PATH = Path("git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
|
||||
OUTPUT_DIR = Path("results/kronos")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Evaluate Kronos as model (alongside LightGBM)")
|
||||
parser.add_argument("--context", type=int, default=512, help="Context window in bars")
|
||||
parser.add_argument("--pred", type=int, default=30, help="Prediction horizon in bars")
|
||||
parser.add_argument("--stride", type=int, default=None, help="Stride between evaluations (default: pred)")
|
||||
parser.add_argument("--device", type=str, default="cuda" if torch.cuda.is_available() else "cpu")
|
||||
args = parser.parse_args()
|
||||
|
||||
stride = args.stride or args.pred
|
||||
|
||||
print(f"Kronos Model Evaluator (alongside LightGBM)")
|
||||
print(f" Context: {args.context} bars | Pred: {args.pred} bars | Device: {args.device}")
|
||||
print()
|
||||
|
||||
if not DATA_PATH.exists():
|
||||
print(f"ERROR: Data not found at {DATA_PATH}")
|
||||
raise SystemExit(1)
|
||||
|
||||
from rdagent.components.coder.kronos_adapter import evaluate_kronos_model
|
||||
|
||||
print("Running evaluation (this may take several minutes)...")
|
||||
metrics = evaluate_kronos_model(
|
||||
hdf5_path=DATA_PATH,
|
||||
context_bars=args.context,
|
||||
pred_bars=args.pred,
|
||||
stride_bars=stride,
|
||||
device=args.device,
|
||||
)
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("Kronos-mini Model Evaluation Results")
|
||||
print("=" * 50)
|
||||
print(f" Predictions: {metrics['n_predictions']}")
|
||||
print(f" IC (mean): {metrics['IC_mean']:.4f}")
|
||||
print(f" IC (std): {metrics['IC_std']:.4f}")
|
||||
print(f" IC IR: {metrics['IC_IR']:.4f} (>0.5 = good)")
|
||||
print(f" Hit Rate: {metrics['hit_rate']:.2%} (>50% = directionally useful)")
|
||||
print("=" * 50)
|
||||
print()
|
||||
print("Reference: LightGBM baseline IC typically 0.01–0.05 on 1-min EUR/USD")
|
||||
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
out = OUTPUT_DIR / f"kronos_eval_ctx{args.context}_pred{args.pred}.json"
|
||||
with open(out, "w") as f:
|
||||
json.dump({**metrics, "context_bars": args.context, "pred_bars": args.pred}, f, indent=2)
|
||||
print(f"\nResults saved to: {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -198,6 +198,55 @@ def scan_factors(workspace_dir: Path, skip_evaluated: bool = True) -> List[Facto
|
||||
return factors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Look-ahead bias detection for daily-constant factors
|
||||
# ---------------------------------------------------------------------------
|
||||
def _shift_daily_constant_factor_if_needed(factor_col: "pd.Series", factor_name: str) -> "pd.Series":
|
||||
"""Detect daily-constant factors (look-ahead bias) and shift by 1 trading day."""
|
||||
sample_days = factor_col.index.get_level_values("datetime").normalize().unique()
|
||||
if len(sample_days) < 10:
|
||||
return factor_col
|
||||
rng = np.random.default_rng(42)
|
||||
days_to_check = rng.choice(sample_days, size=min(50, len(sample_days)), replace=False)
|
||||
constant_count = 0
|
||||
for day in days_to_check:
|
||||
day_mask = factor_col.index.get_level_values("datetime").normalize() == day
|
||||
day_vals = factor_col[day_mask].dropna()
|
||||
if len(day_vals) == 0:
|
||||
continue
|
||||
if day_vals.nunique() == 1:
|
||||
constant_count += 1
|
||||
fraction_constant = constant_count / len(days_to_check)
|
||||
if fraction_constant < 0.90:
|
||||
return factor_col
|
||||
# Shift by 1 trading day per instrument
|
||||
import logging
|
||||
logging.getLogger(__name__).info(
|
||||
"Factor '%s' is %.0f%% daily-constant — shifting 1 trading day to fix look-ahead bias",
|
||||
factor_name, fraction_constant * 100,
|
||||
)
|
||||
instruments = factor_col.index.get_level_values("instrument").unique() if "instrument" in factor_col.index.names else [None]
|
||||
shifted_parts = []
|
||||
for instr in instruments:
|
||||
if instr is not None:
|
||||
mask = factor_col.index.get_level_values("instrument") == instr
|
||||
col_instr = factor_col[mask]
|
||||
else:
|
||||
col_instr = factor_col
|
||||
dates = col_instr.index.get_level_values("datetime").normalize()
|
||||
trading_days = dates.unique().sort_values()
|
||||
day_first = col_instr.groupby(dates).first()
|
||||
day_first_shifted = day_first.shift(1)
|
||||
day_first_shifted.index = pd.to_datetime(day_first_shifted.index)
|
||||
day_map = day_first_shifted.reindex(pd.to_datetime(trading_days)).values
|
||||
new_vals = pd.Series(
|
||||
day_map[np.searchsorted(trading_days.values, dates.values)],
|
||||
index=col_instr.index,
|
||||
)
|
||||
shifted_parts.append(new_vals)
|
||||
return pd.concat(shifted_parts).sort_index()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factor evaluator
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -263,6 +312,7 @@ def evaluate_factor_full(factor: FactorInfo, full_data: pd.DataFrame,
|
||||
result = pd.read_hdf(str(result_file), key="data")
|
||||
total_count = len(result)
|
||||
factor_val = result.iloc[:, 0]
|
||||
factor_val = _shift_daily_constant_factor_if_needed(factor_val, factor.factor_name)
|
||||
non_null_count = factor_val.notna().sum()
|
||||
|
||||
if non_null_count < 1000:
|
||||
|
||||
@@ -55,7 +55,7 @@ if TRADING_STYLE == 'daytrading':
|
||||
FORWARD_BARS = int(os.getenv('FORWARD_BARS', '12'))
|
||||
MIN_IC = 0.02
|
||||
MIN_SHARPE = 0.5
|
||||
MIN_TRADES = 20
|
||||
MIN_TRADES = 300
|
||||
MAX_DRAWDOWN = -0.10
|
||||
STYLE_EMOJI = '🎯 Daytrading'
|
||||
STYLE_DESC = 'short-term intraday with FTMO compliance'
|
||||
@@ -68,9 +68,40 @@ else:
|
||||
STYLE_EMOJI = '📈 Swing'
|
||||
STYLE_DESC = 'medium-term intraday'
|
||||
|
||||
TXN_COST_BPS = float(os.getenv('TXN_COST_BPS', '1.0'))
|
||||
# Whether to use raw OHLCV-only strategies (no daily factors)
|
||||
OHLCV_ONLY = os.getenv('OHLCV_ONLY', '0') == '1'
|
||||
|
||||
console = Console()
|
||||
TXN_COST_BPS = float(os.getenv('TXN_COST_BPS', '2.14')) # 2.35 pip realistic EUR/USD costs
|
||||
|
||||
# ── Logging setup: everything printed goes to log file + stdout ───────────────
|
||||
_LOG_DIR = Path(__file__).parent.parent / "git_ignore_folder" / "logs"
|
||||
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_log_file_path = _LOG_DIR / f"gen_strategies_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
|
||||
_log_file = open(_log_file_path, "w", encoding="utf-8", buffering=1) # line-buffered
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler(_log_file_path, encoding="utf-8"),
|
||||
],
|
||||
)
|
||||
|
||||
class _TeeFile:
|
||||
"""Writes to both stdout and log file — used as Rich Console file."""
|
||||
def __init__(self, *files):
|
||||
self._files = files
|
||||
def write(self, data):
|
||||
for f in self._files:
|
||||
f.write(data)
|
||||
def flush(self):
|
||||
for f in self._files:
|
||||
f.flush()
|
||||
def fileno(self):
|
||||
return self._files[0].fileno()
|
||||
|
||||
console = Console(file=_TeeFile(sys.stdout, _log_file), highlight=False)
|
||||
|
||||
# ============================================================================
|
||||
# LLM Configuration (Process-safe)
|
||||
@@ -153,7 +184,44 @@ def generate_single_strategy(args):
|
||||
factor_list = "\n".join([f"- {f['name']} (IC={f['ic']:.4f})" for f in factor_subset])
|
||||
|
||||
# Optimized prompts for daytrading vs swing
|
||||
if TRADING_STYLE == 'daytrading':
|
||||
if TRADING_STYLE == 'daytrading' and OHLCV_ONLY:
|
||||
system_prompt = """You are an expert EUR/USD intraday quant. You build strategies that work ONLY on raw price data (OHLCV), computing all indicators directly from the 1-minute close series.
|
||||
|
||||
CRITICAL RULES:
|
||||
1. The code receives ONLY a pandas Series called 'close' (1-minute EUR/USD close prices, UTC timestamps).
|
||||
2. 'factors' is NOT available — compute everything from 'close' directly.
|
||||
3. Create a pandas Series called 'signal' with values: 1 (long), -1 (short), 0 (neutral).
|
||||
4. signal.index MUST match close.index exactly.
|
||||
5. signal.name must be 'signal'.
|
||||
6. Use ONLY pandas/numpy — no external libraries.
|
||||
7. MANDATORY: The signal MUST flip at least 300 times across the full dataset. Use low thresholds.
|
||||
|
||||
Allowed intraday techniques (pick 2-3 and combine):
|
||||
- Session timing: London open (07:00-09:00 UTC), NY open (13:00-15:00 UTC), session overlap
|
||||
- Short-window RSI (7-14 bars) on 1-min close
|
||||
- EMA crossovers (fast=5-15 bars, slow=20-60 bars)
|
||||
- Bollinger Bands (20-bar, 1.5σ) for mean reversion
|
||||
- ATR-based volatility breakouts
|
||||
- VWAP deviation (approximate with rolling mean)
|
||||
- Time-of-day filters combined with momentum
|
||||
|
||||
Output ONLY valid JSON:
|
||||
{"strategy_name": "short_name", "factor_names": [], "description": "one sentence", "code": "python code"}"""
|
||||
|
||||
user_prompt = f"""Create a EUR/USD 1-minute intraday strategy using ONLY the raw close price series.
|
||||
|
||||
{f'Previous feedback: {feedback}' if feedback else 'First attempt — be creative and combine session timing with a momentum or mean-reversion indicator!'}
|
||||
|
||||
Hard requirements:
|
||||
- Signal must change direction at least 300 times total (~4-8 trades per trading day)
|
||||
- NEVER use ffill() or forward-fill on the signal — recompute fresh at every bar
|
||||
- Use RSI thresholds between 35-45 (long) and 55-65 (short) — NOT extreme values like 10/90
|
||||
- Use EMA crossover thresholds of 0 (cross above/below) for maximum trade frequency
|
||||
- Use causal indicators only: rolling windows, shift(1) — NO look-ahead bias
|
||||
- No factor data — compute everything from 'close'
|
||||
- Keep it simple: 2-3 indicators max"""
|
||||
|
||||
elif TRADING_STYLE == 'daytrading':
|
||||
system_prompt = f"""You are an expert daytrading quant specializing in EUR/USD scalping and intraday strategies.
|
||||
|
||||
CRITICAL RULES for {STYLE_DESC} (forward horizon: {FORWARD_BARS} bars = ~{FORWARD_BARS} minutes):
|
||||
@@ -162,27 +230,27 @@ CRITICAL RULES for {STYLE_DESC} (forward horizon: {FORWARD_BARS} bars = ~{FORWAR
|
||||
3. Create a pandas Series called 'signal' with values: 1 (long), -1 (short), 0 (neutral)
|
||||
4. signal.index MUST match close.index
|
||||
5. signal.name must be 'signal'
|
||||
6. Optimize for FREQUENT signals (many trades) since the horizon is only {FORWARD_BARS} minutes
|
||||
7. Use LOWER thresholds (0.2-0.5) to generate more trades for daytrading
|
||||
6. MANDATORY: signal must flip direction at least 300 times total — use low thresholds (0.1-0.3)
|
||||
7. Use rolling z-scores with SHORT windows (5-20 bars) and TIGHT thresholds
|
||||
|
||||
Output ONLY valid JSON with these fields:
|
||||
{{"strategy_name": "short_name", "factor_names": ["f1", "f2"], "description": "one sentence", "code": "python code"}}"""
|
||||
|
||||
|
||||
user_prompt = f"""Create a EUR/USD DAYTRADING strategy ({FORWARD_BARS}-minute horizon) using these factors:
|
||||
|
||||
{factor_list}
|
||||
|
||||
{f'Previous feedback: {feedback}' if feedback else 'First attempt - be creative!'}
|
||||
|
||||
Requirements for daytrading:
|
||||
- Use {FORWARD_BARS}-minute forward returns (not daily)
|
||||
- Generate frequent signals (aim for 20+ trades in the dataset)
|
||||
- Use rolling z-scores with short windows (10-30 bars)
|
||||
- Apply tight thresholds (0.2-0.5) for more trades
|
||||
- Combine momentum + mean-reversion effectively"""
|
||||
|
||||
Hard requirements:
|
||||
- signal must change at least 300 times total (~4 trades/day) — use thresholds of 0.1-0.3
|
||||
- NEVER use ffill() or forward-fill on the signal — recompute fresh at every bar
|
||||
- Use rolling z-scores with windows of 5-20 bars (not 50-100), thresholds ±0.2 to ±0.5
|
||||
- Combine 2 factors: one momentum, one mean-reversion
|
||||
- NO global mean/std — always use rolling(window).mean() with shift(1) to avoid look-ahead bias"""
|
||||
|
||||
else:
|
||||
system_prompt = f"""You are a quantitative trading expert specializing in EUR/USD intraday strategies.
|
||||
system_prompt = f"""You are a quantitative trading expert specializing in EUR/USD daily swing strategies.
|
||||
|
||||
CRITICAL RULES for {STYLE_DESC} (forward horizon: {FORWARD_BARS} bars = ~{FORWARD_BARS/60:.1f} hours):
|
||||
1. ONLY use the factors listed below - no others!
|
||||
@@ -190,15 +258,27 @@ CRITICAL RULES for {STYLE_DESC} (forward horizon: {FORWARD_BARS} bars = ~{FORWAR
|
||||
3. Create a pandas Series called 'signal' with values: 1 (long), -1 (short), 0 (neutral)
|
||||
4. signal.index MUST match close.index
|
||||
5. signal.name must be 'signal'
|
||||
6. IMPORTANT: factors are DAILY values broadcast to every 1-minute bar — they change once per day.
|
||||
Use daily-level logic: compare today's factor value to a rolling daily mean (window 5-20 DAYS).
|
||||
To get daily rolling mean: group by date, take first value per day, compute rolling, then reindex back.
|
||||
Example: dates = factors[col].index.get_level_values('datetime').normalize()
|
||||
daily_vals = factors[col].groupby(dates).first()
|
||||
daily_mean = daily_vals.rolling(10).mean().shift(1)
|
||||
daily_signal = (daily_vals > daily_mean).astype(int) * 2 - 1
|
||||
signal = daily_signal.reindex(dates).values (broadcast back to minute bars)
|
||||
7. The signal should change roughly once per day — this produces ~250-500 trades over 6 years.
|
||||
8. Keep conditions SIMPLE: one factor above/below its N-day rolling average. Avoid combining 3+ conditions.
|
||||
|
||||
Output ONLY valid JSON with these fields:
|
||||
{{"strategy_name": "short_name", "factor_names": ["f1", "f2"], "description": "one sentence", "code": "python code"}}"""
|
||||
|
||||
user_prompt = f"""Create a EUR/USD trading strategy using these factors:
|
||||
|
||||
user_prompt = f"""Create a EUR/USD SWING trading strategy (hold ~{FORWARD_BARS/60:.0f} hours) using these factors:
|
||||
|
||||
{factor_list}
|
||||
|
||||
{f'Previous feedback: {feedback}' if feedback else 'First attempt - be creative!'}"""
|
||||
{f'Previous feedback: {feedback}' if feedback else 'First attempt - be creative!'}
|
||||
|
||||
Use daily-level signal logic (factor above/below rolling daily mean). Signal changes once per day."""
|
||||
|
||||
api = APIBackend()
|
||||
response = api.build_messages_and_create_chat_completion(
|
||||
@@ -228,19 +308,27 @@ def run_backtest(close, factors_df, strategy_code):
|
||||
the signal, then delegate all metric computation to the unified
|
||||
``backtest_signal`` engine in the main process.
|
||||
"""
|
||||
if close is None or factors_df is None or len(factors_df.columns) < 2:
|
||||
if close is None:
|
||||
return None
|
||||
if not OHLCV_ONLY and (factors_df is None or len(factors_df.columns) < 2):
|
||||
return None
|
||||
|
||||
# Flatten MultiIndex — strategy code expects a plain DatetimeIndex
|
||||
if isinstance(close.index, pd.MultiIndex):
|
||||
close = close.droplevel(-1)
|
||||
close = close.sort_index()
|
||||
|
||||
import tempfile
|
||||
|
||||
# Subprocess stays minimal: it only runs the untrusted strategy code
|
||||
# and pickles the resulting signal. All numbers come from the shared engine.
|
||||
factors_line = "" if OHLCV_ONLY else "factors = pd.read_pickle('factors.pkl')"
|
||||
script = f"""
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
close = pd.read_pickle('close.pkl')
|
||||
factors = pd.read_pickle('factors.pkl')
|
||||
{factors_line}
|
||||
|
||||
try:
|
||||
{chr(10).join(' ' + l for l in strategy_code.split(chr(10)))}
|
||||
@@ -258,7 +346,8 @@ signal.fillna(0).to_pickle('signal.pkl')
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
tdp = Path(td)
|
||||
close.to_pickle(str(tdp / 'close.pkl'))
|
||||
factors_df.to_pickle(str(tdp / 'factors.pkl'))
|
||||
if not OHLCV_ONLY and factors_df is not None:
|
||||
factors_df.to_pickle(str(tdp / 'factors.pkl'))
|
||||
(tdp / 'run.py').write_text(script)
|
||||
|
||||
try:
|
||||
@@ -276,27 +365,80 @@ signal.fillna(0).to_pickle('signal.pkl')
|
||||
except Exception as e:
|
||||
return {'status': 'failed', 'reason': str(e)[:200]}
|
||||
|
||||
# Main process: unified backtest (identical formulas everywhere).
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
||||
# Main process: FTMO-realistic backtest (leverage + daily/total loss limits).
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_ftmo
|
||||
|
||||
common = close.index.intersection(signal.index)
|
||||
if len(common) < 100:
|
||||
return {'status': 'failed', 'reason': f'Not enough aligned data ({len(common)} bars)'}
|
||||
|
||||
close_a = close.loc[common]
|
||||
close_a = close.loc[common]
|
||||
signal_a = signal.reindex(common).fillna(0)
|
||||
|
||||
# Forward returns at the configured horizon feed IC computation.
|
||||
fwd_returns = close_a.pct_change(FORWARD_BARS).shift(-FORWARD_BARS)
|
||||
|
||||
return backtest_signal(
|
||||
from rdagent.components.backtesting.vbt_backtest import OOS_START_DEFAULT
|
||||
return backtest_signal_ftmo(
|
||||
close=close_a,
|
||||
signal=signal_a,
|
||||
txn_cost_bps=TXN_COST_BPS,
|
||||
freq='1min',
|
||||
forward_returns=fwd_returns,
|
||||
oos_start=OOS_START_DEFAULT,
|
||||
wf_rolling=False, # too slow on 2M bars — run via rebacktest script instead
|
||||
mc_n_permutations=50,
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# Threshold Tuner — relax numeric thresholds until MIN_TRADES is reached
|
||||
# ============================================================================
|
||||
def _rescale_thresholds(code: str, scale: float) -> str:
|
||||
"""
|
||||
Scale numeric literals in the strategy code that look like signal thresholds.
|
||||
RSI thresholds (30-70 range) are moved toward 50.
|
||||
Z-score / ratio thresholds (0.0–3.0 range) are multiplied by scale.
|
||||
"""
|
||||
import re
|
||||
|
||||
def replace_rsi(m):
|
||||
val = float(m.group(0))
|
||||
# Pull toward 50 by (1-scale) fraction
|
||||
new_val = 50 + (val - 50) * scale
|
||||
return f"{new_val:.1f}"
|
||||
|
||||
def replace_small(m):
|
||||
val = float(m.group(0))
|
||||
return f"{val * scale:.3f}"
|
||||
|
||||
# RSI-style thresholds: integers/floats between 10 and 90
|
||||
code = re.sub(r'\b([1-9]\d(?:\.\d+)?)\b', replace_rsi, code)
|
||||
# Small float thresholds: 0.05 – 2.99
|
||||
code = re.sub(r'\b(0\.\d+|[12]\.\d+)\b', replace_small, code)
|
||||
return code
|
||||
|
||||
|
||||
def tune_thresholds(close, factors_df, code: str) -> tuple:
|
||||
"""
|
||||
Binary-search scale factor (1.0 → 0.05) until n_trades >= MIN_TRADES.
|
||||
Returns (best_bt_result, tuned_code) where best_bt_result has max Sharpe
|
||||
among all runs that hit MIN_TRADES.
|
||||
"""
|
||||
best_bt, best_code = None, code
|
||||
|
||||
for scale in [1.0, 0.7, 0.5, 0.35, 0.2, 0.1, 0.05]:
|
||||
tuned = _rescale_thresholds(code, scale) if scale < 1.0 else code
|
||||
bt = run_backtest(close, factors_df, tuned)
|
||||
if bt is None or bt.get('status') != 'success':
|
||||
continue
|
||||
trades = bt.get('n_trades', 0)
|
||||
sharpe = bt.get('sharpe', -999)
|
||||
if trades >= MIN_TRADES:
|
||||
if best_bt is None or sharpe > best_bt.get('sharpe', -999):
|
||||
best_bt = bt
|
||||
best_code = tuned
|
||||
break # first scale that hits MIN_TRADES wins (they get looser after this)
|
||||
|
||||
return best_bt, best_code
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main Parallel Strategy Generation
|
||||
# ============================================================================
|
||||
@@ -314,6 +456,7 @@ def main(target_count=10):
|
||||
)
|
||||
|
||||
console.print(f"\n[bold cyan]{STYLE_EMOJI} Parallel Strategy Generation[/bold cyan]")
|
||||
console.print(f"[dim]Log: {_log_file_path}[/dim]")
|
||||
console.print(f" Style: {STYLE_DESC}")
|
||||
console.print(f" Forward bars: {FORWARD_BARS}")
|
||||
console.print(f" Target: {target_count} accepted strategies")
|
||||
@@ -373,38 +516,83 @@ def main(target_count=10):
|
||||
if len(accepted) >= target_count:
|
||||
break
|
||||
|
||||
# Select random factor subset (2-5 factors)
|
||||
n_factors = random.randint(2, min(5, len(factors)))
|
||||
factor_subset = random.sample(factors, n_factors)
|
||||
|
||||
# Select random factor subset (2-5 factors) — empty for OHLCV-only mode
|
||||
if OHLCV_ONLY:
|
||||
factor_subset = []
|
||||
else:
|
||||
n_factors = random.randint(2, min(5, len(factors)))
|
||||
factor_subset = random.sample(factors, n_factors)
|
||||
|
||||
feedback = feedback_history[-1] if feedback_history and random.random() < 0.7 else None
|
||||
|
||||
|
||||
# Generate in main process (LLM doesn't parallelize well)
|
||||
gen_result = generate_single_strategy((attempt, factor_subset, feedback, attempt))
|
||||
|
||||
|
||||
if gen_result['status'] != 'generated':
|
||||
progress.update(task, advance=1)
|
||||
continue
|
||||
|
||||
|
||||
strategy = gen_result['strategy']
|
||||
|
||||
|
||||
# Backtest (main process - needs data access)
|
||||
# Build factors DataFrame for this strategy
|
||||
strat_factors = df_aligned[[f for f in strategy.get('factor_names', []) if f in df_aligned.columns]]
|
||||
if len(strat_factors.columns) < 2:
|
||||
progress.update(task, advance=1)
|
||||
continue
|
||||
|
||||
bt_result = run_backtest(close_aligned, strat_factors, strategy.get('code', ''))
|
||||
if OHLCV_ONLY:
|
||||
strat_factors = None
|
||||
bt_result = run_backtest(close, None, strategy.get('code', ''))
|
||||
else:
|
||||
strat_factors = df_aligned[[f for f in strategy.get('factor_names', []) if f in df_aligned.columns]]
|
||||
if len(strat_factors.columns) < 2:
|
||||
progress.update(task, advance=1)
|
||||
continue
|
||||
bt_result = run_backtest(close_aligned, strat_factors, strategy.get('code', ''))
|
||||
|
||||
if bt_result and bt_result.get('status') == 'success':
|
||||
ic = bt_result.get('ic', 0)
|
||||
sharpe = bt_result.get('sharpe', 0)
|
||||
trades = bt_result.get('n_trades', 0)
|
||||
dd = bt_result.get('max_drawdown', 0)
|
||||
|
||||
# Check acceptance criteria
|
||||
if abs(ic) > MIN_IC and sharpe > MIN_SHARPE and trades > MIN_TRADES and dd > MAX_DRAWDOWN:
|
||||
|
||||
# If too few trades, auto-tune thresholds before giving up
|
||||
original_code = strategy.get('code', '')
|
||||
if trades < MIN_TRADES and bt_result.get('status') == 'success':
|
||||
_log.info(f"TUNING trades={trades}<{MIN_TRADES} — trying looser thresholds")
|
||||
tuned_bt, tuned_code = tune_thresholds(
|
||||
close if OHLCV_ONLY else close_aligned,
|
||||
None if OHLCV_ONLY else strat_factors,
|
||||
original_code,
|
||||
)
|
||||
if tuned_bt and tuned_bt.get('n_trades', 0) >= MIN_TRADES:
|
||||
bt_result = tuned_bt
|
||||
strategy['code'] = tuned_code
|
||||
ic = bt_result.get('ic', 0)
|
||||
sharpe = bt_result.get('sharpe', 0)
|
||||
trades = bt_result.get('n_trades', 0)
|
||||
dd = bt_result.get('max_drawdown', 0)
|
||||
_log.info(f"TUNED Sharpe={sharpe:.2f} Trades={trades}")
|
||||
|
||||
# OOS metrics — mandatory, no fallback to IS values
|
||||
oos_sharpe = bt_result.get('oos_sharpe')
|
||||
oos_monthly = bt_result.get('oos_monthly_return_pct')
|
||||
oos_trades = bt_result.get('oos_n_trades', 0)
|
||||
|
||||
# Reject if OOS data is missing (strategy trained on data without OOS period)
|
||||
if oos_sharpe is None or oos_monthly is None:
|
||||
_log.info(f"REJECTED no OOS data (data ends before {OOS_START_DEFAULT}?)")
|
||||
feedback_history.append(f"Rejected: no out-of-sample data after {OOS_START_DEFAULT}.")
|
||||
progress.update(task, advance=1)
|
||||
continue
|
||||
|
||||
# Monte Carlo p-value (edge significance)
|
||||
mc_pvalue = bt_result.get('mc_pvalue')
|
||||
|
||||
# Rolling walk-forward metrics
|
||||
wf_consistency = bt_result.get('wf_oos_consistency')
|
||||
wf_sharpe_mean = bt_result.get('wf_oos_sharpe_mean')
|
||||
|
||||
# Check acceptance criteria — OOS must be profitable + statistically significant
|
||||
mc_ok = mc_pvalue is None or mc_pvalue < 0.20 # lenient: top 20% non-random
|
||||
wf_ok = wf_consistency is None or wf_consistency >= 0.5 # ≥50% of WF windows profitable
|
||||
if (abs(ic or 0) > MIN_IC and sharpe > MIN_SHARPE and trades > MIN_TRADES and dd > MAX_DRAWDOWN
|
||||
and oos_sharpe > 0.0 and oos_monthly > 0.0 and mc_ok and wf_ok):
|
||||
# ACCEPT
|
||||
strategy['real_backtest'] = bt_result
|
||||
strategy['metrics'] = bt_result
|
||||
@@ -414,6 +602,28 @@ def main(target_count=10):
|
||||
'annual_return_pct': bt_result.get('annual_return_pct', 0),
|
||||
'real_ic': ic, 'real_n_trades': trades, 'real_backtest_status': 'success',
|
||||
'n_bars': bt_result.get('n_bars', 0), 'n_months': bt_result.get('n_months', 0),
|
||||
'trading_style': TRADING_STYLE,
|
||||
'ohlcv_only': OHLCV_ONLY,
|
||||
'engine': 'ftmo_v2',
|
||||
'txn_cost_bps': TXN_COST_BPS,
|
||||
# Walk-forward OOS split
|
||||
'oos_sharpe': bt_result.get('oos_sharpe'),
|
||||
'oos_monthly_return_pct': bt_result.get('oos_monthly_return_pct'),
|
||||
'oos_max_drawdown': bt_result.get('oos_max_drawdown'),
|
||||
'oos_win_rate': bt_result.get('oos_win_rate'),
|
||||
'oos_n_trades': bt_result.get('oos_n_trades'),
|
||||
'is_sharpe': bt_result.get('is_sharpe'),
|
||||
'is_monthly_return_pct': bt_result.get('is_monthly_return_pct'),
|
||||
'oos_start': bt_result.get('oos_start'),
|
||||
# Rolling walk-forward
|
||||
'wf_n_windows': bt_result.get('wf_n_windows'),
|
||||
'wf_oos_sharpe_mean': wf_sharpe_mean,
|
||||
'wf_oos_sharpe_std': bt_result.get('wf_oos_sharpe_std'),
|
||||
'wf_oos_monthly_return_mean': bt_result.get('wf_oos_monthly_return_mean'),
|
||||
'wf_oos_consistency': wf_consistency,
|
||||
# Monte Carlo significance
|
||||
'mc_pvalue': mc_pvalue,
|
||||
'mc_n_permutations': bt_result.get('mc_n_permutations'),
|
||||
}
|
||||
|
||||
fname = f"{int(time.time())}_{strategy['strategy_name']}.json"
|
||||
@@ -435,8 +645,19 @@ def main(target_count=10):
|
||||
progress.console.print(f"[green]✓ Strategy #{len(accepted)}:[/green] {strategy['strategy_name']} "
|
||||
f"IC={ic:.4f}, Sharpe={sharpe:.3f}, Trades={trades}, DD={dd:.1%}")
|
||||
else:
|
||||
_log.info(f"REJECTED IC={ic:.4f} Sharpe={sharpe:.2f} Trades={trades} DD={dd:.1%}")
|
||||
feedback_history.append(f"Failed: IC={ic:.4f}, Sharpe={sharpe:.2f}, Trades={trades}, DD={dd:.1%}. Need |IC|>{MIN_IC}, Sharpe>{MIN_SHARPE}, Trades>{MIN_TRADES}")
|
||||
oos_info = f"OOS_Sharpe={oos_sharpe:+.2f} OOS_Mon={oos_monthly:+.2f}%" if oos_sharpe is not None else ""
|
||||
mc_info = f" MC_p={mc_pvalue:.2f}" if mc_pvalue is not None else ""
|
||||
wf_info = f" WF_consistency={wf_consistency:.0%}" if wf_consistency is not None else ""
|
||||
_ic = ic or 0; _sh = sharpe or 0; _dd = dd or 0
|
||||
_log.info(f"REJECTED IC={_ic:.4f} Sharpe={_sh:.2f} Trades={trades} DD={_dd:.1%} {oos_info}{mc_info}{wf_info}")
|
||||
feedback_history.append(
|
||||
f"Failed: IC={_ic:.4f}, Sharpe={_sh:.2f}, Trades={trades}, DD={_dd:.1%}, "
|
||||
f"OOS_Sharpe={oos_sharpe:+.2f}, OOS_Monthly={oos_monthly:+.2f}%"
|
||||
+ (f", MC_p={mc_pvalue:.2f}" if mc_pvalue is not None else "")
|
||||
+ (f", WF_consistency={wf_consistency:.0%}" if wf_consistency is not None else "")
|
||||
+ f". Need |IC|>{MIN_IC}, Sharpe>{MIN_SHARPE}, Trades>{MIN_TRADES}, "
|
||||
f"OOS_Sharpe>0, OOS_Monthly>0, MC_p<0.20, WF_consistency≥50%."
|
||||
)
|
||||
|
||||
progress.update(task, advance=1)
|
||||
|
||||
|
||||
@@ -22,9 +22,11 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -34,13 +36,41 @@ from rich.console import Console
|
||||
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal # noqa: E402
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_ftmo # noqa: E402
|
||||
|
||||
OHLCV_PATH = Path("/home/nico/Predix/git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
|
||||
FACTORS_VALUES_DIR = Path("/home/nico/Predix/results/factors/values")
|
||||
STRATEGIES_DIR = Path("/home/nico/Predix/results/strategies_new")
|
||||
|
||||
console = Console()
|
||||
# ── Logging setup: everything printed goes to log file + stdout ───────────────
|
||||
_LOG_DIR = Path(__file__).resolve().parent.parent / "git_ignore_folder" / "logs"
|
||||
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_log_file_path = _LOG_DIR / f"rebacktest_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
|
||||
_log_file = open(_log_file_path, "w", encoding="utf-8", buffering=1)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler(_log_file_path, encoding="utf-8"),
|
||||
],
|
||||
)
|
||||
|
||||
class _TeeFile:
|
||||
"""Writes to both stdout and log file — used as Rich Console file."""
|
||||
def __init__(self, *files):
|
||||
self._files = files
|
||||
def write(self, data):
|
||||
for f in self._files:
|
||||
f.write(data)
|
||||
def flush(self):
|
||||
for f in self._files:
|
||||
f.flush()
|
||||
def fileno(self):
|
||||
return self._files[0].fileno()
|
||||
|
||||
console = Console(file=_TeeFile(sys.stdout, _log_file), highlight=False)
|
||||
|
||||
|
||||
def load_close() -> pd.Series:
|
||||
@@ -154,11 +184,12 @@ def rebacktest_one(
|
||||
# Signal can arrive on either the factor index or the close index.
|
||||
signal = signal.reindex(close_a.index).ffill().fillna(0)
|
||||
|
||||
result = backtest_signal(
|
||||
result = backtest_signal_ftmo(
|
||||
close=close_a,
|
||||
signal=signal,
|
||||
txn_cost_bps=txn_cost_bps,
|
||||
freq="1min",
|
||||
wf_rolling=True,
|
||||
mc_n_permutations=200,
|
||||
)
|
||||
result["status_detail"] = result.pop("status")
|
||||
result["status"] = "ok"
|
||||
@@ -173,9 +204,13 @@ def main() -> None:
|
||||
help="Strategy directory to re-backtest")
|
||||
parser.add_argument("--csv", type=Path, default=None,
|
||||
help="Write a CSV report to this path")
|
||||
parser.add_argument("--txn-cost-bps", type=float, default=1.5)
|
||||
parser.add_argument("--txn-cost-bps", type=float, default=2.14,
|
||||
help="Transaction cost bps (default 2.14 ≈ 2.35 pip EUR/USD)")
|
||||
parser.add_argument("--write-back", action="store_true",
|
||||
help="Overwrite summary field in strategy JSON files with new results")
|
||||
args = parser.parse_args()
|
||||
|
||||
console.print(f"[dim]Log: {_log_file_path}[/dim]")
|
||||
console.print(f"[cyan]Loading OHLCV close...[/cyan]")
|
||||
close = load_close()
|
||||
console.print(f"[green]✓[/green] {len(close):,} 1-min bars "
|
||||
@@ -207,6 +242,51 @@ def main() -> None:
|
||||
|
||||
bt = rebacktest_one(data, close, args.txn_cost_bps)
|
||||
|
||||
if args.write_back and bt.get("status") == "ok":
|
||||
data["summary"] = {
|
||||
"sharpe": bt.get("sharpe"),
|
||||
"max_drawdown": bt.get("max_drawdown"),
|
||||
"win_rate": bt.get("win_rate"),
|
||||
"monthly_return_pct": bt.get("monthly_return_pct"),
|
||||
"real_ic": data.get("summary", {}).get("real_ic"),
|
||||
"real_n_trades": bt.get("n_trades"),
|
||||
"total_return": bt.get("total_return"),
|
||||
"annualized_return": bt.get("annualized_return"),
|
||||
"ftmo_daily_loss_hit": bt.get("ftmo_daily_loss_hit"),
|
||||
"ftmo_total_loss_hit": bt.get("ftmo_total_loss_hit"),
|
||||
"trading_style": data.get("summary", {}).get("trading_style"),
|
||||
"engine": "ftmo_v2",
|
||||
"txn_cost_bps": args.txn_cost_bps,
|
||||
# Walk-forward OOS
|
||||
"is_sharpe": bt.get("is_sharpe"),
|
||||
"is_monthly_return_pct": bt.get("is_monthly_return_pct"),
|
||||
"oos_sharpe": bt.get("oos_sharpe"),
|
||||
"oos_monthly_return_pct": bt.get("oos_monthly_return_pct"),
|
||||
"oos_max_drawdown": bt.get("oos_max_drawdown"),
|
||||
"oos_win_rate": bt.get("oos_win_rate"),
|
||||
"oos_n_trades": bt.get("oos_n_trades"),
|
||||
"oos_start": bt.get("oos_start"),
|
||||
# Rolling walk-forward
|
||||
"wf_n_windows": bt.get("wf_n_windows"),
|
||||
"wf_oos_sharpe_mean": bt.get("wf_oos_sharpe_mean"),
|
||||
"wf_oos_sharpe_std": bt.get("wf_oos_sharpe_std"),
|
||||
"wf_oos_monthly_return_mean": bt.get("wf_oos_monthly_return_mean"),
|
||||
"wf_oos_consistency": bt.get("wf_oos_consistency"),
|
||||
# Monte Carlo significance
|
||||
"mc_pvalue": bt.get("mc_pvalue"),
|
||||
"mc_n_permutations": bt.get("mc_n_permutations"),
|
||||
}
|
||||
data["sharpe_ratio"] = bt.get("sharpe")
|
||||
data["max_drawdown"] = bt.get("max_drawdown")
|
||||
data["win_rate"] = bt.get("win_rate")
|
||||
data["total_return"] = bt.get("total_return")
|
||||
data["reevaluation_status"] = "ftmo_v2"
|
||||
try:
|
||||
import json as _json
|
||||
f.write_text(_json.dumps(data, indent=2, ensure_ascii=False))
|
||||
except Exception as _e:
|
||||
logging.warning(f"write-back failed for {f.name}: {_e}")
|
||||
|
||||
row = {
|
||||
"file": f.name,
|
||||
"name": name,
|
||||
@@ -220,10 +300,23 @@ def main() -> None:
|
||||
"new_dd": bt.get("max_drawdown"),
|
||||
"new_trades": bt.get("n_trades"),
|
||||
"new_total_return": bt.get("total_return"),
|
||||
"new_monthly_pct": bt.get("monthly_return_pct"),
|
||||
"new_annual_return_cagr": None,
|
||||
"data_quality": bt.get("data_quality_flag"),
|
||||
# OOS walk-forward
|
||||
"is_sharpe": bt.get("is_sharpe"),
|
||||
"is_monthly_pct": bt.get("is_monthly_return_pct"),
|
||||
"oos_sharpe": bt.get("oos_sharpe"),
|
||||
"oos_monthly_pct": bt.get("oos_monthly_return_pct"),
|
||||
"oos_dd": bt.get("oos_max_drawdown"),
|
||||
"oos_trades": bt.get("oos_n_trades"),
|
||||
# Rolling walk-forward
|
||||
"wf_n_windows": bt.get("wf_n_windows"),
|
||||
"wf_oos_sharpe_mean": bt.get("wf_oos_sharpe_mean"),
|
||||
"wf_oos_consistency": bt.get("wf_oos_consistency"),
|
||||
# Monte Carlo
|
||||
"mc_pvalue": bt.get("mc_pvalue"),
|
||||
}
|
||||
# annualized CAGR is not in forward_returns wrapper; use annual_return_pct/100 proxy
|
||||
if "annualized_return" in bt:
|
||||
row["new_annual_return_cagr"] = bt["annualized_return"]
|
||||
rows.append(row)
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
Realistic backtest of all strategies in results/strategies_new/.
|
||||
|
||||
Costs modeled per trade:
|
||||
1.5 pip spread + 0.5 pip slippage + 0.35 pip commission = 2.35 pip total
|
||||
|
||||
FTMO 100k rules enforced:
|
||||
- Max daily loss: 5% of initial balance ($5,000) → no trading rest of day if hit
|
||||
- Max total loss: 10% of initial balance ($10,000) → account blown, simulation ends
|
||||
- Position sizing: 1% equity risk per trade, 10-pip stop (no artificial lot cap)
|
||||
- Max leverage: 1:30 (EU regulation standard, FTMO default)
|
||||
- Compounding: position size grows with equity each trade
|
||||
|
||||
Out-of-sample window: 2024-01-01 onwards (never seen during factor research).
|
||||
|
||||
Usage:
|
||||
conda activate predix
|
||||
python scripts/realistic_backtest_all.py
|
||||
python scripts/realistic_backtest_all.py --target-monthly 4.0 --min-trades 50
|
||||
python scripts/realistic_backtest_all.py --workers 8
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import glob
|
||||
import os
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
# ── Constants ──────────────────────────────────────────────────────────────────
|
||||
DATA_H5 = Path("git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
|
||||
FACTOR_DIR = Path("results/factors/values")
|
||||
STRAT_DIR = Path("results/strategies_new")
|
||||
OUTPUT_DIR = Path("results/realistic_backtest")
|
||||
|
||||
PIP = 0.0001
|
||||
COST_ENTRY = 2.0 * PIP # spread + slippage
|
||||
COST_EXIT = 0.35 * PIP # commission
|
||||
RISK_PCT = 0.01 # 1% equity risk per trade
|
||||
STOP = 10 * PIP # 10-pip hard stop
|
||||
MAX_LEVERAGE = 30 # 1:30 max leverage (FTMO / EU standard)
|
||||
FTMO_MAX_DAILY = 0.05 # 5% max daily loss of initial balance
|
||||
FTMO_MAX_TOTAL = 0.10 # 10% max total loss of initial balance
|
||||
OOS_START = "2024-01-01"
|
||||
|
||||
|
||||
def _load_market_data() -> tuple[pd.Series, str]:
|
||||
raw = pd.read_hdf(DATA_H5, key="data")
|
||||
instrument = raw.index.get_level_values("instrument").unique()[0]
|
||||
ohlcv = raw.xs(instrument, level="instrument").rename(columns={
|
||||
"$open": "open", "$high": "high", "$low": "low",
|
||||
"$close": "close", "$volume": "volume",
|
||||
})
|
||||
return ohlcv["close"], instrument
|
||||
|
||||
|
||||
def _load_factor(name: str, full_idx: pd.Index, instrument: str) -> pd.Series | None:
|
||||
path = FACTOR_DIR / f"{name}.parquet"
|
||||
if not path.exists():
|
||||
return None
|
||||
df = pd.read_parquet(path)
|
||||
if isinstance(df.index, pd.MultiIndex):
|
||||
try:
|
||||
s = df.xs(instrument, level="instrument").iloc[:, 0]
|
||||
except KeyError:
|
||||
s = df.iloc[:, 0]
|
||||
else:
|
||||
s = df.iloc[:, 0]
|
||||
return s.reindex(full_idx)
|
||||
|
||||
|
||||
def _build_signal(factor_names: list[str], full_idx: pd.Index,
|
||||
instrument: str, code: str) -> pd.Series | None:
|
||||
"""Build composite z-score signal (same logic as the strategy code uses)."""
|
||||
factors: dict[str, pd.Series] = {}
|
||||
for fn in factor_names:
|
||||
s = _load_factor(fn, full_idx, instrument)
|
||||
if s is None:
|
||||
return None
|
||||
factors[fn] = s
|
||||
|
||||
# Try to reproduce the signal via the original strategy code
|
||||
close = pd.Series(np.zeros(len(full_idx)), index=full_idx) # not used by signal code
|
||||
try:
|
||||
local_ns: dict = {"pd": pd, "np": np, "close": close, "factors": factors}
|
||||
exec(code, local_ns) # noqa: S102
|
||||
sig = local_ns.get("signal")
|
||||
if sig is not None and isinstance(sig, pd.Series):
|
||||
return sig.reindex(full_idx).fillna(0).astype(int)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: generic composite z-score (same as original loop)
|
||||
composite = pd.Series(0.0, index=full_idx)
|
||||
for fn, s in factors.items():
|
||||
s = s.fillna(0)
|
||||
std = s.std()
|
||||
if std > 0:
|
||||
composite += (s - s.mean()) / std
|
||||
sig = pd.Series(0, index=full_idx)
|
||||
sig[composite > 0.5] = 1
|
||||
sig[composite < -0.5] = -1
|
||||
return sig
|
||||
|
||||
|
||||
def _run_engine(sig_arr: np.ndarray, px_arr: np.ndarray,
|
||||
ts_arr: np.ndarray) -> dict:
|
||||
"""
|
||||
FTMO-compliant backtest engine.
|
||||
|
||||
Rules enforced:
|
||||
- Daily loss limit: if daily PnL < -5% of initial ($5k), no new trades that day
|
||||
- Total loss limit: if equity < $90k (10% below initial), simulation ends (account blown)
|
||||
- Position sizing: 1% equity risk per trade, 10-pip stop, max leverage 1:30
|
||||
- Full compounding: position size recalculated from current equity each trade
|
||||
"""
|
||||
INITIAL = 100_000.0
|
||||
equity = INITIAL
|
||||
peak = INITIAL
|
||||
max_dd = 0.0
|
||||
pos = 0
|
||||
entry_px = 0.0
|
||||
pos_size = 0.0
|
||||
n_wins = 0
|
||||
trade_rets: list[float] = []
|
||||
blown = False
|
||||
|
||||
# Daily tracking
|
||||
current_day = None
|
||||
day_start_eq = INITIAL
|
||||
day_blocked = False
|
||||
|
||||
for i in range(1, len(px_arr)):
|
||||
p = float(px_arr[i])
|
||||
sig_i = int(sig_arr[i])
|
||||
day = ts_arr[i].astype("datetime64[D]")
|
||||
|
||||
# ── New day: reset daily loss tracker ────────────────────────────────
|
||||
if day != current_day:
|
||||
current_day = day
|
||||
day_start_eq = equity
|
||||
day_blocked = False
|
||||
|
||||
# ── Close position if signal flips ────────────────────────────────────
|
||||
if pos != 0 and sig_i != pos:
|
||||
exit_p = p - pos * COST_EXIT
|
||||
raw_pnl = (exit_p - entry_px) * pos_size * pos
|
||||
equity += raw_pnl
|
||||
|
||||
if equity > peak:
|
||||
peak = equity
|
||||
dd = (peak - equity) / peak
|
||||
if dd > max_dd:
|
||||
max_dd = dd
|
||||
|
||||
ret = raw_pnl / (pos_size * entry_px) if (pos_size * entry_px) > 0 else 0.0
|
||||
trade_rets.append(ret)
|
||||
if raw_pnl > 0:
|
||||
n_wins += 1
|
||||
pos = 0
|
||||
|
||||
# Check daily loss limit
|
||||
if (equity - day_start_eq) / INITIAL < -FTMO_MAX_DAILY:
|
||||
day_blocked = True
|
||||
|
||||
# Check total loss limit → account blown
|
||||
if equity < INITIAL * (1 - FTMO_MAX_TOTAL):
|
||||
blown = True
|
||||
break
|
||||
|
||||
# ── Open new position (if not blocked) ───────────────────────────────
|
||||
if sig_i != 0 and pos == 0 and not day_blocked and not blown:
|
||||
pos = sig_i
|
||||
entry_px = p + pos * COST_ENTRY
|
||||
# Full compounding: size from current equity, capped by max leverage
|
||||
max_by_leverage = equity * MAX_LEVERAGE / p
|
||||
pos_size = min(equity * RISK_PCT / STOP, max_by_leverage)
|
||||
|
||||
ret_arr = np.array(trade_rets) if trade_rets else np.array([0.0])
|
||||
n_trades = len(trade_rets)
|
||||
total_ret = (equity - INITIAL) / INITIAL
|
||||
sharpe = float("nan")
|
||||
if n_trades > 1 and ret_arr.std() > 0:
|
||||
sharpe = float(ret_arr.mean() / ret_arr.std() * np.sqrt(n_trades))
|
||||
|
||||
return dict(
|
||||
end_equity=equity,
|
||||
total_return=total_ret,
|
||||
max_drawdown=-max_dd,
|
||||
sharpe=sharpe,
|
||||
n_trades=n_trades,
|
||||
win_rate=n_wins / n_trades if n_trades else 0.0,
|
||||
trade_rets=ret_arr,
|
||||
blown=blown,
|
||||
)
|
||||
|
||||
|
||||
def _monthly_ret(total_ret: float, n_months: float) -> float:
|
||||
return float((1 + total_ret) ** (1 / max(n_months, 1)) - 1)
|
||||
|
||||
|
||||
def backtest_strategy(json_path: str, close: pd.Series, instrument: str) -> dict | None:
|
||||
try:
|
||||
d = json.load(open(json_path))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
factor_names = d.get("factor_names", [])
|
||||
code = d.get("code", "")
|
||||
name = d.get("strategy_name", Path(json_path).stem)
|
||||
|
||||
if not factor_names:
|
||||
return None
|
||||
|
||||
sig = _build_signal(factor_names, close.index, instrument, code)
|
||||
if sig is None:
|
||||
return None
|
||||
|
||||
# Full period
|
||||
full = _run_engine(sig.values, close.values, close.index.values)
|
||||
n_days_full = (close.index[-1] - close.index[0]).days
|
||||
n_months_full = n_days_full / 30.44
|
||||
|
||||
# OOS only
|
||||
oos_mask = close.index >= OOS_START
|
||||
if oos_mask.sum() < 1000:
|
||||
return None
|
||||
oos_close = close[oos_mask]
|
||||
oos_sig = sig[oos_mask]
|
||||
oos = _run_engine(oos_sig.values, oos_close.values, oos_close.index.values)
|
||||
n_months_oos = (oos_close.index[-1] - oos_close.index[0]).days / 30.44
|
||||
|
||||
return dict(
|
||||
name=name,
|
||||
path=json_path,
|
||||
factors=factor_names,
|
||||
# Full
|
||||
full_monthly_pct=_monthly_ret(full["total_return"], n_months_full) * 100,
|
||||
full_annual_pct=((1 + _monthly_ret(full["total_return"], n_months_full)) ** 12 - 1) * 100,
|
||||
full_dd_pct=full["max_drawdown"] * 100,
|
||||
full_sharpe=full["sharpe"],
|
||||
full_trades=full["n_trades"],
|
||||
full_winrate=full["win_rate"] * 100,
|
||||
full_blown=full["blown"],
|
||||
# OOS
|
||||
oos_monthly_pct=_monthly_ret(oos["total_return"], n_months_oos) * 100,
|
||||
oos_annual_pct=((1 + _monthly_ret(oos["total_return"], n_months_oos)) ** 12 - 1) * 100,
|
||||
oos_dd_pct=oos["max_drawdown"] * 100,
|
||||
oos_sharpe=oos["sharpe"],
|
||||
oos_trades=oos["n_trades"],
|
||||
oos_winrate=oos["win_rate"] * 100,
|
||||
oos_end_equity=oos["end_equity"],
|
||||
oos_blown=oos["blown"],
|
||||
n_months_oos=n_months_oos,
|
||||
)
|
||||
|
||||
|
||||
def _worker(args: tuple) -> dict | None:
|
||||
json_path, close_bytes, instrument = args
|
||||
close = pd.read_pickle(close_bytes) if isinstance(close_bytes, (str, Path)) else close_bytes
|
||||
return backtest_strategy(json_path, close, instrument)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Realistic backtest of all strategies")
|
||||
parser.add_argument("--target-monthly", type=float, default=4.0,
|
||||
help="Minimum OOS monthly return %% (default: 4.0)")
|
||||
parser.add_argument("--min-trades", type=int, default=30,
|
||||
help="Minimum OOS trades (default: 30)")
|
||||
parser.add_argument("--max-dd", type=float, default=-8.0,
|
||||
help="Maximum OOS drawdown %% (default: -8.0)")
|
||||
parser.add_argument("--workers", type=int, default=4,
|
||||
help="Parallel workers (default: 4)")
|
||||
parser.add_argument("--top", type=int, default=20,
|
||||
help="Show top N strategies (default: 20)")
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"\nLoading market data...")
|
||||
close, instrument = _load_market_data()
|
||||
print(f" {close.index[0].date()} → {close.index[-1].date()} | {len(close):,} bars")
|
||||
print(f" OOS window: {OOS_START} onwards")
|
||||
print(f" Costs: 2.35 pip/trade (1.5 spread + 0.5 slip + 0.35 comm)")
|
||||
print(f" Filters: OOS monthly ≥ {args.target_monthly}% | trades ≥ {args.min_trades} | DD ≥ {args.max_dd}%\n")
|
||||
|
||||
json_files = sorted(glob.glob(str(STRAT_DIR / "*.json")))
|
||||
print(f"Backtesting {len(json_files)} strategies with {args.workers} workers...\n")
|
||||
|
||||
# Save close to temp file for multiprocessing
|
||||
import tempfile
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".pkl", delete=False)
|
||||
close.to_pickle(tmp.name)
|
||||
tmp.close()
|
||||
|
||||
results = []
|
||||
done = 0
|
||||
errors = 0
|
||||
|
||||
try:
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
futures = {
|
||||
ex.submit(backtest_strategy, fp, close, instrument): fp
|
||||
for fp in json_files
|
||||
}
|
||||
for fut in as_completed(futures):
|
||||
done += 1
|
||||
try:
|
||||
res = fut.result()
|
||||
if res is not None:
|
||||
results.append(res)
|
||||
except Exception:
|
||||
errors += 1
|
||||
if done % 100 == 0 or done == len(json_files):
|
||||
print(f" {done}/{len(json_files)} done, {len(results)} valid, {errors} errors")
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
if not results:
|
||||
print("No valid results.")
|
||||
return
|
||||
|
||||
df = pd.DataFrame(results)
|
||||
|
||||
# ── Save full results ──────────────────────────────────────────────────────
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
out_csv = OUTPUT_DIR / "all_strategies_realistic.csv"
|
||||
df.sort_values("oos_monthly_pct", ascending=False).to_csv(out_csv, index=False)
|
||||
print(f"\nFull results saved → {out_csv}")
|
||||
|
||||
# ── Filter for target ──────────────────────────────────────────────────────
|
||||
hits = df[
|
||||
(df["oos_monthly_pct"] >= args.target_monthly) &
|
||||
(df["oos_trades"] >= args.min_trades) &
|
||||
(df["oos_dd_pct"] >= args.max_dd) &
|
||||
(df["oos_blown"] == False) # noqa: E712
|
||||
].sort_values("oos_monthly_pct", ascending=False)
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print(f" Strategies meeting target: OOS monthly ≥ {args.target_monthly}% | "
|
||||
f"trades ≥ {args.min_trades} | DD ≥ {args.max_dd}%")
|
||||
print(f" Found: {len(hits)} / {len(df)}")
|
||||
print(f"{'='*70}\n")
|
||||
|
||||
top = hits.head(args.top)
|
||||
if top.empty:
|
||||
print(" No strategies met the criteria.")
|
||||
# Show best available
|
||||
best = df.sort_values("oos_monthly_pct", ascending=False).head(10)
|
||||
print(f"\n Best available (by OOS monthly return):\n")
|
||||
_print_table(best)
|
||||
else:
|
||||
_print_table(top)
|
||||
|
||||
# ── Save filtered results ──────────────────────────────────────────────────
|
||||
if not hits.empty:
|
||||
out_hits = OUTPUT_DIR / f"strategies_oos_{args.target_monthly}pct_monthly.csv"
|
||||
hits.to_csv(out_hits, index=False)
|
||||
print(f"\nFiltered results saved → {out_hits}")
|
||||
|
||||
# ── FTMO projection for #1 ────────────────────────────────────────────────
|
||||
best_row = (hits if not hits.empty else df.sort_values("oos_monthly_pct", ascending=False)).iloc[0]
|
||||
mon = best_row["oos_monthly_pct"]
|
||||
dd = abs(best_row["oos_dd_pct"])
|
||||
gross = 100_000 * mon / 100
|
||||
challenge_m = 10 / max(mon, 0.01)
|
||||
print(f"\n{'='*70}")
|
||||
print(f" FTMO 100k projection — #{1}: {best_row['name']}")
|
||||
print(f"{'='*70}")
|
||||
print(f" OOS monthly return: {mon:+.2f}%")
|
||||
print(f" Monthly gross profit: ${gross:,.0f}")
|
||||
print(f" Trader share (80%): ${gross*0.8:,.0f} / month")
|
||||
print(f" Trader annual (80%): ${gross*0.8*12:,.0f} / year")
|
||||
print(f" OOS Max Drawdown: {-dd:.2f}% (FTMO limit: 10%)")
|
||||
print(f" Challenge duration: ~{challenge_m:.1f} months to hit +10%")
|
||||
print(f" FTMO safe? {'YES ✓' if dd < 8 else 'BORDERLINE ⚠' if dd < 10 else 'NO ✗'}")
|
||||
|
||||
|
||||
def _print_table(df: pd.DataFrame) -> None:
|
||||
hdr = f"{'#':>3} {'Name':<35} {'OOS Mon%':>8} {'OOS DD%':>8} {'Sharpe':>7} {'WinR%':>6} {'Trades':>7} {'Blown':>6} {'Factors'}"
|
||||
print(hdr)
|
||||
print("-" * len(hdr))
|
||||
for i, (_, r) in enumerate(df.iterrows(), 1):
|
||||
factors_str = ",".join(r["factors"][:2]) + ("…" if len(r["factors"]) > 2 else "")
|
||||
blown = "💥YES" if r.get("oos_blown") else " no"
|
||||
print(f"{i:>3} {r['name']:<35} {r['oos_monthly_pct']:>+7.2f}% "
|
||||
f"{r['oos_dd_pct']:>+7.2f}% {r['oos_sharpe']:>7.2f} "
|
||||
f"{r['oos_winrate']:>5.1f}% {r['oos_trades']:>7,} {blown} {factors_str}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Tests for backtest_signal_ftmo and walk-forward OOS validation.
|
||||
|
||||
Covers:
|
||||
- FTMO daily/total loss limits
|
||||
- Risk-based leverage calculation
|
||||
- OOS split returns independent IS and OOS metrics
|
||||
- OOS uses fresh FTMO simulation (not contaminated by IS losses)
|
||||
- Monte Carlo permutation test helper
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from rdagent.components.backtesting.vbt_backtest import (
|
||||
OOS_START_DEFAULT,
|
||||
backtest_signal_ftmo,
|
||||
FTMO_MAX_DAILY_LOSS,
|
||||
FTMO_MAX_TOTAL_LOSS,
|
||||
monte_carlo_trade_pvalue,
|
||||
walk_forward_rolling,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.fixture
|
||||
def close_2yr() -> pd.Series:
|
||||
"""~3 months of synthetic 1-min EUR/USD (enough bars for all leverage/FTMO tests)."""
|
||||
np.random.seed(42)
|
||||
n = 90 * 1440 # 90 days × 1440 min
|
||||
idx = pd.date_range("2022-01-01", periods=n, freq="1min")
|
||||
price = 1.10 + np.cumsum(np.random.randn(n) * 0.00005)
|
||||
return pd.Series(price, index=idx)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def close_6yr() -> pd.Series:
|
||||
"""Synthetic data crossing the 2024-01-01 IS/OOS boundary.
|
||||
|
||||
120 days starting 2023-09-01 → ends ~2024-01-01, giving ~30 days of OOS data.
|
||||
Small enough to keep tests fast.
|
||||
"""
|
||||
np.random.seed(7)
|
||||
n = 150 * 1440 # 2023-09-01 + 150d ≈ 2024-01-28 → ~28 days of OOS data
|
||||
idx = pd.date_range("2023-09-01", periods=n, freq="1min")
|
||||
price = 1.10 + np.cumsum(np.random.randn(n) * 0.00005)
|
||||
return pd.Series(price, index=idx)
|
||||
|
||||
|
||||
def _random_signal(index: pd.Index, seed: int = 0) -> pd.Series:
|
||||
np.random.seed(seed)
|
||||
return pd.Series(np.random.choice([-1.0, 0.0, 1.0], size=len(index)), index=index)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FTMO leverage tests
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_ftmo_result_contains_leverage_fields(close_2yr):
|
||||
signal = _random_signal(close_2yr.index)
|
||||
r = backtest_signal_ftmo(close_2yr, signal, oos_start=None)
|
||||
assert "ftmo_leverage" in r
|
||||
assert "ftmo_risk_pct" in r
|
||||
assert "ftmo_stop_pips" in r
|
||||
assert r["ftmo_leverage"] > 0
|
||||
|
||||
|
||||
def test_ftmo_leverage_capped_at_max(close_2yr):
|
||||
signal = _random_signal(close_2yr.index)
|
||||
# With very tight stop (1 pip) risk_pct=0.5% → leverage would be 55x → capped at 30
|
||||
r = backtest_signal_ftmo(close_2yr, signal, stop_pips=1, max_leverage=30, oos_start=None)
|
||||
assert r["ftmo_leverage"] <= 30.0
|
||||
|
||||
|
||||
def test_ftmo_zero_signal_produces_no_trades(close_2yr):
|
||||
signal = pd.Series(0.0, index=close_2yr.index)
|
||||
r = backtest_signal_ftmo(close_2yr, signal, oos_start=None)
|
||||
assert r["n_trades"] == 0
|
||||
assert r["total_return"] == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OOS split tests
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_oos_split_produces_is_and_oos_keys(close_6yr):
|
||||
signal = _random_signal(close_6yr.index)
|
||||
r = backtest_signal_ftmo(close_6yr, signal, oos_start="2024-01-01")
|
||||
|
||||
assert "is_sharpe" in r
|
||||
assert "oos_sharpe" in r
|
||||
assert "is_monthly_return_pct" in r
|
||||
assert "oos_monthly_return_pct" in r
|
||||
assert "is_n_bars" in r
|
||||
assert "oos_n_bars" in r
|
||||
assert r["oos_start"] == "2024-01-01"
|
||||
|
||||
|
||||
def test_oos_split_bars_sum_to_total(close_6yr):
|
||||
signal = _random_signal(close_6yr.index)
|
||||
r = backtest_signal_ftmo(close_6yr, signal, oos_start="2024-01-01")
|
||||
assert r["is_n_bars"] + r["oos_n_bars"] == len(close_6yr)
|
||||
|
||||
|
||||
def test_oos_none_disables_split(close_6yr):
|
||||
signal = _random_signal(close_6yr.index)
|
||||
r = backtest_signal_ftmo(close_6yr, signal, oos_start=None)
|
||||
assert "is_sharpe" not in r
|
||||
assert "oos_sharpe" not in r
|
||||
|
||||
|
||||
def test_oos_is_independent_of_is_losses(close_6yr):
|
||||
"""OOS must use a fresh FTMO simulation — IS blowup must not zero OOS trades."""
|
||||
# Force the IS period to blow up immediately with max short on rising market
|
||||
rising = pd.Series(
|
||||
np.linspace(1.0, 2.0, len(close_6yr)),
|
||||
index=close_6yr.index,
|
||||
)
|
||||
always_short = pd.Series(-1.0, index=close_6yr.index)
|
||||
|
||||
r = backtest_signal_ftmo(rising, always_short, oos_start="2024-01-01")
|
||||
|
||||
# IS should be wiped out (total loss limit hit), but OOS must still trade
|
||||
assert r.get("oos_n_trades", 0) is not None
|
||||
assert r.get("oos_n_bars", 0) > 0
|
||||
|
||||
|
||||
def test_oos_default_start_matches_constant(close_6yr):
|
||||
signal = _random_signal(close_6yr.index)
|
||||
r = backtest_signal_ftmo(close_6yr, signal)
|
||||
assert r.get("oos_start") == OOS_START_DEFAULT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Monte Carlo permutation test helper
|
||||
# ---------------------------------------------------------------------------
|
||||
def _monte_carlo_pvalue(close: pd.Series, signal: pd.Series, n_permutations: int = 200, seed: int = 0) -> float:
|
||||
"""
|
||||
Estimate p-value: fraction of random permutations that beat the real Sharpe.
|
||||
p < 0.05 → strategy has statistically significant edge.
|
||||
"""
|
||||
real_r = backtest_signal_ftmo(close, signal, oos_start=None)
|
||||
real_sharpe = real_r.get("sharpe", 0.0) or 0.0
|
||||
|
||||
rng = np.random.default_rng(seed)
|
||||
beat = 0
|
||||
signal_vals = signal.values.copy()
|
||||
for _ in range(n_permutations):
|
||||
perm = rng.permutation(signal_vals)
|
||||
perm_signal = pd.Series(perm, index=signal.index)
|
||||
perm_r = backtest_signal_ftmo(close, perm_signal, oos_start=None)
|
||||
if (perm_r.get("sharpe") or 0.0) >= real_sharpe:
|
||||
beat += 1
|
||||
return beat / n_permutations
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_random_signal_has_no_edge(close_2yr):
|
||||
"""A purely random signal should NOT beat most permutations."""
|
||||
signal = _random_signal(close_2yr.index, seed=42)
|
||||
pval = _monte_carlo_pvalue(close_2yr, signal, n_permutations=50)
|
||||
# Random vs random: p-value should be near 0.5 (not significant)
|
||||
assert pval > 0.10, f"Random signal unexpectedly significant: p={pval:.2f}"
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_perfect_signal_is_significant(close_2yr):
|
||||
"""An oracle signal on hourly bars should beat random permutations significantly.
|
||||
|
||||
Per-minute oracle trading is unprofitable due to FTMO transaction costs, so we
|
||||
use 60-bar held positions (≈1h) where each directional move is large enough to
|
||||
cover the spread.
|
||||
"""
|
||||
bar_ret = close_2yr.pct_change().fillna(0)
|
||||
# Hourly oracle: sign of 60-bar future return, broadcast to all 60 minute bars
|
||||
hourly_ret = bar_ret.rolling(60).sum().shift(-60).fillna(0)
|
||||
perfect = pd.Series(np.sign(hourly_ret), index=close_2yr.index)
|
||||
pval = _monte_carlo_pvalue(close_2yr, perfect, n_permutations=50)
|
||||
assert pval < 0.30, f"Hourly oracle signal should beat random permutations: p={pval:.2f}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FTMO metrics in result dict
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_ftmo_result_has_equity_and_profit(close_2yr):
|
||||
signal = _random_signal(close_2yr.index)
|
||||
r = backtest_signal_ftmo(close_2yr, signal, oos_start=None)
|
||||
assert "ftmo_end_equity" in r
|
||||
assert "ftmo_monthly_profit" in r
|
||||
assert r["ftmo_end_equity"] > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Monte Carlo trade permutation tests
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_mc_pvalue_in_result(close_2yr):
|
||||
signal = _random_signal(close_2yr.index)
|
||||
r = backtest_signal_ftmo(close_2yr, signal, oos_start=None, mc_n_permutations=50)
|
||||
assert "mc_pvalue" in r
|
||||
assert 0.0 <= r["mc_pvalue"] <= 1.0
|
||||
assert r["mc_n_permutations"] == 50
|
||||
|
||||
|
||||
def test_mc_pvalue_disabled_by_default(close_2yr):
|
||||
signal = _random_signal(close_2yr.index)
|
||||
r = backtest_signal_ftmo(close_2yr, signal, oos_start=None)
|
||||
assert "mc_pvalue" not in r
|
||||
|
||||
|
||||
def test_mc_zero_trades_returns_one(close_2yr):
|
||||
"""Zero-signal → no trades → p-value must be 1.0 (no edge)."""
|
||||
trade_pnl = pd.Series([], dtype=float)
|
||||
assert monte_carlo_trade_pvalue(trade_pnl, n_permutations=10) == 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rolling walk-forward tests
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_wf_rolling_keys_in_result(close_6yr):
|
||||
signal = _random_signal(close_6yr.index)
|
||||
r = backtest_signal_ftmo(close_6yr, signal, oos_start="2024-01-01", wf_rolling=True)
|
||||
# With only ~150 days of data, windows may be 0 — just check key presence
|
||||
assert "wf_n_windows" in r
|
||||
|
||||
|
||||
def test_wf_rolling_disabled_by_default(close_6yr):
|
||||
signal = _random_signal(close_6yr.index)
|
||||
r = backtest_signal_ftmo(close_6yr, signal, oos_start="2024-01-01")
|
||||
assert "wf_n_windows" not in r
|
||||
|
||||
|
||||
def test_wf_consistency_range(close_6yr):
|
||||
"""wf_oos_consistency must be in [0, 1] when windows exist."""
|
||||
signal = _random_signal(close_6yr.index)
|
||||
r = backtest_signal_ftmo(close_6yr, signal, oos_start="2024-01-01", wf_rolling=True)
|
||||
c = r.get("wf_oos_consistency")
|
||||
if c is not None:
|
||||
assert 0.0 <= c <= 1.0
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Tests for KronosAdapter and CLI commands — mock-based, no real model download needed."""
|
||||
|
||||
import json
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_ohlcv(n: int = 600, freq: str = "1min") -> pd.DataFrame:
|
||||
"""Synthetic 1-min OHLCV DataFrame."""
|
||||
idx = pd.date_range("2024-01-01", periods=n, freq=freq)
|
||||
close = 1.1000 + np.cumsum(np.random.randn(n) * 0.0001)
|
||||
return pd.DataFrame({
|
||||
"open": close + np.random.randn(n) * 0.00005,
|
||||
"high": close + np.abs(np.random.randn(n) * 0.0001),
|
||||
"low": close - np.abs(np.random.randn(n) * 0.0001),
|
||||
"close": close,
|
||||
"volume": np.abs(np.random.randn(n) * 100),
|
||||
}, index=idx)
|
||||
|
||||
|
||||
def _make_predix_hdf5(tmp_path: Path, n: int = 300) -> Path:
|
||||
"""Write a minimal Predix-format HDF5 file and return its path."""
|
||||
idx = pd.MultiIndex.from_arrays(
|
||||
[pd.date_range("2024-01-01", periods=n, freq="1min"), ["EURUSD"] * n],
|
||||
names=["datetime", "instrument"],
|
||||
)
|
||||
df = pd.DataFrame({
|
||||
"$open": (np.random.rand(n) + 1.1).astype("float32"),
|
||||
"$close": (np.random.rand(n) + 1.1).astype("float32"),
|
||||
"$high": (np.random.rand(n) + 1.11).astype("float32"),
|
||||
"$low": (np.random.rand(n) + 1.09).astype("float32"),
|
||||
"$volume": (np.random.rand(n) * 100).astype("float32"),
|
||||
}, index=idx)
|
||||
h5 = tmp_path / "intraday_pv.h5"
|
||||
df.to_hdf(h5, key="data", mode="w")
|
||||
return h5
|
||||
|
||||
|
||||
def _make_mock_adapter():
|
||||
"""Return a mock KronosAdapter whose predict_next_bars is deterministic."""
|
||||
class MockAdapter:
|
||||
def load(self): return self
|
||||
def predict_next_bars(self, ohlcv_df, context_bars, pred_bars, **kw):
|
||||
idx = pd.date_range(ohlcv_df.index[-1], periods=pred_bars + 1, freq="1min")[1:]
|
||||
last_close = float(ohlcv_df["close"].iloc[-1])
|
||||
return pd.DataFrame({
|
||||
"open": last_close * 1.001,
|
||||
"close": last_close * 1.002,
|
||||
"high": last_close * 1.003,
|
||||
"low": last_close * 0.999,
|
||||
"volume": 100.0,
|
||||
}, index=idx)
|
||||
def predict_return(self, ohlcv_df, context_bars=512, pred_bars=1):
|
||||
return 0.001
|
||||
return MockAdapter()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests: _ohlcv_from_predix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestOhlcvConversion:
|
||||
def test_renames_dollar_columns(self):
|
||||
from rdagent.components.coder.kronos_adapter import _ohlcv_from_predix
|
||||
idx = pd.MultiIndex.from_arrays(
|
||||
[pd.date_range("2024-01-01", periods=3, freq="1min"), ["EURUSD"] * 3],
|
||||
names=["datetime", "instrument"],
|
||||
)
|
||||
df = pd.DataFrame({
|
||||
"$open": [1.1, 1.2, 1.3], "$high": [1.15, 1.25, 1.35],
|
||||
"$low": [1.05, 1.15, 1.25], "$close": [1.12, 1.22, 1.32],
|
||||
"$volume": [100.0, 200.0, 300.0],
|
||||
}, index=idx)
|
||||
result = _ohlcv_from_predix(df)
|
||||
assert list(result.columns) == ["open", "high", "low", "close", "volume"]
|
||||
|
||||
def test_no_dollar_columns_passthrough(self):
|
||||
from rdagent.components.coder.kronos_adapter import _ohlcv_from_predix
|
||||
df = pd.DataFrame({"open": [1.0], "close": [1.1], "high": [1.2], "low": [0.9], "volume": [100.0]})
|
||||
result = _ohlcv_from_predix(df)
|
||||
assert "close" in result.columns
|
||||
|
||||
def test_output_is_float64(self):
|
||||
from rdagent.components.coder.kronos_adapter import _ohlcv_from_predix
|
||||
df = pd.DataFrame({
|
||||
"$open": np.array([1.1], dtype="float32"),
|
||||
"$close": np.array([1.1], dtype="float32"),
|
||||
"$high": np.array([1.1], dtype="float32"),
|
||||
"$low": np.array([1.1], dtype="float32"),
|
||||
"$volume": np.array([100.0], dtype="float32"),
|
||||
})
|
||||
result = _ohlcv_from_predix(df)
|
||||
assert result["close"].dtype == np.float64
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests: KronosAdapter availability check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestKronosAvailability:
|
||||
def test_unavailable_without_repo(self, tmp_path, monkeypatch):
|
||||
import rdagent.components.coder.kronos_adapter as mod
|
||||
monkeypatch.setattr(mod, "KRONOS_REPO", tmp_path / "nonexistent")
|
||||
monkeypatch.setattr(mod, "_KRONOS_AVAILABLE", None)
|
||||
assert mod._ensure_kronos() is False
|
||||
|
||||
def test_load_raises_without_repo(self, tmp_path, monkeypatch):
|
||||
import rdagent.components.coder.kronos_adapter as mod
|
||||
monkeypatch.setattr(mod, "KRONOS_REPO", tmp_path / "nonexistent")
|
||||
monkeypatch.setattr(mod, "_KRONOS_AVAILABLE", None)
|
||||
adapter = mod.KronosAdapter()
|
||||
with pytest.raises(RuntimeError, match="Kronos not available"):
|
||||
adapter.load()
|
||||
|
||||
def test_predict_without_load_raises(self):
|
||||
from rdagent.components.coder.kronos_adapter import KronosAdapter
|
||||
adapter = KronosAdapter()
|
||||
with pytest.raises(RuntimeError, match="Call .load()"):
|
||||
adapter.predict_next_bars(_make_ohlcv(100), 50, 10)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests: build_kronos_factor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildKronosFactor:
|
||||
def test_output_has_correct_multiindex(self, tmp_path, monkeypatch):
|
||||
import rdagent.components.coder.kronos_adapter as mod
|
||||
monkeypatch.setattr(mod, "KronosAdapter", lambda **kw: _make_mock_adapter())
|
||||
h5 = _make_predix_hdf5(tmp_path)
|
||||
result = mod.build_kronos_factor(h5, context_bars=100, pred_bars=20, stride_bars=20, device="cpu")
|
||||
assert result.index.names == ["datetime", "instrument"]
|
||||
assert result.index.nlevels == 2
|
||||
|
||||
def test_output_column_name(self, tmp_path, monkeypatch):
|
||||
import rdagent.components.coder.kronos_adapter as mod
|
||||
monkeypatch.setattr(mod, "KronosAdapter", lambda **kw: _make_mock_adapter())
|
||||
h5 = _make_predix_hdf5(tmp_path)
|
||||
result = mod.build_kronos_factor(h5, context_bars=100, pred_bars=20, stride_bars=20, device="cpu")
|
||||
assert "KronosPredReturn" in result.columns
|
||||
|
||||
def test_output_has_non_nan_values(self, tmp_path, monkeypatch):
|
||||
import rdagent.components.coder.kronos_adapter as mod
|
||||
monkeypatch.setattr(mod, "KronosAdapter", lambda **kw: _make_mock_adapter())
|
||||
h5 = _make_predix_hdf5(tmp_path)
|
||||
result = mod.build_kronos_factor(h5, context_bars=100, pred_bars=20, stride_bars=20, device="cpu")
|
||||
assert result["KronosPredReturn"].notna().sum() > 0
|
||||
|
||||
def test_output_length_matches_input(self, tmp_path, monkeypatch):
|
||||
import rdagent.components.coder.kronos_adapter as mod
|
||||
monkeypatch.setattr(mod, "KronosAdapter", lambda **kw: _make_mock_adapter())
|
||||
n = 300
|
||||
h5 = _make_predix_hdf5(tmp_path, n=n)
|
||||
result = mod.build_kronos_factor(h5, context_bars=100, pred_bars=20, stride_bars=20, device="cpu")
|
||||
assert len(result) == n
|
||||
|
||||
def test_forward_fill_propagates_signal(self, tmp_path, monkeypatch):
|
||||
"""Values within a predicted window should be forward-filled, not NaN."""
|
||||
import rdagent.components.coder.kronos_adapter as mod
|
||||
monkeypatch.setattr(mod, "KronosAdapter", lambda **kw: _make_mock_adapter())
|
||||
h5 = _make_predix_hdf5(tmp_path, n=300)
|
||||
result = mod.build_kronos_factor(h5, context_bars=100, pred_bars=20, stride_bars=20, device="cpu")
|
||||
non_nan_ratio = result["KronosPredReturn"].notna().mean()
|
||||
assert non_nan_ratio > 0.5, f"Expected >50% non-NaN, got {non_nan_ratio:.2%}"
|
||||
|
||||
def test_raises_on_missing_hdf5(self, tmp_path, monkeypatch):
|
||||
import rdagent.components.coder.kronos_adapter as mod
|
||||
monkeypatch.setattr(mod, "KronosAdapter", lambda **kw: _make_mock_adapter())
|
||||
with pytest.raises(Exception):
|
||||
mod.build_kronos_factor(tmp_path / "missing.h5", context_bars=50, pred_bars=10, stride_bars=10)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests: evaluate_kronos_model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEvaluateKronosModel:
|
||||
def test_returns_required_keys(self, tmp_path, monkeypatch):
|
||||
import rdagent.components.coder.kronos_adapter as mod
|
||||
monkeypatch.setattr(mod, "KronosAdapter", lambda **kw: _make_mock_adapter())
|
||||
h5 = _make_predix_hdf5(tmp_path, n=400)
|
||||
metrics = mod.evaluate_kronos_model(h5, context_bars=100, pred_bars=20, stride_bars=20, device="cpu")
|
||||
for key in ["IC_mean", "IC_std", "IC_IR", "hit_rate", "n_predictions"]:
|
||||
assert key in metrics, f"Missing key: {key}"
|
||||
|
||||
def test_hit_rate_in_valid_range(self, tmp_path, monkeypatch):
|
||||
import rdagent.components.coder.kronos_adapter as mod
|
||||
monkeypatch.setattr(mod, "KronosAdapter", lambda **kw: _make_mock_adapter())
|
||||
h5 = _make_predix_hdf5(tmp_path, n=400)
|
||||
metrics = mod.evaluate_kronos_model(h5, context_bars=100, pred_bars=20, stride_bars=20, device="cpu")
|
||||
assert 0.0 <= metrics["hit_rate"] <= 1.0
|
||||
|
||||
def test_n_predictions_positive(self, tmp_path, monkeypatch):
|
||||
import rdagent.components.coder.kronos_adapter as mod
|
||||
monkeypatch.setattr(mod, "KronosAdapter", lambda **kw: _make_mock_adapter())
|
||||
h5 = _make_predix_hdf5(tmp_path, n=400)
|
||||
metrics = mod.evaluate_kronos_model(h5, context_bars=100, pred_bars=20, stride_bars=20, device="cpu")
|
||||
assert metrics["n_predictions"] > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests: CLI commands (via typer test runner)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCLICommands:
|
||||
def test_kronos_factor_missing_data_exits(self, tmp_path, monkeypatch):
|
||||
"""kronos-factor exits with code 1 when HDF5 data is missing."""
|
||||
from typer.testing import CliRunner
|
||||
import predix as predix_mod
|
||||
monkeypatch.chdir(tmp_path)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(predix_mod.app, ["kronos-factor"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
def test_kronos_eval_missing_data_exits(self, tmp_path, monkeypatch):
|
||||
"""kronos-eval exits with code 1 when HDF5 data is missing."""
|
||||
from typer.testing import CliRunner
|
||||
import predix as predix_mod
|
||||
monkeypatch.chdir(tmp_path)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(predix_mod.app, ["kronos-eval"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
def test_kronos_factor_runs_with_mock(self, tmp_path, monkeypatch):
|
||||
"""kronos-factor completes and saves parquet + json when adapter is mocked."""
|
||||
from typer.testing import CliRunner
|
||||
import rdagent.components.coder.kronos_adapter as mod
|
||||
import predix as predix_mod
|
||||
|
||||
monkeypatch.setattr(mod, "KronosAdapter", lambda **kw: _make_mock_adapter())
|
||||
|
||||
data_dir = tmp_path / "git_ignore_folder" / "factor_implementation_source_data"
|
||||
data_dir.mkdir(parents=True)
|
||||
_make_predix_hdf5(data_dir.parent.parent, n=300)
|
||||
h5_src = tmp_path / "intraday_pv.h5"
|
||||
# Put HDF5 where the CLI expects it
|
||||
import shutil
|
||||
src = _make_predix_hdf5(tmp_path, n=300)
|
||||
shutil.copy(src, data_dir / "intraday_pv.h5")
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(predix_mod.app, [
|
||||
"kronos-factor", "--context", "100", "--pred", "20", "--device", "cpu"
|
||||
])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "saved" in result.output.lower()
|
||||
|
||||
def test_kronos_eval_runs_with_mock(self, tmp_path, monkeypatch):
|
||||
"""kronos-eval completes and prints IC metrics when adapter is mocked."""
|
||||
from typer.testing import CliRunner
|
||||
import rdagent.components.coder.kronos_adapter as mod
|
||||
import predix as predix_mod
|
||||
|
||||
monkeypatch.setattr(mod, "KronosAdapter", lambda **kw: _make_mock_adapter())
|
||||
|
||||
data_dir = tmp_path / "git_ignore_folder" / "factor_implementation_source_data"
|
||||
data_dir.mkdir(parents=True)
|
||||
_make_predix_hdf5(data_dir.parent.parent, n=400)
|
||||
src = _make_predix_hdf5(tmp_path, n=400)
|
||||
import shutil
|
||||
shutil.copy(src, data_dir / "intraday_pv.h5")
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(predix_mod.app, [
|
||||
"kronos-eval", "--context", "100", "--pred", "20", "--device", "cpu"
|
||||
])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "IC" in result.output
|
||||
Reference in New Issue
Block a user