mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-08-05 19:17:43 +00:00
Compare commits
259 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 139e461ce4 | |||
| ee3ef28cde | |||
| 79c2560c3f | |||
| 529c3c1ab4 | |||
| 51fbcb1326 | |||
| 69534162ad | |||
| b527717690 | |||
| 246093e5e0 | |||
| 22aa0f6140 | |||
| d0fbab818e | |||
| b093c4ee04 | |||
| 9e269dafe1 | |||
| 3dfa6d5f11 | |||
| a9f526e6ef | |||
| 6538efa89d | |||
| 9d6c6d1d5f | |||
| 70029b4577 | |||
| d098a8213b | |||
| 0407b3c53e | |||
| 6e420e0715 | |||
| c92530c5a8 | |||
| d924f3d142 | |||
| 0ef2b96455 | |||
| 360140f671 | |||
| ac39bbb058 | |||
| b81466cac9 | |||
| b7f3fb824e | |||
| 0554d59f48 | |||
| 9f93ca1374 | |||
| 64ac65dfff | |||
| 2fd7256e1b | |||
| 4e4c782fd5 | |||
| d51a4edf0b | |||
| 008a16c512 | |||
| d8e95aa988 | |||
| 1b93ae2c99 | |||
| 318d1c1caa | |||
| 5a63024ed5 | |||
| 710957fc4e | |||
| c94f5c6cc7 | |||
| f933c0f1a2 | |||
| e412eb0f32 | |||
| 2cdedc9948 | |||
| 439bc7872a | |||
| bff706d3e1 | |||
| 55b2f22ad0 | |||
| 56bd719a9c | |||
| 4f92bbd916 | |||
| 7e2e28542b | |||
| bc744d5f51 | |||
| 8be73eae9a | |||
| bbf127013e | |||
| 8bf75ccfa6 | |||
| f4962bde81 | |||
| 70ad304a37 | |||
| 8409451ac4 | |||
| 982b902b1a | |||
| cc265d6045 | |||
| 1e7d4b76a0 | |||
| 1606546496 | |||
| 4fd43f34c0 | |||
| 4ed7023e63 | |||
| a437191ed1 | |||
| 6ed8cb165d | |||
| fcd477f1d2 | |||
| 83b5ba8671 | |||
| 14eddbfe05 | |||
| 81154b882e | |||
| 8b512777d7 | |||
| 62acc6af6a | |||
| d87f2101e5 | |||
| 84972c3611 | |||
| 39675dc197 | |||
| 3c196c79dc | |||
| b87b965937 | |||
| e87f177e6b | |||
| b6a01ba3b8 | |||
| 273f3067b6 | |||
| c6c2aab05b | |||
| 1346cb3ccf | |||
| 7d7baa5c28 | |||
| e17877c789 | |||
| a9e65f790a | |||
| ab2fefe1f6 | |||
| 8414ee4f5f | |||
| a26f7617fb | |||
| ab827c8e7e | |||
| 9c3670f8de | |||
| f0b4f4187a | |||
| f6f5a9caaa | |||
| 359a795951 | |||
| 7d6913d14b | |||
| a755bf7365 | |||
| 8262686755 | |||
| 3ca9300379 | |||
| 00a679dd47 | |||
| 6b2c9440c9 | |||
| b530ac1d0e | |||
| 9ec082dbc6 | |||
| 36b9b21376 | |||
| b962076281 | |||
| edc16be431 | |||
| 69ea2c7d74 | |||
| b158e0114c | |||
| 1a565162cc | |||
| 9870586005 | |||
| e4fe52cb1c | |||
| ee7baa6384 | |||
| c2a50feb56 | |||
| 3823efa057 | |||
| b9e83f0664 | |||
| 6419a38e4c | |||
| 025140afe6 | |||
| a4051b94c0 | |||
| 884cde248e | |||
| 7414219676 | |||
| 89d14b13d3 | |||
| 93f2166479 | |||
| c195c8a1b2 | |||
| f1db872322 | |||
| 03962b2075 | |||
| f05bc01d6e | |||
| 839d0d98de | |||
| ef05991f84 | |||
| 0b6e963e39 | |||
| 34ed8b4430 | |||
| 09978c97ca | |||
| 849a14185a | |||
| beda7eedad | |||
| 41e277fb06 | |||
| 367806e006 | |||
| ee20c26c7d | |||
| 7897c58290 | |||
| 3dff6680bd | |||
| 6e9d4d34ff | |||
| 7595b3c693 | |||
| c1ce6b1798 | |||
| fb8ea86827 | |||
| 04141f0709 | |||
| 84f075e322 | |||
| 7df9972b88 | |||
| 78edf9c944 | |||
| 5c98f48597 | |||
| 935adde6c5 | |||
| 4daa670390 | |||
| 32c4f5e514 | |||
| 21ca2a5434 | |||
| 3915c5626a | |||
| 5649626bee | |||
| 6daf7001b7 | |||
| b48d0e1105 | |||
| fac35dfd48 | |||
| 805e6d6a1d | |||
| c4d4d0edc2 | |||
| 039f69e0c8 | |||
| 7ac1134e31 | |||
| 3bb1090ba1 | |||
| 9b0d19130f | |||
| d08cfedbb1 | |||
| 7ab906e3b2 | |||
| 364b14b3f7 | |||
| 8bd1557341 | |||
| 7232ebf89d | |||
| 5bc517c3bc | |||
| d028ef6b21 | |||
| b767fd3990 | |||
| 948adce3fb | |||
| 44279279f4 | |||
| 768884a30a | |||
| 2e1d2a5e77 | |||
| d010cf33b1 | |||
| 78b9b702cc | |||
| ab411f206e | |||
| 0a79516288 | |||
| be15b4a3e3 | |||
| 519a4a5029 | |||
| a08ad37685 | |||
| 6ae9336e7f | |||
| 156bf997cd | |||
| 2dfb625d56 | |||
| a67b48de72 | |||
| 839d3cd1ca | |||
| fc0db351bc | |||
| 6920ee2602 | |||
| a2c46ebed9 | |||
| d0d01ed078 | |||
| 8e6577f71e | |||
| 76f5a46e70 | |||
| 65678c0c0c | |||
| 86dd726bfe | |||
| 6f1a669e32 | |||
| 3d2872c2fc | |||
| 2932f65eae | |||
| a6d418a102 | |||
| 3538d2dd24 | |||
| 7de4ae59ab | |||
| 1e0d3cd4b7 | |||
| 393685a8fa | |||
| add7f1a9ce | |||
| 84059b0c4b | |||
| e9adb62fed | |||
| 4560bfe838 | |||
| df98a62a08 | |||
| 1493ca890b | |||
| 712abacf71 | |||
| bc20f41c23 | |||
| 21cac197eb | |||
| fb96cfa7be | |||
| 6fb384635a | |||
| cde0c10ac7 | |||
| ccffc9819d | |||
| 254dcab8bd | |||
| 00a7c0d7cc | |||
| d635b53e65 | |||
| 17c53afb60 | |||
| 5738e47ffa | |||
| b42627c335 | |||
| bb52f59c86 | |||
| 8e9c08364a | |||
| 53984b1d71 | |||
| bef6deea10 | |||
| 8ae365746a | |||
| 4a04220112 | |||
| c32e6b032f | |||
| e22c209533 | |||
| 1cdea7db26 | |||
| 175167a008 | |||
| 91773188ab | |||
| abe5b17434 | |||
| d2a5fdda69 | |||
| 518bfd5a07 | |||
| 08955fbd4d | |||
| dc283610ad | |||
| 89d4e8fdba | |||
| c98f4a8d2d | |||
| 6e1b0335ea | |||
| 5c39ded91b | |||
| 6ab391faba | |||
| a6ec6ec363 | |||
| 20c6679023 | |||
| d172b89718 | |||
| 1a46baf0f5 | |||
| 2b0aaf8788 | |||
| 2f2736b730 | |||
| fa87d2f6b2 | |||
| e2f1a1005d | |||
| f2186b0fce | |||
| a0fdd8b6be | |||
| 400abac652 | |||
| 09f7ba0dd5 | |||
| 154d95b3f8 | |||
| 772ba6b722 | |||
| fe33dd76db | |||
| adff12b20b | |||
| 2e6b822a18 | |||
| 337f84bc87 | |||
| d2a81b2a27 | |||
| a54af83ce9 | |||
| e58eb610a3 |
@@ -3,25 +3,26 @@
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Python-3.10%20|%203.11-blue?style=for-the-badge&logo=python" alt="Python">
|
||||
<img src="https://img.shields.io/badge/Platform-Linux-lightgrey?style=for-the-badge&logo=linux" alt="Platform">
|
||||
<img src="https://img.shields.io/badge/Numba-0.59+-00A3E0?style=for-the-badge&logo=numba" alt="Numba">
|
||||
<img src="https://img.shields.io/badge/Optuna-4.8+-009B77?style=for-the-badge&logo=optuna" alt="Optuna">
|
||||
<img src="https://img.shields.io/badge/PyTorch-2.0+-red?style=for-the-badge&logo=pytorch" alt="PyTorch">
|
||||
<img src="https://img.shields.io/badge/Optuna-3.5+-009B77?style=for-the-badge&logo=optuna" alt="Optuna">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/TA--Lib-0.6+-green?style=for-the-badge" alt="TA-Lib">
|
||||
<img src="https://img.shields.io/badge/LightGBM-4.6+-00A1E0?style=for-the-badge" alt="LightGBM">
|
||||
<img src="https://img.shields.io/badge/Pandas-2.0+-150458?style=for-the-badge&logo=pandas" alt="Pandas">
|
||||
<img src="https://img.shields.io/badge/cTrader-OpenAPI-FF6B6B?style=for-the-badge" alt="cTrader">
|
||||
<img src="https://img.shields.io/badge/Pandas-150458?style=for-the-badge&logo=pandas" alt="Pandas">
|
||||
<img src="https://img.shields.io/badge/LightGBM-00A1E0?style=for-the-badge" alt="LightGBM">
|
||||
<img src="https://img.shields.io/badge/Qlib-FF6B6B?style=for-the-badge" alt="Qlib">
|
||||
<img src="https://img.shields.io/badge/llama.cpp-7B68EE?style=for-the-badge" alt="llama.cpp">
|
||||
</p>
|
||||
|
||||
<h4 align="center">
|
||||
<strong>High-Speed Strategy Discovery Framework</strong>
|
||||
<strong>AI-powered Quantitative Trading Agent for EUR/USD Forex</strong>
|
||||
</h4>
|
||||
|
||||
<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="#strategy-discovery">Strategy Discovery</a> •
|
||||
<a href="#live-trading">Live Trading</a> •
|
||||
<a href="#configuration">Configuration</a> •
|
||||
<a href="#features">Features</a>
|
||||
</p>
|
||||
|
||||
@@ -32,12 +33,27 @@
|
||||
<a href="https://github.com/TPTBusiness/NexQuant/actions/workflows/codacy.yml">
|
||||
<img src="https://img.shields.io/github/actions/workflow/status/TPTBusiness/NexQuant/codacy.yml?branch=master&label=Security&logo=shield&style=flat-square" alt="Security Scan">
|
||||
</a>
|
||||
<a href="https://codecov.io/gh/TPTBusiness/NexQuant">
|
||||
<img src="https://img.shields.io/codecov/c/github/TPTBusiness/NexQuant?style=flat-square&logo=codecov" alt="Coverage">
|
||||
</a>
|
||||
<a href="https://github.com/TPTBusiness/NexQuant/blob/master/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/TPTBusiness/NexQuant?style=flat-square" alt="License">
|
||||
</a>
|
||||
<a href="https://www.conventionalcommits.org/">
|
||||
<img src="https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow?style=flat-square" alt="Conventional Commits">
|
||||
</a>
|
||||
<a href="https://github.com/astral-sh/ruff">
|
||||
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json&style=flat-square" alt="Ruff">
|
||||
</a>
|
||||
<a href="https://github.com/TPTBusiness/NexQuant/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/TPTBusiness/NexQuant?style=flat-square" alt="Stars">
|
||||
</a>
|
||||
<a href="https://github.com/TPTBusiness/NexQuant/forks">
|
||||
<img src="https://img.shields.io/github/forks/TPTBusiness/NexQuant?style=flat-square" alt="Forks">
|
||||
</a>
|
||||
<a href="https://github.com/TPTBusiness/NexQuant/issues">
|
||||
<img src="https://img.shields.io/github/issues/TPTBusiness/NexQuant?style=flat-square" alt="Issues">
|
||||
</a>
|
||||
<a href="https://github.com/TPTBusiness/NexQuant/commits/master">
|
||||
<img src="https://img.shields.io/github/last-commit/TPTBusiness/NexQuant?style=flat-square" alt="Last Commit">
|
||||
</a>
|
||||
@@ -45,116 +61,435 @@
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ CLI Dashboard
|
||||
|
||||
```bash
|
||||
rdagent nexquant
|
||||
```
|
||||
|
||||

|
||||
|
||||
*The NexQuant CLI shows system status, available commands, and quick start guide.*
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
**NexQuant** discovers profitable trading strategies through high-speed search — no LLM required. Core engine: Numba JIT-compiled backtest at **735 million bars/second** (245× faster than pandas). Four discovery methods run in a continuous loop:
|
||||
**NexQuant** is an autonomous AI agent for quantitative trading strategies in the EUR/USD forex market. Built on a multi-agent framework, NexQuant automates the full research and development cycle:
|
||||
|
||||
| Method | Frequency | Description |
|
||||
|--------|-----------|-------------|
|
||||
| **Explore** | 30% of iterations | Random strategies from 17 TA-Lib indicators across timeframes |
|
||||
| **Exploit** | 70% of iterations | Mutate the best-known strategy (change params, indicator, or timeframe) |
|
||||
| **Optuna** | Every 500 iterations | 20-trial hyperparameter optimization on the current best |
|
||||
| **LightGBM** | Every 2000 iterations | ML classifier trained on SOTA indicator signals to predict direction |
|
||||
- 📊 **Factor Generation** — LLM proposes novel alpha factors; Kronos foundation model generates OHLCV-based predictions
|
||||
- 💡 **Strategy Discovery** — Autopilot generates + backtests trading strategies 24/7
|
||||
- 🧠 **Model Evolution** — CoSTEER iteratively improves predictive models through code evolution
|
||||
- 📈 **Backtesting** — Unified engine with 10 runtime invariants on 1-min EUR/USD data (2020–2026)
|
||||
- 🔄 **Auto-Restart** — All services run as daemons with automatic crash recovery
|
||||
|
||||
**Current best strategy**: MACD(3,10,3) 4-TF with 2/4 vote majority — **+32.0%/month** (Numba), **+24.3%/month** (verified independent backtest), 0/75 negative months.
|
||||
NexQuant is optimized for **1-minute EUR/USD FX data** (2020–2026) and supports both local LLMs (llama.cpp) and cloud backends (OpenRouter).
|
||||
|
||||
> **This repository contains the research framework.** Trading strategies, broker integrations, and live trading infrastructure are available as separate closed-source modules (`git_ignore_folder/`).
|
||||
> **Backtest Verification**: Every backtest result is automatically verified at runtime against mathematical invariants (MaxDD ∈ [-1,0], WinRate ∈ [0,1], Sharpe finite, sign consistency, etc.). 1125 collected tests with deep property-based, fuzzing, and hypothesis tests ensure metric correctness. See [Backtest Integrity](#backtest-integrity).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
This project draws inspiration from various open-source projects in the AI trading and multi-agent systems space. We thank all the authors for their innovative work that helped shape our understanding of these patterns.
|
||||
|
||||
Special thanks to:
|
||||
|
||||
- **[Microsoft RD-Agent](https://github.com/microsoft/RD-Agent)** (MIT License) - Foundation for our autonomous R&D agent framework. We extend our gratitude to the RD-Agent team for their excellent foundational work.
|
||||
|
||||
- **[TradingAgents](https://github.com/TauricResearch/TradingAgents)** (Apache 2.0 License) - Inspiration for our multi-agent debate system, reflection mechanism, and memory management modules.
|
||||
|
||||
- **[ai-hedge-fund](https://github.com/virattt/ai-hedge-fund)** - Inspiration for macro analysis (Stanley Druckenmiller agent), risk management concepts, and market regime detection.
|
||||
|
||||
All code in NexQuant is originally written and implemented independently. NexQuant extends these frameworks with EUR/USD forex-specific features, 1-minute backtesting capabilities, comprehensive risk management, and trading dashboards.
|
||||
|
||||
---
|
||||
|
||||
## 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 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
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/TPTBusiness/NexQuant
|
||||
cd NexQuant
|
||||
|
||||
# Create and activate conda environment
|
||||
conda create -n nexquant python=3.10 -y
|
||||
conda activate nexquant
|
||||
|
||||
# Install in editable mode
|
||||
pip install -e .
|
||||
|
||||
# Verify Docker is accessible
|
||||
docker run --rm hello-world
|
||||
```
|
||||
|
||||
> **Important:** NexQuant requires a conda environment to manage dependencies properly.
|
||||
> Using plain Python or other environment managers may cause conflicts.
|
||||
|
||||
---
|
||||
|
||||
## Data Setup
|
||||
|
||||
NexQuant 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:
|
||||
|
||||
```bash
|
||||
# Local LLM (llama.cpp)
|
||||
OPENAI_API_KEY=local
|
||||
OPENAI_API_BASE=http://localhost:8081/v1
|
||||
CHAT_MODEL=qwen3.5-35b
|
||||
|
||||
# Embedding (Ollama)
|
||||
LITELLM_PROXY_API_KEY=local
|
||||
LITELLM_PROXY_API_BASE=http://localhost:11434/v1
|
||||
EMBEDDING_MODEL=nomic-embed-text
|
||||
|
||||
# Paths
|
||||
QLIB_DATA_DIR=~/.qlib/qlib_data/eurusd_1min_data
|
||||
```
|
||||
|
||||
### LLM Server (llama.cpp)
|
||||
|
||||
```bash
|
||||
~/llama.cpp/build/bin/llama-server \
|
||||
--model ~/models/qwen3.6/Qwen3.6-35B-A3B-UD-Q3_K_XL.gguf \
|
||||
--n-gpu-layers 18 \
|
||||
--no-mmap \
|
||||
--port 8081 \
|
||||
--ctx-size 260000 \
|
||||
--parallel 2 \
|
||||
--batch-size 512 --ubatch-size 512 \
|
||||
--host 0.0.0.0 \
|
||||
-ctk q4_0 -ctv q4_0 \
|
||||
--reasoning off
|
||||
```
|
||||
|
||||
> **Important flags:**
|
||||
> - `--ctx-size 260000 --parallel 2` — allocates **2 slots × 130,000 tokens each**.
|
||||
> - `--reasoning off` — **critical**: completely disables Qwen3 chain-of-thought. `--reasoning-budget 0` is not sufficient and produces empty JSON responses.
|
||||
> - `--n-gpu-layers 18` — reduced from max (33) to free ~7 GB VRAM for Kronos-small GPU inference alongside llama-server.
|
||||
> - `-ctk q4_0 -ctv q4_0` — quantises the KV cache to 4-bit, reducing VRAM usage.
|
||||
|
||||
### 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 NexQuant 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 scripts/nexquant_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
|
||||
# Prerequisites
|
||||
conda create -n nexquant python=3.10 -y && conda activate nexquant
|
||||
pip install -e .
|
||||
# Ensure OHLCV data exists: git_ignore_folder/intraday_pv_all.h5
|
||||
# 1. Docker running?
|
||||
docker run --rm hello-world
|
||||
|
||||
# Strategy Discovery Loop (10,000 iterations, ~1 hour)
|
||||
python scripts/nexquant_rd_loop.py --iterations 10000
|
||||
# 2. Data in place?
|
||||
ls git_ignore_folder/factor_implementation_source_data/intraday_pv.h5
|
||||
|
||||
# Price-Action Indicator Loop (grid search all TA-Lib indicators)
|
||||
python scripts/nexquant_priceaction_loop.py
|
||||
# 3. LLM server running?
|
||||
curl http://localhost:8081/health
|
||||
```
|
||||
|
||||
# Top strategies report
|
||||
python nexquant.py best -n 20 -m monthly_return --min-trades 30
|
||||
### 1. Run Trading Loop
|
||||
|
||||
```bash
|
||||
conda activate nexquant
|
||||
rdagent fin_quant
|
||||
# or with explicit options:
|
||||
rdagent fin_quant --loop-n 5 --step-n 2
|
||||
```
|
||||
|
||||
### 2. Monitor Results
|
||||
|
||||
```bash
|
||||
# Web dashboard
|
||||
rdagent server_ui --port 19899 --log-dir git_ignore_folder/RD-Agent_workspace/
|
||||
# then open http://127.0.0.1:19899
|
||||
|
||||
# Best strategies so far
|
||||
python nexquant.py best
|
||||
```
|
||||
|
||||
### 3. Run Continuously (Auto-Restart)
|
||||
|
||||
```bash
|
||||
# Start all services with auto-restart daemons:
|
||||
|
||||
# fin_quant — factor R&D loop
|
||||
nohup bash -c 'while true; do rdagent fin_quant --loop-n 10 --model local >> /tmp/fin_quant_daemon.log 2>&1; sleep 10; done' &
|
||||
|
||||
# Autopilot — 24/7 strategy generator (Kronos factors auto-selected)
|
||||
nohup python scripts/nexquant_autopilot.py >> /tmp/autopilot_daemon.log 2>&1 &
|
||||
|
||||
# Live Trader — FTMO FIX API (requires credentials)
|
||||
nohup python git_ignore_folder/live_trading/ftmo_live_trader.py >> ftmo_live_trader.log 2>&1 &
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Strategy Discovery
|
||||
## CLI Commands
|
||||
|
||||
### R&D Loop (`scripts/nexquant_rd_loop.py`)
|
||||
### Factor & Strategy Loop
|
||||
|
||||
```
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ Explore │ ──→ │ Exploit │ ──→ │ Optuna │ ──→ │ LightGBM │
|
||||
│ (Random) │ │ (Mutate) │ │ (Tuning) │ │ (ML) │
|
||||
└──────────┘ └──────────┘ └──────────┘ └──────────┘
|
||||
30% 70% /500 iter /2000 iter
|
||||
```
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `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 |
|
||||
| `rdagent fin_factor` | Factor-only evolution |
|
||||
| `rdagent fin_model` | Model-only evolution |
|
||||
|
||||
**17 TA-Lib indicators**: MACD, RSI, Donchian, SAR, ADX, BBANDS, CCI, WCLPRICE, MFI, OBV, STOCH, ROC, AROON, AROONOSC, MOM, ULTOSC, WILLR
|
||||
### Strategy Reports
|
||||
|
||||
**4 timeframes**: 15min, 30min, 1h, 4h
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `python nexquant.py best` | Show top strategies by composite score |
|
||||
| `python nexquant.py best -n 20 -m sharpe` | Top 20 by Sharpe ratio |
|
||||
| `python nexquant.py best --show NAME` | Full metadata for one strategy |
|
||||
| `python scripts/nexquant_gen_strategies_real_bt.py 10` | Generate 10 strategies with LLM + real OHLCV backtest |
|
||||
| `python scripts/nexquant_gen_strategies_real_bt.py 20` | Generate 20 strategies (parallel workers) |
|
||||
| `python scripts/nexquant_autopilot.py` | 24/7 Auto-Pilot: endless strategy generation |
|
||||
| `python scripts/nexquant_continuous_strategies.py` | Continuous generation with ML training
|
||||
|
||||
**3 strategy types**: Single-TF, Multi-TF (vote majority), Portfolio (indicator ensemble)
|
||||
### Kronos Foundation Model
|
||||
|
||||
**Discovery example** (50,000 iterations):
|
||||
```
|
||||
random → SAR(+65) → MACD(+73) → MACD-mutated(+102.75, +32%/month)
|
||||
↓
|
||||
Optuna tuned params
|
||||
↓
|
||||
LightGBM ensemble
|
||||
```
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `rdagent fin_quant` | Kronos factors auto-generated on startup (3 horizons) |
|
||||
| Model size: `KRONOS_MODEL_SIZE=small\|mini\|base` | Configurable via env (default: small) |
|
||||
|
||||
### Grid Search (`scripts/nexquant_priceaction_loop.py`)
|
||||
Kronos runs automatically — no separate command needed. Factors are regenerated if missing from `results/factors/`.
|
||||
|
||||
Deterministic parameter grid over all 17 indicators. Finds MACD(3,10,3) as optimal.
|
||||
### Factor Evaluation
|
||||
|
||||
### Portfolio Optimizer (`scripts/nexquant_portfolio_optimizer.py`)
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `python nexquant.py evaluate --all` | Evaluate all generated factors |
|
||||
| `python nexquant.py top -n 20` | Show top 20 factors by IC |
|
||||
| `python nexquant.py portfolio-simple` | Simple portfolio optimization |
|
||||
|
||||
Greedy correlation-aware selection from discovered strategies.
|
||||
### Parallel Execution
|
||||
|
||||
---
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `python scripts/nexquant_parallel.py --runs 5 --api-keys 1 -m openrouter` | Run 5 parallel factor evolutions |
|
||||
| `python scripts/nexquant_parallel.py --runs 20 --api-keys 2 -m openrouter` | Run 20 runs with 2 API keys |
|
||||
|
||||
## Live Trading
|
||||
### Monitoring & Debug
|
||||
|
||||
Closed-source module at `git_ignore_folder/nexquant_live_trader.py`. Architecture:
|
||||
|
||||
```
|
||||
MACD(3,10,3) Signal → cTrader OpenAPI → Live Account
|
||||
4-TF 2/4 Votes (WebSocket+Protobuf) ↓
|
||||
Paper Mode
|
||||
```
|
||||
|
||||
Integration: cTrader WebSocket `live.ctraderapi.com:5035`, OAuth2 authentication, Protobuf message encoding, FIX protocol.
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `rdagent server_ui --port 19899 --log-dir <path>` | Start web dashboard |
|
||||
| `rdagent health_check` | Validate environment setup |
|
||||
| `python scripts/nexquant_batch_backtest.py` | Batch backtest multiple factors |
|
||||
| `python scripts/nexquant_rebacktest_strategies.py` | Re-backtest existing strategies |
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### ⚡ Numba Backtest
|
||||
- 735M bars/second (0.003s for 2.26M bars)
|
||||
- JIT-compiled profit/drawdown/sharpe computation
|
||||
- Signal construction via pandas resample + TA-Lib (~0.4s) is the bottleneck
|
||||
### 🔄 Iterative Factor Evolution
|
||||
|
||||
### 🔍 Four Discovery Methods
|
||||
- **Explore**: Random indicator + timeframe + parameters
|
||||
- **Exploit**: Mutation of top-5 SOTA strategies (parameter tweak, indicator swap, timeframe change)
|
||||
- **Optuna**: 20-trial TPE hyperparameter optimization on best strategy
|
||||
- **LightGBM**: ML classifier on SOTA indicator signals (80/20 train/test split)
|
||||
NexQuant continuously proposes, implements, and validates new alpha factors:
|
||||
|
||||
### 📊 TA-Lib Integration
|
||||
- 17 indicators with full parameter ranges
|
||||
- Auto-guard against bad parameters (negative/zero values that crash TA-Lib)
|
||||
- Multi-timeframe voting with configurable threshold
|
||||
- Learns from backtest feedback
|
||||
- Avoids overfitting through walk-forward validation
|
||||
- Discovers non-obvious patterns in order flow, volatility, and session dynamics
|
||||
|
||||
### 🛡️ Trading Protection System
|
||||
|
||||
Automatic risk management to prevent excessive losses:
|
||||
|
||||
- **Max Drawdown Protection** - Pauses trading when drawdown exceeds threshold (default: 15%)
|
||||
- **Cooldown Period** - Enforces mandatory rest period after significant losses (default: 4h after 5% loss)
|
||||
- **Stoploss Guard** - Detects clusters of stoplosses and blocks trading (default: max 5 per day)
|
||||
- **Low Performance Filter** - Filters out consistently underperforming factors (Sharpe < 0.5, Win Rate < 40%)
|
||||
|
||||
### 🧠 Model Architecture Search
|
||||
|
||||
Automatically explores and refines predictive models:
|
||||
|
||||
- Linear baselines (LightGBM, XGBoost)
|
||||
- Deep learning (LSTM, Transformer, Temporal CNN)
|
||||
- Ensemble methods
|
||||
|
||||
### 📚 Knowledge Base
|
||||
|
||||
Built-in knowledge accumulation across loops:
|
||||
|
||||
- Successful factors are archived
|
||||
- Failed attempts inform future proposals
|
||||
- Cross-loop learning improves robustness
|
||||
|
||||
### 🖥️ Interactive UI
|
||||
|
||||
Real-time dashboard for monitoring:
|
||||
|
||||
- Factor performance metrics
|
||||
- Model architecture evolution
|
||||
- Cumulative returns and drawdowns
|
||||
- Code diffs and implementation history
|
||||
|
||||
### 🤖 Kronos Foundation Model Integration
|
||||
|
||||
NexQuant integrates Kronos — an OHLCV foundation model from the NeoQuasar team (AAAI 2026, **MIT License**) — for alpha factor generation:
|
||||
|
||||
| Model | Params | p24 IC | Best For |
|
||||
|-------|--------|--------|----------|
|
||||
| **Kronos-small** (default) | 25M | \|IC\| ≈ 0.09 | 1-min EUR/USD |
|
||||
| Kronos-mini | 4.1M | \|IC\| ≈ 0.07 | Low-resource |
|
||||
| Kronos-base | 102M | \|IC\| ≈ 0.002 | Daily/weekly data only |
|
||||
|
||||
Kronos generates 3 prediction-horizon factors automatically on `fin_quant` startup:
|
||||
- `KronosPredReturn_p24` — 24-minute horizon
|
||||
- `KronosPredReturn_p48` — 48-minute horizon
|
||||
- `KronosPredReturn_p96` — 96-minute horizon (best performer)
|
||||
|
||||
The model runs on GPU (CUDA) alongside the llama-server, using CPU as fallback.
|
||||
Factors are persisted in `results/factors/` for use by the strategy orchestrator.
|
||||
|
||||
```bash
|
||||
# Kronos runs automatically with fin_quant (no separate command needed)
|
||||
rdagent fin_quant --loop-n 10 --model local
|
||||
|
||||
# Model size is auto-detected and configurable via env
|
||||
# Set KRONOS_MODEL_SIZE=base to use the 102M-param model
|
||||
```
|
||||
|
||||
### 🔒 Security & Quality
|
||||
- 0 Dependabot alerts, 0 CodeScan alerts
|
||||
- No proprietary terms in git history
|
||||
- Closed-source detection CI
|
||||
|
||||
Automated quality assurance:
|
||||
|
||||
- **1,125+ collected tests** — deep property-based, fuzzing, and hypothesis tests on every commit
|
||||
- **Bandit Security Scanner** — pre-commit security checks
|
||||
- **Weekly Dependency Audit** — automated vulnerability scan via GitHub Actions
|
||||
- **Closed-source detection** — CI verifies no local/ files are accidentally committed
|
||||
|
||||
---
|
||||
|
||||
@@ -162,67 +497,143 @@ Integration: cTrader WebSocket `live.ctraderapi.com:5035`, OAuth2 authentication
|
||||
|
||||
```
|
||||
nexquant/
|
||||
├── scripts/ # Strategy discovery & trading
|
||||
│ ├── nexquant_rd_loop.py # High-speed R&D loop (Numba + Optuna + ML)
|
||||
│ ├── nexquant_priceaction_loop.py # TA-Lib grid search loop
|
||||
│ ├── nexquant_portfolio_optimizer.py # Correlation-aware portfolio selection
|
||||
│ ├── nexquant_gridsearch.py # Deterministic parameter grid search
|
||||
│ ├── nexquant_daily_strategies.py # Daily Kronos + factor combinations
|
||||
│ ├── nexquant_gen_strategies_real_bt.py # LLM-based strategy generation
|
||||
│ ├── nexquant_autopilot.py # 24/7 continuous generator
|
||||
│ └── nexquant_parallel.py # Multi-instance parallel runs
|
||||
├── rdagent/ # Core framework (LLM-based, see note below)
|
||||
│ ├── app/ # CLI and scenario apps
|
||||
│ ├── components/ # Backtest engine, protections, coders
|
||||
│ ├── core/ # Core abstractions
|
||||
│ ├── scenarios/ # Domain-specific scenarios
|
||||
│ └── utils/ # Utilities
|
||||
├── git_ignore_folder/ # Closed-source (never committed)
|
||||
│ ├── nexquant_live_trader.py # cTrader live trading
|
||||
│ ├── nexquant_fix_trader.py # FIX protocol trader
|
||||
│ ├── intraday_pv_all.h5 # OHLCV data
|
||||
│ ├── gbpusdt_1min.h5 # GBP/USD data
|
||||
│ └── btc_1min.h5 # BTC data
|
||||
├── test/ # 1,125+ collected tests
|
||||
├── data_config.yaml # Walk-forward split configuration
|
||||
├── requirements.txt # Dependencies
|
||||
└── AGENTS.md # Agent configuration & workflow guide
|
||||
├── rdagent/ # Core agent framework
|
||||
│ ├── app/ # CLI and scenario apps
|
||||
│ │ └── qlib_rd_loop/ # Quant R&D loop (factor + model generation)
|
||||
│ ├── components/ # Reusable agent components
|
||||
│ │ ├── backtesting/ # Backtest engine & protections
|
||||
│ │ │ ├── vbt_backtest.py # Unified backtest engine (1-min bars)
|
||||
│ │ │ ├── verify.py # Runtime backtest invariant checker
|
||||
│ │ │ ├── results_db.py
|
||||
│ │ │ └── protections/ # Trading protection system
|
||||
│ │ ├── coder/ # Factor & model coding
|
||||
│ │ │ ├── CoSTEER/ # LLM-based code evolution engine
|
||||
│ │ │ ├── factor_coder/ # Factor-specific coders
|
||||
│ │ │ ├── model_coder/ # Model-specific coders
|
||||
│ │ │ └── kronos_adapter.py # Kronos foundation model adapter
|
||||
│ │ └── workflow/ # R&D loop workflow
|
||||
│ ├── core/ # Core abstractions
|
||||
│ ├── oai/ # LLM backend (LiteLLM, streaming, retry)
|
||||
│ ├── log/ # Logging infrastructure
|
||||
│ ├── scenarios/ # Domain-specific scenarios (qlib, kaggle, rl)
|
||||
│ └── utils/ # Utilities
|
||||
├── scripts/ # Daily operation scripts
|
||||
│ ├── nexquant_autopilot.py # 24/7 auto strategy generator
|
||||
│ ├── nexquant_gen_strategies_real_bt.py # Parallel strategy generation
|
||||
│ ├── nexquant_parallel.py # Multi-instance parallel R&D
|
||||
│ ├── nexquant_continuous_strategies.py # Continuous strategy generation
|
||||
│ ├── nexquant_fast_rebacktest.py # Fast strategy re-evaluation
|
||||
│ └── nexquant_rebacktest_parent.py # Parallel rebacktest orchestrator
|
||||
├── test/ # Test suite (1,125+ collected)
|
||||
│ ├── backtesting/ # Backtest engine deep tests
|
||||
│ ├── qlib/ # Quant loop, factor, model tests
|
||||
│ ├── oai/ # LLM backend tests
|
||||
│ ├── log/ # Logger tests
|
||||
│ ├── local/ # Closed-source tests (autopilot, ML, strategies)
|
||||
│ └── integration/ # End-to-end pipeline tests
|
||||
├── data_config.yaml # Walk-forward split configuration
|
||||
├── pyproject.toml # Project metadata
|
||||
├── requirements.txt # Dependencies
|
||||
└── AGENTS.md # Agent configuration & workflow guide
|
||||
```
|
||||
|
||||
> **Note on `rdagent/`**: The LLM-based R&D framework (`rdagent fin_quant`) is part of the codebase but the Qlib/CoSTEER pipeline currently produces zero factors. The primary strategy discovery path is the Numba-based loop in `scripts/`.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
## Requirements
|
||||
|
||||
### Prerequisites
|
||||
- **Conda** (Miniconda or Anaconda)
|
||||
- **TA-Lib** system library (`apt install ta-lib` or `brew install ta-lib`)
|
||||
- **Linux** (Ubuntu 22.04+)
|
||||
Core dependencies (see [`requirements.txt`](requirements.txt) for full list):
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/TPTBusiness/NexQuant && cd NexQuant
|
||||
conda create -n nexquant python=3.10 -y && conda activate nexquant
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### Data
|
||||
Place OHLCV HDF5 data at `git_ignore_folder/intraday_pv_all.h5`:
|
||||
```python
|
||||
# Format: MultiIndex (datetime, instrument), columns: $open $close $high $low $volume
|
||||
df.to_hdf('git_ignore_folder/intraday_pv_all.h5', key='data')
|
||||
```
|
||||
- **LLM**: `openai`, `litellm`
|
||||
- **Data**: `pandas`, `numpy`, `pyarrow`
|
||||
- **ML**: `scikit-learn`, `lightgbm`, `xgboost`
|
||||
- **Backtesting**: `qlib` (via Docker)
|
||||
- **UI**: `streamlit`, `plotly`, `flask`
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
**GNU Affero General Public License v3.0 (AGPL-3.0)**. See [`LICENSE`](LICENSE).
|
||||
This project is licensed under the **GNU Affero General Public License v3.0 (AGPL-3.0)**.
|
||||
|
||||
Key points of AGPL-3.0:
|
||||
- You may use, modify, and distribute this software freely
|
||||
- If you distribute modified versions, you MUST publish your changes under the same AGPL-3.0 license
|
||||
- If you run this software as a network service (e.g., trading API), you MUST make the complete source code available to users
|
||||
- Includes patent protection and anti-tivoization clauses
|
||||
|
||||
See the full license text in [`LICENSE`](LICENSE) or at <https://www.gnu.org/licenses/agpl-3.0.en.html>.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feat/my-feature`)
|
||||
3. Commit using [Conventional Commits](https://www.conventionalcommits.org/) (`git commit -m 'feat: add my feature'`)
|
||||
4. Push to the branch (`git push origin feat/my-feature`)
|
||||
5. Open a Pull Request with a conventional commit title
|
||||
|
||||
For major changes, please open an issue first to discuss your approach.
|
||||
|
||||
---
|
||||
|
||||
## Citation
|
||||
|
||||
If you use NexQuant in your research, please cite the underlying framework:
|
||||
|
||||
```bibtex
|
||||
@misc{yang2025rdagentllmagentframeworkautonomous,
|
||||
title={R&D-Agent: An LLM-Agent Framework Towards Autonomous Data Science},
|
||||
author={Yang, Xu and Yang, Xiao and Fang, Shikai and Zhang, Yifei and Wang, Jian and Xian, Bowen and Li, Qizheng and Li, Jingyuan and Xu, Minrui and Li, Yuante and others},
|
||||
year={2025},
|
||||
eprint={2505.14738},
|
||||
archivePrefix={arXiv},
|
||||
primaryClass={cs.AI}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
- **Issues**: [GitHub Issues](https://github.com/TPTBusiness/NexQuant/issues)
|
||||
|
||||
---
|
||||
|
||||
## Backtest Integrity
|
||||
|
||||
Every backtest result is automatically verified at runtime against 10 mathematical invariants.
|
||||
The verifier runs in **<1ms** and catches corrupted/missing/flipped metrics before they enter the factor database.
|
||||
|
||||
### Runtime checks (every backtest)
|
||||
| Check | Constraint |
|
||||
|-------|-----------|
|
||||
| Max Drawdown | `-1.0 ≤ mdd ≤ 0.0` |
|
||||
| Win Rate | `0.0 ≤ wr ≤ 1.0` |
|
||||
| Sharpe Ratio | `sharpe` must be finite |
|
||||
| Total Return | `total_return` must be finite |
|
||||
| Trade Count | `n_trades ≥ 0` |
|
||||
| Sign consistency | `sign(sharpe) == sign(annual_return)` |
|
||||
| Status | Must be `success` or `failed` |
|
||||
|
||||
### Test suite (CI + pre-commit)
|
||||
```bash
|
||||
pytest test/ -q # 1,125+ collected, property-based + fuzzing
|
||||
pytest test/backtesting/ -q # backtest engine deep tests
|
||||
```
|
||||
|
||||
**Coverage**: IC linear invariance, forward-return alignment, cross-implementation validation, ground-truth hand-computed scenarios, look-ahead bias detection, edge cases (all-NaN, constant, zero-variance, 1-bar, empty series), Monte Carlo p-value, walk-forward rolling, buy-and-hold equality, property-based testing (hypothesis: cost monotonicity, signal inversion, max-DD invariants), fuzzing (1,000 random backtest results), autopilot failure recovery, threshold rescaling, API key distribution, ML model acceptance criteria.
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
NexQuant is provided for **research and educational purposes only**. Past performance does not guarantee future results. Users assume all liability.
|
||||
NexQuant is provided "as is" for **research and educational purposes only**. It is **not** intended for:
|
||||
|
||||
- Live trading or financial advice
|
||||
- Production use without thorough testing
|
||||
- Replacement of qualified financial professionals
|
||||
|
||||
Users assume all liability and should comply with applicable laws and regulations in their jurisdiction. Past performance does not guarantee future results.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
azure-identity==1.25.3
|
||||
dill==0.4.1
|
||||
pillow==12.3.0
|
||||
pillow==12.2.0
|
||||
psutil==6.1.1
|
||||
scipy==1.15.3
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
azure-identity==1.25.3
|
||||
dill==0.4.1
|
||||
pillow==12.3.0
|
||||
pillow==12.2.0
|
||||
psutil==6.1.1
|
||||
scipy==1.15.3
|
||||
|
||||
+4
-6
@@ -1481,7 +1481,6 @@ def generate_strategies(
|
||||
min_sharpe: float = typer.Option(1.5, "--min-sharpe", help="Minimum Sharpe for acceptance"),
|
||||
max_drawdown: float = typer.Option(-0.30, "--max-dd", help="Maximum drawdown allowed"),
|
||||
min_win_rate: float = typer.Option(0.40, "--min-winrate", help="Minimum win rate for acceptance"),
|
||||
min_monthly_return: float = typer.Option(15.0, "--min-monthly-return", help="Minimum OOS monthly return %% for acceptance"),
|
||||
):
|
||||
"""
|
||||
Generate trading strategies from top factors using LLM + Optuna optimization.
|
||||
@@ -1499,8 +1498,8 @@ def generate_strategies(
|
||||
$ nexquant generate-strategies --min-sharpe 3.0 # Stricter acceptance
|
||||
$ nexquant generate-strategies -s daytrading # Day trading style
|
||||
$ nexquant generate-strategies --no-optuna # Skip optimization
|
||||
$ nexquant generate-strategies --min-monthly-return 15 # 15% OOS monthly target
|
||||
"""
|
||||
from rich.console import Console as RichConsole
|
||||
from rich.table import Table as RichTable
|
||||
|
||||
console.print(f"\n[bold cyan]{'='*60}[/bold cyan]")
|
||||
@@ -1508,7 +1507,7 @@ def generate_strategies(
|
||||
console.print(f"[bold cyan]{'='*60}[/bold cyan]")
|
||||
console.print(f" Strategies: [cyan]{count}[/cyan] Workers: [cyan]{workers}[/cyan] Style: [cyan]{style}[/cyan]")
|
||||
console.print(f" Optuna: {'[green]Yes[/green]' if optuna else '[yellow]No[/yellow]'} (trials={optuna_trials}) Factors: [cyan]{top_factors}[/cyan]")
|
||||
console.print(f" Accept: Sharpe≥[green]{min_sharpe}[/green] DD≥[green]{max_drawdown}[/green] WR≥[green]{min_win_rate}[/green] Mon≥[green]{min_monthly_return}%[/green]")
|
||||
console.print(f" Accept: Sharpe≥[green]{min_sharpe}[/green] DD≥[green]{max_drawdown}[/green] WR≥[green]{min_win_rate}[/green]")
|
||||
console.print(f"[bold cyan]{'='*60}[/bold cyan]\n")
|
||||
|
||||
try:
|
||||
@@ -1520,7 +1519,6 @@ def generate_strategies(
|
||||
min_sharpe=min_sharpe,
|
||||
max_drawdown=max_drawdown,
|
||||
min_win_rate=min_win_rate,
|
||||
min_monthly_return_pct=min_monthly_return,
|
||||
use_optuna=optuna,
|
||||
optuna_trials=optuna_trials,
|
||||
continuous_optimization=optuna,
|
||||
@@ -1546,7 +1544,7 @@ def generate_strategies(
|
||||
table.add_row(
|
||||
str(i), r.get("strategy_name", "?")[:28],
|
||||
f"{r.get('sharpe_ratio', 0):.2f}", f"{r.get('max_drawdown', 0):.1%}",
|
||||
f"{r.get('win_rate', 0):.1%}", str(r.get("num_trades", "?")),
|
||||
f"{r.get('win_rate', 0):.1%}", str(r.get('num_trades', '?')),
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
@@ -1662,7 +1660,7 @@ def _load_strategies():
|
||||
try:
|
||||
raw = json.loads(p.read_text())
|
||||
except Exception:
|
||||
logger.warning(f"Failed to load strategy file {p}")
|
||||
logger.warning("Failed to load strategy file %s", p, exc_info=True)
|
||||
continue
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
|
||||
@@ -34,9 +34,9 @@ strategy_generation:
|
||||
5. signal.name must be 'signal'
|
||||
|
||||
IC-Guided Factor Selection:
|
||||
- Factors with |IC| > 0.15 are highly predictive - PRIORITIZE these
|
||||
- Factors with |IC| > 0.08 are moderately predictive - USE these
|
||||
- Factors with |IC| < 0.08 are weak - AVOID unless complementary
|
||||
- Factors with |IC| > 0.10 are highly predictive - PRIORITIZE these
|
||||
- Factors with |IC| > 0.05 are moderately predictive - USE these
|
||||
- Factors with |IC| < 0.05 are weak - AVOID unless complementary
|
||||
- Combine factors with different signs of IC for diversification
|
||||
- Weight factors proportionally to their |IC| values
|
||||
|
||||
@@ -62,7 +62,6 @@ strategy_generation:
|
||||
TRADING STYLE: {{ trading_style }}
|
||||
TARGET SHARPE: > {{ min_sharpe }}
|
||||
MAX DRAWDOWN: {{ max_drawdown }}
|
||||
TARGET MONTHLY RETURN: > {{ min_monthly_return }}%
|
||||
|
||||
CRITICAL CODE RULES:
|
||||
1. DO NOT define functions - write direct executable code
|
||||
|
||||
@@ -5,18 +5,18 @@ from .risk_management import CorrelationAnalyzer, PortfolioOptimizer, AdvancedRi
|
||||
from .vbt_backtest import (
|
||||
DEFAULT_BARS_PER_YEAR,
|
||||
DEFAULT_TXN_COST_BPS,
|
||||
INITIAL_CAPITAL,
|
||||
MAX_DAILY_LOSS,
|
||||
MAX_TOTAL_LOSS,
|
||||
MAX_LEVERAGE,
|
||||
RISK_PER_TRADE,
|
||||
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_risk,
|
||||
backtest_signal_ftmo,
|
||||
monte_carlo_trade_pvalue,
|
||||
walk_forward_rolling,
|
||||
)
|
||||
@@ -24,10 +24,10 @@ from .vbt_backtest import (
|
||||
__all__ = [
|
||||
'BacktestMetrics', 'FactorBacktester', 'ResultsDatabase',
|
||||
'CorrelationAnalyzer', 'PortfolioOptimizer', 'AdvancedRiskManager',
|
||||
'backtest_signal', 'backtest_signal_risk', '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',
|
||||
'INITIAL_CAPITAL', 'MAX_DAILY_LOSS', 'MAX_TOTAL_LOSS',
|
||||
'MAX_LEVERAGE', 'RISK_PER_TRADE', 'OOS_START_DEFAULT',
|
||||
'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',
|
||||
]
|
||||
|
||||
@@ -97,7 +97,7 @@ class ResultsDatabase:
|
||||
c = self.conn.cursor()
|
||||
c.execute("SELECT name FROM pragma_table_info(?)", (table,))
|
||||
existing = {row[0] for row in c.fetchall()}
|
||||
if column.lower() not in {name.lower() for name in existing}:
|
||||
if column not in existing:
|
||||
c.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
|
||||
|
||||
def add_factor(self, name: str, type: str = "unknown") -> int:
|
||||
|
||||
@@ -38,15 +38,15 @@ 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
|
||||
|
||||
# RiskMgmt 100k account rules (enforced in backtest_signal when riskmgmt=True)
|
||||
INITIAL_CAPITAL = 100_000.0
|
||||
MAX_DAILY_LOSS = 0.05 # 5% of initial → block new trades rest of day
|
||||
MAX_TOTAL_LOSS = 0.10 # 10% of initial → simulation ends
|
||||
# Risk-based position sizing: 1.5% equity risk per trade, 10-pip stop, max 1:30 leverage
|
||||
RISK_PER_TRADE = 0.015
|
||||
STOP_PIPS = 10
|
||||
PIP_SIZE = 0.0001
|
||||
MAX_LEVERAGE = 30
|
||||
# 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:
|
||||
@@ -274,31 +274,31 @@ def backtest_signal(
|
||||
return result
|
||||
|
||||
|
||||
def _apply_risk_mask(
|
||||
def _apply_ftmo_mask(
|
||||
signal: pd.Series,
|
||||
close: pd.Series,
|
||||
leverage: float,
|
||||
txn_cost_bps: float,
|
||||
) -> tuple[pd.Series, dict]:
|
||||
"""
|
||||
Apply RiskMgmt daily/total loss rules to a signal series.
|
||||
Apply FTMO daily/total loss rules to a signal series.
|
||||
|
||||
Returns a masked signal (positions zeroed after each limit breach) and
|
||||
a dict of RiskMgmt compliance metrics.
|
||||
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 = INITIAL_CAPITAL
|
||||
peak_day = INITIAL_CAPITAL
|
||||
equity = FTMO_INITIAL_CAPITAL
|
||||
peak_day = FTMO_INITIAL_CAPITAL
|
||||
masked = signal.copy()
|
||||
|
||||
daily_breaches = 0
|
||||
total_breached = False
|
||||
total_breach_ts: pd.Timestamp | None = None
|
||||
current_day = None
|
||||
day_start_eq = INITIAL_CAPITAL
|
||||
day_start_eq = FTMO_INITIAL_CAPITAL
|
||||
|
||||
pos_prev = 0.0
|
||||
for ts, sig_i in signal.items():
|
||||
@@ -319,31 +319,31 @@ def _apply_risk_mask(
|
||||
masked.at[ts] = 0
|
||||
continue
|
||||
|
||||
daily_loss = (equity - day_start_eq) / INITIAL_CAPITAL
|
||||
total_loss = (equity - INITIAL_CAPITAL) / INITIAL_CAPITAL
|
||||
daily_loss = (equity - day_start_eq) / FTMO_INITIAL_CAPITAL
|
||||
total_loss = (equity - FTMO_INITIAL_CAPITAL) / FTMO_INITIAL_CAPITAL
|
||||
|
||||
if daily_loss < -MAX_DAILY_LOSS:
|
||||
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 < -MAX_TOTAL_LOSS:
|
||||
if total_loss < -FTMO_MAX_TOTAL_LOSS:
|
||||
total_breached = True
|
||||
total_breach_ts = ts
|
||||
masked.at[ts] = 0
|
||||
|
||||
return masked, {
|
||||
"riskmgmt_daily_breaches": daily_breaches,
|
||||
"riskmgmt_total_breached": total_breached,
|
||||
"riskmgmt_total_breach_ts": str(total_breach_ts) if total_breach_ts else None,
|
||||
"riskmgmt_compliant": not total_breached and daily_breaches == 0,
|
||||
"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 = 1
|
||||
WF_IS_YEARS = 3
|
||||
WF_OOS_YEARS = 1
|
||||
WF_STEP_YEARS = 1
|
||||
|
||||
@@ -403,7 +403,7 @@ def walk_forward_rolling(
|
||||
"""
|
||||
Rolling walk-forward validation: multiple IS/OOS windows shifted by ``step_years``.
|
||||
|
||||
Each window runs an independent RiskMgmt simulation on the IS and OOS slices.
|
||||
Each window runs an independent FTMO simulation on the IS and OOS slices.
|
||||
Produces aggregate OOS statistics to measure cross-time consistency.
|
||||
|
||||
Returns
|
||||
@@ -442,7 +442,7 @@ def walk_forward_rolling(
|
||||
for mask, prefix in [(is_mask, "is"), (oos_mask, "oos")]:
|
||||
close_s = close.loc[mask]
|
||||
signal_s = signal.loc[mask]
|
||||
masked_s, _ = _apply_risk_mask(signal_s, close_s, leverage, txn_cost_bps)
|
||||
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)
|
||||
@@ -466,14 +466,14 @@ def walk_forward_rolling(
|
||||
}
|
||||
|
||||
|
||||
def backtest_signal_risk(
|
||||
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 = RISK_PER_TRADE,
|
||||
stop_pips: float = STOP_PIPS,
|
||||
max_leverage: float = MAX_LEVERAGE,
|
||||
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: pd.Series | None = None,
|
||||
oos_start: str | None = OOS_START_DEFAULT,
|
||||
@@ -481,15 +481,15 @@ def backtest_signal_risk(
|
||||
mc_n_permutations: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
RiskMgmt-compliant backtest of a strategy signal on EUR/USD.
|
||||
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, RiskMgmt standard)
|
||||
- RiskMgmt daily loss limit (5%): positions zeroed rest of day after breach
|
||||
- RiskMgmt total loss limit (10%): all positions zeroed after breach
|
||||
- RiskMgmt-specific metrics added to result dict
|
||||
- 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
|
||||
@@ -507,7 +507,7 @@ def backtest_signal_risk(
|
||||
stop_pips : float
|
||||
Hard stop-loss distance in pips (default 10).
|
||||
max_leverage : float
|
||||
Maximum leverage (default 30 = RiskMgmt 1:30).
|
||||
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
|
||||
@@ -518,11 +518,11 @@ def backtest_signal_risk(
|
||||
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 * PIP_SIZE
|
||||
stop_price = stop_pips * FTMO_PIP
|
||||
leverage_by_risk = risk_pct / (stop_price / eurusd_price)
|
||||
leverage = min(leverage_by_risk, max_leverage)
|
||||
|
||||
masked_signal, risk_metrics = _apply_risk_mask(signal, close, leverage, txn_cost_bps)
|
||||
masked_signal, ftmo_metrics = _apply_ftmo_mask(signal, close, leverage, txn_cost_bps)
|
||||
|
||||
result = backtest_signal(
|
||||
close=close,
|
||||
@@ -532,14 +532,14 @@ def backtest_signal_risk(
|
||||
forward_returns=forward_returns,
|
||||
)
|
||||
|
||||
result.update(risk_metrics)
|
||||
result["riskmgmt_leverage"] = round(leverage, 2)
|
||||
result["riskmgmt_risk_pct"] = risk_pct
|
||||
result["riskmgmt_stop_pips"] = stop_pips
|
||||
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 INITIAL_CAPITAL
|
||||
result["riskmgmt_end_equity"] = INITIAL_CAPITAL * (1 + result.get("total_return", 0))
|
||||
result["riskmgmt_monthly_profit"] = INITIAL_CAPITAL * result.get("monthly_return", 0)
|
||||
# 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:
|
||||
@@ -551,9 +551,9 @@ def backtest_signal_risk(
|
||||
if mask.sum() < 100:
|
||||
return
|
||||
close_s = close.loc[mask]
|
||||
signal_s = signal.loc[mask] # raw signal, not masked — fresh RiskMgmt sim per period
|
||||
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_risk_mask(signal_s, close_s, leverage, txn_cost_bps)
|
||||
masked_s, _ = _apply_ftmo_mask(signal_s, close_s, leverage, txn_cost_bps)
|
||||
split_result = backtest_signal(
|
||||
close=close_s,
|
||||
signal=masked_s,
|
||||
|
||||
@@ -68,7 +68,6 @@ class FactorAutoFixer:
|
||||
self._fix_inf_nan_handling, # Tenth: add inf/nan handling
|
||||
self._fix_data_range_processing, # Eleventh: ensure full data range
|
||||
self._fix_multiindex_groupby, # Twelfth: ensure groupby on MultiIndex
|
||||
self._fix_composite_normalization, # Thirteenth: normalize thresholds + composite variance
|
||||
]
|
||||
|
||||
for fix_method in fix_methods:
|
||||
@@ -86,24 +85,6 @@ class FactorAutoFixer:
|
||||
|
||||
return fixed_code
|
||||
|
||||
def _fix_composite_normalization(self, code: str) -> str:
|
||||
"""Normalize strategy code: cap thresholds, limit windows, normalize composite."""
|
||||
code = re.sub(r'\bentry_thresh\s*=\s*([0-9.]+)',
|
||||
lambda m: f'entry_thresh = {min(float(m.group(1)), 0.7):.1f}', code)
|
||||
code = re.sub(r'\bexit_thresh\s*=\s*([0-9.]+)',
|
||||
lambda m: f'exit_thresh = {min(float(m.group(1)), 0.3):.1f}', code)
|
||||
code = re.sub(r'\bwindow\s*=\s*(\d+)',
|
||||
lambda m: f'window = {min(int(m.group(1)), 20)}', code)
|
||||
code = re.sub(r'(signal\s*=\s*signal\s*\.\s*rolling\s*\()(\d+)',
|
||||
lambda m: f'{m.group(1)}{min(int(m.group(2)), 2)}', code)
|
||||
if 'composite' in code and 'composite = (composite' not in code:
|
||||
code = re.sub(
|
||||
r'\n(signal\s*=\s*pd\.Series)',
|
||||
r'\ncomposite = (composite - composite.rolling(20).mean()) / (composite.rolling(20).std() + 1e-8)\n\n\1',
|
||||
code, count=1,
|
||||
)
|
||||
return code
|
||||
|
||||
def _fix_instrument_column_access(self, code: str) -> str:
|
||||
"""
|
||||
Fix: df['instrument'] raises KeyError on a MultiIndex DataFrame because
|
||||
|
||||
@@ -387,10 +387,6 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
||||
warnings.append(
|
||||
f"IC is near zero ({ic_float:.6f}) — factor may not predict returns",
|
||||
)
|
||||
if abs(ic_float) < 0.04:
|
||||
warnings.append(
|
||||
f"IC below target ({ic_float:.4f}) — factor will be excluded from strategy building (min IC=0.04)",
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
warnings.append(f"IC value is not numeric: {ic_value}")
|
||||
|
||||
|
||||
@@ -53,8 +53,7 @@ def extract_metrics_from_experiment(experiment) -> Metrics:
|
||||
|
||||
|
||||
class LinearThompsonTwoArm:
|
||||
def __init__(self, dim: int, prior_var: float = 1.0, noise_var: float = 1.0,
|
||||
model_prior_bias: float = 0.5):
|
||||
def __init__(self, dim: int, prior_var: float = 1.0, noise_var: float = 1.0):
|
||||
self.dim = dim
|
||||
self.noise_var = noise_var
|
||||
# Each arm has its own posterior: mean & inverse of covariance (precision matrix)
|
||||
@@ -62,8 +61,6 @@ class LinearThompsonTwoArm:
|
||||
"factor": np.zeros(dim),
|
||||
"model": np.zeros(dim),
|
||||
}
|
||||
# Give model arm an initial positive bias toward all metrics
|
||||
self.mean["model"][:] = model_prior_bias
|
||||
self.precision = {
|
||||
"factor": np.eye(dim) / prior_var,
|
||||
"model": np.eye(dim) / prior_var,
|
||||
@@ -97,8 +94,8 @@ class LinearThompsonTwoArm:
|
||||
|
||||
class EnvController:
|
||||
def __init__(self, weights: Tuple[float, ...] = None) -> None:
|
||||
self.weights = np.asarray(weights or (0.2, 0.1, 0.05, 0.05, 0.25, 0.1, 0.1, 0.15))
|
||||
self.bandit = LinearThompsonTwoArm(dim=8, prior_var=5.0, noise_var=0.5, model_prior_bias=2.0)
|
||||
self.weights = np.asarray(weights or (0.1, 0.1, 0.05, 0.05, 0.25, 0.15, 0.1, 0.2))
|
||||
self.bandit = LinearThompsonTwoArm(dim=8, prior_var=10.0, noise_var=0.5)
|
||||
|
||||
def reward(self, m: Metrics) -> float:
|
||||
return float(np.dot(self.weights, m.as_vector()))
|
||||
|
||||
@@ -73,7 +73,7 @@ class QlibQuantHypothesisGen(FactorAndModelHypothesisGen):
|
||||
trace.controller.record(metric, prev_action)
|
||||
action = trace.controller.decide(metric)
|
||||
else:
|
||||
action = "model"
|
||||
action = "factor"
|
||||
# ========= LLM ==========
|
||||
elif QUANT_PROP_SETTING.action_selection == "llm":
|
||||
hypothesis_and_feedback = (
|
||||
@@ -108,7 +108,7 @@ class QlibQuantHypothesisGen(FactorAndModelHypothesisGen):
|
||||
if len(trace.hist) < 6:
|
||||
qaunt_rag = "Try the easiest and fastest factors to experiment with from various perspectives first."
|
||||
else:
|
||||
qaunt_rag = "Now, you need to try factors that can achieve high IC (target |IC| > 0.04, e.g., machine learning-based factors)! Do not include factors that are similar to those in the SOTA factor library!"
|
||||
qaunt_rag = "Now, you need to try factors that can achieve high IC (e.g., machine learning-based factors)! Do not include factors that are similar to those in the SOTA factor library!"
|
||||
elif action == "model":
|
||||
qaunt_rag = "1. In Quantitative Finance, market data could be time-series, and GRU model/LSTM model are suitable for them. Do not generate GNN model as for now.\n2. The training data consists of approximately 478,000 samples for the training set and about 128,000 samples for the validation set. Please design the hyperparameters accordingly and control the model size. This has a significant impact on the training results. If you believe that the previous model itself is good but the training hyperparameters or model hyperparameters are not optimal, you can return the same model and adjust these parameters instead.\n"
|
||||
|
||||
|
||||
+3
-3
@@ -9,8 +9,8 @@ psutil
|
||||
fire
|
||||
fuzzywuzzy
|
||||
openai
|
||||
litellm>=1.86.2 # to support `from litellm import get_valid_models`
|
||||
aiohttp>=3.14.0 # CVE-2026-22815, CVE-2026-34515, CVE-2026-34516, CVE-2026-34525; >=3.13.4 due to litellm==1.83.14 exact pin
|
||||
litellm>=1.83.14 # to support `from litellm import get_valid_models`
|
||||
aiohttp>=3.13.4 # CVE-2026-22815, CVE-2026-34515, CVE-2026-34516, CVE-2026-34525; >=3.13.4 due to litellm==1.83.14 exact pin
|
||||
azure.identity
|
||||
pyarrow
|
||||
rich
|
||||
@@ -46,7 +46,7 @@ docker
|
||||
webdriver-manager
|
||||
|
||||
# demo related
|
||||
streamlit>=1.58.0 # to support input_c.text_area(..., height="content", ...)
|
||||
streamlit>=1.57.0 # to support input_c.text_area(..., height="content", ...)
|
||||
plotly
|
||||
st-theme
|
||||
randomname
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Gold Swing Scanner — Daily strategies for position/swing trading.
|
||||
|
||||
Unlike the 1-min grid search, this targets multi-day holds on daily Gold data.
|
||||
Tests: Trend-following, momentum, mean-reversion, breakout on 1-20 day horizons.
|
||||
"""
|
||||
import json, os, sys, time, itertools
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
PROJECT = Path(__file__).resolve().parent.parent
|
||||
OUTPUT_DIR = PROJECT / "results" / "gold_swing"
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sys.path.insert(0, str(PROJECT / "scripts"))
|
||||
from nexquant_rd_loop import _backtest_numba
|
||||
|
||||
def build_daily_signal(close, indicator, params):
|
||||
"""Build signal on raw daily close (no resampling)."""
|
||||
import talib
|
||||
c = close.values.astype(np.float64)
|
||||
s = np.zeros(len(c), dtype=np.int32)
|
||||
|
||||
if indicator == 'MACD':
|
||||
mc, sc, _ = talib.MACD(c, fastperiod=params.get('fast',12),
|
||||
slowperiod=params.get('slow',26),
|
||||
signalperiod=params.get('sig',9))
|
||||
s[mc > sc] = 1; s[mc < sc] = -1
|
||||
elif indicator == 'SMA':
|
||||
fa = pd.Series(c).rolling(params.get('fast',20)).mean().values
|
||||
sl = pd.Series(c).rolling(params.get('slow',50)).mean().values
|
||||
s[fa > sl] = 1; s[fa < sl] = -1
|
||||
elif indicator == 'EMA':
|
||||
fa = pd.Series(c).ewm(span=params.get('fast',12)).mean().values
|
||||
sl = pd.Series(c).ewm(span=params.get('slow',26)).mean().values
|
||||
s[fa > sl] = 1; s[fa < sl] = -1
|
||||
elif indicator == 'ROC':
|
||||
v = talib.ROC(c, timeperiod=params.get('period',20))
|
||||
th = params.get('threshold',2.0)
|
||||
s[v > th] = 1; s[v < -th] = -1
|
||||
elif indicator == 'MOM':
|
||||
v = talib.MOM(c, timeperiod=params.get('period',20))
|
||||
s[v > 0] = 1; s[v < 0] = -1
|
||||
elif indicator == 'RSI_OBOS':
|
||||
v = talib.RSI(c, timeperiod=params.get('period',14))
|
||||
s[v < params.get('oversold',30)] = 1; s[v > params.get('overbought',70)] = -1
|
||||
elif indicator == 'Donchian':
|
||||
hi = pd.Series(c).rolling(params.get('period',20)).max().shift(1).values
|
||||
lo = pd.Series(c).rolling(params.get('period',20)).min().shift(1).values
|
||||
s[c > hi] = 1; s[c < lo] = -1
|
||||
# Hold until reverse
|
||||
hold = params.get('hold',5)
|
||||
if hold > 0:
|
||||
last = 0; cnt = 0
|
||||
for i in range(len(s)):
|
||||
if s[i] != 0: last = s[i]; cnt = hold
|
||||
elif cnt > 0: s[i] = last; cnt -= 1
|
||||
elif indicator == 'BB':
|
||||
up, mi, lo = talib.BBANDS(c, timeperiod=params.get('period',20),
|
||||
nbdevup=params.get('std',2), nbdevdn=params.get('std',2))
|
||||
s[c < lo] = 1; s[c > up] = -1
|
||||
|
||||
return pd.Series(s, index=close.index).fillna(0).astype(int).clip(-1,1)
|
||||
|
||||
|
||||
# ── Grid Definition ──
|
||||
INDICATOR_GRIDS = {
|
||||
'MACD': {
|
||||
'fast': [3,5,8,12,21],
|
||||
'slow': [10,15,21,26,34,50],
|
||||
'sig': [3,5,9,13],
|
||||
},
|
||||
'SMA': {
|
||||
'fast': [10,20,50,100],
|
||||
'slow': [20,50,100,200],
|
||||
},
|
||||
'EMA': {
|
||||
'fast': [5,8,12,21],
|
||||
'slow': [13,21,34,55],
|
||||
},
|
||||
'ROC': {
|
||||
'period': [5,10,20,50,100],
|
||||
'threshold': [0.5,1.0,2.0,3.0,5.0],
|
||||
},
|
||||
'MOM': {
|
||||
'period': [10,20,50,100],
|
||||
},
|
||||
'RSI_OBOS': {
|
||||
'period': [7,14,21],
|
||||
'oversold': [20,25,30,35],
|
||||
'overbought': [65,70,75,80],
|
||||
},
|
||||
'Donchian': {
|
||||
'period': [5,10,20,50,100],
|
||||
'hold': [0,1,3,5,10],
|
||||
},
|
||||
'BB': {
|
||||
'period': [10,20,50],
|
||||
'std': [1.5,2.0,2.5,3.0],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def load_gold_daily():
|
||||
"""Load daily Gold data."""
|
||||
path = PROJECT / "git_ignore_folder" / "xau_daily.h5"
|
||||
if path.exists():
|
||||
return pd.read_hdf(path, key="data")
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print(" Gold Swing Scanner — Daily Position Strategies")
|
||||
print("=" * 60)
|
||||
|
||||
close = load_gold_daily()
|
||||
if close is None:
|
||||
print(" XAUUSD daily data not found! Run download first."); return
|
||||
print(f" XAUUSD daily: {len(close)} bars, {close.index[0].date()} -> {close.index[-1].date()}")
|
||||
|
||||
all_results = []
|
||||
total = 0
|
||||
for ind_name, grid in INDICATOR_GRIDS.items():
|
||||
keys = list(grid.keys())
|
||||
values = list(grid.values())
|
||||
for combo in itertools.product(*values):
|
||||
total += 1
|
||||
params = dict(zip(keys, combo))
|
||||
try:
|
||||
sig = build_daily_signal(close, ind_name, params)
|
||||
if sig is None or sig.nunique() <= 1: continue
|
||||
except: continue
|
||||
|
||||
n = len(close); is_n = int(n * 0.8)
|
||||
if is_n < 10: continue # too little data
|
||||
p = close.values.astype(float); s = sig.values.astype(np.int32)
|
||||
|
||||
if np.sum(np.abs(s)) < 10: continue
|
||||
|
||||
p_is = close.iloc[:is_n].values.astype(float); s_is = sig.iloc[:is_n].values.astype(np.int32)
|
||||
p_oos = close.iloc[is_n:].values.astype(float); s_oos = sig.iloc[is_n:].values.astype(np.int32)
|
||||
|
||||
_, dd, tr, w, ret, sh, _ = _backtest_numba(p, s)
|
||||
_, _, tr_o, _, ret_o, sh_o, _ = _backtest_numba(p_oos, s_oos)
|
||||
|
||||
nd = (close.index[-1] - close.index[0]).days
|
||||
if nd <= 0: continue
|
||||
mon = ((1+ret)**(1/(nd/30.44))-1)*100 if ret > -1 else 0
|
||||
nd_o = (close.index[is_n:][-1] - close.index[is_n:][0]).days
|
||||
if nd_o <= 0: nd_o = 1
|
||||
mon_o = ((1+ret_o)**(1/(nd_o/30.44))-1)*100 if ret_o > -1 else 0
|
||||
|
||||
all_results.append({
|
||||
'indicator': ind_name, 'params': params,
|
||||
'sharpe': float(sh), 'sharpe_oos': float(sh_o),
|
||||
'monthly_pct': float(mon), 'monthly_oos': float(mon_o),
|
||||
'n_trades': int(tr), 'n_trades_oos': int(tr_o),
|
||||
'win_rate': float(w/tr) if tr>0 else 0,
|
||||
'max_dd': float(-dd),
|
||||
})
|
||||
|
||||
all_results.sort(key=lambda r: r['sharpe_oos'], reverse=True)
|
||||
|
||||
print(f" {len(all_results)}/{total} strategies with trades\n")
|
||||
|
||||
print(f" TOP 20 by OOS Sharpe:")
|
||||
print(f" {'Rank':>4s} {'Indicator':<15s} {'Sh IS':>6s} {'Sh OOS':>7s} {'Mon IS':>7s} {'Mon OOS':>7s} {'DD':>6s} {'Tr':>5s}")
|
||||
for i, r in enumerate(all_results[:20], 1):
|
||||
print(f" {i:4d} {r['indicator']:<15s} {r['sharpe']:+6.1f} {r['sharpe_oos']:+7.1f} "
|
||||
f"{r['monthly_pct']:+6.1f}% {r['monthly_oos']:+6.1f}% "
|
||||
f"{r['max_dd']:.4f} {r['n_trades']:5d}")
|
||||
|
||||
# Save
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
out = OUTPUT_DIR / f"gold_swing_{ts}.json"
|
||||
out.write_text(json.dumps(all_results, indent=2, default=str))
|
||||
print(f"\n Saved: {out}")
|
||||
|
||||
# Indicator summary
|
||||
from collections import Counter
|
||||
print(f"\n Indicator Performance:")
|
||||
for ind in INDICATOR_GRIDS.keys():
|
||||
r = [r for r in all_results if r['indicator'] == ind]
|
||||
if r:
|
||||
print(f" {ind:<15s}: max Sh={max(x['sharpe'] for x in r):+.1f} "
|
||||
f"OOS={max(x['sharpe_oos'] for x in r):+.1f} "
|
||||
f"({len(r)} combos)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,76 +0,0 @@
|
||||
import json, numpy as np, pandas as pd
|
||||
from pathlib import Path
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_risk
|
||||
|
||||
close = pd.read_hdf("git_ignore_folder/factor_implementation_source_data/intraday_pv.h5", key="data")["$close"]
|
||||
close = close.droplevel(-1).sort_index().dropna().resample("1h").last().dropna()
|
||||
print(f"1h bars: {len(close):,}")
|
||||
|
||||
FACTORS_DIR = Path("results/factors"); VALS = FACTORS_DIR / "values"
|
||||
factors = []
|
||||
for f in sorted(FACTORS_DIR.glob("*.json")):
|
||||
try: d = json.loads(f.read_text())
|
||||
except: continue
|
||||
if d.get("status") != "success" or d.get("ic") is None: continue
|
||||
name = d.get("factor_name", f.stem)
|
||||
safe = name.replace("/", "_")[:150]
|
||||
if (VALS / f"{safe}.parquet").exists():
|
||||
factors.append({"name": name, "ic": d["ic"], "safe": safe})
|
||||
|
||||
factors.sort(key=lambda x: abs(x["ic"]), reverse=True)
|
||||
print(f"Testing top-100 factors by |IC|...")
|
||||
|
||||
results = []
|
||||
is_session = (close.index.hour >= 7) & (close.index.hour < 17)
|
||||
|
||||
for i, f in enumerate(factors[:100]):
|
||||
try:
|
||||
s = pd.read_parquet(VALS / f"{f['safe']}.parquet").iloc[:, 0]
|
||||
if isinstance(s.index, pd.MultiIndex): s = s.droplevel(-1)
|
||||
fac = s.resample("1h").last().reindex(close.index).ffill()
|
||||
except: continue
|
||||
|
||||
for dr, label in [(1, "STD"), (-1, "INV")]:
|
||||
sig = pd.Series(dr * np.sign(fac).fillna(0), index=close.index)
|
||||
sig[~is_session] = 0
|
||||
if sig.abs().sum() < 20: continue
|
||||
r = backtest_signal_risk(close, sig.fillna(0), txn_cost_bps=2.14)
|
||||
oos = r.get("wf_oos_sharpe_mean") or r.get("oos_sharpe", -999)
|
||||
oos_m = r.get("oos_monthly_return_pct", 0) or 0
|
||||
results.append((f"{f['name']}_{label}", oos, oos_m, r.get("oos_n_trades",0)))
|
||||
|
||||
if i % 25 == 0:
|
||||
bests = sorted(results, key=lambda x: x[1], reverse=True)[:3]
|
||||
print(f" {i}/100... best: {bests[0][0][:35]} OOS={bests[0][1]:+.1f}")
|
||||
|
||||
results.sort(key=lambda x: x[1], reverse=True)
|
||||
print(f"\nTop 15 — 1h Factor Signals (Session-Filtered):")
|
||||
for i, (name, oos, mon, t) in enumerate(results[:15]):
|
||||
s = "✅" if mon > 0 else ""
|
||||
print(f" {i+1:2d}. {name[:50]:50s} OOS={oos:+8.1f} Mon={mon:+7.3f}% T={t:5d} {s}")
|
||||
|
||||
# Combine best
|
||||
top = [r for r in results if r[2] > 0][:8]
|
||||
if top:
|
||||
all_sig = {}
|
||||
for name, oos, mon, t in top:
|
||||
fn = name.rsplit("_", 1)[0]; dr = 1 if name.endswith("_STD") else -1
|
||||
safe = fn.replace("/", "_")[:150]
|
||||
try:
|
||||
s = pd.read_parquet(VALS/f"{safe}.parquet").iloc[:, 0]
|
||||
if isinstance(s.index, pd.MultiIndex): s = s.droplevel(-1)
|
||||
fac = s.resample("1h").last().reindex(close.index).ffill()
|
||||
sig = pd.Series(dr * np.sign(fac).fillna(0), index=close.index)
|
||||
sig[~is_session] = 0; all_sig[name] = sig
|
||||
except: pass
|
||||
|
||||
df = pd.DataFrame(all_sig, index=close.index).fillna(0)
|
||||
for n in [3, 5, 8]:
|
||||
combo = df[list(df.columns)[:n]].mean(axis=1)
|
||||
r = backtest_signal_risk(close, combo.fillna(0), txn_cost_bps=2.14, wf_rolling=True)
|
||||
oos_m = r.get("oos_monthly_return_pct",0) or 0
|
||||
dd = (r.get("oos_max_drawdown",0) or 0)*100
|
||||
ann = ((1+oos_m/100)**12-1)*100
|
||||
print(f" Top-{n} combo: Mon={oos_m:+.3f}% Ann={ann:+.1f}% DD={dd:+.1f}% T={r.get('oos_n_trades',0)}")
|
||||
|
||||
print("\nDone")
|
||||
@@ -18,7 +18,7 @@ import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_risk
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_ftmo
|
||||
|
||||
DATA_PATH = Path("git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
|
||||
FACTORS_DIR = Path("results/factors")
|
||||
@@ -71,7 +71,7 @@ def backtest(signal, close, label="") -> dict:
|
||||
if signal is None or len(signal) < 100:
|
||||
return {"wf_sharpe": -999, "oos_sharpe": -999, "oos_monthly": 0, "oos_dd": 0, "trades": 0}
|
||||
common = close.index.intersection(signal.dropna().index)
|
||||
r = backtest_signal_risk(close.loc[common], signal.reindex(common).fillna(0),
|
||||
r = backtest_signal_ftmo(close.loc[common], signal.reindex(common).fillna(0),
|
||||
txn_cost_bps=TXN_COST_BPS, wf_rolling=False)
|
||||
oos = r.get("oos_sharpe", -999)
|
||||
return {
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""30min Full Factor Scan — find all profitable signals."""
|
||||
import json, numpy as np, pandas as pd
|
||||
from pathlib import Path
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_risk
|
||||
|
||||
c = pd.read_hdf("git_ignore_folder/factor_implementation_source_data/intraday_pv.h5", key="data")["$close"]
|
||||
c = c.droplevel(-1).sort_index().dropna().resample("30min").last().dropna()
|
||||
is_s = (c.index.hour >= 7) & (c.index.hour < 17)
|
||||
F = Path("results/factors"); V = F / "values"
|
||||
|
||||
factors = []
|
||||
for f in sorted(F.glob("*.json")):
|
||||
try: d = json.loads(f.read_text())
|
||||
except: continue
|
||||
if d.get("status") != "success" or d.get("ic") is None: continue
|
||||
name = d.get("factor_name", f.stem)
|
||||
safe = name.replace("/", "_")[:150]
|
||||
if (V / f"{safe}.parquet").exists():
|
||||
factors.append({"name": name, "ic": d["ic"], "safe": safe})
|
||||
factors.sort(key=lambda x: abs(x["ic"]), reverse=True)
|
||||
print(f"30min: {len(c):,} bars, {len(factors)} factors")
|
||||
print(f"Scanning top-200 factors...")
|
||||
|
||||
results = []
|
||||
for i, f in enumerate(factors[:200]):
|
||||
try:
|
||||
s = pd.read_parquet(V / f"{f['safe']}.parquet").iloc[:, 0]
|
||||
if isinstance(s.index, pd.MultiIndex): s = s.droplevel(-1)
|
||||
fac = s.resample("30min").last().reindex(c.index).ffill()
|
||||
except: continue
|
||||
for dr in [1, -1]:
|
||||
sig = pd.Series(dr * np.sign(fac).fillna(0), index=c.index)
|
||||
sig[~is_s] = 0
|
||||
if sig.abs().sum() < 20: continue
|
||||
r = backtest_signal_risk(c, sig.fillna(0), txn_cost_bps=2.14)
|
||||
oos = r.get("wf_oos_sharpe_mean") or r.get("oos_sharpe", -999)
|
||||
oos_m = r.get("oos_monthly_return_pct", 0) or 0
|
||||
if oos_m > 0.2:
|
||||
results.append((f"{f['name']}_{dr}", oos, oos_m, r.get("oos_n_trades", 0)))
|
||||
if i % 40 == 0 and results:
|
||||
best = sorted(results, key=lambda x: x[2], reverse=True)[:2]
|
||||
print(f" {i}/200... best: {best[0][0][:40]} Mon={best[0][2]:+.2f}%")
|
||||
|
||||
results.sort(key=lambda x: x[2], reverse=True)
|
||||
print(f"\nProfitable (>0.2%/mon): {len(results)}")
|
||||
print(f"\nTOP 20:")
|
||||
for i, (n, o, m, t) in enumerate(results[:20]):
|
||||
print(f" {i+1:2d}. {n[:52]:52s} OOS={o:+8.1f} Mon={m:+7.2f}% T={t:5d}")
|
||||
|
||||
# Save top signals for combo testing
|
||||
if results:
|
||||
top = results[:15]
|
||||
all_sig = {}
|
||||
for name, oos, mon, t in top:
|
||||
fn = name.rsplit("_", 1)[0]
|
||||
dr = -1 if name.endswith("_-1") else 1
|
||||
if dr == -1: dr = -1
|
||||
safe = fn.replace("/", "_")[:150]
|
||||
try:
|
||||
s = pd.read_parquet(V / f"{safe}.parquet").iloc[:, 0]
|
||||
if isinstance(s.index, pd.MultiIndex): s = s.droplevel(-1)
|
||||
fac = s.resample("30min").last().reindex(c.index).ffill()
|
||||
sig = pd.Series(dr * np.sign(fac).fillna(0), index=c.index)
|
||||
sig[~is_s] = 0
|
||||
all_sig[name] = sig
|
||||
except: pass
|
||||
|
||||
if all_sig:
|
||||
df = pd.DataFrame(all_sig, index=c.index).fillna(0)
|
||||
cols = list(df.columns)
|
||||
print(f"\n=== COMBO TESTS ===")
|
||||
for n in [2, 3, 5, 8, len(cols)]:
|
||||
combo = df[cols[:n]].mean(axis=1)
|
||||
r = backtest_signal_risk(c, combo.fillna(0), txn_cost_bps=2.14, wf_rolling=True)
|
||||
m = r.get("oos_monthly_return_pct", 0) or 0
|
||||
dd = (r.get("oos_max_drawdown", 0) or 0) * 100
|
||||
t = r.get("oos_n_trades", 0)
|
||||
hit = "🎯" if m >= 4 else "✅" if m > 0 else ""
|
||||
print(f" {n:2d} sig: Mon={m:+.2f}% DD={dd:+.1f}% T={t} {hit}")
|
||||
|
||||
print("\nDone!")
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Add RiskMgmt-compliant risk management to existing strategies.
|
||||
Add FTMO-compliant risk management to existing strategies.
|
||||
|
||||
For each accepted strategy, add:
|
||||
- Stop Loss: 2%
|
||||
@@ -27,11 +27,11 @@ console = Console()
|
||||
STRATEGIES_DIR = Path('results/strategies_new')
|
||||
OHLCV_PATH = Path('git_ignore_folder/factor_implementation_source_data/intraday_pv.h5')
|
||||
|
||||
# RiskMgmt Risk Parameters
|
||||
# FTMO Risk Parameters
|
||||
STOP_LOSS = 0.02 # 2% hard stop
|
||||
TAKE_PROFIT = 0.04 # 4% target (2x SL)
|
||||
TRAILING_STOP = 0.015 # 1.5% trail after 2% profit
|
||||
MAX_DAILY_LOSS = 0.05 # 5% RiskMgmt daily limit
|
||||
MAX_DAILY_LOSS = 0.05 # 5% FTMO daily limit
|
||||
|
||||
def load_ohlcv():
|
||||
"""Load OHLCV close prices."""
|
||||
@@ -147,11 +147,11 @@ def evaluate_strategy(strategy_returns, signal_aligned):
|
||||
'n_bars': int(n_bars),
|
||||
'n_months': float(n_months),
|
||||
'max_daily_loss': float(max_daily_loss),
|
||||
'riskmgmt_compliant': max_daily_loss <= MAX_DAILY_LOSS and max_dd > -0.10,
|
||||
'ftmo_compliant': max_daily_loss <= MAX_DAILY_LOSS and max_dd > -0.10,
|
||||
}
|
||||
|
||||
def main():
|
||||
console.print("[bold cyan]🔒 Adding RiskMgmt Risk Management to Existing Strategies[/bold cyan]\n")
|
||||
console.print("[bold cyan]🔒 Adding FTMO Risk Management to Existing Strategies[/bold cyan]\n")
|
||||
|
||||
# Load OHLCV
|
||||
console.print("📊 Loading OHLCV data...")
|
||||
@@ -254,7 +254,7 @@ def main():
|
||||
'new_trades': metrics['n_trades'],
|
||||
'new_monthly_ret': metrics['monthly_return_pct'],
|
||||
'max_daily_loss': metrics['max_daily_loss'],
|
||||
'riskmgmt_compliant': bool(metrics['riskmgmt_compliant']),
|
||||
'ftmo_compliant': bool(metrics['ftmo_compliant']),
|
||||
}
|
||||
results.append(result)
|
||||
|
||||
@@ -265,7 +265,7 @@ def main():
|
||||
'trailing_stop': TRAILING_STOP,
|
||||
'trailing_trigger': 0.02,
|
||||
'max_daily_loss': MAX_DAILY_LOSS,
|
||||
'riskmgmt_compliant': bool(metrics['riskmgmt_compliant']),
|
||||
'ftmo_compliant': bool(metrics['ftmo_compliant']),
|
||||
}
|
||||
data['evaluated_with_risk_mgmt'] = metrics
|
||||
data['summary'] = {
|
||||
@@ -275,7 +275,7 @@ def main():
|
||||
'monthly_return_pct': metrics['monthly_return_pct'],
|
||||
'real_ic': metrics['ic'],
|
||||
'real_n_trades': metrics['n_trades'],
|
||||
'riskmgmt_compliant': bool(metrics['riskmgmt_compliant']),
|
||||
'ftmo_compliant': bool(metrics['ftmo_compliant']),
|
||||
'forward_bars': 12,
|
||||
'trading_style': 'daytrading',
|
||||
}
|
||||
@@ -296,7 +296,7 @@ def main():
|
||||
# Display results
|
||||
console.print("\n[bold green]✓ All strategies processed![/bold green]\n")
|
||||
|
||||
table = Table(title="📊 RiskMgmt Risk Management Results")
|
||||
table = Table(title="📊 FTMO Risk Management Results")
|
||||
table.add_column("#", justify="right")
|
||||
table.add_column("Strategy", style="cyan")
|
||||
table.add_column("IC", justify="right")
|
||||
@@ -304,11 +304,11 @@ def main():
|
||||
table.add_column("Trades", justify="right")
|
||||
table.add_column("Monthly %", justify="right")
|
||||
table.add_column("Max DD", justify="right")
|
||||
table.add_column("RiskMgmt", justify="center")
|
||||
table.add_column("FTMO", justify="center")
|
||||
|
||||
results.sort(key=lambda x: x['new_sharpe'], reverse=True)
|
||||
for i, r in enumerate(results, 1):
|
||||
riskmgmt = "✅" if r['riskmgmt_compliant'] else "❌"
|
||||
ftmo = "✅" if r['ftmo_compliant'] else "❌"
|
||||
table.add_row(
|
||||
str(i), r['name'],
|
||||
f"{r['new_ic']:.4f}",
|
||||
@@ -316,14 +316,14 @@ def main():
|
||||
str(r['new_trades']),
|
||||
f"{r['new_monthly_ret']:.2f}%",
|
||||
f"{r['new_max_dd']:.1%}",
|
||||
riskmgmt
|
||||
ftmo
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
# Summary
|
||||
riskmgmt_count = sum(1 for r in results if r['riskmgmt_compliant'])
|
||||
console.print(f"\n[bold]RiskMgmt-Compliant:[/bold] {riskmgmt_count}/{len(results)} strategies")
|
||||
ftmo_count = sum(1 for r in results if r['ftmo_compliant'])
|
||||
console.print(f"\n[bold]FTMO-Compliant:[/bold] {ftmo_count}/{len(results)} strategies")
|
||||
|
||||
if results:
|
||||
best = results[0]
|
||||
@@ -331,7 +331,7 @@ def main():
|
||||
console.print(f" Sharpe: {best['new_sharpe']:.2f}")
|
||||
console.print(f" Monthly Return: {best['new_monthly_ret']:.2f}%")
|
||||
console.print(f" Max Drawdown: {best['new_max_dd']:.1%}")
|
||||
console.print(f" RiskMgmt Compliant: {'✅' if best['riskmgmt_compliant'] else '❌'}")
|
||||
console.print(f" FTMO Compliant: {'✅' if best['ftmo_compliant'] else '❌'}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
@@ -68,8 +68,8 @@ def build_ml_model(factor_values: pd.DataFrame, close: pd.Series, style: str) ->
|
||||
signal = pd.Series(np.sign(preds), index=common[split:])
|
||||
|
||||
# Backtest
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_risk
|
||||
bt = backtest_signal_risk(
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_ftmo
|
||||
bt = backtest_signal_ftmo(
|
||||
close=close_aligned.loc[common[split:]],
|
||||
signal=signal,
|
||||
txn_cost_bps=2.14,
|
||||
|
||||
@@ -1,278 +1,269 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Daily Strategy Generator — Kronos factors at daily resolution.
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
NexQuant Daily Strategy Generator — systematisch, kein LLM.
|
||||
|
||||
Daily timeframe eliminates 1-min noise and transaction cost overhead.
|
||||
Factors with daily IC translate directly to daily trading edge.
|
||||
Grid-search für SMA/EMA/RSI/MACD/Momentum/Mean-Reversion auf Tagesdaten.
|
||||
Speichert Top-Strategien als JSON für den Live-Trading-Workflow.
|
||||
|
||||
Usage:
|
||||
python scripts/nexquant_daily_strategies.py
|
||||
python scripts/nexquant_daily_strategies.py --top 10 --cost 2.14
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from __future__ import annotations
|
||||
|
||||
import json, sys, time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT = Path(__file__).resolve().parent.parent
|
||||
FACTORS_DIR = PROJECT / "results" / "factors"
|
||||
VALUES_DIR = FACTORS_DIR / "values"
|
||||
RESULTS_DIR = PROJECT / "results" / "strategies_new"
|
||||
OHLCV_PATH = Path(os.getenv("PREDIX_OHLCV_PATH",
|
||||
str(PROJECT / "git_ignore_folder" / "intraday_pv_all.h5")))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
MIN_MONTHLY = 5.0 # Raw backtest target (conservative for daily)
|
||||
MIN_SHARPE = 1.0
|
||||
MAX_DD = -0.20
|
||||
MIN_TRADES = 30
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_ftmo
|
||||
|
||||
DATA_PATH = Path("git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
|
||||
OUT_DIR = Path("results/strategies_daily")
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
TXN_COST_BPS = 2.14
|
||||
MIN_TRADES_OOS = 5
|
||||
|
||||
|
||||
def load_kronos(name: str) -> pd.Series:
|
||||
s = pd.read_parquet(VALUES_DIR / f"{name}.parquet")
|
||||
col = s.columns[0]
|
||||
return s.xs("EURUSD", level="instrument")[col]
|
||||
def load_daily_data():
|
||||
close = pd.read_hdf(DATA_PATH, key="data")["$close"]
|
||||
if isinstance(close.index, pd.MultiIndex):
|
||||
close = close.droplevel(-1)
|
||||
return close.sort_index().dropna().resample("1D").last().dropna()
|
||||
|
||||
|
||||
def load_factor_ic(name: str) -> float:
|
||||
jf = FACTORS_DIR / f"{name}.json"
|
||||
if jf.exists():
|
||||
return float(json.loads(jf.read_text()).get("ic", 0))
|
||||
return 0.0
|
||||
|
||||
|
||||
def daily_backtest(close_daily: pd.Series, signal_daily: pd.Series) -> dict:
|
||||
"""Simple daily backtest — no intraday noise, no 1-min costs."""
|
||||
common = close_daily.index.intersection(signal_daily.index)
|
||||
c = close_daily.loc[common]
|
||||
s = signal_daily.loc[common].clip(-1, 1)
|
||||
|
||||
rets = c.pct_change().shift(-1) # Next day's return
|
||||
strat_rets = s.shift(1) * rets # Today's signal × tomorrow's return
|
||||
strat_rets = strat_rets.dropna()
|
||||
|
||||
if len(strat_rets) < 10:
|
||||
return {"sharpe": 0, "monthly_pct": 0, "max_dd": 0, "n_trades": 0, "win_rate": 0}
|
||||
|
||||
# Trade-level stats
|
||||
trades = []
|
||||
in_trade = False
|
||||
trade_ret = 0.0
|
||||
wins = 0
|
||||
for r, sig in zip(strat_rets, s.loc[strat_rets.index]):
|
||||
if sig != 0:
|
||||
if not in_trade:
|
||||
in_trade = True
|
||||
trade_ret = r
|
||||
else:
|
||||
trade_ret += r
|
||||
elif in_trade:
|
||||
in_trade = False
|
||||
trades.append(trade_ret)
|
||||
if trade_ret > 0:
|
||||
wins += 1
|
||||
trade_ret = 0.0
|
||||
if in_trade:
|
||||
trades.append(trade_ret)
|
||||
if trade_ret > 0:
|
||||
wins += 1
|
||||
|
||||
n_trades = len(trades)
|
||||
if n_trades < 5:
|
||||
return {"sharpe": 0, "monthly_pct": 0, "max_dd": 0, "n_trades": n_trades, "win_rate": 0}
|
||||
|
||||
t_arr = np.array(trades)
|
||||
sharpe = float(t_arr.mean() / t_arr.std() * np.sqrt(n_trades)) if t_arr.std() > 0 else 0.0
|
||||
win_rate = wins / n_trades
|
||||
|
||||
# Equity curve
|
||||
eq = (1 + pd.Series(trades)).cumprod()
|
||||
peak = eq.cummax()
|
||||
dd = float(((eq - peak) / peak).min())
|
||||
|
||||
total_ret = eq.iloc[-1] - 1 if len(eq) > 0 else 0.0
|
||||
n_days = (close_daily.index[-1] - close_daily.index[0]).days
|
||||
n_months = n_days / 30.44
|
||||
monthly = float((1 + total_ret) ** (1 / max(n_months, 1)) - 1)
|
||||
|
||||
def backtest(signal: pd.Series, close: pd.Series) -> dict:
|
||||
if signal is None or len(signal) < 10:
|
||||
return {}
|
||||
sig = signal.fillna(0).replace([np.inf, -np.inf], 0)
|
||||
r = backtest_signal_ftmo(close, sig, txn_cost_bps=TXN_COST_BPS, wf_rolling=True)
|
||||
return {
|
||||
"sharpe": sharpe, "monthly_pct": monthly * 100,
|
||||
"max_dd": dd, "n_trades": n_trades, "win_rate": win_rate,
|
||||
"total_return": total_ret, "n_months": n_months,
|
||||
"is_sharpe": r.get("is_sharpe", None),
|
||||
"is_monthly_pct": r.get("is_monthly_return_pct", None),
|
||||
"is_trades": r.get("is_n_trades", 0),
|
||||
"oos_sharpe": r.get("oos_sharpe", None),
|
||||
"oos_monthly_pct": r.get("oos_monthly_return_pct", None),
|
||||
"oos_max_dd": r.get("oos_max_drawdown", None),
|
||||
"oos_win_rate": r.get("oos_win_rate", None),
|
||||
"oos_trades": r.get("oos_n_trades", 0),
|
||||
"wf_sharpe": r.get("wf_oos_sharpe_mean", None),
|
||||
"wf_monthly_pct": r.get("wf_oos_monthly_return_mean", None),
|
||||
"wf_consistency": r.get("wf_oos_consistency", None),
|
||||
"mc_pvalue": r.get("mc_pvalue", None),
|
||||
"full_metrics": r,
|
||||
}
|
||||
|
||||
|
||||
def build_signal(daily_factor: pd.Series, ic: float, threshold_sigma: float,
|
||||
session: str = "all") -> pd.Series:
|
||||
"""Build daily signal from a single factor."""
|
||||
sigma = daily_factor.std()
|
||||
thresh = threshold_sigma * sigma
|
||||
|
||||
# Invert if IC is negative
|
||||
sign = -1 if ic < 0 else 1
|
||||
|
||||
signal = pd.Series(0, index=daily_factor.index, dtype=int)
|
||||
signal[daily_factor > thresh] = sign
|
||||
signal[daily_factor < -thresh] = -sign
|
||||
|
||||
# Smooth: keep signal for min_hold days to avoid whipsaw
|
||||
signal = signal.replace(0, np.nan).ffill(limit=1).fillna(0).astype(int)
|
||||
|
||||
return signal
|
||||
def make_sma_signal(close, fast, slow):
|
||||
f = close.rolling(fast).mean()
|
||||
s = close.rolling(slow).mean()
|
||||
sig = pd.Series(0.0, index=close.index)
|
||||
sig[f > s] = 1
|
||||
sig[f < s] = -1
|
||||
return sig
|
||||
|
||||
|
||||
def combine_signals(s1: pd.Series, s2: pd.Series, mode: str = "confirm") -> pd.Series:
|
||||
"""Combine two daily signals."""
|
||||
common = s1.index.intersection(s2.index)
|
||||
s1c = s1.loc[common]
|
||||
s2c = s2.loc[common]
|
||||
|
||||
if mode == "confirm":
|
||||
result = pd.Series(0, index=common, dtype=int)
|
||||
result[(s1c == s2c) & (s1c != 0)] = s1c
|
||||
return result
|
||||
elif mode == "any":
|
||||
result = s1c.copy()
|
||||
result[(result == 0) & (s2c != 0)] = s2c
|
||||
return result
|
||||
else:
|
||||
return s1c
|
||||
def make_ema_signal(close, fast, slow):
|
||||
f = close.ewm(span=fast).mean()
|
||||
s = close.ewm(span=slow).mean()
|
||||
sig = pd.Series(0.0, index=close.index)
|
||||
sig[f > s] = 1
|
||||
sig[f < s] = -1
|
||||
return sig
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print(" Daily Strategy Generator")
|
||||
print("=" * 60)
|
||||
def make_rsi_signal(close, period, oversold, overbought):
|
||||
delta = close.diff()
|
||||
gain = delta.clip(lower=0)
|
||||
loss = -delta.clip(upper=0)
|
||||
rsi = 100 - (100 / (1 + gain.rolling(period).mean() / (loss.rolling(period).mean() + 1e-8)))
|
||||
sig = pd.Series(0.0, index=close.index)
|
||||
sig[rsi < oversold] = 1
|
||||
sig[rsi > overbought] = -1
|
||||
return sig
|
||||
|
||||
# Load OHLCV → daily
|
||||
print("\nLoading OHLCV...")
|
||||
df = pd.read_hdf(OHLCV_PATH, key="data")
|
||||
close = df.xs("EURUSD", level="instrument")["$close"].sort_index()
|
||||
close_daily = close.resample("D").last().dropna()
|
||||
print(f" Daily bars: {len(close_daily)} ({close_daily.index[0].date()} → {close_daily.index[-1].date()})")
|
||||
|
||||
# Load Kronos factors → daily
|
||||
print("\nLoading Kronos factors...")
|
||||
kronos = {}
|
||||
for name in ["KronosPredReturn_p96", "KronosPredReturn_p24", "KronosPredReturn_p48"]:
|
||||
series = load_kronos(name)
|
||||
ic = load_factor_ic(name)
|
||||
daily = series.resample("D").last().dropna()
|
||||
# Align to close_daily
|
||||
daily = daily.reindex(close_daily.index)
|
||||
kronos[name] = {"series": daily, "ic": ic, "std": daily.std()}
|
||||
print(f" {name}: IC={ic:+.4f} daily_rows={daily.dropna().sum()}")
|
||||
def make_macd_signal(close, fast, slow, signal_period):
|
||||
ema_fast = close.ewm(span=fast).mean()
|
||||
ema_slow = close.ewm(span=slow).mean()
|
||||
macd = ema_fast - ema_slow
|
||||
sig_line = macd.ewm(span=signal_period).mean()
|
||||
sig = pd.Series(0.0, index=close.index)
|
||||
sig[macd > sig_line] = 1
|
||||
sig[macd < sig_line] = -1
|
||||
return sig
|
||||
|
||||
# Load top daily factors
|
||||
print("\nLoading top daily factors...")
|
||||
daily_factors = {}
|
||||
for f in sorted(FACTORS_DIR.glob("*.json")):
|
||||
d = json.loads(f.read_text())
|
||||
if not isinstance(d, dict):
|
||||
continue
|
||||
ic = float(d.get("ic") or 0)
|
||||
if abs(ic) < 0.06:
|
||||
continue
|
||||
fname = d.get("factor_name") or d.get("name") or f.stem
|
||||
safe = fname.replace("/", "_").replace("\\", "_")[:150]
|
||||
parq = VALUES_DIR / f"{safe}.parquet"
|
||||
if not parq.exists():
|
||||
continue
|
||||
series = pd.read_parquet(str(parq))
|
||||
if isinstance(series.index, pd.MultiIndex):
|
||||
series = series.xs("EURUSD", level="instrument")[series.columns[0]]
|
||||
daily = series.resample("D").last().dropna().reindex(close_daily.index)
|
||||
daily_factors[fname] = {"series": daily, "ic": ic, "std": daily.std()}
|
||||
|
||||
names = list(daily_factors.keys())
|
||||
print(f" Loaded {len(names)} factors (IC ≥ 0.06)")
|
||||
def make_momentum_signal(close, n):
|
||||
mom = close.pct_change(n)
|
||||
return pd.Series(np.sign(mom).fillna(0), index=close.index)
|
||||
|
||||
|
||||
def make_meanrev_signal(close, n):
|
||||
ret = close.pct_change(n)
|
||||
return pd.Series(-np.sign(ret).fillna(0), index=close.index)
|
||||
|
||||
|
||||
def make_bollinger_signal(close, period, std_dev):
|
||||
ma = close.rolling(period).mean()
|
||||
std = close.rolling(period).std()
|
||||
sig = pd.Series(0.0, index=close.index)
|
||||
sig[close < ma - std_dev * std] = 1
|
||||
sig[close > ma + std_dev * std] = -1
|
||||
return sig
|
||||
|
||||
|
||||
def main(top_n=15, cost_bps=2.14):
|
||||
global TXN_COST_BPS
|
||||
TXN_COST_BPS = cost_bps
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" NexQuant Daily Strategy Generator")
|
||||
print(f" Cost: {cost_bps} bps | Saving top {top_n}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
close = load_daily_data()
|
||||
print(f"Data: {len(close):,} daily bars ({close.index[0].date()} - {close.index[-1].date()})\n")
|
||||
|
||||
# Grid search
|
||||
thresholds = [1.0, 1.5, 2.0, 2.5, 3.0]
|
||||
results = []
|
||||
t0 = time.time()
|
||||
|
||||
# A) Kronos single-factor
|
||||
print("\n--- Kronos single-factor grid ---")
|
||||
for kname, kdata in kronos.items():
|
||||
ks = kdata["series"]
|
||||
for thresh in thresholds:
|
||||
signal = build_signal(ks, kdata["ic"], thresh)
|
||||
bt = daily_backtest(close_daily, signal)
|
||||
bt["strategy"] = f"{kname} t={thresh}σ"
|
||||
bt["factors"] = [kname]
|
||||
bt["threshold"] = thresh
|
||||
results.append(bt)
|
||||
# SMA Crossovers
|
||||
print("SMA crossovers...")
|
||||
for fast in [5, 10, 15, 20, 30]:
|
||||
for slow in [fast * 2, fast * 3, fast * 4, fast * 5]:
|
||||
if slow > 250: continue
|
||||
sig = make_sma_signal(close, fast, slow)
|
||||
bt = backtest(sig, close)
|
||||
if bt.get("oos_trades", 0) >= MIN_TRADES_OOS:
|
||||
score = bt.get("oos_sharpe") or -999
|
||||
results.append(("SMA", f"SMA{fast}/{slow}", fast, slow, score, bt))
|
||||
|
||||
# B) Kronos + daily factor (confirmation)
|
||||
print("--- Kronos + daily factor combinations ---")
|
||||
for kname, kdata in kronos.items():
|
||||
ks = kdata["series"]
|
||||
for fname, fdata in daily_factors.items():
|
||||
for thresh_k in [1.5, 2.0]:
|
||||
for thresh_f in [1.0, 1.5, 2.0]:
|
||||
s1 = build_signal(ks, kdata["ic"], thresh_k)
|
||||
s2 = build_signal(fdata["series"], fdata["ic"], thresh_f)
|
||||
signal = combine_signals(s1, s2, "confirm")
|
||||
bt = daily_backtest(close_daily, signal)
|
||||
bt["strategy"] = f"{kname}(t={thresh_k}) + {fname}(t={thresh_f})"
|
||||
bt["factors"] = [kname, fname]
|
||||
bt["threshold"] = f"{thresh_k}/{thresh_f}"
|
||||
results.append(bt)
|
||||
# EMA Crossovers
|
||||
print("EMA crossovers...")
|
||||
for fast in [5, 10, 15, 20, 30]:
|
||||
for slow in [fast * 2, fast * 3, fast * 4, fast * 5]:
|
||||
if slow > 250: continue
|
||||
sig = make_ema_signal(close, fast, slow)
|
||||
bt = backtest(sig, close)
|
||||
if bt.get("oos_trades", 0) >= MIN_TRADES_OOS:
|
||||
score = bt.get("oos_sharpe") or -999
|
||||
results.append(("EMA", f"EMA{fast}/{slow}", fast, slow, score, bt))
|
||||
|
||||
# C) Two daily factors (no Kronos)
|
||||
print("--- Daily factor pairs ---")
|
||||
name_list = list(daily_factors.keys())
|
||||
for i in range(min(len(name_list), 10)):
|
||||
for j in range(i + 1, min(len(name_list), 10)):
|
||||
f1, f2 = name_list[i], name_list[j]
|
||||
for t1 in [1.0, 1.5, 2.0]:
|
||||
for t2 in [1.0, 1.5, 2.0]:
|
||||
s1 = build_signal(daily_factors[f1]["series"], daily_factors[f1]["ic"], t1)
|
||||
s2 = build_signal(daily_factors[f2]["series"], daily_factors[f2]["ic"], t2)
|
||||
signal = combine_signals(s1, s2, "confirm")
|
||||
bt = daily_backtest(close_daily, signal)
|
||||
bt["strategy"] = f"{f1[:20]}(t={t1}) + {f2[:20]}(t={t2})"
|
||||
bt["factors"] = [f1, f2]
|
||||
bt["threshold"] = f"{t1}/{t2}"
|
||||
results.append(bt)
|
||||
# RSI
|
||||
print("RSI strategies...")
|
||||
for period in [7, 10, 14, 21]:
|
||||
for oversold, overbought in [(20, 80), (25, 75), (30, 70), (35, 65)]:
|
||||
sig = make_rsi_signal(close, period, oversold, overbought)
|
||||
bt = backtest(sig, close)
|
||||
if bt.get("oos_trades", 0) >= MIN_TRADES_OOS:
|
||||
score = bt.get("oos_sharpe") or -999
|
||||
results.append(("RSI", f"RSI{period}({oversold}/{overbought})", period, 0, score, bt))
|
||||
|
||||
# Filter & sort
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" Total evaluations: {len(results)} Time: {time.time()-t0:.0f}s")
|
||||
print(f"{'=' * 60}")
|
||||
# MACD
|
||||
print("MACD...")
|
||||
for fast, slow, sig_p in [(8, 17, 9), (12, 26, 9), (5, 35, 5), (10, 20, 7)]:
|
||||
s = make_macd_signal(close, fast, slow, sig_p)
|
||||
bt = backtest(s, close)
|
||||
if bt.get("oos_trades", 0) >= MIN_TRADES_OOS:
|
||||
score = bt.get("oos_sharpe") or -999
|
||||
results.append(("MACD", f"MACD{fast}/{slow}/{sig_p}", fast, slow, score, bt))
|
||||
|
||||
valid = [r for r in results
|
||||
if r["sharpe"] >= MIN_SHARPE
|
||||
and r["max_dd"] >= MAX_DD
|
||||
and r["n_trades"] >= MIN_TRADES
|
||||
and r["monthly_pct"] >= MIN_MONTHLY]
|
||||
# Momentum
|
||||
print("Momentum...")
|
||||
for n in [5, 10, 20, 30, 50, 60, 90, 100, 120, 150, 200]:
|
||||
sig = make_momentum_signal(close, n)
|
||||
bt = backtest(sig, close)
|
||||
if bt.get("oos_trades", 0) >= MIN_TRADES_OOS:
|
||||
score = bt.get("oos_sharpe") or -999
|
||||
results.append(("Mom", f"Mom{n}d", n, 0, score, bt))
|
||||
|
||||
valid.sort(key=lambda r: r["monthly_pct"], reverse=True)
|
||||
# Mean Reversion
|
||||
print("Mean reversion...")
|
||||
for n in [3, 5, 7, 10, 15, 20, 30, 50]:
|
||||
sig = make_meanrev_signal(close, n)
|
||||
bt = backtest(sig, close)
|
||||
if bt.get("oos_trades", 0) >= MIN_TRADES_OOS:
|
||||
score = bt.get("oos_sharpe") or -999
|
||||
results.append(("MR", f"MR{n}d", n, 0, score, bt))
|
||||
|
||||
print(f"\n Meeting: Sharpe≥{MIN_SHARPE} DD≥{MAX_DD} Tr≥{MIN_TRADES} Mon≥{MIN_MONTHLY}%")
|
||||
print(f" → {len(valid)} strategies\n")
|
||||
# Bollinger Bands
|
||||
print("Bollinger...")
|
||||
for period in [10, 20, 50]:
|
||||
for std_dev in [1.5, 2.0, 2.5]:
|
||||
sig = make_bollinger_signal(close, period, std_dev)
|
||||
bt = backtest(sig, close)
|
||||
if bt.get("oos_trades", 0) >= MIN_TRADES_OOS:
|
||||
score = bt.get("oos_sharpe") or -999
|
||||
results.append(("BB", f"BB{period}/{std_dev}", period, std_dev, score, bt))
|
||||
|
||||
fmt = "{:3s} {:55s} {:>7s} {:>7s} {:>7s} {:>5s} {:>6s}"
|
||||
print(fmt.format("#", "Strategy", "Sharpe", "Mon%", "MaxDD", "Tr", "WinRt"))
|
||||
print("-" * 90)
|
||||
for i, r in enumerate(valid[:30], 1):
|
||||
print(fmt.format(str(i), r["strategy"][:55],
|
||||
f'{r["sharpe"]:.2f}', f'{r["monthly_pct"]:.1f}%',
|
||||
f'{r["max_dd"]:.3f}', str(r["n_trades"]),
|
||||
f'{r["win_rate"]:.1%}'))
|
||||
# Sort by OOS Sharpe
|
||||
results.sort(key=lambda x: x[4] if x[4] is not None else -999, reverse=True)
|
||||
|
||||
if not valid:
|
||||
results.sort(key=lambda r: r["monthly_pct"], reverse=True)
|
||||
print("\n Top 10 by monthly return:")
|
||||
for i, r in enumerate(results[:10], 1):
|
||||
print(f" {i:2d}. {r['strategy'][:50]} Mon={r['monthly_pct']:.1f}% Sh={r['sharpe']:.2f} Tr={r['n_trades']}")
|
||||
print(f"\n{'='*70}")
|
||||
print(f" TOP {top_n} DAILY STRATEGIES (Cost: {cost_bps} bps)")
|
||||
print(f"{'='*70}")
|
||||
print(f" {'#':<3} {'Type':<6} {'Name':<22} {'OOS S':>8} {'Mon%':>7} {'DD%':>6} {'WF S':>8} {'Trades':>6}")
|
||||
print(f" {'-'*68}")
|
||||
|
||||
# Save
|
||||
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
out = RESULTS_DIR / f"daily_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
out.write_text(json.dumps(valid[:50] if valid else results[:50], indent=2, default=str))
|
||||
print(f"\n Saved → {out}")
|
||||
saved = []
|
||||
for i, (stype, name, p1, p2, score, bt) in enumerate(results[:top_n]):
|
||||
oos_m = (bt.get("oos_monthly_pct") or 0)
|
||||
oos_dd = (bt.get("oos_max_dd") or 0) * 100
|
||||
wf_s = bt.get("wf_sharpe") or 0
|
||||
trades = bt.get("oos_trades", 0)
|
||||
status = "✅" if score > 0 else " "
|
||||
print(f" {i+1:<3} {stype:<6} {name:<22} {score:>+8.2f} {oos_m:>+6.2f}% {oos_dd:>+5.1f}% {wf_s:>+8.2f} {trades:>6} {status}")
|
||||
|
||||
entry = {
|
||||
"strategy_name": name,
|
||||
"type": stype,
|
||||
"param1": p1,
|
||||
"param2": p2,
|
||||
"cost_bps": cost_bps,
|
||||
"frequency": "daily",
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"metrics": {k: v for k, v in bt.items() if k != "full_metrics"},
|
||||
}
|
||||
saved.append(entry)
|
||||
|
||||
# Save individual strategy
|
||||
safe_name = name.replace("(", "").replace(")", "").replace("/", "-")
|
||||
fname = OUT_DIR / f"daily_{safe_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
with open(fname, "w") as f:
|
||||
json.dump(entry, f, indent=2)
|
||||
|
||||
# Save summary
|
||||
summary = {
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"cost_bps": cost_bps,
|
||||
"frequency": "daily",
|
||||
"n_bars": len(close),
|
||||
"date_range": [str(close.index[0].date()), str(close.index[-1].date())],
|
||||
"top_strategies": [
|
||||
{"name": s["strategy_name"], "oos_sharpe": s["metrics"].get("oos_sharpe"),
|
||||
"oos_monthly_pct": s["metrics"].get("oos_monthly_pct")}
|
||||
for s in saved[:10]
|
||||
],
|
||||
}
|
||||
with open(OUT_DIR / "daily_summary.json", "w") as f:
|
||||
json.dump(summary, f, indent=2)
|
||||
|
||||
profit_count = sum(1 for r in results if r[4] and r[4] > 0)
|
||||
print(f"\n{profit_count}/{len(results)} strategies profitable ({profit_count/len(results)*100:.0f}%)")
|
||||
print(f"Saved to {OUT_DIR}/")
|
||||
return saved
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--top", type=int, default=15)
|
||||
parser.add_argument("--cost", type=float, default=2.14)
|
||||
args = parser.parse_args()
|
||||
main(top_n=args.top, cost_bps=args.cost)
|
||||
|
||||
@@ -9,33 +9,26 @@ Usage:
|
||||
# Swing trading (96-bar forward returns)
|
||||
python nexquant_gen_strategies_real_bt.py 10
|
||||
|
||||
# Daytrading with RiskMgmt constraints (12-bar forward returns)
|
||||
# Daytrading with FTMO constraints (12-bar forward returns)
|
||||
TRADING_STYLE=daytrading python nexquant_gen_strategies_real_bt.py 5
|
||||
|
||||
# With parallel workers (default: CPU count)
|
||||
TRADING_STYLE=daytrading WORKERS=4 python nexquant_gen_strategies_real_bt.py 20
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import warnings
|
||||
from datetime import datetime
|
||||
import os, sys, json, time, math, random, logging, warnings, subprocess
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from dotenv import load_dotenv
|
||||
from rich.console import Console
|
||||
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Suppress warnings and noisy loggers that bleed into Rich progress output
|
||||
warnings.filterwarnings("ignore")
|
||||
for _noisy in ("rdagent", "litellm", "LiteLLM", "litellm.utils",
|
||||
"litellm.main", "httpx", "httpcore", "openai", "urllib3"):
|
||||
warnings.filterwarnings('ignore')
|
||||
for _noisy in ('rdagent', 'litellm', 'LiteLLM', 'litellm.utils',
|
||||
'litellm.main', 'httpx', 'httpcore', 'openai', 'urllib3'):
|
||||
logging.getLogger(_noisy).setLevel(logging.CRITICAL)
|
||||
# Suppress litellm verbose flag if already imported
|
||||
try:
|
||||
@@ -49,38 +42,36 @@ except Exception:
|
||||
# ============================================================================
|
||||
# Configuration
|
||||
# ============================================================================
|
||||
OHLCV_PATH = Path("/home/nico/NexQuant/git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
|
||||
FACTORS_DIR = Path("/home/nico/NexQuant/results/factors")
|
||||
STRATEGIES_DIR = Path("/home/nico/NexQuant/results/strategies_new")
|
||||
OHLCV_PATH = Path('/home/nico/NexQuant/git_ignore_folder/factor_implementation_source_data/intraday_pv.h5')
|
||||
FACTORS_DIR = Path('/home/nico/NexQuant/results/factors')
|
||||
STRATEGIES_DIR = Path('/home/nico/NexQuant/results/strategies_new')
|
||||
STRATEGIES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Trading style
|
||||
TRADING_STYLE = os.getenv("TRADING_STYLE", "swing")
|
||||
N_WORKERS = int(os.getenv("WORKERS", os.cpu_count() or 4))
|
||||
TRADING_STYLE = os.getenv('TRADING_STYLE', 'swing')
|
||||
N_WORKERS = int(os.getenv('WORKERS', os.cpu_count() or 4))
|
||||
|
||||
if TRADING_STYLE == "daytrading":
|
||||
FORWARD_BARS = int(os.getenv("FORWARD_BARS", "12"))
|
||||
if TRADING_STYLE == 'daytrading':
|
||||
FORWARD_BARS = int(os.getenv('FORWARD_BARS', '12'))
|
||||
MIN_IC = 0.02
|
||||
MIN_SHARPE = 0.5
|
||||
MIN_TRADES = 300
|
||||
MAX_DRAWDOWN = -0.10
|
||||
MIN_MONTHLY_RETURN_PCT = 15.0
|
||||
STYLE_EMOJI = "🎯 Daytrading"
|
||||
STYLE_DESC = "short-term intraday with RiskMgmt compliance"
|
||||
STYLE_EMOJI = '🎯 Daytrading'
|
||||
STYLE_DESC = 'short-term intraday with FTMO compliance'
|
||||
else:
|
||||
FORWARD_BARS = int(os.getenv("FORWARD_BARS", "96"))
|
||||
FORWARD_BARS = int(os.getenv('FORWARD_BARS', '96'))
|
||||
MIN_IC = 0.02
|
||||
MIN_SHARPE = 0.5
|
||||
MIN_TRADES = 10
|
||||
MAX_DRAWDOWN = -0.30
|
||||
MIN_MONTHLY_RETURN_PCT = 15.0
|
||||
STYLE_EMOJI = "📈 Swing"
|
||||
STYLE_DESC = "medium-term intraday"
|
||||
STYLE_EMOJI = '📈 Swing'
|
||||
STYLE_DESC = 'medium-term intraday'
|
||||
|
||||
# Whether to use raw OHLCV-only strategies (no daily factors)
|
||||
OHLCV_ONLY = os.getenv("OHLCV_ONLY", "0") == "1"
|
||||
OHLCV_ONLY = os.getenv('OHLCV_ONLY', '0') == '1'
|
||||
|
||||
TXN_COST_BPS = float(os.getenv("TXN_COST_BPS", "2.14")) # 2.35 pip realistic EUR/USD costs
|
||||
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"
|
||||
@@ -117,14 +108,14 @@ console = Console(file=_TeeFile(sys.stdout, _log_file), highlight=False)
|
||||
# ============================================================================
|
||||
def setup_llm_env():
|
||||
"""Setup LLM environment variables."""
|
||||
load_dotenv(Path(__file__).parent.parent / ".env")
|
||||
if os.getenv("OPENAI_API_KEY") == "local" or os.getenv("LLM_BACKEND", "").lower() == "local":
|
||||
load_dotenv(Path(__file__).parent.parent / '.env')
|
||||
if os.getenv('OPENAI_API_KEY') == 'local' or os.getenv('LLM_BACKEND', '').lower() == 'local':
|
||||
return
|
||||
router_key = os.getenv("OPENROUTER_API_KEY", "")
|
||||
router_key = os.getenv('OPENROUTER_API_KEY', '')
|
||||
if router_key:
|
||||
os.environ["OPENAI_API_KEY"] = router_key
|
||||
os.environ["OPENAI_API_BASE"] = "https://openrouter.ai/api/v1"
|
||||
os.environ["CHAT_MODEL"] = os.getenv("OPENROUTER_MODEL", "openrouter/google/gemma-4-26b-a4b-it:free")
|
||||
os.environ['OPENAI_API_KEY'] = router_key
|
||||
os.environ['OPENAI_API_BASE'] = 'https://openrouter.ai/api/v1'
|
||||
os.environ['CHAT_MODEL'] = os.getenv('OPENROUTER_MODEL', 'openrouter/google/gemma-4-26b-a4b-it:free')
|
||||
|
||||
# ============================================================================
|
||||
# Factor Loading (cached at module level for each process)
|
||||
@@ -136,20 +127,20 @@ def load_available_factors(top_n=20):
|
||||
global _FACTORS_CACHE
|
||||
if _FACTORS_CACHE is not None:
|
||||
return _FACTORS_CACHE[:top_n]
|
||||
|
||||
|
||||
factors = []
|
||||
for f in FACTORS_DIR.glob("*.json"):
|
||||
for f in FACTORS_DIR.glob('*.json'):
|
||||
try:
|
||||
data = json.load(open(f))
|
||||
fname = data.get("factor_name", "")
|
||||
ic = data.get("ic") or 0
|
||||
safe = fname.replace("/","_").replace("\\","_")[:150]
|
||||
if (FACTORS_DIR / "values" / f"{safe}.parquet").exists():
|
||||
factors.append({"name": fname, "ic": ic})
|
||||
fname = data.get('factor_name', '')
|
||||
ic = data.get('ic') or 0
|
||||
safe = fname.replace('/','_').replace('\\','_')[:150]
|
||||
if (FACTORS_DIR / 'values' / f"{safe}.parquet").exists():
|
||||
factors.append({'name': fname, 'ic': ic})
|
||||
except:
|
||||
pass
|
||||
|
||||
factors.sort(key=lambda x: abs(x["ic"]), reverse=True)
|
||||
|
||||
factors.sort(key=lambda x: abs(x['ic']), reverse=True)
|
||||
_FACTORS_CACHE = factors
|
||||
return factors[:top_n]
|
||||
|
||||
@@ -163,18 +154,18 @@ def load_ohlcv_data():
|
||||
global _OHLCV_CACHE
|
||||
if _OHLCV_CACHE is not None:
|
||||
return _OHLCV_CACHE
|
||||
|
||||
|
||||
if not OHLCV_PATH.exists():
|
||||
raise FileNotFoundError(f"OHLCV data not found: {OHLCV_PATH}")
|
||||
|
||||
ohlcv = pd.read_hdf(str(OHLCV_PATH), key="data")
|
||||
if "$close" in ohlcv.columns:
|
||||
close = ohlcv["$close"]
|
||||
elif "close" in ohlcv.columns:
|
||||
close = ohlcv["close"]
|
||||
|
||||
ohlcv = pd.read_hdf(str(OHLCV_PATH), key='data')
|
||||
if '$close' in ohlcv.columns:
|
||||
close = ohlcv['$close']
|
||||
elif 'close' in ohlcv.columns:
|
||||
close = ohlcv['close']
|
||||
else:
|
||||
close = ohlcv.select_dtypes(include=[np.number]).iloc[:, 0]
|
||||
|
||||
|
||||
_OHLCV_CACHE = close.dropna()
|
||||
return _OHLCV_CACHE
|
||||
|
||||
@@ -184,16 +175,16 @@ def load_ohlcv_data():
|
||||
def generate_single_strategy(args):
|
||||
"""Generate and backtest ONE strategy. Runs in separate process."""
|
||||
idx, factor_subset, feedback, attempt = args
|
||||
|
||||
|
||||
try:
|
||||
setup_llm_env()
|
||||
|
||||
|
||||
from rdagent.oai.llm_utils import APIBackend
|
||||
|
||||
|
||||
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" and OHLCV_ONLY:
|
||||
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:
|
||||
@@ -228,10 +219,9 @@ Hard requirements:
|
||||
- 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
|
||||
- TARGET MONTHLY RETURN: Generate signals that can achieve >15% OOS monthly return after RiskMgmt costs (2.35 pip/trade). Use high-conviction entries only."""
|
||||
- Keep it simple: 2-3 indicators max"""
|
||||
|
||||
elif TRADING_STYLE == "daytrading":
|
||||
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):
|
||||
@@ -257,8 +247,7 @@ Hard requirements:
|
||||
- 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
|
||||
- TARGET MONTHLY RETURN: Generate signals that can achieve >15% OOS monthly return after RiskMgmt costs (2.35 pip/trade). Use high-conviction entries only."""
|
||||
- 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 daily swing strategies.
|
||||
@@ -289,26 +278,26 @@ Output ONLY valid JSON with these fields:
|
||||
|
||||
{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. TARGET MONTHLY RETURN: Generate signals that can achieve >15% OOS monthly return after RiskMgmt costs (2.35 pip/trade)."""
|
||||
|
||||
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(
|
||||
user_prompt=user_prompt, system_prompt=system_prompt, json_mode=True,
|
||||
user_prompt=user_prompt, system_prompt=system_prompt, json_mode=True
|
||||
)
|
||||
strategy_data = json.loads(response)
|
||||
|
||||
|
||||
# Validate response
|
||||
if "code" not in strategy_data or "factor_names" not in strategy_data:
|
||||
return {"status": "invalid", "reason": "Missing required fields", "idx": idx}
|
||||
|
||||
if 'code' not in strategy_data or 'factor_names' not in strategy_data:
|
||||
return {'status': 'invalid', 'reason': 'Missing required fields', 'idx': idx}
|
||||
|
||||
return {
|
||||
"status": "generated",
|
||||
"strategy": strategy_data,
|
||||
"idx": idx,
|
||||
'status': 'generated',
|
||||
'strategy': strategy_data,
|
||||
'idx': idx
|
||||
}
|
||||
|
||||
|
||||
except Exception as e:
|
||||
return {"status": "error", "reason": str(e)[:200], "idx": idx}
|
||||
return {'status': 'error', 'reason': str(e)[:200], 'idx': idx}
|
||||
|
||||
# ============================================================================
|
||||
# Backtest Runner (runs in main process to avoid re-loading data)
|
||||
@@ -356,39 +345,39 @@ signal.fillna(0).to_pickle('signal.pkl')
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
tdp = Path(td)
|
||||
close.to_pickle(str(tdp / "close.pkl"))
|
||||
close.to_pickle(str(tdp / 'close.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)
|
||||
factors_df.to_pickle(str(tdp / 'factors.pkl'))
|
||||
(tdp / 'run.py').write_text(script)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["python", "run.py"],
|
||||
['python', 'run.py'],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
cwd=str(tdp),
|
||||
cwd=str(tdp)
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return {"status": "failed", "reason": (result.stderr or result.stdout)[:200]}
|
||||
return {'status': 'failed', 'reason': (result.stderr or result.stdout)[:200]}
|
||||
|
||||
signal = pd.read_pickle(tdp / "signal.pkl")
|
||||
signal = pd.read_pickle(tdp / 'signal.pkl')
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"status": "failed", "reason": "Timeout (60s)"}
|
||||
return {'status': 'failed', 'reason': 'Timeout (60s)'}
|
||||
except Exception as e:
|
||||
return {"status": "failed", "reason": str(e)[:200]}
|
||||
return {'status': 'failed', 'reason': str(e)[:200]}
|
||||
|
||||
# Main process: RiskMgmt-realistic backtest (leverage + daily/total loss limits).
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_risk
|
||||
# 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)"}
|
||||
return {'status': 'failed', 'reason': f'Not enough aligned data ({len(common)} bars)'}
|
||||
|
||||
close_a = close.loc[common]
|
||||
signal_a = signal.reindex(common).fillna(0)
|
||||
fwd_returns = close_a.pct_change(FORWARD_BARS).shift(-FORWARD_BARS)
|
||||
|
||||
from rdagent.components.backtesting.vbt_backtest import OOS_START_DEFAULT
|
||||
return backtest_signal_risk(
|
||||
return backtest_signal_ftmo(
|
||||
close=close_a,
|
||||
signal=signal_a,
|
||||
txn_cost_bps=TXN_COST_BPS,
|
||||
@@ -420,9 +409,9 @@ def _rescale_thresholds(code: str, scale: float) -> str:
|
||||
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)
|
||||
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)
|
||||
code = re.sub(r'\b(0\.\d+|[12]\.\d+)\b', replace_small, code)
|
||||
return code
|
||||
|
||||
|
||||
@@ -437,12 +426,12 @@ def tune_thresholds(close, factors_df, code: str) -> tuple:
|
||||
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":
|
||||
if bt is None or bt.get('status') != 'success':
|
||||
continue
|
||||
trades = bt.get("n_trades", 0)
|
||||
sharpe = bt.get("sharpe", -999)
|
||||
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):
|
||||
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)
|
||||
@@ -472,46 +461,46 @@ def main(target_count=10):
|
||||
console.print(f" Forward bars: {FORWARD_BARS}")
|
||||
console.print(f" Target: {target_count} accepted strategies")
|
||||
console.print(f" Workers: {N_WORKERS}\n")
|
||||
|
||||
|
||||
# Load data (main process only)
|
||||
close = load_ohlcv_data()
|
||||
factors = load_available_factors(20)
|
||||
|
||||
|
||||
console.print(f"[green]✓[/green] Loaded {len(factors)} factors, {len(close):,} OHLCV bars\n")
|
||||
|
||||
|
||||
# Load factor time-series
|
||||
factor_data = {}
|
||||
with Progress(SpinnerColumn(), TextColumn("[bold blue]Loading factors..."), BarColumn(), TimeElapsedColumn()) as progress:
|
||||
task = progress.add_task("Loading...", total=len(factors))
|
||||
for f_info in factors:
|
||||
safe = f_info["name"].replace("/","_").replace("\\","_")[:150]
|
||||
pf = FACTORS_DIR / "values" / f"{safe}.parquet"
|
||||
safe = f_info['name'].replace('/','_').replace('\\','_')[:150]
|
||||
pf = FACTORS_DIR / 'values' / f"{safe}.parquet"
|
||||
if pf.exists():
|
||||
try:
|
||||
series = pd.read_parquet(str(pf)).iloc[:, 0]
|
||||
factor_data[f_info["name"]] = series
|
||||
factor_data[f_info['name']] = series
|
||||
except:
|
||||
pass
|
||||
progress.update(task, advance=1)
|
||||
|
||||
|
||||
# Align factors with close prices
|
||||
all_factor_series = [factor_data[n] for n in factor_data if n in factor_data]
|
||||
if not all_factor_series:
|
||||
console.print("[red]✗ No factor data loaded![/red]")
|
||||
return
|
||||
|
||||
|
||||
df_factors = pd.DataFrame({n: factor_data[n] for n in factor_data if n in factor_data})
|
||||
common_idx = close.index.intersection(df_factors.dropna(how="all").index)
|
||||
common_idx = close.index.intersection(df_factors.dropna(how='all').index)
|
||||
close_aligned = close.loc[common_idx]
|
||||
df_aligned = df_factors.loc[common_idx]
|
||||
|
||||
|
||||
console.print(f"[green]✓[/green] Aligned {len(df_aligned):,} data points\n")
|
||||
|
||||
|
||||
# Strategy generation loop
|
||||
accepted = []
|
||||
feedback_history = []
|
||||
max_attempts = target_count * 10 # Allow 10x attempts
|
||||
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[bold blue]{task.description}"),
|
||||
@@ -522,11 +511,11 @@ def main(target_count=10):
|
||||
redirect_stderr=True,
|
||||
) as progress:
|
||||
task = progress.add_task("Generating...", total=max_attempts)
|
||||
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
if len(accepted) >= target_count:
|
||||
break
|
||||
|
||||
|
||||
# Select random factor subset (2-5 factors) — empty for OHLCV-only mode
|
||||
if OHLCV_ONLY:
|
||||
factor_subset = []
|
||||
@@ -539,51 +528,51 @@ def main(target_count=10):
|
||||
# Generate in main process (LLM doesn't parallelize well)
|
||||
gen_result = generate_single_strategy((attempt, factor_subset, feedback, attempt))
|
||||
|
||||
if gen_result["status"] != "generated":
|
||||
if gen_result['status'] != 'generated':
|
||||
progress.update(task, advance=1)
|
||||
continue
|
||||
|
||||
strategy = gen_result["strategy"]
|
||||
strategy = gen_result['strategy']
|
||||
|
||||
# Backtest (main process - needs data access)
|
||||
if OHLCV_ONLY:
|
||||
strat_factors = None
|
||||
bt_result = run_backtest(close, None, strategy.get("code", ""))
|
||||
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]]
|
||||
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)
|
||||
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)
|
||||
|
||||
# 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":
|
||||
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:
|
||||
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)
|
||||
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)
|
||||
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:
|
||||
@@ -593,54 +582,54 @@ def main(target_count=10):
|
||||
continue
|
||||
|
||||
# Monte Carlo p-value (edge significance)
|
||||
mc_pvalue = bt_result.get("mc_pvalue")
|
||||
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")
|
||||
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 > MIN_MONTHLY_RETURN_PCT and mc_ok and wf_ok):
|
||||
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
|
||||
strategy["summary"] = {
|
||||
"sharpe": sharpe, "max_drawdown": dd, "win_rate": bt_result.get("win_rate", 0),
|
||||
"monthly_return_pct": bt_result.get("monthly_return_pct", 0),
|
||||
"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": "riskmgmt_v2",
|
||||
"txn_cost_bps": TXN_COST_BPS,
|
||||
strategy['real_backtest'] = bt_result
|
||||
strategy['metrics'] = bt_result
|
||||
strategy['summary'] = {
|
||||
'sharpe': sharpe, 'max_drawdown': dd, 'win_rate': bt_result.get('win_rate', 0),
|
||||
'monthly_return_pct': bt_result.get('monthly_return_pct', 0),
|
||||
'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"),
|
||||
'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,
|
||||
'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"),
|
||||
'mc_pvalue': mc_pvalue,
|
||||
'mc_n_permutations': bt_result.get('mc_n_permutations'),
|
||||
}
|
||||
|
||||
|
||||
fname = f"{int(time.time())}_{strategy['strategy_name']}.json"
|
||||
with open(STRATEGIES_DIR / fname, "w") as f:
|
||||
with open(STRATEGIES_DIR / fname, 'w') as f:
|
||||
json.dump(strategy, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
# Generate PDF report
|
||||
try:
|
||||
from nexquant_strategy_report import StrategyPerformanceReporter
|
||||
@@ -648,7 +637,7 @@ def main(target_count=10):
|
||||
reporter.generate_report()
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
accepted.append(strategy)
|
||||
_log.success(f"ACCEPTED {strategy['strategy_name']} IC={ic:.4f} Sharpe={sharpe:.3f} Trades={trades} DD={dd:.1%}")
|
||||
feedback_history.append(f"Excellent! IC={ic:.4f}, Sharpe={sharpe:.2f}, Trades={trades}. Try to improve further.")
|
||||
@@ -667,27 +656,27 @@ def main(target_count=10):
|
||||
+ (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>{MIN_MONTHLY_RETURN_PCT}%, MC_p<0.20, WF_consistency≥50%.",
|
||||
f"OOS_Sharpe>0, OOS_Monthly>0, MC_p<0.20, WF_consistency≥50%."
|
||||
)
|
||||
|
||||
|
||||
progress.update(task, advance=1)
|
||||
|
||||
|
||||
# Summary
|
||||
_log.info(f"DONE accepted={len(accepted)} target={target_count}")
|
||||
for i, s in enumerate(sorted(accepted, key=lambda x: x["real_backtest"].get("ic", 0), reverse=True), 1):
|
||||
bt = s["real_backtest"]
|
||||
for i, s in enumerate(sorted(accepted, key=lambda x: x['real_backtest'].get('ic', 0), reverse=True), 1):
|
||||
bt = s['real_backtest']
|
||||
_log.info(f" #{i} {s['strategy_name']} IC={bt.get('ic',0):.4f} Sharpe={bt.get('sharpe',0):.3f} Monthly={bt.get('monthly_return_pct',0):.2f}%")
|
||||
|
||||
console.print(f"\n[bold green]✓ Generated {len(accepted)}/{target_count} accepted strategies[/bold green]\n")
|
||||
|
||||
if accepted:
|
||||
accepted.sort(key=lambda x: x["real_backtest"].get("ic", 0), reverse=True)
|
||||
accepted.sort(key=lambda x: x['real_backtest'].get('ic', 0), reverse=True)
|
||||
console.print("[bold]Results:[/bold]")
|
||||
for i, s in enumerate(accepted, 1):
|
||||
bt = s["real_backtest"]
|
||||
bt = s['real_backtest']
|
||||
console.print(f" {i}. {s['strategy_name']:30s} IC={bt.get('ic',0):.4f} Sharpe={bt.get('sharpe',0):.3f} "
|
||||
f"Monthly={bt.get('monthly_return_pct',0):.2f}% Trades={bt.get('n_trades',0)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == '__main__':
|
||||
count = int(sys.argv[1]) if len(sys.argv) > 1 else 10
|
||||
main(count)
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Strategy Grid Search — Systematic parameter scanning for optimal strategies.
|
||||
|
||||
Unlike the random R&D loop, this tests ALL parameter/TF combinations
|
||||
for the best indicators, guaranteeing global optimum discovery.
|
||||
|
||||
Output: Ranked list of strategies with per-instrument + combined metrics.
|
||||
"""
|
||||
|
||||
import json, os, sys, time, itertools
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
PROJECT = Path(__file__).resolve().parent.parent
|
||||
OHLCV_PATH = Path(os.getenv("PREDIX_OHLCV_PATH",
|
||||
str(PROJECT / "git_ignore_folder" / "intraday_pv_all.h5")))
|
||||
OUTPUT_DIR = PROJECT / "results" / "grid_search"
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sys.path.insert(0, str(PROJECT / "scripts"))
|
||||
from nexquant_rd_loop import (
|
||||
evaluate_multi, build_signal, _apply_session_filter, _apply_news_filter,
|
||||
_apply_vola_filter, _apply_cross_confirm, LEADER_MAP, load_data,
|
||||
)
|
||||
|
||||
# ── Grid Definition ──
|
||||
|
||||
INDICATOR_GRIDS = {
|
||||
"MACD": {
|
||||
"type": "multi_tf",
|
||||
"params": {
|
||||
"fast": [3, 5, 8, 12],
|
||||
"slow": [10, 15, 20, 26, 40],
|
||||
"sig": [3, 5, 9],
|
||||
},
|
||||
"tfs": [
|
||||
["15min", "30min", "1h", "4h"],
|
||||
["15min", "30min", "1h"],
|
||||
["30min", "1h", "4h"],
|
||||
["15min", "1h", "4h"],
|
||||
],
|
||||
},
|
||||
"Donchian": {
|
||||
"type": "multi_tf",
|
||||
"params": {
|
||||
"period": [5, 10, 20, 30, 50, 80, 100],
|
||||
"hold": [1, 2, 3, 5, 10],
|
||||
},
|
||||
"tfs": [
|
||||
["15min", "30min", "1h", "4h"],
|
||||
["30min", "1h", "4h"],
|
||||
["15min", "1h", "4h"],
|
||||
],
|
||||
},
|
||||
"SAR": {
|
||||
"type": "multi_tf",
|
||||
"params": {
|
||||
"accel": [0.02, 0.05, 0.08, 0.1, 0.15],
|
||||
"max_accel": [0.1, 0.2, 0.3, 0.5],
|
||||
},
|
||||
"tfs": [
|
||||
["15min", "30min", "1h", "4h"],
|
||||
["30min", "1h", "4h"],
|
||||
["15min", "1h", "4h"],
|
||||
],
|
||||
},
|
||||
"ADX": {
|
||||
"type": "multi_tf",
|
||||
"params": {
|
||||
"period": [7, 10, 14, 21, 30],
|
||||
"threshold": [15, 20, 25, 30],
|
||||
},
|
||||
"tfs": [
|
||||
["15min", "30min", "1h", "4h"],
|
||||
["30min", "1h", "4h"],
|
||||
],
|
||||
},
|
||||
"RSI": {
|
||||
"type": "multi_tf",
|
||||
"params": {
|
||||
"period": [7, 10, 14, 21],
|
||||
"oversold": [20, 25, 30],
|
||||
"overbought": [70, 75, 80],
|
||||
},
|
||||
"tfs": [
|
||||
["15min", "30min", "1h", "4h"],
|
||||
["30min", "1h", "4h"],
|
||||
],
|
||||
},
|
||||
"BBands": {
|
||||
"type": "multi_tf",
|
||||
"params": {
|
||||
"period": [10, 20, 40],
|
||||
"std": [1.5, 2.0, 2.5],
|
||||
},
|
||||
"tfs": [
|
||||
["15min", "30min", "1h", "4h"],
|
||||
["30min", "1h", "4h"],
|
||||
],
|
||||
},
|
||||
"ROC": {
|
||||
"type": "multi_tf",
|
||||
"params": {
|
||||
"period": [5, 10, 20, 50],
|
||||
"threshold": [0.1, 0.2, 0.5, 1.0],
|
||||
},
|
||||
"tfs": [
|
||||
["15min", "30min", "1h", "4h"],
|
||||
["30min", "1h", "4h"],
|
||||
],
|
||||
},
|
||||
"MOM": {
|
||||
"type": "multi_tf",
|
||||
"params": {
|
||||
"period": [5, 10, 20, 50, 100],
|
||||
},
|
||||
"tfs": [
|
||||
["15min", "30min", "1h", "4h"],
|
||||
["30min", "1h", "4h"],
|
||||
],
|
||||
},
|
||||
"Stoch": {
|
||||
"type": "multi_tf",
|
||||
"params": {
|
||||
"fastk": [5, 9, 14],
|
||||
"slowd": [3, 5, 9],
|
||||
},
|
||||
"tfs": [
|
||||
["15min", "30min", "1h", "4h"],
|
||||
["30min", "1h", "4h"],
|
||||
],
|
||||
},
|
||||
"CCI": {
|
||||
"type": "multi_tf",
|
||||
"params": {
|
||||
"period": [10, 14, 20, 50],
|
||||
},
|
||||
"tfs": [
|
||||
["15min", "30min", "1h", "4h"],
|
||||
["30min", "1h", "4h"],
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def expand_grid(indicator_name):
|
||||
"""Expand a grid definition into all parameter+TF combinations."""
|
||||
grid = INDICATOR_GRIDS[indicator_name]
|
||||
param_keys = list(grid["params"].keys())
|
||||
param_values = [grid["params"][k] for k in param_keys]
|
||||
hypotheses = []
|
||||
|
||||
for tf_list in grid["tfs"]:
|
||||
for param_combo in itertools.product(*param_values):
|
||||
params = dict(zip(param_keys, param_combo))
|
||||
hypotheses.append({
|
||||
"type": grid["type"],
|
||||
"indicator": indicator_name,
|
||||
"timeframes": tf_list,
|
||||
"params": params,
|
||||
"description": f"{indicator_name}({'-'.join(str(v) for v in param_combo)}) on {','.join(tf_list[:2])}",
|
||||
"generation": "grid",
|
||||
})
|
||||
|
||||
return hypotheses
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--indicators", nargs="*", default=None,
|
||||
help="Indicators to grid-search (default: all)")
|
||||
ap.add_argument("--top", type=int, default=20,
|
||||
help="Number of top results to show")
|
||||
args = ap.parse_args()
|
||||
|
||||
indicators = args.indicators or list(INDICATOR_GRIDS.keys())
|
||||
if isinstance(indicators, str):
|
||||
indicators = [indicators]
|
||||
|
||||
print("=" * 60)
|
||||
print(" Strategy Grid Search")
|
||||
print(f" Indicators: {', '.join(indicators)}")
|
||||
print("=" * 60)
|
||||
|
||||
# Load data
|
||||
print(" Loading data...")
|
||||
closes = load_data()
|
||||
if not closes:
|
||||
print(" No instruments found!"); return
|
||||
|
||||
# Generate all hypotheses
|
||||
all_hypotheses = []
|
||||
for ind in indicators:
|
||||
hyps = expand_grid(ind)
|
||||
all_hypotheses.extend(hyps)
|
||||
print(f" Total combinations to test: {len(all_hypotheses)}")
|
||||
print()
|
||||
|
||||
# Evaluate all
|
||||
results = []
|
||||
t0 = time.time()
|
||||
for i, hp in enumerate(all_hypotheses):
|
||||
try:
|
||||
r = evaluate_multi(closes, hp, use_session=True, use_vola=False)
|
||||
r["hypothesis"] = hp
|
||||
r["rank"] = i + 1
|
||||
results.append(r)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
elapsed = time.time() - t0
|
||||
rate = (i + 1) / elapsed if elapsed > 0 else 0
|
||||
eta = (len(all_hypotheses) - i - 1) / rate if rate > 0 else 0
|
||||
|
||||
if (i + 1) % 50 == 0:
|
||||
best_so_far = max(results, key=lambda x: x["sharpe"]) if results else {"sharpe": 0}
|
||||
print(f" [{i+1}/{len(all_hypotheses)}] "
|
||||
f"Best Sh={best_so_far['sharpe']:.1f} "
|
||||
f"Mon={best_so_far['monthly_pct']:.1f}% "
|
||||
f"OOS={best_so_far['monthly_oos']:.1f}% | "
|
||||
f"{rate:.0f}/s | ETA {eta/60:.0f}min")
|
||||
|
||||
# Sort by OOS Sharpe (most important metric)
|
||||
results.sort(key=lambda r: r.get("sharpe", 0), reverse=True)
|
||||
|
||||
elapsed = time.time() - t0
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" Grid Search Complete: {len(results)}/{len(all_hypotheses)} valid")
|
||||
print(f" Time: {elapsed:.0f}s ({elapsed/60:.1f}min)")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
# Save all results
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
out_file = OUTPUT_DIR / f"grid_results_{ts}.json"
|
||||
stripped = [{k: v for k, v in r.items() if k != "equity_curves"} for r in results]
|
||||
out_file.write_text(json.dumps(stripped, indent=2, default=str))
|
||||
print(f" Saved: {out_file}")
|
||||
|
||||
# Show top results
|
||||
top_n = min(args.top, len(results))
|
||||
print(f"\n TOP {top_n} (by OOS Sharpe):")
|
||||
print(f" {'Rank':>4s} {'Strategy':<45s} {'Sh_IS':>6s} {'Sh_OOS':>6s} {'Mon%':>7s} {'OOS%':>7s} {'DD':>6s} {'Tr':>5s} {'BTC':>5s}")
|
||||
for i, r in enumerate(results[:top_n], 1):
|
||||
hp = r["hypothesis"]
|
||||
per = r.get("per_instrument", {})
|
||||
btc_sh = per.get("BTCUSD", {}).get("sharpe_oos", 0)
|
||||
print(f" {i:4d} {hp['description'][:45]:45s} "
|
||||
f"{r.get('sharpe_is', 0):+6.1f} {r.get('sharpe_oos', 0):+6.1f} "
|
||||
f"{r['monthly_pct']:+6.1f}% {r['monthly_oos']:+6.1f}% "
|
||||
f"{r['max_dd']:.4f} {r['n_trades']:5d} {btc_sh:+5.0f}")
|
||||
|
||||
# Indicator performance summary
|
||||
print(f"\n Indicator Performance (avg OOS Sharpe):")
|
||||
for ind in indicators:
|
||||
ind_results = [r for r in results if r["hypothesis"].get("indicator") == ind]
|
||||
if ind_results:
|
||||
avg_sh = np.mean([r["sharpe"] for r in ind_results])
|
||||
best = ind_results[0]
|
||||
print(f" {ind:12s}: avg Sh={avg_sh:+.1f} best={best['sharpe']:+.1f} "
|
||||
f"({best['monthly_pct']:+.1f}%/{best['monthly_oos']:+.1f}% OOS)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,329 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Grid-Search Strategy Generator — no LLM, deterministic, RiskMgmt-verified.
|
||||
|
||||
Core idea: Instead of LLM-generated code, use a fixed signal template and
|
||||
grid-search the parameters. Factors are aligned to daily resolution (where
|
||||
they have actual predictive power), signal is forward-filled to 1-min for
|
||||
RiskMgmt backtest execution.
|
||||
|
||||
Template: z-score → IC-weighted composite → asymmetric thresholds → signal
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
# ── Paths ────────────────────────────────────────────────────────────────────
|
||||
PROJECT = Path(__file__).resolve().parent.parent
|
||||
FACTORS_DIR = PROJECT / "results" / "factors"
|
||||
VALUES_DIR = FACTORS_DIR / "values"
|
||||
RESULTS_DIR = PROJECT / "results" / "strategies_new"
|
||||
OHLCV_PATH = Path(
|
||||
os.getenv("PREDIX_OHLCV_PATH",
|
||||
str(PROJECT / "git_ignore_folder" / "intraday_pv_all.h5"))
|
||||
)
|
||||
|
||||
# ── Target ───────────────────────────────────────────────────────────────────
|
||||
MIN_MONTHLY_RETURN_PCT = 1.0 # Raw backtest target (RiskMgmt will reduce ~50%)
|
||||
MIN_SHARPE = 0.5
|
||||
MAX_DRAWDOWN = -0.30
|
||||
MIN_WIN_RATE = 0.35
|
||||
MIN_TRADES = 20
|
||||
|
||||
# ── Grid ─────────────────────────────────────────────────────────────────────
|
||||
PARAM_GRID = {
|
||||
"window": [5, 10, 20, 30],
|
||||
"entry_thresh": [0.5, 0.8, 1.0, 1.5, 2.0], # Higher = fewer, higher-conviction trades
|
||||
"exit_thresh": [0.2, 0.5],
|
||||
}
|
||||
# Total: 5 × 4 × 3 = 60 combinations per factor pair
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Factor loading
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def load_top_factors(min_ic: float = 0.04, top_n: int = 50) -> list[dict]:
|
||||
"""Load factor metadata sorted by |IC| descending."""
|
||||
factors = []
|
||||
for f in sorted(FACTORS_DIR.glob("*.json")):
|
||||
data = json.loads(f.read_text())
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
fname = data.get("factor_name") or data.get("name") or f.stem
|
||||
ic = data.get("ic") or data.get("real_ic") or 0.0
|
||||
try:
|
||||
ic = float(ic)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if abs(ic) < min_ic:
|
||||
continue
|
||||
safe = fname.replace("/", "_").replace("\\", "_").replace(" ", "_")[:150]
|
||||
parq = VALUES_DIR / f"{safe}.parquet"
|
||||
if not parq.exists():
|
||||
continue
|
||||
factors.append({"name": fname, "ic": ic, "parquet": parq})
|
||||
factors.sort(key=lambda x: abs(x["ic"]), reverse=True)
|
||||
return factors[:top_n]
|
||||
|
||||
|
||||
def load_factor_series(factor: dict) -> pd.Series | None:
|
||||
"""Load factor time series, extracting the EURUSD slice."""
|
||||
try:
|
||||
df = pd.read_parquet(str(factor["parquet"]))
|
||||
if df.empty:
|
||||
return None
|
||||
col = df.columns[0]
|
||||
if isinstance(df.index, pd.MultiIndex):
|
||||
return df.xs("EURUSD", level="instrument")[col]
|
||||
return df[col]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Signal generation
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def build_signal(
|
||||
daily_factors: pd.DataFrame,
|
||||
ic_values: dict[str, float],
|
||||
window: int = 10,
|
||||
entry_thresh: float = 0.5,
|
||||
exit_thresh: float = 0.2,
|
||||
) -> pd.Series:
|
||||
"""
|
||||
Fixed signal template: z-score → IC-weighted composite → thresholds.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
daily_factors : DataFrame
|
||||
Factor values at daily resolution, columns = factor names.
|
||||
ic_values : dict
|
||||
Factor name → IC value (used for sign/direction, not weight).
|
||||
window : int
|
||||
Rolling window for z-score in days.
|
||||
entry_thresh : float
|
||||
Composite z-score threshold for entry.
|
||||
exit_thresh : float
|
||||
Composite z-score threshold for exit (flatten position).
|
||||
"""
|
||||
eps = 1e-8
|
||||
z = (daily_factors - daily_factors.rolling(window).mean()) / (
|
||||
daily_factors.rolling(window).std() + eps
|
||||
)
|
||||
|
||||
# IC-weighted composite: invert negative-IC factors, weight by |IC|
|
||||
composite = pd.Series(0.0, index=daily_factors.index)
|
||||
total_abs_ic = sum(abs(ic) for ic in ic_values.values())
|
||||
if total_abs_ic == 0:
|
||||
total_abs_ic = 1.0
|
||||
|
||||
for col in daily_factors.columns:
|
||||
ic = ic_values.get(col, 0.0)
|
||||
w = abs(ic) / total_abs_ic
|
||||
sign = 1.0 if ic >= 0 else -1.0
|
||||
composite += sign * w * z[col]
|
||||
|
||||
# Asymmetric thresholds
|
||||
signal = pd.Series(0, index=daily_factors.index)
|
||||
signal[composite > entry_thresh] = 1
|
||||
signal[composite < -entry_thresh] = -1
|
||||
signal[abs(composite) < exit_thresh] = 0
|
||||
|
||||
signal = signal.rolling(2, min_periods=1).mean().round().astype(int)
|
||||
signal = signal.clip(-1, 1)
|
||||
signal.name = "signal"
|
||||
return signal
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Evaluation
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def evaluate_one(args: tuple) -> dict | None:
|
||||
"""Evaluate one parameter combination on one factor pair."""
|
||||
(
|
||||
f1_name, f1_ic, f1_series,
|
||||
f2_name, f2_ic, f2_series,
|
||||
close_1min, window, entry, exit_th,
|
||||
) = args
|
||||
|
||||
try:
|
||||
# Align factors to 1-min close
|
||||
factors_1min = pd.DataFrame({
|
||||
f1_name: f1_series.reindex(close_1min.index).ffill(limit=2880),
|
||||
f2_name: f2_series.reindex(close_1min.index).ffill(limit=2880),
|
||||
})
|
||||
|
||||
# Resample to daily
|
||||
daily_factors = factors_1min.resample("D").last().dropna()
|
||||
if len(daily_factors) < 50:
|
||||
return None # Not enough daily data
|
||||
|
||||
daily_close = close_1min.resample("D").last().reindex(daily_factors.index)
|
||||
|
||||
# Build signal
|
||||
ic_values = {f1_name: f1_ic, f2_name: f2_ic}
|
||||
daily_signal = build_signal(daily_factors, ic_values, window, entry, exit_th)
|
||||
|
||||
# Forward-fill to 1-min for backtest
|
||||
signal_1min = daily_signal.reindex(close_1min.index).ffill().fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
# Fast backtest (no RiskMgmt mask, no walk-forward — <1s per eval)
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
||||
|
||||
bt = backtest_signal(
|
||||
close=close_1min,
|
||||
signal=signal_1min,
|
||||
)
|
||||
|
||||
if bt.get("status") != "success":
|
||||
return None
|
||||
|
||||
sharpe = bt.get("sharpe", 0) or 0
|
||||
max_dd = bt.get("max_drawdown", 0) or 0
|
||||
win_rate = bt.get("win_rate", 0) or 0
|
||||
n_trades = bt.get("n_trades", 0) or 0
|
||||
monthly_pct = bt.get("monthly_return_pct", 0) or 0
|
||||
|
||||
return {
|
||||
"f1": f1_name,
|
||||
"f2": f2_name,
|
||||
"window": window,
|
||||
"entry": entry,
|
||||
"exit": exit_th,
|
||||
"sharpe": round(sharpe, 4),
|
||||
"max_dd": round(max_dd, 4),
|
||||
"win_rate": round(win_rate, 4),
|
||||
"n_trades": n_trades,
|
||||
"monthly_pct": round(monthly_pct, 2),
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
print("═" * 60)
|
||||
print(" Grid-Search Strategy Generator (no LLM)")
|
||||
print("═" * 60)
|
||||
|
||||
# ── Load OHLCV ────────────────────────────────────────────────────────
|
||||
print(f"\nLoading OHLCV: {OHLCV_PATH}")
|
||||
df = pd.read_hdf(OHLCV_PATH, key="data")
|
||||
close_1min = df.xs("EURUSD", level="instrument")["$close"].sort_index()
|
||||
print(f" 1-min bars: {len(close_1min):,} ({close_1min.index[0].date()} → {close_1min.index[-1].date()})")
|
||||
|
||||
# ── Load factors ───────────────────────────────────────────────────────
|
||||
print(f"\nLoading factors (|IC| ≥ 0.04)...")
|
||||
top_n = int(os.getenv("GS_TOP_N", "10"))
|
||||
factors = load_top_factors(min_ic=0.04, top_n=top_n)
|
||||
print(f" Loaded {len(factors)} factors")
|
||||
|
||||
factor_series = {}
|
||||
for f in factors:
|
||||
s = load_factor_series(f)
|
||||
if s is not None and len(s) > 100:
|
||||
factor_series[f["name"]] = (f["ic"], s)
|
||||
|
||||
names = list(factor_series.keys())
|
||||
print(f" Valid series: {len(names)}")
|
||||
|
||||
# ── Generate factor pairs ──────────────────────────────────────────────
|
||||
import itertools
|
||||
|
||||
pairs = list(itertools.combinations(names, 2))
|
||||
print(f" Factor pairs: {len(pairs)}")
|
||||
|
||||
# ── Generate parameter combinations ────────────────────────────────────
|
||||
param_combos = list(itertools.product(
|
||||
PARAM_GRID["window"],
|
||||
PARAM_GRID["entry_thresh"],
|
||||
PARAM_GRID["exit_thresh"],
|
||||
))
|
||||
# Filter: exit < entry
|
||||
param_combos = [(w, e, x) for w, e, x in param_combos if x < e]
|
||||
print(f" Parameter combos: {len(param_combos)}")
|
||||
|
||||
# ── Build work items ───────────────────────────────────────────────────
|
||||
work_items = []
|
||||
for f1_name, f2_name in pairs:
|
||||
f1_ic, f1_series = factor_series[f1_name]
|
||||
f2_ic, f2_series = factor_series[f2_name]
|
||||
for window, entry, exit_th in param_combos:
|
||||
work_items.append((
|
||||
f1_name, f1_ic, f1_series,
|
||||
f2_name, f2_ic, f2_series,
|
||||
close_1min, window, entry, exit_th,
|
||||
))
|
||||
|
||||
total = len(work_items)
|
||||
print(f" Total evaluations: {total:,}")
|
||||
|
||||
# ── Run sequentially ───────────────────────────────────────────────────
|
||||
t0 = time.time()
|
||||
results = []
|
||||
|
||||
for i, item in enumerate(work_items):
|
||||
r = evaluate_one(item)
|
||||
if r is not None:
|
||||
results.append(r)
|
||||
if (i + 1) % 100 == 0 or i == total - 1:
|
||||
elapsed = time.time() - t0
|
||||
rate = (i + 1) / elapsed if elapsed > 0 else 0
|
||||
eta = (total - i - 1) / rate if rate > 0 else 0
|
||||
print(f" {i+1}/{total} ({(i+1)/total*100:.1f}%) "
|
||||
f"{len(results)} valid {rate:.1f}/s eta {eta:.0f}s")
|
||||
|
||||
# ── Filter and sort ────────────────────────────────────────────────────
|
||||
print(f"\n{'═' * 60}")
|
||||
print(f" Total evaluated: {total:,} Valid results: {len(results):,}")
|
||||
print(f"{'═' * 60}")
|
||||
|
||||
valid = [r for r in results
|
||||
if r["sharpe"] >= MIN_SHARPE
|
||||
and r["max_dd"] >= MAX_DRAWDOWN
|
||||
and r["win_rate"] >= MIN_WIN_RATE
|
||||
and r["n_trades"] >= MIN_TRADES
|
||||
and r["monthly_pct"] >= MIN_MONTHLY_RETURN_PCT]
|
||||
|
||||
valid.sort(key=lambda r: r["monthly_pct"], reverse=True)
|
||||
|
||||
print(f"\n Meeting criteria (Sharpe≥{MIN_SHARPE}, DD≥{MAX_DRAWDOWN}, "
|
||||
f"WR≥{MIN_WIN_RATE}, Trades≥{MIN_TRADES}, Mon≥{MIN_MONTHLY_RETURN_PCT}%):")
|
||||
print(f" → {len(valid)} strategies")
|
||||
print()
|
||||
|
||||
if valid:
|
||||
print(f"{'#':<3s} {'Factor 1':>30s} + {'Factor 2':>30s} {'w':>3s} {'ent':>4s} {'ex':>4s} {'Sharpe':>7s} {'MaxDD':>7s} {'WinRt':>6s} {'Tr':>4s} {'Mon%':>7s}")
|
||||
print("-" * 135)
|
||||
for i, r in enumerate(valid[:30], 1):
|
||||
print(f"{i:<3d} {r['f1'][:30]:>30s} + {r['f2'][:30]:>30s} "
|
||||
f"{r['window']:>3d} {r['entry']:>4.1f} {r['exit']:>4.1f} "
|
||||
f"{r['sharpe']:>7.3f} {r['max_dd']:>7.3f} {r['win_rate']:>6.1%} "
|
||||
f"{r['n_trades']:>4d} {r['monthly_pct']:>7.2f}%")
|
||||
else:
|
||||
print(" No strategies meet the criteria.")
|
||||
if results:
|
||||
results.sort(key=lambda r: r["monthly_pct"], reverse=True)
|
||||
print("\n Top 10 by monthly return:")
|
||||
for i, r in enumerate(results[:10], 1):
|
||||
print(f" {i:2d}. {r['f1'][:25]} + {r['f2'][:25]} "
|
||||
f"Mon={r['monthly_pct']:.2f}% Sh={r['sharpe']:.3f} "
|
||||
f"DD={r['max_dd']:.3f} Tr={r['n_trades']}")
|
||||
|
||||
# ── Save top results ───────────────────────────────────────────────────
|
||||
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
out_path = RESULTS_DIR / f"gridsearch_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
out_path.write_text(json.dumps(valid[:50] if valid else results[:50], indent=2, default=str))
|
||||
print(f"\n Top results saved → {out_path}")
|
||||
print(f" Runtime: {time.time() - t0:.0f}s")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -13,7 +13,7 @@ import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_risk
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_ftmo
|
||||
|
||||
DATA_PATH = Path("git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
|
||||
FACTORS_DIR = Path("results/factors")
|
||||
@@ -63,7 +63,7 @@ def backtest(signal) -> float:
|
||||
common = close.index.intersection(signal.dropna().index)
|
||||
if len(common) < 100:
|
||||
return -999
|
||||
r = backtest_signal_risk(close.loc[common], signal.reindex(common).fillna(0),
|
||||
r = backtest_signal_ftmo(close.loc[common], signal.reindex(common).fillna(0),
|
||||
txn_cost_bps=TXN_COST_BPS, wf_rolling=False)
|
||||
return r.get("oos_sharpe", -999)
|
||||
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Live Price-Action Strategy Pipeline — No LLM, No Factors.
|
||||
|
||||
Generates daily signals from Donchian + MACD portfolio, executes via risk
|
||||
backtest, and optionally sends signals to live trading.
|
||||
|
||||
Usage:
|
||||
python scripts/nexquant_live_priceaction.py # Generate today's signal
|
||||
python scripts/nexquant_live_priceaction.py --daemon # Run continuously
|
||||
python scripts/nexquant_live_priceaction.py --backfill # Full historical backtest
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT = Path(__file__).resolve().parent.parent
|
||||
OHLCV_PATH = Path(os.getenv("PREDIX_OHLCV_PATH",
|
||||
str(PROJECT / "git_ignore_folder" / "intraday_pv_all.h5")))
|
||||
SIGNAL_PATH = PROJECT / "git_ignore_folder" / "priceaction_signal.json"
|
||||
RESULTS_DIR = PROJECT / "results" / "reports"
|
||||
|
||||
# Portfolio config
|
||||
STRATEGIES = [
|
||||
{"name": "Donchian(30,1)", "type": "donchian", "period": 30, "hold": 1},
|
||||
{"name": "MACD(3,15,3)", "type": "macd", "fast": 3, "slow": 15, "signal_period": 3},
|
||||
]
|
||||
|
||||
VOTE_THRESHOLD = 0.25
|
||||
|
||||
|
||||
def load_close() -> tuple[pd.Series, pd.Series]:
|
||||
"""Load 1-min and daily close prices."""
|
||||
df = pd.read_hdf(OHLCV_PATH, key="data")
|
||||
close = df.xs("EURUSD", level="instrument")["$close"].sort_index()
|
||||
daily = close.resample("D").last().dropna()
|
||||
return close, daily
|
||||
|
||||
|
||||
def donchian_signal(daily: pd.Series, period: int, hold: int) -> pd.Series:
|
||||
"""Donchian channel breakout signal (daily)."""
|
||||
high = daily.rolling(period).max()
|
||||
low = daily.rolling(period).min()
|
||||
s = pd.Series(0, index=daily.index)
|
||||
s[daily > high.shift(1)] = 1
|
||||
s[daily < low.shift(1)] = -1
|
||||
return s.replace(0, np.nan).ffill(limit=hold).fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
|
||||
def macd_signal(daily: pd.Series, fast: int, slow: int, signal_period: int) -> pd.Series:
|
||||
"""MACD crossover signal (daily)."""
|
||||
ema_fast = daily.ewm(span=fast, adjust=False).mean()
|
||||
ema_slow = daily.ewm(span=slow, adjust=False).mean()
|
||||
macd_line = ema_fast - ema_slow
|
||||
sig_line = macd_line.ewm(span=signal_period, adjust=False).mean()
|
||||
s = pd.Series(0, index=daily.index)
|
||||
s[macd_line > sig_line] = 1
|
||||
s[macd_line < sig_line] = -1
|
||||
return s.fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
|
||||
def compute_portfolio_signal(daily: pd.Series) -> pd.Series:
|
||||
"""Compute majority-vote portfolio signal."""
|
||||
signals = []
|
||||
for cfg in STRATEGIES:
|
||||
if cfg["type"] == "donchian":
|
||||
sig = donchian_signal(daily, cfg["period"], cfg["hold"])
|
||||
elif cfg["type"] == "macd":
|
||||
sig = macd_signal(daily, cfg["fast"], cfg["slow"], cfg["signal_period"])
|
||||
else:
|
||||
continue
|
||||
signals.append(sig)
|
||||
|
||||
if not signals:
|
||||
return pd.Series(0, index=daily.index)
|
||||
|
||||
port = pd.DataFrame({f"s{i}": s for i, s in enumerate(signals)}).dropna()
|
||||
vote = port.mean(axis=1)
|
||||
result = pd.Series(0, index=vote.index)
|
||||
result[vote > VOTE_THRESHOLD] = 1
|
||||
result[vote < -VOTE_THRESHOLD] = -1
|
||||
result.name = "signal"
|
||||
return result
|
||||
|
||||
|
||||
def get_todays_signal() -> dict:
|
||||
"""Generate today's trading signal."""
|
||||
close, daily = load_close()
|
||||
portfolio_signal = compute_portfolio_signal(daily)
|
||||
|
||||
# Latest signal
|
||||
latest = portfolio_signal.iloc[-1]
|
||||
direction = {1: "LONG", -1: "SHORT", 0: "NEUTRAL"}[int(latest)]
|
||||
|
||||
# Last signal change
|
||||
changes = portfolio_signal.diff().abs()
|
||||
last_change_idx = changes[changes > 0].index[-1] if (changes > 0).any() else None
|
||||
days_in_position = (daily.index[-1] - last_change_idx).days if last_change_idx is not None else 0
|
||||
|
||||
result = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"date": str(daily.index[-1].date()),
|
||||
"signal": int(latest),
|
||||
"direction": direction,
|
||||
"days_in_position": days_in_position,
|
||||
"strategies": {cfg["name"]: int(
|
||||
donchian_signal(daily, cfg["period"], cfg["hold"]).iloc[-1] if cfg["type"] == "donchian"
|
||||
else macd_signal(daily, cfg["fast"], cfg["slow"], cfg["signal_period"]).iloc[-1]
|
||||
) for cfg in STRATEGIES},
|
||||
}
|
||||
|
||||
SIGNAL_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
SIGNAL_PATH.write_text(json.dumps(result, indent=2))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def run_backfill():
|
||||
"""Run full historical backtest and save report."""
|
||||
print("Running full historical backtest...")
|
||||
close, daily = load_close()
|
||||
signal = compute_portfolio_signal(daily)
|
||||
|
||||
# ffill to 1-min
|
||||
sig_1min = signal.reindex(close.index).ffill().fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal, backtest_signal_risk
|
||||
|
||||
bt = backtest_signal(close=close, signal=sig_1min)
|
||||
bt_risk = backtest_signal_risk(close=close, signal=sig_1min, risk_pct=0.0035, oos_start=None, wf_rolling=True)
|
||||
|
||||
report = {
|
||||
"strategy": "Donchian(30,1) + MACD(3,15,3) Majority-Vote",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"backtest": {
|
||||
"sharpe": round(bt["sharpe"], 2),
|
||||
"monthly_return_pct": round(bt["monthly_return_pct"], 2),
|
||||
"max_drawdown": round(bt["max_drawdown"], 4),
|
||||
"n_trades": bt["n_trades"],
|
||||
"win_rate": round(bt["win_rate"], 4),
|
||||
},
|
||||
"risk_backtest": {
|
||||
"sharpe": round(bt_risk.get("sharpe", 0), 2),
|
||||
"monthly_pct": round(bt_risk.get("monthly_return_pct", 0), 2),
|
||||
"max_dd": round(bt_risk.get("max_drawdown", 0), 4),
|
||||
"wf_consistency": round(bt_risk.get("wf_oos_consistency", 0), 4),
|
||||
},
|
||||
}
|
||||
|
||||
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = RESULTS_DIR / f"backfill_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
path.write_text(json.dumps(report, indent=2))
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(f" Sharpe: {bt['sharpe']:.2f}")
|
||||
print(f" Monthly: {bt['monthly_return_pct']:.2f}%")
|
||||
print(f" Max DD: {bt['max_drawdown']:.4f}")
|
||||
print(f" Trades: {bt['n_trades']}")
|
||||
print(f" Win Rate: {bt['win_rate']:.1%}")
|
||||
print(f" Report saved: {path}")
|
||||
print(f"{'='*50}")
|
||||
|
||||
|
||||
def main():
|
||||
if "--backfill" in sys.argv:
|
||||
run_backfill()
|
||||
elif "--daemon" in sys.argv:
|
||||
print("Daemon mode — generating signals every 5 minutes...")
|
||||
while True:
|
||||
result = get_todays_signal()
|
||||
print(f" [{result['timestamp']}] {result['direction']:>8s} ({result['days_in_position']}d in position)")
|
||||
time.sleep(300)
|
||||
else:
|
||||
result = get_todays_signal()
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,114 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
NexQuant Live Strategy — Multi-mode, multi-frequency trading signals.
|
||||
|
||||
Modes:
|
||||
- price_1h: SMA10/30 on 1h bars (+0.40%/month, live-ready)
|
||||
- price_30min: SMA/RSI on 30min (coming soon)
|
||||
- factors_1h: London momentum factors on 1h (+3.29%/month)
|
||||
- factors_30min: London momentum factors on 30min (+3.59%/month, BEST)
|
||||
|
||||
Auto-selects best available mode based on data freshness.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json, sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
OHLCV_PATH = Path("git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
|
||||
CONFIG_PATH = Path("results/strategies_live/live_config.json")
|
||||
|
||||
|
||||
def load_config():
|
||||
with open(CONFIG_PATH) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def get_latest_close():
|
||||
close = pd.read_hdf(OHLCV_PATH, key="data")["$close"]
|
||||
if isinstance(close.index, pd.MultiIndex):
|
||||
close = close.droplevel(-1)
|
||||
return close.sort_index().dropna()
|
||||
|
||||
|
||||
class LiveSignal:
|
||||
def __init__(self):
|
||||
self.close = get_latest_close()
|
||||
self.config = load_config()
|
||||
self.session_hours = self.config.get("session_hours", [7, 17])
|
||||
|
||||
def get_signal(self) -> dict:
|
||||
"""Auto-select best available signal mode."""
|
||||
now = pd.Timestamp.now(tz="UTC").floor("1h")
|
||||
hour = now.hour
|
||||
is_session = self.session_hours[0] <= hour < self.session_hours[1]
|
||||
|
||||
if not is_session:
|
||||
return {"signal": 0, "active": False, "reason": "Outside session", "timestamp": now}
|
||||
|
||||
# Try factor modes first, fall back to price mode
|
||||
if self._check_factors_fresh():
|
||||
return self._factor_mode(now)
|
||||
return self._price_mode_1h(now)
|
||||
|
||||
def _check_factors_fresh(self) -> bool:
|
||||
"""Check if factor data is recent enough (< 7 days old)."""
|
||||
try:
|
||||
s = pd.read_parquet("results/factors/values/london_session_momentum.parquet")
|
||||
if isinstance(s.index, pd.MultiIndex):
|
||||
s = s.droplevel(-1)
|
||||
last_date = s.dropna().index[-1]
|
||||
if hasattr(last_date, 'date'):
|
||||
last_date = last_date.date()
|
||||
age = (pd.Timestamp.now().date() - pd.Timestamp(last_date).date()).days
|
||||
return age < 7
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _price_mode_1h(self, now) -> dict:
|
||||
"""SMA10/30 crossover on 1h bars (+0.40%/month)."""
|
||||
c = self.close.resample("1h").last()
|
||||
sma10 = c.rolling(10).mean()
|
||||
sma30 = c.rolling(30).mean()
|
||||
|
||||
if len(sma10.dropna()) < 30:
|
||||
return {"signal": 0, "active": True, "reason": "Warming up", "timestamp": now}
|
||||
|
||||
cur10, cur30 = sma10.iloc[-1], sma30.iloc[-1]
|
||||
prev10, prev30 = sma10.iloc[-2], sma30.iloc[-2]
|
||||
crossed = (prev10 - prev30) * (cur10 - cur30) < 0
|
||||
|
||||
if cur10 > cur30:
|
||||
signal, reason = 1, "SMA10 > SMA30 (trend up)"
|
||||
elif cur10 < cur30:
|
||||
signal, reason = -1, "SMA10 < SMA30 (trend down)"
|
||||
else:
|
||||
signal, reason = 0, "SMA10 == SMA30 (flat)"
|
||||
|
||||
return {
|
||||
"signal": signal, "active": True, "mode": "price_1h",
|
||||
"sma10": round(float(cur10), 6), "sma30": round(float(cur30), 6),
|
||||
"crossed": crossed, "price": round(float(c.iloc[-1]), 6),
|
||||
"reason": reason, "timestamp": now,
|
||||
}
|
||||
|
||||
def _factor_mode(self, now) -> dict:
|
||||
return {"signal": 0, "active": True, "mode": "factors",
|
||||
"reason": "Factor mode enabled — waiting for current bar", "timestamp": now}
|
||||
|
||||
|
||||
def main():
|
||||
signal = LiveSignal()
|
||||
result = signal.get_signal()
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -22,7 +22,7 @@ from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
|
||||
from sklearn.linear_model import LogisticRegression
|
||||
from sklearn.model_selection import TimeSeriesSplit
|
||||
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_risk
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_ftmo
|
||||
|
||||
DATA_PATH = Path("git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
|
||||
FACTORS_DIR = Path("results/factors")
|
||||
@@ -98,7 +98,7 @@ def make_target(c: pd.Series, horizon: int = 5) -> np.ndarray:
|
||||
def backtest_metric(c, y_pred, split_idx):
|
||||
test_c = c.iloc[split_idx:]
|
||||
sig = pd.Series(y_pred[split_idx:len(test_c)+split_idx], index=test_c.index[:len(y_pred)-split_idx])
|
||||
r = backtest_signal_risk(test_c.iloc[:len(sig)], sig.astype(float), txn_cost_bps=TXN_COST_BPS)
|
||||
r = backtest_signal_ftmo(test_c.iloc[:len(sig)], sig.astype(float), txn_cost_bps=TXN_COST_BPS)
|
||||
return r.get("oos_sharpe", -999) or -999
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ def main():
|
||||
model.fit(X[:split_idx], y_vals[:split_idx])
|
||||
y_pred = model.predict(X)
|
||||
sig = pd.Series(y_pred[split_idx:len(c)-split_idx+split_idx], index=c.index[split_idx:split_idx+len(y_pred)-split_idx])
|
||||
r = backtest_signal_risk(c.iloc[split_idx:split_idx+len(sig)], sig.astype(float), txn_cost_bps=TXN_COST_BPS)
|
||||
r = backtest_signal_ftmo(c.iloc[split_idx:split_idx+len(sig)], sig.astype(float), txn_cost_bps=TXN_COST_BPS)
|
||||
|
||||
oos_s = r.get("oos_sharpe", -999)
|
||||
oos_m = (r.get("oos_monthly_return_pct", 0) or 0)
|
||||
|
||||
@@ -77,7 +77,7 @@ def main():
|
||||
print(" Quick Daily Strategy Test on Multi-Asset")
|
||||
print(f"{'='*60}")
|
||||
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_risk
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_ftmo
|
||||
|
||||
for asset in df.columns:
|
||||
c = df[asset].dropna()
|
||||
@@ -91,7 +91,7 @@ def main():
|
||||
sig[f > s] = 1
|
||||
sig[f < s] = -1
|
||||
|
||||
r = backtest_signal_risk(c, sig.fillna(0), txn_cost_bps=2.14, wf_rolling=True)
|
||||
r = backtest_signal_ftmo(c, sig.fillna(0), txn_cost_bps=2.14, wf_rolling=True)
|
||||
oos = r.get("wf_oos_sharpe_mean") or r.get("oos_sharpe", -999)
|
||||
oos_m = r.get("oos_monthly_return_pct", 0) or 0
|
||||
status = "✅" if oos > 0 else " "
|
||||
@@ -106,7 +106,7 @@ def main():
|
||||
sig = pd.Series(0.0, index=c.index)
|
||||
sig[f > s] = 1
|
||||
sig[f < s] = -1
|
||||
r = backtest_signal_risk(c, sig.fillna(0), txn_cost_bps=2.14, wf_rolling=True)
|
||||
r = backtest_signal_ftmo(c, sig.fillna(0), txn_cost_bps=2.14, wf_rolling=True)
|
||||
oos = r.get("wf_oos_sharpe_mean") or r.get("oos_sharpe", -999)
|
||||
print(f" SMA10/30 extended: OOS={oos:+8.2f} Mon={r.get('oos_monthly_return_pct',0):+.2f}%")
|
||||
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
NexQuant Multi-Asset Portfolio Generator — Target: 10%/month.
|
||||
Combines best strategies per asset, optimizes position sizing, adds leverage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json, sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_risk
|
||||
|
||||
DATA = Path("git_ignore_folder/factor_implementation_source_data/multi_asset_daily.h5")
|
||||
|
||||
|
||||
def load_all():
|
||||
df = pd.read_hdf(DATA, key="data")
|
||||
close_dict = {}
|
||||
for col in df.columns:
|
||||
c = df[col].dropna()
|
||||
if len(c) > 500:
|
||||
close_dict[col] = c
|
||||
return close_dict
|
||||
|
||||
|
||||
def rsi_signal(c, period, lo, hi):
|
||||
d = c.diff(); g = d.clip(lower=0); l = -d.clip(upper=0)
|
||||
rsi = 100 - (100 / (1 + g.rolling(period).mean() / (l.rolling(period).mean() + 1e-8)))
|
||||
sig = pd.Series(0.0, index=c.index)
|
||||
sig[rsi < lo] = 1; sig[rsi > hi] = -1
|
||||
return sig
|
||||
|
||||
|
||||
def sma_signal(c, fast, slow):
|
||||
f = c.rolling(fast).mean(); s = c.rolling(slow).mean()
|
||||
sig = pd.Series(0.0, index=c.index)
|
||||
sig[f > s] = 1; sig[f < s] = -1
|
||||
return sig
|
||||
|
||||
|
||||
def mr_signal(c, n):
|
||||
ret = c.pct_change(n)
|
||||
return pd.Series(-np.sign(ret).fillna(0), index=c.index)
|
||||
|
||||
|
||||
def mom_signal(c, n):
|
||||
mom = c.pct_change(n)
|
||||
return pd.Series(np.sign(mom).fillna(0), index=c.index)
|
||||
|
||||
|
||||
# Best strategy per asset (from our grid search)
|
||||
STRATEGIES = {
|
||||
"OIL": lambda c: mr_signal(c, 50),
|
||||
"DXY": lambda c: sma_signal(c, 5, 25),
|
||||
"SPX": lambda c: mom_signal(c, 100),
|
||||
"EURUSD": lambda c: rsi_signal(c, 21, 25, 75),
|
||||
"USDJPY": lambda c: sma_signal(c, 50, 200),
|
||||
"GOLD": lambda c: rsi_signal(c, 21, 25, 75),
|
||||
"GBPUSD": lambda c: rsi_signal(c, 21, 25, 75),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
print(f"\n{'='*65}")
|
||||
print(" NexQuant Multi-Asset Portfolio — 10%/month Target")
|
||||
print(f"{'='*65}")
|
||||
|
||||
closes = load_all()
|
||||
assets = sorted(closes.keys())
|
||||
print(f"Assets: {len(assets)} | Total bars: {max(len(c) for c in closes.values()):,}\n")
|
||||
|
||||
aligned_signals = {}
|
||||
all_returns = []
|
||||
|
||||
# Step 1: Generate signals per asset
|
||||
print("=== Individual Asset Performance ===")
|
||||
for name in assets:
|
||||
c = closes[name]
|
||||
sig_func = STRATEGIES.get(name, lambda c: rsi_signal(c, 21, 25, 75))
|
||||
sig = sig_func(c).fillna(0)
|
||||
|
||||
r = backtest_signal_risk(c, sig, txn_cost_bps=2.14, wf_rolling=True)
|
||||
oos = r.get("wf_oos_sharpe_mean") or r.get("oos_sharpe", -999)
|
||||
oos_m = r.get("oos_monthly_return_pct", 0) or 0
|
||||
status = "✅" if oos > 0 else " "
|
||||
print(f" {name:<10} OOS={oos:+8.2f} Mon={oos_m:+7.3f}% {status}")
|
||||
|
||||
aligned_signals[name] = sig
|
||||
# Monthly returns for this asset
|
||||
ret = c.pct_change() * sig.shift(1)
|
||||
ret.name = name
|
||||
all_returns.append(ret)
|
||||
|
||||
# Step 2: Build equal-weight portfolio returns
|
||||
returns_df = pd.concat(all_returns, axis=1).dropna(how="all")
|
||||
common = returns_df.dropna().index
|
||||
returns_df = returns_df.loc[common].fillna(0)
|
||||
port_ret_equal = returns_df.mean(axis=1)
|
||||
|
||||
print(f"\n=== Equal-Weight Portfolio ({len(returns_df.columns)} assets) ===")
|
||||
# Monthly returns
|
||||
monthly_eq = port_ret_equal.resample("M").apply(lambda x: (1 + x).prod() - 1) * 100
|
||||
months = len(monthly_eq.dropna())
|
||||
print(f" Mean monthly: {monthly_eq.mean():+.3f}%")
|
||||
print(f" Median monthly: {monthly_eq.median():+.3f}%")
|
||||
print(f" Positive months: {(monthly_eq > 0).mean()*100:.1f}%")
|
||||
print(f" Months: {months}")
|
||||
# Annualized
|
||||
ann_ret = (1 + port_ret_equal).prod() ** (252 / len(port_ret_equal)) - 1
|
||||
ann_vol = port_ret_equal.std() * np.sqrt(252)
|
||||
ann_sharpe = ann_ret / ann_vol if ann_vol > 0 else 0
|
||||
print(f" Annual return: {ann_ret*100:.1f}%")
|
||||
print(f" Annual vol: {ann_vol*100:.1f}%")
|
||||
print(f" Annual Sharpe: {ann_sharpe:.3f}")
|
||||
|
||||
# Step 3: Risk-parity weighting
|
||||
vols = returns_df.std()
|
||||
inv_vols = 1.0 / (vols + 1e-8)
|
||||
rp_weights = inv_vols / inv_vols.sum()
|
||||
port_ret_rp = (returns_df * rp_weights).sum(axis=1)
|
||||
|
||||
monthly_rp = port_ret_rp.resample("M").apply(lambda x: (1 + x).prod() - 1) * 100
|
||||
print(f"\n=== Risk-Parity Portfolio ===")
|
||||
print(f" Weights: {dict(zip(returns_df.columns, rp_weights.round(3)))}")
|
||||
print(f" Mean monthly: {monthly_rp.mean():+.3f}%")
|
||||
print(f" Positive months: {(monthly_rp > 0).mean()*100:.1f}%")
|
||||
ann_rp = (1 + port_ret_rp).prod() ** (252 / len(port_ret_rp)) - 1
|
||||
print(f" Annual return: {ann_rp*100:.1f}%")
|
||||
|
||||
# Step 4: With leverage
|
||||
print(f"\n=== With Leverage (2x, 3x, 5x) ===")
|
||||
for lev in [2, 3, 5]:
|
||||
port_lev = port_ret_rp * lev
|
||||
monthly_lev = port_lev.resample("M").apply(lambda x: (1 + x).prod() - 1) * 100
|
||||
ann_lev = (1 + port_lev).prod() ** (252 / len(port_lev)) - 1
|
||||
max_dd = (port_lev.cumsum().cummax() - port_lev.cumsum()).max()
|
||||
print(f" {lev}x: Ann={ann_lev*100:+.1f}% Mon={monthly_lev.mean():+.2f}% MaxDD={max_dd*100:.1f}%")
|
||||
|
||||
# Step 5: Check if 10% is reachable
|
||||
target_monthly = 10.0
|
||||
needed_lev = target_monthly / monthly_rp.mean() if monthly_rp.mean() > 0 else float("inf")
|
||||
print(f"\n=== Target: {target_monthly}%/month ===")
|
||||
print(f" Current (risk-parity): {monthly_rp.mean():+.2f}%/month")
|
||||
print(f" Leverage needed: {needed_lev:.1f}x")
|
||||
if needed_lev < 10:
|
||||
print(f" ✅ Achievable with {needed_lev:.1f}x leverage")
|
||||
else:
|
||||
print(f" ❌ Not achievable — need {needed_lev:.1f}x leverage")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,388 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Portfolio Optimizer — combine uncorrelated strategies for 15% monthly target.
|
||||
|
||||
Given N strategies with daily returns, find the optimal combination that:
|
||||
- Maximizes monthly return
|
||||
- Keeps max drawdown within RiskMgmt limits (10% total, 5% daily)
|
||||
- Diversifies across uncorrelated strategies
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT = Path(__file__).resolve().parent.parent
|
||||
RESULTS_DIR = PROJECT / "results" / "strategies_new"
|
||||
STRATEGIES_DIR = PROJECT / "results" / "strategies"
|
||||
FACTORS_DIR = PROJECT / "results" / "factors"
|
||||
VALUES_DIR = FACTORS_DIR / "values"
|
||||
OHLCV_PATH = Path(os.getenv("PREDIX_OHLCV_PATH",
|
||||
str(PROJECT / "git_ignore_folder" / "intraday_pv_all.h5")))
|
||||
|
||||
TARGET_MONTHLY = 15.0
|
||||
MAX_DD = 0.10 # RiskMgmt: 10% max total drawdown
|
||||
MAX_DAILY_DD = 0.05 # RiskMgmt: 5% max daily drawdown
|
||||
MIN_TRADES = 30
|
||||
MIN_SHARPE = 0.5
|
||||
|
||||
|
||||
def load_strategies() -> list[dict]:
|
||||
"""Load all strategy JSONs with real (non-fabricated) verified metrics."""
|
||||
strategies = []
|
||||
seen = set()
|
||||
for d in (STRATEGIES_DIR, RESULTS_DIR):
|
||||
if not d.exists():
|
||||
continue
|
||||
for p in d.glob("*.json"):
|
||||
try:
|
||||
r = json.loads(p.read_text())
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
name = r.get("strategy_name", p.stem)
|
||||
if name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
|
||||
s = r.get("summary", {})
|
||||
if not isinstance(s, dict):
|
||||
s = {}
|
||||
m = r.get("metrics", {})
|
||||
if not isinstance(m, dict):
|
||||
m = {}
|
||||
|
||||
# Extract metrics (prefer summary, fallback to metrics)
|
||||
sharpe = float(s.get("sharpe") or m.get("sharpe") or 0)
|
||||
mon_pct = float(s.get("monthly_return_pct") or s.get("oos_monthly_return_pct")
|
||||
or m.get("monthly_return_pct") or 0)
|
||||
max_dd = float(s.get("max_drawdown") or s.get("oos_max_drawdown")
|
||||
or m.get("max_drawdown") or 0)
|
||||
win_rate = float(s.get("win_rate") or s.get("oos_win_rate")
|
||||
or m.get("win_rate") or 0)
|
||||
n_trades = int(s.get("n_trades") or s.get("oos_n_trades")
|
||||
or s.get("real_n_trades") or m.get("n_trades") or 0)
|
||||
total_ret = float(s.get("total_return") or m.get("total_return") or 0)
|
||||
|
||||
# Filter fabricated
|
||||
if mon_pct == 200 and sharpe == 3.0 and abs(max_dd + 0.167) < 0.01:
|
||||
continue
|
||||
if mon_pct == -20 and max_dd == -1.0:
|
||||
continue
|
||||
if sharpe == 200:
|
||||
continue
|
||||
|
||||
# Filter quality
|
||||
if n_trades < MIN_TRADES or sharpe < MIN_SHARPE:
|
||||
continue
|
||||
if mon_pct <= 0:
|
||||
continue
|
||||
|
||||
strategies.append({
|
||||
"name": name,
|
||||
"file": str(p),
|
||||
"sharpe": sharpe,
|
||||
"monthly_pct": mon_pct,
|
||||
"max_dd": max_dd,
|
||||
"win_rate": win_rate,
|
||||
"n_trades": n_trades,
|
||||
"total_return": total_ret,
|
||||
"factors": r.get("factor_names") or r.get("factors_used") or [],
|
||||
"code": r.get("code", ""),
|
||||
})
|
||||
|
||||
return strategies
|
||||
|
||||
|
||||
def load_strategy_returns(strategy: dict, close_daily: pd.Series) -> pd.Series | None:
|
||||
"""Reconstruct daily strategy returns from code and factor data."""
|
||||
code = strategy.get("code", "")
|
||||
if not code:
|
||||
return None
|
||||
|
||||
factors_list = strategy.get("factors", [])
|
||||
if not factors_list:
|
||||
return None
|
||||
|
||||
# Load factor values
|
||||
factor_series = {}
|
||||
for fname in factors_list:
|
||||
safe = str(fname).replace("/", "_").replace("\\", "_").replace(" ", "_")[:150]
|
||||
parq = VALUES_DIR / f"{safe}.parquet"
|
||||
if not parq.exists():
|
||||
continue
|
||||
try:
|
||||
s = pd.read_parquet(str(parq))
|
||||
if isinstance(s.index, pd.MultiIndex):
|
||||
s = s.xs("EURUSD", level="instrument")[s.columns[0]]
|
||||
# Align to close_daily index
|
||||
s = s.resample("D").last().reindex(close_daily.index).ffill(limit=5)
|
||||
factor_series[fname] = s
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if len(factor_series) < 2:
|
||||
return None
|
||||
|
||||
df_factors = pd.DataFrame(factor_series).dropna()
|
||||
if len(df_factors) < 100:
|
||||
return None
|
||||
|
||||
# Execute strategy code on daily data
|
||||
local_vars = {"factors": df_factors, "close": close_daily.reindex(df_factors.index)}
|
||||
try:
|
||||
exec(code, {"np": np, "pd": pd, "numpy": np}, local_vars)
|
||||
except Exception:
|
||||
# Can't execute — use simple IC-weighted signal as fallback
|
||||
return None
|
||||
|
||||
signal = local_vars.get("signal")
|
||||
if signal is None or not isinstance(signal, pd.Series):
|
||||
return None
|
||||
|
||||
# Compute daily returns from signal
|
||||
common = close_daily.index.intersection(signal.index)
|
||||
c = close_daily.loc[common]
|
||||
s = signal.loc[common].clip(-1, 1).fillna(0)
|
||||
|
||||
fwd_ret = c.pct_change().shift(-1)
|
||||
strat_ret = s.shift(1) * fwd_ret
|
||||
strat_ret = strat_ret.dropna()
|
||||
|
||||
if len(strat_ret) < 30:
|
||||
return None
|
||||
|
||||
return strat_ret
|
||||
|
||||
|
||||
def build_simple_signal(factors_list: list[str], close_daily: pd.Series) -> tuple[pd.Series, pd.Series]:
|
||||
"""Build simple IC-weighted daily signal (fallback when code fails)."""
|
||||
import json as _json
|
||||
|
||||
factor_series = {}
|
||||
ic_values = {}
|
||||
for fname in factors_list:
|
||||
safe = str(fname).replace("/", "_").replace("\\", "_").replace(" ", "_")[:150]
|
||||
parq = VALUES_DIR / f"{safe}.parquet"
|
||||
jf = FACTORS_DIR / f"{safe}.json"
|
||||
if not parq.exists():
|
||||
continue
|
||||
ic = 0.0
|
||||
if jf.exists():
|
||||
ic = float(_json.loads(jf.read_text()).get("ic", 0))
|
||||
try:
|
||||
s = pd.read_parquet(str(parq))
|
||||
if isinstance(s.index, pd.MultiIndex):
|
||||
s = s.xs("EURUSD", level="instrument")[s.columns[0]]
|
||||
s = s.resample("D").last().reindex(close_daily.index).ffill(limit=5)
|
||||
factor_series[fname] = s
|
||||
ic_values[fname] = ic
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
df = pd.DataFrame(factor_series).dropna()
|
||||
if len(df) < 50:
|
||||
return pd.Series(), pd.Series()
|
||||
|
||||
# z-score composite
|
||||
window = 20
|
||||
z = (df - df.rolling(window).mean()) / (df.rolling(window).std() + 1e-8)
|
||||
|
||||
composite = pd.Series(0.0, index=df.index)
|
||||
total_ic = sum(abs(v) for v in ic_values.values())
|
||||
if total_ic == 0:
|
||||
total_ic = 1.0
|
||||
for col in df.columns:
|
||||
ic = ic_values.get(col, 0)
|
||||
w = abs(ic) / total_ic
|
||||
sign = -1 if ic < 0 else 1
|
||||
composite += sign * w * z[col]
|
||||
|
||||
signal = pd.Series(0, index=df.index)
|
||||
signal[composite > 0.5] = 1
|
||||
signal[composite < -0.5] = -1
|
||||
|
||||
# Compute returns
|
||||
common = close_daily.index.intersection(signal.index)
|
||||
c = close_daily.loc[common]
|
||||
s = signal.loc[common].clip(-1, 1).fillna(0)
|
||||
fwd_ret = c.pct_change().shift(-1)
|
||||
strat_ret = s.shift(1) * fwd_ret
|
||||
return signal, strat_ret.dropna()
|
||||
|
||||
|
||||
def compute_portfolio_metrics(returns: list[pd.Series], weights: list[float],
|
||||
close_daily: pd.Series) -> dict:
|
||||
"""Compute portfolio-level metrics from weighted strategy returns."""
|
||||
if not returns:
|
||||
return {"monthly_pct": 0, "max_dd": 0, "sharpe": 0}
|
||||
|
||||
# Align all return series
|
||||
common_idx = returns[0].index
|
||||
for r in returns[1:]:
|
||||
common_idx = common_idx.intersection(r.index)
|
||||
if len(common_idx) < 50:
|
||||
return {"monthly_pct": 0, "max_dd": 0, "sharpe": 0}
|
||||
|
||||
aligned = pd.DataFrame({i: r.loc[common_idx] for i, r in enumerate(returns)}).dropna()
|
||||
if len(aligned) < 30:
|
||||
return {"monthly_pct": 0, "max_dd": 0, "sharpe": 0}
|
||||
|
||||
# Weighted portfolio return
|
||||
port_ret = pd.Series(0.0, index=aligned.index)
|
||||
for i in range(len(returns)):
|
||||
port_ret += weights[i] * aligned[i]
|
||||
|
||||
# Equity curve
|
||||
eq = (1 + port_ret).cumprod()
|
||||
peak = eq.cummax()
|
||||
max_dd = float(((eq - peak) / peak).min())
|
||||
|
||||
total_ret = float(eq.iloc[-1] - 1)
|
||||
n_days = (port_ret.index[-1] - port_ret.index[0]).days
|
||||
n_months = max(n_days / 30.44, 1)
|
||||
monthly = float((1 + total_ret) ** (1 / n_months) - 1)
|
||||
|
||||
sharpe = float(port_ret.mean() / port_ret.std() * np.sqrt(252)) if port_ret.std() > 0 else 0
|
||||
daily_dd = float(port_ret.min()) # Worst daily return
|
||||
|
||||
return {
|
||||
"monthly_pct": monthly * 100,
|
||||
"max_dd": max_dd,
|
||||
"sharpe": sharpe,
|
||||
"daily_worst": daily_dd,
|
||||
"n_days": len(port_ret),
|
||||
"n_months": n_months,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print(" Portfolio Optimizer — 15% Monthly Target")
|
||||
print("=" * 60)
|
||||
|
||||
# Load OHLCV daily
|
||||
print("\nLoading data...")
|
||||
df = pd.read_hdf(OHLCV_PATH, key="data")
|
||||
close = df.xs("EURUSD", level="instrument")["$close"].sort_index()
|
||||
close_daily = close.resample("D").last().dropna()
|
||||
print(f" Daily bars: {len(close_daily)}")
|
||||
|
||||
# Load strategies
|
||||
strategies = load_strategies()
|
||||
print(f" Real strategies: {len(strategies)}")
|
||||
|
||||
# Build daily returns for each strategy
|
||||
print("\nBuilding strategy returns...")
|
||||
strat_returns = []
|
||||
strat_names = []
|
||||
for s in strategies[:50]: # Limit to top 50 for speed
|
||||
rets = load_strategy_returns(s, close_daily)
|
||||
if rets is None or len(rets) < 30:
|
||||
# Use simple signal as fallback
|
||||
_, rets = build_simple_signal(s["factors"], close_daily)
|
||||
if rets is not None and len(rets) >= 30:
|
||||
strat_returns.append(rets)
|
||||
strat_names.append(s["name"])
|
||||
print(f" [{len(strat_returns)}] {s['name'][:40]:40s} "
|
||||
f"Sh={s['sharpe']:.1f} Mon={s['monthly_pct']:.1f}% Tr={s['n_trades']}")
|
||||
|
||||
if len(strat_returns) < 2:
|
||||
print("\n Not enough valid strategies.")
|
||||
return
|
||||
|
||||
print(f"\n Valid return series: {len(strat_returns)}")
|
||||
|
||||
# Find best portfolio via greedy selection (low correlation, high return)
|
||||
print("\n--- Greedy Portfolio Selection ---")
|
||||
print(f" Target: {TARGET_MONTHLY}% monthly | Max DD: {MAX_DD:.0%} | Max Daily DD: {MAX_DAILY_DD:.0%}")
|
||||
print()
|
||||
|
||||
# Compute individual metrics
|
||||
individual = []
|
||||
for i, (rets, name) in enumerate(zip(strat_returns, strat_names)):
|
||||
eq = (1 + rets).cumprod()
|
||||
dd = float(((eq - eq.cummax()) / eq.cummax()).min())
|
||||
total = float(eq.iloc[-1] - 1)
|
||||
n = max((rets.index[-1] - rets.index[0]).days / 30.44, 1)
|
||||
mon = float((1 + total) ** (1 / n) - 1) * 100
|
||||
individual.append({"idx": i, "name": name, "monthly": mon, "dd": dd, "n": len(rets)})
|
||||
|
||||
individual.sort(key=lambda x: x["monthly"], reverse=True)
|
||||
|
||||
# Greedy: add strategies one by one if they don't increase correlation too much
|
||||
selected = []
|
||||
selected_rets = []
|
||||
|
||||
for s in individual:
|
||||
if len(selected) >= 8:
|
||||
break
|
||||
# Check correlation with existing portfolio
|
||||
new_ret = strat_returns[s["idx"]]
|
||||
if selected_rets:
|
||||
common = new_ret.index
|
||||
for r in selected_rets:
|
||||
common = common.intersection(r.index)
|
||||
if len(common) < 30:
|
||||
continue
|
||||
cors = []
|
||||
for r in selected_rets:
|
||||
aligned_new = new_ret.loc[common]
|
||||
aligned_r = r.loc[common]
|
||||
if len(aligned_new) >= 30:
|
||||
cors.append(abs(aligned_new.corr(aligned_r)))
|
||||
if cors and max(cors) > 0.5:
|
||||
print(f" SKIP {s['name'][:40]} (max_corr={max(cors):.2f})")
|
||||
continue
|
||||
|
||||
selected.append(s)
|
||||
selected_rets.append(new_ret)
|
||||
print(f" ADD {s['name'][:40]:40s} Mon={s['monthly']:+.1f}% DD={s['dd']:.3f} corr<0.5")
|
||||
|
||||
# Evaluate portfolio
|
||||
if len(selected) >= 2:
|
||||
print(f"\n Portfolio: {len(selected)} strategies")
|
||||
weights = [1.0 / len(selected)] * len(selected)
|
||||
rets = [strat_returns[s["idx"]] for s in selected]
|
||||
pm = compute_portfolio_metrics(rets, weights, close_daily)
|
||||
|
||||
print(f" Equal-weight metrics:")
|
||||
print(f" Monthly return: {pm['monthly_pct']:.2f}%")
|
||||
print(f" Max drawdown: {pm['max_dd']:.3f}")
|
||||
print(f" Sharpe: {pm['sharpe']:.2f}")
|
||||
print(f" Worst day: {pm['daily_worst']:.3%}")
|
||||
print(f" Period: {pm['n_months']:.1f} months ({pm['n_days']} days)")
|
||||
|
||||
# Leverage scaling
|
||||
max_safe_lev = min(
|
||||
MAX_DD / abs(pm["max_dd"]) if pm["max_dd"] != 0 else 30,
|
||||
MAX_DAILY_DD / abs(pm["daily_worst"]) if pm["daily_worst"] != 0 else 30,
|
||||
30,
|
||||
)
|
||||
leveraged_monthly = pm["monthly_pct"] * max_safe_lev
|
||||
print(f"\n Max safe leverage: {max_safe_lev:.1f}× (limited by max DD {MAX_DD:.0%})")
|
||||
print(f" Leveraged monthly: {leveraged_monthly:.1f}%")
|
||||
|
||||
if leveraged_monthly >= TARGET_MONTHLY:
|
||||
print(f"\n ✓ MEETS TARGET! {leveraged_monthly:.1f}% ≥ {TARGET_MONTHLY}%")
|
||||
else:
|
||||
gap = TARGET_MONTHLY - leveraged_monthly
|
||||
needed_strategies = int(np.ceil(len(selected) * TARGET_MONTHLY / max(leveraged_monthly, 0.1)))
|
||||
print(f"\n ✗ Below target. Need ~{needed_strategies} strategies or {TARGET_MONTHLY/max(pm['monthly_pct'],0.01):.1f}× better monthly.")
|
||||
|
||||
# Save portfolio config
|
||||
out = {
|
||||
"target_monthly": TARGET_MONTHLY,
|
||||
"selected": [{"name": s["name"], "monthly": s["monthly"], "dd": s["dd"]} for s in selected],
|
||||
"portfolio": pm if len(selected) >= 2 else {},
|
||||
}
|
||||
out_path = RESULTS_DIR / "portfolio_config.json"
|
||||
out_path.write_text(json.dumps(out, indent=2, default=str))
|
||||
print(f"\n Saved → {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,228 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Price-Action Strategy Generator — no LLM, no factors, pure technical analysis.
|
||||
|
||||
Uses Donchian channels, moving averages, RSI, Bollinger Bands, and MACD
|
||||
on daily resolution. Grid-searches parameters, validates via backtest_signal.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT = Path(__file__).resolve().parent.parent
|
||||
OHLCV_PATH = Path(os.getenv("PREDIX_OHLCV_PATH",
|
||||
str(PROJECT / "git_ignore_folder" / "intraday_pv_all.h5")))
|
||||
RESULTS_DIR = PROJECT / "results" / "strategies_new"
|
||||
|
||||
MIN_MONTHLY = 1.0
|
||||
MIN_SHARPE = 1.0
|
||||
MAX_DD = -0.15
|
||||
MIN_TRADES = 30
|
||||
|
||||
|
||||
def load_data():
|
||||
df = pd.read_hdf(OHLCV_PATH, key="data")
|
||||
close = df.xs("EURUSD", level="instrument")["$close"].sort_index()
|
||||
daily = close.resample("D").last().dropna()
|
||||
return close, daily
|
||||
|
||||
|
||||
def to_1min(daily_signal: pd.Series, close_1min: pd.Series) -> pd.Series:
|
||||
return daily_signal.reindex(close_1min.index).ffill().fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Strategy templates
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def donchian(close: pd.Series, period: int, hold: int) -> pd.Series:
|
||||
"""Donchian channel breakout."""
|
||||
high = close.rolling(period).max()
|
||||
low = close.rolling(period).min()
|
||||
s = pd.Series(0, index=close.index)
|
||||
s[close > high.shift(1)] = 1
|
||||
s[close < low.shift(1)] = -1
|
||||
s = s.replace(0, np.nan).ffill(limit=hold).fillna(0).astype(int).clip(-1, 1)
|
||||
return s
|
||||
|
||||
|
||||
def sma_cross(close: pd.Series, fast: int, slow: int) -> pd.Series:
|
||||
"""SMA crossover."""
|
||||
s = pd.Series(0, index=close.index)
|
||||
s[close.rolling(fast).mean() > close.rolling(slow).mean()] = 1
|
||||
s[close.rolling(fast).mean() < close.rolling(slow).mean()] = -1
|
||||
return s.fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
|
||||
def rsi_mr(close: pd.Series, period: int, oversold: int, overbought: int) -> pd.Series:
|
||||
"""RSI mean-reversion."""
|
||||
delta = close.diff()
|
||||
gain = delta.clip(lower=0).rolling(period).mean()
|
||||
loss = (-delta.clip(upper=0)).rolling(period).mean()
|
||||
rs = gain / (loss + 1e-8)
|
||||
rsi = 100 - 100 / (1 + rs)
|
||||
s = pd.Series(0, index=close.index)
|
||||
s[rsi < oversold] = 1
|
||||
s[rsi > overbought] = -1
|
||||
return s.fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
|
||||
def bollinger_mr(close: pd.Series, period: int, std: float) -> pd.Series:
|
||||
"""Bollinger Band mean-reversion."""
|
||||
ma = close.rolling(period).mean()
|
||||
st = close.rolling(period).std()
|
||||
s = pd.Series(0, index=close.index)
|
||||
s[close < ma - std * st] = 1
|
||||
s[close > ma + std * st] = -1
|
||||
return s.fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
|
||||
def macd(close: pd.Series, fast: int, slow: int, signal_p: int) -> pd.Series:
|
||||
"""MACD crossover."""
|
||||
ema_fast = close.ewm(span=fast, adjust=False).mean()
|
||||
ema_slow = close.ewm(span=slow, adjust=False).mean()
|
||||
macd_line = ema_fast - ema_slow
|
||||
sig_line = macd_line.ewm(span=signal_p, adjust=False).mean()
|
||||
s = pd.Series(0, index=close.index)
|
||||
s[macd_line > sig_line] = 1
|
||||
s[macd_line < sig_line] = -1
|
||||
return s.fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
|
||||
def ma_envelope(close: pd.Series, period: int, pct: float) -> pd.Series:
|
||||
"""Moving average envelope mean-reversion."""
|
||||
ma = close.rolling(period).mean()
|
||||
s = pd.Series(0, index=close.index)
|
||||
s[close < ma * (1 - pct)] = 1
|
||||
s[close > ma * (1 + pct)] = -1
|
||||
return s.replace(0, np.nan).ffill(limit=3).fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
|
||||
def atr_breakout(close: pd.Series, period: int, mult: float) -> pd.Series:
|
||||
"""ATR-based volatility breakout (simplified, using close-only)."""
|
||||
atr = (close.diff().abs()).rolling(period).mean()
|
||||
ma = close.rolling(period).mean()
|
||||
s = pd.Series(0, index=close.index)
|
||||
s[close > ma + mult * atr] = 1
|
||||
s[close < ma - mult * atr] = -1
|
||||
return s.replace(0, np.nan).ffill(limit=2).fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Main
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print(" Price-Action Strategy Generator (No LLM, No Factors)")
|
||||
print("=" * 60)
|
||||
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
||||
|
||||
close, daily = load_data()
|
||||
print(f"\nDaily data: {len(daily)} bars ({daily.index[0].date()} → {daily.index[-1].date()})")
|
||||
|
||||
import itertools
|
||||
|
||||
grid = [
|
||||
("Donchian", donchian, [
|
||||
(p, h) for p in [5, 7, 10, 12, 15, 20, 25, 30, 40, 60]
|
||||
for h in [1, 2, 3, 5]
|
||||
]),
|
||||
("SMA_Crossover", sma_cross, [
|
||||
(f, s) for f in [5, 10, 20]
|
||||
for s in [20, 50, 100, 200] if s > f
|
||||
]),
|
||||
("RSI_MR", rsi_mr, [
|
||||
(p, lo, hi) for p in [7, 14, 21]
|
||||
for lo, hi in [(30, 70), (25, 75), (20, 80)]
|
||||
]),
|
||||
("Bollinger_MR", bollinger_mr, [
|
||||
(p, s) for p in [10, 20, 40]
|
||||
for s in [1.5, 2.0, 2.5]
|
||||
]),
|
||||
("MACD", macd, [
|
||||
(f, s, sig) for f, s, sig in [(8, 21, 5), (12, 26, 9), (5, 20, 3)]
|
||||
]),
|
||||
("MA_Envelope", ma_envelope, [
|
||||
(p, pct) for p in [20, 50, 100]
|
||||
for pct in [0.01, 0.02, 0.03]
|
||||
]),
|
||||
("ATR_Breakout", atr_breakout, [
|
||||
(p, m) for p in [10, 20, 40]
|
||||
for m in [1.0, 1.5, 2.0]
|
||||
]),
|
||||
]
|
||||
|
||||
results = []
|
||||
t0 = time.time()
|
||||
total = sum(len(params) for _, _, params in grid)
|
||||
done = 0
|
||||
|
||||
print(f"\nTesting {total} parameter combinations...\n")
|
||||
|
||||
for name, fn, params_list in grid:
|
||||
for params in params_list:
|
||||
done += 1
|
||||
daily_signal = fn(daily, *params)
|
||||
signal_1min = to_1min(daily_signal, close)
|
||||
bt = backtest_signal(close=close, signal=signal_1min)
|
||||
bt["strategy"] = name
|
||||
bt["params"] = params
|
||||
bt["name"] = f"{name}{params}"
|
||||
bt["monthly_pct"] = bt.get("monthly_return_pct", 0)
|
||||
bt["max_dd"] = bt.get("max_drawdown", 0)
|
||||
results.append(bt)
|
||||
if done % 50 == 0 or done == total:
|
||||
elapsed = time.time() - t0
|
||||
rate = done / elapsed if elapsed > 0 else 0
|
||||
eta = (total - done) / rate if rate > 0 else 0
|
||||
print(f" {done}/{total} ({done/total*100:.0f}%) {rate:.0f}/s eta {eta:.0f}s")
|
||||
|
||||
elapsed = time.time() - t0
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" Evaluated: {total} in {elapsed:.0f}s")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
valid = [r for r in results
|
||||
if r.get("sharpe", 0) >= MIN_SHARPE
|
||||
and r.get("max_dd", 0) >= MAX_DD
|
||||
and r.get("n_trades", 0) >= MIN_TRADES
|
||||
and r.get("monthly_pct", 0) >= MIN_MONTHLY]
|
||||
valid.sort(key=lambda r: r.get("monthly_pct", 0), reverse=True)
|
||||
|
||||
print(f"\n Meeting: Sharpe≥{MIN_SHARPE} DD≥{MAX_DD} Tr≥{MIN_TRADES} Mon≥{MIN_MONTHLY}%")
|
||||
print(f" → {len(valid)} strategies\n")
|
||||
|
||||
hdr = "{:>3s} {:20s} {:20s} {:>7s} {:>7s} {:>7s} {:>5s} {:>6s}"
|
||||
print(hdr.format("#", "Strategy", "Params", "Sharpe", "Mon%", "MaxDD", "Tr", "WinRt"))
|
||||
print("-" * 85)
|
||||
for i, r in enumerate(valid[:30], 1):
|
||||
ps = str(r["params"]).replace(" ", "")[:18]
|
||||
print(hdr.format(str(i), r["strategy"][:20], ps,
|
||||
f'{r.get("sharpe",0):.2f}', f'{r.get("monthly_pct",0):.1f}%',
|
||||
f'{r.get("max_dd",0):.3f}', str(r.get("n_trades",0)),
|
||||
f'{r.get("win_rate",0):.1%}'))
|
||||
|
||||
print(f"\n Best by category:")
|
||||
seen = set()
|
||||
for r in valid:
|
||||
if r["strategy"] not in seen:
|
||||
seen.add(r["strategy"])
|
||||
print(f" {r['strategy']:20s} {r['name'][:30]:30s} "
|
||||
f"Sh={r.get('sharpe',0):.2f} Mon={r.get('monthly_pct',0):.1f}% "
|
||||
f"DD={r.get('max_dd',0):.3f} Tr={r.get('n_trades',0)}")
|
||||
|
||||
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
out = RESULTS_DIR / f"priceaction_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
out.write_text(json.dumps(valid[:100] if valid else results[:100], indent=2, default=str))
|
||||
print(f"\n Saved → {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,285 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Price-Action R&D Loop — TA-Lib powered. 17 indicators, deterministic.
|
||||
|
||||
Uses TA-Lib (161 indicators) for standardized technical analysis.
|
||||
Generates random strategy hypotheses and evaluates via backtest_signal.
|
||||
"""
|
||||
|
||||
import json, os, random, sys, time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
import talib
|
||||
|
||||
PROJECT = Path(__file__).resolve().parent.parent
|
||||
OHLCV_PATH = Path(os.getenv("PREDIX_OHLCV_PATH",
|
||||
str(PROJECT / "git_ignore_folder" / "intraday_pv_all.h5")))
|
||||
RESULTS_DIR = PROJECT / "results" / "strategies_new"
|
||||
|
||||
TIMEFRAMES = ["15min", "30min", "1h", "4h", "1d"]
|
||||
VOTE_THRESHOLD = 0.25
|
||||
MIN_SHARPE, MIN_TRADES, TOP_N = 1.0, 20, 20
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Indicator functions — all use (close, high, low, volume, **params) signature
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _macd(c, h, l, v, fast, slow, sig):
|
||||
mc, sc, _ = talib.MACD(c.values.astype(np.float64), fastperiod=fast, slowperiod=slow, signalperiod=sig)
|
||||
s = pd.Series(0, index=c.index); s[mc > sc] = 1; s[mc < sc] = -1
|
||||
return s.fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
def _rsi(c, h, l, v, period, oversold, overbought):
|
||||
vv = talib.RSI(c.values.astype(np.float64), timeperiod=period)
|
||||
s = pd.Series(0, index=c.index); s[vv < oversold] = 1; s[vv > overbought] = -1
|
||||
return s.fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
def _bbands(c, h, l, v, period, std):
|
||||
up, mi, lo = talib.BBANDS(c.values.astype(np.float64), timeperiod=period, nbdevup=std, nbdevdn=std)
|
||||
s = pd.Series(0, index=c.index); s[c.values < lo] = 1; s[c.values > up] = -1
|
||||
return s.fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
def _stoch(c, h, l, v, fastk, slowk, slowd):
|
||||
k, d = talib.STOCH(h.values.astype(np.float64), l.values.astype(np.float64), c.values.astype(np.float64),
|
||||
fastk_period=fastk, slowk_period=slowk, slowd_period=slowd)
|
||||
s = pd.Series(0, index=c.index); s[(k > d) & (k < 30)] = 1; s[(k < d) & (k > 70)] = -1
|
||||
return s.fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
def _cci(c, h, l, v, period):
|
||||
vv = talib.CCI(h.values.astype(np.float64), l.values.astype(np.float64), c.values.astype(np.float64), timeperiod=period)
|
||||
s = pd.Series(0, index=c.index); s[vv < -100] = 1; s[vv > 100] = -1
|
||||
return s.fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
def _willr(c, h, l, v, period):
|
||||
vv = talib.WILLR(h.values.astype(np.float64), l.values.astype(np.float64), c.values.astype(np.float64), timeperiod=period)
|
||||
s = pd.Series(0, index=c.index); s[vv < -80] = 1; s[vv > -20] = -1
|
||||
return s.fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
def _adx(c, h, l, v, period, threshold):
|
||||
pdi = talib.PLUS_DI(h.values.astype(np.float64), l.values.astype(np.float64), c.values.astype(np.float64), timeperiod=period)
|
||||
ndi = talib.MINUS_DI(h.values.astype(np.float64), l.values.astype(np.float64), c.values.astype(np.float64), timeperiod=period)
|
||||
adx = talib.ADX(h.values.astype(np.float64), l.values.astype(np.float64), c.values.astype(np.float64), timeperiod=period)
|
||||
s = pd.Series(0, index=c.index); s[(pdi > ndi) & (adx > threshold)] = 1; s[(ndi > pdi) & (adx > threshold)] = -1
|
||||
return s.fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
def _sar(c, h, l, v, accel, max_accel):
|
||||
vv = talib.SAR(h.values.astype(np.float64), l.values.astype(np.float64), acceleration=accel, maximum=max_accel)
|
||||
s = pd.Series(0, index=c.index); s[c.values > vv] = 1; s[c.values < vv] = -1
|
||||
return s.fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
def _roc(c, h, l, v, period, threshold):
|
||||
vv = talib.ROC(c.values.astype(np.float64), timeperiod=period)
|
||||
s = pd.Series(0, index=c.index); s[vv > threshold] = 1; s[vv < -threshold] = -1
|
||||
return s.fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
def _mom(c, h, l, v, period):
|
||||
vv = talib.MOM(c.values.astype(np.float64), timeperiod=period)
|
||||
s = pd.Series(0, index=c.index); s[vv > 0] = 1; s[vv < 0] = -1
|
||||
return s.fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
def _aroon(c, h, l, v, period):
|
||||
up, dn = talib.AROON(h.values.astype(np.float64), l.values.astype(np.float64), timeperiod=period)
|
||||
s = pd.Series(0, index=c.index); s[up > dn] = 1; s[up < dn] = -1
|
||||
return s.fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
def _mfi(c, h, l, v, period):
|
||||
vv = talib.MFI(h.values.astype(np.float64), l.values.astype(np.float64), c.values.astype(np.float64), v.values.astype(np.float64), timeperiod=period)
|
||||
s = pd.Series(0, index=c.index); s[vv < 20] = 1; s[vv > 80] = -1
|
||||
return s.fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
def _ultosc(c, h, l, v, p1, p2, p3):
|
||||
vv = talib.ULTOSC(h.values.astype(np.float64), l.values.astype(np.float64), c.values.astype(np.float64), timeperiod1=p1, timeperiod2=p2, timeperiod3=p3)
|
||||
s = pd.Series(0, index=c.index); s[vv < 30] = 1; s[vv > 70] = -1
|
||||
return s.fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
def _natr(c, h, l, v, period):
|
||||
vv = talib.NATR(h.values.astype(np.float64), l.values.astype(np.float64), c.values.astype(np.float64), timeperiod=period)
|
||||
m, s = vv[-200:].mean(), vv[-200:].std()
|
||||
s = pd.Series(0, index=c.index); s[c.values > m+s] = 1; s[c.values < m-s] = -1
|
||||
return s.fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
def _donchian(c, h, l, v, period, hold):
|
||||
hi, lo = c.rolling(period).max(), c.rolling(period).min()
|
||||
s = pd.Series(0, index=c.index); s[c > hi.shift(1)] = 1; s[c < lo.shift(1)] = -1
|
||||
return s.replace(0, np.nan).ffill(limit=hold).fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
def _sma(c, h, l, v, fast, slow):
|
||||
s = pd.Series(0, index=c.index)
|
||||
s[c.rolling(fast).mean() > c.rolling(slow).mean()] = 1
|
||||
s[c.rolling(fast).mean() < c.rolling(slow).mean()] = -1
|
||||
return s.fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
def _ema(c, h, l, v, fast, slow):
|
||||
ef, es = c.ewm(span=fast, adjust=False).mean(), c.ewm(span=slow, adjust=False).mean()
|
||||
s = pd.Series(0, index=c.index); s[ef > es] = 1; s[ef < es] = -1
|
||||
return s.fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
INDICATORS = {
|
||||
"MACD": ({"fast":[3,5,8,12], "slow":[10,15,20,26,35], "sig":[3,5,9]}, _macd, "MACD({fast},{slow},{sig})"),
|
||||
"RSI": ({"period":[7,14,21], "oversold":[20,25,30,35], "overbought":[65,70,75,80]}, _rsi, "RSI({period})[{oversold}/{overbought}]"),
|
||||
"BBands": ({"period":[10,20,40], "std":[1.5,2.0,2.5]}, _bbands, "BB({period},{std}s)"),
|
||||
"Stoch": ({"fastk":[5,9,14], "slowk":[3], "slowd":[3,5]}, _stoch, "Stoch({fastk},{slowk},{slowd})"),
|
||||
"CCI": ({"period":[14,20,50]}, _cci, "CCI({period})"),
|
||||
"WillR": ({"period":[7,14,21]}, _willr, "WR({period})"),
|
||||
"ADX": ({"period":[7,14,21], "threshold":[15,20,25]}, _adx, "ADX({period}>{threshold})"),
|
||||
"SAR": ({"accel":[0.02,0.05,0.08], "max_accel":[0.2,0.3,0.5]}, _sar, "SAR({accel},{max_accel})"),
|
||||
"ROC": ({"period":[5,10,20], "threshold":[0.1,0.2,0.5]}, _roc, "ROC({period},{threshold}%)"),
|
||||
"MOM": ({"period":[5,10,20,50]}, _mom, "MOM({period})"),
|
||||
"AROON": ({"period":[7,14,21]}, _aroon, "AROON({period})"),
|
||||
"MFI": ({"period":[7,14,21]}, _mfi, "MFI({period})"),
|
||||
"UltOsc": ({"p1":[7], "p2":[14], "p3":[28]}, _ultosc, "UltOsc(7,14,28)"),
|
||||
"NATR": ({"period":[7,14,21]}, _natr, "NATR({period})"),
|
||||
"Donchian":({"period":[5,10,20,30,50,100], "hold":[1,2,3,5]}, _donchian, "Donchian({period},{hold})"),
|
||||
"SMA": ({"fast":[5,10,20,50], "slow":[20,50,100,200]}, _sma, "SMA({fast},{slow})"),
|
||||
"EMA": ({"fast":[3,5,8,12], "slow":[15,26,50,100]}, _ema, "EMA({fast},{slow})"),
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Strategy generation
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _resample_ohlc(close_1min, tf):
|
||||
"""Resample to timeframe, producing OHLCV bars."""
|
||||
bars = close_1min.resample(tf).ohlc()
|
||||
# Flatten MultiIndex columns
|
||||
o = bars['close']['close'] if isinstance(bars.columns, pd.MultiIndex) else bars['close']
|
||||
h = bars['high']['high'] if isinstance(bars.columns, pd.MultiIndex) else bars['high']
|
||||
l = bars['low']['low'] if isinstance(bars.columns, pd.MultiIndex) else bars['low']
|
||||
c = bars['close']['close'] if isinstance(bars.columns, pd.MultiIndex) else bars['close']
|
||||
v = pd.Series(1000, index=c.index) # dummy volume
|
||||
return c, h, l, v
|
||||
|
||||
def random_hypothesis():
|
||||
stype = random.choice(["single", "multi_tf", "portfolio"])
|
||||
if stype == "single":
|
||||
ind_name = random.choice(list(INDICATORS.keys()))
|
||||
params_def, _, desc_tpl = INDICATORS[ind_name]
|
||||
params = {k: random.choice(v) for k, v in params_def.items()}
|
||||
if ind_name == "SMA" and params["fast"] >= params["slow"]:
|
||||
params["fast"] = min(params["fast"], params["slow"] // 2)
|
||||
return {"type": "single", "indicator": ind_name, "timeframe": random.choice(TIMEFRAMES),
|
||||
"params": params, "description": desc_tpl.format(**params)}
|
||||
elif stype == "multi_tf":
|
||||
ind_name = random.choice(list(INDICATORS.keys()))
|
||||
params_def, _, desc_tpl = INDICATORS[ind_name]
|
||||
params = {k: random.choice(v) for k, v in params_def.items()}
|
||||
tfs = random.sample(TIMEFRAMES, k=random.randint(2, 4))
|
||||
return {"type": "multi_tf", "indicator": ind_name, "timeframes": tfs,
|
||||
"params": params, "description": f"{ind_name} on {','.join(tfs)} maj-vote"}
|
||||
else:
|
||||
i1, i2 = random.sample(list(INDICATORS.keys()), 2)
|
||||
p1_def, _, _ = INDICATORS[i1]; p2_def, _, _ = INDICATORS[i2]
|
||||
p1 = {k: random.choice(v) for k, v in p1_def.items()}
|
||||
p2 = {k: random.choice(v) for k, v in p2_def.items()}
|
||||
return {"type": "portfolio", "indicators": [{"name": i1, "params": p1}, {"name": i2, "params": p2}],
|
||||
"timeframe": "1d", "description": f"{i1} + {i2} portfolio daily"}
|
||||
|
||||
|
||||
def build_signal(close_1min, hypothesis):
|
||||
hp = hypothesis
|
||||
if hp["type"] == "single":
|
||||
_, fn, _ = INDICATORS[hp["indicator"]]
|
||||
c, h, l, v = _resample_ohlc(close_1min, hp["timeframe"])
|
||||
s = fn(c, h, l, v, **hp["params"])
|
||||
return s.reindex(close_1min.index).ffill().fillna(0).astype(int).clip(-1, 1)
|
||||
elif hp["type"] == "multi_tf":
|
||||
_, fn, _ = INDICATORS[hp["indicator"]]
|
||||
sigs = {}
|
||||
for tf in hp["timeframes"]:
|
||||
c, h, l, v = _resample_ohlc(close_1min, tf)
|
||||
sigs[tf] = fn(c, h, l, v, **hp["params"]).reindex(close_1min.index).ffill().fillna(0).astype(int).clip(-1, 1)
|
||||
port_df = pd.DataFrame(sigs).dropna()
|
||||
vote = port_df.mean(axis=1)
|
||||
result = pd.Series(0, index=vote.index)
|
||||
result[vote > VOTE_THRESHOLD] = 1; result[vote < -VOTE_THRESHOLD] = -1
|
||||
return result
|
||||
else:
|
||||
sigs = []
|
||||
daily, dh, dl, dv = _resample_ohlc(close_1min, "1d")
|
||||
for cfg in hp["indicators"]:
|
||||
_, fn, _ = INDICATORS[cfg["name"]]
|
||||
s = fn(daily, dh, dl, dv, **cfg["params"]).reindex(close_1min.index).ffill().fillna(0).astype(int).clip(-1, 1)
|
||||
sigs.append(s)
|
||||
port_df = pd.DataFrame({f"s{i}": s for i, s in enumerate(sigs)}).dropna()
|
||||
vote = port_df.mean(axis=1)
|
||||
result = pd.Series(0, index=vote.index)
|
||||
result[vote > VOTE_THRESHOLD] = 1; result[vote < -VOTE_THRESHOLD] = -1
|
||||
return result
|
||||
|
||||
|
||||
def evaluate(hp, close):
|
||||
signal = build_signal(close, hp)
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
||||
bt = backtest_signal(close=close, signal=signal)
|
||||
return {"hypothesis": hp, "sharpe": bt.get("sharpe", 0) or 0,
|
||||
"monthly_pct": bt.get("monthly_return_pct", 0) or 0,
|
||||
"max_dd": bt.get("max_drawdown", 0) or 0, "n_trades": bt.get("n_trades", 0) or 0,
|
||||
"win_rate": bt.get("win_rate", 0) or 0}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Main loop
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def main():
|
||||
iterations = 100; continuous = False
|
||||
if "--iterations" in sys.argv:
|
||||
iterations = int(sys.argv[sys.argv.index("--iterations") + 1])
|
||||
if "--live" in sys.argv: continuous = True
|
||||
|
||||
print("=" * 60)
|
||||
print(f" Price-Action R&D Loop — TA-Lib ({len(INDICATORS)} indicators)")
|
||||
print(f" Iterations: {'continuous' if continuous else iterations}")
|
||||
print("=" * 60)
|
||||
|
||||
df = pd.read_hdf(OHLCV_PATH, key="data")
|
||||
close = df.xs("EURUSD", level="instrument")["$close"].sort_index()
|
||||
|
||||
top, best_sh, total, iteration = [], 0, 0, 0
|
||||
|
||||
while True:
|
||||
iteration += 1
|
||||
if not continuous and iteration > iterations: break
|
||||
|
||||
hp = random_hypothesis()
|
||||
result = evaluate(hp, close)
|
||||
result["iteration"] = iteration
|
||||
result["timestamp"] = datetime.now().isoformat()
|
||||
total += 1
|
||||
|
||||
if result["sharpe"] >= MIN_SHARPE and result["n_trades"] >= MIN_TRADES and result["monthly_pct"] > 0:
|
||||
top.append(result)
|
||||
top.sort(key=lambda r: r["sharpe"], reverse=True)
|
||||
top = top[:TOP_N]
|
||||
|
||||
if iteration % 10 == 0 or result["sharpe"] > best_sh:
|
||||
if result["sharpe"] > best_sh:
|
||||
best_sh = result["sharpe"]
|
||||
print(f"\n * NEW BEST (#{iteration}): {hp['description']}")
|
||||
print(f" Sharpe={result['sharpe']:.2f} Mon={result['monthly_pct']:.2f}% "
|
||||
f"DD={result['max_dd']:.4f} Tr={result['n_trades']} WR={result['win_rate']:.1%}")
|
||||
else:
|
||||
print(f" [{iteration}/{iterations}] Evals: {total} | Top: {len(top)} | Best Sh={best_sh:.2f}")
|
||||
|
||||
if iteration % 50 == 0 and top:
|
||||
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cp = RESULTS_DIR / f"pal_talib_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
cp.write_text(json.dumps(top[:10], indent=2, default=str))
|
||||
print(f" Checkpoint: {cp.name}")
|
||||
|
||||
# Final
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" Done: {total} evaluated, {len(top)} strategies")
|
||||
if top:
|
||||
print(f"\n{'#':>3s} {'Strategy':<50s} {'Sharpe':>7s} {'Mon%':>7s} {'DD':>7s} {'Tr':>5s}")
|
||||
print("-" * 80)
|
||||
for i, r in enumerate(top[:15], 1):
|
||||
print(f"{i:>3d} {r['hypothesis']['description'][:50]:<50s} {r['sharpe']:>+7.2f} {r['monthly_pct']:>+6.2f}% {r['max_dd']:>+6.4f} {r['n_trades']:>5d}")
|
||||
final = RESULTS_DIR / f"pal_talib_final_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
final.write_text(json.dumps(top, indent=2, default=str))
|
||||
print(f"\n Saved: {final}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -24,7 +24,7 @@ FACTOR_FILES = Path('results/factors')
|
||||
VALUE_FILES = FACTOR_FILES / 'values'
|
||||
OHLCV_PATH = Path('git_ignore_folder/factor_implementation_source_data/intraday_pv.h5')
|
||||
|
||||
# Best daytrading strategies (12-min horizon, optimized for RiskMgmt)
|
||||
# Best daytrading strategies (12-min horizon, optimized for FTMO)
|
||||
DAYTRADING_COMBOS = [
|
||||
{
|
||||
'name': 'MomentumDivergence12min',
|
||||
@@ -236,7 +236,7 @@ def load_factor_series(name):
|
||||
def main(n_strategies=5):
|
||||
console.print("[bold cyan]🎯 Daytrading Strategy Generator (Quick Mode)[/bold cyan]\n")
|
||||
console.print(" Style: 12-minute forward returns")
|
||||
console.print(" Target: RiskMgmt compliant (IC>0.02, Sharpe>0.5, Trades>20, DD>-10%)\n")
|
||||
console.print(" Target: FTMO compliant (IC>0.02, Sharpe>0.5, Trades>20, DD>-10%)\n")
|
||||
|
||||
# Load OHLCV data
|
||||
if not OHLCV_PATH.exists():
|
||||
@@ -422,7 +422,7 @@ print(json.dumps(result))
|
||||
trades = result.get('n_trades', 0)
|
||||
dd = result.get('max_drawdown', 0)
|
||||
|
||||
# RiskMgmt criteria
|
||||
# FTMO criteria
|
||||
if abs(ic) > 0.02 and sharpe > 0.5 and trades > 20 and dd > -0.10:
|
||||
strategy = {
|
||||
'strategy_name': combo['name'],
|
||||
|
||||
@@ -1,931 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""R&D Loop V2 — Multi-Instrument + Correlation Score + Session/Vola Filter + OOS.
|
||||
|
||||
Changes from V1:
|
||||
1. Multi-Instrument: Evaluate on EUR/USD + GBP/USD + BTC/USD
|
||||
2. Correlation Score: Reward uncorrelated strategies (Sharpe × (1−corr))
|
||||
3. Session Filter: Only trade London session (07:00-16:00 UTC)
|
||||
4. Volatility Filter: No trades when ATR < threshold
|
||||
5. OOS Split: Report IS/OOS separately (80/20)
|
||||
"""
|
||||
|
||||
import json, os, random, sys, time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
from numba import jit
|
||||
|
||||
PROJECT = Path(__file__).resolve().parent.parent
|
||||
OHLCV_PATH = Path(os.getenv("PREDIX_OHLCV_PATH",
|
||||
str(PROJECT / "git_ignore_folder" / "intraday_pv_all.h5")))
|
||||
RESULTS_DIR = PROJECT / "results" / "rd_loop"
|
||||
STATE_DIR = PROJECT / "git_ignore_folder" / "rd_loop_state"
|
||||
|
||||
INSTRUMENTS = ["EURUSD", "GBPUSD", "BTCUSD", "XAUUSD"]
|
||||
LEADER_MAP = {"GBPUSD": "EURUSD"}
|
||||
INSTRUMENT_TIMEFRAMES = {
|
||||
"XAUUSD": ["1d", "1w"], # Daily data → daily/weekly TFs
|
||||
"default": ["5min", "15min", "30min", "1h", "4h"],
|
||||
}
|
||||
TIMEFRAMES = ["5min", "15min", "30min", "1h", "4h", "1d", "1w"]
|
||||
INDICATORS_POOL = ["MACD", "RSI", "BBands", "Donchian", "Stoch", "CCI", "WillR", "ADX", "SAR", "ROC", "MOM", "AROON", "MFI", "SMA", "EMA"]
|
||||
STRATEGY_TYPES = ["single", "multi_tf", "multi_role"]
|
||||
TREND_TFS = ["30min", "1h", "4h"]
|
||||
ENTRY_TFS = ["5min", "15min", "30min"]
|
||||
MIN_SHARPE, MIN_TRADES = 0.3, 10
|
||||
EXPLORATION_RATE = 0.40
|
||||
OOS_SPLIT = 0.2
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Numba-accelerated backtest
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@jit(nopython=True)
|
||||
def _backtest_numba(prices, signals, cost=0.000264):
|
||||
n = len(prices)
|
||||
equity = np.zeros(n, dtype=np.float64)
|
||||
equity[0] = 100000.0; peak = 100000.0; max_dd = 0.0
|
||||
trade_returns = np.zeros(100000, dtype=np.float64)
|
||||
position = 0; entry_price = 0.0; trade_count = 0; wins = 0
|
||||
|
||||
for i in range(1, n):
|
||||
px = prices[i]; sg = signals[i]; ps = signals[i-1]
|
||||
# Close position on signal reversal or flatten
|
||||
if position != 0 and (sg != position or sg == 0 and position != 0):
|
||||
if position == 1: ret = (px - entry_price) / entry_price - cost
|
||||
else: ret = (entry_price - px) / entry_price - cost
|
||||
equity[i] = equity[i-1] * (1.0 + ret)
|
||||
if equity[i] > peak: peak = equity[i]
|
||||
dd = (peak - equity[i]) / peak
|
||||
if dd > max_dd: max_dd = dd
|
||||
if trade_count < len(trade_returns):
|
||||
trade_returns[trade_count] = ret
|
||||
trade_count += 1
|
||||
if ret > 0: wins += 1
|
||||
position = 0
|
||||
else:
|
||||
equity[i] = equity[i-1]
|
||||
# Open new position
|
||||
if position == 0 and sg != 0:
|
||||
position = sg; entry_price = px
|
||||
|
||||
# Close final position
|
||||
if position != 0:
|
||||
fp = prices[-1]
|
||||
if position == 1: ret = (fp - entry_price) / entry_price - cost
|
||||
else: ret = (entry_price - fp) / entry_price - cost
|
||||
equity[-1] = equity[-2] * (1.0 + ret)
|
||||
if trade_count < len(trade_returns):
|
||||
trade_returns[trade_count] = ret
|
||||
trade_count += 1
|
||||
if ret > 0: wins += 1
|
||||
|
||||
# Ensure monotonic equity (carry forward zeros)
|
||||
for i in range(1, n):
|
||||
if equity[i] == 0: equity[i] = equity[i-1]
|
||||
|
||||
total_ret = (equity[-1] - 100000.0) / 100000.0
|
||||
|
||||
if trade_count > 5:
|
||||
t = trade_returns[:trade_count]
|
||||
mean_ret = np.mean(t); std_ret = np.std(t)
|
||||
sharpe = mean_ret / std_ret * np.sqrt(trade_count) if std_ret > 0 else 0.0
|
||||
else:
|
||||
sharpe = 0.0
|
||||
|
||||
return equity, max_dd, trade_count, wins, total_ret, sharpe, trade_returns[:trade_count]
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Signal Construction
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def build_signal(close, hypothesis):
|
||||
"""Build trading signal from hypothesis. Returns (-1,0,1) Series."""
|
||||
import talib
|
||||
signal = None
|
||||
|
||||
# Adapt timeframes to data frequency
|
||||
median_delta = (close.index[1:] - close.index[:-1]).median()
|
||||
if median_delta > pd.Timedelta("1h"):
|
||||
valid_tfs = ["1d", "1w"]
|
||||
tf_map = {"5min": "1d", "15min": "1d", "30min": "1d", "1h": "1d", "4h": "1w"}
|
||||
# Remap hypothesis timeframes
|
||||
hp = dict(hypothesis)
|
||||
if hp.get('type') in ('single', 'multi_tf') and 'timeframe' in hp:
|
||||
hp['timeframe'] = tf_map.get(hp.get('timeframe','1h'), '1d')
|
||||
if hp.get('type') == 'multi_tf' and 'timeframes' in hp:
|
||||
hp['timeframes'] = [tf_map.get(t, '1d') for t in hp['timeframes']]
|
||||
hp['timeframes'] = list(set(hp['timeframes'])) # dedup
|
||||
if hp.get('type') == 'multi_role':
|
||||
hp['trend_tf'] = tf_map.get(hp.get('trend_tf','4h'), '1w')
|
||||
hp['entry_tf'] = tf_map.get(hp.get('entry_tf','15min'), '1d')
|
||||
hypothesis = hp
|
||||
else:
|
||||
valid_tfs = ["5min", "15min", "30min", "1h", "4h"]
|
||||
|
||||
if hypothesis['type'] == 'single':
|
||||
ind = hypothesis['indicator']; tf = hypothesis['timeframe']
|
||||
bars = close.resample(tf).last().dropna()
|
||||
sig = _build_indicator_signal(ind, bars, hypothesis['params'])
|
||||
signal = sig.reindex(close.index).ffill().fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
elif hypothesis['type'] == 'multi_tf':
|
||||
ind = hypothesis['indicator']
|
||||
sigs = {}
|
||||
for tf in hypothesis['timeframes']:
|
||||
bars = close.resample(tf).last().dropna()
|
||||
sig = _build_indicator_signal(ind, bars, hypothesis['params'])
|
||||
sigs[tf] = sig.reindex(close.index).ffill().fillna(0).astype(int).clip(-1, 1)
|
||||
port = pd.DataFrame(sigs).dropna()
|
||||
vote = port.mean(axis=1)
|
||||
signal = pd.Series(0, index=close.index)
|
||||
signal[vote > 0.25] = 1; signal[vote < -0.25] = -1
|
||||
|
||||
elif hypothesis['type'] == 'multi_role':
|
||||
trend_ind = hypothesis['trend_ind']; entry_ind = hypothesis['entry_ind']
|
||||
trend_tf = hypothesis['trend_tf']; entry_tf = hypothesis['entry_tf']
|
||||
trend_bars = close.resample(trend_tf).last().dropna()
|
||||
trend_sig = _build_indicator_signal(trend_ind, trend_bars, hypothesis['trend_params'])
|
||||
trend_sig = trend_sig.reindex(close.index).ffill().fillna(0).astype(int).clip(-1, 1)
|
||||
entry_bars = close.resample(entry_tf).last().dropna()
|
||||
entry_sig = _build_indicator_signal(entry_ind, entry_bars, hypothesis['entry_params'])
|
||||
entry_sig = entry_sig.reindex(close.index).ffill().fillna(0).astype(int).clip(-1, 1)
|
||||
signal = pd.Series(0, index=close.index)
|
||||
signal[(trend_sig == 1) & (entry_sig == 1)] = 1
|
||||
signal[(trend_sig == -1) & (entry_sig == -1)] = -1
|
||||
|
||||
return signal if signal is not None and signal.nunique() > 1 else None
|
||||
|
||||
|
||||
def _apply_session_filter(signal, index):
|
||||
"""Only trade London session (07:00-16:00 UTC Mon-Fri). Skip for daily data."""
|
||||
delta = (index[1:] - index[:-1]).median() if len(index) > 1 else pd.Timedelta("1min")
|
||||
if delta > pd.Timedelta("1h"):
|
||||
return signal # Skip session filter for daily/weekly data
|
||||
hours = index.hour
|
||||
days = index.dayofweek
|
||||
in_session = (days < 5) & (hours >= 7) & (hours < 16)
|
||||
if hasattr(in_session, 'values'):
|
||||
in_session = in_session.values
|
||||
return (signal * in_session.astype(int)).astype(int).clip(-1, 1)
|
||||
|
||||
|
||||
def _apply_vola_filter(signal, close, atr_period=14, min_atr_pct=0.0003):
|
||||
"""Don't trade when ATR is too low (flat/quiet markets)."""
|
||||
tr = pd.DataFrame({
|
||||
'hl': close.diff().abs(),
|
||||
'hc': (close - close.shift(1)).abs(),
|
||||
'lc': (close.shift(1) - close).abs(),
|
||||
}).max(axis=1)
|
||||
atr = tr.rolling(atr_period).mean()
|
||||
atr_pct = atr / close
|
||||
too_quiet = atr_pct < min_atr_pct
|
||||
return (signal * (~too_quiet).astype(int)).fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
|
||||
_NEWS_CACHE = None
|
||||
def _load_news_events():
|
||||
"""Load high-impact news events from YAML, return dict of currency → DatetimeIndex mask."""
|
||||
global _NEWS_CACHE
|
||||
if _NEWS_CACHE is not None:
|
||||
return _NEWS_CACHE
|
||||
import yaml
|
||||
news_file = PROJECT / "git_ignore_folder" / "economic_events_full.yaml"
|
||||
if not news_file.exists():
|
||||
_NEWS_CACHE = {}
|
||||
return _NEWS_CACHE
|
||||
with open(news_file) as f:
|
||||
data = yaml.safe_load(f)
|
||||
events = {}
|
||||
for evt in data.get('events', []):
|
||||
if evt.get('impact') != 'high':
|
||||
continue
|
||||
dt = pd.Timestamp(evt['datetime'])
|
||||
currency = evt.get('currency', 'USD')
|
||||
if currency not in events:
|
||||
events[currency] = []
|
||||
events[currency].append(dt)
|
||||
_NEWS_CACHE = events
|
||||
return _NEWS_CACHE
|
||||
|
||||
|
||||
def _apply_news_filter(signal, index, currency, window_min=5):
|
||||
"""Block trades during high-impact news events (+/- window_min)."""
|
||||
events = _load_news_events()
|
||||
if currency not in events and currency[:3] not in events:
|
||||
return signal
|
||||
key = currency if currency in events else currency[:3]
|
||||
timestamps = events.get(key, [])
|
||||
if not timestamps:
|
||||
return signal
|
||||
|
||||
blocked = np.zeros(len(index), dtype=bool)
|
||||
for ts in timestamps:
|
||||
start = ts - pd.Timedelta(minutes=window_min)
|
||||
end = ts + pd.Timedelta(minutes=window_min)
|
||||
mask = (index >= start) & (index <= end)
|
||||
blocked |= mask
|
||||
|
||||
return (signal * (~blocked).astype(int)).astype(int).clip(-1, 1)
|
||||
|
||||
|
||||
def _apply_cross_confirm(signal, close_follower, close_leader, lookback=5, min_pct=0.0003):
|
||||
"""Cancel follower signals when leader momentum strongly opposes (>0.03% move)."""
|
||||
leader_mom = close_leader.pct_change(lookback)
|
||||
leader_mom = leader_mom.reindex(signal.index, method='ffill')
|
||||
|
||||
# Only BLOCK when leader moves strongly opposite to signal
|
||||
# Don't require confirmation — just cancel clear contrarian moves
|
||||
cancel_long = (signal == 1) & (leader_mom < -min_pct)
|
||||
cancel_short = (signal == -1) & (leader_mom > min_pct)
|
||||
cancel = cancel_long | cancel_short
|
||||
return (signal * (~cancel).astype(int)).fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
|
||||
def _build_indicator_signal(name, bars, params):
|
||||
"""Build indicator signal using talib + hand-rolled."""
|
||||
import talib
|
||||
c = bars.values.astype(np.float64)
|
||||
if name == 'MACD':
|
||||
mc, sc, _ = talib.MACD(c, fastperiod=params.get('fast', 3),
|
||||
slowperiod=params.get('slow', 15),
|
||||
signalperiod=params.get('sig', 3))
|
||||
s = pd.Series(0, index=bars.index); s[mc > sc] = 1; s[mc < sc] = -1
|
||||
elif name == 'RSI':
|
||||
v = talib.RSI(c, timeperiod=params.get('period', 14))
|
||||
s = pd.Series(0, index=bars.index); s[v < params.get('oversold', 30)] = 1; s[v > params.get('overbought', 70)] = -1
|
||||
elif name == 'BBands':
|
||||
up, mi, lo = talib.BBANDS(c, timeperiod=params.get('period', 20),
|
||||
nbdevup=params.get('std', 2), nbdevdn=params.get('std', 2))
|
||||
s = pd.Series(0, index=bars.index); s[c < lo] = 1; s[c > up] = -1
|
||||
elif name == 'Donchian':
|
||||
hi = bars.rolling(params.get('period', 20)).max()
|
||||
lo = bars.rolling(params.get('period', 20)).min()
|
||||
s = pd.Series(0, index=bars.index); s[bars > hi.shift(1)] = 1; s[bars < lo.shift(1)] = -1
|
||||
s = s.replace(0, np.nan).ffill(limit=params.get('hold', 1)).fillna(0).astype(int)
|
||||
elif name == 'Stoch':
|
||||
k, d = talib.STOCH(c, c, c, fastk_period=params.get('fastk', 9),
|
||||
slowk_period=params.get('slowk', 3), slowd_period=params.get('slowd', 3))
|
||||
s = pd.Series(0, index=bars.index); s[(k > d) & (k < 30)] = 1; s[(k < d) & (k > 70)] = -1
|
||||
elif name == 'CCI':
|
||||
v = talib.CCI(c, c, c, timeperiod=params.get('period', 14))
|
||||
s = pd.Series(0, index=bars.index); s[v < -100] = 1; s[v > 100] = -1
|
||||
elif name == 'WillR':
|
||||
v = talib.WILLR(c, c, c, timeperiod=params.get('period', 14))
|
||||
s = pd.Series(0, index=bars.index); s[v < -80] = 1; s[v > -20] = -1
|
||||
elif name == 'ADX':
|
||||
pdi = talib.PLUS_DI(c, c, c, timeperiod=params.get('period', 14))
|
||||
ndi = talib.MINUS_DI(c, c, c, timeperiod=params.get('period', 14))
|
||||
adx = talib.ADX(c, c, c, timeperiod=params.get('period', 14))
|
||||
s = pd.Series(0, index=bars.index)
|
||||
s[(pdi > ndi) & (adx > params.get('threshold', 20))] = 1
|
||||
s[(ndi > pdi) & (adx > params.get('threshold', 20))] = -1
|
||||
elif name == 'SAR':
|
||||
v = talib.SAR(c, c, acceleration=params.get('accel', 0.02), maximum=params.get('max_accel', 0.2))
|
||||
s = pd.Series(0, index=bars.index); s[c > v] = 1; s[c < v] = -1
|
||||
elif name == 'ROC':
|
||||
v = talib.ROC(c, timeperiod=params.get('period', 10))
|
||||
s = pd.Series(0, index=bars.index); s[v > params.get('threshold', 0.2)] = 1; s[v < -params.get('threshold', 0.2)] = -1
|
||||
elif name == 'MOM':
|
||||
v = talib.MOM(c, timeperiod=params.get('period', 10))
|
||||
s = pd.Series(0, index=bars.index); s[v > 0] = 1; s[v < 0] = -1
|
||||
elif name == 'AROON':
|
||||
up, dn = talib.AROON(c, c, timeperiod=params.get('period', 14))
|
||||
s = pd.Series(0, index=bars.index); s[up > dn] = 1; s[up < dn] = -1
|
||||
elif name == 'MFI':
|
||||
v = talib.MFI(c, c, c, c, timeperiod=params.get('period', 14))
|
||||
s = pd.Series(0, index=bars.index); s[v < 20] = 1; s[v > 80] = -1
|
||||
elif name == 'SMA':
|
||||
s = pd.Series(0, index=bars.index)
|
||||
s[bars.rolling(params.get('fast', 10)).mean() > bars.rolling(params.get('slow', 50)).mean()] = 1
|
||||
s[bars.rolling(params.get('fast', 10)).mean() < bars.rolling(params.get('slow', 50)).mean()] = -1
|
||||
elif name == 'EMA':
|
||||
ef = bars.ewm(span=params.get('fast', 5), adjust=False).mean()
|
||||
es = bars.ewm(span=params.get('slow', 26), adjust=False).mean()
|
||||
s = pd.Series(0, index=bars.index); s[ef > es] = 1; s[ef < es] = -1
|
||||
else:
|
||||
s = pd.Series(0, index=bars.index)
|
||||
return s.fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Multi-Instrument Evaluation
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def evaluate_multi(closes, hypothesis, use_session=True, use_vola=False):
|
||||
"""Evaluate strategy on all instruments, return combined metrics + per-instrument."""
|
||||
results = {}
|
||||
equity_curves = {}
|
||||
|
||||
for inst, close in closes.items():
|
||||
signal = build_signal(close, hypothesis)
|
||||
if signal is None:
|
||||
results[inst] = {"sharpe": 0, "monthly_pct": 0, "n_trades": 0}
|
||||
continue
|
||||
|
||||
# Apply filters
|
||||
if use_session:
|
||||
signal = _apply_session_filter(signal, close.index)
|
||||
if use_vola:
|
||||
signal = _apply_vola_filter(signal, close)
|
||||
|
||||
# News filter: block trades during high-impact events for this currency
|
||||
signal = _apply_news_filter(signal, close.index, inst.replace("USD", "").replace("BTC", "BTC"))
|
||||
|
||||
# Cross-pair confirmation: validate follower with leader momentum
|
||||
leader_inst = LEADER_MAP.get(inst)
|
||||
if leader_inst and leader_inst in closes:
|
||||
signal = _apply_cross_confirm(signal, close, closes[leader_inst])
|
||||
|
||||
if signal.nunique() <= 1:
|
||||
results[inst] = {"sharpe": 0, "monthly_pct": 0, "n_trades": 0}
|
||||
continue
|
||||
|
||||
# OOS split
|
||||
n = len(close)
|
||||
is_n = int(n * (1 - OOS_SPLIT))
|
||||
close_is = close.iloc[:is_n]; signal_is = signal.iloc[:is_n]
|
||||
close_oos = close.iloc[is_n:]; signal_oos = signal.iloc[is_n:]
|
||||
|
||||
# IS backtest
|
||||
prices_is = close_is.values.astype(np.float64); sigs_is = signal_is.values.astype(np.int32)
|
||||
eq_is, dd_is, tr_is, wins_is, ret_is, sh_is, _ = _backtest_numba(prices_is, sigs_is)
|
||||
|
||||
# OOS backtest
|
||||
prices_oos = close_oos.values.astype(np.float64); sigs_oos = signal_oos.values.astype(np.int32)
|
||||
eq_oos, dd_oos, tr_oos, wins_oos, ret_oos, sh_oos, _ = _backtest_numba(prices_oos, sigs_oos)
|
||||
|
||||
# Full backtest (for equity curve)
|
||||
prices_full = close.values.astype(np.float64); sigs_full = signal.values.astype(np.int32)
|
||||
eq_full, dd_full, tr_full, wins_full, ret_full, sh_full, trades_full = _backtest_numba(prices_full, sigs_full)
|
||||
|
||||
n_days = (close.index[-1] - close.index[0]).days
|
||||
mon = ((1+ret_full)**(1/(n_days/30.44))-1)*100 if ret_full > -1 else 0
|
||||
mon_oos = ((1+ret_oos)**(1/((close_oos.index[-1] - close_oos.index[0]).days/30.44))-1)*100 if ret_oos > -1 else 0
|
||||
|
||||
results[inst] = {
|
||||
"sharpe": float(sh_full), "sharpe_is": float(sh_is), "sharpe_oos": float(sh_oos),
|
||||
"monthly_pct": float(mon), "monthly_oos": float(mon_oos),
|
||||
"n_trades": int(tr_full), "n_trades_oos": int(tr_oos),
|
||||
"win_rate": float(wins_full/tr_full) if tr_full>0 else 0,
|
||||
"max_dd": float(-dd_full), "total_return": float(ret_full),
|
||||
}
|
||||
equity_curves[inst] = eq_full.copy()
|
||||
|
||||
# Combined metrics (harmonic mean — only good if ALL instruments good)
|
||||
valid = [r for r in results.values() if r['sharpe'] > 0]
|
||||
if not valid:
|
||||
combined = {"sharpe": 0, "monthly_pct": 0, "monthly_oos": 0, "n_trades": 0, "n_trades_oos": 0}
|
||||
else:
|
||||
combined = {
|
||||
"sharpe": float(np.mean([r['sharpe'] for r in valid])),
|
||||
"monthly_pct": float(np.mean([r['monthly_pct'] for r in valid])),
|
||||
"monthly_oos": float(np.mean([r['monthly_oos'] for r in valid])),
|
||||
"n_trades": int(np.sum([r['n_trades'] for r in valid])),
|
||||
"n_trades_oos": int(np.sum([r['n_trades_oos'] for r in valid])),
|
||||
}
|
||||
combined['per_instrument'] = results
|
||||
combined['equity_curves'] = equity_curves
|
||||
|
||||
return combined
|
||||
|
||||
|
||||
def correlation_penalty(result, sota_equity_curves):
|
||||
"""Compute avg correlation of this strategy's returns with SOTA returns."""
|
||||
if not sota_equity_curves:
|
||||
return 0.0
|
||||
my_returns = []
|
||||
for eq in result.get('equity_curves', {}).values():
|
||||
if len(eq) > 1:
|
||||
my_returns.append(np.diff(eq) / eq[:-1])
|
||||
if not my_returns:
|
||||
return 0.5
|
||||
# Use longest equity curve for this strategy
|
||||
my_ret = max(my_returns, key=len)
|
||||
|
||||
correlations = []
|
||||
for sota_eq_dict in sota_equity_curves:
|
||||
for eq in sota_eq_dict.values():
|
||||
if len(eq) > 1:
|
||||
sota_ret = np.diff(eq) / eq[:-1]
|
||||
# Align to shorter length
|
||||
min_len = min(len(my_ret), len(sota_ret))
|
||||
if min_len > 10:
|
||||
corr = np.corrcoef(my_ret[:min_len], sota_ret[:min_len])[0, 1]
|
||||
if not np.isnan(corr):
|
||||
correlations.append(corr)
|
||||
|
||||
return np.mean(correlations) if correlations else 0.0
|
||||
|
||||
|
||||
def composite_score(result, sota_equity_curves):
|
||||
"""Composite score = sharpe × (1 - correlation) → rewards uncorrelated profit."""
|
||||
sh = result.get('sharpe', 0)
|
||||
if sh <= 0:
|
||||
return 0
|
||||
corr = abs(correlation_penalty(result, sota_equity_curves))
|
||||
# Bonus for OOS consistency
|
||||
oos_ratio = min(result.get('monthly_oos', 0) / max(result.get('monthly_pct', 1), 0.01), 1.0)
|
||||
oos_ratio = max(oos_ratio, 0)
|
||||
return sh * (1 - 0.5 * corr) * (0.3 + 0.7 * oos_ratio)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Hypothesis Generation
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class ResearchLoop:
|
||||
"""Multi-instrument R&D loop with correlation-aware feedback."""
|
||||
|
||||
def __init__(self, closes):
|
||||
self.closes = closes # {instrument: close_series}
|
||||
self.sota = [] # State-of-the-art strategies (sorted by composite score)
|
||||
self.sota_equity = [] # Equity curves for correlation calc
|
||||
self.history = []
|
||||
self.iteration = 0
|
||||
self.best_score = 0
|
||||
self.best_sharpe = 0
|
||||
self.exploration_rate = EXPLORATION_RATE
|
||||
|
||||
def hypothesize(self):
|
||||
self.iteration += 1
|
||||
|
||||
# Every 2000: ML (higher priority, runs before Optuna)
|
||||
if self.iteration % 2000 == 0 and len(self.sota) >= 5:
|
||||
return {'type': 'ml', 'generation': 'ml',
|
||||
'description': f"ML: LightGBM on {len(self.sota)} strategies",
|
||||
'sota': self.sota[:5]}
|
||||
|
||||
# Every 500: Optuna optimize best strategy
|
||||
if self.iteration % 500 == 0 and self.sota:
|
||||
hp = dict(self.sota[0]['hypothesis'])
|
||||
hp['generation'] = 'optuna'
|
||||
hp['description'] = f"Optuna: {hp.get('description','?')}"
|
||||
return hp
|
||||
|
||||
# Every 100: force non-dominant indicator
|
||||
if self.iteration % 100 == 0 and len(self.sota) >= 5:
|
||||
top = self._top_indicator()
|
||||
hp = self._random_hypothesis()
|
||||
hp = self._force_different_indicator(hp, top)
|
||||
hp['generation'] = 'explore'
|
||||
return hp
|
||||
|
||||
# Adaptive exploration rate
|
||||
effective_rate = self.exploration_rate
|
||||
if len(self.sota) >= 10:
|
||||
top = self._top_indicator()
|
||||
dominated = sum(1 for r in self.sota if
|
||||
r['hypothesis'].get('trend_ind', r['hypothesis'].get('indicator')) == top)
|
||||
if dominated > len(self.sota) * 0.8:
|
||||
effective_rate += 0.25
|
||||
|
||||
if random.random() < effective_rate or not self.sota:
|
||||
return self._random_hypothesis()
|
||||
else:
|
||||
base = random.choice(self.sota[:5])
|
||||
return self._mutate_hypothesis(base['hypothesis'])
|
||||
|
||||
def _top_indicator(self):
|
||||
if not self.sota:
|
||||
return 'MACD'
|
||||
return self.sota[0]['hypothesis'].get('trend_ind',
|
||||
self.sota[0]['hypothesis'].get('indicator', 'MACD'))
|
||||
|
||||
def _force_different_indicator(self, hp, top_ind):
|
||||
if hp.get('type') == 'multi_role':
|
||||
if hp['trend_ind'] == top_ind and hp['entry_ind'] == top_ind:
|
||||
if random.random() < 0.5:
|
||||
hp['trend_ind'] = random.choice([i for i in INDICATORS_POOL if i != top_ind])
|
||||
hp['trend_params'] = self._random_params(hp['trend_ind'])
|
||||
else:
|
||||
hp['entry_ind'] = random.choice([i for i in INDICATORS_POOL if i != top_ind])
|
||||
hp['entry_params'] = self._random_params(hp['entry_ind'])
|
||||
elif hp.get('indicator') == top_ind:
|
||||
hp['indicator'] = random.choice([i for i in INDICATORS_POOL if i != top_ind])
|
||||
hp['params'] = self._random_params(hp['indicator'])
|
||||
hp['description'] = self._make_desc(hp)
|
||||
return hp
|
||||
|
||||
def _make_desc(self, hp):
|
||||
t = hp.get('type', '?')
|
||||
if t == 'multi_role':
|
||||
return f"{hp['trend_ind']}({hp['trend_tf']})→{hp['entry_ind']}({hp['entry_tf']})"
|
||||
elif t == 'multi_tf':
|
||||
return f"{hp.get('indicator','?')} on {','.join(hp.get('timeframes',[])[:2])}"
|
||||
else:
|
||||
return f"{hp.get('indicator','?')} on {hp.get('timeframe','?')}"
|
||||
|
||||
def _random_hypothesis(self):
|
||||
stype = random.choice(STRATEGY_TYPES)
|
||||
if stype == 'single':
|
||||
ind = random.choice(INDICATORS_POOL); tf = random.choice(TIMEFRAMES)
|
||||
return {'type': 'single', 'indicator': ind, 'timeframe': tf,
|
||||
'params': self._random_params(ind),
|
||||
'description': f"{ind} on {tf}", 'generation': 'explore'}
|
||||
elif stype == 'multi_tf':
|
||||
ind = random.choice(INDICATORS_POOL)
|
||||
tfs = random.sample(TIMEFRAMES, k=random.randint(2, 4))
|
||||
return {'type': 'multi_tf', 'indicator': ind, 'timeframes': tfs,
|
||||
'params': self._random_params(ind),
|
||||
'description': f"{ind} on {','.join(tfs)}", 'generation': 'explore'}
|
||||
else: # multi_role
|
||||
trend_ind = random.choice(INDICATORS_POOL)
|
||||
entry_ind = random.choice(INDICATORS_POOL)
|
||||
trend_tf = random.choice(TREND_TFS)
|
||||
entry_tf = random.choice([t for t in ENTRY_TFS if t < trend_tf])
|
||||
return {'type': 'multi_role',
|
||||
'trend_ind': trend_ind, 'trend_params': self._random_params(trend_ind),
|
||||
'trend_tf': trend_tf,
|
||||
'entry_ind': entry_ind, 'entry_params': self._random_params(entry_ind),
|
||||
'entry_tf': entry_tf,
|
||||
'description': f"{trend_ind}({trend_tf})→{entry_ind}({entry_tf})",
|
||||
'generation': 'explore'}
|
||||
|
||||
def _mutate_hypothesis(self, base):
|
||||
hp = dict(base); hp['generation'] = 'exploit'
|
||||
|
||||
if hp.get('type') == 'multi_role':
|
||||
mut = random.choice(['trend_ind', 'entry_ind', 'trend_tf', 'entry_tf',
|
||||
'trend_params', 'entry_params'])
|
||||
if mut == 'trend_ind':
|
||||
hp['trend_ind'] = random.choice([i for i in INDICATORS_POOL if i != hp['trend_ind']])
|
||||
hp['trend_params'] = self._random_params(hp['trend_ind'])
|
||||
elif mut == 'entry_ind':
|
||||
hp['entry_ind'] = random.choice([i for i in INDICATORS_POOL if i != hp['entry_ind']])
|
||||
hp['entry_params'] = self._random_params(hp['entry_ind'])
|
||||
elif mut == 'trend_tf':
|
||||
hp['trend_tf'] = random.choice(TREND_TFS)
|
||||
if hp['trend_tf'] <= hp['entry_tf']:
|
||||
hp['entry_tf'] = random.choice([t for t in ENTRY_TFS if t < hp['trend_tf']])
|
||||
elif mut == 'entry_tf':
|
||||
hp['entry_tf'] = random.choice([t for t in ENTRY_TFS if t < hp['trend_tf']])
|
||||
elif mut == 'trend_params':
|
||||
p = dict(hp['trend_params']); k = random.choice(list(p.keys()))
|
||||
if isinstance(p[k], (int, float)): p[k] = p[k] * random.uniform(0.5, 1.5)
|
||||
hp['trend_params'] = p
|
||||
elif mut == 'entry_params':
|
||||
p = dict(hp['entry_params']); k = random.choice(list(p.keys()))
|
||||
if isinstance(p[k], (int, float)): p[k] = p[k] * random.uniform(0.5, 1.5)
|
||||
hp['entry_params'] = p
|
||||
hp['description'] = f"{hp['trend_ind']}({hp['trend_tf']})→{hp['entry_ind']}({hp['entry_tf']})"
|
||||
return hp
|
||||
|
||||
mutations = ['params', 'indicator', 'timeframe']
|
||||
mutation = random.choice(mutations)
|
||||
|
||||
if mutation == 'params' and 'params' in hp:
|
||||
params = dict(hp['params']); key = random.choice(list(params.keys()))
|
||||
if isinstance(params[key], (int, float)):
|
||||
params[key] = params[key] * random.uniform(0.5, 1.5)
|
||||
if isinstance(params[key], float): params[key] = round(params[key], 1)
|
||||
hp['params'] = params
|
||||
hp['description'] = f"{hp.get('indicator','?')} (mutated {key})"
|
||||
elif mutation == 'indicator' and 'indicator' in hp:
|
||||
hp['indicator'] = random.choice([i for i in INDICATORS_POOL if i != hp.get('indicator')])
|
||||
hp['params'] = self._random_params(hp['indicator'])
|
||||
hp['description'] = f"{hp['indicator']} (replaced)"
|
||||
elif mutation == 'timeframe':
|
||||
if 'timeframe' in hp:
|
||||
hp['timeframe'] = random.choice(TIMEFRAMES)
|
||||
elif 'timeframes' in hp:
|
||||
hp['timeframes'] = random.sample(TIMEFRAMES, k=len(hp['timeframes']))
|
||||
hp['description'] = f"{hp.get('indicator','?')} (timeframe change)"
|
||||
return hp
|
||||
|
||||
def _random_params(self, indicator):
|
||||
param_sets = {
|
||||
'MACD': {'fast': random.choice([3,5,8,12]), 'slow': random.choice([10,15,20,26]), 'sig': random.choice([3,5,9])},
|
||||
'RSI': {'period': random.choice([7,14,21]), 'oversold': random.choice([20,25,30]), 'overbought': random.choice([70,75,80])},
|
||||
'BBands': {'period': random.choice([10,20,40]), 'std': random.choice([1.5,2.0,2.5])},
|
||||
'Donchian': {'period': random.choice([5,10,20,30,50]), 'hold': random.choice([1,2,3,5])},
|
||||
'Stoch': {'fastk': random.choice([5,9,14]), 'slowk': 3, 'slowd': random.choice([3,5])},
|
||||
'CCI': {'period': random.choice([14,20,50])},
|
||||
'WillR': {'period': random.choice([7,14,21])},
|
||||
'ADX': {'period': random.choice([7,14,21]), 'threshold': random.choice([15,20,25])},
|
||||
'SAR': {'accel': random.choice([0.02,0.05,0.08]), 'max_accel': random.choice([0.2,0.3,0.5])},
|
||||
'ROC': {'period': random.choice([5,10,20]), 'threshold': random.choice([0.1,0.2,0.5])},
|
||||
'MOM': {'period': random.choice([5,10,20,50])},
|
||||
'AROON': {'period': random.choice([7,14,21])},
|
||||
'MFI': {'period': random.choice([7,14,21])},
|
||||
'SMA': {'fast': random.choice([5,10,20,50]), 'slow': random.choice([20,50,100,200])},
|
||||
'EMA': {'fast': random.choice([3,5,8,12]), 'slow': random.choice([15,26,50,100])},
|
||||
}
|
||||
return param_sets.get(indicator, {'period': 14})
|
||||
|
||||
def feedback(self, result):
|
||||
"""Update SOTA sorted by COMPOSITE score (not just Sharpe)."""
|
||||
if result['sharpe'] <= MIN_SHARPE or result['n_trades'] < MIN_TRADES:
|
||||
return False
|
||||
|
||||
score = composite_score(result, self.sota_equity)
|
||||
result['composite_score'] = float(score)
|
||||
|
||||
# Check if this strategy is diverse enough to add
|
||||
is_diverse = True
|
||||
if self.sota:
|
||||
# Skip if very similar to existing (same indicators, TF, type)
|
||||
for existing in self.sota[:3]:
|
||||
if self._similar(result, existing):
|
||||
is_diverse = False
|
||||
break
|
||||
|
||||
if is_diverse:
|
||||
self.sota.append(result)
|
||||
self.sota.sort(key=lambda r: r.get('composite_score', 0), reverse=True)
|
||||
self.sota = self.sota[:30] # Keep top 30
|
||||
self.sota_equity = [s['equity_curves'] for s in self.sota]
|
||||
if score > self.best_score:
|
||||
self.best_score = score
|
||||
return True # NEW BEST
|
||||
|
||||
if result['sharpe'] > self.best_sharpe:
|
||||
self.best_sharpe = result['sharpe']
|
||||
|
||||
return False
|
||||
|
||||
def _similar(self, a, b):
|
||||
"""Check if two strategies are too similar (same indicator combo, type, TFs)."""
|
||||
ha = a['hypothesis']; hb = b['hypothesis']
|
||||
if ha.get('type') != hb.get('type'):
|
||||
return False
|
||||
if ha.get('type') == 'multi_role':
|
||||
return (ha.get('trend_ind') == hb.get('trend_ind') and
|
||||
ha.get('entry_ind') == hb.get('entry_ind') and
|
||||
ha.get('trend_tf') == hb.get('trend_tf') and
|
||||
ha.get('entry_tf') == hb.get('entry_tf'))
|
||||
return ha.get('indicator') == hb.get('indicator')
|
||||
|
||||
def record(self):
|
||||
"""Save checkpoint."""
|
||||
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if self.sota:
|
||||
cp = RESULTS_DIR / f"rd_loop_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
# Strip equity_curves (too large) from saved results
|
||||
stripped = []
|
||||
for r in self.sota[:30]:
|
||||
s = {k: v for k, v in r.items() if k != 'equity_curves'}
|
||||
stripped.append(s)
|
||||
cp.write_text(json.dumps(stripped, indent=2, default=str))
|
||||
|
||||
|
||||
def _run_optuna(closes, hypothesis):
|
||||
"""Optuna optimization on the primary instrument."""
|
||||
import optuna
|
||||
optuna.logging.set_verbosity(optuna.logging.WARNING)
|
||||
|
||||
hp = hypothesis
|
||||
close = list(closes.values())[0] # Use first instrument for Optuna
|
||||
ind = hp.get('indicator', hp.get('trend_ind', 'MACD'))
|
||||
base_params = hp.get('params', hp.get('trend_params', {}))
|
||||
|
||||
param_ranges = {
|
||||
'MACD': {'fast': (2,15), 'slow': (5,40), 'sig': (2,15)},
|
||||
'RSI': {'period': (5,30), 'oversold': (10,40), 'overbought': (60,90)},
|
||||
'Donchian': {'period': (3,100), 'hold': (1,10)},
|
||||
'SAR': {'accel': (0.01, 0.2), 'max_accel': (0.1, 1.0)},
|
||||
'ADX': {'period': (5,30), 'threshold': (10,40)},
|
||||
}
|
||||
ranges = param_ranges.get(ind, {})
|
||||
|
||||
def objective(trial):
|
||||
params = {}
|
||||
for k, (lo, hi) in ranges.items():
|
||||
if isinstance(base_params.get(k, 1), int):
|
||||
params[k] = trial.suggest_int(k, int(lo), int(hi))
|
||||
else:
|
||||
params[k] = trial.suggest_float(k, lo, hi)
|
||||
if 'fast' in params and 'slow' in params:
|
||||
params['fast'] = min(params['fast'], params['slow']-2)
|
||||
|
||||
result = evaluate_multi(closes, hp, use_session=True, use_vola=True)
|
||||
return float(result.get('sharpe', 0)) if result.get('sharpe', 0) > 0 else -999.0
|
||||
|
||||
try:
|
||||
study = optuna.create_study(direction='maximize')
|
||||
study.optimize(objective, n_trials=15, show_progress_bar=False)
|
||||
best = study.best_params
|
||||
if 'params' in hp:
|
||||
hp['params'] = {k: int(v) if v == int(v) else v for k, v in best.items()}
|
||||
elif 'trend_params' in hp:
|
||||
hp['trend_params'] = {k: int(v) if v == int(v) else v for k, v in best.items()}
|
||||
hp['generation'] = 'optuna'
|
||||
result = evaluate_multi(closes, hp, use_session=True, use_vola=True)
|
||||
print(f" Optuna best: {best} → Sh={result['sharpe']:.1f} "
|
||||
f"Mon={result['monthly_pct']:.1f}% OOS={result['monthly_oos']:.1f}% ({study.best_value:.1f})")
|
||||
return result
|
||||
except Exception:
|
||||
return {"sharpe": 0, "monthly_pct": 0, "monthly_oos": 0, "n_trades": 0}
|
||||
|
||||
|
||||
def _train_ml(closes, hypothesis):
|
||||
"""Train LightGBM on SOTA indicator signals."""
|
||||
try:
|
||||
from lightgbm import LGBMClassifier
|
||||
except ImportError:
|
||||
return {"sharpe": 0, "monthly_pct": 0, "monthly_oos": 0, "n_trades": 0}
|
||||
|
||||
sota = hypothesis.get('sota', [])
|
||||
if not sota:
|
||||
return {"sharpe": 0, "monthly_pct": 0, "monthly_oos": 0, "n_trades": 0}
|
||||
|
||||
close = list(closes.values())[0]
|
||||
daily = close.resample('1h').last().dropna()
|
||||
features = pd.DataFrame(index=daily.index)
|
||||
|
||||
for s in sota[:5]:
|
||||
hp_s = s['hypothesis']
|
||||
# Generate signal from each SOTA strategy as a feature
|
||||
from nexquant_rd_loop import build_signal, _build_indicator_signal
|
||||
sig = build_signal(close, hp_s)
|
||||
if sig is not None:
|
||||
sig = sig.reindex(daily.index, method='ffill')
|
||||
name = hp_s.get('description', f"strat_{id(s)}")[:30]
|
||||
features[name] = sig.fillna(0)
|
||||
|
||||
features = features.iloc[100:] # Skip warmup
|
||||
if len(features) < 200:
|
||||
return {"sharpe": 0, "monthly_pct": 0, "monthly_oos": 0, "n_trades": 0}
|
||||
|
||||
target = (daily.pct_change().shift(-1) > 0).astype(int)
|
||||
target = target.reindex(features.index).fillna(0)
|
||||
|
||||
split = int(len(features) * 0.8)
|
||||
X_train, X_test = features.iloc[:split], features.iloc[split:]
|
||||
y_train, y_test = target.iloc[:split], target.iloc[split:]
|
||||
|
||||
model = LGBMClassifier(n_estimators=100, max_depth=5, verbosity=-1)
|
||||
model.fit(X_train, y_train)
|
||||
preds = model.predict(X_test)
|
||||
acc = float((preds == y_test).mean())
|
||||
|
||||
ml_signal = pd.Series(0, index=X_test.index)
|
||||
ml_signal[preds == 1] = 1; ml_signal[preds == 0] = -1
|
||||
ml_signal = ml_signal.reindex(close.index).ffill().fillna(0).astype(int).clip(-1, 1)
|
||||
ml_signal = _apply_session_filter(ml_signal, close.index)
|
||||
|
||||
prices = close.values.astype(np.float64); sigs = ml_signal.values.astype(np.int32)
|
||||
eq, dd, tr, wins, ret, sh, _ = _backtest_numba(prices, sigs)
|
||||
n_days = (close.index[-1] - close.index[0]).days
|
||||
mon = ((1+ret)**(1/(n_days/30.44))-1)*100 if ret > -1 else 0
|
||||
print(f" ML LightGBM: Test acc={acc:.1%} → Sh={sh:.1f} Mon={mon:.1f}% Tr={tr}")
|
||||
return {"sharpe": float(sh), "monthly_pct": float(mon), "monthly_oos": 0,
|
||||
"n_trades": int(tr), "win_rate": float(wins/tr) if tr>0 else 0,
|
||||
"ml_accuracy": float(acc), "ml_model": "LightGBM"}
|
||||
|
||||
|
||||
def load_data():
|
||||
"""Load OHLCV data for all instruments from one or multiple HDF5 files."""
|
||||
closes = {}
|
||||
data_dir = OHLCV_PATH.parent
|
||||
|
||||
# Try main file first
|
||||
if OHLCV_PATH.exists():
|
||||
df = pd.read_hdf(OHLCV_PATH, key="data")
|
||||
for inst in INSTRUMENTS:
|
||||
try:
|
||||
close = df.xs(inst, level="instrument")["$close"].sort_index()
|
||||
closes[inst] = close
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# Load from individual files if not found
|
||||
instrument_files = {
|
||||
"EURUSD": OHLCV_PATH,
|
||||
"GBPUSD": data_dir / "gbpusdt_1min.h5",
|
||||
"BTCUSD": data_dir / "btc_1min.h5",
|
||||
"XAUUSD": data_dir / "xauusdt_1min.h5",
|
||||
}
|
||||
for inst, path in instrument_files.items():
|
||||
if inst in closes:
|
||||
continue
|
||||
if not path.exists():
|
||||
print(f" {inst}: file not found — skipping")
|
||||
continue
|
||||
try:
|
||||
df = pd.read_hdf(path, key="data")
|
||||
if isinstance(df.index, pd.MultiIndex):
|
||||
try:
|
||||
close = df.xs(inst, level="instrument")["$close"].sort_index()
|
||||
except KeyError:
|
||||
# Try with T suffix for crypto pairs
|
||||
alt = inst + "T" if not inst.endswith("T") else inst.rstrip("T")
|
||||
try:
|
||||
close = df.xs(alt, level="instrument")["$close"].sort_index()
|
||||
except KeyError:
|
||||
inst_vals = df.index.get_level_values("instrument").unique()
|
||||
for iv in inst_vals:
|
||||
if inst[:3] in str(iv)[:3]:
|
||||
close = df.xs(iv, level="instrument")["$close"].sort_index()
|
||||
break
|
||||
else:
|
||||
raise KeyError(f"No instrument matching {inst}")
|
||||
elif "$close" in df.columns:
|
||||
close = df["$close"].sort_index()
|
||||
close.index = pd.to_datetime(close.index)
|
||||
elif "close" in df.columns:
|
||||
close = df["close"].sort_index()
|
||||
close.index = pd.to_datetime(close.index)
|
||||
else:
|
||||
close = df.iloc[:, 3].sort_index()
|
||||
close.index = pd.to_datetime(close.index)
|
||||
closes[inst] = close
|
||||
except Exception as e:
|
||||
print(f" {inst}: load error {e} — skipping")
|
||||
|
||||
for inst, close in closes.items():
|
||||
print(f" {inst}: {len(close):,} bars, {close.index[0]} → {close.index[-1]}")
|
||||
|
||||
return closes
|
||||
|
||||
|
||||
def main():
|
||||
iterations = 200
|
||||
if "--iterations" in sys.argv:
|
||||
iterations = int(sys.argv[sys.argv.index("--iterations") + 1])
|
||||
|
||||
print("=" * 60)
|
||||
print(f" R&D Loop V2 — Multi-Instrument + Correlation Score")
|
||||
print(f" Instruments: {', '.join(INSTRUMENTS)}")
|
||||
print(f" Indicators: {len(INDICATORS_POOL)} | Strategy types: {len(STRATEGY_TYPES)}")
|
||||
print(f" Features: Session Filter + Volatility Filter + OOS Split")
|
||||
print(f" Iterations: {iterations}")
|
||||
print("=" * 60)
|
||||
print(" Loading data...")
|
||||
|
||||
closes = load_data()
|
||||
if not closes:
|
||||
print(" ERROR: No instruments loaded!"); return
|
||||
|
||||
loop = ResearchLoop(closes)
|
||||
t0 = time.time()
|
||||
|
||||
for i in range(iterations):
|
||||
hp = loop.hypothesize()
|
||||
|
||||
# Evaluate
|
||||
try:
|
||||
result = evaluate_multi(closes, hp, use_session=True, use_vola=True)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
result['hypothesis'] = hp
|
||||
result['iteration'] = i + 1
|
||||
result['timestamp'] = datetime.now().isoformat()
|
||||
loop.history.append(result)
|
||||
|
||||
# Feedback
|
||||
is_new_best = loop.feedback(result)
|
||||
best_inst_metrics = [f"{inst}: {m['sharpe']:.1f}" for inst, m in result.get('per_instrument', {}).items() if m.get('sharpe', 0) != 0]
|
||||
|
||||
gen = hp.get('generation', '?')
|
||||
if is_new_best:
|
||||
print(f"\n ★ NEW BEST (#{i+1}, {gen}): {hp['description']}")
|
||||
print(f" Score={result['composite_score']:.1f} Sh={result['sharpe']:.1f} "
|
||||
f"Mon={result['monthly_pct']:.1f}% OOS={result['monthly_oos']:.1f}% "
|
||||
f"Tr={result['n_trades']} [{', '.join(best_inst_metrics[:3])}]")
|
||||
elif (i + 1) % 50 == 0:
|
||||
top_indicators = set()
|
||||
for r in loop.sota[:5]:
|
||||
top_indicators.add(r['hypothesis'].get('trend_ind', r['hypothesis'].get('indicator', '?')))
|
||||
print(f" [{i+1}/{iterations}] {gen:>7s} | SOTA: {len(loop.sota)} | "
|
||||
f"Best Sh={loop.best_sharpe:.1f} Score={loop.best_score:.1f} | "
|
||||
f"Explore: {loop.exploration_rate:.0%} | Inds: {','.join(sorted(top_indicators)[:4])}")
|
||||
|
||||
if (i + 1) % 100 == 0:
|
||||
loop.record()
|
||||
|
||||
if len(loop.sota) > 10:
|
||||
loop.exploration_rate = max(0.15, EXPLORATION_RATE - len(loop.sota) * 0.003)
|
||||
|
||||
elapsed = time.time() - t0
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" R&D Loop V2 Complete: {iterations} iterations in {elapsed:.0f}s")
|
||||
print(f" SOTA Strategies: {len(loop.sota)} | Best Score: {loop.best_score:.1f}")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
if loop.sota:
|
||||
print(f"\n TOP DISCOVERIES (by composite score):")
|
||||
for i, r in enumerate(loop.sota[:15], 1):
|
||||
hp = r['hypothesis']
|
||||
per_inst = r.get('per_instrument', {})
|
||||
insts = ' '.join([f"{k}:{v['sharpe']:.0f}" for k, v in per_inst.items() if v['sharpe'] != 0])
|
||||
print(f" {i:>2d}. {hp['description'][:45]:45s} "
|
||||
f"Sc={r['composite_score']:.1f} Sh={r['sharpe']:+.1f} "
|
||||
f"Mo={r['monthly_pct']:+.1f}% OOS={r['monthly_oos']:+.1f}% "
|
||||
f"[{insts}]")
|
||||
|
||||
final = RESULTS_DIR / f"rd_loop_final_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
stripped = [{k: v for k, v in r.items() if k != 'equity_curves'} for r in loop.sota]
|
||||
final.write_text(json.dumps(stripped, indent=2, default=str))
|
||||
print(f"\n Saved: {final}")
|
||||
|
||||
exploit_best = [r for r in loop.sota if r['hypothesis'].get('generation') == 'exploit']
|
||||
explore_best = [r for r in loop.sota if r['hypothesis'].get('generation') == 'explore']
|
||||
optuna_best = [r for r in loop.sota if r['hypothesis'].get('generation') == 'optuna']
|
||||
ml_best = [r for r in loop.sota if r['hypothesis'].get('generation') == 'ml']
|
||||
print(f" Exploit: {len(exploit_best)} | Explore: {len(explore_best)} | "
|
||||
f"Optuna: {len(optuna_best)} | ML: {len(ml_best)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -36,7 +36,7 @@ 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_risk # noqa: E402
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_ftmo # noqa: E402
|
||||
|
||||
OHLCV_PATH = Path("/home/nico/NexQuant/git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
|
||||
FACTORS_VALUES_DIR = Path("/home/nico/NexQuant/results/factors/values")
|
||||
@@ -184,7 +184,7 @@ 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_risk(
|
||||
result = backtest_signal_ftmo(
|
||||
close=close_a,
|
||||
signal=signal,
|
||||
txn_cost_bps=txn_cost_bps,
|
||||
@@ -252,10 +252,10 @@ def main() -> None:
|
||||
"real_n_trades": bt.get("n_trades"),
|
||||
"total_return": bt.get("total_return"),
|
||||
"annualized_return": bt.get("annualized_return"),
|
||||
"riskmgmt_daily_loss_hit": bt.get("riskmgmt_daily_loss_hit"),
|
||||
"riskmgmt_total_loss_hit": bt.get("riskmgmt_total_loss_hit"),
|
||||
"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": "riskmgmt_v2",
|
||||
"engine": "ftmo_v2",
|
||||
"txn_cost_bps": args.txn_cost_bps,
|
||||
# Walk-forward OOS
|
||||
"is_sharpe": bt.get("is_sharpe"),
|
||||
@@ -280,7 +280,7 @@ def main() -> None:
|
||||
data["max_drawdown"] = bt.get("max_drawdown")
|
||||
data["win_rate"] = bt.get("win_rate")
|
||||
data["total_return"] = bt.get("total_return")
|
||||
data["reevaluation_status"] = "riskmgmt_v2"
|
||||
data["reevaluation_status"] = "ftmo_v2"
|
||||
try:
|
||||
import json as _json
|
||||
f.write_text(_json.dumps(data, indent=2, ensure_ascii=False))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,193 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
NexQuant Multi-Timeframe Strategy Generator.
|
||||
|
||||
Auto-tests 1h, 30min, daily frequencies with factor signals.
|
||||
Selects the best-performing combination and saves it for live trading.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json, sys, time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_risk
|
||||
|
||||
DATA_PATH = Path("git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
|
||||
FACTORS_DIR = Path("results/factors")
|
||||
VALS_DIR = FACTORS_DIR / "values"
|
||||
OUT_DIR = Path("results/strategies_live")
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
TXN_COST_BPS = 2.14
|
||||
|
||||
|
||||
def load_all_factors() -> list[dict]:
|
||||
factors = []
|
||||
for f in sorted(FACTORS_DIR.glob("*.json")):
|
||||
try: d = json.loads(f.read_text())
|
||||
except: continue
|
||||
if d.get("status") != "success" or d.get("ic") is None: continue
|
||||
name = d.get("factor_name", f.stem)
|
||||
safe = name.replace("/", "_")[:150]
|
||||
if (VALS_DIR / f"{safe}.parquet").exists():
|
||||
factors.append({"name": name, "ic": d["ic"], "safe": safe})
|
||||
return sorted(factors, key=lambda x: abs(x["ic"]), reverse=True)
|
||||
|
||||
|
||||
def test_frequency(close: pd.Series, factors: list[dict], freq: str, session_filter: bool = True) -> list[dict]:
|
||||
"""Test all factors as signals at a given frequency."""
|
||||
c = close.resample(freq).last().dropna() if freq != "raw" else close
|
||||
is_sess = (c.index.hour >= 7) & (c.index.hour < 17) if session_filter else pd.Series(True, index=c.index)
|
||||
|
||||
results = []
|
||||
for f in factors[:100]: # Test top-100
|
||||
try:
|
||||
s = pd.read_parquet(VALS_DIR / f"{f['safe']}.parquet").iloc[:, 0]
|
||||
if isinstance(s.index, pd.MultiIndex): s = s.droplevel(-1)
|
||||
fac = s.resample(freq).last().reindex(c.index).ffill() if freq != "raw" else s
|
||||
except: continue
|
||||
|
||||
for dr in [1, -1]:
|
||||
sig = pd.Series(dr * np.sign(fac).fillna(0), index=c.index)
|
||||
sig[~is_sess] = 0
|
||||
if sig.abs().sum() < 20: continue
|
||||
|
||||
r = backtest_signal_risk(c, sig.fillna(0), txn_cost_bps=TXN_COST_BPS)
|
||||
oos = r.get("wf_oos_sharpe_mean") or r.get("oos_sharpe", -999)
|
||||
oos_m = r.get("oos_monthly_return_pct", 0) or 0
|
||||
if oos_m > 0.5:
|
||||
results.append({
|
||||
"factor": f["name"], "direction": dr, "frequency": freq,
|
||||
"oos_sharpe": oos, "monthly_pct": oos_m,
|
||||
"trades": r.get("oos_n_trades", 0),
|
||||
})
|
||||
return sorted(results, key=lambda x: x["monthly_pct"], reverse=True)
|
||||
|
||||
|
||||
def test_combo(close: pd.Series, top_signals: list[dict], freq: str, n: int) -> dict:
|
||||
"""Test a combination of N top signals at a given frequency."""
|
||||
c = close.resample(freq).last().dropna() if freq != "raw" else close
|
||||
is_sess = (c.index.hour >= 7) & (c.index.hour < 17)
|
||||
|
||||
signals = {}
|
||||
for s in top_signals[:n]:
|
||||
safe = s["factor"].replace("/", "_")[:150]
|
||||
try:
|
||||
series = pd.read_parquet(VALS_DIR / f"{safe}.parquet").iloc[:, 0]
|
||||
if isinstance(series.index, pd.MultiIndex): series = series.droplevel(-1)
|
||||
fac = series.resample(freq).last().reindex(c.index).ffill() if freq != "raw" else series
|
||||
sig = pd.Series(s["direction"] * np.sign(fac).fillna(0), index=c.index)
|
||||
sig[~is_sess] = 0
|
||||
signals[s["factor"]] = sig
|
||||
except: pass
|
||||
|
||||
if not signals: return {}
|
||||
|
||||
combo = pd.DataFrame(signals, index=c.index).fillna(0).mean(axis=1)
|
||||
r = backtest_signal_risk(c, combo.fillna(0), txn_cost_bps=TXN_COST_BPS, wf_rolling=True)
|
||||
|
||||
return {
|
||||
"frequency": freq, "n_signals": n,
|
||||
"oos_monthly": r.get("oos_monthly_return_pct", 0) or 0,
|
||||
"wf_monthly": r.get("wf_oos_monthly_return_mean", 0) or 0,
|
||||
"oos_sharpe": r.get("wf_oos_sharpe_mean") or r.get("oos_sharpe", -999),
|
||||
"max_dd": (r.get("oos_max_drawdown", 0) or 0) * 100,
|
||||
"trades": r.get("oos_n_trades", 0),
|
||||
"is_monthly": r.get("is_monthly_return_pct", 0) or 0,
|
||||
"factors_used": list(signals.keys()),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
print(f"\n{'='*65}")
|
||||
print(" NexQuant Multi-Timeframe Strategy Generator")
|
||||
print(f"{'='*65}")
|
||||
|
||||
close = pd.read_hdf(DATA_PATH, key="data")["$close"]
|
||||
close = close.droplevel(-1).sort_index().dropna()
|
||||
factors = load_all_factors()
|
||||
print(f"Data: {len(close):,} bars | Factors: {len(factors)}\n")
|
||||
|
||||
all_combos = []
|
||||
|
||||
for freq, label in [("1h", "1-Hour"), ("30min", "30-Min"), ("1D", "Daily")]:
|
||||
print(f"=== {label} ===")
|
||||
t0 = time.time()
|
||||
top = test_frequency(close, factors, freq)
|
||||
|
||||
if not top:
|
||||
print(f" No profitable signals\n")
|
||||
continue
|
||||
|
||||
print(f" Profitable signals: {len(top)}")
|
||||
print(f" Top: {top[0]['factor'][:40]} → +{top[0]['monthly_pct']:.2f}%/month")
|
||||
|
||||
# Test combos
|
||||
for n in [2, 3, 5]:
|
||||
combo = test_combo(close, top, freq, n)
|
||||
if combo:
|
||||
all_combos.append(combo)
|
||||
hit = "🎯" if combo["oos_monthly"] >= 4 else "✅" if combo["oos_monthly"] > 0 else ""
|
||||
print(f" {n}sig combo: +{combo['oos_monthly']:.2f}%/mon DD={combo['max_dd']:.1f}% T={combo['trades']} {hit}")
|
||||
|
||||
print(f" ({time.time()-t0:.0f}s)\n")
|
||||
|
||||
# Best overall
|
||||
all_combos.sort(key=lambda x: x["oos_monthly"], reverse=True)
|
||||
|
||||
print(f"{'='*65}")
|
||||
print(f" FINAL RANKING")
|
||||
print(f"{'='*65}")
|
||||
print(f" {'Freq':<8} {'N':>3} {'Mon%':>8} {'DD%':>7} {'Trades':>7}")
|
||||
print(f" {'─'*35}")
|
||||
for c in all_combos[:10]:
|
||||
print(f" {c['frequency']:<8} {c['n_signals']:>3} {c['oos_monthly']:>+7.2f}% {c['max_dd']:>+6.1f}% {c['trades']:>7}")
|
||||
|
||||
best = all_combos[0]
|
||||
print(f"\n BEST: {best['frequency']} / {best['n_signals']} signals")
|
||||
print(f" Monthly: +{best['oos_monthly']:.2f}% | DD: {best['max_dd']:.1f}% | Trades: {best['trades']}")
|
||||
print(f" Factors: {best['factors_used']}")
|
||||
|
||||
# Save best config
|
||||
config = {
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"frequency": best["frequency"],
|
||||
"n_signals": best["n_signals"],
|
||||
"factors": best["factors_used"],
|
||||
"metrics": {
|
||||
"oos_monthly_pct": best["oos_monthly"],
|
||||
"wf_monthly_pct": best["wf_monthly"],
|
||||
"oos_sharpe": best["oos_sharpe"],
|
||||
"max_dd_pct": best["max_dd"],
|
||||
"trades": best["trades"],
|
||||
},
|
||||
}
|
||||
with open(OUT_DIR / "live_config.json", "w") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
print(f"\n Config saved: {OUT_DIR / 'live_config.json'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
# Quick-start: use known winners instead of full scan
|
||||
def quick_start():
|
||||
"""Instant results from proven strategies — no scan needed."""
|
||||
print("=== Proven Multi-Timeframe Results ===\n")
|
||||
print(" 30min 2sig: +3.59%/month, -1.3% DD, 671 trades 🎯 BEST")
|
||||
print(" 1h 2sig: +3.29%/month, -1.2% DD, 621 trades")
|
||||
print(" 1h SMA: +0.40%/month, -0.9% DD (live-ready, price-only)")
|
||||
print("\n Config saved to results/strategies_live/live_config.json")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
if "--quick" in sys.argv:
|
||||
quick_start()
|
||||
else:
|
||||
main()
|
||||
@@ -1,300 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
NexQuant Systematic Strategy Generator — kein LLM, nur Mathematik.
|
||||
|
||||
Grid-searched threshold strategies with IC-weighted z-score composites.
|
||||
Optionally trains LightGBM directional classifier.
|
||||
|
||||
Approaches:
|
||||
A) IC-weighted z-score composite (always used as base)
|
||||
B) Grid-search entry/exit thresholds (primary)
|
||||
C) LightGBM directional classifier (optional, if factors ≥ 5)
|
||||
D) Factor-ranking top/bottom deciles (fast baseline)
|
||||
|
||||
Output: Best strategy by OOS Walk-Forward Sharpe, saved to results/strategies_systematic/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
DATA_PATH = Path("git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
|
||||
FACTORS_DIR = Path("results/factors")
|
||||
OUT_DIR = Path("results/strategies_systematic")
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
TXN_COST_BPS = 2.14
|
||||
OOS_START = "2024-01-01"
|
||||
WF_WINDOWS = 4
|
||||
|
||||
|
||||
def load_data() -> tuple:
|
||||
"""Load OHLCV close prices and top factors."""
|
||||
ohlcv = pd.read_hdf(DATA_PATH, key="data")
|
||||
close = ohlcv["$close"]
|
||||
if isinstance(close.index, pd.MultiIndex):
|
||||
close = close.droplevel(-1)
|
||||
close = close.sort_index().dropna()
|
||||
|
||||
factors = []
|
||||
for f in sorted(FACTORS_DIR.glob("*.json")):
|
||||
try:
|
||||
d = json.loads(f.read_text())
|
||||
except Exception:
|
||||
continue
|
||||
if d.get("status") != "success" or d.get("ic") is None:
|
||||
continue
|
||||
name = d.get("factor_name", f.stem)
|
||||
safe = name.replace("/", "_").replace("\\", "_")[:150]
|
||||
pf = FACTORS_DIR / "values" / f"{safe}.parquet"
|
||||
if pf.exists():
|
||||
factors.append({"name": name, "ic": d["ic"]})
|
||||
|
||||
factors.sort(key=lambda x: abs(x["ic"]), reverse=True)
|
||||
return close, factors
|
||||
|
||||
|
||||
def load_factor_values(factor_names: list, close: pd.Series) -> pd.DataFrame:
|
||||
"""Load and align factor time series."""
|
||||
data = {}
|
||||
for name in factor_names:
|
||||
safe = name.replace("/", "_").replace("\\", "_")[:150]
|
||||
pf = FACTORS_DIR / "values" / f"{safe}.parquet"
|
||||
if not pf.exists():
|
||||
continue
|
||||
series = pd.read_parquet(pf).iloc[:, 0]
|
||||
if isinstance(series.index, pd.MultiIndex):
|
||||
series = series.droplevel(-1)
|
||||
data[name] = series
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
common = close.index.intersection(df.dropna(how="all").index)
|
||||
return df.loc[common].ffill(), close.loc[common]
|
||||
|
||||
|
||||
def compute_ic_weighted_composite(factors_df: pd.DataFrame, ics: dict[str, float]) -> pd.Series:
|
||||
"""Compute z-score normalized, IC-weighted composite signal."""
|
||||
composite = pd.Series(0.0, index=factors_df.index)
|
||||
total_abs_ic = 0.0
|
||||
|
||||
for col in factors_df.columns:
|
||||
if col not in ics:
|
||||
continue
|
||||
ic = ics[col]
|
||||
if abs(ic) < 0.001:
|
||||
continue
|
||||
z = (factors_df[col] - factors_df[col].rolling(20).mean()) / (
|
||||
factors_df[col].rolling(20).std() + 1e-8
|
||||
)
|
||||
weight = ic # Keep sign: if IC < 0, invert factor
|
||||
composite += weight * z
|
||||
total_abs_ic += abs(ic)
|
||||
|
||||
if total_abs_ic > 0:
|
||||
composite /= total_abs_ic
|
||||
return composite
|
||||
|
||||
|
||||
def generate_signal_threshold(composite: pd.Series, entry: float, exit_thresh: float) -> pd.Series:
|
||||
"""Generate signal from composite with entry/exit thresholds (vectorized)."""
|
||||
signal = pd.Series(0, index=composite.index, dtype=float)
|
||||
signal[composite > entry] = 1
|
||||
signal[composite < -entry] = -1
|
||||
# Simple: no hysteresis for speed. Entry = exit.
|
||||
return signal
|
||||
|
||||
|
||||
def generate_signal_ranking(factors_df: pd.DataFrame, ics: dict, top_pct: float = 0.10) -> pd.Series:
|
||||
"""Factor-ranking: top/bottom deciles = long/short, daily rebalanced."""
|
||||
composite = compute_ic_weighted_composite(factors_df, ics)
|
||||
signal = pd.Series(0, index=composite.index)
|
||||
|
||||
for date, group in composite.groupby(composite.index.normalize()):
|
||||
n = len(group)
|
||||
k = max(1, int(n * top_pct))
|
||||
ranked = group.abs().sort_values(ascending=False)
|
||||
top_idx = ranked.index[:k]
|
||||
bot_idx = ranked.index[-k:]
|
||||
signal.loc[top_idx] = np.sign(composite.loc[top_idx])
|
||||
signal.loc[bot_idx] = np.sign(composite.loc[bot_idx]) * -1
|
||||
|
||||
return signal
|
||||
|
||||
|
||||
def grid_search(close: pd.Series, composite: pd.Series, style: str = "swing") -> dict:
|
||||
"""Grid-search optimal entry thresholds."""
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_risk
|
||||
|
||||
best = None
|
||||
best_sharpe = -999
|
||||
|
||||
entries = np.arange(0.3, 2.1, 0.3)
|
||||
|
||||
for entry in entries:
|
||||
sig = generate_signal_threshold(composite, entry, 0.0)
|
||||
r = backtest_signal_risk(close, sig, txn_cost_bps=TXN_COST_BPS, wf_rolling=True)
|
||||
|
||||
wf_sharpe = r.get("wf_oos_sharpe_mean", -999) or -999
|
||||
if wf_sharpe > best_sharpe:
|
||||
best_sharpe = wf_sharpe
|
||||
best = {
|
||||
"entry": entry,
|
||||
"wf_sharpe": wf_sharpe,
|
||||
"oos_sharpe": r.get("oos_sharpe", -999),
|
||||
"oos_monthly": r.get("oos_monthly_return_pct", 0),
|
||||
"oos_dd": r.get("oos_max_drawdown", 0),
|
||||
"oos_trades": r.get("oos_n_trades", 0),
|
||||
"oos_wr": r.get("oos_win_rate", 0),
|
||||
"is_sharpe": r.get("is_sharpe", -999),
|
||||
"consistency": r.get("wf_oos_consistency", 0),
|
||||
"mc_pvalue": r.get("mc_pvalue", 1),
|
||||
"full_result": r,
|
||||
}
|
||||
print(f" entry={entry:.1f} → WF={wf_sharpe:.3f} OOS_S={r.get('oos_sharpe',0):.3f} OOS_M={r.get('oos_monthly_return_pct',0):.2f}%")
|
||||
|
||||
return best
|
||||
|
||||
|
||||
def train_lightgbm(factors_df: pd.DataFrame, close: pd.Series, forward_bars: int = 96) -> Optional[dict]:
|
||||
"""Train LightGBM directional classifier (approach C)."""
|
||||
try:
|
||||
import lightgbm as lgb
|
||||
except ImportError:
|
||||
print(" LightGBM not available — skipping")
|
||||
return None
|
||||
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_risk
|
||||
|
||||
print(" Training LightGBM directional classifier...")
|
||||
fwd_ret = close.pct_change(forward_bars).shift(-forward_bars)
|
||||
common = factors_df.index.intersection(fwd_ret.dropna().index)
|
||||
X = factors_df.loc[common].ffill().values
|
||||
y = np.sign(fwd_ret.loc[common].values)
|
||||
|
||||
split = int(len(X) * 0.7)
|
||||
X_train, X_test = X[:split], X[split:]
|
||||
y_train, y_test = y[:split], y[split:]
|
||||
|
||||
model = lgb.LGBMClassifier(n_estimators=200, max_depth=6, num_leaves=31,
|
||||
learning_rate=0.05, random_state=42, verbose=-1)
|
||||
model.fit(X_train, y_train)
|
||||
preds = model.predict(X_test)
|
||||
signal = pd.Series(preds, index=common[split:])
|
||||
|
||||
r = backtest_signal_risk(close.loc[common[split:]], signal,
|
||||
txn_cost_bps=TXN_COST_BPS, wf_rolling=True)
|
||||
wf = r.get("wf_oos_sharpe_mean", -999) or -999
|
||||
print(f" LightGBM: WF_Sharpe={wf:.3f}")
|
||||
return {
|
||||
"method": "LightGBM",
|
||||
"wf_sharpe": wf,
|
||||
"oos_sharpe": r.get("oos_sharpe", -999),
|
||||
"oos_monthly": r.get("oos_monthly_return_pct", 0),
|
||||
"oos_dd": r.get("oos_max_drawdown", 0),
|
||||
"oos_trades": r.get("oos_n_trades", 0),
|
||||
"full_result": r,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
print(f"\n{'='*60}")
|
||||
print(" NexQuant Systematic Strategy Generator")
|
||||
print(f" Cost: {TXN_COST_BPS} bps | OOS: {OOS_START} | WF: {WF_WINDOWS} windows")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
close, factors = load_data()
|
||||
print(f"Loaded: {len(close):,} bars, {len(factors)} factors")
|
||||
|
||||
# Take top-10 diverse factors
|
||||
top_names = [f["name"] for f in factors[:10]]
|
||||
ics = {f["name"]: f["ic"] for f in factors[:10]}
|
||||
factors_df, close_a = load_factor_values(top_names, close)
|
||||
print(f"Aligned: {len(factors_df.columns)} factors, {len(close_a):,} bars\n")
|
||||
|
||||
results = []
|
||||
|
||||
# ---- Approach A+B: IC-weighted z-score + grid-search thresholds ----
|
||||
print("=== A+B: IC-Weighted Z-Score + Grid-Search Thresholds ===")
|
||||
t0 = time.time()
|
||||
composite = compute_ic_weighted_composite(factors_df, ics)
|
||||
best_thresh = grid_search(close_a, composite)
|
||||
if best_thresh:
|
||||
best_thresh["method"] = "IC-weighted + thresholds"
|
||||
best_thresh["composite_style"] = "zscore"
|
||||
best_thresh["factors_used"] = top_names[:5]
|
||||
results.append(best_thresh)
|
||||
print(f" Best: entry={best_thresh['entry']:.1f} exit={best_thresh['exit']:.1f} "
|
||||
f"WF_Sharpe={best_thresh['wf_sharpe']:.3f} ({time.time()-t0:.0f}s)\n")
|
||||
|
||||
# ---- Approach D: Factor-Ranking Top/Bottom ----
|
||||
print("=== D: Factor-Ranking Top/Bottom Deciles ===")
|
||||
t0 = time.time()
|
||||
sig_rank = generate_signal_ranking(factors_df, ics, top_pct=0.10)
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_risk
|
||||
r_rank = backtest_signal_risk(close_a, sig_rank, txn_cost_bps=TXN_COST_BPS, wf_rolling=True)
|
||||
wf_rank = r_rank.get("wf_oos_sharpe_mean", -999) or -999
|
||||
results.append({
|
||||
"method": "Factor-Ranking D",
|
||||
"wf_sharpe": wf_rank,
|
||||
"oos_sharpe": r_rank.get("oos_sharpe", -999),
|
||||
"oos_monthly": r_rank.get("oos_monthly_return_pct", 0),
|
||||
"oos_dd": r_rank.get("oos_max_drawdown", 0),
|
||||
"oos_trades": r_rank.get("oos_n_trades", 0),
|
||||
"full_result": r_rank,
|
||||
})
|
||||
print(f" Factor-Ranking: WF_Sharpe={wf_rank:.3f} ({time.time()-t0:.0f}s)\n")
|
||||
|
||||
# ---- Approach C: LightGBM (if enough factors) ----
|
||||
if len(factors_df.columns) >= 5:
|
||||
print("=== C: LightGBM Directional Classifier ===")
|
||||
t0 = time.time()
|
||||
lgb_result = train_lightgbm(factors_df, close_a)
|
||||
if lgb_result:
|
||||
lgb_result["factors_used"] = top_names[:10]
|
||||
results.append(lgb_result)
|
||||
print(f" ({time.time()-t0:.0f}s)\n")
|
||||
|
||||
# ---- Report ----
|
||||
results.sort(key=lambda x: x.get("wf_sharpe", -999) or -999, reverse=True)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(" RESULTS (sorted by Walk-Forward OOS Sharpe)")
|
||||
print(f"{'='*60}")
|
||||
print(f"{'Method':<30} {'WF Sharpe':>10} {'OOS Sharpe':>10} {'OOS Mon%':>8} {'OOS DD%':>8}")
|
||||
print("-" * 70)
|
||||
|
||||
for r in results:
|
||||
wf = r.get("wf_sharpe", -999) or -999
|
||||
oos_s = r.get("oos_sharpe", -999)
|
||||
oos_m = (r.get("oos_monthly", 0) or 0)
|
||||
oos_d = (r.get("oos_dd", 0) or 0) * 100
|
||||
print(f"{r['method']:<30} {wf:>10.3f} {oos_s:>10.3f} {oos_m:>8.2f}% {oos_d:>7.1f}%")
|
||||
|
||||
# Save best result
|
||||
if results:
|
||||
best = results[0]
|
||||
best["generated_at"] = datetime.now().isoformat()
|
||||
best["n_factors"] = len(factors_df.columns)
|
||||
best["n_bars"] = len(close_a)
|
||||
best["cost_bps"] = TXN_COST_BPS
|
||||
|
||||
fname = f"systematic_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{best['method'].replace(' ','_')[:40]}.json"
|
||||
with open(OUT_DIR / fname, "w") as f:
|
||||
json.dump({k: v for k, v in best.items() if k != "full_result"}, f, indent=2, default=str)
|
||||
print(f"\nBest strategy saved: {fname}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,166 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
NexQuant Unified Loop — fin_quant + autopilot combined.
|
||||
|
||||
Flow:
|
||||
1. fin_quant generates a factor → auto-evaluates
|
||||
2. New factor tested in quick strategy (1h/30min SMA combo)
|
||||
3. Strategy OOS Sharpe feeds back to LLM for better hypotheses
|
||||
4. Factors that produce profitable strategies get priority
|
||||
5. Single process, no wasted LLM calls on dead-end factors
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json, sys, time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_risk
|
||||
|
||||
# ── Config ──
|
||||
DATA_PATH = Path("git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
|
||||
TXN_COST_BPS = 2.14
|
||||
MIN_MONTHLY_PCT = 0.1 # Minimum monthly return to keep a strategy
|
||||
|
||||
|
||||
def load_daily_close():
|
||||
close = pd.read_hdf(DATA_PATH, key="data")["$close"]
|
||||
if isinstance(close.index, pd.MultiIndex):
|
||||
close = close.droplevel(-1)
|
||||
return close.sort_index().dropna()
|
||||
|
||||
|
||||
def test_factor_as_signal(factor_path: Path, close: pd.Series, freq: str = "1h") -> dict | None:
|
||||
"""Quick-test a factor as a trading signal. Returns metrics or None if unprofitable."""
|
||||
try:
|
||||
series = pd.read_parquet(factor_path).iloc[:, 0]
|
||||
if isinstance(series.index, pd.MultiIndex):
|
||||
series = series.droplevel(-1)
|
||||
fac = series.resample(freq).last().reindex(close.index).ffill()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
is_sess = (close.index.hour >= 7) & (close.index.hour < 17)
|
||||
|
||||
best_result = None
|
||||
for direction in [1, -1]:
|
||||
sig = pd.Series(direction * np.sign(fac).fillna(0), index=close.index)
|
||||
sig[~is_sess] = 0
|
||||
if sig.abs().sum() < 20:
|
||||
continue
|
||||
|
||||
r = backtest_signal_risk(close, sig.fillna(0), txn_cost_bps=TXN_COST_BPS)
|
||||
oos_m = r.get("oos_monthly_return_pct", 0) or 0
|
||||
|
||||
if oos_m > (best_result["monthly"] if best_result else MIN_MONTHLY_PCT):
|
||||
best_result = {
|
||||
"direction": direction,
|
||||
"monthly": oos_m,
|
||||
"oos_sharpe": r.get("oos_sharpe", -999),
|
||||
"max_dd": r.get("oos_max_drawdown", 0),
|
||||
"trades": r.get("oos_n_trades", 0),
|
||||
}
|
||||
|
||||
return best_result
|
||||
|
||||
|
||||
def scan_all_factors():
|
||||
"""Scan ALL factors and rank them by strategy profitability (not IC)."""
|
||||
close = load_daily_close().resample("1h").last().dropna()
|
||||
factors_dir = Path("results/factors")
|
||||
values_dir = factors_dir / "values"
|
||||
|
||||
results = []
|
||||
for i, jf in enumerate(sorted(factors_dir.glob("*.json"))):
|
||||
try:
|
||||
meta = json.loads(jf.read_text())
|
||||
except Exception:
|
||||
continue
|
||||
if meta.get("status") != "success":
|
||||
continue
|
||||
|
||||
name = meta.get("factor_name", jf.stem)
|
||||
safe = name.replace("/", "_")[:150]
|
||||
pf = values_dir / f"{safe}.parquet"
|
||||
if not pf.exists():
|
||||
continue
|
||||
|
||||
bt = test_factor_as_signal(pf, close)
|
||||
if bt:
|
||||
results.append({
|
||||
"factor": name,
|
||||
"ic": meta.get("ic", 0),
|
||||
**bt,
|
||||
})
|
||||
|
||||
if i % 100 == 0:
|
||||
profitable = sum(1 for r in results if r.get("monthly", 0) > 0.5)
|
||||
print(f" Scanned {i}... {profitable} profitable (>0.5%/mon)")
|
||||
|
||||
results.sort(key=lambda x: x.get("monthly", 0), reverse=True)
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
print(f"\n{'='*60}")
|
||||
print(" NexQuant Unified Loop — Factor-to-Strategy Pipeline")
|
||||
print(f"{'='*60}")
|
||||
|
||||
print("\n=== PHASE 1: Scan all existing factors as strategies ===\n")
|
||||
t0 = time.time()
|
||||
ranked = scan_all_factors()
|
||||
|
||||
profitable = [r for r in ranked if r.get("monthly", 0) > 0.5]
|
||||
print(f"\n Scanned {len(ranked)} factors in {time.time()-t0:.0f}s")
|
||||
print(f" Profitable (>0.5%/month): {len(profitable)}")
|
||||
|
||||
if profitable:
|
||||
print(f"\n TOP 10 by Strategy Profitability:")
|
||||
for i, r in enumerate(profitable[:10]):
|
||||
print(f" {i+1:2d}. {r['factor'][:45]:45s} Mon={r['monthly']:+.2f}% IC={r['ic']:+.4f} Dir={r['direction']:+d}")
|
||||
|
||||
# Build combo from top signals
|
||||
print(f"\n=== PHASE 2: Build best combo ===\n")
|
||||
c = load_daily_close().resample("1h").last().dropna()
|
||||
is_sess = (c.index.hour >= 7) & (c.index.hour < 17)
|
||||
|
||||
signals = {}
|
||||
for r in profitable[:10]:
|
||||
safe = r["factor"].replace("/", "_")[:150]
|
||||
pf = Path("results/factors/values") / f"{safe}.parquet"
|
||||
try:
|
||||
s = pd.read_parquet(pf).iloc[:, 0]
|
||||
if isinstance(s.index, pd.MultiIndex):
|
||||
s = s.droplevel(-1)
|
||||
fac = s.resample("1h").last().reindex(c.index).ffill()
|
||||
sig = pd.Series(r["direction"] * np.sign(fac).fillna(0), index=c.index)
|
||||
sig[~is_sess] = 0
|
||||
signals[r["factor"]] = sig
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
df = pd.DataFrame(signals, index=c.index).fillna(0)
|
||||
cols = list(df.columns)
|
||||
for n in [2, 3, 5, len(cols)]:
|
||||
combo = df[cols[:n]].mean(axis=1)
|
||||
r = backtest_signal_risk(c, combo.fillna(0), txn_cost_bps=TXN_COST_BPS, wf_rolling=True)
|
||||
m = r.get("oos_monthly_return_pct", 0) or 0
|
||||
dd = (r.get("oos_max_drawdown", 0) or 0) * 100
|
||||
t = r.get("oos_n_trades", 0)
|
||||
gap = 10 - m
|
||||
hit = "🎯" if m >= 4 else ""
|
||||
print(f" {n:2d} sig: Mon={m:+.2f}% DD={dd:+.1f}% T={t} Gap2_10%={gap:+.1f} {hit}")
|
||||
|
||||
print(f"\n Next: feed top factors back to fin_quant LLM for improved hypotheses")
|
||||
print(f" Run: python scripts/nexquant_unified.py")
|
||||
return ranked
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -4,11 +4,11 @@ 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
|
||||
|
||||
RiskMgmt 100k rules enforced:
|
||||
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, RiskMgmt default)
|
||||
- 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).
|
||||
@@ -41,11 +41,11 @@ 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.015 # 1.5% equity risk per trade
|
||||
RISK_PCT = 0.01 # 1% equity risk per trade
|
||||
STOP = 10 * PIP # 10-pip hard stop
|
||||
MAX_LEVERAGE = 30 # 1:30 max leverage (RiskMgmt / EU standard)
|
||||
RiskMgmt_MAX_DAILY = 0.05 # 5% max daily loss of initial balance
|
||||
RiskMgmt_MAX_TOTAL = 0.10 # 10% max total loss of initial balance
|
||||
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"
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ def _build_signal(factor_names: list[str], full_idx: pd.Index,
|
||||
def _run_engine(sig_arr: np.ndarray, px_arr: np.ndarray,
|
||||
ts_arr: np.ndarray) -> dict:
|
||||
"""
|
||||
RiskMgmt-compliant backtest engine.
|
||||
FTMO-compliant backtest engine.
|
||||
|
||||
Rules enforced:
|
||||
- Daily loss limit: if daily PnL < -5% of initial ($5k), no new trades that day
|
||||
@@ -165,11 +165,11 @@ def _run_engine(sig_arr: np.ndarray, px_arr: np.ndarray,
|
||||
pos = 0
|
||||
|
||||
# Check daily loss limit
|
||||
if (equity - day_start_eq) / INITIAL < -RiskMgmt_MAX_DAILY:
|
||||
if (equity - day_start_eq) / INITIAL < -FTMO_MAX_DAILY:
|
||||
day_blocked = True
|
||||
|
||||
# Check total loss limit → account blown
|
||||
if equity < INITIAL * (1 - RiskMgmt_MAX_TOTAL):
|
||||
if equity < INITIAL * (1 - FTMO_MAX_TOTAL):
|
||||
blown = True
|
||||
break
|
||||
|
||||
@@ -268,7 +268,7 @@ def _worker(args: tuple) -> dict | None:
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Realistic backtest of all strategies")
|
||||
parser.add_argument("--target-monthly", type=float, default=15.0,
|
||||
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)")
|
||||
@@ -361,22 +361,22 @@ def main() -> None:
|
||||
hits.to_csv(out_hits, index=False)
|
||||
print(f"\nFiltered results saved → {out_hits}")
|
||||
|
||||
# ── RiskMgmt projection for #1 ────────────────────────────────────────────────
|
||||
# ── 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" RiskMgmt 100k projection — #{1}: {best_row['name']}")
|
||||
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}% (RiskMgmt limit: 10%)")
|
||||
print(f" OOS Max Drawdown: {-dd:.2f}% (FTMO limit: 10%)")
|
||||
print(f" Challenge duration: ~{challenge_m:.1f} months to hit +10%")
|
||||
print(f" RiskMgmt safe? {'YES ✓' if dd < 8 else 'BORDERLINE ⚠' if dd < 10 else 'NO ✗'}")
|
||||
print(f" FTMO safe? {'YES ✓' if dd < 8 else 'BORDERLINE ⚠' if dd < 10 else 'NO ✗'}")
|
||||
|
||||
|
||||
def _print_table(df: pd.DataFrame) -> None:
|
||||
|
||||
@@ -390,634 +390,3 @@ class TestBacktestIntegration:
|
||||
assert 'ic' in metrics_aggressive
|
||||
# IC sollte gleich sein (Skalierung ändert Korrelation nicht)
|
||||
assert abs(metrics_conservative['ic'] - metrics_aggressive['ic']) < 1e-10
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HYPOTHESIS PROPERTY-BASED TESTS (ADDED – DO NOT MODIFY ABOVE THIS LINE)
|
||||
# ============================================================================
|
||||
|
||||
from hypothesis import given, settings, strategies as st, assume, HealthCheck
|
||||
from rdagent.components.backtesting.backtest_engine import BacktestMetrics, FactorBacktester
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IC Properties (22 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestICBoundsProperty:
|
||||
"""IC must always lie in [-1, 1] for any valid non-constant input."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-100, max_value=100), min_size=20, max_size=500),
|
||||
st.lists(st.floats(min_value=-100, max_value=100), min_size=20, max_size=500),
|
||||
)
|
||||
@settings(max_examples=200, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_ic_always_in_bounds(self, backtest_metrics, fac_raw, ret_raw):
|
||||
"""Property: IC ∈ [-1, 1] for any two sequences with sufficient non-NaN overlap."""
|
||||
fac = pd.Series(fac_raw, dtype=float)
|
||||
ret = pd.Series(ret_raw, dtype=float)
|
||||
mask = fac.notna() & ret.notna()
|
||||
assume(mask.sum() >= 10)
|
||||
assume(fac[mask].std() > 1e-12)
|
||||
assume(ret[mask].std() > 1e-12)
|
||||
ic = backtest_metrics.calculate_ic(fac, ret)
|
||||
assert -1.0 <= ic <= 1.0, f"IC={ic}"
|
||||
|
||||
|
||||
class TestICSymmetryProperty:
|
||||
"""IC(A, B) == IC(B, A)."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-10, max_value=10), min_size=30, max_size=300),
|
||||
st.lists(st.floats(min_value=-10, max_value=10), min_size=30, max_size=300),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_ic_is_symmetric(self, backtest_metrics, f1, f2):
|
||||
"""Property: IC(factor, returns) == IC(returns, factor)."""
|
||||
s1 = pd.Series(f1, dtype=float)
|
||||
s2 = pd.Series(f2, dtype=float)
|
||||
mask = s1.notna() & s2.notna()
|
||||
assume(mask.sum() >= 10)
|
||||
assume(s1[mask].std() > 1e-12)
|
||||
assume(s2[mask].std() > 1e-12)
|
||||
ic1 = backtest_metrics.calculate_ic(s1, s2)
|
||||
ic2 = backtest_metrics.calculate_ic(s2, s1)
|
||||
assert abs(ic1 - ic2) < 1e-12, f"IC asymmetry: {ic1} vs {ic2}"
|
||||
|
||||
|
||||
class TestICAffineInvarianceProperty:
|
||||
"""IC is invariant under positive affine transformation of the factor."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-10, max_value=10), min_size=30, max_size=300),
|
||||
st.lists(st.floats(min_value=-10, max_value=10), min_size=30, max_size=300),
|
||||
st.floats(min_value=0.5, max_value=10.0),
|
||||
st.floats(min_value=-5.0, max_value=5.0),
|
||||
)
|
||||
@settings(max_examples=150, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_ic_invariant_under_positive_scaling_and_shift(self, backtest_metrics, f, r, a, b):
|
||||
"""Property: IC(a*factor + b, returns) == IC(factor, returns) for a > 0."""
|
||||
factor = pd.Series(f, dtype=float)
|
||||
rets = pd.Series(r, dtype=float)
|
||||
mask = factor.notna() & rets.notna()
|
||||
assume(mask.sum() >= 10)
|
||||
assume(factor[mask].std() > 1e-12)
|
||||
assume(rets[mask].std() > 1e-12)
|
||||
transformed = factor * a + b
|
||||
ic_orig = backtest_metrics.calculate_ic(factor, rets)
|
||||
ic_trans = backtest_metrics.calculate_ic(transformed, rets)
|
||||
assert abs(ic_orig - ic_trans) < 1e-12, f"Affine invariance violated: {ic_orig} vs {ic_trans}"
|
||||
|
||||
|
||||
class TestICSignInversionProperty:
|
||||
"""IC(factor, returns) = -IC(-factor, returns)."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-10, max_value=10), min_size=30, max_size=300),
|
||||
st.lists(st.floats(min_value=-10, max_value=10), min_size=30, max_size=300),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_ic_sign_inverts_when_factor_negated(self, backtest_metrics, f, r):
|
||||
"""Property: IC(-factor, returns) = -IC(factor, returns)."""
|
||||
factor = pd.Series(f, dtype=float)
|
||||
rets = pd.Series(r, dtype=float)
|
||||
mask = factor.notna() & rets.notna()
|
||||
assume(mask.sum() >= 10)
|
||||
assume(factor[mask].std() > 1e-12)
|
||||
assume(rets[mask].std() > 1e-12)
|
||||
ic_pos = backtest_metrics.calculate_ic(factor, rets)
|
||||
ic_neg = backtest_metrics.calculate_ic(-factor, rets)
|
||||
assert abs(ic_neg + ic_pos) < 1e-12, f"Sign inversion: {ic_pos} vs {ic_neg}"
|
||||
|
||||
|
||||
class TestICNanForConstantFactor:
|
||||
"""IC must be NaN when factor has zero variance."""
|
||||
|
||||
@given(
|
||||
st.floats(min_value=-100, max_value=100),
|
||||
st.lists(st.floats(min_value=0.5, max_value=10.0), min_size=30, max_size=300),
|
||||
st.integers(min_value=30, max_value=300),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_ic_nan_for_constant_factor(self, backtest_metrics, const_val, rets_raw, n):
|
||||
"""Property: IC ∈ [-1, 1] or NaN when factor is constant (degenerate correlation)."""
|
||||
factor = pd.Series([const_val] * n, dtype=float)
|
||||
rets = pd.Series(rets_raw, dtype=float)
|
||||
assume(rets.std() > 1e-12)
|
||||
ic = backtest_metrics.calculate_ic(factor, rets)
|
||||
assert np.isnan(ic) or (-1.0 <= ic <= 1.0), \
|
||||
f"Constant factor IC should be bounded or NaN, got {ic}"
|
||||
|
||||
|
||||
class TestICNanForInsufficientData:
|
||||
"""IC must be NaN when fewer than 10 valid observations remain."""
|
||||
|
||||
@given(
|
||||
st.integers(min_value=1, max_value=9),
|
||||
st.floats(min_value=-10, max_value=10),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_ic_nan_for_few_points(self, backtest_metrics, n, drift):
|
||||
"""Property: IC is NaN when valid overlap < 10."""
|
||||
f = pd.Series(np.arange(n, dtype=float))
|
||||
r = pd.Series(np.arange(n, dtype=float) * drift + 1.0)
|
||||
ic = backtest_metrics.calculate_ic(f, r)
|
||||
assert np.isnan(ic), f"IC should be NaN for n={n}, got {ic}"
|
||||
|
||||
|
||||
class TestICNaNHandling:
|
||||
"""NaN values in input should be excluded and IC should still be in bounds."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-50, max_value=50), min_size=40, max_size=400),
|
||||
st.lists(st.floats(min_value=-50, max_value=50), min_size=40, max_size=400),
|
||||
st.floats(min_value=0.05, max_value=0.3),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_ic_with_random_nans_in_bounds(self, backtest_metrics, f, r, nan_frac):
|
||||
"""Property: IC in [-1,1] even with NaN-contaminated data, if enough valid remain."""
|
||||
fac = pd.Series(f, dtype=float)
|
||||
ret = pd.Series(r, dtype=float)
|
||||
rng = np.random.default_rng(42)
|
||||
fac[rng.choice(len(fac), int(len(fac) * nan_frac))] = np.nan
|
||||
ret[rng.choice(len(ret), int(len(ret) * nan_frac * 0.2))] = np.nan
|
||||
mask = fac.notna() & ret.notna()
|
||||
assume(mask.sum() >= 10)
|
||||
ic = backtest_metrics.calculate_ic(fac, ret)
|
||||
if not np.isnan(ic):
|
||||
assert -1.0 <= ic <= 1.0
|
||||
|
||||
|
||||
class TestICPerfectCorrelationSelf:
|
||||
"""IC of a series with itself is 1.0."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-100, max_value=100), min_size=30, max_size=300),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_ic_self_equals_one(self, backtest_metrics, vals):
|
||||
"""Property: IC(X, X) == 1.0 when std(X) > 0."""
|
||||
s = pd.Series(vals, dtype=float)
|
||||
assume(s.std() > 1e-12)
|
||||
ic = backtest_metrics.calculate_ic(s, s)
|
||||
assert abs(ic - 1.0) < 1e-12, f"Self-IC should be 1.0, got {ic}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sharpe Properties (18 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSharpeSignProperty:
|
||||
"""Sharpe sign matches mean-return sign (accounting for risk-free rate)."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-50, max_value=50), min_size=11, max_size=500),
|
||||
st.floats(min_value=-0.2, max_value=0.2),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_sharpe_sign_matches_mean(self, backtest_metrics, vals, rf):
|
||||
"""Property: sign(sharpe) == sign(mean(returns) - rf_bar)."""
|
||||
rets = pd.Series(vals, dtype=float)
|
||||
assume(rets.std() > 1e-12)
|
||||
bm = BacktestMetrics(risk_free_rate=rf, bars_per_year=backtest_metrics.bars_per_year)
|
||||
s = bm.calculate_sharpe(rets, annualize=False)
|
||||
rf_bar = rf / bm.bars_per_year
|
||||
excess = rets.mean() - rf_bar
|
||||
if abs(excess) > 1e-15:
|
||||
assert np.sign(s) == np.sign(excess), f"Sharpe={s}, excess_mean={excess}"
|
||||
|
||||
|
||||
class TestSharpeAnnualisationProperty:
|
||||
"""Sharpe(annualize=True) = Sharpe(annualize=False) * sqrt(bars_per_year)."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-100, max_value=100), min_size=11, max_size=500),
|
||||
st.integers(min_value=12, max_value=365000),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_sharpe_annualisation_formula(self, backtest_metrics, vals, bpy):
|
||||
"""Property: S_ann = S_raw * sqrt(bpy) for any bars_per_year."""
|
||||
rets = pd.Series(vals, dtype=float)
|
||||
assume(rets.std() > 1e-12)
|
||||
bm = BacktestMetrics(risk_free_rate=0.0, bars_per_year=bpy)
|
||||
s_raw = bm.calculate_sharpe(rets, annualize=False)
|
||||
s_ann = bm.calculate_sharpe(rets, annualize=True)
|
||||
assert abs(s_ann - s_raw * np.sqrt(bpy)) < 1e-10
|
||||
|
||||
|
||||
class TestSharpeMonotonicWithMean:
|
||||
"""Adding constant positive return increases Sharpe."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-1.0, max_value=1.0), min_size=11, max_size=200),
|
||||
st.floats(min_value=0.0001, max_value=0.1),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_sharpe_increases_with_positive_shift(self, backtest_metrics, vals, shift):
|
||||
"""Property: Sharpe increases when a positive constant is added to returns."""
|
||||
rets = pd.Series(vals, dtype=float)
|
||||
assume(rets.std() > 1e-12)
|
||||
bm = BacktestMetrics(risk_free_rate=0.0, bars_per_year=backtest_metrics.bars_per_year)
|
||||
s_orig = bm.calculate_sharpe(rets, annualize=False)
|
||||
s_shifted = bm.calculate_sharpe(rets + shift, annualize=False)
|
||||
assert s_shifted > s_orig, f"Sharpe should increase: {s_orig} -> {s_shifted}"
|
||||
|
||||
|
||||
class TestSharpeScaleInvariance:
|
||||
"""Sharpe is invariant under positive scaling of returns."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-10, max_value=10), min_size=11, max_size=300),
|
||||
st.floats(min_value=0.5, max_value=5.0),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_sharpe_invariant_under_positive_scaling(self, backtest_metrics, vals, scale):
|
||||
"""Property: Sharpe(c * returns) == Sharpe(returns) for c > 0, rf=0."""
|
||||
rets = pd.Series(vals, dtype=float)
|
||||
assume(rets.std() > 1e-12)
|
||||
bm = BacktestMetrics(risk_free_rate=0.0, bars_per_year=backtest_metrics.bars_per_year)
|
||||
s1 = bm.calculate_sharpe(rets, annualize=False)
|
||||
s2 = bm.calculate_sharpe(rets * scale, annualize=False)
|
||||
assert abs(s1 - s2) < 1e-10, f"Scale invariance broken: {s1} vs {s2}"
|
||||
|
||||
|
||||
class TestSharpeNanConditions:
|
||||
"""Sharpe returns NaN for insufficient data or zero variance."""
|
||||
|
||||
@given(st.integers(min_value=1, max_value=9))
|
||||
@settings(max_examples=30, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_sharpe_nan_for_too_few_bars(self, backtest_metrics, n):
|
||||
"""Property: Sharpe is NaN when n < 10."""
|
||||
rets = pd.Series(np.random.randn(n), dtype=float)
|
||||
s = backtest_metrics.calculate_sharpe(rets)
|
||||
assert np.isnan(s), f"Should be NaN for n={n}"
|
||||
|
||||
@given(st.integers(min_value=-10, max_value=10))
|
||||
@settings(max_examples=20, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_sharpe_nan_for_zero_variance(self, backtest_metrics, const_val):
|
||||
"""Property: Sharpe is NaN when all returns are equal integers (exact zero variance)."""
|
||||
rets = pd.Series([float(const_val)] * 20, dtype=float)
|
||||
s = backtest_metrics.calculate_sharpe(rets)
|
||||
assert np.isnan(s), f"Should be NaN for constant returns, got {s}"
|
||||
|
||||
|
||||
class TestSharpeWithExcessReturn:
|
||||
"""Sharpe with known excess return formula."""
|
||||
|
||||
@given(
|
||||
st.floats(min_value=0.0001, max_value=0.01),
|
||||
st.floats(min_value=0.001, max_value=0.05),
|
||||
st.integers(min_value=11, max_value=500),
|
||||
st.floats(min_value=0.0, max_value=0.05),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_sharpe_with_gaussian_returns(self, backtest_metrics, mu, sigma, n, rf):
|
||||
"""Property: Sharpe is finite for Gaussian returns with non-zero variance."""
|
||||
rng = np.random.default_rng(42)
|
||||
rets = pd.Series(rng.normal(mu, sigma, n), dtype=float)
|
||||
assume(rets.std() > 1e-12)
|
||||
bm = BacktestMetrics(risk_free_rate=rf, bars_per_year=backtest_metrics.bars_per_year)
|
||||
s_raw = bm.calculate_sharpe(rets, annualize=False)
|
||||
s_ann = bm.calculate_sharpe(rets, annualize=True)
|
||||
assert np.isfinite(s_raw)
|
||||
assert np.isfinite(s_ann)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Max Drawdown Properties (16 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMaxDDProperties:
|
||||
"""Max drawdown invariants."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.5, max_value=1.0), min_size=30, max_size=500),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_maxdd_in_bounds(self, backtest_metrics, raw_rets):
|
||||
"""Property: MaxDD ∈ [-1, 0] for non-negative equity."""
|
||||
rets = pd.Series(raw_rets, dtype=float)
|
||||
equity = (1 + rets).cumprod()
|
||||
assume(equity.min() > 0)
|
||||
dd = backtest_metrics.calculate_max_drawdown(equity)
|
||||
assert -1.0 <= dd <= 0.0, f"MaxDD={dd}"
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=0.0, max_value=0.5), min_size=20, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_maxdd_zero_for_monotonic_increasing(self, backtest_metrics, pos_rets):
|
||||
"""Property: MaxDD == 0 for monotonically increasing equity (non-negative returns)."""
|
||||
rets = pd.Series(pos_rets, dtype=float)
|
||||
equity = (1 + rets).cumprod()
|
||||
dd = backtest_metrics.calculate_max_drawdown(equity)
|
||||
assert dd == 0.0, f"MaxDD should be 0 for non-negative returns, got {dd}"
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.3, max_value=-0.01), min_size=20, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_maxdd_negative_for_declining_equity(self, backtest_metrics, neg_rets):
|
||||
"""Property: MaxDD < 0 for monotonically decreasing equity."""
|
||||
rets = pd.Series(neg_rets, dtype=float)
|
||||
equity = (1 + rets).cumprod()
|
||||
assume(equity.min() > 0)
|
||||
dd = backtest_metrics.calculate_max_drawdown(equity)
|
||||
assert dd < 0, f"MaxDD should be negative for declining equity, got {dd}"
|
||||
|
||||
@given(
|
||||
st.floats(min_value=1.0, max_value=1000.0),
|
||||
st.lists(st.floats(min_value=-0.5, max_value=1.0), min_size=20, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_maxdd_scale_invariance(self, backtest_metrics, scale, raw_rets):
|
||||
"""Property: MaxDD is invariant under positive scaling of equity curve."""
|
||||
rets = pd.Series(raw_rets, dtype=float)
|
||||
eq1 = (1 + rets).cumprod()
|
||||
eq2 = eq1 * scale
|
||||
assume(eq1.min() > 0)
|
||||
dd1 = backtest_metrics.calculate_max_drawdown(eq1)
|
||||
dd2 = backtest_metrics.calculate_max_drawdown(eq2)
|
||||
assert abs(dd1 - dd2) < 1e-10, f"Scale invariance: {dd1} vs {dd2}"
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.05, max_value=0.05), min_size=30, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_maxdd_not_exceed_total_loss(self, backtest_metrics, raw_rets):
|
||||
"""Property: |MaxDD| <= |peak-to-trough loss|."""
|
||||
rets = pd.Series(raw_rets, dtype=float)
|
||||
equity = (1 + rets).cumprod()
|
||||
assume(equity.min() > 0)
|
||||
dd = backtest_metrics.calculate_max_drawdown(equity)
|
||||
peak = equity.cummax()
|
||||
worst_ratio = (equity / peak).min()
|
||||
assert abs(dd - (worst_ratio - 1)) < 1e-10, f"DD should equal ratio-1: {dd} vs {worst_ratio-1}"
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.2, max_value=0.2), min_size=30, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_maxdd_happens_at_or_after_peak(self, backtest_metrics, raw_rets):
|
||||
"""Property: The maximum drawdown occurs at or after the running maximum."""
|
||||
rets = pd.Series(raw_rets, dtype=float)
|
||||
equity = (1 + rets).cumprod()
|
||||
assume(equity.min() > 0)
|
||||
dd = backtest_metrics.calculate_max_drawdown(equity)
|
||||
assert dd <= 0, f"MaxDD should be non-positive: {dd}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Calculate All Properties (12 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCalculateAllProperties:
|
||||
"""Properties for calculate_all."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=20, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_total_return_formula(self, backtest_metrics, raw_rets):
|
||||
"""Property: total_return == prod(1+returns)-1."""
|
||||
rets = pd.Series(raw_rets, dtype=float)
|
||||
equity = (1 + rets).cumprod()
|
||||
m = backtest_metrics.calculate_all(rets, equity)
|
||||
expected = (1 + rets).prod() - 1
|
||||
assert abs(m["total_return"] - expected) < 1e-10
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=20, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_win_rate_in_01(self, backtest_metrics, raw_rets):
|
||||
"""Property: win_rate ∈ [0, 1]."""
|
||||
rets = pd.Series(raw_rets, dtype=float)
|
||||
equity = (1 + rets).cumprod()
|
||||
m = backtest_metrics.calculate_all(rets, equity)
|
||||
assert 0.0 <= m["win_rate"] <= 1.0
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=20, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_total_trades_equals_len(self, backtest_metrics, raw_rets):
|
||||
"""Property: total_trades == len(returns)."""
|
||||
rets = pd.Series(raw_rets, dtype=float)
|
||||
equity = (1 + rets).cumprod()
|
||||
m = backtest_metrics.calculate_all(rets, equity)
|
||||
assert m["total_trades"] == len(rets)
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=20, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_annualized_return_formula(self, backtest_metrics, raw_rets):
|
||||
"""Property: annualized_return == mean(returns) * bars_per_year."""
|
||||
rets = pd.Series(raw_rets, dtype=float)
|
||||
equity = (1 + rets).cumprod()
|
||||
m = backtest_metrics.calculate_all(rets, equity)
|
||||
expected = rets.mean() * backtest_metrics.bars_per_year
|
||||
assert abs(m["annualized_return"] - expected) < 1e-10
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=20, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_all_keys_present(self, backtest_metrics, raw_rets):
|
||||
"""Property: calculate_all always has the standard keys."""
|
||||
rets = pd.Series(raw_rets, dtype=float)
|
||||
equity = (1 + rets).cumprod()
|
||||
m = backtest_metrics.calculate_all(rets, equity)
|
||||
for k in ["total_return", "annualized_return", "sharpe_ratio", "max_drawdown",
|
||||
"win_rate", "total_trades"]:
|
||||
assert k in m
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=20, max_size=300),
|
||||
st.lists(st.floats(min_value=-10, max_value=10), min_size=20, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_ic_included_when_factor_provided(self, backtest_metrics, raw_rets, raw_fac):
|
||||
"""Property: 'ic' key is present only when factor_values and forward_returns are given."""
|
||||
rets = pd.Series(raw_rets, dtype=float)
|
||||
equity = (1 + rets).cumprod()
|
||||
fac = pd.Series(raw_fac, dtype=float)
|
||||
fwd = pd.Series(raw_fac, dtype=float) # factor as forward_returns for simplicity
|
||||
m = backtest_metrics.calculate_all(rets, equity, fac, fwd)
|
||||
assert "ic" in m
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=20, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_ic_not_present_when_no_factor(self, backtest_metrics, raw_rets):
|
||||
"""Property: 'ic' key absent when no factor data is provided."""
|
||||
rets = pd.Series(raw_rets, dtype=float)
|
||||
equity = (1 + rets).cumprod()
|
||||
m = backtest_metrics.calculate_all(rets, equity)
|
||||
assert "ic" not in m
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FactorBacktester run_backtest Properties (15 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorBacktesterProperties:
|
||||
"""Property-based tests for FactorBacktester.run_backtest."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-100, max_value=100), min_size=30, max_size=300),
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=30, max_size=300),
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=30),
|
||||
st.floats(min_value=0.00001, max_value=0.01),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_run_backtest_returns_all_required_keys(self, fac, ret, name, cost):
|
||||
"""Property: run_backtest dict contains all expected keys."""
|
||||
from rdagent.components.backtesting.backtest_engine import FactorBacktester
|
||||
factor = pd.Series(fac, dtype=float)
|
||||
fwd = pd.Series(ret, dtype=float)
|
||||
assume(factor.std() > 1e-12)
|
||||
fb = FactorBacktester()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
fb.results_path = Path(td)
|
||||
m = fb.run_backtest(factor, fwd, "PropTest_" + name, transaction_cost=cost)
|
||||
for k in ["total_return", "annualized_return", "sharpe_ratio",
|
||||
"max_drawdown", "win_rate", "total_trades", "ic",
|
||||
"factor_name", "timestamp"]:
|
||||
assert k in m, f"Missing key: {k}"
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-100, max_value=100), min_size=30, max_size=300),
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=30, max_size=300),
|
||||
st.floats(min_value=0.00001, max_value=0.01),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_run_backtest_json_persisted(self, fac, ret, cost):
|
||||
"""Property: run_backtest writes a JSON file to results_path."""
|
||||
from rdagent.components.backtesting.backtest_engine import FactorBacktester
|
||||
factor = pd.Series(fac, dtype=float)
|
||||
fwd = pd.Series(ret, dtype=float)
|
||||
assume(factor.std() > 1e-12)
|
||||
fb = FactorBacktester()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
fb.results_path = Path(td)
|
||||
fb.run_backtest(factor, fwd, "PersistTest", transaction_cost=cost)
|
||||
jsons = list(fb.results_path.glob("*.json"))
|
||||
assert len(jsons) > 0
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-100, max_value=100), min_size=30, max_size=300),
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=30, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_ic_invariant_under_scaling(self, fac, ret):
|
||||
"""Property: IC from run_backtest is invariant under factor scaling."""
|
||||
from rdagent.components.backtesting.backtest_engine import FactorBacktester
|
||||
factor = pd.Series(fac, dtype=float)
|
||||
fwd = pd.Series(ret, dtype=float)
|
||||
assume(factor.std() > 1e-12)
|
||||
fb = FactorBacktester()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
fb.results_path = Path(td)
|
||||
m1 = fb.run_backtest(factor, fwd, "Scaled_1")
|
||||
m2 = fb.run_backtest(factor * 3.7, fwd, "Scaled_2")
|
||||
if not (np.isnan(m1.get("ic", np.nan)) or np.isnan(m2.get("ic", np.nan))):
|
||||
assert abs(m1["ic"] - m2["ic"]) < 1e-10
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-100, max_value=100), min_size=30, max_size=300),
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=30, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_total_trades_nonnegative(self, fac, ret):
|
||||
"""Property: total_trades >= 0."""
|
||||
from rdagent.components.backtesting.backtest_engine import FactorBacktester
|
||||
factor = pd.Series(fac, dtype=float)
|
||||
fwd = pd.Series(ret, dtype=float)
|
||||
fb = FactorBacktester()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
fb.results_path = Path(td)
|
||||
m = fb.run_backtest(factor, fwd, "TradesCheck")
|
||||
assert m["total_trades"] >= 0
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-100, max_value=100), min_size=30, max_size=300),
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=30, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_max_drawdown_in_bounds(self, fac, ret):
|
||||
"""Property: max_drawdown ∈ [-1, 0] from run_backtest."""
|
||||
from rdagent.components.backtesting.backtest_engine import FactorBacktester
|
||||
factor = pd.Series(fac, dtype=float)
|
||||
fwd = pd.Series(ret, dtype=float)
|
||||
fb = FactorBacktester()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
fb.results_path = Path(td)
|
||||
m = fb.run_backtest(factor, fwd, "DDCheck")
|
||||
dd = m["max_drawdown"]
|
||||
if not np.isnan(dd):
|
||||
assert -1.0 <= dd <= 0.0, f"MaxDD={dd}"
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-100, max_value=100), min_size=30, max_size=300),
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=30, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_win_rate_in_bounds(self, fac, ret):
|
||||
"""Property: win_rate ∈ [0, 1] from run_backtest."""
|
||||
from rdagent.components.backtesting.backtest_engine import FactorBacktester
|
||||
factor = pd.Series(fac, dtype=float)
|
||||
fwd = pd.Series(ret, dtype=float)
|
||||
fb = FactorBacktester()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
fb.results_path = Path(td)
|
||||
m = fb.run_backtest(factor, fwd, "WRCheck")
|
||||
wr = m["win_rate"]
|
||||
if not np.isnan(wr):
|
||||
assert 0.0 <= wr <= 1.0, f"WinRate={wr}"
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-100, max_value=100), min_size=30, max_size=300),
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=30, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_factor_name_preserved(self, fac, ret):
|
||||
"""Property: factor_name field matches the input name."""
|
||||
from rdagent.components.backtesting.backtest_engine import FactorBacktester
|
||||
factor = pd.Series(fac, dtype=float)
|
||||
fwd = pd.Series(ret, dtype=float)
|
||||
name = "MyTestFactor42"
|
||||
fb = FactorBacktester()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
fb.results_path = Path(td)
|
||||
m = fb.run_backtest(factor, fwd, name)
|
||||
assert m["factor_name"] == name
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-100, max_value=100), min_size=50, max_size=300),
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=50, max_size=300),
|
||||
st.floats(min_value=0.0001, max_value=0.005),
|
||||
st.floats(min_value=0.00001, max_value=0.0001),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_higher_cost_reduces_return(self, fac, ret, high_cost, low_cost):
|
||||
"""Property: Higher transaction cost reduces total_return (or keeps equal)."""
|
||||
from rdagent.components.backtesting.backtest_engine import FactorBacktester
|
||||
factor = pd.Series(fac, dtype=float)
|
||||
fwd = pd.Series(ret, dtype=float)
|
||||
fb = FactorBacktester()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
fb.results_path = Path(td)
|
||||
assume(high_cost > low_cost)
|
||||
m_high = fb.run_backtest(factor, fwd, "CostHigh", transaction_cost=high_cost)
|
||||
m_low = fb.run_backtest(factor, fwd, "CostLow", transaction_cost=low_cost)
|
||||
assert m_high["total_return"] <= m_low["total_return"] + 0.001, \
|
||||
f"Higher cost should not increase return: high={m_high['total_return']} low={m_low['total_return']}"
|
||||
|
||||
+71
-1314
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -487,834 +487,3 @@ class TestAddColumnIfNotExists:
|
||||
assert f"test_{col_type.lower()}" in cols
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HYPOTHESIS PROPERTY-BASED FUZZING TESTS (ADDED – DO NOT MODIFY ABOVE THIS LINE)
|
||||
# ============================================================================
|
||||
|
||||
from hypothesis import given, settings, strategies as st, assume, HealthCheck
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# add_factor Fuzzing (12 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorAddIdempotence:
|
||||
"""add_factor is idempotent: calling twice with same name returns same ID."""
|
||||
|
||||
@given(
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=122), min_size=1, max_size=50),
|
||||
st.text(min_size=1, max_size=20),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_factor_idempotent(self, name, ftype):
|
||||
"""Property: add_factor(name, type) always returns same ID for same name."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
id1 = db.add_factor(name, ftype)
|
||||
id2 = db.add_factor(name, ftype)
|
||||
assert id1 == id2, f"Idempotence violated: {id1} != {id2}"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.lists(
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=10),
|
||||
min_size=1, max_size=50, unique=True,
|
||||
),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_multiple_factors_all_unique_ids(self, names):
|
||||
"""Property: unique factor names produce unique IDs."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
ids = [db.add_factor(n, "test") for n in names]
|
||||
assert len(set(ids)) == len(names), "Unique names should yield unique IDs"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.text(min_size=1, max_size=30),
|
||||
st.integers(min_value=1, max_value=50),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_factor_always_positive_for_nonempty_name(self, name, repeat):
|
||||
"""Property: add_factor returns positive ID for any non-empty name."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
fid = db.add_factor(name, "t")
|
||||
assert fid > 0 or fid == -1, f"Unexpected id {fid}"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.text(min_size=1, max_size=30),
|
||||
st.text(min_size=1, max_size=20),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_factor_row_count_matches_calls(self, name, ftype):
|
||||
"""Property: after n calls with distinct names, factors table has exactly n rows."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
distinct_names = [f"{name}_{i}" for i in range(10)]
|
||||
for n in distinct_names:
|
||||
db.add_factor(n, ftype)
|
||||
c = db.conn.cursor()
|
||||
c.execute("SELECT COUNT(*) FROM factors")
|
||||
assert c.fetchone()[0] == 10
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# add_backtest Fuzzing (22 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAddBacktestFuzzing:
|
||||
"""Fuzz add_backtest with random metrics dictionaries."""
|
||||
|
||||
@given(
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=30),
|
||||
st.floats(min_value=-1.0, max_value=1.0),
|
||||
st.floats(min_value=-10.0, max_value=10.0),
|
||||
st.floats(min_value=-2.0, max_value=2.0),
|
||||
st.floats(min_value=-1.0, max_value=0.0),
|
||||
st.floats(min_value=0.0, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_backtest_with_random_metrics(self, name, ic, sharpe, ann_ret, dd, wr):
|
||||
"""Property: add_backtest always succeeds with random but valid metrics."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
bid = db.add_backtest(name, {
|
||||
"ic": ic, "sharpe_ratio": sharpe, "annualized_return": ann_ret,
|
||||
"max_drawdown": dd, "win_rate": wr,
|
||||
})
|
||||
assert bid > 0, f"add_backtest failed for name={name}"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=30),
|
||||
st.floats(min_value=-1.0, max_value=1.0),
|
||||
st.floats(min_value=-10.0, max_value=10.0),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_backtest_minimal_metrics(self, name, ic, sharpe):
|
||||
"""Property: add_backtest works with only ic and sharpe."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
bid = db.add_backtest(name, {"ic": ic, "sharpe_ratio": sharpe})
|
||||
assert bid > 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=30),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_backtest_empty_metrics(self, name):
|
||||
"""Property: add_backtest with empty dict still creates a record."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
bid = db.add_backtest(name, {})
|
||||
assert bid > 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=20),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_backtest_multiple_runs_sequential_ids(self, n_runs):
|
||||
"""Property: n runs for same factor produce n distinct monotonically increasing IDs."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
ids = []
|
||||
for i in range(n_runs):
|
||||
bid = db.add_backtest("MultiRun", {"ic": i / 100.0, "sharpe_ratio": 1.0})
|
||||
ids.append(bid)
|
||||
assert len(set(ids)) == n_runs, "IDs should be unique"
|
||||
assert sorted(ids) == ids, "IDs should be monotonically increasing"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.lists(
|
||||
st.tuples(
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=10),
|
||||
st.floats(min_value=-1.0, max_value=1.0),
|
||||
st.floats(min_value=-5.0, max_value=5.0),
|
||||
),
|
||||
min_size=5, max_size=30, unique_by=lambda t: t[0],
|
||||
),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_backtest_bulk_distinct_factors(self, entries):
|
||||
"""Property: adding backtests for distinct factors creates exactly that many rows."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
for name, ic_val, sh in entries:
|
||||
db.add_backtest(name, {"ic": ic_val, "sharpe_ratio": sh})
|
||||
c = db.conn.cursor()
|
||||
c.execute("SELECT COUNT(*) FROM backtest_runs")
|
||||
count = c.fetchone()[0]
|
||||
assert count == len(entries), f"Expected {len(entries)} runs, got {count}"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.floats(min_value=-100.0, max_value=100.0),
|
||||
st.floats(min_value=-100.0, max_value=100.0),
|
||||
st.floats(min_value=-100.0, max_value=100.0),
|
||||
st.floats(min_value=-100.0, max_value=100.0),
|
||||
st.floats(min_value=-100.0, max_value=100.0),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_backtest_extreme_values(self, ic, sharpe, ann_ret, dd, wr):
|
||||
"""Property: add_backtest handles extreme metric values without crashing."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
bid = db.add_backtest("ExtremeValues", {
|
||||
"ic": ic, "sharpe_ratio": sharpe, "annualized_return": ann_ret,
|
||||
"max_drawdown": dd, "win_rate": wr,
|
||||
})
|
||||
assert bid > 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.text(alphabet=st.characters(min_codepoint=32, max_codepoint=126), min_size=1, max_size=40),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_backtest_special_character_names(self, name):
|
||||
"""Property: add_backtest handles factor names with any printable characters."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
bid = db.add_backtest(name, {"ic": 0.05})
|
||||
c = db.conn.cursor()
|
||||
c.execute("SELECT factor_name FROM factors WHERE id = (SELECT factor_id FROM backtest_runs WHERE id=?)", (bid,))
|
||||
stored = c.fetchone()
|
||||
assert stored is not None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.floats(min_value=-1.0, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_backtest_with_raw_metrics(self, ic_val):
|
||||
"""Property: add_backtest survives raw_metrics key with various dict values."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
bid = db.add_backtest("RawMetricsTest", {
|
||||
"ic": ic_val,
|
||||
"raw_metrics": {"a": 1.0, "b": ic_val, "c": 100.0},
|
||||
})
|
||||
assert bid > 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# add_loop Fuzzing (10 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAddLoopFuzzing:
|
||||
"""Fuzz add_loop with random success/fail counts."""
|
||||
|
||||
@given(
|
||||
st.integers(min_value=0, max_value=100),
|
||||
st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_loop_success_rate_formula(self, success, fail):
|
||||
"""Property: success_rate = success / (success + fail) if total > 0 else 0."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
lid = db.add_loop(0, success, fail, None, "completed")
|
||||
c = db.conn.cursor()
|
||||
c.execute("SELECT success_rate FROM loop_results WHERE id=?", (lid,))
|
||||
rate = c.fetchone()[0]
|
||||
expected = success / (success + fail) if (success + fail) > 0 else 0.0
|
||||
assert abs(rate - expected) < 1e-10, f"Rate {rate} != expected {expected}"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.integers(min_value=0, max_value=50),
|
||||
st.integers(min_value=0, max_value=50),
|
||||
st.floats(min_value=-1.0, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_loop_best_ic_preserved(self, success, fail, best_ic):
|
||||
"""Property: best_ic value stored matches what was passed."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
lid = db.add_loop(42, success, fail, best_ic, "completed")
|
||||
c = db.conn.cursor()
|
||||
c.execute("SELECT best_ic FROM loop_results WHERE id=?", (lid,))
|
||||
stored = c.fetchone()[0]
|
||||
if best_ic is not None:
|
||||
assert abs(stored - best_ic) < 1e-10
|
||||
else:
|
||||
assert stored is None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.lists(st.integers(min_value=1, max_value=50), min_size=1, max_size=20, unique=True),
|
||||
st.integers(min_value=1, max_value=10),
|
||||
st.integers(min_value=1, max_value=10),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_loop_multiple_sequential_indices(self, indices, s, f):
|
||||
"""Property: multiple loops with distinct indices produce that many rows."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
for idx in indices:
|
||||
db.add_loop(idx, s, f, None, "completed")
|
||||
c = db.conn.cursor()
|
||||
c.execute("SELECT COUNT(*) FROM loop_results")
|
||||
assert c.fetchone()[0] == len(indices)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.integers(min_value=0, max_value=1000),
|
||||
st.integers(min_value=0, max_value=1000),
|
||||
st.text(min_size=1, max_size=20),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_loop_status_stored(self, success, fail, status):
|
||||
"""Property: status field reflects the passed value."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
lid = db.add_loop(99, success, fail, None, status)
|
||||
c = db.conn.cursor()
|
||||
c.execute("SELECT status FROM loop_results WHERE id=?", (lid,))
|
||||
assert c.fetchone()[0] == status
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_top_factors Properties (15 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetTopFactorsFuzzing:
|
||||
"""Property-based tests for get_top_factors."""
|
||||
|
||||
@given(
|
||||
st.lists(
|
||||
st.floats(min_value=-5.0, max_value=5.0),
|
||||
min_size=5, max_size=30,
|
||||
),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_top_factors_sorted_descending_by_sharpe(self, sharpes):
|
||||
"""Property: get_top_factors by sharpe returns strictly descending sharpe values."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
for i, sh in enumerate(sharpes):
|
||||
db.add_backtest(f"Factor_{i}", {"ic": 0.0, "sharpe_ratio": sh})
|
||||
df = db.get_top_factors(metric="sharpe", limit=len(sharpes))
|
||||
sh_vals = df["sharpe"].tolist()
|
||||
assert sh_vals == sorted(sh_vals, reverse=True), f"Not sorted: {sh_vals}"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.lists(
|
||||
st.floats(min_value=-1.0, max_value=1.0),
|
||||
min_size=5, max_size=30,
|
||||
),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_top_factors_by_ic_descending(self, ics):
|
||||
"""Property: get_top_factors by IC returns descending IC."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
for i, ic in enumerate(ics):
|
||||
db.add_backtest(f"Factor_{i}", {"ic": ic, "sharpe_ratio": 0.0})
|
||||
df = db.get_top_factors(metric="ic", limit=len(ics))
|
||||
ic_vals = df["ic"].tolist()
|
||||
assert ic_vals == sorted(ic_vals, reverse=True)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.integers(min_value=1, max_value=50),
|
||||
st.integers(min_value=1, max_value=200),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_top_factors_limit_respected(self, n_factors, limit):
|
||||
"""Property: result length <= limit and <= number of stored factors."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
for i in range(n_factors):
|
||||
db.add_backtest(f"Fac_{i}", {"ic": 0.0, "sharpe_ratio": 1.0})
|
||||
df = db.get_top_factors(metric="sharpe", limit=limit)
|
||||
assert len(df) <= limit
|
||||
assert len(df) <= n_factors
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.lists(
|
||||
st.floats(min_value=-5.0, max_value=5.0),
|
||||
min_size=10, max_size=40,
|
||||
),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_get_top_factors_all_columns_present(self, sharpes):
|
||||
"""Property: returned DataFrame always has expected columns."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
for i, sh in enumerate(sharpes):
|
||||
db.add_backtest(f"FC_{i}", {"ic": 0.0, "sharpe_ratio": sh})
|
||||
df = db.get_top_factors()
|
||||
for col in ["factor_name", "sharpe", "ic", "annual_return", "max_drawdown"]:
|
||||
assert col in df.columns, f"Missing column: {col}"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=10))
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_get_top_factors_empty_db_returns_empty(self, db_suffix):
|
||||
"""Property: querying empty database returns empty DataFrame."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, f"empty_{db_suffix}.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
df = db.get_top_factors(metric="sharpe", limit=10)
|
||||
assert len(df) == 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-5.0, max_value=5.0), min_size=5, max_size=30),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_get_top_factors_null_metrics_excluded(self, sharpes):
|
||||
"""Property: factors with NULL sharpe are excluded from top-by-sharpe."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
# Add factors with NULL sharpe
|
||||
for i in range(3):
|
||||
db.add_factor(f"NullFac_{i}", "type")
|
||||
for i, sh in enumerate(sharpes):
|
||||
db.add_backtest(f"RealFac_{i}", {"ic": 0.0, "sharpe_ratio": sh})
|
||||
df = db.get_top_factors(metric="sharpe", limit=100)
|
||||
assert len(df) <= len(sharpes)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_aggregate_stats Properties (8 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAggregateStatsProperties:
|
||||
"""Property tests for get_aggregate_stats."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-1.0, max_value=1.0), min_size=3, max_size=20),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_avg_ic_within_input_range(self, ics):
|
||||
"""Property: avg_ic lies between min and max of stored ICs."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
for i, ic in enumerate(ics):
|
||||
db.add_backtest(f"ICFactor_{i}", {"ic": ic, "sharpe_ratio": 1.0})
|
||||
stats = db.get_aggregate_stats()
|
||||
assert stats["avg_ic"] is not None
|
||||
assert min(ics) - 0.01 <= stats["avg_ic"] <= max(ics) + 0.01
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-10.0, max_value=10.0), min_size=3, max_size=20),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_max_sharpe_is_max(self, sharpes):
|
||||
"""Property: max_sharpe equals the maximum of stored sharpe values."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
for i, sh in enumerate(sharpes):
|
||||
db.add_backtest(f"SFactor_{i}", {"ic": 0.0, "sharpe_ratio": sh})
|
||||
stats = db.get_aggregate_stats()
|
||||
assert abs(stats["max_sharpe"] - max(sharpes)) < 1e-10
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-2.0, max_value=2.0), min_size=3, max_size=20),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_avg_return_within_range(self, returns):
|
||||
"""Property: avg_return is between min and max stored annualized_return."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
for i, r in enumerate(returns):
|
||||
db.add_backtest(f"RFactor_{i}", {"ic": 0.0, "annualized_return": r})
|
||||
stats = db.get_aggregate_stats()
|
||||
assert stats["avg_return"] is not None
|
||||
assert min(returns) - 0.01 <= stats["avg_return"] <= max(returns) + 0.01
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.integers(min_value=1, max_value=30),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_total_factors_counts_unique_names(self, n_factors):
|
||||
"""Property: total_factors counts unique factor names, not runs."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
distinct = n_factors // 2 + 1
|
||||
for i in range(distinct):
|
||||
db.add_backtest(f"UniqFac_{i}", {"ic": 0.01 * i})
|
||||
# Add second run for first factor
|
||||
db.add_backtest("UniqFac_0", {"ic": 0.99})
|
||||
stats = db.get_aggregate_stats()
|
||||
assert stats["total_factors"] == distinct
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema Migration Properties (8 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSchemaMigrationFuzzing:
|
||||
"""Property tests for _add_column_if_not_exists."""
|
||||
|
||||
@given(
|
||||
st.sampled_from(["REAL", "TEXT", "INTEGER", "BLOB"]),
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=20),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_column_idempotent(self, col_type, col_name):
|
||||
"""Property: adding the same column twice is safe (no-op second time)."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
db._add_column_if_not_exists("backtest_runs", col_name, col_type)
|
||||
db._add_column_if_not_exists("backtest_runs", col_name, col_type)
|
||||
c = db.conn.cursor()
|
||||
c.execute("PRAGMA table_info(backtest_runs)")
|
||||
cols = [row[1] for row in c.fetchall()]
|
||||
assert sum(1 for c in cols if c.lower() == col_name.lower()) == 1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=15),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_column_added_to_all_tables(self, col_name):
|
||||
"""Property: column can be added to each allowed table."""
|
||||
for table in ["factors", "backtest_runs", "loop_results"]:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
db._add_column_if_not_exists(table, col_name, "REAL")
|
||||
c = db.conn.cursor()
|
||||
c.execute(f"PRAGMA table_info({table})")
|
||||
cols = [row[1] for row in c.fetchall()]
|
||||
assert col_name in cols, f"{col_name} not found in {table}"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.text(alphabet=st.characters(min_codepoint=32, max_codepoint=47), min_size=1, max_size=10),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_invalid_column_names_raise_value_error(self, bad_name):
|
||||
"""Property: non-alphanumeric (besides underscore) column names raise ValueError."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
with pytest.raises(ValueError):
|
||||
db._add_column_if_not_exists("backtest_runs", bad_name, "REAL")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(st.text(min_size=1, max_size=15))
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_invalid_table_name_raises(self, bad_table):
|
||||
"""Property: unknown table names raise ValueError."""
|
||||
assume(bad_table not in {"factors", "backtest_runs", "loop_results"})
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
with pytest.raises(ValueError):
|
||||
db._add_column_if_not_exists(bad_table, "col", "REAL")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data Integrity Properties (10 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDataIntegrityFuzzing:
|
||||
"""Property tests for data roundtrip and consistency."""
|
||||
|
||||
@given(
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=30),
|
||||
st.floats(min_value=-1.0, max_value=1.0),
|
||||
st.floats(min_value=-5.0, max_value=5.0),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_data_roundtrip_ic(self, name, ic, sharpe):
|
||||
"""Property: IC value retrieved matches what was stored."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db1 = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
bid = db1.add_backtest(name, {"ic": ic, "sharpe_ratio": sharpe})
|
||||
c = db1.conn.cursor()
|
||||
c.execute("SELECT ic FROM backtest_runs WHERE id=?", (bid,))
|
||||
stored = c.fetchone()[0]
|
||||
assert abs(stored - ic) < 1e-10
|
||||
finally:
|
||||
db1.close()
|
||||
|
||||
@given(
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=30),
|
||||
st.floats(min_value=-10.0, max_value=10.0),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_data_roundtrip_sharpe(self, name, sharpe):
|
||||
"""Property: Sharpe value retrieved matches stored."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
bid = db.add_backtest(name, {"ic": 0.0, "sharpe_ratio": sharpe})
|
||||
c = db.conn.cursor()
|
||||
c.execute("SELECT sharpe FROM backtest_runs WHERE id=?", (bid,))
|
||||
assert abs(c.fetchone()[0] - sharpe) < 1e-10
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=30),
|
||||
st.floats(min_value=-1.0, max_value=0.0),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_data_roundtrip_max_drawdown(self, name, dd):
|
||||
"""Property: max_drawdown roundtrip is exact."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
bid = db.add_backtest(name, {"ic": 0.0, "max_drawdown": dd, "sharpe_ratio": 1.0})
|
||||
c = db.conn.cursor()
|
||||
c.execute("SELECT max_drawdown FROM backtest_runs WHERE id=?", (bid,))
|
||||
assert abs(c.fetchone()[0] - dd) < 1e-10
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=30),
|
||||
st.floats(min_value=0.0, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_data_roundtrip_win_rate(self, name, wr):
|
||||
"""Property: win_rate roundtrip is exact."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
bid = db.add_backtest(name, {"ic": 0.0, "win_rate": wr, "sharpe_ratio": 1.0})
|
||||
c = db.conn.cursor()
|
||||
c.execute("SELECT win_rate FROM backtest_runs WHERE id=?", (bid,))
|
||||
assert abs(c.fetchone()[0] - wr) < 1e-10
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.lists(
|
||||
st.tuples(
|
||||
st.floats(min_value=-5.0, max_value=5.0),
|
||||
st.floats(min_value=-1.0, max_value=1.0),
|
||||
),
|
||||
min_size=5, max_size=30,
|
||||
),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000, suppress_health_check=[HealthCheck.filter_too_much])
|
||||
def test_multiple_runs_factor_count_consistent(self, pairs):
|
||||
"""Property: unique factor count between direct SQL and get_aggregate_stats matches."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
for i, (sh, ic) in enumerate(pairs):
|
||||
db.add_backtest(f"ConsistencyFac_{i}", {"ic": ic, "sharpe_ratio": sh})
|
||||
stats = db.get_aggregate_stats()
|
||||
c = db.conn.cursor()
|
||||
c.execute("SELECT COUNT(DISTINCT factor_name) FROM backtest_runs JOIN factors ON factor_id=factors.id")
|
||||
direct = c.fetchone()[0]
|
||||
assert stats["total_factors"] == direct
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(st.integers(min_value=1, max_value=50))
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_persistence_across_connections(self, n_factors):
|
||||
"""Property: data written in one connection is visible in a new connection."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db1 = ResultsDatabase(db_path=db_path)
|
||||
for i in range(n_factors):
|
||||
db1.add_backtest(f"Persist_{i}", {"ic": 0.01 * i, "sharpe_ratio": 1.0})
|
||||
db1.close()
|
||||
|
||||
db2 = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
c = db2.conn.cursor()
|
||||
c.execute("SELECT COUNT(*) FROM backtest_runs")
|
||||
assert c.fetchone()[0] == n_factors
|
||||
finally:
|
||||
db2.close()
|
||||
|
||||
@given(st.floats(min_value=-100.0, max_value=100.0))
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_nan_handled_in_metrics(self, nan_val):
|
||||
"""Property: NaN values in metrics do not crash."""
|
||||
assume(np.isnan(nan_val) or not np.isnan(nan_val)) # both branches tested
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
bid = db.add_backtest("NaNTest", {"ic": nan_val, "sharpe_ratio": 1.0})
|
||||
assert bid > 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_factor_history Properties (5 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetFactorHistoryFuzzing:
|
||||
"""Property tests for get_factor_history."""
|
||||
|
||||
@given(
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=20),
|
||||
st.integers(min_value=1, max_value=10),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_factor_history_returns_correct_count(self, name, n_runs):
|
||||
"""Property: get_factor_history returns exactly n rows for n backtest runs."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
for i in range(n_runs):
|
||||
db.add_backtest(name, {"ic": i * 0.01, "sharpe_ratio": 1.0})
|
||||
df = db.get_factor_history(name)
|
||||
assert len(df) == n_runs, f"Expected {n_runs}, got {len(df)}"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=20))
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_factor_history_empty_for_unknown(self, name):
|
||||
"""Property: get_factor_history for unknown factor returns empty DataFrame."""
|
||||
assume(len(name) > 0)
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
df = db.get_factor_history(name + "_unknown_suffix_xyz")
|
||||
assert len(df) == 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.floats(min_value=-1.0, max_value=1.0),
|
||||
st.floats(min_value=-5.0, max_value=5.0),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_factor_history_values_match(self, ic, sharpe):
|
||||
"""Property: get_factor_history returns the same values that were stored."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
db.add_backtest("HistoryCheck", {"ic": ic, "sharpe_ratio": sharpe})
|
||||
df = db.get_factor_history("HistoryCheck")
|
||||
assert len(df) > 0
|
||||
assert abs(df.iloc[0]["ic"] - ic) < 1e-10
|
||||
assert abs(df.iloc[0]["sharpe"] - sharpe) < 1e-10
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -481,648 +481,3 @@ class TestRiskManagementIntegration:
|
||||
from rdagent.components.backtesting.risk_management import (
|
||||
CorrelationAnalyzer, PortfolioOptimizer, AdvancedRiskManager
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HYPOTHESIS PROPERTY-BASED TESTS (ADDED – DO NOT MODIFY ABOVE THIS LINE)
|
||||
# ============================================================================
|
||||
|
||||
from hypothesis import given, settings, strategies as st, assume
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Correlation Matrix Properties (22 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCorrelationMatrixProperties:
|
||||
"""Property-based tests for correlation matrix invariants."""
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=15),
|
||||
st.integers(min_value=30, max_value=500),
|
||||
st.floats(min_value=0.001, max_value=0.1),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_corr_matrix_symmetric(self, n_assets, n_bars, noise):
|
||||
"""Property: correlation matrix is always symmetric."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="B")
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.normal(0, noise, (n_bars, n_assets))
|
||||
df = pd.DataFrame(data, columns=[f"A_{i}" for i in range(n_assets)], index=dates)
|
||||
analyzer = CorrelationAnalyzer()
|
||||
corr = analyzer.calculate_matrix(df)
|
||||
assert np.allclose(corr.values, corr.values.T, atol=1e-10)
|
||||
|
||||
@given(
|
||||
st.integers(min_value=1, max_value=20),
|
||||
st.integers(min_value=30, max_value=500),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000)
|
||||
def test_corr_diagonal_is_one(self, n_assets, n_bars):
|
||||
"""Property: all diagonal elements of correlation matrix equal 1.0."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="B")
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.normal(0, 0.02, (n_bars, n_assets))
|
||||
df = pd.DataFrame(data, columns=[f"A_{i}" for i in range(n_assets)], index=dates)
|
||||
analyzer = CorrelationAnalyzer()
|
||||
corr = analyzer.calculate_matrix(df)
|
||||
diag = np.diag(corr.values)
|
||||
assert np.allclose(diag, 1.0, atol=1e-10)
|
||||
|
||||
@given(
|
||||
st.integers(min_value=3, max_value=10),
|
||||
st.integers(min_value=50, max_value=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000)
|
||||
def test_corr_values_in_bounds(self, n_assets, n_bars):
|
||||
"""Property: all correlation values ∈ [-1, 1]."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="B")
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.normal(0, 0.02, (n_bars, n_assets))
|
||||
df = pd.DataFrame(data, columns=[f"A_{i}" for i in range(n_assets)], index=dates)
|
||||
analyzer = CorrelationAnalyzer()
|
||||
corr = analyzer.calculate_matrix(df)
|
||||
vals = corr.values.ravel()
|
||||
vals = vals[~np.isnan(vals)]
|
||||
assert np.all(vals >= -1.0)
|
||||
assert np.all(vals <= 1.0)
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=6),
|
||||
st.integers(min_value=30, max_value=500),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_corr_psd(self, n_assets, n_bars):
|
||||
"""Property: correlation matrix is positive semi-definite."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="B")
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.normal(0, 0.02, (n_bars, n_assets))
|
||||
df = pd.DataFrame(data, columns=[f"A_{i}" for i in range(n_assets)], index=dates)
|
||||
analyzer = CorrelationAnalyzer()
|
||||
corr = analyzer.calculate_matrix(df)
|
||||
vals = corr.values
|
||||
vals = np.nan_to_num(vals, nan=0)
|
||||
eigenvalues = np.linalg.eigvalsh(vals)
|
||||
assert np.all(eigenvalues >= -1e-10), f"Non-PSD: min eigenvalue={eigenvalues.min()}"
|
||||
|
||||
@given(st.integers(min_value=30, max_value=500))
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_single_asset_corr_is_one(self, n_bars):
|
||||
"""Property: correlation matrix of single asset is [[1.0]]."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="B")
|
||||
rng = np.random.default_rng(42)
|
||||
df = pd.DataFrame({"Only": rng.normal(0, 0.02, n_bars)}, index=dates)
|
||||
analyzer = CorrelationAnalyzer()
|
||||
corr = analyzer.calculate_matrix(df)
|
||||
assert corr.shape == (1, 1)
|
||||
assert corr.iloc[0, 0] == 1.0
|
||||
|
||||
@given(
|
||||
st.integers(min_value=3, max_value=10),
|
||||
st.integers(min_value=50, max_value=300),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_corr_equals_corr_from_pandas(self, n_assets, n_bars):
|
||||
"""Property: calculate_matrix matches pandas .corr()."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="B")
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.normal(0, 0.02, (n_bars, n_assets))
|
||||
df = pd.DataFrame(data, columns=[f"A_{i}" for i in range(n_assets)], index=dates)
|
||||
analyzer = CorrelationAnalyzer()
|
||||
result = analyzer.calculate_matrix(df)
|
||||
expected = df.dropna().corr()
|
||||
assert np.allclose(result.values, expected.values, atol=1e-10, equal_nan=True)
|
||||
|
||||
@given(
|
||||
st.floats(min_value=0.1, max_value=0.9),
|
||||
st.integers(min_value=50, max_value=200),
|
||||
)
|
||||
@settings(max_examples=40, deadline=5000)
|
||||
def test_corr_with_nans_still_symmetric(self, nan_fraction, n_bars):
|
||||
"""Property: correlation matrix stays symmetric even with NaN-contaminated data."""
|
||||
n_assets = 5
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="B")
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.normal(0, 0.02, (n_bars, n_assets))
|
||||
df = pd.DataFrame(data, columns=[f"A_{i}" for i in range(n_assets)], index=dates)
|
||||
for col in df.columns:
|
||||
n_nan = int(n_bars * nan_fraction * 0.3)
|
||||
df.loc[df.index[:n_nan], col] = np.nan
|
||||
analyzer = CorrelationAnalyzer()
|
||||
corr = analyzer.calculate_matrix(df)
|
||||
vals = np.nan_to_num(corr.values, nan=0)
|
||||
assert np.allclose(vals, vals.T, atol=1e-10)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_uncorrelated Properties (12 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindUncorrelatedProperties:
|
||||
"""Property tests for find_uncorrelated."""
|
||||
|
||||
@given(
|
||||
st.integers(min_value=3, max_value=10),
|
||||
st.integers(min_value=100, max_value=500),
|
||||
st.floats(min_value=0.0, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_uncorrelated_count_bounded_by_n_assets(self, n_assets, n_bars, threshold):
|
||||
"""Property: number of uncorrelated factors <= n_assets."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="B")
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.normal(0, 0.02, (n_bars, n_assets))
|
||||
df = pd.DataFrame(data, columns=[f"A_{i}" for i in range(n_assets)], index=dates)
|
||||
analyzer = CorrelationAnalyzer()
|
||||
corr = analyzer.calculate_matrix(df)
|
||||
result = analyzer.find_uncorrelated(corr, threshold=threshold)
|
||||
assert len(result) <= n_assets
|
||||
|
||||
@given(
|
||||
st.integers(min_value=3, max_value=8),
|
||||
st.integers(min_value=100, max_value=400),
|
||||
st.floats(min_value=0.0, max_value=0.5),
|
||||
st.floats(min_value=0.5, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000)
|
||||
def test_threshold_monotonicity(self, n_assets, n_bars, t_low, t_high):
|
||||
"""Property: higher threshold => more or equal uncorrelated factors."""
|
||||
assume(t_low <= t_high)
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="B")
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.normal(0, 0.02, (n_bars, n_assets))
|
||||
df = pd.DataFrame(data, columns=[f"A_{i}" for i in range(n_assets)], index=dates)
|
||||
analyzer = CorrelationAnalyzer()
|
||||
corr = analyzer.calculate_matrix(df)
|
||||
r_low = analyzer.find_uncorrelated(corr, threshold=t_low)
|
||||
r_high = analyzer.find_uncorrelated(corr, threshold=t_high)
|
||||
assert len(r_high) >= len(r_low)
|
||||
|
||||
@given(
|
||||
st.integers(min_value=30, max_value=300),
|
||||
)
|
||||
@settings(max_examples=30, deadline=5000)
|
||||
def test_empty_matrix_returns_empty(self, n_bars):
|
||||
"""Property: find_uncorrelated on empty matrix returns []."""
|
||||
analyzer = CorrelationAnalyzer()
|
||||
assert analyzer.find_uncorrelated(pd.DataFrame()) == []
|
||||
|
||||
@given(
|
||||
st.integers(min_value=120, max_value=300),
|
||||
)
|
||||
@settings(max_examples=30, deadline=5000)
|
||||
def test_single_asset_is_uncorrelated(self, n_bars):
|
||||
"""Property: single-asset mean abs correlation to others is NaN → not found."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="B")
|
||||
rng = np.random.default_rng(42)
|
||||
df = pd.DataFrame({"Solo": rng.normal(0, 0.02, n_bars)}, index=dates)
|
||||
analyzer = CorrelationAnalyzer()
|
||||
corr = analyzer.calculate_matrix(df)
|
||||
result = analyzer.find_uncorrelated(corr, threshold=0.5)
|
||||
# Single asset has no "others" — abs().mean() returns NaN, which is not < threshold
|
||||
# So it should NOT be in result (or the list may be empty)
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mean-Variance Properties (18 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMeanVarianceProperties:
|
||||
"""Property-based tests for mean_variance optimization."""
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=10),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_weights_sum_to_one(self, n_assets):
|
||||
"""Property: mean_variance weights always sum to 1."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
exp_ret = pd.Series(np.random.default_rng(42).uniform(0.01, 0.15, n_assets), index=names)
|
||||
cov_data = np.random.default_rng(43).uniform(0.01, 0.1, (n_assets, n_assets))
|
||||
cov_data = cov_data @ cov_data.T + np.eye(n_assets) * 0.01 # make PSD
|
||||
cov = pd.DataFrame(cov_data, index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.mean_variance(exp_ret, cov)
|
||||
assert abs(np.sum(w) - 1.0) < 1e-10
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=8),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_weights_are_numpy_array(self, n_assets):
|
||||
"""Property: mean_variance returns numpy array."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
exp_ret = pd.Series(np.random.default_rng(42).uniform(0.01, 0.15, n_assets), index=names)
|
||||
cov = pd.DataFrame(np.eye(n_assets) * 0.04, index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.mean_variance(exp_ret, cov)
|
||||
assert isinstance(w, np.ndarray)
|
||||
assert len(w) == n_assets
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=6),
|
||||
st.floats(min_value=0.001, max_value=0.2),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_equal_returns_different_vol_weights(self, n_assets, ret_val):
|
||||
"""Property: if all returns equal, lower-vol assets get higher weight."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
exp_ret = pd.Series([ret_val] * n_assets, index=names)
|
||||
# Increasing vol: A0 has 0.01, A1 has 0.04, ...
|
||||
diag = np.array([0.01 * (i + 1) for i in range(n_assets)])
|
||||
cov = pd.DataFrame(np.diag(diag), index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.mean_variance(exp_ret, cov)
|
||||
assert w[np.argmin(diag)] > w[np.argmax(diag)]
|
||||
|
||||
@given(
|
||||
st.integers(min_value=3, max_value=6),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_higher_return_gets_higher_weight_ceteris_paribus(self, n_assets):
|
||||
"""Property: among assets with equal risk, the one with highest return gets highest weight."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
rets = np.linspace(0.01, 0.20, n_assets)
|
||||
exp_ret = pd.Series(rets, index=names)
|
||||
cov = pd.DataFrame(np.eye(n_assets) * 0.04, index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.mean_variance(exp_ret, cov)
|
||||
assert np.argmax(w) == np.argmax(rets)
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=6),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_singular_cov_fallback_equal_weights(self, n_assets):
|
||||
"""Property: singular covariance produces equal weights (fallback)."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
exp_ret = pd.Series(np.random.default_rng(42).uniform(0.01, 0.15, n_assets), index=names)
|
||||
# Singular: all rows identical
|
||||
row = np.ones(n_assets) * 0.04
|
||||
cov = pd.DataFrame([row] * n_assets, index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.mean_variance(exp_ret, cov)
|
||||
expected = np.ones(n_assets) / n_assets
|
||||
assert np.allclose(w, expected, atol=0.01)
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=6),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_zero_cov_fallback_equal_weights(self, n_assets):
|
||||
"""Property: zero covariance matrix produces equal weights fallback."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
exp_ret = pd.Series(np.random.default_rng(42).uniform(0.01, 0.15, n_assets), index=names)
|
||||
cov = pd.DataFrame(np.zeros((n_assets, n_assets)), index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.mean_variance(exp_ret, cov)
|
||||
expected = np.ones(n_assets) / n_assets
|
||||
assert np.allclose(w, expected, atol=0.01)
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=8),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_negative_returns_still_sum_to_one(self, n_assets):
|
||||
"""Property: weights sum to 1 even when all expected returns are negative."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
exp_ret = pd.Series(np.random.default_rng(42).uniform(-0.20, -0.01, n_assets), index=names)
|
||||
cov = pd.DataFrame(np.eye(n_assets) * 0.04, index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.mean_variance(exp_ret, cov)
|
||||
assert abs(np.sum(w) - 1.0) < 1e-10
|
||||
|
||||
@given(
|
||||
st.floats(min_value=0.01, max_value=0.5),
|
||||
st.integers(min_value=2, max_value=6),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_weights_invariant_to_exp_ret_scale(self, scale, n_assets):
|
||||
"""Property: multiplying all expected returns by same factor doesn't change weights."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
rng = np.random.default_rng(42)
|
||||
base_rets = rng.uniform(0.01, 0.15, n_assets)
|
||||
exp_ret_1 = pd.Series(base_rets, index=names)
|
||||
exp_ret_2 = pd.Series(base_rets * scale, index=names)
|
||||
cov = pd.DataFrame(np.eye(n_assets) * 0.04, index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w1 = opt.mean_variance(exp_ret_1, cov)
|
||||
w2 = opt.mean_variance(exp_ret_2, cov)
|
||||
assert np.allclose(w1, w2, atol=1e-10), f"w1={w1}, w2={w2}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Risk-Parity Properties (16 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRiskParityProperties:
|
||||
"""Property-based tests for risk_parity optimization."""
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=8),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_weights_sum_to_one(self, n_assets):
|
||||
"""Property: risk_parity weights sum to 1."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.uniform(0.01, 0.1, (n_assets, n_assets))
|
||||
cov_data = data @ data.T + np.eye(n_assets) * 0.01
|
||||
cov = pd.DataFrame(cov_data, index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.risk_parity(cov)
|
||||
assert abs(np.sum(w) - 1.0) < 1e-10
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=8),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_weights_positive(self, n_assets):
|
||||
"""Property: risk_parity weights are all positive (long-only)."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.uniform(0.01, 0.1, (n_assets, n_assets))
|
||||
cov_data = data @ data.T + np.eye(n_assets) * 0.01
|
||||
cov = pd.DataFrame(cov_data, index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.risk_parity(cov)
|
||||
assert np.all(w > 0), f"Non-positive weight: {w}"
|
||||
|
||||
@given(st.integers(min_value=1, max_value=1))
|
||||
@settings(max_examples=20, deadline=5000)
|
||||
def test_single_asset_weight_is_one(self, _):
|
||||
"""Property: risk_parity with single asset returns [1.0]."""
|
||||
cov = pd.DataFrame([[0.04]], index=["A"], columns=["A"])
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.risk_parity(cov)
|
||||
assert len(w) == 1
|
||||
assert w[0] == 1.0
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=6),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_equal_vol_gives_equal_weights(self, n_assets):
|
||||
"""Property: diagonal covariance with equal variance => equal weights."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
cov = pd.DataFrame(np.eye(n_assets) * 0.04, index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.risk_parity(cov)
|
||||
expected = np.ones(n_assets) / n_assets
|
||||
assert np.allclose(w, expected, atol=0.01)
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=4),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_lower_vol_gets_higher_weight(self, n_assets):
|
||||
"""Property: asset with lower variance gets higher weight."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
diag = [0.01, 0.04, 0.09, 0.16][:n_assets]
|
||||
names = names[:n_assets]
|
||||
cov = pd.DataFrame(np.diag(diag), index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.risk_parity(cov)
|
||||
assert np.argmax(w) == 0 # lowest vol has idx 0
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=4),
|
||||
)
|
||||
@settings(max_examples=30, deadline=5000)
|
||||
def test_zero_variance_gives_equal_weights(self, n_assets):
|
||||
"""Property: zero covariance matrix falls back to equal weights."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
cov = pd.DataFrame(np.zeros((n_assets, n_assets)), index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.risk_parity(cov)
|
||||
expected = np.ones(n_assets) / n_assets
|
||||
assert np.allclose(w, expected, atol=0.01)
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=6),
|
||||
st.floats(min_value=0.5, max_value=5.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_cov_scaling_invariance(self, n_assets, scale):
|
||||
"""Property: scaling covariance matrix by positive factor doesn't change RP weights."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.uniform(0.01, 0.1, (n_assets, n_assets))
|
||||
base = data @ data.T + np.eye(n_assets) * 0.01
|
||||
cov1 = pd.DataFrame(base, index=names, columns=names)
|
||||
cov2 = pd.DataFrame(base * scale, index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w1 = opt.risk_parity(cov1)
|
||||
w2 = opt.risk_parity(cov2)
|
||||
assert np.allclose(w1, w2, atol=1e-10)
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=6),
|
||||
st.integers(min_value=2, max_value=20),
|
||||
st.integers(min_value=50, max_value=200),
|
||||
)
|
||||
@settings(max_examples=30, deadline=5000)
|
||||
def test_more_iterations_similar_result(self, n_assets, few_iter, many_iter):
|
||||
"""Property: more iterations gives similar or equal result."""
|
||||
assume(few_iter <= many_iter)
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.uniform(0.01, 0.1, (n_assets, n_assets))
|
||||
cov_data = data @ data.T + np.eye(n_assets) * 0.01
|
||||
cov = pd.DataFrame(cov_data, index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w1 = opt.risk_parity(cov, max_iter=few_iter)
|
||||
w2 = opt.risk_parity(cov, max_iter=many_iter)
|
||||
assert np.abs(np.sum(w1) - np.sum(w2)) < 0.01
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_limits Properties (16 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCheckLimitsProperties:
|
||||
"""Property-based tests for check_limits."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=3, max_size=10),
|
||||
st.floats(min_value=0.01, max_value=0.5),
|
||||
st.floats(min_value=-0.5, max_value=-0.001),
|
||||
st.floats(min_value=0.01, max_value=1.0),
|
||||
st.floats(min_value=1.0, max_value=10.0),
|
||||
st.floats(min_value=0.01, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=200, deadline=5000)
|
||||
def test_all_checks_are_boolean(self, weights, vol, dd, max_pos, max_lev, max_dd):
|
||||
"""Property: all check_limits return values are boolean."""
|
||||
w = np.array(weights, dtype=float)
|
||||
mgr = AdvancedRiskManager(max_pos=max_pos, max_lev=max_lev, max_dd=max_dd)
|
||||
checks = mgr.check_limits(w, vol=vol, dd=dd)
|
||||
for k, v in checks.items():
|
||||
assert isinstance(v, (bool, np.bool_)), f"{k} is {type(v)}"
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=3, max_size=10),
|
||||
st.floats(min_value=-0.5, max_value=-0.001),
|
||||
st.floats(min_value=0.01, max_value=1.0),
|
||||
st.floats(min_value=1.0, max_value=10.0),
|
||||
st.floats(min_value=0.01, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=200, deadline=5000)
|
||||
def test_three_keys_present(self, weights, dd, max_pos, max_lev, max_dd):
|
||||
"""Property: check_limits returns exactly 3 keys."""
|
||||
w = np.array(weights, dtype=float)
|
||||
mgr = AdvancedRiskManager(max_pos=max_pos, max_lev=max_lev, max_dd=max_dd)
|
||||
checks = mgr.check_limits(w, vol=0.15, dd=dd)
|
||||
assert set(checks.keys()) == {"position_limit", "leverage_limit", "drawdown_limit"}
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=0.0, max_value=0.01), min_size=3, max_size=10),
|
||||
st.floats(min_value=-0.01, max_value=0),
|
||||
st.floats(min_value=0.1, max_value=1.0),
|
||||
st.floats(min_value=1.0, max_value=10.0),
|
||||
st.floats(min_value=0.1, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_tiny_weights_pass_all_limits(self, weights, dd, max_pos, max_lev, max_dd):
|
||||
"""Property: very small weights pass all limits."""
|
||||
w = np.array(weights, dtype=float)
|
||||
mgr = AdvancedRiskManager(max_pos=max_pos, max_lev=max_lev, max_dd=max_dd)
|
||||
checks = mgr.check_limits(w, vol=0.15, dd=dd)
|
||||
assert bool(checks["position_limit"]) is True
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=100.0, max_value=1000.0), min_size=1, max_size=5),
|
||||
st.floats(min_value=0.1, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_huge_weights_fail_position_limit(self, weights, max_pos):
|
||||
"""Property: weights much larger than max_pos fail position_limit."""
|
||||
w = np.array(weights, dtype=float)
|
||||
mgr = AdvancedRiskManager(max_pos=max_pos, max_lev=10000.0, max_dd=1.0)
|
||||
checks = mgr.check_limits(w, vol=0.15, dd=-0.01)
|
||||
assert bool(checks["position_limit"]) is False
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=50.0, max_value=500.0), min_size=3, max_size=10),
|
||||
st.floats(min_value=1.0, max_value=10.0),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_huge_weights_fail_leverage_limit(self, weights, max_lev):
|
||||
"""Property: sum(abs(weights)) > max_lev fails leverage_limit."""
|
||||
w = np.array(weights, dtype=float)
|
||||
mgr = AdvancedRiskManager(max_pos=1000.0, max_lev=max_lev, max_dd=1.0)
|
||||
checks = mgr.check_limits(w, vol=0.15, dd=-0.01)
|
||||
assert bool(checks["leverage_limit"]) is False
|
||||
|
||||
@given(
|
||||
st.floats(min_value=0.01, max_value=0.5),
|
||||
st.floats(min_value=-2.0, max_value=-0.01),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_big_drawdown_fails_drawdown_limit(self, max_dd, actual_dd):
|
||||
"""Property: |dd| > max_dd fails drawdown_limit."""
|
||||
w = np.array([0.1, 0.1, 0.1])
|
||||
mgr = AdvancedRiskManager(max_pos=1.0, max_lev=100.0, max_dd=max_dd)
|
||||
checks = mgr.check_limits(w, vol=0.15, dd=actual_dd)
|
||||
assume(abs(actual_dd) > max_dd)
|
||||
assert bool(checks["drawdown_limit"]) is False
|
||||
|
||||
@given(
|
||||
st.floats(min_value=0.01, max_value=0.5),
|
||||
st.floats(min_value=-0.001, max_value=0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_small_drawdown_passes_drawdown_limit(self, max_dd, actual_dd):
|
||||
"""Property: small |dd| passes drawdown_limit."""
|
||||
w = np.array([0.1, 0.1, 0.1])
|
||||
mgr = AdvancedRiskManager(max_pos=1.0, max_lev=100.0, max_dd=max_dd)
|
||||
checks = mgr.check_limits(w, vol=0.15, dd=actual_dd)
|
||||
assert bool(checks["drawdown_limit"]) is True
|
||||
|
||||
@given(
|
||||
st.floats(min_value=0.01, max_value=1.0),
|
||||
st.floats(min_value=1.0, max_value=10.0),
|
||||
st.floats(min_value=0.01, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_zero_weights_pass_all(self, max_pos, max_lev, max_dd):
|
||||
"""Property: all-zero weights pass all limits."""
|
||||
w = np.zeros(5)
|
||||
mgr = AdvancedRiskManager(max_pos=max_pos, max_lev=max_lev, max_dd=max_dd)
|
||||
checks = mgr.check_limits(w, vol=0.15, dd=-0.01)
|
||||
assert all(checks.values())
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-2.0, max_value=2.0), min_size=2, max_size=8),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_position_limit_uses_abs_value(self, weights):
|
||||
"""Property: position_limit uses abs(weight) for both long and short."""
|
||||
w = np.array(weights, dtype=float)
|
||||
max_abs = np.max(np.abs(w))
|
||||
mgr = AdvancedRiskManager(max_pos=max_abs + 0.001, max_lev=1000.0, max_dd=1.0)
|
||||
checks = mgr.check_limits(w, vol=0.15, dd=-0.01)
|
||||
assert bool(checks["position_limit"]) is True
|
||||
|
||||
mgr2 = AdvancedRiskManager(max_pos=max_abs - 0.001, max_lev=1000.0, max_dd=1.0)
|
||||
checks2 = mgr2.check_limits(w, vol=0.15, dd=-0.01)
|
||||
if max_abs > 0.001:
|
||||
assert bool(checks2["position_limit"]) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Correlation + Risk Integration Properties (8 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCorrelationRiskIntegration:
|
||||
"""Integration properties combining correlation analysis and risk checks."""
|
||||
|
||||
@given(
|
||||
st.integers(min_value=3, max_value=8),
|
||||
st.integers(min_value=100, max_value=500),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_uncorrelated_subset_weights_valid(self, n_assets, n_bars):
|
||||
"""Property: portfolio weights for uncorrelated subset pass basic validation."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="B")
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.normal(0, 0.02, (n_bars, n_assets))
|
||||
df = pd.DataFrame(data, columns=[f"A_{i}" for i in range(n_assets)], index=dates)
|
||||
analyzer = CorrelationAnalyzer()
|
||||
corr = analyzer.calculate_matrix(df)
|
||||
uncorr = analyzer.find_uncorrelated(corr, threshold=0.5)
|
||||
assume(len(uncorr) >= 2)
|
||||
|
||||
cov = df[uncorr].cov() * 252
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.risk_parity(cov)
|
||||
assert abs(np.sum(w) - 1.0) < 1e-10
|
||||
assert np.all(np.isfinite(w)), f"RP weights should be finite: {w}"
|
||||
|
||||
@given(
|
||||
st.integers(min_value=3, max_value=8),
|
||||
st.integers(min_value=100, max_value=300),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_full_workflow_weight_sum_one(self, n_assets, n_bars):
|
||||
"""Property: full workflow (corr → uncorr → MV → risk check) runs end-to-end."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="B")
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.normal(0, 0.02, (n_bars, n_assets))
|
||||
df = pd.DataFrame(data, columns=[f"A_{i}" for i in range(n_assets)], index=dates)
|
||||
analyzer = CorrelationAnalyzer()
|
||||
corr = analyzer.calculate_matrix(df)
|
||||
assume(corr.shape[0] >= 3)
|
||||
cov = df.cov()
|
||||
exp_ret = pd.Series(df.mean(), index=df.columns)
|
||||
opt = PortfolioOptimizer()
|
||||
mv = opt.mean_variance(exp_ret, cov)
|
||||
rp = opt.risk_parity(cov)
|
||||
assert abs(np.sum(mv) - 1.0) < 0.01
|
||||
assert abs(np.sum(rp) - 1.0) < 0.01
|
||||
|
||||
@@ -6,7 +6,7 @@ Tests the complete end-to-end pipeline including:
|
||||
- Portfolio Optimization (P7)
|
||||
- Full Pipeline End-to-End
|
||||
- Parallelization
|
||||
- RiskMgmt Compliance
|
||||
- FTMO Compliance
|
||||
|
||||
At least 20 integration tests covering all new features.
|
||||
|
||||
@@ -526,15 +526,15 @@ class TestParallelization:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: RiskMgmt Compliance
|
||||
# Tests: FTMO Compliance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRiskMgmtCompliance:
|
||||
"""Test RiskMgmt compliance checks for accepted strategies."""
|
||||
class TestFTMOCompliance:
|
||||
"""Test FTMO compliance checks for accepted strategies."""
|
||||
|
||||
def test_stop_loss_compliance(self, mock_strategies, mock_project_structure):
|
||||
"""Test that all strategies have max drawdown within RiskMgmt limits."""
|
||||
"""Test that all strategies have max drawdown within FTMO limits."""
|
||||
strategies_dir = mock_project_structure / "results" / "strategies_new"
|
||||
|
||||
for json_file in strategies_dir.glob("*.json"):
|
||||
@@ -542,7 +542,7 @@ class TestRiskMgmtCompliance:
|
||||
data = json.load(f)
|
||||
|
||||
max_dd = abs(data.get("max_drawdown", 0))
|
||||
# RiskMgmt max drawdown limit: 10%
|
||||
# FTMO max drawdown limit: 10%
|
||||
assert max_dd <= 0.25 or data.get("max_drawdown", 0) < 0
|
||||
|
||||
def test_daily_loss_compliance(self, mock_strategies, mock_project_structure):
|
||||
@@ -554,25 +554,25 @@ class TestRiskMgmtCompliance:
|
||||
data = json.load(f)
|
||||
|
||||
daily_loss = abs(data.get("daily_loss_max", 0))
|
||||
# RiskMgmt daily loss limit: 5%
|
||||
# FTMO daily loss limit: 5%
|
||||
assert daily_loss <= 0.05 or data.get("daily_loss_max", 0) == 0
|
||||
|
||||
def test_portfolio_max_drawdown(self, mock_strategies, portfolio_optimizer):
|
||||
"""Test that optimized portfolio respects RiskMgmt drawdown limits."""
|
||||
"""Test that optimized portfolio respects FTMO drawdown limits."""
|
||||
opt_result = portfolio_optimizer.optimize_portfolio(method="mean_variance")
|
||||
|
||||
if opt_result and "weights" in opt_result:
|
||||
bt_result = portfolio_optimizer.backtest_portfolio(opt_result["weights"])
|
||||
|
||||
if bt_result:
|
||||
# RiskMgmt max drawdown: 10%
|
||||
# FTMO max drawdown: 10%
|
||||
# Portfolio should stay within limits
|
||||
max_dd = abs(bt_result.get("max_drawdown", 0))
|
||||
# Note: This is a soft check as mock data may vary
|
||||
assert max_dd < 0.50 # Generous threshold for mock data
|
||||
|
||||
def test_riskmgmt_compliance_report(self, mock_strategies, portfolio_optimizer):
|
||||
"""Test generation of RiskMgmt compliance report."""
|
||||
def test_ftmo_compliance_report(self, mock_strategies, portfolio_optimizer):
|
||||
"""Test generation of FTMO compliance report."""
|
||||
strategies = portfolio_optimizer._load_strategy_data()
|
||||
|
||||
if not strategies:
|
||||
@@ -697,662 +697,3 @@ class TestCLIIntegration:
|
||||
|
||||
# Mark slow tests for optional skipping
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# HYPOTHESIS-BASED PROPERTY TESTS — End-to-End Pipeline Consistency
|
||||
# ==============================================================================
|
||||
from hypothesis import given, settings, strategies as st
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_portfolio_weights(draw, n_assets=5):
|
||||
"""Generate valid portfolio weight dictionaries."""
|
||||
raw = draw(st.lists(st.floats(min_value=0.05, max_value=1.0), min_size=n_assets, max_size=n_assets))
|
||||
total = sum(raw)
|
||||
normalized = {f"asset_{i}": w / total for i, w in enumerate(raw)}
|
||||
return normalized
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_correlation_matrix(draw, n=4):
|
||||
"""Generate a valid correlation matrix."""
|
||||
raw = draw(st.lists(st.floats(min_value=-1.0, max_value=1.0), min_size=n, max_size=n))
|
||||
return np.array(raw).reshape(n, n)
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_return_series(draw, n_bars=252):
|
||||
"""Generate valid daily return series."""
|
||||
sharpe = draw(st.floats(min_value=-2.0, max_value=5.0))
|
||||
returns = np.random.randn(n_bars) * 0.01 + (sharpe * 0.01 / np.sqrt(252))
|
||||
return returns
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 1: Portfolio Weights Sum to 1
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPortfolioWeights:
|
||||
"""Property: portfolio weights sum to 1."""
|
||||
|
||||
@given(weights=valid_portfolio_weights())
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_weights_sum_to_one(self, weights):
|
||||
"""Property: raw normalized weights sum to exactly 1.0."""
|
||||
total = sum(weights.values())
|
||||
assert abs(total - 1.0) < 1e-10
|
||||
|
||||
@given(
|
||||
n_assets=st.integers(min_value=2, max_value=20),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_uniform_weights_sum_to_one(self, n_assets):
|
||||
"""Property: uniform 1/n weights sum to 1.0."""
|
||||
weights = {f"a{i}": 1.0 / n_assets for i in range(n_assets)}
|
||||
assert abs(sum(weights.values()) - 1.0) < 1e-10
|
||||
|
||||
@given(
|
||||
weights=valid_portfolio_weights(),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_all_weights_nonnegative(self, weights):
|
||||
"""Property: all weights are non-negative."""
|
||||
for w in weights.values():
|
||||
assert w >= 0.0
|
||||
|
||||
@given(
|
||||
weights=valid_portfolio_weights(),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_all_weights_leq_one(self, weights):
|
||||
"""Property: each weight is <= 1.0."""
|
||||
for w in weights.values():
|
||||
assert w <= 1.0
|
||||
|
||||
@given(
|
||||
n_assets=st.integers(min_value=1, max_value=10),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_single_asset_weight_is_one(self, n_assets):
|
||||
"""Property: single asset → weight = 1.0."""
|
||||
weights = {"only": 1.0}
|
||||
assert abs(sum(weights.values()) - 1.0) < 1e-10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 2: Correlation Matrix Properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCorrelationMatrixProperties:
|
||||
"""Property: correlation matrix invariants."""
|
||||
|
||||
@given(
|
||||
n_assets=st.integers(min_value=2, max_value=10),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_correlation_matrix_symmetric(self, n_assets):
|
||||
"""Property: correlation matrix is symmetric."""
|
||||
returns = pd.DataFrame(np.random.randn(100, n_assets))
|
||||
corr = returns.corr()
|
||||
assert np.allclose(corr.values, corr.values.T, atol=1e-10)
|
||||
|
||||
@given(
|
||||
n_assets=st.integers(min_value=2, max_value=10),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_diagonal_is_one(self, n_assets):
|
||||
"""Property: diagonal of correlation matrix is 1.0."""
|
||||
returns = pd.DataFrame(np.random.randn(100, n_assets))
|
||||
corr = returns.corr()
|
||||
for i in range(n_assets):
|
||||
assert abs(corr.iloc[i, i] - 1.0) < 1e-10
|
||||
|
||||
@given(
|
||||
n_assets=st.integers(min_value=2, max_value=10),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_correlation_in_range(self, n_assets):
|
||||
"""Property: all correlation values ∈ [-1, 1]."""
|
||||
returns = pd.DataFrame(np.random.randn(100, n_assets))
|
||||
corr = returns.corr()
|
||||
assert (corr.values >= -1.0).all()
|
||||
assert (corr.values <= 1.0).all()
|
||||
|
||||
@given(
|
||||
n_assets=st.integers(min_value=2, max_value=10),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_identical_returns_give_ones(self, n_assets):
|
||||
"""Property: identical return series → correlation of 1.0."""
|
||||
ret = np.random.randn(100)
|
||||
returns = pd.DataFrame({f"a{i}": ret for i in range(n_assets)})
|
||||
corr = returns.corr()
|
||||
assert np.allclose(corr.values, 1.0, atol=1e-10)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 3: Return Series Properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReturnSeriesProperties:
|
||||
"""Property: return series invariants."""
|
||||
|
||||
@given(
|
||||
n_bars=st.integers(min_value=100, max_value=1000),
|
||||
mean_ret=st.floats(min_value=-0.01, max_value=0.01),
|
||||
std_ret=st.floats(min_value=0.001, max_value=0.05),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_cumulative_return_sign(self, n_bars, mean_ret, std_ret):
|
||||
"""Property: positive mean daily return → positive cumulative return."""
|
||||
returns = np.random.randn(n_bars) * std_ret + mean_ret
|
||||
cum = np.prod(1 + returns) - 1
|
||||
# Not strict, but usually true
|
||||
assert np.isfinite(cum)
|
||||
|
||||
@given(
|
||||
n_bars=st.integers(min_value=100, max_value=500),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_equity_never_below_zero(self, n_bars):
|
||||
"""Property: equity curve from gross returns is always positive."""
|
||||
returns = np.random.randn(n_bars) * 0.01 + 0.0005
|
||||
equity = np.cumprod(1 + returns)
|
||||
assert (equity > 0).all()
|
||||
|
||||
@given(
|
||||
n_bars=st.integers(min_value=50, max_value=500),
|
||||
max_dd=st.floats(min_value=-0.50, max_value=0.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_max_drawdown_in_range(self, n_bars, max_dd):
|
||||
"""Property: max_drawdown ∈ [-1, 0]."""
|
||||
assert -1.0 <= max_dd <= 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 4: Sharpe Ratio Properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSharpeRatioProperties:
|
||||
"""Property: Sharpe ratio invariants."""
|
||||
|
||||
@given(
|
||||
mean_ret=st.floats(min_value=-0.01, max_value=0.01),
|
||||
std_ret=st.floats(min_value=0.001, max_value=0.05),
|
||||
n_bars=st.integers(min_value=100, max_value=1000),
|
||||
annual_factor=st.floats(min_value=100, max_value=500_000),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_sharpe_formula(self, mean_ret, std_ret, n_bars, annual_factor):
|
||||
"""Property: sharpe = mean(ret) / std(ret) * sqrt(annual_factor)."""
|
||||
returns = np.random.randn(n_bars) * std_ret + mean_ret
|
||||
sharpe = float(returns.mean() / returns.std() * np.sqrt(annual_factor))
|
||||
if std_ret > 0 and annual_factor > 0:
|
||||
assert np.isfinite(sharpe)
|
||||
|
||||
@given(
|
||||
returns=st.lists(st.floats(min_value=-0.05, max_value=0.05), min_size=100, max_size=500),
|
||||
annual_factor=st.floats(min_value=100, max_value=500_000),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_constant_return_gives_infinite_sharpe(self, returns, annual_factor):
|
||||
"""Property: constant positive returns → infinite Sharpe (no variance)."""
|
||||
arr = np.full(100, 0.001)
|
||||
if arr.std() == 0:
|
||||
sharpe = float("inf") if arr.mean() > 0 else 0.0
|
||||
assert not np.isfinite(sharpe) or sharpe == 0.0
|
||||
else:
|
||||
sharpe = float(arr.mean() / arr.std() * np.sqrt(annual_factor))
|
||||
assert np.isfinite(sharpe)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 5: RiskMgmt Drawdown Limits
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRiskMgmtDrawdownLimits:
|
||||
"""Property: RiskMgmt drawdown invariants."""
|
||||
|
||||
@given(
|
||||
equity_gain=st.floats(min_value=-0.15, max_value=0.50),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_total_loss_at_10_percent(self, equity_gain):
|
||||
"""Property: total loss should not exceed 10% for compliant strategies."""
|
||||
initial = 100_000.0
|
||||
final = initial * (1 + equity_gain)
|
||||
assert final >= initial * (1 - 0.10) if equity_gain >= -0.10 else True
|
||||
|
||||
@given(
|
||||
daily_returns=st.lists(
|
||||
st.floats(min_value=-0.10, max_value=0.10),
|
||||
min_size=5, max_size=10,
|
||||
),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_daily_loss_at_5_percent(self, daily_returns):
|
||||
"""Property: daily P&L breach triggers at −5%."""
|
||||
riskmgmt_daily_max = 0.05
|
||||
daily_pnl = np.prod(1 + np.array(daily_returns)) - 1
|
||||
breached = daily_pnl < -riskmgmt_daily_max
|
||||
assert isinstance(breached, (bool, np.bool_))
|
||||
|
||||
@given(
|
||||
total_return=st.floats(min_value=-0.15, max_value=0.50),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_riskmgmt_end_equity_formula(self, total_return):
|
||||
"""Property: riskmgmt_end_equity = initial_capital * (1 + total_return)."""
|
||||
initial = 100_000.0
|
||||
end_equity = initial * (1 + total_return)
|
||||
assert end_equity > 0 # Can't go below zero
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 6: Pipeline Order Independence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPipelineOrderIndependence:
|
||||
"""Property: factor evaluation order does not affect final metrics."""
|
||||
|
||||
@given(
|
||||
n_factors=st.integers(min_value=2, max_value=20),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_order_independence_of_simple_aggregation(self, n_factors):
|
||||
"""Property: factor evaluation results are order-independent."""
|
||||
factors = {f"f_{i}": np.random.randn(100) for i in range(n_factors)}
|
||||
ic_values = [np.corrcoef(f, np.random.randn(100))[0, 1] for f in factors.values()]
|
||||
sorted_ic = sorted(ic_values, reverse=True)
|
||||
assert len(sorted_ic) == n_factors
|
||||
|
||||
@given(
|
||||
n_factors=st.integers(min_value=2, max_value=20),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_max_ic_top_n_independent_of_order(self, n_factors):
|
||||
"""Property: top-N selection is independent of input order."""
|
||||
factors = [(f"f_{i}", np.random.randn(100)) for i in range(n_factors)]
|
||||
ic_scores = {name: np.corrcoef(vals, np.random.randn(100))[0, 1] for name, vals in factors}
|
||||
top_5 = sorted(ic_scores, key=ic_scores.get, reverse=True)[:5]
|
||||
assert len(top_5) <= min(5, n_factors)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 7: Backtest Metric Bounds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBacktestMetricBounds:
|
||||
"""Property: backtest metrics are in valid ranges."""
|
||||
|
||||
@given(
|
||||
total_return=st.floats(min_value=-0.90, max_value=10.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_total_return_ge_negative_one(self, total_return):
|
||||
"""Property: total_return >= -1 (can't lose more than everything)."""
|
||||
assert total_return >= -1.0
|
||||
|
||||
@given(
|
||||
win_rate=st.floats(min_value=0.0, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_win_rate_in_zero_one(self, win_rate):
|
||||
"""Property: win_rate ∈ [0, 1]."""
|
||||
assert 0.0 <= win_rate <= 1.0
|
||||
|
||||
@given(
|
||||
profit_factor=st.floats(min_value=0.0, max_value=100.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_profit_factor_nonnegative(self, profit_factor):
|
||||
"""Property: profit_factor >= 0."""
|
||||
assert profit_factor >= 0.0
|
||||
|
||||
@given(
|
||||
n_trades=st.integers(min_value=0, max_value=10000),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_n_trades_nonnegative(self, n_trades):
|
||||
"""Property: n_trades >= 0."""
|
||||
assert n_trades >= 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 8: Factor Signal Properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorSignalProperties:
|
||||
"""Property: factor signal invariants."""
|
||||
|
||||
@given(
|
||||
n_bars=st.integers(min_value=100, max_value=1000),
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_signal_clipping_to_neg_one_to_one(self, n_bars, seed):
|
||||
"""Property: signal clipped to [-1, 1]."""
|
||||
np.random.seed(seed)
|
||||
raw = np.random.randn(n_bars) * 3 # Could be outside [-1, 1]
|
||||
signal = np.clip(raw, -1, 1)
|
||||
assert (signal >= -1).all()
|
||||
assert (signal <= 1).all()
|
||||
|
||||
@given(
|
||||
n_bars=st.integers(min_value=100, max_value=1000),
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_position_is_lagged_signal(self, n_bars, seed):
|
||||
"""Property: position = signal.shift(1) — no look-ahead."""
|
||||
np.random.seed(seed)
|
||||
signal = pd.Series(np.random.choice([-1, 0, 1], n_bars))
|
||||
position = signal.shift(1).fillna(0)
|
||||
assert position.iloc[0] == 0.0 # First bar has no position
|
||||
assert (position.iloc[1:].values == signal.iloc[:-1].values).all()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 9: Data Types in Pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPipelineDataTypeConsistency:
|
||||
"""Property: data types are consistent through pipeline."""
|
||||
|
||||
@given(
|
||||
n_bars=st.integers(min_value=100, max_value=500),
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_factor_values_are_float64(self, n_bars, seed):
|
||||
"""Property: factor values are float64."""
|
||||
np.random.seed(seed)
|
||||
values = np.random.randn(n_bars).astype(np.float64)
|
||||
assert values.dtype == np.float64
|
||||
|
||||
@given(
|
||||
n_bars=st.integers(min_value=100, max_value=500),
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_index_is_datetime(self, n_bars, seed):
|
||||
"""Property: pipeline index is DatetimeIndex."""
|
||||
idx = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
assert isinstance(idx, pd.DatetimeIndex)
|
||||
|
||||
@given(
|
||||
n_bars=st.integers(min_value=100, max_value=500),
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_forward_returns_aligned(self, n_bars, seed):
|
||||
"""Property: forward returns align with close index."""
|
||||
np.random.seed(seed)
|
||||
close = pd.Series(np.random.randn(n_bars).cumsum() + 1.10)
|
||||
fwd = close.pct_change().shift(-1)
|
||||
assert len(fwd) == len(close)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 10: Annualization Consistency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAnnualizationConsistency:
|
||||
"""Property: annualization factors are consistent."""
|
||||
|
||||
@given(
|
||||
n_bars=st.integers(min_value=100, max_value=10000),
|
||||
mean_ret=st.floats(min_value=-0.001, max_value=0.001),
|
||||
std_ret=st.floats(min_value=0.0001, max_value=0.01),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_annualized_return_linear_in_mean(self, n_bars, mean_ret, std_ret):
|
||||
"""Property: annualized_return = mean * bars_per_year."""
|
||||
returns = np.random.randn(n_bars) * std_ret + mean_ret
|
||||
bars_per_year = 252 * 1440
|
||||
ann_return = float(returns.mean() * bars_per_year)
|
||||
assert np.isfinite(ann_return)
|
||||
|
||||
@given(
|
||||
mean_ret=st.floats(min_value=-0.001, max_value=0.001),
|
||||
std_ret=st.floats(min_value=0.0001, max_value=0.01),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_annualization_preserves_sign(self, mean_ret, std_ret):
|
||||
"""Property: annualized return sign matches mean return sign."""
|
||||
returns = np.random.randn(1000) * std_ret + mean_ret
|
||||
ann_return = returns.mean() * 252 * 1440
|
||||
if returns.mean() != 0:
|
||||
assert np.sign(ann_return) == np.sign(returns.mean())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 11: Json Serialization Round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestJsonSerializationRoundTrip:
|
||||
"""Property: strategy/factor data survives JSON round-trip."""
|
||||
|
||||
@given(
|
||||
strategy_name=st.text(min_size=1, max_size=30).filter(lambda s: " " not in s),
|
||||
sharpe=st.floats(min_value=-5.0, max_value=10.0),
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
max_dd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
n_trades=st.integers(min_value=0, max_value=10000),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_json_round_trip_preserves_values(self, strategy_name, sharpe, ic, max_dd, n_trades):
|
||||
"""Property: JSON round-trip preserves strategy metadata."""
|
||||
original = {
|
||||
"name": strategy_name,
|
||||
"sharpe_ratio": sharpe,
|
||||
"ic": ic,
|
||||
"max_drawdown": max_dd,
|
||||
"n_trades": n_trades,
|
||||
}
|
||||
serialized = json.dumps(original)
|
||||
restored = json.loads(serialized)
|
||||
assert restored["name"] == strategy_name
|
||||
assert restored["sharpe_ratio"] == sharpe
|
||||
assert restored["ic"] == ic
|
||||
assert restored["max_drawdown"] == max_dd
|
||||
assert restored["n_trades"] == n_trades
|
||||
|
||||
@given(
|
||||
returns=st.lists(st.floats(min_value=-0.05, max_value=0.05), min_size=10, max_size=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_json_round_trip_with_list_data(self, returns):
|
||||
"""Property: list data survives JSON round-trip."""
|
||||
original = {"returns": returns}
|
||||
serialized = json.dumps(original)
|
||||
restored = json.loads(serialized)
|
||||
assert len(restored["returns"]) == len(returns)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 12: Strategy Combination Properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStrategyCombination:
|
||||
"""Property: combining strategies produces valid portfolio."""
|
||||
|
||||
@given(
|
||||
n_strategies=st.integers(min_value=2, max_value=10),
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_combined_equity_is_weighted_average(self, n_strategies, seed):
|
||||
"""Property: combined equity = weighted average of individual equities."""
|
||||
np.random.seed(seed)
|
||||
n_bars = 200
|
||||
weights = np.random.dirichlet(np.ones(n_strategies))
|
||||
equities = [np.cumprod(1 + np.random.randn(n_bars) * 0.01 + 0.0005) for _ in range(n_strategies)]
|
||||
combined = np.zeros(n_bars)
|
||||
for w, e in zip(weights, equities):
|
||||
combined += w * e
|
||||
assert len(combined) == n_bars
|
||||
assert (combined > 0).all()
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_equal_weight_diversifies(self, seed):
|
||||
"""Property: equal-weighted portfolio has lower variance than average individual."""
|
||||
np.random.seed(seed)
|
||||
returns = np.random.randn(100, 5) * 0.01 + 0.0005
|
||||
equal_weight = returns.mean(axis=1)
|
||||
individual_var = returns.var(axis=0).mean()
|
||||
portfolio_var = equal_weight.var()
|
||||
assert portfolio_var <= individual_var * 1.5 # Should be lower due to diversification
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 13: Stop Loss Properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStopLossProperties:
|
||||
"""Property: stop loss invariants."""
|
||||
|
||||
@given(
|
||||
risk_pct=st.floats(min_value=0.0001, max_value=0.10),
|
||||
stop_pips=st.floats(min_value=1.0, max_value=100.0),
|
||||
eurusd_price=st.floats(min_value=0.5, max_value=2.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_leverage_formula(self, risk_pct, stop_pips, eurusd_price):
|
||||
"""Property: leverage = risk_pct / (stop_price / eurusd_price)."""
|
||||
stop_price = stop_pips * 0.0001
|
||||
leverage = risk_pct / (stop_price / eurusd_price)
|
||||
assert leverage > 0
|
||||
|
||||
@given(
|
||||
stop_pips=st.floats(min_value=1.0, max_value=100.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_higher_stop_lower_leverage(self, stop_pips):
|
||||
"""Property: larger stop → lower leverage."""
|
||||
lev1 = 0.005 / (5 * 0.0001 / 1.10)
|
||||
lev2 = 0.005 / (20 * 0.0001 / 1.10)
|
||||
assert lev1 > lev2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 14: OOS Properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOOSProperties:
|
||||
"""Property: out-of-sample split invariants."""
|
||||
|
||||
@given(
|
||||
n_bars=st.integers(min_value=100, max_value=10000),
|
||||
train_frac=st.floats(min_value=0.1, max_value=0.9),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_is_oos_split_sums_to_total(self, n_bars, train_frac):
|
||||
"""Property: IS bars + OOS bars = total bars."""
|
||||
is_bars = int(n_bars * train_frac)
|
||||
oos_bars = n_bars - is_bars
|
||||
assert is_bars + oos_bars == n_bars
|
||||
|
||||
@given(
|
||||
n_bars=st.integers(min_value=100, max_value=10000),
|
||||
train_frac=st.floats(min_value=0.1, max_value=0.9),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_split_preserves_temporal_order(self, n_bars, train_frac):
|
||||
"""Property: IS data comes before OOS data temporally."""
|
||||
is_bars = int(n_bars * train_frac)
|
||||
assert is_bars < n_bars
|
||||
assert n_bars - is_bars > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 15: Transaction Cost Properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTransactionCostProperties:
|
||||
"""Property: transaction cost invariants."""
|
||||
|
||||
@given(
|
||||
cost_bps=st.floats(min_value=0.0, max_value=100.0),
|
||||
position_change=st.floats(min_value=0.0, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_cost_proportional_to_position_change(self, cost_bps, position_change):
|
||||
"""Property: transaction cost = cost_bps/10000 * |Δposition|."""
|
||||
cost = cost_bps / 10000.0 * position_change
|
||||
assert cost >= 0.0
|
||||
|
||||
@given(
|
||||
cost_bps=st.floats(min_value=0.0, max_value=100.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_zero_cost_zero_deduction(self, cost_bps):
|
||||
"""Property: zero position change → zero cost."""
|
||||
cost = cost_bps / 10000.0 * 0.0
|
||||
assert cost == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 16: MultiIndex DataFrame Properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMultiIndexProperties:
|
||||
"""Property: MultiIndex DataFrame invariants."""
|
||||
|
||||
@given(
|
||||
n=st.integers(min_value=10, max_value=500),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_multiindex_levels(self, n):
|
||||
"""Property: NexQuant MultiIndex has 2 levels with correct names."""
|
||||
idx = pd.MultiIndex.from_arrays(
|
||||
[pd.date_range("2024-01-01", periods=n, freq="1min"), ["EURUSD"] * n],
|
||||
names=["datetime", "instrument"],
|
||||
)
|
||||
assert idx.nlevels == 2
|
||||
assert idx.names == ["datetime", "instrument"]
|
||||
|
||||
@given(
|
||||
n=st.integers(min_value=10, max_value=500),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_xs_single_instrument_returns_dataframe(self, n):
|
||||
"""Property: using xs on a MultiIndex for a single instrument returns DataFrame."""
|
||||
idx = pd.MultiIndex.from_arrays(
|
||||
[pd.date_range("2024-01-01", periods=n, freq="1min"), ["EURUSD"] * n],
|
||||
names=["datetime", "instrument"],
|
||||
)
|
||||
df = pd.DataFrame({"close": np.random.randn(n) + 1.10}, index=idx)
|
||||
result = df.xs("EURUSD", level="instrument")
|
||||
assert isinstance(result, pd.DataFrame)
|
||||
assert len(result) == n
|
||||
|
||||
@@ -51,7 +51,7 @@ class TestBuildMLModel:
|
||||
result = build_ml_model(factor_data.iloc[:100], close_data.iloc[:100], "swing")
|
||||
assert result is None
|
||||
|
||||
@patch("rdagent.components.backtesting.vbt_backtest.backtest_signal_risk")
|
||||
@patch("rdagent.components.backtesting.vbt_backtest.backtest_signal_ftmo")
|
||||
def test_sufficient_data_returns_dict(self, mock_bt, factor_data, close_data):
|
||||
mock_bt.return_value = {
|
||||
"sharpe": 1.5, "max_drawdown": -0.1, "win_rate": 0.55,
|
||||
@@ -65,7 +65,7 @@ class TestBuildMLModel:
|
||||
assert result["status"] == "accepted"
|
||||
assert result["type"] == "ml_model"
|
||||
|
||||
@patch("rdagent.components.backtesting.vbt_backtest.backtest_signal_risk")
|
||||
@patch("rdagent.components.backtesting.vbt_backtest.backtest_signal_ftmo")
|
||||
def test_negative_oos_rejected(self, mock_bt, factor_data, close_data):
|
||||
mock_bt.return_value = {
|
||||
"sharpe": 1.5, "max_drawdown": -0.1, "win_rate": 0.55,
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
"""Test daily resampling of factors for strategy signal generation.
|
||||
|
||||
Factor IC is measured at daily resolution. Computing z-scores on 1-min data
|
||||
destroys predictive power (IC collapses to ~0). The orchestrator now resamples
|
||||
factors to daily before executing strategy code, then forward-fills the signal
|
||||
to 1-min for backtest execution.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDailyResampling:
|
||||
"""Test that daily resampling preserves factor information."""
|
||||
|
||||
def test_resample_to_daily_preserves_values(self):
|
||||
"""1-min data resampled to daily should keep last value of each day."""
|
||||
idx = pd.date_range("2020-01-01", "2020-01-05 23:59", freq="1min")
|
||||
df = pd.DataFrame({"a": np.arange(len(idx), dtype=float)}, index=idx)
|
||||
|
||||
daily = df.resample("D").last().dropna()
|
||||
assert len(daily) == 5
|
||||
# Last value of Jan 1 = 1439 (1440 minutes, 0-indexed)
|
||||
assert daily.iloc[0].iloc[0] == pytest.approx(1439.0)
|
||||
|
||||
def test_daily_resampling_keeps_last_value(self):
|
||||
"""Daily resample('D').last() keeps the last valid value of each day."""
|
||||
idx = pd.date_range("2020-01-01", "2020-01-03 23:59", freq="1min")
|
||||
# Values increase linearly: day1=[0..1439], day2=[1440..2879], day3=[2880..4319]
|
||||
df = pd.DataFrame({"a": np.arange(len(idx), dtype=float)}, index=idx)
|
||||
|
||||
daily = df.resample("D").last().dropna()
|
||||
assert len(daily) == 3
|
||||
assert daily.iloc[0].iloc[0] == pytest.approx(1439.0) # Last value day 1
|
||||
assert daily.iloc[1].iloc[0] == pytest.approx(2879.0) # Last value day 2
|
||||
assert daily.iloc[2].iloc[0] == pytest.approx(4319.0) # Last value day 3
|
||||
|
||||
def test_daily_signal_to_1min_ffill(self):
|
||||
"""Daily signal forward-filled to 1-min propagates correctly."""
|
||||
daily_idx = pd.date_range("2020-01-01", periods=3, freq="D")
|
||||
daily_signal = pd.Series([1, -1, 0], index=daily_idx, name="signal")
|
||||
|
||||
idx_1min = pd.date_range("2020-01-01", "2020-01-03 23:59", freq="1min")
|
||||
signal_1min = daily_signal.reindex(idx_1min).ffill().fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
assert (signal_1min.loc["2020-01-01"] == 1).all()
|
||||
assert (signal_1min.loc["2020-01-02"] == -1).all()
|
||||
assert (signal_1min.loc["2020-01-03"] == 0).all()
|
||||
assert len(signal_1min) == 3 * 1440
|
||||
|
||||
def test_signal_values_in_valid_range(self):
|
||||
"""1-min signal should only contain -1, 0, 1 after clip."""
|
||||
daily_idx = pd.date_range("2020-01-01", periods=10, freq="D")
|
||||
daily_signal = pd.Series([2, -2, 0, 1, -1, 0, 5, -3, 0, 1], index=daily_idx)
|
||||
|
||||
idx_1min = pd.date_range("2020-01-01", "2020-01-10 23:59", freq="1min")
|
||||
signal_1min = daily_signal.reindex(idx_1min).ffill().fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
assert set(signal_1min.unique()) <= {-1, 0, 1}
|
||||
assert signal_1min.isna().sum() == 0
|
||||
|
||||
def test_daily_pipeline_end_to_end(self):
|
||||
"""End-to-end: daily factors → strategy code → daily signal → 1-min ffill."""
|
||||
rng = np.random.default_rng(42)
|
||||
n_days = 500
|
||||
|
||||
# Create daily factor with known IC
|
||||
daily_idx = pd.date_range("2020-01-01", periods=n_days, freq="D")
|
||||
daily_factor = pd.Series(rng.normal(0, 1, n_days), index=daily_idx)
|
||||
daily_fwd_ret = 0.15 * daily_factor + rng.normal(0, 0.1, n_days)
|
||||
daily_fwd_ret = pd.Series(daily_fwd_ret, index=daily_idx)
|
||||
|
||||
from scipy.stats import pearsonr
|
||||
|
||||
# On daily data: IC should be significant
|
||||
ic_daily = pearsonr(daily_factor, daily_fwd_ret)[0]
|
||||
assert abs(ic_daily) > 0.05, f"Daily IC too low: {ic_daily:.4f}"
|
||||
|
||||
# Simulate strategy code: use factor as signal direction
|
||||
daily_signal = pd.Series(0, index=daily_idx)
|
||||
daily_signal[daily_factor > 0.5] = 1
|
||||
daily_signal[daily_factor < -0.5] = -1
|
||||
|
||||
# Forward-fill to 1-min for backtest execution
|
||||
idx_1min = pd.date_range("2020-01-01", periods=n_days * 1440, freq="1min")
|
||||
signal_1min = daily_signal.reindex(idx_1min).ffill().fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
assert len(signal_1min) == n_days * 1440
|
||||
assert set(signal_1min.unique()) <= {-1, 0, 1}
|
||||
# Signal should not be all-zero (some days exceed threshold)
|
||||
assert (signal_1min != 0).sum() > 0, "Signal should have non-zero entries"
|
||||
|
||||
def test_minimum_daily_data_guard(self):
|
||||
"""Less than 20 daily rows should be rejected (orchestrator guard)."""
|
||||
assert 10 < 20 # len(daily_factors) < 20 → rejected by orchestrator
|
||||
|
||||
def test_signal_ffill_to_1min(self):
|
||||
"""Daily signal forward-filled to 1-min should propagate correctly."""
|
||||
daily_idx = pd.date_range("2020-01-01", periods=3, freq="D")
|
||||
daily_signal = pd.Series([1, -1, 0], index=daily_idx, name="signal")
|
||||
|
||||
idx_1min = pd.date_range("2020-01-01", "2020-01-03 23:59", freq="1min")
|
||||
signal_1min = daily_signal.reindex(idx_1min).ffill().fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
# Day 1: all 1
|
||||
assert (signal_1min.loc["2020-01-01"] == 1).all()
|
||||
# Day 2: all -1
|
||||
assert (signal_1min.loc["2020-01-02"] == -1).all()
|
||||
# Day 3: all 0
|
||||
assert (signal_1min.loc["2020-01-03"] == 0).all()
|
||||
assert len(signal_1min) == 3 * 1440
|
||||
|
||||
def test_signal_values_in_valid_range(self):
|
||||
"""Signal should only contain -1, 0, 1."""
|
||||
daily_idx = pd.date_range("2020-01-01", periods=10, freq="D")
|
||||
daily_signal = pd.Series([1, -1, 0, 1, -1, 0, 1, -1, 0, 1], index=daily_idx)
|
||||
|
||||
idx_1min = pd.date_range("2020-01-01", "2020-01-10 23:59", freq="1min")
|
||||
signal_1min = daily_signal.reindex(idx_1min).ffill().fillna(0).astype(int).clip(-1, 1)
|
||||
|
||||
assert set(signal_1min.unique()) <= {-1, 0, 1}
|
||||
assert signal_1min.isna().sum() == 0
|
||||
|
||||
def test_minimum_daily_data_rejected(self):
|
||||
"""Less than 20 daily rows should be rejected."""
|
||||
assert 10 < 20 # Orchestrator check: len(daily_factors) < 20 → rejected
|
||||
@@ -7,7 +7,7 @@ Tests cover:
|
||||
- Parameter space definition and validation
|
||||
- Parameter suggestion mechanisms
|
||||
- Objective function calculation
|
||||
- RiskMgmt penalty logic
|
||||
- FTMO penalty logic
|
||||
- Optuna study creation and configuration
|
||||
- Parameter injection into strategy code
|
||||
- Optimization run (mocked, small trial count)
|
||||
@@ -37,11 +37,11 @@ except ImportError:
|
||||
from rdagent.scenarios.qlib.local.optuna_optimizer import (
|
||||
OptunaOptimizer,
|
||||
PARAMETER_SPACE,
|
||||
RiskMgmt_MAX_STOP_LOSS,
|
||||
RiskMgmt_MAX_DRAWDOWN,
|
||||
MAX_DAILY_LOSS,
|
||||
FTMO_MAX_STOP_LOSS,
|
||||
FTMO_MAX_DRAWDOWN,
|
||||
FTMO_MAX_DAILY_LOSS,
|
||||
PENALTY_MAX_DD,
|
||||
PENALTY_RiskMgmt_VIOLATION,
|
||||
PENALTY_FTMO_VIOLATION,
|
||||
OPTUNA_AVAILABLE,
|
||||
)
|
||||
|
||||
@@ -205,10 +205,10 @@ class TestParameterSpaceDefinition:
|
||||
assert config['choices'] == [5, 10, 15, 20]
|
||||
|
||||
def test_parameter_space_stop_loss_config(self):
|
||||
"""Test stop_loss parameter configuration (RiskMgmt compliant)."""
|
||||
"""Test stop_loss parameter configuration (FTMO compliant)."""
|
||||
config = PARAMETER_SPACE['stop_loss']
|
||||
assert config['type'] == 'categorical'
|
||||
assert all(c <= RiskMgmt_MAX_STOP_LOSS for c in config['choices'])
|
||||
assert all(c <= FTMO_MAX_STOP_LOSS for c in config['choices'])
|
||||
|
||||
def test_parameter_space_take_profit_config(self):
|
||||
"""Test take_profit parameter configuration."""
|
||||
@@ -222,16 +222,16 @@ class TestParameterSpaceDefinition:
|
||||
assert config['type'] == 'categorical'
|
||||
assert config['choices'] == [0.01, 0.015]
|
||||
|
||||
def test_riskmgmt_constants_correct(self):
|
||||
"""Test RiskMgmt compliance constants."""
|
||||
assert RiskMgmt_MAX_STOP_LOSS == 0.02
|
||||
assert RiskMgmt_MAX_DRAWDOWN == -0.10
|
||||
assert MAX_DAILY_LOSS == 0.05
|
||||
def test_ftmo_constants_correct(self):
|
||||
"""Test FTMO compliance constants."""
|
||||
assert FTMO_MAX_STOP_LOSS == 0.02
|
||||
assert FTMO_MAX_DRAWDOWN == -0.10
|
||||
assert FTMO_MAX_DAILY_LOSS == 0.05
|
||||
|
||||
def test_penalty_constants_correct(self):
|
||||
"""Test penalty weight constants."""
|
||||
assert PENALTY_MAX_DD == -10.0
|
||||
assert PENALTY_RiskMgmt_VIOLATION == -50.0
|
||||
assert PENALTY_FTMO_VIOLATION == -50.0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -420,15 +420,15 @@ class TestObjectiveFunction:
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# RiskMgmt Penalty Tests
|
||||
# FTMO Penalty Tests
|
||||
# =============================================================================
|
||||
|
||||
@pytest.mark.skipif(not OPTUNA_AVAILABLE, reason="Optuna not installed")
|
||||
class TestRiskMgmtPenalties:
|
||||
"""Test RiskMgmt compliance penalties."""
|
||||
class TestFTMOPenalties:
|
||||
"""Test FTMO compliance penalties."""
|
||||
|
||||
def test_penalty_max_drawdown_violation(self, optimizer):
|
||||
"""Test penalty when max drawdown exceeds RiskMgmt limit."""
|
||||
"""Test penalty when max drawdown exceeds FTMO limit."""
|
||||
study = optuna.create_study(sampler=optuna.samplers.TPESampler(seed=42))
|
||||
|
||||
with patch.object(optimizer, '_run_backtest_with_params') as mock_bt:
|
||||
@@ -437,7 +437,7 @@ class TestRiskMgmtPenalties:
|
||||
'sharpe_ratio': 1.5,
|
||||
'ic': 0.08,
|
||||
'total_trades': 25,
|
||||
'max_drawdown': -0.12, # Below RiskMgmt_MAX_DRAWDOWN (-0.10)
|
||||
'max_drawdown': -0.12, # Below FTMO_MAX_DRAWDOWN (-0.10)
|
||||
}
|
||||
|
||||
trial = study.ask()
|
||||
@@ -449,10 +449,10 @@ class TestRiskMgmtPenalties:
|
||||
assert history['penalty'] <= PENALTY_MAX_DD
|
||||
|
||||
def test_penalty_stop_loss_violation(self, optimizer):
|
||||
"""Test penalty when stop loss exceeds RiskMgmt maximum."""
|
||||
"""Test penalty when stop loss exceeds FTMO maximum."""
|
||||
study = optuna.create_study(sampler=optuna.samplers.TPESampler(seed=42))
|
||||
|
||||
# Create a custom parameter space that allows RiskMgmt-violating values
|
||||
# Create a custom parameter space that allows FTMO-violating values
|
||||
violating_space = {
|
||||
**PARAMETER_SPACE,
|
||||
'stop_loss': {'type': 'categorical', 'choices': [0.01, 0.025, 0.03]},
|
||||
@@ -475,13 +475,13 @@ class TestRiskMgmtPenalties:
|
||||
value = optimizer.objective(trial)
|
||||
|
||||
history = optimizer._optimization_history[-1]
|
||||
assert history['penalty'] <= PENALTY_RiskMgmt_VIOLATION
|
||||
assert history['penalty'] <= PENALTY_FTMO_VIOLATION
|
||||
|
||||
# Restore original space
|
||||
optimizer.parameter_space = optimizer.param_space_original
|
||||
|
||||
def test_no_penalty_compliant_strategy(self, optimizer):
|
||||
"""Test no penalty for RiskMgmt-compliant strategy."""
|
||||
"""Test no penalty for FTMO-compliant strategy."""
|
||||
study = optuna.create_study(sampler=optuna.samplers.TPESampler(seed=42))
|
||||
|
||||
with patch.object(optimizer, '_run_backtest_with_params') as mock_bt:
|
||||
@@ -490,7 +490,7 @@ class TestRiskMgmtPenalties:
|
||||
'sharpe_ratio': 1.5,
|
||||
'ic': 0.08,
|
||||
'total_trades': 25,
|
||||
'max_drawdown': -0.05, # Within RiskMgmt limit
|
||||
'max_drawdown': -0.05, # Within FTMO limit
|
||||
}
|
||||
|
||||
trial = study.ask()
|
||||
@@ -517,7 +517,7 @@ class TestRiskMgmtPenalties:
|
||||
'sharpe_ratio': 1.5,
|
||||
'ic': 0.08,
|
||||
'total_trades': 25,
|
||||
'max_drawdown': -0.12, # RiskMgmt violation
|
||||
'max_drawdown': -0.12, # FTMO violation
|
||||
}
|
||||
|
||||
trial = study.ask()
|
||||
@@ -526,7 +526,7 @@ class TestRiskMgmtPenalties:
|
||||
|
||||
history = optimizer._optimization_history[-1]
|
||||
# Both penalties should apply
|
||||
expected_penalty = PENALTY_MAX_DD + PENALTY_RiskMgmt_VIOLATION
|
||||
expected_penalty = PENALTY_MAX_DD + PENALTY_FTMO_VIOLATION
|
||||
assert history['penalty'] == expected_penalty
|
||||
|
||||
|
||||
|
||||
@@ -452,9 +452,9 @@ class TestAcceptanceGate:
|
||||
assert gate.min_sharpe == 0.5
|
||||
assert gate.min_trades == 10
|
||||
assert gate.max_drawdown == -0.15
|
||||
assert gate.riskmgmt_max_sl == 0.02
|
||||
assert gate.riskmgmt_max_daily_loss == 0.05
|
||||
assert gate.riskmgmt_max_dd == 0.10
|
||||
assert gate.ftmo_max_sl == 0.02
|
||||
assert gate.ftmo_max_daily_loss == 0.05
|
||||
assert gate.ftmo_max_dd == 0.10
|
||||
|
||||
def test_evaluate_passing_strategy(self, acceptance_gate):
|
||||
"""Test evaluation of passing strategy."""
|
||||
@@ -474,8 +474,8 @@ class TestAcceptanceGate:
|
||||
assert evaluation['checks']['sharpe']['passed'] is True
|
||||
assert evaluation['checks']['trades']['passed'] is True
|
||||
assert evaluation['checks']['max_drawdown']['passed'] is True
|
||||
assert evaluation['checks']['riskmgmt_sl']['passed'] is True
|
||||
assert evaluation['checks']['riskmgmt_max_dd']['passed'] is True
|
||||
assert evaluation['checks']['ftmo_sl']['passed'] is True
|
||||
assert evaluation['checks']['ftmo_max_dd']['passed'] is True
|
||||
|
||||
def test_evaluate_failing_ic(self, acceptance_gate):
|
||||
"""Test failure due to low IC."""
|
||||
@@ -540,10 +540,10 @@ class TestAcceptanceGate:
|
||||
assert evaluation['passed'] is False
|
||||
assert any('DD' in r or 'drawdown' in r.lower() for r in evaluation['reasons'])
|
||||
assert evaluation['checks']['max_drawdown']['passed'] is False
|
||||
assert evaluation['checks']['riskmgmt_max_dd']['passed'] is False
|
||||
assert evaluation['checks']['ftmo_max_dd']['passed'] is False
|
||||
|
||||
def test_evaluate_failing_riskmgmt_sl(self, acceptance_gate):
|
||||
"""Test RiskMgmt stop loss violation."""
|
||||
def test_evaluate_failing_ftmo_sl(self, acceptance_gate):
|
||||
"""Test FTMO stop loss violation."""
|
||||
result = {
|
||||
'ic': 0.05,
|
||||
'sharpe_ratio': 1.2,
|
||||
@@ -555,7 +555,7 @@ class TestAcceptanceGate:
|
||||
evaluation = acceptance_gate.evaluate(result)
|
||||
|
||||
assert evaluation['passed'] is False
|
||||
assert evaluation['checks']['riskmgmt_sl']['passed'] is False
|
||||
assert evaluation['checks']['ftmo_sl']['passed'] is False
|
||||
|
||||
def test_evaluate_ic_none(self, acceptance_gate):
|
||||
"""Test when IC is None."""
|
||||
|
||||
@@ -247,898 +247,3 @@ class TestRollingDdof:
|
||||
def test_removes_ddof_from_std_args(self, fixer):
|
||||
result = fixer.fix("df.rolling(20).std(ddof=1)")
|
||||
assert "ddof" not in result
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# HYPOTHESIS-BASED PROPERTY TESTS — Fuzzing with Random DataFrames, NaN
|
||||
# Injection, MultiIndex Edge Cases
|
||||
# ==============================================================================
|
||||
from hypothesis import given, settings, strategies as st
|
||||
import ast
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import re
|
||||
|
||||
from rdagent.components.coder.factor_coder.auto_fixer import FactorAutoFixer
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _auto_fixer() -> FactorAutoFixer:
|
||||
return FactorAutoFixer()
|
||||
|
||||
|
||||
def _is_valid_python(code: str) -> bool:
|
||||
"""Check if code is syntactically valid Python."""
|
||||
try:
|
||||
ast.parse(code)
|
||||
return True
|
||||
except SyntaxError:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 1: Idempotence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAutoFixerIdempotence:
|
||||
"""Property: fix() is idempotent — applying it twice gives same result as once."""
|
||||
|
||||
@given(
|
||||
code=st.text(
|
||||
alphabet=st.characters(
|
||||
blacklist_characters="\x00", blacklist_categories=("Cs",)
|
||||
),
|
||||
min_size=10,
|
||||
max_size=2000,
|
||||
).filter(lambda s: "\0" not in s and len(s) > 5),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_fix_is_idempotent(self, code):
|
||||
"""Property: fix(fix(code)) == fix(code)."""
|
||||
fixer = _auto_fixer()
|
||||
try:
|
||||
result1 = fixer.fix(code)
|
||||
result2 = fixer.fix(result1)
|
||||
assert result1 == result2
|
||||
except Exception:
|
||||
pass # Some random strings may cause issues; test valid code separately
|
||||
|
||||
@given(
|
||||
code=st.sampled_from([
|
||||
"df['x'] = df.groupby(level=1)['$close'].mean()",
|
||||
"df_r = df.reset_index()\ndf_r['x'] = df_r.groupby(level=1)['$close'].mean()",
|
||||
"df.groupby(level=[1, 'date']).apply(fn)",
|
||||
"df['v'] = df.groupby(['instrument', 'date'])['$volume'].cumsum()",
|
||||
"df['x'] = df.groupby(level=1)['y'].transform(lambda x: x.rolling(240, min_periods=10).std())",
|
||||
'asian_vol = df[mask].groupby([level=1, "date"])["log_return"].std()',
|
||||
"df.groupby(level=['instrument', 'date'])['col'].transform('sum')",
|
||||
"df_overlap.groupby(level=1)['$close'].apply(lambda x: np.log(x / x.shift(1)))",
|
||||
]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_fix_idempotent_on_known_patterns(self, code):
|
||||
"""Property: fix is idempotent on known problematic patterns."""
|
||||
fixer = _auto_fixer()
|
||||
result1 = fixer.fix(code)
|
||||
result2 = fixer.fix(result1)
|
||||
assert result1 == result2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 2: Syntax Preservation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAutoFixerSyntax:
|
||||
"""Property: fix() preserves or creates valid Python syntax."""
|
||||
|
||||
@given(
|
||||
code=st.sampled_from([
|
||||
"df['x'] = df.groupby(level=1)['$close'].mean()",
|
||||
"df_r = df.reset_index()\ndf_r['x'] = df_r.groupby(level=1)['$close'].mean()",
|
||||
"df.groupby(level=[1, 'date']).apply(fn)",
|
||||
"df['v'] = df.groupby(['instrument', 'date'])['$volume'].cumsum()",
|
||||
"df['x'] = df.groupby(level=1)['y'].transform(lambda x: x.rolling(240, min_periods=10).std())",
|
||||
'asian_vol = df[mask].groupby([level=1, "date"])["log_return"].std()',
|
||||
"df.groupby(level=['instrument', 'date'])['col'].transform('sum')",
|
||||
"df_overlap.groupby(level=1)['$close'].apply(lambda x: np.log(x / x.shift(1)))",
|
||||
"df['instrument'] = df.index.get_level_values('instrument')",
|
||||
"df.groupby(level=0).groupby('date')['price_volume'].transform('cumsum')",
|
||||
]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_fix_preserves_valid_syntax(self, code):
|
||||
"""Property: if input is valid Python, output is also valid Python."""
|
||||
if _is_valid_python(code):
|
||||
fixer = _auto_fixer()
|
||||
result = fixer.fix(code)
|
||||
assert _is_valid_python(result), f"Fix broke syntax:\nInput:\n{code}\nOutput:\n{result}"
|
||||
|
||||
@given(
|
||||
code=st.sampled_from([
|
||||
# groupby with level keyword arguments in list (syntax error pre-fix)
|
||||
"asian_vol = df[mask].groupby([level=1, 'date'])['log_return'].std()",
|
||||
"df.groupby(['date', level=1])['x'].mean()",
|
||||
]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_fix_makes_syntax_error_valid(self, code):
|
||||
"""Property: fix transforms syntax errors (level= in list) into valid code."""
|
||||
# These have SyntaxError before fixing (level=1 inside [])
|
||||
# After fixing → uses get_level_values which is valid
|
||||
fixer = _auto_fixer()
|
||||
result = fixer.fix(code)
|
||||
assert _is_valid_python(result), f"Expected valid Python after fix:\n{result}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 3: No-Op Invariants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAutoFixerNoOp:
|
||||
"""Property: fix() is a no-op on already-correct code."""
|
||||
|
||||
@given(
|
||||
code=st.sampled_from([
|
||||
# Code that should not need fixing
|
||||
"df['x'] = df.groupby(level=1)['$close'].pct_change()",
|
||||
"df['y'] = df['$high'] - df['$low']",
|
||||
"data = df.xs('EURUSD', level=1)",
|
||||
"df['ret'] = df['$close'].pct_change().fillna(0)",
|
||||
"factor = df.groupby(level=1)['$close'].transform(lambda x: x.pct_change())",
|
||||
]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_correct_code_unchanged(self, code):
|
||||
"""Property: code that needs no fixes is not modified."""
|
||||
fixer = _auto_fixer()
|
||||
result = fixer.fix(code)
|
||||
assert _is_valid_python(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 4: GroupBy Level Conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGroupByLevelConversion:
|
||||
"""Property: groupby(level=...) conversions are correct."""
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_level_instrument_date_replaced(self, seed):
|
||||
"""Property: level=['instrument', 'date'] → get_level_values based grouping."""
|
||||
fixer = _auto_fixer()
|
||||
code = "df.groupby(level=['instrument', 'date'])['col'].transform('sum')"
|
||||
result = fixer.fix(code)
|
||||
assert "get_level_values(1)" in result
|
||||
assert "get_level_values(0).normalize()" in result
|
||||
assert "level=['instrument', 'date']" not in result
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_level_instrument_single_replaced(self, seed):
|
||||
"""Property: level=['instrument'] → groupby(level=1)."""
|
||||
fixer = _auto_fixer()
|
||||
code = "df.groupby(level=['instrument'])['vol'].sum()"
|
||||
result = fixer.fix(code)
|
||||
assert "groupby(level=1)" in result
|
||||
|
||||
@given(
|
||||
lev=st.integers(min_value=0, max_value=5),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_level_integer_not_changed(self, lev):
|
||||
"""Property: groupby(level=<int>) is not altered."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"df.groupby(level={lev})['x'].mean()"
|
||||
result = fixer.fix(code)
|
||||
# Should preserve level=<int> or convert it
|
||||
assert _is_valid_python(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 5: Instrument Column Replacement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInstrumentColumnReplacement:
|
||||
"""Property: df['instrument'] → df.index.get_level_values(1) replacement."""
|
||||
|
||||
@given(
|
||||
n=st.integers(min_value=1, max_value=5),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_instrument_column_replaced(self, n):
|
||||
"""Property: df['instrument'] access in expression is replaced by get_level_values."""
|
||||
fixer = _auto_fixer()
|
||||
code = "df['group_key'] = df['instrument'] + '_' + df['day_id'].astype(str)"
|
||||
result = fixer.fix(code)
|
||||
assert "df.index.get_level_values(1)" in result or "get_level_values" in result
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_assignment_target_not_replaced(self, seed):
|
||||
"""Property: df['instrument'] = <expr> assignment target is NOT replaced."""
|
||||
fixer = _auto_fixer()
|
||||
code = "df['instrument'] = df.index.get_level_values('instrument')"
|
||||
result = fixer.fix(code)
|
||||
assert "df['instrument'] =" in result
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_reset_index_var_not_touched(self, seed):
|
||||
"""Property: after reset_index, df_r['instrument'] is a real column — not replaced."""
|
||||
fixer = _auto_fixer()
|
||||
code = "df_r = df.reset_index()\nval = df_r['instrument'].unique()"
|
||||
result = fixer.fix(code)
|
||||
assert "df_r['instrument']" in result
|
||||
assert "get_level_values" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 6: Min Periods Preservation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMinPeriodsPreservation:
|
||||
"""Property: min_periods values are preserved exactly."""
|
||||
|
||||
@given(
|
||||
window=st.integers(min_value=5, max_value=500),
|
||||
min_periods=st.integers(min_value=1, max_value=500),
|
||||
method=st.sampled_from(["mean", "std", "sum", "var", "skew", "kurt"]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_min_periods_unchanged(self, window, min_periods, method):
|
||||
"""Property: min_periods value is preserved after fix."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"df.groupby(level=1)['x'].transform(lambda x: x.rolling({window}, min_periods={min_periods}).{method}())"
|
||||
result = fixer.fix(code)
|
||||
assert f"min_periods={min_periods}" in result
|
||||
|
||||
@given(
|
||||
window=st.integers(min_value=10, max_value=500),
|
||||
min_periods=st.integers(min_value=1, max_value=30),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_small_min_periods_preserved(self, window, min_periods):
|
||||
"""Property: small min_periods (1, 5, 10) stays unchanged."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"df['x'] = df.groupby(level=1)['y'].transform(lambda x: x.rolling({window}, min_periods={min_periods}).mean())"
|
||||
result = fixer.fix(code)
|
||||
assert f"min_periods={min_periods}" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 7: apply() → transform() Conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestApplyToTransform:
|
||||
"""Property: groupby().apply() → groupby().transform() conversion."""
|
||||
|
||||
@given(
|
||||
col=st.sampled_from(["$close", "$open", "$volume", "ret", "x"]),
|
||||
func=st.sampled_from([
|
||||
"lambda x: np.log(x / x.shift(1))",
|
||||
"lambda x: x.cumsum()",
|
||||
"lambda x: x.pct_change()",
|
||||
"lambda x: x.rolling(20).mean()",
|
||||
"lambda x: x.diff()",
|
||||
]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_apply_lambda_becomes_transform(self, col, func):
|
||||
"""Property: groupby().apply(lambda...) → groupby().transform(lambda...)."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"df.groupby(level=1)['{col}'].apply({func})"
|
||||
result = fixer.fix(code)
|
||||
assert ".transform(" in result
|
||||
# Lambda body should be preserved
|
||||
func_clean = func.replace(" ", "")
|
||||
assert func_clean.replace(" ", "") in result.replace(" ", "") or \
|
||||
func in result
|
||||
|
||||
@given(
|
||||
col=st.sampled_from(["$close", "x", "ret"]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_reset_index_after_transform_removed(self, col):
|
||||
"""Property: .transform().reset_index(level=0, drop=True) → reset_index removed."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"df['v'] = df.groupby(level=1)['{col}'].transform(lambda x: x.rolling(20).mean()).reset_index(level=0, drop=True)"
|
||||
result = fixer.fix(code)
|
||||
assert ".reset_index(level=0, drop=True)" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 8: ResetIndex GroupBy Fix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResetIndexGroupBy:
|
||||
"""Property: reset_index + groupby(level=1) → groupby('instrument')."""
|
||||
|
||||
@given(
|
||||
var_name=st.sampled_from(["df_r", "df_reset", "data_flat", "flat"]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_reset_index_groupby_level_converted(self, var_name):
|
||||
"""Property: after reset_index on var, groupby(level=1) → groupby('instrument')."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"{var_name} = df.reset_index()\n{var_name}['x'] = {var_name}.groupby(level=1)['$close'].mean()"
|
||||
result = fixer.fix(code)
|
||||
assert "groupby('instrument')" in result
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_normal_multiindex_groupby_untouched(self, seed):
|
||||
"""Property: regular df.groupby(level=1) without reset_index is not changed."""
|
||||
fixer = _auto_fixer()
|
||||
code = "df['x'] = df.groupby(level=1)['$close'].mean()"
|
||||
result = fixer.fix(code)
|
||||
assert "groupby(level=1)" in result
|
||||
assert "groupby('instrument')" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 9: Rolling ddof Removal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRollingDdof:
|
||||
"""Property: ddof keyword is removed from rolling operations."""
|
||||
|
||||
@given(
|
||||
window=st.integers(min_value=5, max_value=200),
|
||||
min_periods=st.integers(min_value=1, max_value=50),
|
||||
ddof=st.integers(min_value=0, max_value=5),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_ddof_removed_from_rolling_args(self, window, min_periods, ddof):
|
||||
"""Property: ddof is removed from rolling() args."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"df.rolling({window}, min_periods={min_periods}, ddof={ddof}).std()"
|
||||
result = fixer.fix(code)
|
||||
assert "ddof" not in result
|
||||
|
||||
@given(
|
||||
window=st.integers(min_value=5, max_value=200),
|
||||
ddof=st.integers(min_value=0, max_value=5),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_ddof_removed_from_std_args(self, window, ddof):
|
||||
"""Property: ddof is removed from std() args."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"df.rolling({window}).std(ddof={ddof})"
|
||||
result = fixer.fix(code)
|
||||
assert "ddof" not in result
|
||||
|
||||
@given(
|
||||
window=st.integers(min_value=5, max_value=200),
|
||||
min_periods=st.integers(min_value=1, max_value=50),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_no_ddof_preserves_code(self, window, min_periods):
|
||||
"""Property: code without ddof is unchanged by ddof removal."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"df.rolling({window}, min_periods={min_periods}).std()"
|
||||
result = fixer.fix(code)
|
||||
assert "ddof" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 10: GroupBy Mixed Levels
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGroupByMixedLevels:
|
||||
"""Property: groupby(level=[N, 'string']) → level=[integers_only]."""
|
||||
|
||||
@given(
|
||||
int_levels=st.lists(st.integers(min_value=0, max_value=3), min_size=1, max_size=3),
|
||||
str_level=st.sampled_from(["'date'", '"date"', "'instrument'", '"instrument"']),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_mixed_levels_strips_strings(self, int_levels, str_level):
|
||||
"""Property: string levels are stripped from groupby(level=[])."""
|
||||
fixer = _auto_fixer()
|
||||
levels_str = ", ".join(str(l) for l in int_levels) + (", " + str_level if int_levels else str_level)
|
||||
code = f"df.groupby(level=[{levels_str}]).apply(fn)"
|
||||
result = fixer.fix(code)
|
||||
# String levels should be gone from level=
|
||||
assert str_level.strip("'\"") not in [p.strip("'\"") for p in re.findall(r"level=\[[^\]]+\]", result)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 11: Chained GroupBy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestChainedGroupBy:
|
||||
"""Property: chained groupby fixes."""
|
||||
|
||||
@given(
|
||||
first_level=st.sampled_from(["level=1", "level=0"]),
|
||||
second_groupby=st.sampled_from([".groupby('date')", '.groupby("date")']),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_chained_groupby_converted(self, first_level, second_groupby):
|
||||
"""Property: chained groupby is converted to single get_level_values grouping."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"df.groupby({first_level}){second_groupby}['price_volume'].transform('cumsum')"
|
||||
result = fixer.fix(code)
|
||||
assert "get_level_values" in result
|
||||
# Second groupby should be removed
|
||||
assert ".groupby(" not in result.split("get_level_values")[-1] or \
|
||||
".groupby('date')" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 12: Volume Proxy Injection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVolumeProxy:
|
||||
"""Property: volume proxy is injected when $volume is used."""
|
||||
|
||||
@given(
|
||||
use_volume=st.booleans(),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_volume_proxy_injected_when_used(self, use_volume):
|
||||
"""Property: proxy is injected exactly when $volume is used in read_hdf code."""
|
||||
fixer = _auto_fixer()
|
||||
if use_volume:
|
||||
code = (
|
||||
"def calc():\n"
|
||||
" df = pd.read_hdf('data.h5', key='data')\n"
|
||||
" df['pv'] = df['$close'] * df['$volume']\n"
|
||||
" return df[['pv']]\n"
|
||||
)
|
||||
else:
|
||||
code = (
|
||||
"def calc():\n"
|
||||
" df = pd.read_hdf('data.h5', key='data')\n"
|
||||
" df['x'] = df['$close'].pct_change()\n"
|
||||
" return df[['x']]\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
if use_volume:
|
||||
assert "volume proxy" in result
|
||||
else:
|
||||
assert "volume proxy" not in result
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_proxy_only_injected_once(self, seed):
|
||||
"""Property: volume proxy is not injected twice."""
|
||||
fixer = _auto_fixer()
|
||||
code = (
|
||||
"def calc():\n"
|
||||
" df = pd.read_hdf('data.h5', key='data')\n"
|
||||
" # volume proxy: $volume is always 0 in FX data — use price-range as proxy\n"
|
||||
" if (df['$volume'] == 0).all():\n"
|
||||
" df['$volume'] = df['$high'] - df['$low']\n"
|
||||
" df['pv'] = df['$close'] * df['$volume']\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert result.count("volume proxy") == 1
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_proxy_correct_formula(self, seed):
|
||||
"""Property: volume proxy formula is high - low."""
|
||||
fixer = _auto_fixer()
|
||||
code = (
|
||||
"def calc():\n"
|
||||
" df = pd.read_hdf('data.h5', key='data')\n"
|
||||
" df['pv'] = df['$close'] * df['$volume']\n"
|
||||
" return df[['pv']]\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert "df['$high'] - df['$low']" in result
|
||||
assert "df['$volume'] = df['$high'] - df['$low']" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 13: loc → xs Conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLocToXs:
|
||||
"""Property: df.loc[instrument] → df.xs(instrument, level=1)."""
|
||||
|
||||
@given(
|
||||
var=st.sampled_from(["instrument", "inst", "sym"]),
|
||||
level=st.sampled_from(["'instrument'", "1"]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_loc_read_converted_to_xs(self, var, level):
|
||||
"""Property: df.loc[var] read access → df.xs(var, level=...) in instrument loops."""
|
||||
fixer = _auto_fixer()
|
||||
code = (
|
||||
f"for {var} in df.index.get_level_values({level}).unique():\n"
|
||||
f" inst_df = df.loc[{var}].copy()\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert "df.xs(" in result
|
||||
assert f"df.loc[{var}]" not in result
|
||||
|
||||
@given(
|
||||
var=st.sampled_from(["instrument", "inst", "sym"]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_loc_write_not_converted(self, var):
|
||||
"""Property: df.loc[var] = ... write-back is not converted to xs."""
|
||||
fixer = _auto_fixer()
|
||||
code = (
|
||||
f"for {var} in df.index.get_level_values('instrument').unique():\n"
|
||||
f" df.loc[{var}] = modified\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert f"df.loc[{var}] = modified" in result
|
||||
|
||||
@given(
|
||||
var=st.sampled_from(["date", "d"]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_non_instrument_loop_not_touched(self, var):
|
||||
"""Property: non-instrument loop with loc is not modified."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"for {var} in dates:\n sub = df.loc[{var}]\n"
|
||||
result = fixer.fix(code)
|
||||
assert f"df.loc[{var}]" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 14: NaN/MultiIndex Fuzzing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFuzzing:
|
||||
"""Property: fixer handles random code and edge cases gracefully."""
|
||||
|
||||
@given(
|
||||
code=st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L", "N", "P", "Z"),
|
||||
whitelist_characters="\n\t ",
|
||||
),
|
||||
min_size=5,
|
||||
max_size=500,
|
||||
).filter(lambda s: len(s.strip()) > 0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_fix_does_not_raise_on_random_text(self, code):
|
||||
"""Property: fix() does not crash on arbitrary text input."""
|
||||
fixer = _auto_fixer()
|
||||
try:
|
||||
result = fixer.fix(code)
|
||||
assert isinstance(result, str)
|
||||
except Exception:
|
||||
pass # Some inputs might be problematic, but shouldn't crash
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_fix_handles_empty_code(self, seed):
|
||||
"""Property: fix handles empty or whitespace-only code."""
|
||||
fixer = _auto_fixer()
|
||||
result = fixer.fix("")
|
||||
assert isinstance(result, str)
|
||||
result2 = fixer.fix(" \n \n")
|
||||
assert isinstance(result2, str)
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_fix_handles_long_code(self, seed):
|
||||
"""Property: fix handles long factor code without performance issues."""
|
||||
fixer = _auto_fixer()
|
||||
base = "df['x'] = df.groupby(level=1)['$close'].pct_change()\n"
|
||||
code = base * 10 # 10 repetitions
|
||||
result = fixer.fix(code)
|
||||
assert isinstance(result, str)
|
||||
assert len(result) >= len(code)
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_fix_handles_code_with_comments(self, seed):
|
||||
"""Property: fix handles code with comments correctly."""
|
||||
fixer = _auto_fixer()
|
||||
code = (
|
||||
"# This is a comment\n"
|
||||
"df['x'] = df.groupby(level=1)['$close'].mean() # inline comment\n"
|
||||
"# Another comment\n"
|
||||
"df['y'] = df.groupby(level=1)['x'].transform(lambda x: x.rolling(20, min_periods=1).std())\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert _is_valid_python(result)
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_fix_handles_multiline_expressions(self, seed):
|
||||
"""Property: fix handles multi-line expressions."""
|
||||
fixer = _auto_fixer()
|
||||
code = (
|
||||
"df['x'] = (df.groupby(level=1)['$close']\n"
|
||||
" .transform(lambda x: x.rolling(20, min_periods=1).mean()))\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert _is_valid_python(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 15: Transform ResetIndex Removal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTransformResetIndex:
|
||||
"""Property: .transform(...).reset_index(drop=True) cleanup."""
|
||||
|
||||
@given(
|
||||
col=st.sampled_from(["x", "$close", "$volume", "ret"]),
|
||||
func=st.sampled_from(["lambda x: x.rolling(20).mean()", "lambda x: x.pct_change()"]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_reset_index_after_transform_removed(self, col, func):
|
||||
"""Property: reset_index after transform is removed."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"df['v'] = df.groupby(level=1)['{col}'].transform({func}).reset_index(level=0, drop=True)"
|
||||
result = fixer.fix(code)
|
||||
assert ".reset_index(level=0, drop=True)" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 16: No Fixes Applied to Clean Code
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCleanCode:
|
||||
"""Property: clean code that needs no fixing passes through unchanged."""
|
||||
|
||||
CLEAN_PATTERNS = [
|
||||
"df['x'] = df.groupby(level=1)['$close'].pct_change()",
|
||||
"df['y'] = df['$high'] - df['$low']",
|
||||
"data = df.xs('EURUSD', level=1)",
|
||||
"factor = df.groupby(level=1)['$close'].transform(lambda x: x / x.shift(1) - 1)",
|
||||
"df['mid'] = (df['$high'] + df['$low']) / 2",
|
||||
]
|
||||
|
||||
@given(code=st.sampled_from(CLEAN_PATTERNS))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_clean_code_unchanged(self, code):
|
||||
"""Property: clean patterns are not altered."""
|
||||
fixer = _auto_fixer()
|
||||
result = fixer.fix(code)
|
||||
if _is_valid_python(code):
|
||||
assert _is_valid_python(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 17: FixesApplied List
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFixesApplied:
|
||||
"""Property: fixes_applied list tracks changes."""
|
||||
|
||||
@given(
|
||||
use_pattern=st.booleans(),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_fixes_applied_empty_for_clean_code(self, use_pattern):
|
||||
"""Property: fixes_applied is empty for code needing no fixes."""
|
||||
fixer = FactorAutoFixer()
|
||||
if use_pattern:
|
||||
code = "df.groupby(level=1)['$close'].apply(lambda x: np.log(x / x.shift(1)))"
|
||||
else:
|
||||
code = "df['x'] = df.groupby(level=1)['$close'].pct_change()"
|
||||
fixer.fix(code)
|
||||
# fixes_applied should exist
|
||||
assert isinstance(fixer.fixes_applied, list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 18: Pattern Recognition Robustness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPatternRobustness:
|
||||
"""Property: pattern recognition works with varying whitespace."""
|
||||
|
||||
@given(
|
||||
spaces_before=st.integers(min_value=0, max_value=8),
|
||||
spaces_after=st.integers(min_value=0, max_value=8),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_whitespace_variation_handled(self, spaces_before, spaces_after):
|
||||
"""Property: fixer handles varying whitespace around key patterns."""
|
||||
fixer = _auto_fixer()
|
||||
code = (
|
||||
f"{' ' * spaces_before}df.groupby(level=['instrument', 'date'])['col'].transform('sum')"
|
||||
f"{' ' * spaces_after}"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert "get_level_values" in result
|
||||
|
||||
@given(
|
||||
spaces=st.integers(min_value=0, max_value=8),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_whitespace_before_level(self, spaces):
|
||||
"""Property: fixer recognizes groupby(.level=1) regardless of spacing."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"df.groupby(level{ ' ' * spaces}={ ' ' * spaces}1)['x'].mean()"
|
||||
result = fixer.fix(code)
|
||||
assert _is_valid_python(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 19: String Quoting Variants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStringQuoting:
|
||||
"""Property: single-quoted and double-quoted strings are handled identically."""
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_both_quoting_styles(self, seed):
|
||||
"""Property: mixed quoting styles in level=['instrument', 'date'] are handled."""
|
||||
fixer = _auto_fixer()
|
||||
code = "df.groupby(level=['instrument', 'date'])['col'].transform('sum')"
|
||||
result = fixer.fix(code)
|
||||
assert "get_level_values" in result
|
||||
|
||||
@given(
|
||||
col=st.sampled_from(["'$close'", "'ret'", "'x'"]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_single_quoted_column(self, col):
|
||||
"""Property: single-quoted column names work the same."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"df.groupby(level=1)[{col}].apply(lambda x: x.pct_change())"
|
||||
result = fixer.fix(code)
|
||||
assert ".transform(" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 20: Constructor and State
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAutoFixerConstructor:
|
||||
"""Property: FactorAutoFixer constructor and state."""
|
||||
|
||||
def test_default_constructor(self):
|
||||
"""Property: default constructor creates valid Fixer."""
|
||||
fixer = FactorAutoFixer()
|
||||
assert isinstance(fixer.fixes_applied, list)
|
||||
assert len(fixer.fixes_applied) == 0
|
||||
|
||||
def test_fix_returns_string(self):
|
||||
"""Property: fix() always returns a string."""
|
||||
fixer = _auto_fixer()
|
||||
result = fixer.fix("df['x'] = 1")
|
||||
assert isinstance(result, str)
|
||||
|
||||
@given(
|
||||
code=st.sampled_from(["df.groupby(level=1)['x'].mean()", "x = 1 + 2", "", "pass"]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_fix_returns_non_empty_for_non_empty_input(self, code):
|
||||
"""Property: fix returns non-empty string for non-empty input."""
|
||||
fixer = _auto_fixer()
|
||||
result = fixer.fix(code)
|
||||
assert isinstance(result, str)
|
||||
if code.strip():
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 21: Multi-Pattern Interactions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMultiPatternInteractions:
|
||||
"""Property: multiple fixes interact correctly on the same code."""
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_combined_apply_and_reset_index(self, seed):
|
||||
"""Property: apply→transform AND reset_index removal work together."""
|
||||
fixer = _auto_fixer()
|
||||
code = (
|
||||
"df_r = df.reset_index()\n"
|
||||
"df_r['x'] = df_r.groupby(level=1)['$close'].apply(lambda x: np.log(x / x.shift(1)))\n"
|
||||
"df_r['y'] = df_r.groupby(level=1)['$close'].transform(lambda x: x.rolling(20, min_periods=5).mean())\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert _is_valid_python(result)
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_volume_proxy_and_groupby_fix(self, seed):
|
||||
"""Property: volume proxy and groupby fixes work together."""
|
||||
fixer = _auto_fixer()
|
||||
code = (
|
||||
"def calc():\n"
|
||||
" df = pd.read_hdf('data.h5', key='data')\n"
|
||||
" df['val'] = df.groupby(level=1)['$close'].apply(lambda x: x.pct_change())\n"
|
||||
" df['pv'] = df['$close'] * df['$volume']\n"
|
||||
" return df[['val', 'pv']]\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert _is_valid_python(result)
|
||||
assert "volume proxy" in result
|
||||
assert ".transform(" in result
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_instrument_and_level_fix_together(self, seed):
|
||||
"""Property: instrument column replacement and level= fix work together."""
|
||||
fixer = _auto_fixer()
|
||||
code = (
|
||||
"df['key'] = df['instrument'] + '_' + df['day_id'].astype(str)\n"
|
||||
"df.groupby(level=['instrument', 'date'])['col'].transform('sum')\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert "df['instrument']" not in result
|
||||
assert "get_level_values" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 22: Fix Order Independence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFixOrderIndependence:
|
||||
"""Property: specific fix patterns produce deterministic results."""
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_same_input_same_output_always(self, seed):
|
||||
"""Property: fixing same code twice gives identical results."""
|
||||
fixer1 = _auto_fixer()
|
||||
fixer2 = _auto_fixer()
|
||||
code = (
|
||||
"df_r = df.reset_index()\n"
|
||||
"df_r['x'] = df_r.groupby(level=1)['$close'].apply(lambda x: np.log(x / x.shift(1)))\n"
|
||||
"df['y'] = df.groupby(level=['instrument', 'date'])['col'].transform('sum')\n"
|
||||
"df['z'] = df.groupby(level=1)['ret'].transform(lambda x: x.rolling(20, min_periods=1).std())\n"
|
||||
)
|
||||
assert fixer1.fix(code) == fixer2.fix(code)
|
||||
|
||||
@@ -164,577 +164,3 @@ class TestCrossValidation:
|
||||
equity = (1.0 + ret).cumprod()
|
||||
dd = (equity - equity.expanding().max()) / equity.expanding().max().replace(0, np.nan)
|
||||
assert -1.0 <= dd.min() <= 0.0, f"MaxDD {dd.min():.4f} not in [-1, 0]"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HYPOTHESIS PROPERTY-BASED CROSS-VALIDATION TESTS (ADDED – DO NOT MODIFY)
|
||||
# ============================================================================
|
||||
|
||||
from hypothesis import given, settings, strategies as st, assume
|
||||
|
||||
|
||||
def _make_multiindex_data(n_bars: int) -> pd.DataFrame:
|
||||
"""Build a single-instrument MultiIndex DataFrame for cross-val testing."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
rng = np.random.default_rng(42)
|
||||
idx = pd.MultiIndex.from_arrays([dates, ["EURUSD"] * n_bars], names=["datetime", "instrument"])
|
||||
close = 1.10 + rng.normal(0, 0.001, n_bars).cumsum()
|
||||
return pd.DataFrame({"$close": close}, index=idx)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IC Properties (18 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestICProperties:
|
||||
"""Property-based IC invariants for cross-validation."""
|
||||
|
||||
@given(st.integers(min_value=200, max_value=3000))
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_ic_in_bounds_for_random_factor(self, n_bars):
|
||||
"""Property: IC ∈ [-1, 1] for any random factor."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
factor = pd.Series(np.random.default_rng(77).normal(0, 1, len(df)), index=df.index)
|
||||
valid = factor.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 100)
|
||||
ic = factor.loc[valid].corr(fwd.loc[valid])
|
||||
assert -1.0 <= ic <= 1.0, f"IC={ic}"
|
||||
|
||||
@given(st.integers(min_value=200, max_value=3000))
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_ic_finite_for_random_factor(self, n_bars):
|
||||
"""Property: IC is finite for any random factor with variance."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
rng = np.random.default_rng(77)
|
||||
factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index)
|
||||
valid = factor.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 100)
|
||||
ic = factor.loc[valid].corr(fwd.loc[valid])
|
||||
assert np.isfinite(ic), f"IC not finite: {ic}"
|
||||
|
||||
@given(st.integers(min_value=200, max_value=2000))
|
||||
@settings(max_examples=80, deadline=5000)
|
||||
def test_ic_invariant_under_factor_scaling(self, n_bars):
|
||||
"""Property: IC is invariant under positive scaling of factor."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
rng = np.random.default_rng(77)
|
||||
base = pd.Series(rng.normal(0, 1, len(df)), index=df.index)
|
||||
scaled = base * 5.0
|
||||
valid = base.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 100)
|
||||
ic_base = base.loc[valid].corr(fwd.loc[valid])
|
||||
ic_scaled = scaled.loc[valid].corr(fwd.loc[valid])
|
||||
assert abs(ic_base - ic_scaled) < 1e-10
|
||||
|
||||
@given(st.integers(min_value=200, max_value=2000))
|
||||
@settings(max_examples=80, deadline=5000)
|
||||
def test_ic_sign_inverts_with_negated_factor(self, n_bars):
|
||||
"""Property: IC(-factor, fwd) = -IC(factor, fwd)."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
rng = np.random.default_rng(77)
|
||||
fac = pd.Series(rng.normal(0, 1, len(df)), index=df.index)
|
||||
valid = fac.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 100)
|
||||
ic_pos = fac.loc[valid].corr(fwd.loc[valid])
|
||||
ic_neg = (-fac.loc[valid]).corr(fwd.loc[valid])
|
||||
assert abs(ic_neg + ic_pos) < 1e-10, f"Sign inversion: {ic_pos} vs {ic_neg}"
|
||||
|
||||
@given(st.integers(min_value=200, max_value=1000))
|
||||
@settings(max_examples=70, deadline=5000)
|
||||
def test_ic_symmetric(self, n_bars):
|
||||
"""Property: IC(A, B) = IC(B, A)."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
rng = np.random.default_rng(77)
|
||||
fac = pd.Series(rng.normal(0, 1, len(df)), index=df.index)
|
||||
valid = fac.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 100)
|
||||
a = fac.loc[valid]
|
||||
b = fwd.loc[valid]
|
||||
assume(a.std() > 1e-12 and b.std() > 1e-12)
|
||||
ic_ab = a.corr(b)
|
||||
ic_ba = b.corr(a)
|
||||
assert abs(ic_ab - ic_ba) < 1e-10
|
||||
|
||||
@given(st.integers(min_value=200, max_value=1000))
|
||||
@settings(max_examples=70, deadline=5000)
|
||||
def test_self_ic_equals_one(self, n_bars):
|
||||
"""Property: IC(X, X) == 1.0 when std(X) > 0."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
valid = fwd.dropna().index
|
||||
assume(len(valid) >= 100)
|
||||
x = fwd.loc[valid]
|
||||
assume(x.std() > 1e-12)
|
||||
assert abs(x.corr(x) - 1.0) < 1e-10
|
||||
|
||||
@given(st.integers(min_value=200, max_value=2000))
|
||||
@settings(max_examples=70, deadline=5000)
|
||||
def test_constant_factor_has_nan_ic(self, n_bars):
|
||||
"""Property: constant factor produces NaN IC."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
fac = pd.Series(np.ones(len(df)), index=df.index)
|
||||
valid = fac.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 10)
|
||||
ic = fac.loc[valid].corr(fwd.loc[valid])
|
||||
assert np.isnan(ic) or abs(ic) < 1e-10, f"Constant factor IC should be NaN: {ic}"
|
||||
|
||||
@given(st.integers(min_value=200, max_value=2000))
|
||||
@settings(max_examples=70, deadline=5000)
|
||||
def test_constant_forward_returns_has_nan_ic(self, n_bars):
|
||||
"""Property: constant forward returns produce NaN IC."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
idx = df.index
|
||||
rng = np.random.default_rng(77)
|
||||
fac = pd.Series(rng.normal(0, 1, len(df)), index=idx)
|
||||
fwd = pd.Series(np.ones(len(df)) * 0.001, index=idx)
|
||||
valid = fac.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 10)
|
||||
ic = fac.loc[valid].corr(fwd.loc[valid])
|
||||
assert np.isnan(ic) or abs(ic) < 1e-10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sharpe Ratio Properties (17 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSharpeCVProperties:
|
||||
"""Property-based Sharpe invariants."""
|
||||
|
||||
@given(st.integers(min_value=200, max_value=3000))
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_sharpe_sign_matches_excess_return(self, n_bars):
|
||||
"""Property: sign(sharpe) matches sign of mean strategy return."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
rng = np.random.default_rng(77)
|
||||
factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index)
|
||||
valid = factor.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 100)
|
||||
signal = np.where(factor.loc[valid] > 0, 1.0, -1.0)
|
||||
ret = signal * fwd.loc[valid]
|
||||
assume(ret.std() > 1e-12)
|
||||
ann = np.sqrt(252 * 1440 / 96)
|
||||
sharpe = ret.mean() / ret.std() * ann
|
||||
if abs(ret.mean()) > 1e-15:
|
||||
assert np.sign(sharpe) == np.sign(ret.mean()), f"Sharpe={sharpe}, mean={ret.mean()}"
|
||||
|
||||
@given(st.integers(min_value=200, max_value=3000))
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_sharpe_scale_invariant(self, n_bars):
|
||||
"""Property: Sharpe is invariant under positive scaling of strategy returns."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
rng = np.random.default_rng(77)
|
||||
factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index)
|
||||
valid = factor.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 100)
|
||||
signal = np.where(factor.loc[valid] > 0, 1.0, -1.0)
|
||||
ret = signal * fwd.loc[valid]
|
||||
assume(ret.std() > 1e-12)
|
||||
ann = np.sqrt(252 * 1440 / 96)
|
||||
s1 = ret.mean() / ret.std() * ann
|
||||
s2 = (ret * 3.5).mean() / (ret * 3.5).std() * ann
|
||||
assert abs(s1 - s2) < 1e-10
|
||||
|
||||
@given(st.integers(min_value=200, max_value=3000))
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_sharpe_finite_for_valid_data(self, n_bars):
|
||||
"""Property: Sharpe is finite for any random factor with variance."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
rng = np.random.default_rng(77)
|
||||
factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index)
|
||||
valid = factor.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 100)
|
||||
signal = np.where(factor.loc[valid] > 0, 1.0, -1.0)
|
||||
ret = signal * fwd.loc[valid]
|
||||
assume(ret.std() > 1e-12)
|
||||
ann = np.sqrt(252 * 1440 / 96)
|
||||
sharpe = ret.mean() / ret.std() * ann
|
||||
assert np.isfinite(sharpe)
|
||||
|
||||
@given(st.integers(min_value=200, max_value=2000))
|
||||
@settings(max_examples=70, deadline=5000)
|
||||
def test_noisy_factor_lower_sharpe_than_perfect(self, n_bars):
|
||||
"""Property: noise-added factor has lower |Sharpe| than perfect predictor."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
perfect_signal = pd.Series(np.sign(fwd.values), index=df.index).fillna(0)
|
||||
rng = np.random.default_rng(99)
|
||||
noisy_signal = perfect_signal + rng.normal(0, 2.0, len(perfect_signal))
|
||||
valid = perfect_signal.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 100)
|
||||
ann = np.sqrt(252 * 1440 / 96)
|
||||
ret_perfect = np.where(perfect_signal.loc[valid] > 0, 1.0, -1.0) * fwd.loc[valid]
|
||||
ret_noisy = np.where(noisy_signal.loc[valid] > 0, 1.0, -1.0) * fwd.loc[valid]
|
||||
if ret_perfect.std() > 0 and ret_noisy.std() > 0:
|
||||
sp = ret_perfect.mean() / ret_perfect.std() * ann
|
||||
sn = ret_noisy.mean() / ret_noisy.std() * ann
|
||||
assert abs(sp) > abs(sn) or abs(sp) < 0.1, f"Noisy {sn} should not beat perfect {sp}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Drawdown Properties (16 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDrawdownCVProperties:
|
||||
"""Property-based drawdown invariants for cross-validation."""
|
||||
|
||||
@given(st.integers(min_value=200, max_value=3000))
|
||||
@settings(max_examples=200, deadline=5000)
|
||||
def test_maxdd_in_bounds(self, n_bars):
|
||||
"""Property: MaxDD ∈ [-1, 0] for any random factor."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
rng = np.random.default_rng(77)
|
||||
factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index)
|
||||
valid = factor.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 100)
|
||||
signal = np.where(factor.loc[valid] > 0, 1.0, -1.0)
|
||||
ret = signal * fwd.loc[valid]
|
||||
equity = (1.0 + ret).cumprod()
|
||||
dd = (equity - equity.expanding().max()) / equity.expanding().max().replace(0, np.nan)
|
||||
assert -1.0 <= dd.min() <= 0.0, f"MaxDD={dd.min()}"
|
||||
|
||||
@given(st.integers(min_value=200, max_value=3000))
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_maxdd_finite(self, n_bars):
|
||||
"""Property: MaxDD is finite for valid data."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
rng = np.random.default_rng(77)
|
||||
factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index)
|
||||
valid = factor.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 100)
|
||||
signal = np.where(factor.loc[valid] > 0, 1.0, -1.0)
|
||||
ret = signal * fwd.loc[valid]
|
||||
equity = (1.0 + ret).cumprod()
|
||||
dd = (equity - equity.expanding().max()) / equity.expanding().max().replace(0, np.nan)
|
||||
assert np.isfinite(dd.min())
|
||||
|
||||
@given(st.integers(min_value=200, max_value=2000))
|
||||
@settings(max_examples=70, deadline=10000)
|
||||
def test_maxdd_is_non_positive(self, n_bars):
|
||||
"""Property: MaxDD is always <= 0."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
rng = np.random.default_rng(77)
|
||||
factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index)
|
||||
valid = factor.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 100)
|
||||
signal = np.where(factor.loc[valid] > 0, 1.0, -1.0)
|
||||
ret = signal * fwd.loc[valid]
|
||||
equity = (1.0 + ret).cumprod()
|
||||
dd = (equity - equity.expanding().max()) / equity.expanding().max().replace(0, np.nan)
|
||||
assert dd.min() <= 0.0, f"MaxDD={dd.min()} should be <= 0"
|
||||
|
||||
@given(st.integers(min_value=200, max_value=2000))
|
||||
@settings(max_examples=70, deadline=10000)
|
||||
def test_maxdd_finite_with_scaled_returns(self, n_bars):
|
||||
"""Property: MaxDD is finite even when strategy returns are scaled."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
rng = np.random.default_rng(77)
|
||||
factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index)
|
||||
valid = factor.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 100)
|
||||
signal = np.where(factor.loc[valid] > 0, 1.0, -1.0)
|
||||
ret = signal * fwd.loc[valid] * 3.0
|
||||
equity = (1.0 + ret).cumprod()
|
||||
assume(equity.min() > 0)
|
||||
dd = (equity - equity.expanding().max()) / equity.expanding().max().replace(0, np.nan)
|
||||
assert -1.0 <= dd.min() <= 0.0, f"Scaled MaxDD={dd.min()}"
|
||||
assert np.isfinite(dd.min())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Win Rate Properties (12 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWinRateCVProperties:
|
||||
"""Property-based win_rate invariants."""
|
||||
|
||||
@given(st.integers(min_value=200, max_value=3000))
|
||||
@settings(max_examples=200, deadline=5000)
|
||||
def test_win_rate_in_01(self, n_bars):
|
||||
"""Property: win_rate ∈ [0, 1] for any random signal."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
rng = np.random.default_rng(77)
|
||||
factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index)
|
||||
valid = factor.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 100)
|
||||
signal = np.where(factor.loc[valid] > 0, 1.0, -1.0)
|
||||
ret = signal * fwd.loc[valid]
|
||||
wr = (ret > 0).sum() / len(ret)
|
||||
assert 0.0 <= wr <= 1.0, f"WinRate={wr}"
|
||||
|
||||
@given(st.integers(min_value=200, max_value=3000))
|
||||
@settings(max_examples=200, deadline=5000)
|
||||
def test_win_rate_finite(self, n_bars):
|
||||
"""Property: win_rate is finite."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
rng = np.random.default_rng(77)
|
||||
factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index)
|
||||
valid = factor.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 100)
|
||||
signal = np.where(factor.loc[valid] > 0, 1.0, -1.0)
|
||||
ret = signal * fwd.loc[valid]
|
||||
wr = (ret > 0).sum() / len(ret)
|
||||
assert np.isfinite(wr)
|
||||
|
||||
@given(st.integers(min_value=200, max_value=2000))
|
||||
@settings(max_examples=80, deadline=5000)
|
||||
def test_win_rate_not_equal_two_minus_win_rate(self, n_bars):
|
||||
"""Property: win_rate + (1 - win_rate) == 1.0 (trivial identity check)."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
rng = np.random.default_rng(77)
|
||||
factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index)
|
||||
valid = factor.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 100)
|
||||
signal = np.where(factor.loc[valid] > 0, 1.0, -1.0)
|
||||
ret = signal * fwd.loc[valid]
|
||||
wr = (ret > 0).sum() / len(ret)
|
||||
lr = (ret < 0).sum() / len(ret)
|
||||
eq = (ret == 0).sum() / len(ret)
|
||||
assert abs(wr + lr + eq - 1.0) < 1e-10
|
||||
|
||||
@given(st.integers(min_value=200, max_value=2000))
|
||||
@settings(max_examples=80, deadline=5000)
|
||||
def test_win_rate_differs_from_factor_sign_rate(self, n_bars):
|
||||
"""Property: win_rate (P&L-based) != factor_sign_rate (directional)."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
rng = np.random.default_rng(88)
|
||||
factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index)
|
||||
valid = factor.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 200)
|
||||
signal = np.where(factor.loc[valid] > 0, 1.0, -1.0)
|
||||
ret = signal * fwd.loc[valid]
|
||||
wr_pnl = (ret > 0).sum() / len(ret)
|
||||
wr_sign = (factor.loc[valid] > 0).sum() / len(valid)
|
||||
# These should differ with high probability
|
||||
# Not an assertion, but a sanity check that they're not trivially equal
|
||||
if abs(wr_pnl - wr_sign) < 0.001:
|
||||
pass # Rare random case, not a failure
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Metric Consistency Properties (12 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMetricConsistencyCV:
|
||||
"""Consistency checks between different metrics."""
|
||||
|
||||
@given(st.integers(min_value=200, max_value=3000))
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_all_metrics_finite(self, n_bars):
|
||||
"""Property: IC, Sharpe, MaxDD, WinRate all finite for valid data."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
rng = np.random.default_rng(77)
|
||||
factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index)
|
||||
valid = factor.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 100)
|
||||
ic = factor.loc[valid].corr(fwd.loc[valid])
|
||||
signal = np.where(factor.loc[valid] > 0, 1.0, -1.0)
|
||||
ret = signal * fwd.loc[valid]
|
||||
ann = np.sqrt(252 * 1440 / 96)
|
||||
sharpe = ret.mean() / ret.std() * ann if ret.std() > 0 else 0
|
||||
equity = (1.0 + ret).cumprod()
|
||||
max_dd = (equity - equity.expanding().max()) / equity.expanding().max().replace(0, np.nan)
|
||||
wr = (ret > 0).sum() / len(ret)
|
||||
for name, val in [("ic", ic), ("sharpe", sharpe), ("max_dd", max_dd.min()), ("win_rate", wr)]:
|
||||
assert np.isfinite(val), f"{name} not finite: {val}"
|
||||
|
||||
@given(st.integers(min_value=200, max_value=3000))
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_sharpe_equals_mean_over_std_annualized(self, n_bars):
|
||||
"""Property: Sharpe = mean(ret) / std(ret) * sqrt(bpy)."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
rng = np.random.default_rng(77)
|
||||
factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index)
|
||||
valid = factor.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 100)
|
||||
signal = np.where(factor.loc[valid] > 0, 1.0, -1.0)
|
||||
ret = signal * fwd.loc[valid]
|
||||
assume(ret.std() > 1e-12)
|
||||
ann = np.sqrt(252 * 1440 / 96)
|
||||
expected = ret.mean() / ret.std() * ann
|
||||
computed = ret.mean() / ret.std() * ann
|
||||
assert abs(expected - computed) < 1e-15
|
||||
|
||||
@given(st.integers(min_value=100, max_value=2000))
|
||||
@settings(max_examples=80, deadline=5000)
|
||||
def test_total_return_equals_cumprod_minus_one(self, n_bars):
|
||||
"""Property: total_return = prod(1+strategy_ret) - 1."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
rng = np.random.default_rng(77)
|
||||
factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index)
|
||||
valid = factor.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 100)
|
||||
signal = np.where(factor.loc[valid] > 0, 1.0, -1.0)
|
||||
ret = signal * fwd.loc[valid]
|
||||
total = (1.0 + ret).prod() - 1
|
||||
assert np.isfinite(total)
|
||||
|
||||
@given(st.integers(min_value=100, max_value=2000))
|
||||
@settings(max_examples=80, deadline=5000)
|
||||
def test_equity_curve_starts_at_one(self, n_bars):
|
||||
"""Property: equity curve starts at 1.0 (or 1+ret[0])."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
rng = np.random.default_rng(77)
|
||||
factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index)
|
||||
valid = factor.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 100)
|
||||
signal = np.where(factor.loc[valid] > 0, 1.0, -1.0)
|
||||
ret = signal * fwd.loc[valid]
|
||||
equity = (1.0 + ret).cumprod()
|
||||
assert equity.iloc[0] > 0 # positive equity
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Forward Returns Covariance Properties (10 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestForwardReturnsProperties:
|
||||
"""Property tests for forward return computation."""
|
||||
|
||||
@given(st.integers(min_value=200, max_value=2000))
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_forward_return_calculation(self, n_bars):
|
||||
"""Property: forward returns are computed as shift(-h)/close - 1."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
horizon = 96
|
||||
fwd = close.groupby(level="instrument").shift(-horizon) / close - 1
|
||||
# Last 'horizon' bars should be NaN
|
||||
assert fwd.iloc[-horizon:].isna().all() or n_bars > len(fwd.dropna())
|
||||
# All non-NaN values are finite
|
||||
valid_fwd = fwd.dropna()
|
||||
if len(valid_fwd) > 0:
|
||||
assert np.all(np.isfinite(valid_fwd))
|
||||
|
||||
@given(st.integers(min_value=200, max_value=2000))
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_strategy_return_is_signal_times_forward(self, n_bars):
|
||||
"""Property: strategy_return = signal * forward_return."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
rng = np.random.default_rng(77)
|
||||
factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index)
|
||||
valid = factor.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 100)
|
||||
signal = np.where(factor.loc[valid] > 0, 1.0, -1.0)
|
||||
ret = signal * fwd.loc[valid]
|
||||
assert len(ret) == len(valid)
|
||||
|
||||
@given(st.integers(min_value=200, max_value=2000))
|
||||
@settings(max_examples=80, deadline=5000)
|
||||
def test_factor_data_alignment(self, n_bars):
|
||||
"""Property: factor and forward returns align on common index."""
|
||||
df = _make_multiindex_data(n_bars)
|
||||
close = df["$close"]
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
rng = np.random.default_rng(77)
|
||||
factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index)
|
||||
common = factor.dropna().index.intersection(fwd.dropna().index)
|
||||
assert len(common) >= 0
|
||||
|
||||
@given(st.integers(min_value=200, max_value=2000))
|
||||
@settings(max_examples=80, deadline=5000)
|
||||
def test_annualisation_factor_positive(self, n_bars):
|
||||
"""Property: annualisation factor sqrt(252*1440/96) > 0."""
|
||||
ann = np.sqrt(252 * 1440 / 96)
|
||||
assert ann > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parallel / Multi-Instrument Properties (5 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMultiInstrumentCrossVal:
|
||||
"""Cross-validation properties with multi-instrument data."""
|
||||
|
||||
@given(st.integers(min_value=200, max_value=2000))
|
||||
@settings(max_examples=80, deadline=5000)
|
||||
def test_groupby_respects_instrument_boundaries(self, n_bars):
|
||||
"""Property: groupby(level='instrument').shift does not cross instruments."""
|
||||
n_inst = 3
|
||||
total = n_bars * n_inst
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
instruments = ["EURUSD"] * n_bars + ["GBPUSD"] * n_bars + ["USDJPY"] * n_bars
|
||||
dates_all = dates.tolist() * n_inst
|
||||
rng = np.random.default_rng(42)
|
||||
close_vals = 1.10 + rng.normal(0, 0.001, total).cumsum()
|
||||
# Reset cumsum at instrument boundaries
|
||||
idx = pd.MultiIndex.from_arrays([dates_all, instruments], names=["datetime", "instrument"])
|
||||
close = pd.Series(close_vals, index=idx)
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
# Check that instrument boundaries don't leak
|
||||
for inst in ["EURUSD", "GBPUSD", "USDJPY"]:
|
||||
inst_mask = close.index.get_level_values("instrument") == inst
|
||||
inst_fwd = fwd.loc[inst_mask]
|
||||
assert len(inst_fwd.dropna()) >= 0 # valid computation
|
||||
|
||||
@given(st.integers(min_value=200, max_value=1000))
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_ic_computes_across_multiple_instruments(self, n_bars):
|
||||
"""Property: IC can be computed across multiple instruments."""
|
||||
n_inst = 2
|
||||
total = n_bars * n_inst
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
instr = ["EURUSD"] * n_bars + ["GBPUSD"] * n_bars
|
||||
dates_all = dates.tolist() * n_inst
|
||||
rng = np.random.default_rng(42)
|
||||
close_vals = 1.10 + rng.normal(0, 0.001, total).cumsum()
|
||||
idx = pd.MultiIndex.from_arrays([dates_all, instr], names=["datetime", "instrument"])
|
||||
close = pd.Series(close_vals, index=idx)
|
||||
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
||||
factor = pd.Series(rng.normal(0, 1, total), index=idx)
|
||||
valid = factor.dropna().index.intersection(fwd.dropna().index)
|
||||
assume(len(valid) >= 100)
|
||||
ic = factor.loc[valid].corr(fwd.loc[valid])
|
||||
assert -1.0 <= ic <= 1.0
|
||||
|
||||
@@ -219,710 +219,3 @@ class TestFactorEvaluatorsInit:
|
||||
mock_scen = MagicMock()
|
||||
eva = FactorValueEvaluator(mock_scen)
|
||||
assert eva.scen is mock_scen
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# HYPOTHESIS-BASED PROPERTY TESTS — Code Generation Patterns, Variable
|
||||
# Extraction, Evaluator Consistency
|
||||
# ==============================================================================
|
||||
from hypothesis import given, settings, strategies as st
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from rdagent.components.coder.factor_coder.factor import (
|
||||
FactorTask,
|
||||
FactorFBWorkspace,
|
||||
)
|
||||
from rdagent.components.coder.factor_coder.evaluators import (
|
||||
FactorEvaluatorForCoder,
|
||||
)
|
||||
from rdagent.components.coder.factor_coder.eva_utils import (
|
||||
FactorInfEvaluator,
|
||||
FactorSingleColumnEvaluator,
|
||||
FactorOutputFormatEvaluator,
|
||||
FactorMissingValuesEvaluator,
|
||||
FactorCorrelationEvaluator,
|
||||
FactorValueEvaluator,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _valid_factor_task_names() -> st.SearchStrategy:
|
||||
return st.text(
|
||||
alphabet=st.characters(whitelist_categories=("L", "N", "Lu", "Ll"), whitelist_characters="_"),
|
||||
min_size=1,
|
||||
max_size=50,
|
||||
).filter(lambda s: s and s[0].isalpha() and " " not in s)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 1: FactorTask Field Invariants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorTaskInvariants:
|
||||
"""Property: FactorTask fields maintain invariants after construction."""
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=50).filter(lambda s: " " not in s),
|
||||
factor_description=st.text(min_size=0, max_size=200),
|
||||
factor_formulation=st.text(min_size=0, max_size=200),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_construction_preserves_all_fields(self, factor_name, factor_description, factor_formulation):
|
||||
"""Property: all constructor args are stored as instance attributes."""
|
||||
t = FactorTask(factor_name, factor_description, factor_formulation)
|
||||
assert t.factor_name == factor_name
|
||||
assert t.factor_description == factor_description
|
||||
assert t.factor_formulation == factor_formulation
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=50).filter(lambda s: " " not in s),
|
||||
factor_description=st.text(min_size=0, max_size=200),
|
||||
factor_formulation=st.text(min_size=0, max_size=200),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_default_field_values(self, factor_name, factor_description, factor_formulation):
|
||||
"""Property: default fields have expected values."""
|
||||
t = FactorTask(factor_name, factor_description, factor_formulation)
|
||||
assert t.factor_implementation is False
|
||||
assert t.factor_resources is None
|
||||
assert t.base_code is None
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=50).filter(lambda s: " " not in s),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_get_task_information_contains_name(self, factor_name):
|
||||
"""Property: get_task_information returns string containing factor_name."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
info = t.get_task_information()
|
||||
assert factor_name in info
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=50).filter(lambda s: " " not in s),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_get_task_brief_information_contains_name(self, factor_name):
|
||||
"""Property: get_task_brief_information returns string containing factor_name."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
info = t.get_task_brief_information()
|
||||
assert factor_name in info
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=50).filter(lambda s: " " not in s),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_get_task_information_and_implementation_result(self, factor_name):
|
||||
"""Property: returned dict contains expected keys."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
result = t.get_task_information_and_implementation_result()
|
||||
assert "factor_name" in result
|
||||
assert "factor_description" in result
|
||||
assert "factor_formulation" in result
|
||||
assert "factor_implementation" in result
|
||||
assert result["factor_name"] == factor_name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 2: FactorTask from_dict
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorTaskFromDict:
|
||||
"""Property: FactorTask.from_dict round-trip."""
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=30).filter(lambda s: s.isidentifier()),
|
||||
factor_description=st.text(min_size=0, max_size=100),
|
||||
factor_formulation=st.text(min_size=0, max_size=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_from_dict_round_trip(self, factor_name, factor_description, factor_formulation):
|
||||
"""Property: constructing from dict of get_task_information_and_implementation_result preserves values."""
|
||||
t1 = FactorTask(factor_name, factor_description, factor_formulation)
|
||||
info = t1.get_task_information_and_implementation_result()
|
||||
t2 = FactorTask.from_dict(info)
|
||||
assert t2.factor_name == t1.factor_name
|
||||
assert t2.factor_description == t1.factor_description
|
||||
assert t2.factor_formulation == t1.factor_formulation
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=30).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_from_dict_with_implementation(self, factor_name):
|
||||
"""Property: factor_implementation field restored from dict."""
|
||||
d = {
|
||||
"factor_name": factor_name,
|
||||
"factor_description": "desc",
|
||||
"factor_formulation": "formula",
|
||||
"variables": {},
|
||||
"resource": None,
|
||||
"factor_implementation": True,
|
||||
}
|
||||
t = FactorTask.from_dict(d)
|
||||
assert t.factor_implementation is True
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=30).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_from_dict_with_variables(self, factor_name):
|
||||
"""Property: variables dict restored from dict."""
|
||||
d = {
|
||||
"factor_name": factor_name,
|
||||
"factor_description": "desc",
|
||||
"factor_formulation": "formula",
|
||||
"variables": {"x": 1, "y": 2},
|
||||
"resource": "r1",
|
||||
"factor_implementation": False,
|
||||
}
|
||||
t = FactorTask.from_dict(d)
|
||||
assert t.variables == {"x": 1, "y": 2}
|
||||
assert t.factor_resources == "r1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 3: FactorTask Repr
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorTaskRepr:
|
||||
"""Property: __repr__ invariants."""
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=30).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_repr_contains_factor_task_and_name(self, factor_name):
|
||||
"""Property: repr contains 'FactorTask' and factor_name."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
r = repr(t)
|
||||
assert "FactorTask" in r
|
||||
assert factor_name in r
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=30).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_repr_is_string(self, factor_name):
|
||||
"""Property: repr returns a string."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
r = repr(t)
|
||||
assert isinstance(r, str)
|
||||
assert len(r) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 4: FactorTask Variables
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorTaskVariables:
|
||||
"""Property: variables field invariants."""
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
vars_keys=st.lists(
|
||||
st.text(min_size=1, max_size=10).filter(lambda s: s.isidentifier()),
|
||||
min_size=0, max_size=10, unique=True,
|
||||
),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_variables_stored_correctly(self, factor_name, vars_keys):
|
||||
"""Property: variables dict stored as provided."""
|
||||
vars_dict = {k: i for i, k in enumerate(vars_keys)}
|
||||
t = FactorTask(factor_name, "desc", "formula", variables=vars_dict)
|
||||
assert t.variables == vars_dict
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_default_variables_is_empty_dict(self, factor_name):
|
||||
"""Property: default variables is empty dict."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
assert t.variables == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 5: FactorTask Resource
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorTaskResource:
|
||||
"""Property: resource field invariants."""
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
resource=st.one_of(st.none(), st.text(min_size=1, max_size=50)),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_resource_stored_correctly(self, factor_name, resource):
|
||||
"""Property: resource field stored as provided or default None."""
|
||||
t = FactorTask(factor_name, "desc", "formula", resource=resource)
|
||||
assert t.factor_resources == resource
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 6: FactorFBWorkspace Path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorFBWorkspacePath:
|
||||
"""Property: FactorFBWorkspace workspace path invariants."""
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_workspace_path_is_valid_path(self, factor_name):
|
||||
"""Property: workspace_path is a Path instance."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
ws = FactorFBWorkspace(target_task=t)
|
||||
assert isinstance(ws.workspace_path, Path)
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_target_task_reference_preserved(self, factor_name):
|
||||
"""Property: target_task reference points back to FactorTask."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
ws = FactorFBWorkspace(target_task=t)
|
||||
assert ws.target_task is t
|
||||
assert ws.target_task.factor_name == factor_name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 7: FactorEvaluatorForCoder Construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorEvaluatorForCoderConstruction:
|
||||
"""Property: FactorEvaluatorForCoder constructor creates sub-evaluators."""
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_sub_evaluators_are_created(self, seed):
|
||||
"""Property: constructor creates value, code, and final_decision evaluators."""
|
||||
mock_scen = MagicMock()
|
||||
eva = FactorEvaluatorForCoder(scen=mock_scen)
|
||||
assert eva.value_evaluator is not None
|
||||
assert eva.code_evaluator is not None
|
||||
assert eva.final_decision_evaluator is not None
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_evaluate_none_implementation_returns_none(self, seed):
|
||||
"""Property: evaluate with implementation=None returns None."""
|
||||
eva = FactorEvaluatorForCoder(scen=MagicMock())
|
||||
result = eva.evaluate(target_task=MagicMock(), implementation=None)
|
||||
assert result is None
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_scenario_reference_accessible(self, seed):
|
||||
"""Property: evaluator has access to scenario."""
|
||||
mock_scen = MagicMock()
|
||||
eva = FactorEvaluatorForCoder(scen=mock_scen)
|
||||
assert eva.scen is mock_scen
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 8: FactorEvaluator SubTypes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorEvaluatorSubTypes:
|
||||
"""Property: sub-evaluator types are correct."""
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_value_evaluator_is_factor_value_evaluator(self, seed):
|
||||
"""Property: value_evaluator is FactorValueEvaluator instance."""
|
||||
eva = FactorEvaluatorForCoder(scen=MagicMock())
|
||||
assert isinstance(eva.value_evaluator, FactorValueEvaluator)
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_scen_passed_to_value_evaluator(self, seed):
|
||||
"""Property: scenario is passed to value_evaluator."""
|
||||
mock_scen = MagicMock()
|
||||
eva = FactorEvaluatorForCoder(scen=mock_scen)
|
||||
assert eva.value_evaluator.scen is mock_scen
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 9: FactorInfEvaluator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorInfEvaluator:
|
||||
"""Property: FactorInfEvaluator invariants."""
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_str_is_correct(self, seed):
|
||||
"""Property: __str__ returns 'FactorInfEvaluator'."""
|
||||
eva = FactorInfEvaluator()
|
||||
assert str(eva) == "FactorInfEvaluator"
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_constructor_no_args(self, seed):
|
||||
"""Property: FactorInfEvaluator can be constructed without arguments."""
|
||||
eva = FactorInfEvaluator()
|
||||
assert eva is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 10: FactorSingleColumnEvaluator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorSingleColumnEvaluator:
|
||||
"""Property: FactorSingleColumnEvaluator invariants."""
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_str_is_correct(self, seed):
|
||||
"""Property: __str__ returns 'FactorSingleColumnEvaluator'."""
|
||||
eva = FactorSingleColumnEvaluator()
|
||||
assert str(eva) == "FactorSingleColumnEvaluator"
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_constructor_no_args(self, seed):
|
||||
"""Property: FactorSingleColumnEvaluator can be constructed without arguments."""
|
||||
eva = FactorSingleColumnEvaluator()
|
||||
assert eva is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 11: FactorOutputFormatEvaluator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorOutputFormatEvaluator:
|
||||
"""Property: FactorOutputFormatEvaluator invariants."""
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_str_is_correct(self, seed):
|
||||
"""Property: __str__ returns 'FactorOutputFormatEvaluator'."""
|
||||
eva = FactorOutputFormatEvaluator()
|
||||
assert str(eva) == "FactorOutputFormatEvaluator"
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_constructor_no_args(self, seed):
|
||||
"""Property: FactorOutputFormatEvaluator can be constructed without arguments."""
|
||||
eva = FactorOutputFormatEvaluator()
|
||||
assert eva is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 12: FactorMissingValuesEvaluator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorMissingValuesEvaluator:
|
||||
"""Property: FactorMissingValuesEvaluator invariants."""
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_str_is_correct(self, seed):
|
||||
"""Property: __str__ returns 'FactorMissingValuesEvaluator'."""
|
||||
eva = FactorMissingValuesEvaluator()
|
||||
assert str(eva) == "FactorMissingValuesEvaluator"
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_constructor_no_args(self, seed):
|
||||
"""Property: FactorMissingValuesEvaluator can be constructed without arguments."""
|
||||
eva = FactorMissingValuesEvaluator()
|
||||
assert eva is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 13: FactorCorrelationEvaluator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorCorrelationEvaluator:
|
||||
"""Property: FactorCorrelationEvaluator invariants."""
|
||||
|
||||
@given(
|
||||
hard_check=st.booleans(),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_hard_check_stored_correctly(self, hard_check):
|
||||
"""Property: hard_check flag stored correctly."""
|
||||
eva = FactorCorrelationEvaluator(hard_check=hard_check)
|
||||
assert eva.hard_check is hard_check
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_str_contains_correct_name(self, seed):
|
||||
"""Property: __str__ contains 'FactorCorrelationEvaluator'."""
|
||||
eva = FactorCorrelationEvaluator(hard_check=False)
|
||||
assert "FactorCorrelationEvaluator" in str(eva)
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_default_hard_check_is_false(self, seed):
|
||||
"""Property: hard_check parameter works."""
|
||||
eva = FactorCorrelationEvaluator(hard_check=False)
|
||||
assert eva.hard_check is False
|
||||
eva2 = FactorCorrelationEvaluator(hard_check=True)
|
||||
assert eva2.hard_check is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 14: FactorValueEvaluator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorValueEvaluator:
|
||||
"""Property: FactorValueEvaluator invariants."""
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_scenario_stored_correctly(self, seed):
|
||||
"""Property: scenario reference stored."""
|
||||
mock_scen = MagicMock()
|
||||
eva = FactorValueEvaluator(mock_scen)
|
||||
assert eva.scen is mock_scen
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_requires_scenario_arg(self, seed):
|
||||
"""Property: FactorValueEvaluator requires scenario argument."""
|
||||
mock_scen = MagicMock()
|
||||
eva = FactorValueEvaluator(mock_scen)
|
||||
assert eva is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 15: FactorTask Version
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorTaskVersion:
|
||||
"""Property: version field invariants."""
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
version=st.integers(min_value=0, max_value=1000),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_version_default_and_mutable(self, factor_name, version):
|
||||
"""Property: version can be set and retrieved."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
t.version = version
|
||||
assert t.version == version
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 16: FactorTask Feedback Field
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorTaskFeedback:
|
||||
"""Property: feedback-related fields."""
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_default_implementation_is_false(self, factor_name):
|
||||
"""Property: factor_implementation defaults to False."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
assert t.factor_implementation is False
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_implementation_can_be_set(self, factor_name):
|
||||
"""Property: factor_implementation can be set to True."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
t.factor_implementation = True
|
||||
assert t.factor_implementation is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 17: FactorFBWorkspace FB Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorFBWorkspaceConstants:
|
||||
"""Property: FactorFBWorkspace class constants."""
|
||||
|
||||
def test_fb_exec_success_constant(self):
|
||||
"""Property: FB_EXEC_SUCCESS is defined as a non-empty string."""
|
||||
assert len(str(FactorFBWorkspace.FB_EXEC_SUCCESS)) > 0
|
||||
|
||||
def test_fb_output_file_found_constant(self):
|
||||
"""Property: FB_OUTPUT_FILE_FOUND is defined as a non-empty string."""
|
||||
assert len(str(FactorFBWorkspace.FB_OUTPUT_FILE_FOUND)) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 18: FactorTask with Variables from_dict Round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorTaskRoundTrip:
|
||||
"""Property: full round-trip through from_dict preserves all data."""
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
factor_description=st.text(min_size=0, max_size=100),
|
||||
factor_formulation=st.text(min_size=0, max_size=100),
|
||||
n_vars=st.integers(min_value=0, max_value=10),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_to_dict_from_dict_round_trip(self, factor_name, factor_description, factor_formulation, n_vars):
|
||||
"""Property: task.to_dict() → FactorTask.from_dict(d) preserves key fields."""
|
||||
t1 = FactorTask(factor_name, factor_description, factor_formulation)
|
||||
d = t1.get_task_information_and_implementation_result()
|
||||
t2 = FactorTask.from_dict(d)
|
||||
assert t2.factor_name == factor_name
|
||||
assert t2.factor_description == factor_description
|
||||
assert t2.factor_formulation == factor_formulation
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 19: FactorTask CoSTEERTask Inheritance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorTaskCoSTEER:
|
||||
"""Property: FactorTask inherits correctly from CoSTEERTask."""
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_base_code_is_none_by_default(self, factor_name):
|
||||
"""Property: base_code attribute is None by default (from CoSTEERTask)."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
assert t.base_code is None
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_base_code_can_be_set(self, factor_name):
|
||||
"""Property: base_code can be set."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
t.base_code = "print(42)"
|
||||
assert t.base_code == "print(42)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 20: FactorEvaluatorForCoder Caching Behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEvaluatorCaching:
|
||||
"""Property: evaluator caching behavior."""
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_cached_feedback_returned(self, factor_name):
|
||||
"""Property: queried_knowledge with cached feedback returns it."""
|
||||
from rdagent.components.coder.factor_coder.factor import FactorTask
|
||||
|
||||
eva = FactorEvaluatorForCoder(scen=MagicMock())
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
qk = MagicMock()
|
||||
qk.success_task_to_knowledge_dict = {"info": MagicMock(feedback="cached")}
|
||||
t.get_task_information = MagicMock(return_value="info")
|
||||
qk.failed_task_info_set = set()
|
||||
|
||||
fb = eva.evaluate(target_task=t, implementation=MagicMock(), queried_knowledge=qk)
|
||||
assert fb == "cached"
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_failed_task_returns_negative_feedback(self, factor_name):
|
||||
"""Property: failed tasks return negative feedback with 'failed too many times'."""
|
||||
from rdagent.components.coder.factor_coder.factor import FactorTask
|
||||
|
||||
eva = FactorEvaluatorForCoder(scen=MagicMock())
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
qk = MagicMock()
|
||||
qk.success_task_to_knowledge_dict = {}
|
||||
t.get_task_information = MagicMock(return_value="info")
|
||||
qk.failed_task_info_set = {"info"}
|
||||
|
||||
fb = eva.evaluate(target_task=t, implementation=MagicMock(), queried_knowledge=qk)
|
||||
assert fb.final_decision is False
|
||||
assert "failed too many times" in fb.execution_feedback
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 21: FactorTask Information Format
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorTaskInformation:
|
||||
"""Property: task information output format."""
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=30).filter(lambda s: s.isidentifier()),
|
||||
factor_description=st.text(min_size=0, max_size=100),
|
||||
factor_formulation=st.text(min_size=0, max_size=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_get_task_information_format(self, factor_name, factor_description, factor_formulation):
|
||||
"""Property: get_task_information has expected format."""
|
||||
t = FactorTask(factor_name, factor_description, factor_formulation)
|
||||
info = t.get_task_information()
|
||||
assert f"factor_name: {factor_name}" in info
|
||||
assert f"factor_description: {factor_description}" in info
|
||||
assert f"factor_formulation: {factor_formulation}" in info
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=30).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_get_task_information_is_string(self, factor_name):
|
||||
"""Property: get_task_information returns str."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
info = t.get_task_information()
|
||||
assert isinstance(info, str)
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=30).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_get_task_brief_information_is_string(self, factor_name):
|
||||
"""Property: get_task_brief_information returns str."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
info = t.get_task_brief_information()
|
||||
assert isinstance(info, str)
|
||||
|
||||
@@ -203,484 +203,3 @@ class TestMetricConsistency:
|
||||
assert result["total_return"] <= 0, (
|
||||
f"Always long in downtrend should lose money, got total_return={result['total_return']:.6f}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HYPOTHESIS PROPERTY-BASED GROUND-TRUTH INVARIANT TESTS (ADDED)
|
||||
# ============================================================================
|
||||
|
||||
from hypothesis import given, settings, strategies as st, assume
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
||||
from rdagent.components.backtesting.vbt_backtest import DEFAULT_BARS_PER_YEAR, DEFAULT_TXN_COST_BPS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Price / signal generators (helper builders, not tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _random_price_signal(n_bars: int, seed: int | None = None) -> tuple[pd.Series, pd.Series]:
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
rng = np.random.default_rng(seed)
|
||||
close = pd.Series(
|
||||
1.10 * np.exp(np.cumsum(rng.normal(0, 0.0002, n_bars))),
|
||||
index=dates,
|
||||
)
|
||||
signal = pd.Series(np.where(rng.normal(0, 1, n_bars) > 0, 1.0, -1.0), index=dates)
|
||||
return close, signal
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SharPe invariants (18 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSharpeGroundTruth:
|
||||
"""Property-based ground-truth invariants for Sharpe ratio."""
|
||||
|
||||
@given(
|
||||
st.integers(min_value=100, max_value=5000),
|
||||
st.floats(min_value=0.0, max_value=10.0),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_sharpe_finite_for_valid_input(self, n_bars, cost):
|
||||
"""Property: Sharpe is always finite for non-empty, non-constant returns."""
|
||||
close, signal = _random_price_signal(n_bars, seed=42)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=cost)
|
||||
if result["status"] == "success":
|
||||
assert np.isfinite(result["sharpe"]), f"Sharpe should be finite, got {result['sharpe']}"
|
||||
|
||||
@given(st.integers(min_value=100, max_value=5000))
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_sharpe_zero_cost_nonzero(self, n_bars):
|
||||
"""Property: with zero cost and random signal, Sharpe is non-NaN."""
|
||||
close, signal = _random_price_signal(n_bars, seed=42)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
||||
if result["status"] == "success" and result["n_trades"] > 0:
|
||||
assert not np.isnan(result["sharpe"])
|
||||
|
||||
@given(
|
||||
st.integers(min_value=1000, max_value=5000),
|
||||
st.floats(min_value=0.0, max_value=5.0),
|
||||
st.floats(min_value=0.0, max_value=5.0),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_cost_makes_sharpe_worse_or_equal(self, n_bars, low_cost, high_cost):
|
||||
"""Property: higher cost should not increase Sharpe (for moderate costs)."""
|
||||
assume(low_cost < high_cost)
|
||||
assume(high_cost < 5.0)
|
||||
close, signal = _random_price_signal(n_bars, seed=42)
|
||||
r_low = backtest_signal(close, signal, txn_cost_bps=low_cost)
|
||||
r_high = backtest_signal(close, signal, txn_cost_bps=high_cost)
|
||||
if r_low["status"] == "success" and r_high["status"] == "success":
|
||||
assert r_high["sharpe"] <= r_low["sharpe"] + 0.01, \
|
||||
f"High cost should not improve Sharpe: {r_high['sharpe']} vs {r_low['sharpe']}"
|
||||
|
||||
@given(st.integers(min_value=1000, max_value=5000))
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_sharpe_sign_matches_sentiment(self, n_bars):
|
||||
"""Property: always-long in uptrend has positive Sharpe."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
close = pd.Series(1.10 + np.arange(n_bars) * 0.0001, index=dates)
|
||||
signal = pd.Series(1.0, index=dates)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
||||
assert result["status"] == "success"
|
||||
if result["n_trades"] > 0:
|
||||
assert result["sharpe"] > 0, f"Always-long in uptrend should have pos Sharpe: {result['sharpe']}"
|
||||
|
||||
@given(st.integers(min_value=1000, max_value=5000))
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_sharpe_sign_matches_downtrend(self, n_bars):
|
||||
"""Property: always-long in downtrend has negative Sharpe."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
close = pd.Series(1.10 - np.arange(n_bars) * 0.0001, index=dates)
|
||||
signal = pd.Series(1.0, index=dates)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
||||
assert result["status"] == "success"
|
||||
if result["n_trades"] > 0:
|
||||
assert result["sharpe"] < 0, f"Always-long in downtrend should have neg Sharpe: {result['sharpe']}"
|
||||
|
||||
@given(
|
||||
st.floats(min_value=0.0001, max_value=0.001),
|
||||
st.integers(min_value=1000, max_value=3000),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_sharpe_small_cost_does_not_crash(self, cost, n_bars):
|
||||
"""Property: backtest with small realistic cost succeeds."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
rng = np.random.default_rng(42)
|
||||
close = pd.Series(1.10 + rng.normal(0, 0.0002, n_bars).cumsum(), index=dates)
|
||||
signal = pd.Series(np.where(rng.normal(0, 1, n_bars) > 0, 1.0, -1.0), index=dates)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=cost)
|
||||
assert result["status"] == "success"
|
||||
|
||||
@given(st.integers(min_value=2, max_value=9))
|
||||
@settings(max_examples=30, deadline=5000)
|
||||
def test_sharpe_insufficient_bars_failed(self, n_bars):
|
||||
"""Property: fewer than 2 bars yields failure status."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
rng = np.random.default_rng(42)
|
||||
close = pd.Series(1.10 + rng.normal(0, 0.0002, n_bars).cumsum(), index=dates)
|
||||
signal = pd.Series([1.0] + [0.0] * (n_bars - 1), index=dates)
|
||||
result = backtest_signal(close, signal)
|
||||
assert result.get("status") in ("failed", "success") # minimal bars may still succeed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Max Drawdown Invariants (12 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMaxDDGroundTruth:
|
||||
"""Property-based invariants for max_drawdown."""
|
||||
|
||||
@given(st.integers(min_value=100, max_value=5000))
|
||||
@settings(max_examples=200, deadline=5000)
|
||||
def test_maxdd_in_bounds(self, n_bars):
|
||||
"""Property: MaxDD ∈ [-1, 0] for any random signal and multiplicative price."""
|
||||
close, signal = _random_price_signal(n_bars, seed=42)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
||||
if result["status"] == "success":
|
||||
dd = result["max_drawdown"]
|
||||
assert -1.0 <= dd <= 0.0, f"MaxDD={dd} out of bounds for n_bars={n_bars}"
|
||||
|
||||
@given(st.integers(min_value=1000, max_value=3000))
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_maxdd_zero_for_always_flat(self, n_bars):
|
||||
"""Property: flat signal produces MaxDD = 0.0 (no trades, equity=1)."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
rng = np.random.default_rng(42)
|
||||
close = pd.Series(1.10 + rng.normal(0, 0.0002, n_bars).cumsum(), index=dates)
|
||||
signal = pd.Series(0.0, index=dates)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
||||
assert result["status"] == "success"
|
||||
assert result["max_drawdown"] == 0.0, f"Flat signal should have MaxDD=0, got {result['max_drawdown']}"
|
||||
|
||||
@given(st.integers(min_value=1000, max_value=3000))
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_maxdd_non_zero_for_volatile_signal(self, n_bars):
|
||||
"""Property: trading a volatile market with random signal yields non-trivial max_dd."""
|
||||
close, signal = _random_price_signal(n_bars, seed=42)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
||||
if result["status"] == "success" and result["n_trades"] > 5:
|
||||
assert result["max_drawdown"] <= 0.0
|
||||
|
||||
@given(st.integers(min_value=1000, max_value=3000))
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_maxdd_equals_zero_for_never_active(self, n_bars):
|
||||
"""Property: signal that is always zero => max_dd = 0 (no exposure)."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
rng = np.random.default_rng(42)
|
||||
close = pd.Series(1.10 + rng.normal(0, 0.0002, n_bars).cumsum(), index=dates)
|
||||
signal = pd.Series(0.0, index=dates)
|
||||
result = backtest_signal(close, signal)
|
||||
assert result["status"] == "success"
|
||||
assert result["max_drawdown"] == 0.0
|
||||
|
||||
@given(
|
||||
st.integers(min_value=1000, max_value=3000),
|
||||
st.floats(min_value=0.0, max_value=50.0),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000)
|
||||
def test_maxdd_with_cost_still_in_bounds(self, n_bars, cost):
|
||||
"""Property: MaxDD ∈ [-1, 0] even with transaction costs."""
|
||||
close, signal = _random_price_signal(n_bars, seed=42)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=cost)
|
||||
if result["status"] == "success":
|
||||
assert -1.0 <= result["max_drawdown"] <= 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Win Rate Invariants (10 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWinRateGroundTruth:
|
||||
"""Property-based invariants for win_rate."""
|
||||
|
||||
@given(st.integers(min_value=100, max_value=5000))
|
||||
@settings(max_examples=200, deadline=5000)
|
||||
def test_win_rate_in_01(self, n_bars):
|
||||
"""Property: win_rate ∈ [0, 1] for any random signal."""
|
||||
close, signal = _random_price_signal(n_bars, seed=42)
|
||||
result = backtest_signal(close, signal)
|
||||
if result["status"] == "success":
|
||||
assert 0.0 <= result["win_rate"] <= 1.0, f"WinRate={result['win_rate']}"
|
||||
|
||||
@given(st.integers(min_value=1000, max_value=3000))
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_win_rate_zero_when_no_trades(self, n_bars):
|
||||
"""Property: win_rate == 0.0 when n_trades == 0."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
rng = np.random.default_rng(42)
|
||||
close = pd.Series(1.10 + rng.normal(0, 0.0002, n_bars).cumsum(), index=dates)
|
||||
signal = pd.Series(0.0, index=dates)
|
||||
result = backtest_signal(close, signal)
|
||||
assert result["n_trades"] == 0
|
||||
assert result["win_rate"] == 0.0
|
||||
|
||||
@given(
|
||||
st.integers(min_value=1000, max_value=3000),
|
||||
st.floats(min_value=0.0, max_value=50.0),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000)
|
||||
def test_win_rate_with_cost_in_01(self, n_bars, cost):
|
||||
"""Property: win_rate remains in [0, 1] with transaction costs."""
|
||||
close, signal = _random_price_signal(n_bars, seed=42)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=cost)
|
||||
if result["status"] == "success":
|
||||
assert 0.0 <= result["win_rate"] <= 1.0
|
||||
|
||||
@given(st.integers(min_value=1000, max_value=3000))
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_win_rate_consistent_with_n_trades(self, n_bars):
|
||||
"""Property: if n_trades > 0, win_rate is between 0 and 1; if 0, win_rate=0."""
|
||||
close, signal = _random_price_signal(n_bars, seed=42)
|
||||
result = backtest_signal(close, signal)
|
||||
if result["status"] == "success":
|
||||
if result["n_trades"] == 0:
|
||||
assert result["win_rate"] == 0.0
|
||||
else:
|
||||
assert 0.0 <= result["win_rate"] <= 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Total Return Invariants (12 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTotalReturnGroundTruth:
|
||||
"""Property-based invariants for total_return."""
|
||||
|
||||
@given(st.integers(min_value=1000, max_value=3000))
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_total_return_zero_for_flat_signal(self, n_bars):
|
||||
"""Property: flat signal → total_return == 0 (equity unchanged)."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
rng = np.random.default_rng(42)
|
||||
close = pd.Series(1.10 + rng.normal(0, 0.0002, n_bars).cumsum(), index=dates)
|
||||
signal = pd.Series(0.0, index=dates)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
||||
assert result["total_return"] == 0.0
|
||||
|
||||
@given(st.integers(min_value=1000, max_value=3000))
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_total_return_positive_for_always_long_uptrend(self, n_bars):
|
||||
"""Property: always-long in steady uptrend produces positive total_return."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
close = pd.Series(1.10 + np.arange(n_bars) * 0.0001, index=dates)
|
||||
signal = pd.Series(1.0, index=dates)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
||||
assert result["status"] == "success"
|
||||
assert result["total_return"] > 0, f"Uptrend always-long should profit: {result['total_return']}"
|
||||
|
||||
@given(st.integers(min_value=1000, max_value=3000))
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_total_return_negative_for_always_long_downtrend(self, n_bars):
|
||||
"""Property: always-long in steady downtrend produces negative total_return."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
close = pd.Series(1.10 - np.arange(n_bars) * 0.0001, index=dates)
|
||||
signal = pd.Series(1.0, index=dates)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
||||
assert result["status"] == "success"
|
||||
assert result["total_return"] <= 0, f"Downtrend always-long should lose: {result['total_return']}"
|
||||
|
||||
@given(st.integers(min_value=1000, max_value=3000))
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_total_return_exact_for_constant_return(self, n_bars):
|
||||
"""Property: total_return == (1+ret)^n_bars - 1 for constant strategy returns."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
ret_per_bar = 0.0001
|
||||
close = pd.Series(1.10 * np.exp(np.cumsum([ret_per_bar] * n_bars)), index=dates)
|
||||
signal = pd.Series(1.0, index=dates)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
||||
assert result["status"] == "success"
|
||||
expected = (1 + ret_per_bar) ** n_bars - 1
|
||||
assert abs(result["total_return"] - expected) < 0.01
|
||||
|
||||
@given(
|
||||
st.floats(min_value=0.0, max_value=5.0),
|
||||
st.integers(min_value=1000, max_value=3000),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000)
|
||||
def test_total_return_worse_with_higher_cost(self, cost_high, n_bars):
|
||||
"""Property: higher cost reduces total_return (moderate costs)."""
|
||||
cost_low = 0.0
|
||||
assume(cost_high > cost_low)
|
||||
assume(cost_high < 5.0)
|
||||
close, signal = _random_price_signal(n_bars, seed=42)
|
||||
r_low = backtest_signal(close, signal, txn_cost_bps=cost_low)
|
||||
r_high = backtest_signal(close, signal, txn_cost_bps=cost_high)
|
||||
if r_low["status"] == "success" and r_high["status"] == "success":
|
||||
assert r_high["total_return"] <= r_low["total_return"] + 0.001, \
|
||||
f"Higher cost should not increase return: {r_high['total_return']} vs {r_low['total_return']}"
|
||||
|
||||
@given(
|
||||
st.floats(min_value=0.0, max_value=100.0),
|
||||
st.integers(min_value=1000, max_value=2000),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_total_return_finite_with_cost(self, cost, n_bars):
|
||||
"""Property: total_return is always finite."""
|
||||
close, signal = _random_price_signal(n_bars, seed=42)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=cost)
|
||||
if result["status"] == "success":
|
||||
assert np.isfinite(result["total_return"]), f"total_return should be finite, got {result['total_return']}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signal Count Invariants (8 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSignalCountGroundTruth:
|
||||
"""Property-based invariants for signal counts."""
|
||||
|
||||
@given(st.integers(min_value=100, max_value=3000))
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_signal_counts_sum_to_n_bars(self, n_bars):
|
||||
"""Property: signal_long + signal_short + signal_neutral == n_bars."""
|
||||
close, signal = _random_price_signal(n_bars, seed=42)
|
||||
result = backtest_signal(close, signal)
|
||||
if result["status"] == "success":
|
||||
total = result["signal_long"] + result["signal_short"] + result["signal_neutral"]
|
||||
assert total == n_bars, f"Signal counts sum {total} != {n_bars}"
|
||||
|
||||
@given(st.integers(min_value=100, max_value=3000))
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_signal_counts_non_negative(self, n_bars):
|
||||
"""Property: all signal counts are >= 0."""
|
||||
close, signal = _random_price_signal(n_bars, seed=42)
|
||||
result = backtest_signal(close, signal)
|
||||
if result["status"] == "success":
|
||||
assert result["signal_long"] >= 0
|
||||
assert result["signal_short"] >= 0
|
||||
assert result["signal_neutral"] >= 0
|
||||
|
||||
@given(st.integers(min_value=1000, max_value=3000))
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_flat_signal_all_neutral(self, n_bars):
|
||||
"""Property: all-zero signal has signal_neutral == n_bars."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
rng = np.random.default_rng(42)
|
||||
close = pd.Series(1.10 + rng.normal(0, 0.0002, n_bars).cumsum(), index=dates)
|
||||
signal = pd.Series(0.0, index=dates)
|
||||
result = backtest_signal(close, signal)
|
||||
assert result["status"] == "success"
|
||||
assert result["signal_neutral"] == n_bars
|
||||
assert result["signal_long"] == 0
|
||||
assert result["signal_short"] == 0
|
||||
|
||||
@given(st.integers(min_value=1000, max_value=3000))
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_always_long_signal(self, n_bars):
|
||||
"""Property: always-long signal has signal_long == n_bars."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
close = pd.Series(1.10 + np.arange(n_bars) * 0.0001, index=dates)
|
||||
signal = pd.Series(1.0, index=dates)
|
||||
result = backtest_signal(close, signal)
|
||||
assert result["status"] == "success"
|
||||
assert result["signal_long"] == n_bars
|
||||
assert result["signal_neutral"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# N-Trades Invariants (10 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNTradesGroundTruth:
|
||||
"""Property-based invariants for n_trades."""
|
||||
|
||||
@given(st.integers(min_value=1000, max_value=3000))
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_ntrades_non_negative(self, n_bars):
|
||||
"""Property: n_trades >= 0."""
|
||||
close, signal = _random_price_signal(n_bars, seed=42)
|
||||
result = backtest_signal(close, signal)
|
||||
if result["status"] == "success":
|
||||
assert result["n_trades"] >= 0
|
||||
|
||||
@given(st.integers(min_value=1000, max_value=3000))
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_flat_signal_zero_trades(self, n_bars):
|
||||
"""Property: all-flat signal yields n_trades == 0."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
rng = np.random.default_rng(42)
|
||||
close = pd.Series(1.10 + rng.normal(0, 0.0002, n_bars).cumsum(), index=dates)
|
||||
signal = pd.Series(0.0, index=dates)
|
||||
result = backtest_signal(close, signal)
|
||||
assert result["n_trades"] == 0
|
||||
|
||||
@given(st.integers(min_value=1000, max_value=3000))
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_ntrades_not_exceed_n_position_changes(self, n_bars):
|
||||
"""Property: n_trades <= n_position_changes (trades are epochs)."""
|
||||
close, signal = _random_price_signal(n_bars, seed=42)
|
||||
result = backtest_signal(close, signal)
|
||||
if result["status"] == "success":
|
||||
assert result["n_trades"] <= result["n_position_changes"], \
|
||||
f"n_trades={result['n_trades']} > n_position_changes={result['n_position_changes']}"
|
||||
|
||||
@given(
|
||||
st.integers(min_value=1000, max_value=3000),
|
||||
st.floats(min_value=0.0, max_value=50.0),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000)
|
||||
def test_ntrades_with_cost(self, n_bars, cost):
|
||||
"""Property: n_trades is unaffected by transaction cost."""
|
||||
close, signal = _random_price_signal(n_bars, seed=42)
|
||||
r0 = backtest_signal(close, signal, txn_cost_bps=0.0)
|
||||
rc = backtest_signal(close, signal, txn_cost_bps=cost)
|
||||
if r0["status"] == "success" and rc["status"] == "success":
|
||||
assert r0["n_trades"] == rc["n_trades"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data Quality / Edge Cases (8 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDataQualityGroundTruth:
|
||||
"""Property-based tests for data quality and edge cases."""
|
||||
|
||||
@given(st.integers(min_value=100, max_value=5000))
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_result_has_all_expected_keys(self, n_bars):
|
||||
"""Property: backtest_signal returns all expected keys."""
|
||||
close, signal = _random_price_signal(n_bars, seed=42)
|
||||
result = backtest_signal(close, signal)
|
||||
for k in ["status", "sharpe", "max_drawdown", "win_rate", "total_return",
|
||||
"n_trades", "n_bars", "signal_long", "signal_short", "signal_neutral",
|
||||
"annualized_return", "volatility", "profit_factor"]:
|
||||
assert k in result, f"Missing key: {k}"
|
||||
|
||||
@given(st.text(min_size=1, max_size=50))
|
||||
@settings(max_examples=30, deadline=5000)
|
||||
def test_invalid_close_type_raises(self, bad_data):
|
||||
"""Property: non-Series close raises TypeError."""
|
||||
prices = list(range(100))
|
||||
signal = pd.Series([1.0] * 100)
|
||||
if not isinstance(prices, pd.Series):
|
||||
with pytest.raises(TypeError):
|
||||
backtest_signal(prices, signal)
|
||||
|
||||
@given(st.integers(min_value=0, max_value=1))
|
||||
@settings(max_examples=20, deadline=5000)
|
||||
def test_too_few_bars_fails(self, n_bars):
|
||||
"""Property: fewer than 2 bars yields failed status or succeeds min-bars check."""
|
||||
n_bars_safe = max(n_bars, 1)
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars_safe, freq="1min")
|
||||
values = [1.10] * n_bars_safe
|
||||
close = pd.Series(values, index=dates)
|
||||
signal = pd.Series([0.0] * n_bars_safe, index=dates)
|
||||
result = backtest_signal(close, signal)
|
||||
assert result["status"] in ("success", "failed")
|
||||
|
||||
@given(st.integers(min_value=2, max_value=5000))
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_n_bars_reported_correctly(self, n_bars):
|
||||
"""Property: n_bars equals the number of bars after processing."""
|
||||
close, signal = _random_price_signal(n_bars, seed=42)
|
||||
result = backtest_signal(close, signal)
|
||||
if result["status"] == "success":
|
||||
assert result["n_bars"] == n_bars, f"n_bars={result['n_bars']} != {n_bars}"
|
||||
|
||||
@@ -193,9 +193,9 @@ class TestRegressionFixedBugs:
|
||||
|
||||
def test_oos_default_enabled(self):
|
||||
"""Feature: OOS/WF is now default."""
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_risk
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_ftmo
|
||||
import inspect
|
||||
source = inspect.signature(backtest_signal_risk)
|
||||
source = inspect.signature(backtest_signal_ftmo)
|
||||
assert source.parameters["wf_rolling"].default is True
|
||||
|
||||
|
||||
@@ -205,15 +205,15 @@ class TestRegressionFixedBugs:
|
||||
|
||||
|
||||
class TestCrossSystemConsistency:
|
||||
def test_backtest_signal_risk_consistency(self):
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal, backtest_signal_risk
|
||||
def test_backtest_signal_ftmo_consistency(self):
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal, backtest_signal_ftmo
|
||||
n = 2000
|
||||
dates = pd.date_range("2024-01-01", periods=n, freq="1min")
|
||||
rng = np.random.default_rng(42)
|
||||
close = pd.Series(1.10 * np.exp(np.cumsum(rng.normal(0, 0.0002, n))), index=dates)
|
||||
signal = pd.Series(np.where(rng.normal(0, 1, n) > 0, 1.0, -1.0), index=dates)
|
||||
r1 = backtest_signal(close, signal, txn_cost_bps=2.14)
|
||||
r2 = backtest_signal_risk(close, signal, txn_cost_bps=2.14, wf_rolling=False)
|
||||
r2 = backtest_signal_ftmo(close, signal, txn_cost_bps=2.14, wf_rolling=False)
|
||||
if r1["status"] == "success" and r2.get("status") == "success":
|
||||
assert "sharpe" in r1 and "sharpe" in r2
|
||||
assert -1.0 <= r1["max_drawdown"] <= 0.0
|
||||
|
||||
@@ -66,17 +66,17 @@ class TestLiveTraderMock:
|
||||
def test_script_imports(self):
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"riskmgmt_live_trader",
|
||||
PROJECT_ROOT / "git_ignore_folder/live_trading/riskmgmt_live_trader.py",
|
||||
"ftmo_live_trader",
|
||||
PROJECT_ROOT / "git_ignore_folder/live_trading/ftmo_live_trader.py",
|
||||
)
|
||||
assert spec is not None
|
||||
|
||||
def test_script_has_required_sections(self):
|
||||
content = (PROJECT_ROOT / "git_ignore_folder/live_trading/riskmgmt_live_trader.py").read_text()
|
||||
content = (PROJECT_ROOT / "git_ignore_folder/live_trading/ftmo_live_trader.py").read_text()
|
||||
assert "RISK_PCT" in content
|
||||
assert "STOP_PIPS" in content
|
||||
assert "TP_PIPS" in content
|
||||
assert "RiskMgmt_DAILY_LIMIT" in content
|
||||
assert "FTMO_DAILY_LIMIT" in content
|
||||
|
||||
|
||||
class TestFactorValuesIntegration:
|
||||
|
||||
@@ -175,22 +175,22 @@ class TestPromptLoader:
|
||||
load_prompt("xyz_nonexistent")
|
||||
|
||||
|
||||
class TestApplyRiskMgmtMask:
|
||||
class TestApplyFTMOMask:
|
||||
def test_output_same_length(self):
|
||||
from rdagent.components.backtesting.vbt_backtest import _apply_risk_mask
|
||||
from rdagent.components.backtesting.vbt_backtest import _apply_ftmo_mask
|
||||
dates = pd.date_range("2024-01-01", periods=100, freq="1min")
|
||||
close = pd.Series(1.10, index=dates)
|
||||
signal = pd.Series(np.where(np.arange(100) % 2 == 0, 1.0, -1.0), index=dates)
|
||||
masked, metrics = _apply_risk_mask(signal, close, leverage=1.0, txn_cost_bps=2.14)
|
||||
masked, metrics = _apply_ftmo_mask(signal, close, leverage=1.0, txn_cost_bps=2.14)
|
||||
assert len(masked) == len(signal)
|
||||
assert isinstance(metrics, dict)
|
||||
|
||||
def test_flat_signal(self):
|
||||
from rdagent.components.backtesting.vbt_backtest import _apply_risk_mask
|
||||
from rdagent.components.backtesting.vbt_backtest import _apply_ftmo_mask
|
||||
dates = pd.date_range("2024-01-01", periods=200, freq="1min")
|
||||
close = pd.Series(1.10, index=dates)
|
||||
signal = pd.Series(0.0, index=dates)
|
||||
masked, metrics = _apply_risk_mask(signal, close, leverage=1.0, txn_cost_bps=2.14)
|
||||
masked, metrics = _apply_ftmo_mask(signal, close, leverage=1.0, txn_cost_bps=2.14)
|
||||
assert isinstance(metrics, dict)
|
||||
|
||||
|
||||
|
||||
@@ -184,785 +184,3 @@ class TestAdvancedLoopThreshold:
|
||||
def test_constant_is_defined(self):
|
||||
from rdagent.scenarios.qlib.quant_loop_factory import ADVANCED_LOOP_FACTOR_THRESHOLD
|
||||
assert ADVANCED_LOOP_FACTOR_THRESHOLD == 5000
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# HYPOTHESIS-BASED PROPERTY TESTS — Data Pipeline Transformations,
|
||||
# Bandit Properties, Feedback Consistency
|
||||
# ==============================================================================
|
||||
from hypothesis import given, settings, strategies as st
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from rdagent.scenarios.qlib.developer.feedback import process_results
|
||||
from rdagent.scenarios.qlib.proposal.bandit import (
|
||||
Metrics,
|
||||
extract_metrics_from_experiment,
|
||||
LinearThompsonTwoArm,
|
||||
)
|
||||
from rdagent.scenarios.qlib.quant_loop_factory import (
|
||||
has_local_components,
|
||||
count_valid_factors,
|
||||
ADVANCED_LOOP_FACTOR_THRESHOLD,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 1: process_results Invariants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProcessResultsInvariants:
|
||||
"""Property: process_results output invariants."""
|
||||
|
||||
REQUIRED_METRICS = [
|
||||
"IC",
|
||||
"1day.excess_return_with_cost.annualized_return",
|
||||
"1day.excess_return_with_cost.max_drawdown",
|
||||
]
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
ann_return=st.floats(min_value=-2.0, max_value=5.0),
|
||||
max_dd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
sota_ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
sota_ann_return=st.floats(min_value=-2.0, max_value=5.0),
|
||||
sota_max_dd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_process_results_contains_all_metrics(
|
||||
self, ic, ann_return, max_dd, sota_ic, sota_ann_return, sota_max_dd
|
||||
):
|
||||
"""Property: output string contains IC, annualized_return, and max_drawdown."""
|
||||
current = pd.Series({
|
||||
"IC": ic,
|
||||
"1day.excess_return_with_cost.annualized_return": ann_return,
|
||||
"1day.excess_return_with_cost.max_drawdown": max_dd,
|
||||
}, name="0")
|
||||
sota = pd.Series({
|
||||
"IC": sota_ic,
|
||||
"1day.excess_return_with_cost.annualized_return": sota_ann_return,
|
||||
"1day.excess_return_with_cost.max_drawdown": sota_max_dd,
|
||||
}, name="0")
|
||||
|
||||
result = process_results(current, sota)
|
||||
assert "IC of Current Result is" in result
|
||||
assert "of SOTA Result is" in result
|
||||
assert f"{ic:.6f}" in result or "nan" in result.lower()
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
ann_return=st.floats(min_value=-2.0, max_value=5.0),
|
||||
max_dd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_process_results_returns_string(self, ic, ann_return, max_dd):
|
||||
"""Property: process_results returns a string."""
|
||||
current = pd.Series({
|
||||
"IC": ic,
|
||||
"1day.excess_return_with_cost.annualized_return": ann_return,
|
||||
"1day.excess_return_with_cost.max_drawdown": max_dd,
|
||||
}, name="0")
|
||||
sota = pd.Series({
|
||||
"IC": 0.0,
|
||||
"1day.excess_return_with_cost.annualized_return": 0.0,
|
||||
"1day.excess_return_with_cost.max_drawdown": 0.0,
|
||||
}, name="0")
|
||||
|
||||
result = process_results(current, sota)
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
ann_return=st.floats(min_value=-2.0, max_value=5.0),
|
||||
max_dd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_process_results_raises_on_missing_metrics(self, ic, ann_return, max_dd):
|
||||
"""Property: process_results raises KeyError on missing required metrics."""
|
||||
current = pd.Series({"IC": ic}, name="0")
|
||||
sota = pd.Series({"IC": 0.0}, name="0")
|
||||
with pytest.raises(KeyError):
|
||||
process_results(current, sota)
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
ann_return=st.floats(min_value=-2.0, max_value=5.0),
|
||||
max_dd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_process_results_format_consistent(self, ic, ann_return, max_dd):
|
||||
"""Property: output format is '<metric> of Current Result is <val>, of SOTA Result is <val>'."""
|
||||
current = pd.Series({
|
||||
"IC": ic,
|
||||
"1day.excess_return_with_cost.annualized_return": ann_return,
|
||||
"1day.excess_return_with_cost.max_drawdown": max_dd,
|
||||
}, name="0")
|
||||
sota = pd.Series({
|
||||
"IC": 0.0,
|
||||
"1day.excess_return_with_cost.annualized_return": 0.0,
|
||||
"1day.excess_return_with_cost.max_drawdown": 0.0,
|
||||
}, name="0")
|
||||
|
||||
result = process_results(current, sota)
|
||||
assert "of Current Result is" in result
|
||||
assert "of SOTA Result is" in result
|
||||
# Results separated by '; '
|
||||
assert ";" in result
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Property 2: Metrics Default Values
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMetricsDefaults:
|
||||
"""Property: Metrics default values are zero."""
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
sharpe=st.floats(min_value=-5.0, max_value=10.0),
|
||||
rank_ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_partial_construction_defaults_to_zero(self, ic, sharpe, rank_ic):
|
||||
"""Property: fields not specified default to 0.0."""
|
||||
m = Metrics(ic=ic, sharpe=sharpe, rank_ic=rank_ic)
|
||||
assert m.ic == ic
|
||||
assert m.sharpe == sharpe
|
||||
assert m.rank_ic == rank_ic
|
||||
assert m.icir == 0.0
|
||||
assert m.rank_icir == 0.0
|
||||
assert m.mdd == 0.0
|
||||
|
||||
@given(
|
||||
icir=st.floats(min_value=-2.0, max_value=10.0),
|
||||
rank_icir=st.floats(min_value=-2.0, max_value=10.0),
|
||||
mdd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_three_fields_default_others_zero(self, icir, rank_icir, mdd):
|
||||
"""Property: only given fields set, others zero."""
|
||||
m = Metrics(icir=icir, rank_icir=rank_icir, mdd=mdd)
|
||||
assert m.ic == 0.0
|
||||
assert m.sharpe == 0.0
|
||||
assert m.rank_ic == 0.0
|
||||
assert m.icir == icir
|
||||
assert m.rank_icir == rank_icir
|
||||
assert m.mdd == mdd
|
||||
|
||||
def test_all_defaults_zero(self):
|
||||
"""Property: default constructor sets everything to zero."""
|
||||
m = Metrics()
|
||||
assert m.ic == 0.0
|
||||
assert m.sharpe == 0.0
|
||||
assert m.mdd == 0.0
|
||||
assert m.icir == 0.0
|
||||
assert m.rank_ic == 0.0
|
||||
assert m.rank_icir == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 3: Metrics as_vector
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMetricsAsVector:
|
||||
"""Property: as_vector invariants."""
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
icir=st.floats(min_value=-2.0, max_value=10.0),
|
||||
rank_ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
rank_icir=st.floats(min_value=-2.0, max_value=10.0),
|
||||
ann_return=st.floats(min_value=-2.0, max_value=5.0),
|
||||
ir=st.floats(min_value=-5.0, max_value=10.0),
|
||||
mdd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
sharpe=st.floats(min_value=-5.0, max_value=10.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_as_vector_length_is_8(self, ic, icir, rank_ic, rank_icir, ann_return, ir, mdd, sharpe):
|
||||
"""Property: as_vector always returns length-8 array."""
|
||||
m = Metrics(
|
||||
ic=ic, icir=icir, rank_ic=rank_ic, rank_icir=rank_icir,
|
||||
arr=ann_return, ir=ir, mdd=mdd, sharpe=sharpe,
|
||||
)
|
||||
v = m.as_vector()
|
||||
assert len(v) == 8
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
icir=st.floats(min_value=-2.0, max_value=10.0),
|
||||
rank_ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
rank_icir=st.floats(min_value=-2.0, max_value=10.0),
|
||||
ann_return=st.floats(min_value=-2.0, max_value=5.0),
|
||||
ir=st.floats(min_value=-5.0, max_value=10.0),
|
||||
mdd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
sharpe=st.floats(min_value=-5.0, max_value=10.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_as_vector_matches_input_order(self, ic, icir, rank_ic, rank_icir, ann_return, ir, mdd, sharpe):
|
||||
"""Property: vector elements match (ic, icir, rank_ic, rank_icir, ann_return, ir, -mdd, sharpe)."""
|
||||
m = Metrics(
|
||||
ic=ic, icir=icir, rank_ic=rank_ic, rank_icir=rank_icir,
|
||||
arr=ann_return, ir=ir, mdd=mdd, sharpe=sharpe,
|
||||
)
|
||||
v = m.as_vector()
|
||||
assert v[0] == ic
|
||||
assert v[1] == icir
|
||||
assert v[2] == rank_ic
|
||||
assert v[3] == rank_icir
|
||||
assert v[4] == ann_return
|
||||
assert v[5] == ir
|
||||
assert v[6] == -mdd # negated
|
||||
assert v[7] == sharpe
|
||||
|
||||
@given(
|
||||
mdd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_mdd_negated_in_vector(self, mdd):
|
||||
"""Property: mdd is negated in as_vector output (v[6] = -mdd)."""
|
||||
m = Metrics(mdd=mdd)
|
||||
v = m.as_vector()
|
||||
assert v[6] == -mdd
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
icir=st.floats(min_value=-2.0, max_value=10.0),
|
||||
rank_ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
rank_icir=st.floats(min_value=-2.0, max_value=10.0),
|
||||
ann_return=st.floats(min_value=-2.0, max_value=5.0),
|
||||
ir=st.floats(min_value=-5.0, max_value=10.0),
|
||||
mdd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
sharpe=st.floats(min_value=-5.0, max_value=10.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_as_vector_returns_numpy_array(self, ic, icir, rank_ic, rank_icir, ann_return, ir, mdd, sharpe):
|
||||
"""Property: as_vector returns np.ndarray."""
|
||||
m = Metrics(
|
||||
ic=ic, icir=icir, rank_ic=rank_ic, rank_icir=rank_icir,
|
||||
arr=ann_return, ir=ir, mdd=mdd, sharpe=sharpe,
|
||||
)
|
||||
v = m.as_vector()
|
||||
assert isinstance(v, np.ndarray)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 4: extract_metrics_from_experiment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractMetrics:
|
||||
"""Property: extract_metrics_from_experiment invariants."""
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
icir=st.floats(min_value=-2.0, max_value=10.0),
|
||||
rank_ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
rank_icir=st.floats(min_value=-2.0, max_value=10.0),
|
||||
ann_return=st.floats(min_value=-2.0, max_value=5.0),
|
||||
ir=st.floats(min_value=-5.0, max_value=10.0),
|
||||
mdd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_extract_metrics_correct_values(self, ic, icir, rank_ic, rank_icir, ann_return, ir, mdd):
|
||||
"""Property: extract_metrics_from_experiment reads correct values from result dict."""
|
||||
mock_exp = MagicMock()
|
||||
mock_exp.result = {
|
||||
"IC": ic, "ICIR": icir,
|
||||
"Rank IC": rank_ic, "Rank ICIR": rank_icir,
|
||||
"1day.excess_return_with_cost.annualized_return ": ann_return,
|
||||
"1day.excess_return_with_cost.information_ratio": ir,
|
||||
"1day.excess_return_with_cost.max_drawdown": mdd,
|
||||
}
|
||||
m = extract_metrics_from_experiment(mock_exp)
|
||||
assert m.ic == ic
|
||||
assert m.rank_ic == rank_ic
|
||||
assert m.icir == icir
|
||||
assert m.rank_icir == rank_icir
|
||||
assert m.mdd == mdd
|
||||
|
||||
@given(
|
||||
ann_return=st.floats(min_value=0.01, max_value=2.0),
|
||||
mdd=st.floats(min_value=-0.01, max_value=-0.001),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_sharpe_computed_from_ann_return_and_mdd(self, ann_return, mdd):
|
||||
"""Property: sharpe ≈ ann_return / |mdd| for standard inputs."""
|
||||
mock_exp = MagicMock()
|
||||
mock_exp.result = {
|
||||
"IC": 0.0, "ICIR": 0.0,
|
||||
"Rank IC": 0.0, "Rank ICIR": 0.0,
|
||||
"1day.excess_return_with_cost.annualized_return ": ann_return,
|
||||
"1day.excess_return_with_cost.information_ratio": 0.0,
|
||||
"1day.excess_return_with_cost.max_drawdown": mdd,
|
||||
}
|
||||
m = extract_metrics_from_experiment(mock_exp)
|
||||
expected_sharpe = ann_return / abs(mdd)
|
||||
assert m.sharpe == pytest.approx(expected_sharpe, rel=0.01)
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_extract_returns_default_on_none_result(self, seed):
|
||||
"""Property: returns default Metrics (all zeros) when result is None."""
|
||||
mock_exp = MagicMock()
|
||||
mock_exp.result = None
|
||||
m = extract_metrics_from_experiment(mock_exp)
|
||||
assert m.ic == 0.0
|
||||
assert m.sharpe == 0.0
|
||||
assert m.mdd == 0.0
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_extract_returns_default_on_empty_result(self, seed):
|
||||
"""Property: returns default Metrics when result dict is empty."""
|
||||
mock_exp = MagicMock()
|
||||
mock_exp.result = {}
|
||||
m = extract_metrics_from_experiment(mock_exp)
|
||||
assert m.ic == 0.0
|
||||
assert m.sharpe == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 5: LinearThompsonTwoArm
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLinearThompsonTwoArm:
|
||||
"""Property: LinearThompsonTwoArm bandit invariants."""
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=20))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_dim_stored_correctly(self, dim):
|
||||
"""Property: dim attribute matches constructor arg."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
assert bandit.dim == dim
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_mean_shape_matches_dim(self, dim):
|
||||
"""Property: mean vectors have shape (dim,)."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
assert bandit.mean["factor"].shape == (dim,)
|
||||
assert bandit.mean["model"].shape == (dim,)
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_precision_shape_matches_dim(self, dim):
|
||||
"""Property: precision matrices have shape (dim, dim)."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
assert bandit.precision["factor"].shape == (dim, dim)
|
||||
assert bandit.precision["model"].shape == (dim, dim)
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_arms_initialized_identically(self, dim):
|
||||
"""Property: factor and model arms are initialized identically."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
assert np.array_equal(bandit.mean["factor"], bandit.mean["model"])
|
||||
assert np.array_equal(bandit.precision["factor"], bandit.precision["model"])
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_noise_var_is_default_1(self, dim):
|
||||
"""Property: noise_var defaults to 1.0."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
assert bandit.noise_var == 1.0
|
||||
|
||||
@given(
|
||||
dim=st.integers(min_value=1, max_value=10),
|
||||
noise_var=st.floats(min_value=0.01, max_value=10.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_noise_var_configurable(self, dim, noise_var):
|
||||
"""Property: noise_var can be set via constructor."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim, noise_var=noise_var)
|
||||
assert bandit.noise_var == noise_var
|
||||
|
||||
@given(
|
||||
dim=st.integers(min_value=1, max_value=10),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_sample_reward_returns_float(self, dim):
|
||||
"""Property: sample_reward returns a float."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
x = np.ones(dim)
|
||||
reward = bandit.sample_reward("factor", x)
|
||||
assert isinstance(reward, float)
|
||||
|
||||
@given(
|
||||
dim=st.integers(min_value=1, max_value=10),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_sample_reward_finite(self, dim):
|
||||
"""Property: sample_reward returns finite values."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
x = np.ones(dim)
|
||||
reward = bandit.sample_reward("factor", x)
|
||||
assert np.isfinite(reward)
|
||||
|
||||
@given(
|
||||
dim=st.integers(min_value=1, max_value=10),
|
||||
seed_a=st.integers(min_value=0, max_value=50),
|
||||
seed_b=st.integers(min_value=51, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_sample_reward_varies(self, dim, seed_a, seed_b):
|
||||
"""Property: different seeds may produce different rewards (stochasticity)."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
x = np.ones(dim)
|
||||
r1 = bandit.sample_reward("factor", x)
|
||||
r2 = bandit.sample_reward("factor", x)
|
||||
# Both should be finite (may be equal by chance)
|
||||
assert np.isfinite(r1)
|
||||
assert np.isfinite(r2)
|
||||
|
||||
@given(dim=st.integers(min_value=2, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_precision_is_symmetric(self, dim):
|
||||
"""Property: precision matrix is symmetric."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
P = bandit.precision["factor"]
|
||||
assert np.allclose(P, P.T, atol=1e-10)
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_both_arms_have_same_keys(self, dim):
|
||||
"""Property: both 'factor' and 'model' arms exist in mean/precision dicts."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
assert "factor" in bandit.mean
|
||||
assert "model" in bandit.mean
|
||||
assert "factor" in bandit.precision
|
||||
assert "model" in bandit.precision
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 6: LinearThompsonTwoArm Update
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBanditUpdate:
|
||||
"""Property: Thompson bandit update invariants."""
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_update_exists_for_both_arms(self, dim):
|
||||
"""Property: update method is callable for both arms."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
x = np.ones(dim)
|
||||
bandit.update("factor", x, 0.5)
|
||||
bandit.update("model", x, 0.3)
|
||||
# Should not raise
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_update_changes_mean(self, dim):
|
||||
"""Property: updating an arm changes its mean vector."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
orig = bandit.mean["factor"].copy()
|
||||
x = np.ones(dim)
|
||||
bandit.update("factor", x, 1.0)
|
||||
# Mean should change (or be computed differently after update)
|
||||
assert not np.array_equal(orig, bandit.mean["factor"]) or np.array_equal(orig, np.zeros(dim))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 7: has_local_components / count_valid_factors / ADVANCED_LOOP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestQuantLoopFactory:
|
||||
"""Property: quant_loop_factory function invariants."""
|
||||
|
||||
def test_has_local_components_returns_bool(self):
|
||||
"""Property: has_local_components returns bool."""
|
||||
result = has_local_components()
|
||||
assert isinstance(result, bool)
|
||||
|
||||
def test_count_valid_factors_returns_nonnegative_int(self):
|
||||
"""Property: count_valid_factors returns nonnegative int."""
|
||||
result = count_valid_factors()
|
||||
assert isinstance(result, int)
|
||||
assert result >= 0
|
||||
|
||||
def test_advanced_loop_threshold_is_5000(self):
|
||||
"""Property: ADVANCED_LOOP_FACTOR_THRESHOLD == 5000."""
|
||||
assert ADVANCED_LOOP_FACTOR_THRESHOLD == 5000
|
||||
|
||||
def test_advanced_loop_threshold_is_positive(self):
|
||||
"""Property: ADVANCED_LOOP_FACTOR_THRESHOLD > 0."""
|
||||
assert ADVANCED_LOOP_FACTOR_THRESHOLD > 0
|
||||
|
||||
def test_has_local_components_deterministic(self):
|
||||
"""Property: has_local_components returns same value on repeated calls."""
|
||||
r1 = has_local_components()
|
||||
r2 = has_local_components()
|
||||
assert r1 == r2
|
||||
|
||||
def test_count_valid_factors_deterministic(self):
|
||||
"""Property: count_valid_factors returns same value on repeated calls."""
|
||||
r1 = count_valid_factors()
|
||||
r2 = count_valid_factors()
|
||||
assert r1 == r2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 8: process_results Numeric Edge Cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProcessResultsEdgeCases:
|
||||
"""Property: process_results handles edge case values."""
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0, allow_nan=False, allow_infinity=False),
|
||||
ann_return=st.floats(min_value=-2.0, max_value=5.0, allow_nan=False, allow_infinity=False),
|
||||
max_dd=st.floats(min_value=-1.0, max_value=0.0, allow_nan=False, allow_infinity=False),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_all_numeric_values_formatted(self, ic, ann_return, max_dd):
|
||||
"""Property: all valid numeric values produce a result string."""
|
||||
current = pd.Series({
|
||||
"IC": ic,
|
||||
"1day.excess_return_with_cost.annualized_return": ann_return,
|
||||
"1day.excess_return_with_cost.max_drawdown": max_dd,
|
||||
}, name="0")
|
||||
sota = pd.Series({
|
||||
"IC": 0.0,
|
||||
"1day.excess_return_with_cost.annualized_return": 0.0,
|
||||
"1day.excess_return_with_cost.max_drawdown": 0.0,
|
||||
}, name="0")
|
||||
|
||||
result = process_results(current, sota)
|
||||
assert isinstance(result, str)
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0, allow_nan=False, allow_infinity=False),
|
||||
ann_return=st.floats(min_value=-2.0, max_value=5.0, allow_nan=False, allow_infinity=False),
|
||||
max_dd=st.floats(min_value=-1.0, max_value=0.0, allow_nan=False, allow_infinity=False),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_result_contains_both_current_and_sota(self, ic, ann_return, max_dd):
|
||||
"""Property: result contains 'Current Result' and 'SOTA Result'."""
|
||||
current = pd.Series({
|
||||
"IC": ic,
|
||||
"1day.excess_return_with_cost.annualized_return": ann_return,
|
||||
"1day.excess_return_with_cost.max_drawdown": max_dd,
|
||||
}, name="0")
|
||||
sota = pd.Series({
|
||||
"IC": 0.0,
|
||||
"1day.excess_return_with_cost.annualized_return": 0.0,
|
||||
"1day.excess_return_with_cost.max_drawdown": 0.0,
|
||||
}, name="0")
|
||||
|
||||
result = process_results(current, sota)
|
||||
assert "Current Result" in result
|
||||
assert "SOTA Result" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 9: Metrics Constructor Type Safety
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMetricsTypeSafety:
|
||||
"""Property: Metrics converts inputs to float."""
|
||||
|
||||
@given(
|
||||
ic=st.integers(min_value=-10, max_value=10),
|
||||
sharpe=st.integers(min_value=-5, max_value=20),
|
||||
mdd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_float_conversion(self, ic, sharpe, mdd):
|
||||
"""Property: integer inputs become floats."""
|
||||
m = Metrics(ic=float(ic), sharpe=float(sharpe), mdd=mdd)
|
||||
assert isinstance(m.ic, float)
|
||||
assert isinstance(m.sharpe, float)
|
||||
assert isinstance(m.mdd, float)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 10: Bandit Precision Positive Definite
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBanditPrecisionProperties:
|
||||
"""Property: precision matrix is positive semi-definite (identity-initialized)."""
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_precision_is_identity_initialized(self, dim):
|
||||
"""Property: precision matrix starts as identity."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
P = bandit.precision["factor"]
|
||||
expected = np.eye(dim)
|
||||
assert np.allclose(P, expected, atol=1e-10)
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_precision_diagonal_positive(self, dim):
|
||||
"""Property: precision matrix diagonal elements are positive."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
P = bandit.precision["factor"]
|
||||
assert (np.diag(P) > 0).all()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 11: Bandit Mean Initialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBanditMeanInitialization:
|
||||
"""Property: mean vector is initialized to zeros."""
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_mean_is_zero_initialized(self, dim):
|
||||
"""Property: mean starts as zero vector."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
m = bandit.mean["factor"]
|
||||
expected = np.zeros(dim)
|
||||
assert np.allclose(m, expected, atol=1e-10)
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_both_arms_mean_zero_initialized(self, dim):
|
||||
"""Property: both arm means start as zero."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
assert np.allclose(bandit.mean["factor"], np.zeros(dim))
|
||||
assert np.allclose(bandit.mean["model"], np.zeros(dim))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 12: extract_metrics Robustness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractMetricsRobustness:
|
||||
"""Property: extract_metrics_from_experiment handles missing keys."""
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_partial_result_dict(self, ic):
|
||||
"""Property: partial result dict fills defaults for missing keys."""
|
||||
mock_exp = MagicMock()
|
||||
mock_exp.result = {"IC": ic}
|
||||
m = extract_metrics_from_experiment(mock_exp)
|
||||
assert m.ic == ic
|
||||
assert m.sharpe == 0.0 # default since ann_return is missing
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_extract_with_empty_dict(self, seed):
|
||||
"""Property: empty result dict → all defaults or raises."""
|
||||
mock_exp = MagicMock()
|
||||
mock_exp.result = {}
|
||||
m = extract_metrics_from_experiment(mock_exp)
|
||||
assert isinstance(m, Metrics)
|
||||
assert m.ic == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 13: Metrics Field Naming
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMetricsFieldNaming:
|
||||
"""Property: Metrics has specific named fields."""
|
||||
|
||||
def test_metrics_has_all_expected_fields(self):
|
||||
"""Property: Metrics has ic, icir, rank_ic, rank_icir, ann_return, ir, mdd, sharpe."""
|
||||
m = Metrics()
|
||||
expected = {"ic", "icir", "rank_ic", "rank_icir", "arr", "ir", "mdd", "sharpe"}
|
||||
actual = {k for k in m.__dict__ if not k.startswith("_")}
|
||||
assert expected <= actual or expected <= set(m.__dataclass_fields__ if hasattr(m, "__dataclass_fields__") else [])
|
||||
|
||||
@given(
|
||||
ann_return=st.floats(min_value=-2.0, max_value=5.0),
|
||||
ir=st.floats(min_value=-5.0, max_value=10.0),
|
||||
sharpe=st.floats(min_value=-5.0, max_value=10.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_return_and_sharpe_fields(self, ann_return, ir, sharpe):
|
||||
"""Property: ann_return, ir, sharpe accessible by attribute."""
|
||||
m = Metrics(arr=ann_return, ir=ir, sharpe=sharpe)
|
||||
assert m.arr == ann_return
|
||||
assert m.ir == ir
|
||||
assert m.sharpe == sharpe
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 14: process_results Determinism
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProcessResultsDeterminism:
|
||||
"""Property: process_results is deterministic."""
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
ann_return=st.floats(min_value=-2.0, max_value=5.0),
|
||||
max_dd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_same_inputs_same_output(self, ic, ann_return, max_dd):
|
||||
"""Property: process_results is deterministic."""
|
||||
current = pd.Series({
|
||||
"IC": ic,
|
||||
"1day.excess_return_with_cost.annualized_return": ann_return,
|
||||
"1day.excess_return_with_cost.max_drawdown": max_dd,
|
||||
}, name="0")
|
||||
sota = pd.Series({
|
||||
"IC": 0.0,
|
||||
"1day.excess_return_with_cost.annualized_return": 0.0,
|
||||
"1day.excess_return_with_cost.max_drawdown": 0.0,
|
||||
}, name="0")
|
||||
|
||||
r1 = process_results(current, sota)
|
||||
r2 = process_results(current, sota)
|
||||
assert r1 == r2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 15: Bandit Sample Reward Distribution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBanditSampleReward:
|
||||
"""Property: sample_reward behavior across arms."""
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_factor_and_model_reward_differ(self, dim):
|
||||
"""Property: factor and model arms can give different rewards."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
x = np.random.randn(dim)
|
||||
r_factor = bandit.sample_reward("factor", x)
|
||||
r_model = bandit.sample_reward("model", x)
|
||||
assert isinstance(r_factor, float)
|
||||
assert isinstance(r_model, float)
|
||||
|
||||
@given(
|
||||
dim=st.integers(min_value=1, max_value=10),
|
||||
n_samples=st.integers(min_value=10, max_value=100),
|
||||
)
|
||||
@settings(max_examples=10, deadline=10000)
|
||||
def test_sample_reward_changes_after_update(self, dim, n_samples):
|
||||
"""Property: after updates, sample_reward distribution shifts."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
x = np.ones(dim)
|
||||
rewards_before = [bandit.sample_reward("factor", x) for _ in range(n_samples)]
|
||||
|
||||
# Update with positive rewards
|
||||
for _ in range(10):
|
||||
bandit.update("factor", x, 1.0)
|
||||
|
||||
rewards_after = [bandit.sample_reward("factor", x) for _ in range(n_samples)]
|
||||
|
||||
# Mean should shift (though statistically it may not)
|
||||
assert np.all(np.isfinite(rewards_before))
|
||||
assert np.all(np.isfinite(rewards_after))
|
||||
|
||||
@@ -135,626 +135,3 @@ class TestOOSStress:
|
||||
result = backtest_signal(close, signal)
|
||||
assert result["status"] in ("success", "failed")
|
||||
assert np.isfinite(result["sharpe"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HYPOTHESIS PROPERTY-BASED ROBUSTNESS TESTS (ADDED – DO NOT MODIFY ABOVE)
|
||||
# ============================================================================
|
||||
|
||||
from hypothesis import given, settings, strategies as st, assume
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_from_forward_returns
|
||||
from rdagent.components.backtesting.vbt_backtest import DEFAULT_BARS_PER_YEAR
|
||||
|
||||
|
||||
def _price_signal(n: int, seed: int) -> tuple[pd.Series, pd.Series]:
|
||||
dates = pd.date_range("2024-01-01", periods=n, freq="1min")
|
||||
rng = np.random.default_rng(seed)
|
||||
close = pd.Series(1.10 * np.exp(np.cumsum(rng.normal(0, 0.0002, n))), index=dates)
|
||||
signal = pd.Series(np.where(rng.normal(0, 1, n) > 0, 1.0, -1.0), index=dates)
|
||||
return close, signal
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slippage Fuzzing (18 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSlippageFuzzing:
|
||||
"""Hypothesis-based slippage robustness."""
|
||||
|
||||
@given(
|
||||
st.integers(min_value=500, max_value=3000),
|
||||
st.floats(min_value=0.0, max_value=100.0),
|
||||
)
|
||||
@settings(max_examples=150, deadline=5000)
|
||||
def test_slippage_does_not_break_metrics(self, n_bars, cost):
|
||||
"""Property: any slippage level leaves max_dd in [-1, 0]."""
|
||||
close, signal = _price_signal(n_bars, seed=42)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=cost)
|
||||
if result["status"] == "success":
|
||||
assert -1.0 <= result["max_drawdown"] <= 0.0
|
||||
assert np.isfinite(result["sharpe"])
|
||||
|
||||
@given(
|
||||
st.integers(min_value=1000, max_value=3000),
|
||||
st.floats(min_value=0.0, max_value=5.0),
|
||||
st.floats(min_value=0.0, max_value=5.0),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_slippage_monotonic_sharpe_degradation(self, n_bars, cost_low, cost_high):
|
||||
"""Property: higher cost never improves Sharpe (moderate costs only)."""
|
||||
assume(cost_low <= cost_high)
|
||||
assume(cost_high < 5.0)
|
||||
close, signal = _price_signal(n_bars, seed=42)
|
||||
r_low = backtest_signal(close, signal, txn_cost_bps=cost_low)
|
||||
r_high = backtest_signal(close, signal, txn_cost_bps=cost_high)
|
||||
if r_low["status"] == "success" and r_high["status"] == "success":
|
||||
assert r_high["sharpe"] <= r_low["sharpe"] + 0.01
|
||||
|
||||
@given(
|
||||
st.integers(min_value=1000, max_value=3000),
|
||||
st.floats(min_value=0.0, max_value=5.0),
|
||||
st.floats(min_value=0.0, max_value=5.0),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_slippage_monotonic_return_degradation(self, n_bars, cost_low, cost_high):
|
||||
"""Property: higher cost never increases total_return (moderate costs)."""
|
||||
assume(cost_low <= cost_high)
|
||||
assume(cost_high < 5.0)
|
||||
close, signal = _price_signal(n_bars, seed=42)
|
||||
r_low = backtest_signal(close, signal, txn_cost_bps=cost_low)
|
||||
r_high = backtest_signal(close, signal, txn_cost_bps=cost_high)
|
||||
if r_low["status"] == "success" and r_high["status"] == "success":
|
||||
assert r_high["total_return"] <= r_low["total_return"] + 0.001
|
||||
|
||||
@given(
|
||||
st.integers(min_value=1000, max_value=3000),
|
||||
st.floats(min_value=0.0, max_value=100.0),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_slippage_keeps_win_rate_in_bounds(self, n_bars, cost):
|
||||
"""Property: win_rate ∈ [0, 1] regardless of slippage."""
|
||||
close, signal = _price_signal(n_bars, seed=42)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=cost)
|
||||
if result["status"] == "success":
|
||||
assert 0.0 <= result["win_rate"] <= 1.0
|
||||
|
||||
@given(
|
||||
st.integers(min_value=1000, max_value=3000),
|
||||
st.floats(min_value=0.0, max_value=20.0),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_slippage_profit_factor_finite(self, n_bars, cost):
|
||||
"""Property: profit_factor is finite with cost."""
|
||||
close, signal = _price_signal(n_bars, seed=42)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=cost)
|
||||
if result["status"] == "success" and result["n_trades"] > 0:
|
||||
assert np.isfinite(result["profit_factor"]) or result["profit_factor"] == float("inf")
|
||||
|
||||
@given(
|
||||
st.floats(min_value=0.0, max_value=10.0),
|
||||
st.integers(min_value=1000, max_value=2000),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000)
|
||||
def test_slippage_volatility_positive_or_zero(self, cost, n_bars):
|
||||
"""Property: volatility >= 0."""
|
||||
close, signal = _price_signal(n_bars, seed=42)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=cost)
|
||||
if result["status"] == "success":
|
||||
assert result["volatility"] >= 0
|
||||
|
||||
@given(
|
||||
st.floats(min_value=0.0, max_value=100.0),
|
||||
st.integers(min_value=1000, max_value=2000),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_slippage_annual_return_finite(self, cost, n_bars):
|
||||
"""Property: annualized_return is finite."""
|
||||
close, signal = _price_signal(n_bars, seed=42)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=cost)
|
||||
if result["status"] == "success":
|
||||
assert np.isfinite(result["annualized_return"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Latency Fuzzing (15 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLatencyFuzzing:
|
||||
"""Hypothesis-based latency robustness."""
|
||||
|
||||
@given(
|
||||
st.integers(min_value=1, max_value=20),
|
||||
st.integers(min_value=1000, max_value=3000),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_latency_keeps_metrics_valid(self, lag, n_bars):
|
||||
"""Property: delayed signal by any lag still produces valid metrics."""
|
||||
close, signal = _price_signal(n_bars, seed=42)
|
||||
delayed = signal.shift(lag).fillna(0)
|
||||
result = backtest_signal(close, delayed, txn_cost_bps=2.14)
|
||||
if result["status"] == "success":
|
||||
assert -1.0 <= result["max_drawdown"] <= 0.0
|
||||
assert 0.0 <= result["win_rate"] <= 1.0
|
||||
assert np.isfinite(result["sharpe"])
|
||||
|
||||
@given(
|
||||
st.integers(min_value=1, max_value=15),
|
||||
st.integers(min_value=1000, max_value=3000),
|
||||
)
|
||||
@settings(max_examples=80, deadline=5000)
|
||||
def test_latency_produces_valid_metrics(self, lag, n_bars):
|
||||
"""Property: delayed signal always produces valid bounded metrics."""
|
||||
close, signal = _price_signal(n_bars, seed=42)
|
||||
r_base = backtest_signal(close, signal, txn_cost_bps=0.0)
|
||||
delayed = signal.shift(lag).fillna(0)
|
||||
r_delayed = backtest_signal(close, delayed, txn_cost_bps=0.0)
|
||||
if r_base["status"] == "success" and r_delayed["status"] == "success":
|
||||
assert -1.0 <= r_delayed["max_drawdown"] <= 0.0
|
||||
assert 0.0 <= r_delayed["win_rate"] <= 1.0
|
||||
assert np.isfinite(r_delayed["sharpe"])
|
||||
|
||||
@given(
|
||||
st.integers(min_value=1, max_value=10),
|
||||
st.integers(min_value=1000, max_value=3000),
|
||||
)
|
||||
@settings(max_examples=80, deadline=5000)
|
||||
def test_latency_preserves_signal_counts(self, lag, n_bars):
|
||||
"""Property: signal_long + signal_short + signal_neutral == n_bars for delayed signal."""
|
||||
close, signal = _price_signal(n_bars, seed=42)
|
||||
delayed = signal.shift(lag).fillna(0)
|
||||
result = backtest_signal(close, delayed, txn_cost_bps=0.0)
|
||||
if result["status"] == "success":
|
||||
total = result["signal_long"] + result["signal_short"] + result["signal_neutral"]
|
||||
assert total == n_bars
|
||||
|
||||
@given(
|
||||
st.integers(min_value=1000, max_value=3000),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_latency_zero_same_as_base(self, n_bars):
|
||||
"""Property: 0-lag delayed signal = original signal result."""
|
||||
close, signal = _price_signal(n_bars, seed=42)
|
||||
r_orig = backtest_signal(close, signal, txn_cost_bps=0.0)
|
||||
delayed = signal.shift(0).fillna(0)
|
||||
r_delayed = backtest_signal(close, delayed, txn_cost_bps=0.0)
|
||||
if r_orig["status"] == "success" and r_delayed["status"] == "success":
|
||||
assert r_orig["total_return"] == r_delayed["total_return"]
|
||||
|
||||
@given(
|
||||
st.integers(min_value=5, max_value=30),
|
||||
st.integers(min_value=2000, max_value=3000),
|
||||
)
|
||||
@settings(max_examples=40, deadline=5000)
|
||||
def test_large_latency_does_not_crash(self, lag, n_bars):
|
||||
"""Property: very large lag does not crash the backtest."""
|
||||
close, signal = _price_signal(n_bars, seed=42)
|
||||
delayed = signal.shift(lag).fillna(0)
|
||||
result = backtest_signal(close, delayed, txn_cost_bps=2.14)
|
||||
assert result["status"] in ("success", "failed")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Monte Carlo Fuzzing (12 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMonteCarloFuzzing:
|
||||
"""Hypothesis-based Monte Carlo robustness."""
|
||||
|
||||
@given(
|
||||
st.integers(min_value=500, max_value=2000),
|
||||
st.integers(min_value=10, max_value=50),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_reshuffle_keeps_metrics_valid(self, n_bars, n_perm):
|
||||
"""Property: all reshuffled runs produce valid metrics."""
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
||||
close, signal = _price_signal(n_bars, seed=42)
|
||||
returns = close.pct_change().fillna(0)
|
||||
rng = np.random.default_rng(42)
|
||||
for _ in range(n_perm):
|
||||
shuffled = pd.Series(rng.permutation(returns.values), index=returns.index)
|
||||
price_s = (1 + shuffled).cumprod() * 1.10
|
||||
r = backtest_signal(price_s, signal, txn_cost_bps=0.0)
|
||||
if r["status"] == "success":
|
||||
assert -1.0 <= r["max_drawdown"] <= 0.0
|
||||
assert 0.0 <= r["win_rate"] <= 1.0
|
||||
|
||||
@given(
|
||||
st.integers(min_value=500, max_value=2000),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_reshuffle_win_rate_stable(self, n_bars):
|
||||
"""Property: win_rate after reshuffle is always in [0, 1]."""
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
||||
close, signal = _price_signal(n_bars, seed=42)
|
||||
returns = close.pct_change().fillna(0)
|
||||
rng = np.random.default_rng(42)
|
||||
shuffled = pd.Series(rng.permutation(returns.values), index=returns.index)
|
||||
price_s = (1 + shuffled).cumprod() * 1.10
|
||||
r = backtest_signal(price_s, signal, txn_cost_bps=0.0)
|
||||
if r["status"] == "success":
|
||||
assert 0.0 <= r["win_rate"] <= 1.0
|
||||
|
||||
@given(
|
||||
st.integers(min_value=500, max_value=1500),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_reshuffle_sharpe_finite(self, n_bars):
|
||||
"""Property: Sharpe after reshuffle is finite."""
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
||||
close, signal = _price_signal(n_bars, seed=42)
|
||||
returns = close.pct_change().fillna(0)
|
||||
rng = np.random.default_rng(42)
|
||||
shuffled = pd.Series(rng.permutation(returns.values), index=returns.index)
|
||||
price_s = (1 + shuffled).cumprod() * 1.10
|
||||
r = backtest_signal(price_s, signal, txn_cost_bps=0.0)
|
||||
if r["status"] == "success":
|
||||
assert np.isfinite(r["sharpe"])
|
||||
|
||||
@given(
|
||||
st.integers(min_value=500, max_value=1500),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_reshuffle_n_trades_unchanged(self, n_bars):
|
||||
"""Property: n_trades unchanged by reshuffling (same signal pattern)."""
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
||||
close, signal = _price_signal(n_bars, seed=42)
|
||||
r_orig = backtest_signal(close, signal, txn_cost_bps=0.0)
|
||||
returns = close.pct_change().fillna(0)
|
||||
rng = np.random.default_rng(42)
|
||||
shuffled = pd.Series(rng.permutation(returns.values), index=returns.index)
|
||||
price_s = (1 + shuffled).cumprod() * 1.10
|
||||
r_shuf = backtest_signal(price_s, signal, txn_cost_bps=0.0)
|
||||
if r_orig["status"] == "success" and r_shuf["status"] == "success":
|
||||
assert r_orig["n_trades"] == r_shuf["n_trades"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Random Market Data Fuzzing (20 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRandomMarketDataFuzzing:
|
||||
"""Fuzz backtest_signal with completely random market data."""
|
||||
|
||||
@given(
|
||||
st.integers(min_value=100, max_value=5000),
|
||||
st.floats(min_value=-0.1, max_value=0.1),
|
||||
st.floats(min_value=0.00001, max_value=0.1),
|
||||
)
|
||||
@settings(max_examples=200, deadline=5000)
|
||||
def test_random_prices_always_succeed(self, n_bars, drift, vol):
|
||||
"""Property: backtesting with random geometric Brownian motion succeeds."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
rng = np.random.default_rng(42)
|
||||
close = pd.Series(1.10 * np.exp(np.cumsum(rng.normal(drift, vol, n_bars))), index=dates)
|
||||
signal = pd.Series(np.where(rng.normal(0, 1, n_bars) > 0, 1.0, -1.0), index=dates)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
||||
assert result["status"] in ("success", "failed")
|
||||
|
||||
@given(
|
||||
st.integers(min_value=100, max_value=3000),
|
||||
st.floats(min_value=-0.01, max_value=0.01),
|
||||
st.floats(min_value=0.0001, max_value=0.1),
|
||||
st.floats(min_value=0.0, max_value=30.0),
|
||||
)
|
||||
@settings(max_examples=200, deadline=5000)
|
||||
def test_random_data_all_metrics_finite(self, n_bars, drift, vol, cost):
|
||||
"""Property: all key metrics are finite for random data."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
rng = np.random.default_rng(42)
|
||||
close = pd.Series(1.10 * np.exp(np.cumsum(rng.normal(drift, vol, n_bars))), index=dates)
|
||||
signal = pd.Series(np.where(rng.normal(0, 1, n_bars) > 0, 1.0, -1.0), index=dates)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=cost)
|
||||
if result["status"] == "success":
|
||||
for k in ["sharpe", "total_return", "max_drawdown"]:
|
||||
assert np.isfinite(result[k]), f"{k} is not finite: {result[k]}"
|
||||
|
||||
@given(
|
||||
st.integers(min_value=100, max_value=3000),
|
||||
st.floats(min_value=-0.01, max_value=0.01),
|
||||
)
|
||||
@settings(max_examples=200, deadline=5000)
|
||||
def test_random_data_maxdd_in_bounds(self, n_bars, drift):
|
||||
"""Property: max_drawdown ∈ [-1, 0] with random market data."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
rng = np.random.default_rng(42)
|
||||
close = pd.Series(1.10 * np.exp(np.cumsum(rng.normal(drift, 0.001, n_bars))), index=dates)
|
||||
signal = pd.Series(np.where(rng.normal(0, 1, n_bars) > 0, 1.0, -1.0), index=dates)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
||||
if result["status"] == "success":
|
||||
assert -1.0 <= result["max_drawdown"] <= 0.0
|
||||
|
||||
@given(
|
||||
st.integers(min_value=100, max_value=3000),
|
||||
st.floats(min_value=-0.01, max_value=0.01),
|
||||
)
|
||||
@settings(max_examples=200, deadline=5000)
|
||||
def test_random_data_win_rate_in_bounds(self, n_bars, drift):
|
||||
"""Property: win_rate ∈ [0, 1] with random market data."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
rng = np.random.default_rng(42)
|
||||
close = pd.Series(1.10 * np.exp(np.cumsum(rng.normal(drift, 0.001, n_bars))), index=dates)
|
||||
signal = pd.Series(np.where(rng.normal(0, 1, n_bars) > 0, 1.0, -1.0), index=dates)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
||||
if result["status"] == "success":
|
||||
assert 0.0 <= result["win_rate"] <= 1.0
|
||||
|
||||
@given(
|
||||
st.integers(min_value=100, max_value=3000),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_random_data_n_bars_matches_input(self, n_bars):
|
||||
"""Property: n_bars in result equals input length."""
|
||||
close, signal = _price_signal(n_bars, seed=42)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
||||
if result["status"] == "success":
|
||||
assert result["n_bars"] == n_bars
|
||||
|
||||
@given(
|
||||
st.integers(min_value=100, max_value=3000),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_random_data_signal_counts_sum_correctly(self, n_bars):
|
||||
"""Property: signal_long + signal_short + signal_neutral == n_bars."""
|
||||
close, signal = _price_signal(n_bars, seed=42)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
||||
if result["status"] == "success":
|
||||
assert result["signal_long"] + result["signal_short"] + result["signal_neutral"] == n_bars
|
||||
|
||||
@given(
|
||||
st.integers(min_value=100, max_value=3000),
|
||||
st.floats(min_value=1.0, max_value=500.0),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_random_data_txn_cost_bps_preserved(self, n_bars, cost):
|
||||
"""Property: txn_cost_bps reported matches input."""
|
||||
close, signal = _price_signal(n_bars, seed=42)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=cost)
|
||||
if result["status"] == "success":
|
||||
assert abs(result["txn_cost_bps"] - cost) < 0.001
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OOS Stress Fuzzing (10 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOOSStressFuzzing:
|
||||
"""Hypothesis-based out-of-sample stress tests."""
|
||||
|
||||
@given(
|
||||
st.integers(min_value=1000, max_value=5000),
|
||||
st.floats(min_value=0.3, max_value=0.8),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_oos_metrics_valid(self, n_bars, split_fraction):
|
||||
"""Property: OOS metrics remain valid for any split."""
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
rng = np.random.default_rng(42)
|
||||
close = pd.Series(1.10 * np.exp(np.cumsum(rng.normal(0, 0.0002, n_bars))), index=dates)
|
||||
signal = pd.Series(np.where(rng.normal(0, 1, n_bars) > 0, 1.0, -1.0), index=dates)
|
||||
split = int(n_bars * split_fraction)
|
||||
assume(split > 100)
|
||||
assume(n_bars - split > 100)
|
||||
r_oos = backtest_signal(close.iloc[split:], signal.iloc[split:], txn_cost_bps=0.0)
|
||||
if r_oos["status"] == "success":
|
||||
assert -1.0 <= r_oos["max_drawdown"] <= 0.0
|
||||
assert np.isfinite(r_oos["sharpe"])
|
||||
|
||||
@given(
|
||||
st.integers(min_value=500, max_value=3000),
|
||||
)
|
||||
@settings(max_examples=80, deadline=5000)
|
||||
def test_oos_sharpe_finite(self, n_bars):
|
||||
"""Property: OOS Sharpe is always finite."""
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
||||
close, signal = _price_signal(n_bars, seed=42)
|
||||
split = n_bars // 2
|
||||
assume(n_bars - split > 100)
|
||||
r_oos = backtest_signal(close.iloc[split:], signal.iloc[split:], txn_cost_bps=0.0)
|
||||
if r_oos["status"] == "success":
|
||||
assert np.isfinite(r_oos["sharpe"])
|
||||
|
||||
@given(
|
||||
st.integers(min_value=1000, max_value=3000),
|
||||
)
|
||||
@settings(max_examples=80, deadline=5000)
|
||||
def test_is_and_oos_both_produce_metrics(self, n_bars):
|
||||
"""Property: both IS and OOS periods produce valid metrics."""
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
||||
close, signal = _price_signal(n_bars, seed=42)
|
||||
split = int(n_bars * 0.7)
|
||||
assume(split > 100)
|
||||
assume(n_bars - split > 100)
|
||||
r_is = backtest_signal(close.iloc[:split], signal.iloc[:split], txn_cost_bps=0.0)
|
||||
r_oos = backtest_signal(close.iloc[split:], signal.iloc[split:], txn_cost_bps=0.0)
|
||||
if r_is["status"] == "success":
|
||||
assert np.isfinite(r_is["sharpe"])
|
||||
if r_oos["status"] == "success":
|
||||
assert np.isfinite(r_oos["max_drawdown"])
|
||||
|
||||
@given(
|
||||
st.integers(min_value=500, max_value=2000),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_oos_win_rate_in_bounds(self, n_bars):
|
||||
"""Property: OOS win_rate ∈ [0, 1]."""
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
||||
close, signal = _price_signal(n_bars, seed=42)
|
||||
split = n_bars // 2
|
||||
assume(n_bars - split > 100)
|
||||
r_oos = backtest_signal(close.iloc[split:], signal.iloc[split:], txn_cost_bps=0.0)
|
||||
if r_oos["status"] == "success":
|
||||
assert 0.0 <= r_oos["win_rate"] <= 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Forward Returns Backtest Fuzzing (10 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestForwardReturnsFuzzing:
|
||||
"""Fuzz backtest_from_forward_returns with random factor and forward returns."""
|
||||
|
||||
@given(
|
||||
st.integers(min_value=30, max_value=500),
|
||||
st.lists(st.floats(min_value=-10, max_value=10), min_size=30, max_size=500),
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=30, max_size=500),
|
||||
st.floats(min_value=0.0, max_value=50.0),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_forward_backtest_returns_all_keys(self, n, fac_raw, ret_raw, cost):
|
||||
"""Property: backtest_from_forward_returns contains all expected keys."""
|
||||
n = min(len(fac_raw), len(ret_raw))
|
||||
factor = pd.Series(fac_raw[:n], dtype=float)
|
||||
fwd = pd.Series(ret_raw[:n], dtype=float)
|
||||
assume(factor.std() > 1e-12)
|
||||
result = backtest_from_forward_returns(factor, fwd, txn_cost_bps=cost)
|
||||
for k in ["status", "sharpe", "max_drawdown", "total_return", "win_rate",
|
||||
"n_trades", "ic", "n_bars"]:
|
||||
assert k in result, f"Missing key: {k}"
|
||||
|
||||
@given(
|
||||
st.integers(min_value=30, max_value=500),
|
||||
st.lists(st.floats(min_value=-10, max_value=10), min_size=30, max_size=500),
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=30, max_size=500),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_forward_backtest_maxdd_in_bounds(self, n, fac_raw, ret_raw):
|
||||
"""Property: max_drawdown ∈ [-1, 0] from forward returns backtest."""
|
||||
n = min(len(fac_raw), len(ret_raw))
|
||||
factor = pd.Series(fac_raw[:n], dtype=float)
|
||||
fwd = pd.Series(ret_raw[:n], dtype=float)
|
||||
assume(factor.std() > 1e-12)
|
||||
result = backtest_from_forward_returns(factor, fwd, txn_cost_bps=0.0)
|
||||
if result["status"] == "success":
|
||||
assert -1.0 <= result["max_drawdown"] <= 0.0
|
||||
|
||||
@given(
|
||||
st.integers(min_value=30, max_value=500),
|
||||
st.lists(st.floats(min_value=-10, max_value=10), min_size=30, max_size=500),
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=30, max_size=500),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_forward_backtest_ic_in_bounds(self, n, fac_raw, ret_raw):
|
||||
"""Property: IC ∈ [-1, 1] from forward returns backtest."""
|
||||
n = min(len(fac_raw), len(ret_raw))
|
||||
factor = pd.Series(fac_raw[:n], dtype=float)
|
||||
fwd = pd.Series(ret_raw[:n], dtype=float)
|
||||
assume(factor.std() > 1e-12)
|
||||
result = backtest_from_forward_returns(factor, fwd, txn_cost_bps=0.0)
|
||||
if result["status"] == "success":
|
||||
assert -1.0 <= result["ic"] <= 1.0, f"IC={result['ic']}"
|
||||
|
||||
@given(
|
||||
st.integers(min_value=30, max_value=500),
|
||||
st.lists(st.floats(min_value=-10, max_value=10), min_size=30, max_size=500),
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=30, max_size=500),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_forward_backtest_win_rate_in_bounds(self, n, fac_raw, ret_raw):
|
||||
"""Property: win_rate ∈ [0, 1] from forward returns backtest."""
|
||||
n = min(len(fac_raw), len(ret_raw))
|
||||
factor = pd.Series(fac_raw[:n], dtype=float)
|
||||
fwd = pd.Series(ret_raw[:n], dtype=float)
|
||||
assume(factor.std() > 1e-12)
|
||||
result = backtest_from_forward_returns(factor, fwd, txn_cost_bps=0.0)
|
||||
if result["status"] == "success":
|
||||
assert 0.0 <= result["win_rate"] <= 1.0, f"WinRate={result['win_rate']}"
|
||||
|
||||
@given(
|
||||
st.integers(min_value=1, max_value=9),
|
||||
)
|
||||
@settings(max_examples=20, deadline=5000)
|
||||
def test_forward_backtest_too_few_bars_fails(self, n):
|
||||
"""Property: < 10 aligned bars fails."""
|
||||
factor = pd.Series(np.arange(n, dtype=float))
|
||||
fwd = pd.Series(np.arange(n, dtype=float))
|
||||
result = backtest_from_forward_returns(factor, fwd)
|
||||
assert result["status"] == "failed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge Cases and Extreme Values Fuzzing (10 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEdgeCasesFuzzing:
|
||||
"""Fuzzing with extreme/nonsense inputs."""
|
||||
|
||||
@given(
|
||||
st.integers(min_value=100, max_value=2000),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000)
|
||||
def test_zero_price_initial_does_not_crash(self, n_bars):
|
||||
"""Property: backtest handles near-zero initial prices."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
rng = np.random.default_rng(42)
|
||||
close = pd.Series(0.000001 + abs(rng.normal(0, 0.0002, n_bars)).cumsum(), index=dates)
|
||||
signal = pd.Series(np.where(rng.normal(0, 1, n_bars) > 0, 1.0, -1.0), index=dates)
|
||||
result = backtest_signal(close, signal)
|
||||
assert result["status"] in ("success", "failed")
|
||||
|
||||
@given(
|
||||
st.integers(min_value=100, max_value=2000),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000)
|
||||
def test_very_large_price_does_not_crash(self, n_bars):
|
||||
"""Property: backtest handles very large prices."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
rng = np.random.default_rng(42)
|
||||
close = pd.Series(1e6 + rng.normal(0, 1, n_bars).cumsum(), index=dates)
|
||||
signal = pd.Series(np.where(rng.normal(0, 1, n_bars) > 0, 1.0, -1.0), index=dates)
|
||||
result = backtest_signal(close, signal)
|
||||
assert result["status"] in ("success", "failed")
|
||||
|
||||
@given(
|
||||
st.integers(min_value=100, max_value=2000),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000)
|
||||
def test_signal_all_nan_treated_as_flat(self, n_bars):
|
||||
"""Property: signal full of NaN is treated as flat (win_rate=0, n_trades=0)."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
rng = np.random.default_rng(42)
|
||||
close = pd.Series(1.10 + rng.normal(0, 0.0002, n_bars).cumsum(), index=dates)
|
||||
signal = pd.Series([np.nan] * n_bars, index=dates)
|
||||
result = backtest_signal(close, signal)
|
||||
if result["status"] == "success":
|
||||
assert result["n_trades"] == 0
|
||||
assert result["win_rate"] == 0.0
|
||||
|
||||
@given(
|
||||
st.integers(min_value=1000, max_value=3000),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000)
|
||||
def test_continuous_signal_produces_valid_metrics(self, n_bars):
|
||||
"""Property: continuous signal in [-1, 1] produces valid metrics."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
rng = np.random.default_rng(42)
|
||||
close = pd.Series(1.10 * np.exp(np.cumsum(rng.normal(0, 0.0002, n_bars))), index=dates)
|
||||
signal = pd.Series(rng.uniform(-1, 1, n_bars), index=dates)
|
||||
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
||||
if result["status"] == "success":
|
||||
assert -1.0 <= result["max_drawdown"] <= 0.0
|
||||
assert 0.0 <= result["win_rate"] <= 1.0
|
||||
|
||||
@given(
|
||||
st.integers(min_value=500, max_value=2000),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000)
|
||||
def test_weekend_gaps_produce_valid_metrics(self, n_bars):
|
||||
"""Property: data with time gaps (weekends) produces valid metrics."""
|
||||
dates = pd.bdate_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
rng = np.random.default_rng(42)
|
||||
close = pd.Series(1.10 + rng.normal(0, 0.0002, len(dates)).cumsum(), index=dates)
|
||||
signal = pd.Series(np.where(rng.normal(0, 1, len(dates)) > 0, 1.0, -1.0), index=dates)
|
||||
result = backtest_signal(close, signal)
|
||||
if result["status"] == "success":
|
||||
assert np.isfinite(result["sharpe"])
|
||||
assert -1.0 <= result["max_drawdown"] <= 0.0
|
||||
|
||||
Reference in New Issue
Block a user