mirror of
https://github.com/Xe138/AI-Trader.git
synced 2026-04-20 16:47:24 -04:00
Compare commits
8 Commits
73c0fcd908
...
80b22232ad
| Author | SHA1 | Date | |
|---|---|---|---|
| 80b22232ad | |||
| 2d47bd7a3a | |||
| 28fbd6d621 | |||
| 7d66f90810 | |||
| c220211c3a | |||
| 7e95ce356b | |||
| 03f81b3b5c | |||
| ebc66481df |
@@ -729,6 +729,29 @@ Server loads model definitions from configuration file (default: `configs/defaul
|
||||
- `openai_base_url` - Optional custom API endpoint
|
||||
- `openai_api_key` - Optional model-specific API key
|
||||
|
||||
### Configuration Override System
|
||||
|
||||
**Default config:** `/app/configs/default_config.json` (baked into image)
|
||||
|
||||
**Custom config:** `/app/user-configs/config.json` (optional, via volume mount)
|
||||
|
||||
**Merge behavior:**
|
||||
- Custom config sections completely replace default sections (root-level merge)
|
||||
- If no custom config exists, defaults are used
|
||||
- Validation occurs at container startup (before API starts)
|
||||
- Invalid config causes immediate exit with detailed error message
|
||||
|
||||
**Example custom config** (overrides models only):
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{"name": "gpt-5", "basemodel": "openai/gpt-5", "signature": "gpt-5", "enabled": true}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
All other sections (`agent_config`, `log_config`, etc.) inherited from default.
|
||||
|
||||
---
|
||||
|
||||
## OpenAPI / Swagger Documentation
|
||||
|
||||
@@ -54,7 +54,36 @@ JINA_API_KEY=your-jina-key-here
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Start the API Server
|
||||
## Step 3: (Optional) Custom Model Configuration
|
||||
|
||||
To use different AI models than the defaults, create a custom config:
|
||||
|
||||
1. Create config directory:
|
||||
```bash
|
||||
mkdir -p configs
|
||||
```
|
||||
|
||||
2. Create `configs/config.json`:
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"name": "my-gpt-4",
|
||||
"basemodel": "openai/gpt-4",
|
||||
"signature": "my-gpt-4",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
3. The Docker container will automatically merge this with default settings.
|
||||
|
||||
Your custom config only needs to include sections you want to override.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Start the API Server
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
@@ -79,7 +108,7 @@ docker logs -f ai-trader-server
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Verify Service is Running
|
||||
## Step 5: Verify Service is Running
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
@@ -99,7 +128,7 @@ If you see `"status": "healthy"`, you're ready!
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Run Your First Simulation
|
||||
## Step 6: Run Your First Simulation
|
||||
|
||||
Trigger a simulation for a single day with GPT-4:
|
||||
|
||||
@@ -130,7 +159,7 @@ curl -X POST http://localhost:8080/simulate/trigger \
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Monitor Progress
|
||||
## Step 7: Monitor Progress
|
||||
|
||||
```bash
|
||||
# Replace with your job_id from Step 5
|
||||
@@ -175,7 +204,7 @@ curl http://localhost:8080/simulate/status/$JOB_ID
|
||||
|
||||
---
|
||||
|
||||
## Step 7: View Results
|
||||
## Step 8: View Results
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8080/results?job_id=$JOB_ID" | jq '.'
|
||||
|
||||
@@ -8,7 +8,8 @@ services:
|
||||
volumes:
|
||||
- ${VOLUME_PATH:-.}/data:/app/data
|
||||
- ${VOLUME_PATH:-.}/logs:/app/logs
|
||||
- ${VOLUME_PATH:-.}/configs:/app/configs
|
||||
# User configs mounted to /app/user-configs (default config baked into image)
|
||||
- ${VOLUME_PATH:-.}/configs:/app/user-configs
|
||||
environment:
|
||||
# Deployment Configuration
|
||||
- DEPLOYMENT_MODE=${DEPLOYMENT_MODE:-PROD}
|
||||
|
||||
249
docs/plans/2025-11-01-config-override-system-design.md
Normal file
249
docs/plans/2025-11-01-config-override-system-design.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# Configuration Override System Design
|
||||
|
||||
**Date:** 2025-11-01
|
||||
**Status:** Approved
|
||||
**Context:** Enable per-deployment model configuration while maintaining sensible defaults
|
||||
|
||||
## Problem
|
||||
|
||||
Deployments need to customize model configurations without modifying the image's default config. Currently, the API looks for `configs/default_config.json` at startup, but volume mounts that include custom configs would overwrite the default config baked into the image.
|
||||
|
||||
## Solution Overview
|
||||
|
||||
Implement a layered configuration system where:
|
||||
- Default config is baked into the Docker image
|
||||
- User config is provided via volume mount in a separate directory
|
||||
- Configs are merged at container startup (before API starts)
|
||||
- Validation failures cause immediate container exit
|
||||
|
||||
## Architecture
|
||||
|
||||
### File Locations
|
||||
|
||||
- **Default config (in image):** `/app/configs/default_config.json`
|
||||
- **User config (mounted):** `/app/user-configs/config.json`
|
||||
- **Merged output:** `/tmp/runtime_config.json`
|
||||
|
||||
### Startup Sequence
|
||||
|
||||
1. **Entrypoint phase** (before uvicorn):
|
||||
- Load `configs/default_config.json` from image
|
||||
- Check if `user-configs/config.json` exists
|
||||
- If exists: perform root-level merge (custom sections override default sections)
|
||||
- Validate merged config structure
|
||||
- If validation fails: log detailed error and `exit 1`
|
||||
- Write merged config to `/tmp/runtime_config.json`
|
||||
- Export `CONFIG_PATH=/tmp/runtime_config.json`
|
||||
|
||||
2. **API initialization:**
|
||||
- Load pre-validated config from `$CONFIG_PATH`
|
||||
- No runtime config validation needed (already validated)
|
||||
|
||||
### Merge Behavior
|
||||
|
||||
**Root-level merge:** Custom config sections completely replace default sections.
|
||||
|
||||
```python
|
||||
default = load_json("configs/default_config.json")
|
||||
custom = load_json("user-configs/config.json") if exists else {}
|
||||
|
||||
merged = {**default}
|
||||
for key in custom:
|
||||
merged[key] = custom[key] # Override entire section
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
- Custom has `models` array → entire models array replaced
|
||||
- Custom has `agent_config` → entire agent_config replaced
|
||||
- Custom missing `date_range` → default date_range used
|
||||
- Custom has unknown keys → passed through (validated in next step)
|
||||
|
||||
### Validation Rules
|
||||
|
||||
**Structure validation:**
|
||||
- Required top-level keys: `agent_type`, `models`, `agent_config`, `log_config`
|
||||
- `date_range` is optional (can be overridden by API request params)
|
||||
- `models` must be an array with at least one entry
|
||||
- Each model must have: `name`, `basemodel`, `signature`, `enabled`
|
||||
|
||||
**Model validation:**
|
||||
- At least one model must have `enabled: true`
|
||||
- Model signatures must be unique
|
||||
- No duplicate model names
|
||||
|
||||
**Date validation (if date_range present):**
|
||||
- Dates match `YYYY-MM-DD` format
|
||||
- `init_date` <= `end_date`
|
||||
- Dates are not in the future
|
||||
|
||||
**Agent config validation:**
|
||||
- `max_steps` > 0
|
||||
- `max_retries` >= 0
|
||||
- `initial_cash` > 0
|
||||
|
||||
### Error Handling
|
||||
|
||||
**Validation failure output:**
|
||||
```
|
||||
❌ CONFIG VALIDATION FAILED
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
Error: Missing required field 'models'
|
||||
Location: Root level
|
||||
File: user-configs/config.json
|
||||
|
||||
Merged config written to: /tmp/runtime_config.json (for debugging)
|
||||
|
||||
Container will exit. Fix config and restart.
|
||||
```
|
||||
|
||||
**Benefits of fail-fast approach:**
|
||||
- No silent config errors during API calls
|
||||
- Clear feedback on what's wrong
|
||||
- Container restart loop until config is fixed
|
||||
- Health checks fail immediately (container never reaches "running" state with bad config)
|
||||
|
||||
## Implementation Components
|
||||
|
||||
### New Files
|
||||
|
||||
**`tools/config_merger.py`**
|
||||
```python
|
||||
def load_config(path: str) -> dict:
|
||||
"""Load and parse JSON with error handling"""
|
||||
|
||||
def merge_configs(default: dict, custom: dict) -> dict:
|
||||
"""Root-level merge - custom sections override default"""
|
||||
|
||||
def validate_config(config: dict) -> None:
|
||||
"""Validate structure, raise detailed exception on failure"""
|
||||
|
||||
def merge_and_validate() -> None:
|
||||
"""Main entrypoint - load, merge, validate, write to /tmp"""
|
||||
```
|
||||
|
||||
### Updated Files
|
||||
|
||||
**`entrypoint.sh`**
|
||||
```bash
|
||||
# After MCP service startup, before uvicorn
|
||||
echo "🔧 Merging and validating configuration..."
|
||||
python -c "from tools.config_merger import merge_and_validate; merge_and_validate()" || exit 1
|
||||
export CONFIG_PATH=/tmp/runtime_config.json
|
||||
echo "✅ Configuration validated"
|
||||
|
||||
exec uvicorn api.main:app ...
|
||||
```
|
||||
|
||||
**`docker-compose.yml`**
|
||||
```yaml
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
- ./configs:/app/user-configs # User's config.json (not /app/configs!)
|
||||
```
|
||||
|
||||
**`api/main.py`**
|
||||
- Keep existing `CONFIG_PATH` env var support (already implemented)
|
||||
- Remove any config validation from request handlers (now done at startup)
|
||||
|
||||
### Documentation Updates
|
||||
|
||||
- **`docs/DOCKER.md`** - Explain user-configs volume mount and config.json structure
|
||||
- **`QUICK_START.md`** - Show minimal config.json example
|
||||
- **`API_REFERENCE.md`** - Note that config errors fail at startup, not during API calls
|
||||
- **`CLAUDE.md`** - Update configuration section with new merge behavior
|
||||
|
||||
## User Experience
|
||||
|
||||
### Minimal Custom Config Example
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"name": "my-gpt-4",
|
||||
"basemodel": "openai/gpt-4",
|
||||
"signature": "my-gpt-4",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
All other settings (`agent_config`, `log_config`, etc.) inherited from default.
|
||||
|
||||
### Complete Custom Config Example
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_type": "BaseAgent",
|
||||
"date_range": {
|
||||
"init_date": "2025-10-01",
|
||||
"end_date": "2025-10-31"
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "claude-sonnet-4",
|
||||
"basemodel": "anthropic/claude-sonnet-4",
|
||||
"signature": "claude-sonnet-4",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"agent_config": {
|
||||
"max_steps": 50,
|
||||
"max_retries": 5,
|
||||
"base_delay": 2.0,
|
||||
"initial_cash": 100000.0
|
||||
},
|
||||
"log_config": {
|
||||
"log_path": "./data/agent_data"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All sections replaced, no inheritance from default.
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
**If no `user-configs/config.json` exists:**
|
||||
- System uses `configs/default_config.json` as-is
|
||||
- No merging needed
|
||||
- Existing behavior preserved
|
||||
|
||||
**Breaking change:**
|
||||
- Deployments currently mounting to `/app/configs` must update to `/app/user-configs`
|
||||
- Migration: Update docker-compose.yml volume mount path
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Default config in image is read-only (immutable)
|
||||
- User config directory is writable (mounted volume)
|
||||
- Merged config in `/tmp` is ephemeral (recreated on restart)
|
||||
- API keys in user config are not logged during validation errors
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
**Unit tests (`tests/unit/test_config_merger.py`):**
|
||||
- Merge behavior with various override combinations
|
||||
- Validation catches all error conditions
|
||||
- Error messages are clear and actionable
|
||||
|
||||
**Integration tests:**
|
||||
- Container startup with valid user config
|
||||
- Container startup with invalid user config (should exit 1)
|
||||
- Container startup with no user config (uses default)
|
||||
- API requests use merged config correctly
|
||||
|
||||
**Manual testing:**
|
||||
- Deploy with minimal config.json (only models)
|
||||
- Deploy with complete config.json (all sections)
|
||||
- Deploy with invalid config.json (verify error output)
|
||||
- Deploy with no config.json (verify default behavior)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Deep merge support (merge within sections, not just root-level)
|
||||
- Config schema validation using JSON Schema
|
||||
- Support for multiple config files (e.g., base + environment + deployment)
|
||||
- Hot reload on config file changes (SIGHUP handler)
|
||||
@@ -41,7 +41,16 @@ echo "📊 Initializing database..."
|
||||
python -c "from api.database import initialize_database; initialize_database('data/jobs.db')"
|
||||
echo "✅ Database initialized"
|
||||
|
||||
# Step 2: Start MCP services in background
|
||||
# Step 2: Merge and validate configuration
|
||||
echo "🔧 Merging and validating configuration..."
|
||||
python -c "from tools.config_merger import merge_and_validate; merge_and_validate()" || {
|
||||
echo "❌ Configuration validation failed"
|
||||
exit 1
|
||||
}
|
||||
export CONFIG_PATH=/tmp/runtime_config.json
|
||||
echo "✅ Configuration validated and merged"
|
||||
|
||||
# Step 3: Start MCP services in background
|
||||
echo "🔧 Starting MCP services..."
|
||||
cd /app
|
||||
python agent_tools/start_mcp_services.py &
|
||||
@@ -50,11 +59,11 @@ MCP_PID=$!
|
||||
# Setup cleanup trap before starting uvicorn
|
||||
trap "echo '🛑 Stopping services...'; kill $MCP_PID 2>/dev/null; exit 0" EXIT SIGTERM SIGINT
|
||||
|
||||
# Step 3: Wait for services to initialize
|
||||
# Step 4: Wait for services to initialize
|
||||
echo "⏳ Waiting for MCP services to start..."
|
||||
sleep 3
|
||||
|
||||
# Step 4: Start FastAPI server with uvicorn (this blocks)
|
||||
# Step 5: Start FastAPI server with uvicorn (this blocks)
|
||||
# Note: Container always uses port 8080 internally
|
||||
# The API_PORT env var only affects the host port mapping in docker-compose.yml
|
||||
echo "🌐 Starting FastAPI server on port 8080..."
|
||||
|
||||
121
tests/integration/test_config_override.py
Normal file
121
tests/integration/test_config_override.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Integration tests for config override system."""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_configs(tmp_path):
|
||||
"""Create test config files."""
|
||||
# Default config
|
||||
default_config = {
|
||||
"agent_type": "BaseAgent",
|
||||
"date_range": {"init_date": "2025-10-01", "end_date": "2025-10-21"},
|
||||
"models": [
|
||||
{"name": "default-model", "basemodel": "openai/gpt-4", "signature": "default", "enabled": True}
|
||||
],
|
||||
"agent_config": {"max_steps": 30, "max_retries": 3, "base_delay": 1.0, "initial_cash": 10000.0},
|
||||
"log_config": {"log_path": "./data/agent_data"}
|
||||
}
|
||||
|
||||
configs_dir = tmp_path / "configs"
|
||||
configs_dir.mkdir()
|
||||
|
||||
default_path = configs_dir / "default_config.json"
|
||||
with open(default_path, 'w') as f:
|
||||
json.dump(default_config, f, indent=2)
|
||||
|
||||
return configs_dir, default_config
|
||||
|
||||
|
||||
def test_config_override_models_only(test_configs):
|
||||
"""Test overriding only the models section."""
|
||||
configs_dir, default_config = test_configs
|
||||
|
||||
# Custom config - only override models
|
||||
custom_config = {
|
||||
"models": [
|
||||
{"name": "gpt-5", "basemodel": "openai/gpt-5", "signature": "gpt-5", "enabled": True}
|
||||
]
|
||||
}
|
||||
|
||||
user_configs_dir = configs_dir.parent / "user-configs"
|
||||
user_configs_dir.mkdir()
|
||||
|
||||
custom_path = user_configs_dir / "config.json"
|
||||
with open(custom_path, 'w') as f:
|
||||
json.dump(custom_config, f, indent=2)
|
||||
|
||||
# Run merge
|
||||
result = subprocess.run(
|
||||
[
|
||||
"python", "-c",
|
||||
f"import sys; sys.path.insert(0, '.'); "
|
||||
f"from tools.config_merger import DEFAULT_CONFIG_PATH, CUSTOM_CONFIG_PATH, OUTPUT_CONFIG_PATH, merge_and_validate; "
|
||||
f"import tools.config_merger; "
|
||||
f"tools.config_merger.DEFAULT_CONFIG_PATH = '{configs_dir}/default_config.json'; "
|
||||
f"tools.config_merger.CUSTOM_CONFIG_PATH = '{custom_path}'; "
|
||||
f"tools.config_merger.OUTPUT_CONFIG_PATH = '{configs_dir.parent}/runtime.json'; "
|
||||
f"merge_and_validate()"
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd="/home/bballou/AI-Trader/.worktrees/config-override-system"
|
||||
)
|
||||
|
||||
assert result.returncode == 0, f"Merge failed: {result.stderr}"
|
||||
|
||||
# Verify merged config
|
||||
runtime_path = configs_dir.parent / "runtime.json"
|
||||
with open(runtime_path, 'r') as f:
|
||||
merged = json.load(f)
|
||||
|
||||
# Models should be overridden
|
||||
assert merged["models"] == custom_config["models"]
|
||||
|
||||
# Other sections should be from default
|
||||
assert merged["agent_config"] == default_config["agent_config"]
|
||||
assert merged["date_range"] == default_config["date_range"]
|
||||
|
||||
|
||||
def test_config_validation_fails_gracefully(test_configs):
|
||||
"""Test that invalid config causes exit with clear error."""
|
||||
configs_dir, _ = test_configs
|
||||
|
||||
# Invalid custom config (no enabled models)
|
||||
custom_config = {
|
||||
"models": [
|
||||
{"name": "test", "basemodel": "openai/gpt-4", "signature": "test", "enabled": False}
|
||||
]
|
||||
}
|
||||
|
||||
user_configs_dir = configs_dir.parent / "user-configs"
|
||||
user_configs_dir.mkdir()
|
||||
|
||||
custom_path = user_configs_dir / "config.json"
|
||||
with open(custom_path, 'w') as f:
|
||||
json.dump(custom_config, f, indent=2)
|
||||
|
||||
# Run merge (should fail)
|
||||
result = subprocess.run(
|
||||
[
|
||||
"python", "-c",
|
||||
f"import sys; sys.path.insert(0, '.'); "
|
||||
f"from tools.config_merger import merge_and_validate; "
|
||||
f"import tools.config_merger; "
|
||||
f"tools.config_merger.DEFAULT_CONFIG_PATH = '{configs_dir}/default_config.json'; "
|
||||
f"tools.config_merger.CUSTOM_CONFIG_PATH = '{custom_path}'; "
|
||||
f"tools.config_merger.OUTPUT_CONFIG_PATH = '{configs_dir.parent}/runtime.json'; "
|
||||
f"merge_and_validate()"
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd="/home/bballou/AI-Trader/.worktrees/config-override-system"
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "CONFIG VALIDATION FAILED" in result.stderr
|
||||
assert "At least one model must be enabled" in result.stderr
|
||||
293
tests/unit/test_config_merger.py
Normal file
293
tests/unit/test_config_merger.py
Normal file
@@ -0,0 +1,293 @@
|
||||
import pytest
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from tools.config_merger import load_config, ConfigValidationError, merge_configs, validate_config
|
||||
|
||||
|
||||
def test_load_config_valid_json():
|
||||
"""Test loading a valid JSON config file"""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
||||
json.dump({"key": "value"}, f)
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
result = load_config(temp_path)
|
||||
assert result == {"key": "value"}
|
||||
finally:
|
||||
Path(temp_path).unlink()
|
||||
|
||||
|
||||
def test_load_config_file_not_found():
|
||||
"""Test loading non-existent config file"""
|
||||
with pytest.raises(ConfigValidationError, match="not found"):
|
||||
load_config("/nonexistent/path.json")
|
||||
|
||||
|
||||
def test_load_config_invalid_json():
|
||||
"""Test loading malformed JSON"""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
||||
f.write("{invalid json")
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
with pytest.raises(ConfigValidationError, match="Invalid JSON"):
|
||||
load_config(temp_path)
|
||||
finally:
|
||||
Path(temp_path).unlink()
|
||||
|
||||
|
||||
def test_merge_configs_empty_custom():
|
||||
"""Test merge with no custom config"""
|
||||
default = {"a": 1, "b": 2}
|
||||
custom = {}
|
||||
result = merge_configs(default, custom)
|
||||
assert result == {"a": 1, "b": 2}
|
||||
|
||||
|
||||
def test_merge_configs_override_section():
|
||||
"""Test custom config overrides entire sections"""
|
||||
default = {
|
||||
"models": [{"name": "default-model", "enabled": True}],
|
||||
"agent_config": {"max_steps": 30}
|
||||
}
|
||||
custom = {
|
||||
"models": [{"name": "custom-model", "enabled": False}]
|
||||
}
|
||||
result = merge_configs(default, custom)
|
||||
|
||||
assert result["models"] == [{"name": "custom-model", "enabled": False}]
|
||||
assert result["agent_config"] == {"max_steps": 30}
|
||||
|
||||
|
||||
def test_merge_configs_add_new_section():
|
||||
"""Test custom config adds new sections"""
|
||||
default = {"a": 1}
|
||||
custom = {"b": 2}
|
||||
result = merge_configs(default, custom)
|
||||
assert result == {"a": 1, "b": 2}
|
||||
|
||||
|
||||
def test_merge_configs_does_not_mutate_inputs():
|
||||
"""Test merge doesn't modify original dicts"""
|
||||
default = {"a": 1}
|
||||
custom = {"a": 2}
|
||||
result = merge_configs(default, custom)
|
||||
|
||||
assert default["a"] == 1 # Original unchanged
|
||||
assert result["a"] == 2
|
||||
|
||||
|
||||
def test_validate_config_valid():
|
||||
"""Test validation passes for valid config"""
|
||||
config = {
|
||||
"agent_type": "BaseAgent",
|
||||
"models": [
|
||||
{"name": "test", "basemodel": "openai/gpt-4", "signature": "test", "enabled": True}
|
||||
],
|
||||
"agent_config": {
|
||||
"max_steps": 30,
|
||||
"max_retries": 3,
|
||||
"initial_cash": 10000.0
|
||||
},
|
||||
"log_config": {"log_path": "./data"}
|
||||
}
|
||||
|
||||
validate_config(config) # Should not raise
|
||||
|
||||
|
||||
def test_validate_config_missing_required_field():
|
||||
"""Test validation fails for missing required field"""
|
||||
config = {"agent_type": "BaseAgent"} # Missing models, agent_config, log_config
|
||||
|
||||
with pytest.raises(ConfigValidationError, match="Missing required field"):
|
||||
validate_config(config)
|
||||
|
||||
|
||||
def test_validate_config_no_enabled_models():
|
||||
"""Test validation fails when no models are enabled"""
|
||||
config = {
|
||||
"agent_type": "BaseAgent",
|
||||
"models": [
|
||||
{"name": "test", "basemodel": "openai/gpt-4", "signature": "test", "enabled": False}
|
||||
],
|
||||
"agent_config": {"max_steps": 30, "max_retries": 3, "initial_cash": 10000.0},
|
||||
"log_config": {"log_path": "./data"}
|
||||
}
|
||||
|
||||
with pytest.raises(ConfigValidationError, match="At least one model must be enabled"):
|
||||
validate_config(config)
|
||||
|
||||
|
||||
def test_validate_config_duplicate_signatures():
|
||||
"""Test validation fails for duplicate model signatures"""
|
||||
config = {
|
||||
"agent_type": "BaseAgent",
|
||||
"models": [
|
||||
{"name": "test1", "basemodel": "openai/gpt-4", "signature": "same", "enabled": True},
|
||||
{"name": "test2", "basemodel": "openai/gpt-5", "signature": "same", "enabled": True}
|
||||
],
|
||||
"agent_config": {"max_steps": 30, "max_retries": 3, "initial_cash": 10000.0},
|
||||
"log_config": {"log_path": "./data"}
|
||||
}
|
||||
|
||||
with pytest.raises(ConfigValidationError, match="Duplicate model signature"):
|
||||
validate_config(config)
|
||||
|
||||
|
||||
def test_validate_config_invalid_max_steps():
|
||||
"""Test validation fails for invalid max_steps"""
|
||||
config = {
|
||||
"agent_type": "BaseAgent",
|
||||
"models": [{"name": "test", "basemodel": "openai/gpt-4", "signature": "test", "enabled": True}],
|
||||
"agent_config": {"max_steps": 0, "max_retries": 3, "initial_cash": 10000.0},
|
||||
"log_config": {"log_path": "./data"}
|
||||
}
|
||||
|
||||
with pytest.raises(ConfigValidationError, match="max_steps must be > 0"):
|
||||
validate_config(config)
|
||||
|
||||
|
||||
def test_validate_config_invalid_date_format():
|
||||
"""Test validation fails for invalid date format"""
|
||||
config = {
|
||||
"agent_type": "BaseAgent",
|
||||
"date_range": {"init_date": "2025-13-01", "end_date": "2025-12-31"}, # Invalid month
|
||||
"models": [{"name": "test", "basemodel": "openai/gpt-4", "signature": "test", "enabled": True}],
|
||||
"agent_config": {"max_steps": 30, "max_retries": 3, "initial_cash": 10000.0},
|
||||
"log_config": {"log_path": "./data"}
|
||||
}
|
||||
|
||||
with pytest.raises(ConfigValidationError, match="Invalid date format"):
|
||||
validate_config(config)
|
||||
|
||||
|
||||
def test_validate_config_end_before_init():
|
||||
"""Test validation fails when end_date before init_date"""
|
||||
config = {
|
||||
"agent_type": "BaseAgent",
|
||||
"date_range": {"init_date": "2025-12-31", "end_date": "2025-01-01"},
|
||||
"models": [{"name": "test", "basemodel": "openai/gpt-4", "signature": "test", "enabled": True}],
|
||||
"agent_config": {"max_steps": 30, "max_retries": 3, "initial_cash": 10000.0},
|
||||
"log_config": {"log_path": "./data"}
|
||||
}
|
||||
|
||||
with pytest.raises(ConfigValidationError, match="init_date must be <= end_date"):
|
||||
validate_config(config)
|
||||
|
||||
|
||||
import os
|
||||
from tools.config_merger import merge_and_validate
|
||||
|
||||
|
||||
def test_merge_and_validate_success(tmp_path, monkeypatch):
|
||||
"""Test successful merge and validation"""
|
||||
# Create default config
|
||||
default_config = {
|
||||
"agent_type": "BaseAgent",
|
||||
"models": [{"name": "default", "basemodel": "openai/gpt-4", "signature": "default", "enabled": True}],
|
||||
"agent_config": {"max_steps": 30, "max_retries": 3, "initial_cash": 10000.0},
|
||||
"log_config": {"log_path": "./data"}
|
||||
}
|
||||
|
||||
default_path = tmp_path / "default_config.json"
|
||||
with open(default_path, 'w') as f:
|
||||
json.dump(default_config, f)
|
||||
|
||||
# Create custom config (only overrides models)
|
||||
custom_config = {
|
||||
"models": [{"name": "custom", "basemodel": "openai/gpt-5", "signature": "custom", "enabled": True}]
|
||||
}
|
||||
|
||||
custom_path = tmp_path / "config.json"
|
||||
with open(custom_path, 'w') as f:
|
||||
json.dump(custom_config, f)
|
||||
|
||||
output_path = tmp_path / "runtime_config.json"
|
||||
|
||||
# Mock file paths
|
||||
monkeypatch.setattr("tools.config_merger.DEFAULT_CONFIG_PATH", str(default_path))
|
||||
monkeypatch.setattr("tools.config_merger.CUSTOM_CONFIG_PATH", str(custom_path))
|
||||
monkeypatch.setattr("tools.config_merger.OUTPUT_CONFIG_PATH", str(output_path))
|
||||
|
||||
# Run merge and validate
|
||||
merge_and_validate()
|
||||
|
||||
# Verify output file was created
|
||||
assert output_path.exists()
|
||||
|
||||
# Verify merged content
|
||||
with open(output_path, 'r') as f:
|
||||
result = json.load(f)
|
||||
|
||||
assert result["models"] == [{"name": "custom", "basemodel": "openai/gpt-5", "signature": "custom", "enabled": True}]
|
||||
assert result["agent_config"] == {"max_steps": 30, "max_retries": 3, "initial_cash": 10000.0}
|
||||
|
||||
|
||||
def test_merge_and_validate_no_custom_config(tmp_path, monkeypatch):
|
||||
"""Test when no custom config exists (uses default only)"""
|
||||
default_config = {
|
||||
"agent_type": "BaseAgent",
|
||||
"models": [{"name": "default", "basemodel": "openai/gpt-4", "signature": "default", "enabled": True}],
|
||||
"agent_config": {"max_steps": 30, "max_retries": 3, "initial_cash": 10000.0},
|
||||
"log_config": {"log_path": "./data"}
|
||||
}
|
||||
|
||||
default_path = tmp_path / "default_config.json"
|
||||
with open(default_path, 'w') as f:
|
||||
json.dump(default_config, f)
|
||||
|
||||
custom_path = tmp_path / "config.json" # Does not exist
|
||||
output_path = tmp_path / "runtime_config.json"
|
||||
|
||||
monkeypatch.setattr("tools.config_merger.DEFAULT_CONFIG_PATH", str(default_path))
|
||||
monkeypatch.setattr("tools.config_merger.CUSTOM_CONFIG_PATH", str(custom_path))
|
||||
monkeypatch.setattr("tools.config_merger.OUTPUT_CONFIG_PATH", str(output_path))
|
||||
|
||||
merge_and_validate()
|
||||
|
||||
# Verify output matches default
|
||||
with open(output_path, 'r') as f:
|
||||
result = json.load(f)
|
||||
|
||||
assert result == default_config
|
||||
|
||||
|
||||
def test_merge_and_validate_validation_fails(tmp_path, monkeypatch, capsys):
|
||||
"""Test validation failure exits with error"""
|
||||
default_config = {
|
||||
"agent_type": "BaseAgent",
|
||||
"models": [{"name": "default", "basemodel": "openai/gpt-4", "signature": "default", "enabled": True}],
|
||||
"agent_config": {"max_steps": 30, "max_retries": 3, "initial_cash": 10000.0},
|
||||
"log_config": {"log_path": "./data"}
|
||||
}
|
||||
|
||||
default_path = tmp_path / "default_config.json"
|
||||
with open(default_path, 'w') as f:
|
||||
json.dump(default_config, f)
|
||||
|
||||
# Custom config with no enabled models
|
||||
custom_config = {
|
||||
"models": [{"name": "custom", "basemodel": "openai/gpt-5", "signature": "custom", "enabled": False}]
|
||||
}
|
||||
|
||||
custom_path = tmp_path / "config.json"
|
||||
with open(custom_path, 'w') as f:
|
||||
json.dump(custom_config, f)
|
||||
|
||||
output_path = tmp_path / "runtime_config.json"
|
||||
|
||||
monkeypatch.setattr("tools.config_merger.DEFAULT_CONFIG_PATH", str(default_path))
|
||||
monkeypatch.setattr("tools.config_merger.CUSTOM_CONFIG_PATH", str(custom_path))
|
||||
monkeypatch.setattr("tools.config_merger.OUTPUT_CONFIG_PATH", str(output_path))
|
||||
|
||||
# Should exit with error
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
merge_and_validate()
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
# Check error output (should be in stderr, not stdout)
|
||||
captured = capsys.readouterr()
|
||||
assert "CONFIG VALIDATION FAILED" in captured.err
|
||||
assert "At least one model must be enabled" in captured.err
|
||||
228
tools/config_merger.py
Normal file
228
tools/config_merger.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""Configuration merging and validation for AI-Trader."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ConfigValidationError(Exception):
|
||||
"""Raised when config validation fails."""
|
||||
pass
|
||||
|
||||
|
||||
def load_config(path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Load and parse JSON config file.
|
||||
|
||||
Args:
|
||||
path: Path to JSON config file
|
||||
|
||||
Returns:
|
||||
Parsed config dictionary
|
||||
|
||||
Raises:
|
||||
ConfigValidationError: If file not found or invalid JSON
|
||||
"""
|
||||
config_path = Path(path)
|
||||
|
||||
if not config_path.exists():
|
||||
raise ConfigValidationError(f"Config file not found: {path}")
|
||||
|
||||
try:
|
||||
with open(config_path, 'r') as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ConfigValidationError(f"Invalid JSON in {path}: {e}")
|
||||
|
||||
|
||||
def merge_configs(default: Dict[str, Any], custom: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Merge custom config into default config (root-level override).
|
||||
|
||||
Custom config sections completely replace default sections.
|
||||
Does not mutate input dictionaries.
|
||||
|
||||
Args:
|
||||
default: Default configuration dict
|
||||
custom: Custom configuration dict (overrides)
|
||||
|
||||
Returns:
|
||||
Merged configuration dict
|
||||
"""
|
||||
merged = dict(default) # Shallow copy
|
||||
|
||||
for key, value in custom.items():
|
||||
merged[key] = value
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def validate_config(config: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Validate configuration structure and values.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary to validate
|
||||
|
||||
Raises:
|
||||
ConfigValidationError: If validation fails with detailed message
|
||||
"""
|
||||
# Required top-level fields
|
||||
required_fields = ["agent_type", "models", "agent_config", "log_config"]
|
||||
for field in required_fields:
|
||||
if field not in config:
|
||||
raise ConfigValidationError(f"Missing required field: '{field}'")
|
||||
|
||||
# Validate models
|
||||
models = config["models"]
|
||||
if not isinstance(models, list) or len(models) == 0:
|
||||
raise ConfigValidationError("'models' must be a non-empty array")
|
||||
|
||||
# Check at least one enabled model
|
||||
enabled_models = [m for m in models if m.get("enabled", False)]
|
||||
if not enabled_models:
|
||||
raise ConfigValidationError("At least one model must be enabled")
|
||||
|
||||
# Check required model fields
|
||||
for i, model in enumerate(models):
|
||||
required_model_fields = ["name", "basemodel", "signature", "enabled"]
|
||||
for field in required_model_fields:
|
||||
if field not in model:
|
||||
raise ConfigValidationError(
|
||||
f"Model {i} missing required field: '{field}'"
|
||||
)
|
||||
|
||||
# Check for duplicate signatures
|
||||
signatures = [m["signature"] for m in models]
|
||||
if len(signatures) != len(set(signatures)):
|
||||
duplicates = [s for s in signatures if signatures.count(s) > 1]
|
||||
raise ConfigValidationError(
|
||||
f"Duplicate model signature: {duplicates[0]}"
|
||||
)
|
||||
|
||||
# Validate agent_config
|
||||
agent_config = config["agent_config"]
|
||||
|
||||
if "max_steps" in agent_config:
|
||||
if agent_config["max_steps"] <= 0:
|
||||
raise ConfigValidationError("max_steps must be > 0")
|
||||
|
||||
if "max_retries" in agent_config:
|
||||
if agent_config["max_retries"] < 0:
|
||||
raise ConfigValidationError("max_retries must be >= 0")
|
||||
|
||||
if "initial_cash" in agent_config:
|
||||
if agent_config["initial_cash"] <= 0:
|
||||
raise ConfigValidationError("initial_cash must be > 0")
|
||||
|
||||
# Validate date_range if present (optional)
|
||||
if "date_range" in config:
|
||||
date_range = config["date_range"]
|
||||
|
||||
if "init_date" in date_range:
|
||||
try:
|
||||
init_dt = datetime.strptime(date_range["init_date"], "%Y-%m-%d")
|
||||
except ValueError:
|
||||
raise ConfigValidationError(
|
||||
f"Invalid date format for init_date: {date_range['init_date']}. "
|
||||
"Expected YYYY-MM-DD"
|
||||
)
|
||||
|
||||
if "end_date" in date_range:
|
||||
try:
|
||||
end_dt = datetime.strptime(date_range["end_date"], "%Y-%m-%d")
|
||||
except ValueError:
|
||||
raise ConfigValidationError(
|
||||
f"Invalid date format for end_date: {date_range['end_date']}. "
|
||||
"Expected YYYY-MM-DD"
|
||||
)
|
||||
|
||||
# Check init <= end
|
||||
if "init_date" in date_range and "end_date" in date_range:
|
||||
if init_dt > end_dt:
|
||||
raise ConfigValidationError(
|
||||
f"init_date must be <= end_date (got {date_range['init_date']} > {date_range['end_date']})"
|
||||
)
|
||||
|
||||
|
||||
# File path constants (can be overridden for testing)
|
||||
DEFAULT_CONFIG_PATH = "configs/default_config.json"
|
||||
CUSTOM_CONFIG_PATH = "user-configs/config.json"
|
||||
OUTPUT_CONFIG_PATH = "/tmp/runtime_config.json"
|
||||
|
||||
|
||||
def format_error_message(error: str, location: str, file: str) -> str:
|
||||
"""Format validation error for display."""
|
||||
border = "━" * 60
|
||||
return f"""
|
||||
❌ CONFIG VALIDATION FAILED
|
||||
{border}
|
||||
|
||||
Error: {error}
|
||||
Location: {location}
|
||||
File: {file}
|
||||
|
||||
Merged config written to: {OUTPUT_CONFIG_PATH} (for debugging)
|
||||
|
||||
Container will exit. Fix config and restart.
|
||||
"""
|
||||
|
||||
|
||||
def merge_and_validate() -> None:
|
||||
"""
|
||||
Main entry point for config merging and validation.
|
||||
|
||||
Loads default config, optionally merges custom config,
|
||||
validates the result, and writes to output path.
|
||||
|
||||
Exits with code 1 on any error.
|
||||
"""
|
||||
try:
|
||||
# Load default config
|
||||
print(f"📄 Loading default config from {DEFAULT_CONFIG_PATH}")
|
||||
default_config = load_config(DEFAULT_CONFIG_PATH)
|
||||
|
||||
# Load custom config if exists
|
||||
custom_config = {}
|
||||
if Path(CUSTOM_CONFIG_PATH).exists():
|
||||
print(f"📝 Loading custom config from {CUSTOM_CONFIG_PATH}")
|
||||
custom_config = load_config(CUSTOM_CONFIG_PATH)
|
||||
else:
|
||||
print(f"ℹ️ No custom config found at {CUSTOM_CONFIG_PATH}, using defaults")
|
||||
|
||||
# Merge configs
|
||||
print("🔧 Merging configurations...")
|
||||
merged_config = merge_configs(default_config, custom_config)
|
||||
|
||||
# Write merged config (for debugging even if validation fails)
|
||||
output_path = Path(OUTPUT_CONFIG_PATH)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(output_path, 'w') as f:
|
||||
json.dump(merged_config, f, indent=2)
|
||||
|
||||
# Validate merged config
|
||||
print("✅ Validating merged configuration...")
|
||||
validate_config(merged_config)
|
||||
|
||||
print(f"✅ Configuration validated successfully")
|
||||
print(f"📦 Merged config written to {OUTPUT_CONFIG_PATH}")
|
||||
|
||||
except ConfigValidationError as e:
|
||||
# Determine which file caused the error
|
||||
error_file = CUSTOM_CONFIG_PATH if Path(CUSTOM_CONFIG_PATH).exists() else DEFAULT_CONFIG_PATH
|
||||
|
||||
error_msg = format_error_message(
|
||||
error=str(e),
|
||||
location="Root level",
|
||||
file=error_file
|
||||
)
|
||||
|
||||
print(error_msg, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Unexpected error during config processing: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user