mirror of
https://github.com/Xe138/AI-Trader.git
synced 2026-04-08 11:47:24 -04:00
- Skip already-completed model-day pairs in create_job() - Return warnings for skipped simulations - Raise error if all simulations are already completed - Update create_job() return type from str to Dict[str, Any] - Update all callers to handle new dict return type - Add comprehensive test coverage for duplicate detection - Log warnings when simulations are skipped
1173 lines
32 KiB
Markdown
1173 lines
32 KiB
Markdown
# Fix Duplicate Simulation Bugs Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Prevent duplicate simulations and ensure portfolio continuity across jobs by adding duplicate detection to job creation and removing job_id isolation from position queries.
|
|
|
|
**Architecture:** Two-part fix: (1) Add duplicate checking in JobManager.create_job() to skip already-completed model-day pairs, (2) Remove job_id filter from get_current_position_from_db() to enable cross-job trading history continuity.
|
|
|
|
**Tech Stack:** Python, SQLite, pytest
|
|
|
|
---
|
|
|
|
## Task 1: Add Duplicate Detection to JobManager
|
|
|
|
**Files:**
|
|
- Modify: `api/job_manager.py:53-131`
|
|
- Test: `tests/unit/test_job_manager_duplicate_detection.py` (new file)
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
Create `tests/unit/test_job_manager_duplicate_detection.py`:
|
|
|
|
```python
|
|
"""Test duplicate detection in job creation."""
|
|
import pytest
|
|
import tempfile
|
|
import os
|
|
from pathlib import Path
|
|
from api.job_manager import JobManager
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_db():
|
|
"""Create temporary database for testing."""
|
|
fd, path = tempfile.mkstemp(suffix='.db')
|
|
os.close(fd)
|
|
|
|
# Initialize schema
|
|
from api.database import get_db_connection
|
|
conn = get_db_connection(path)
|
|
cursor = conn.cursor()
|
|
|
|
# Create jobs table
|
|
cursor.execute("""
|
|
CREATE TABLE IF NOT EXISTS jobs (
|
|
job_id TEXT PRIMARY KEY,
|
|
config_path TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
date_range TEXT NOT NULL,
|
|
models TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
started_at TEXT,
|
|
updated_at TEXT,
|
|
completed_at TEXT,
|
|
total_duration_seconds REAL,
|
|
error TEXT,
|
|
warnings TEXT
|
|
)
|
|
""")
|
|
|
|
# Create job_details table
|
|
cursor.execute("""
|
|
CREATE TABLE IF NOT EXISTS job_details (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
job_id TEXT NOT NULL,
|
|
date TEXT NOT NULL,
|
|
model TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
started_at TEXT,
|
|
completed_at TEXT,
|
|
duration_seconds REAL,
|
|
error TEXT,
|
|
FOREIGN KEY (job_id) REFERENCES jobs(job_id) ON DELETE CASCADE,
|
|
UNIQUE(job_id, date, model)
|
|
)
|
|
""")
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
yield path
|
|
|
|
# Cleanup
|
|
if os.path.exists(path):
|
|
os.remove(path)
|
|
|
|
|
|
def test_create_job_with_filter_skips_completed_simulations(temp_db):
|
|
"""Test that job creation with model_day_filter skips already-completed pairs."""
|
|
manager = JobManager(db_path=temp_db)
|
|
|
|
# Create first job and mark model-day as completed
|
|
job_id_1 = manager.create_job(
|
|
config_path="test_config.json",
|
|
date_range=["2025-10-15", "2025-10-16"],
|
|
models=["deepseek-chat-v3.1"],
|
|
model_day_filter=[("deepseek-chat-v3.1", "2025-10-15")]
|
|
)
|
|
|
|
# Mark as completed
|
|
manager.update_job_detail_status(
|
|
job_id_1,
|
|
"2025-10-15",
|
|
"deepseek-chat-v3.1",
|
|
"completed"
|
|
)
|
|
|
|
# Try to create second job with overlapping date
|
|
model_day_filter = [
|
|
("deepseek-chat-v3.1", "2025-10-15"), # Already completed
|
|
("deepseek-chat-v3.1", "2025-10-16") # Not yet completed
|
|
]
|
|
|
|
job_id_2 = manager.create_job(
|
|
config_path="test_config.json",
|
|
date_range=["2025-10-15", "2025-10-16"],
|
|
models=["deepseek-chat-v3.1"],
|
|
model_day_filter=model_day_filter
|
|
)
|
|
|
|
# Get job details for second job
|
|
details = manager.get_job_details(job_id_2)
|
|
|
|
# Should only have 2025-10-16 (2025-10-15 was skipped as already completed)
|
|
assert len(details) == 1
|
|
assert details[0]["date"] == "2025-10-16"
|
|
assert details[0]["model"] == "deepseek-chat-v3.1"
|
|
|
|
|
|
def test_create_job_without_filter_skips_all_completed_simulations(temp_db):
|
|
"""Test that job creation without filter skips all completed model-day pairs."""
|
|
manager = JobManager(db_path=temp_db)
|
|
|
|
# Create first job and complete some model-days
|
|
job_id_1 = manager.create_job(
|
|
config_path="test_config.json",
|
|
date_range=["2025-10-15"],
|
|
models=["model-a", "model-b"]
|
|
)
|
|
|
|
# Mark model-a/2025-10-15 as completed
|
|
manager.update_job_detail_status(job_id_1, "2025-10-15", "model-a", "completed")
|
|
# Leave model-b/2025-10-15 as pending
|
|
|
|
# Create second job with same date range and models
|
|
job_id_2 = manager.create_job(
|
|
config_path="test_config.json",
|
|
date_range=["2025-10-15", "2025-10-16"],
|
|
models=["model-a", "model-b"]
|
|
)
|
|
|
|
# Get job details for second job
|
|
details = manager.get_job_details(job_id_2)
|
|
|
|
# Should have 3 entries (skip model-a/2025-10-15):
|
|
# - model-b/2025-10-15 (not completed in job 1)
|
|
# - model-a/2025-10-16 (new date)
|
|
# - model-b/2025-10-16 (new date)
|
|
assert len(details) == 3
|
|
|
|
dates_models = [(d["date"], d["model"]) for d in details]
|
|
assert ("2025-10-15", "model-a") not in dates_models # Skipped
|
|
assert ("2025-10-15", "model-b") in dates_models
|
|
assert ("2025-10-16", "model-a") in dates_models
|
|
assert ("2025-10-16", "model-b") in dates_models
|
|
|
|
|
|
def test_create_job_returns_warnings_for_skipped_simulations(temp_db):
|
|
"""Test that skipped simulations are returned as warnings."""
|
|
manager = JobManager(db_path=temp_db)
|
|
|
|
# Create and complete first simulation
|
|
job_id_1 = manager.create_job(
|
|
config_path="test_config.json",
|
|
date_range=["2025-10-15"],
|
|
models=["model-a"]
|
|
)
|
|
manager.update_job_detail_status(job_id_1, "2025-10-15", "model-a", "completed")
|
|
|
|
# Try to create job with same model-day
|
|
result = manager.create_job(
|
|
config_path="test_config.json",
|
|
date_range=["2025-10-15"],
|
|
models=["model-a"]
|
|
)
|
|
|
|
# Result should be a dict with job_id and warnings
|
|
assert isinstance(result, dict)
|
|
assert "job_id" in result
|
|
assert "warnings" in result
|
|
assert len(result["warnings"]) == 1
|
|
assert "model-a" in result["warnings"][0]
|
|
assert "2025-10-15" in result["warnings"][0]
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run:
|
|
```bash
|
|
pytest tests/unit/test_job_manager_duplicate_detection.py -v
|
|
```
|
|
|
|
Expected: FAIL with test errors showing that duplicate simulations are not being detected.
|
|
|
|
**Step 3: Implement duplicate detection in create_job()**
|
|
|
|
Modify `api/job_manager.py:53-131`:
|
|
|
|
```python
|
|
def create_job(
|
|
self,
|
|
config_path: str,
|
|
date_range: List[str],
|
|
models: List[str],
|
|
model_day_filter: Optional[List[tuple]] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Create new simulation job.
|
|
|
|
Args:
|
|
config_path: Path to configuration file
|
|
date_range: List of dates to simulate (YYYY-MM-DD)
|
|
models: List of model signatures to execute
|
|
model_day_filter: Optional list of (model, date) tuples to limit job_details.
|
|
If None, creates job_details for all model-date combinations.
|
|
|
|
Returns:
|
|
Dict with:
|
|
- job_id: UUID of created job
|
|
- warnings: List of warning messages for skipped simulations
|
|
|
|
Raises:
|
|
ValueError: If another job is already running/pending or if all simulations are already completed
|
|
"""
|
|
if not self.can_start_new_job():
|
|
raise ValueError("Another simulation job is already running or pending")
|
|
|
|
job_id = str(uuid.uuid4())
|
|
created_at = datetime.utcnow().isoformat() + "Z"
|
|
|
|
conn = get_db_connection(self.db_path)
|
|
cursor = conn.cursor()
|
|
|
|
try:
|
|
# Determine which model-day pairs to check
|
|
if model_day_filter is not None:
|
|
pairs_to_check = model_day_filter
|
|
else:
|
|
pairs_to_check = [(model, date) for date in date_range for model in models]
|
|
|
|
# Check for already-completed simulations
|
|
skipped_pairs = []
|
|
pending_pairs = []
|
|
|
|
for model, date in pairs_to_check:
|
|
cursor.execute("""
|
|
SELECT COUNT(*)
|
|
FROM job_details
|
|
WHERE model = ? AND date = ? AND status = 'completed'
|
|
""", (model, date))
|
|
|
|
count = cursor.fetchone()[0]
|
|
|
|
if count > 0:
|
|
skipped_pairs.append((model, date))
|
|
logger.info(f"Skipping {model}/{date} - already completed in previous job")
|
|
else:
|
|
pending_pairs.append((model, date))
|
|
|
|
# If all simulations are already completed, raise error
|
|
if len(pending_pairs) == 0:
|
|
warnings = [
|
|
f"Skipped {model}/{date} - already completed"
|
|
for model, date in skipped_pairs
|
|
]
|
|
raise ValueError(
|
|
f"All requested simulations are already completed. "
|
|
f"Skipped {len(skipped_pairs)} model-day pair(s). "
|
|
f"Details: {warnings}"
|
|
)
|
|
|
|
# Insert job
|
|
cursor.execute("""
|
|
INSERT INTO jobs (
|
|
job_id, config_path, status, date_range, models, created_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
""", (
|
|
job_id,
|
|
config_path,
|
|
"pending",
|
|
json.dumps(date_range),
|
|
json.dumps(models),
|
|
created_at
|
|
))
|
|
|
|
# Create job_details only for pending pairs
|
|
for model, date in pending_pairs:
|
|
cursor.execute("""
|
|
INSERT INTO job_details (
|
|
job_id, date, model, status
|
|
)
|
|
VALUES (?, ?, ?, ?)
|
|
""", (job_id, date, model, "pending"))
|
|
|
|
logger.info(f"Created job {job_id} with {len(pending_pairs)} model-day tasks")
|
|
|
|
if skipped_pairs:
|
|
logger.info(f"Skipped {len(skipped_pairs)} already-completed simulations")
|
|
|
|
conn.commit()
|
|
|
|
# Prepare warnings
|
|
warnings = [
|
|
f"Skipped {model}/{date} - already completed"
|
|
for model, date in skipped_pairs
|
|
]
|
|
|
|
return {
|
|
"job_id": job_id,
|
|
"warnings": warnings
|
|
}
|
|
|
|
finally:
|
|
conn.close()
|
|
```
|
|
|
|
**Step 4: Update create_job() return type in docstring**
|
|
|
|
The return type changed from `str` to `Dict[str, Any]`. Search for all callers of `create_job()` and update them to handle the new return format.
|
|
|
|
**Step 5: Run test to verify it passes**
|
|
|
|
Run:
|
|
```bash
|
|
pytest tests/unit/test_job_manager_duplicate_detection.py -v
|
|
```
|
|
|
|
Expected: PASS for all tests.
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add api/job_manager.py tests/unit/test_job_manager_duplicate_detection.py
|
|
git commit -m "feat: add duplicate detection to job creation
|
|
|
|
- Skip already-completed model-day pairs in create_job()
|
|
- Return warnings for skipped simulations
|
|
- Raise error if all simulations are already completed
|
|
- Add comprehensive test coverage"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Fix API Routes to Handle New create_job() Return Type
|
|
|
|
**Files:**
|
|
- Modify: `api/routes/*.py` (any files that call `job_manager.create_job()`)
|
|
|
|
**Step 1: Search for create_job() callers**
|
|
|
|
Run:
|
|
```bash
|
|
grep -r "create_job" api/routes/ --include="*.py" -n
|
|
```
|
|
|
|
Identify all locations that call `job_manager.create_job()`.
|
|
|
|
**Step 2: Update each caller to handle dict return type**
|
|
|
|
For each caller, change from:
|
|
```python
|
|
job_id = job_manager.create_job(...)
|
|
```
|
|
|
|
To:
|
|
```python
|
|
result = job_manager.create_job(...)
|
|
job_id = result["job_id"]
|
|
warnings = result.get("warnings", [])
|
|
|
|
# Log or return warnings if present
|
|
if warnings:
|
|
logger.warning(f"Job {job_id} created with warnings: {warnings}")
|
|
```
|
|
|
|
**Step 3: Run integration tests**
|
|
|
|
Run:
|
|
```bash
|
|
pytest tests/integration/ -v -k job
|
|
```
|
|
|
|
Expected: All job-related integration tests should pass.
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add api/routes/
|
|
git commit -m "fix: update create_job() callers to handle dict return type"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Remove job_id Filter from Position Queries
|
|
|
|
**Files:**
|
|
- Modify: `agent_tools/tool_trade.py:24-95`
|
|
- Test: `tests/unit/test_cross_job_position_continuity.py` (new file)
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
Create `tests/unit/test_cross_job_position_continuity.py`:
|
|
|
|
```python
|
|
"""Test portfolio continuity across multiple jobs."""
|
|
import pytest
|
|
import tempfile
|
|
import os
|
|
from agent_tools.tool_trade import get_current_position_from_db
|
|
from api.database import get_db_connection
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_db():
|
|
"""Create temporary database with schema."""
|
|
fd, path = tempfile.mkstemp(suffix='.db')
|
|
os.close(fd)
|
|
|
|
conn = get_db_connection(path)
|
|
cursor = conn.cursor()
|
|
|
|
# Create trading_days table
|
|
cursor.execute("""
|
|
CREATE TABLE IF NOT EXISTS trading_days (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
job_id TEXT NOT NULL,
|
|
model TEXT NOT NULL,
|
|
date TEXT NOT NULL,
|
|
starting_cash REAL NOT NULL,
|
|
ending_cash REAL NOT NULL,
|
|
profit REAL NOT NULL,
|
|
return_pct REAL NOT NULL,
|
|
portfolio_value REAL NOT NULL,
|
|
reasoning_summary TEXT,
|
|
reasoning_full TEXT,
|
|
completed_at TEXT,
|
|
session_duration_seconds REAL,
|
|
UNIQUE(job_id, model, date)
|
|
)
|
|
""")
|
|
|
|
# Create holdings table
|
|
cursor.execute("""
|
|
CREATE TABLE IF NOT EXISTS holdings (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
trading_day_id INTEGER NOT NULL,
|
|
symbol TEXT NOT NULL,
|
|
quantity INTEGER NOT NULL,
|
|
FOREIGN KEY (trading_day_id) REFERENCES trading_days(id) ON DELETE CASCADE
|
|
)
|
|
""")
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
yield path
|
|
|
|
if os.path.exists(path):
|
|
os.remove(path)
|
|
|
|
|
|
def test_position_continuity_across_jobs(temp_db):
|
|
"""Test that position queries see history from previous jobs."""
|
|
# Insert trading_day from job 1
|
|
conn = get_db_connection(temp_db)
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute("""
|
|
INSERT INTO trading_days (
|
|
job_id, model, date, starting_cash, ending_cash,
|
|
profit, return_pct, portfolio_value, completed_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""", (
|
|
"job-1-uuid",
|
|
"deepseek-chat-v3.1",
|
|
"2025-10-14",
|
|
10000.0,
|
|
5121.52, # Negative cash from buying
|
|
0.0,
|
|
0.0,
|
|
14993.945,
|
|
"2025-11-07T01:52:53Z"
|
|
))
|
|
|
|
trading_day_id = cursor.lastrowid
|
|
|
|
# Insert holdings from job 1
|
|
holdings = [
|
|
("ADBE", 5),
|
|
("AVGO", 5),
|
|
("CRWD", 5),
|
|
("GOOGL", 20),
|
|
("META", 5),
|
|
("MSFT", 5),
|
|
("NVDA", 10)
|
|
]
|
|
|
|
for symbol, quantity in holdings:
|
|
cursor.execute("""
|
|
INSERT INTO holdings (trading_day_id, symbol, quantity)
|
|
VALUES (?, ?, ?)
|
|
""", (trading_day_id, symbol, quantity))
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
# Now query position for job 2 on next trading day
|
|
position, _ = get_current_position_from_db(
|
|
job_id="job-2-uuid", # Different job
|
|
model="deepseek-chat-v3.1",
|
|
date="2025-10-15",
|
|
initial_cash=10000.0
|
|
)
|
|
|
|
# Should see job 1's ending position, NOT initial $10k
|
|
assert position["CASH"] == 5121.52
|
|
assert position["ADBE"] == 5
|
|
assert position["AVGO"] == 5
|
|
assert position["CRWD"] == 5
|
|
assert position["GOOGL"] == 20
|
|
assert position["META"] == 5
|
|
assert position["MSFT"] == 5
|
|
assert position["NVDA"] == 10
|
|
|
|
|
|
def test_position_returns_initial_state_for_first_day(temp_db):
|
|
"""Test that first trading day returns initial cash."""
|
|
# No previous trading days exist
|
|
position, _ = get_current_position_from_db(
|
|
job_id="new-job-uuid",
|
|
model="new-model",
|
|
date="2025-10-13",
|
|
initial_cash=10000.0
|
|
)
|
|
|
|
# Should return initial position
|
|
assert position == {"CASH": 10000.0}
|
|
|
|
|
|
def test_position_uses_most_recent_prior_date(temp_db):
|
|
"""Test that position query uses the most recent date before current."""
|
|
conn = get_db_connection(temp_db)
|
|
cursor = conn.cursor()
|
|
|
|
# Insert two trading days
|
|
cursor.execute("""
|
|
INSERT INTO trading_days (
|
|
job_id, model, date, starting_cash, ending_cash,
|
|
profit, return_pct, portfolio_value, completed_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""", (
|
|
"job-1",
|
|
"model-a",
|
|
"2025-10-13",
|
|
10000.0,
|
|
9500.0,
|
|
-500.0,
|
|
-5.0,
|
|
9500.0,
|
|
"2025-11-07T01:00:00Z"
|
|
))
|
|
|
|
cursor.execute("""
|
|
INSERT INTO trading_days (
|
|
job_id, model, date, starting_cash, ending_cash,
|
|
profit, return_pct, portfolio_value, completed_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""", (
|
|
"job-2",
|
|
"model-a",
|
|
"2025-10-14",
|
|
9500.0,
|
|
12000.0,
|
|
2500.0,
|
|
26.3,
|
|
12000.0,
|
|
"2025-11-07T02:00:00Z"
|
|
))
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
# Query for 2025-10-15 should use 2025-10-14's ending position
|
|
position, _ = get_current_position_from_db(
|
|
job_id="job-3",
|
|
model="model-a",
|
|
date="2025-10-15",
|
|
initial_cash=10000.0
|
|
)
|
|
|
|
assert position["CASH"] == 12000.0 # From 2025-10-14, not 2025-10-13
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run:
|
|
```bash
|
|
pytest tests/unit/test_cross_job_position_continuity.py::test_position_continuity_across_jobs -v
|
|
```
|
|
|
|
Expected: FAIL because current code filters by job_id and can't see previous job's data.
|
|
|
|
**Step 3: Remove job_id filter from get_current_position_from_db()**
|
|
|
|
Modify `agent_tools/tool_trade.py:54-60`:
|
|
|
|
```python
|
|
def get_current_position_from_db(
|
|
job_id: str,
|
|
model: str,
|
|
date: str,
|
|
initial_cash: float = 10000.0
|
|
) -> Tuple[Dict[str, float], int]:
|
|
"""
|
|
Get starting position for current trading day from database (new schema).
|
|
|
|
Queries most recent trading_day record BEFORE the given date (previous day's ending).
|
|
Returns ending holdings and cash from that previous day, which becomes the
|
|
starting position for the current day.
|
|
|
|
NOTE: Searches across ALL jobs for the given model, enabling portfolio continuity
|
|
even when new jobs are created with overlapping date ranges.
|
|
|
|
Args:
|
|
job_id: Job UUID (kept for compatibility but not used in query)
|
|
model: Model signature
|
|
date: Current trading date (will query for date < this)
|
|
initial_cash: Initial cash if no prior data (first trading day)
|
|
|
|
Returns:
|
|
(position_dict, action_count) where:
|
|
- position_dict: {"AAPL": 10, "MSFT": 5, "CASH": 8500.0}
|
|
- action_count: Number of holdings (for action_id tracking)
|
|
"""
|
|
db_path = get_db_path("data/jobs.db")
|
|
conn = get_db_connection(db_path)
|
|
cursor = conn.cursor()
|
|
|
|
try:
|
|
# Query most recent trading_day BEFORE current date (previous day's ending position)
|
|
# NOTE: Removed job_id filter to enable cross-job continuity
|
|
cursor.execute("""
|
|
SELECT id, ending_cash
|
|
FROM trading_days
|
|
WHERE model = ? AND date < ?
|
|
ORDER BY date DESC
|
|
LIMIT 1
|
|
""", (model, date))
|
|
|
|
row = cursor.fetchone()
|
|
|
|
if row is None:
|
|
# First day - return initial position
|
|
return {"CASH": initial_cash}, 0
|
|
|
|
trading_day_id, ending_cash = row
|
|
|
|
# Query holdings for that day
|
|
cursor.execute("""
|
|
SELECT symbol, quantity
|
|
FROM holdings
|
|
WHERE trading_day_id = ?
|
|
""", (trading_day_id,))
|
|
|
|
holdings_rows = cursor.fetchall()
|
|
|
|
# Build position dict
|
|
position = {"CASH": ending_cash}
|
|
for symbol, quantity in holdings_rows:
|
|
position[symbol] = quantity
|
|
|
|
return position, len(holdings_rows)
|
|
|
|
finally:
|
|
conn.close()
|
|
```
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run:
|
|
```bash
|
|
pytest tests/unit/test_cross_job_position_continuity.py -v
|
|
```
|
|
|
|
Expected: PASS for all tests.
|
|
|
|
**Step 5: Run existing position query tests**
|
|
|
|
Run:
|
|
```bash
|
|
pytest tests/unit/test_get_position_new_schema.py -v
|
|
```
|
|
|
|
Expected: PASS (existing behavior should still work).
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add agent_tools/tool_trade.py tests/unit/test_cross_job_position_continuity.py
|
|
git commit -m "fix: enable cross-job portfolio continuity
|
|
|
|
- Remove job_id filter from get_current_position_from_db()
|
|
- Position queries now search across all jobs for the model
|
|
- Prevents portfolio reset when new jobs run overlapping dates
|
|
- Add test coverage for cross-job position continuity"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Add Integration Test for Complete Bug Fix
|
|
|
|
**Files:**
|
|
- Test: `tests/integration/test_duplicate_simulation_prevention.py` (new file)
|
|
|
|
**Step 1: Write integration test**
|
|
|
|
Create `tests/integration/test_duplicate_simulation_prevention.py`:
|
|
|
|
```python
|
|
"""Integration test for duplicate simulation prevention."""
|
|
import pytest
|
|
import tempfile
|
|
import os
|
|
import json
|
|
from pathlib import Path
|
|
from api.job_manager import JobManager
|
|
from api.model_day_executor import ModelDayExecutor
|
|
from api.database import get_db_connection
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_env(tmp_path):
|
|
"""Create temporary environment with db and config."""
|
|
# Create temp database
|
|
db_path = str(tmp_path / "test_jobs.db")
|
|
|
|
# Initialize database
|
|
conn = get_db_connection(db_path)
|
|
cursor = conn.cursor()
|
|
|
|
# Create schema
|
|
cursor.execute("""
|
|
CREATE TABLE IF NOT EXISTS jobs (
|
|
job_id TEXT PRIMARY KEY,
|
|
config_path TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
date_range TEXT NOT NULL,
|
|
models TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
started_at TEXT,
|
|
updated_at TEXT,
|
|
completed_at TEXT,
|
|
total_duration_seconds REAL,
|
|
error TEXT,
|
|
warnings TEXT
|
|
)
|
|
""")
|
|
|
|
cursor.execute("""
|
|
CREATE TABLE IF NOT EXISTS job_details (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
job_id TEXT NOT NULL,
|
|
date TEXT NOT NULL,
|
|
model TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
started_at TEXT,
|
|
completed_at TEXT,
|
|
duration_seconds REAL,
|
|
error TEXT,
|
|
FOREIGN KEY (job_id) REFERENCES jobs(job_id) ON DELETE CASCADE,
|
|
UNIQUE(job_id, date, model)
|
|
)
|
|
""")
|
|
|
|
cursor.execute("""
|
|
CREATE TABLE IF NOT EXISTS trading_days (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
job_id TEXT NOT NULL,
|
|
model TEXT NOT NULL,
|
|
date TEXT NOT NULL,
|
|
starting_cash REAL NOT NULL,
|
|
ending_cash REAL NOT NULL,
|
|
profit REAL NOT NULL,
|
|
return_pct REAL NOT NULL,
|
|
portfolio_value REAL NOT NULL,
|
|
reasoning_summary TEXT,
|
|
reasoning_full TEXT,
|
|
completed_at TEXT,
|
|
session_duration_seconds REAL,
|
|
UNIQUE(job_id, model, date)
|
|
)
|
|
""")
|
|
|
|
cursor.execute("""
|
|
CREATE TABLE IF NOT EXISTS holdings (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
trading_day_id INTEGER NOT NULL,
|
|
symbol TEXT NOT NULL,
|
|
quantity INTEGER NOT NULL,
|
|
FOREIGN KEY (trading_day_id) REFERENCES trading_days(id) ON DELETE CASCADE
|
|
)
|
|
""")
|
|
|
|
cursor.execute("""
|
|
CREATE TABLE IF NOT EXISTS actions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
trading_day_id INTEGER NOT NULL,
|
|
action_type TEXT NOT NULL,
|
|
symbol TEXT NOT NULL,
|
|
quantity INTEGER NOT NULL,
|
|
price REAL NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
FOREIGN KEY (trading_day_id) REFERENCES trading_days(id) ON DELETE CASCADE
|
|
)
|
|
""")
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
# Create mock config
|
|
config_path = str(tmp_path / "test_config.json")
|
|
config = {
|
|
"models": [
|
|
{
|
|
"signature": "test-model",
|
|
"basemodel": "mock/model",
|
|
"enabled": True
|
|
}
|
|
],
|
|
"agent_config": {
|
|
"max_steps": 10,
|
|
"initial_cash": 10000.0
|
|
},
|
|
"log_config": {
|
|
"log_path": str(tmp_path / "logs")
|
|
},
|
|
"date_range": {
|
|
"init_date": "2025-10-13"
|
|
}
|
|
}
|
|
|
|
with open(config_path, 'w') as f:
|
|
json.dump(config, f)
|
|
|
|
yield {
|
|
"db_path": db_path,
|
|
"config_path": config_path,
|
|
"data_dir": str(tmp_path)
|
|
}
|
|
|
|
|
|
def test_duplicate_simulation_is_skipped(temp_env):
|
|
"""Test that overlapping job skips already-completed simulation."""
|
|
manager = JobManager(db_path=temp_env["db_path"])
|
|
|
|
# Create first job
|
|
result_1 = manager.create_job(
|
|
config_path=temp_env["config_path"],
|
|
date_range=["2025-10-15"],
|
|
models=["test-model"]
|
|
)
|
|
job_id_1 = result_1["job_id"]
|
|
|
|
# Simulate completion by manually inserting trading_day record
|
|
conn = get_db_connection(temp_env["db_path"])
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute("""
|
|
INSERT INTO trading_days (
|
|
job_id, model, date, starting_cash, ending_cash,
|
|
profit, return_pct, portfolio_value, completed_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""", (
|
|
job_id_1,
|
|
"test-model",
|
|
"2025-10-15",
|
|
10000.0,
|
|
9500.0,
|
|
-500.0,
|
|
-5.0,
|
|
9500.0,
|
|
"2025-11-07T01:00:00Z"
|
|
))
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
# Mark job_detail as completed
|
|
manager.update_job_detail_status(
|
|
job_id_1,
|
|
"2025-10-15",
|
|
"test-model",
|
|
"completed"
|
|
)
|
|
|
|
# Try to create second job with same model-day
|
|
result_2 = manager.create_job(
|
|
config_path=temp_env["config_path"],
|
|
date_range=["2025-10-15", "2025-10-16"],
|
|
models=["test-model"]
|
|
)
|
|
|
|
# Should have warnings about skipped simulation
|
|
assert len(result_2["warnings"]) == 1
|
|
assert "2025-10-15" in result_2["warnings"][0]
|
|
|
|
# Should only create job_detail for 2025-10-16
|
|
details = manager.get_job_details(result_2["job_id"])
|
|
assert len(details) == 1
|
|
assert details[0]["date"] == "2025-10-16"
|
|
|
|
|
|
def test_portfolio_continues_from_previous_job(temp_env):
|
|
"""Test that new job continues portfolio from previous job's last day."""
|
|
manager = JobManager(db_path=temp_env["db_path"])
|
|
|
|
# Create and complete first job
|
|
result_1 = manager.create_job(
|
|
config_path=temp_env["config_path"],
|
|
date_range=["2025-10-13"],
|
|
models=["test-model"]
|
|
)
|
|
job_id_1 = result_1["job_id"]
|
|
|
|
# Insert completed trading_day with holdings
|
|
conn = get_db_connection(temp_env["db_path"])
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute("""
|
|
INSERT INTO trading_days (
|
|
job_id, model, date, starting_cash, ending_cash,
|
|
profit, return_pct, portfolio_value, completed_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""", (
|
|
job_id_1,
|
|
"test-model",
|
|
"2025-10-13",
|
|
10000.0,
|
|
5000.0,
|
|
0.0,
|
|
0.0,
|
|
15000.0,
|
|
"2025-11-07T01:00:00Z"
|
|
))
|
|
|
|
trading_day_id = cursor.lastrowid
|
|
|
|
cursor.execute("""
|
|
INSERT INTO holdings (trading_day_id, symbol, quantity)
|
|
VALUES (?, ?, ?)
|
|
""", (trading_day_id, "AAPL", 10))
|
|
|
|
conn.commit()
|
|
|
|
# Mark as completed
|
|
manager.update_job_detail_status(job_id_1, "2025-10-13", "test-model", "completed")
|
|
manager.update_job_status(job_id_1, "completed")
|
|
|
|
# Create second job for next day
|
|
result_2 = manager.create_job(
|
|
config_path=temp_env["config_path"],
|
|
date_range=["2025-10-14"],
|
|
models=["test-model"]
|
|
)
|
|
job_id_2 = result_2["job_id"]
|
|
|
|
# Get starting position for 2025-10-14
|
|
from agent_tools.tool_trade import get_current_position_from_db
|
|
position, _ = get_current_position_from_db(
|
|
job_id=job_id_2,
|
|
model="test-model",
|
|
date="2025-10-14",
|
|
initial_cash=10000.0
|
|
)
|
|
|
|
# Should continue from job 1's ending position
|
|
assert position["CASH"] == 5000.0
|
|
assert position["AAPL"] == 10
|
|
|
|
conn.close()
|
|
```
|
|
|
|
**Step 2: Run integration test**
|
|
|
|
Run:
|
|
```bash
|
|
pytest tests/integration/test_duplicate_simulation_prevention.py -v
|
|
```
|
|
|
|
Expected: PASS for both tests.
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add tests/integration/test_duplicate_simulation_prevention.py
|
|
git commit -m "test: add integration tests for duplicate prevention and cross-job continuity"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Update Documentation
|
|
|
|
**Files:**
|
|
- Modify: `CLAUDE.md` (add section about duplicate prevention)
|
|
- Modify: `docs/developer/architecture.md` (document cross-job continuity)
|
|
|
|
**Step 1: Update CLAUDE.md**
|
|
|
|
Add section after "Job System" section:
|
|
|
|
```markdown
|
|
### Duplicate Simulation Prevention
|
|
|
|
**Automatic Skip Logic:**
|
|
- `JobManager.create_job()` checks database for already-completed model-day pairs
|
|
- Skips completed simulations automatically
|
|
- Returns warnings list with skipped pairs
|
|
- Raises `ValueError` if all requested simulations are already completed
|
|
|
|
**Example:**
|
|
```python
|
|
result = job_manager.create_job(
|
|
config_path="config.json",
|
|
date_range=["2025-10-15", "2025-10-16"],
|
|
models=["model-a"],
|
|
model_day_filter=[("model-a", "2025-10-15")] # Already completed
|
|
)
|
|
|
|
# result = {
|
|
# "job_id": "new-job-uuid",
|
|
# "warnings": ["Skipped model-a/2025-10-15 - already completed"]
|
|
# }
|
|
```
|
|
|
|
**Cross-Job Portfolio Continuity:**
|
|
- `get_current_position_from_db()` queries across ALL jobs for a given model
|
|
- Enables portfolio continuity even when new jobs are created with overlapping dates
|
|
- Starting position = most recent trading_day.ending_cash + holdings where date < current_date
|
|
```
|
|
|
|
**Step 2: Update architecture.md**
|
|
|
|
Add section about position tracking:
|
|
|
|
```markdown
|
|
### Position Tracking Across Jobs
|
|
|
|
**Design:** Portfolio state is tracked per-model across all jobs, not per-job.
|
|
|
|
**Query Logic:**
|
|
```python
|
|
# Get starting position for current trading day
|
|
SELECT id, ending_cash FROM trading_days
|
|
WHERE model = ? AND date < ? # No job_id filter
|
|
ORDER BY date DESC
|
|
LIMIT 1
|
|
```
|
|
|
|
**Benefits:**
|
|
- Portfolio continuity when creating new jobs with overlapping dates
|
|
- Prevents accidental portfolio resets
|
|
- Enables flexible job scheduling (resume, rerun, backfill)
|
|
|
|
**Example:**
|
|
- Job 1: Runs 2025-10-13 to 2025-10-15 for model-a
|
|
- Job 2: Runs 2025-10-16 to 2025-10-20 for model-a
|
|
- Job 2 starts with Job 1's ending position from 2025-10-15
|
|
```
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add CLAUDE.md docs/developer/architecture.md
|
|
git commit -m "docs: document duplicate prevention and cross-job continuity"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Run Full Test Suite
|
|
|
|
**Step 1: Run all unit tests**
|
|
|
|
Run:
|
|
```bash
|
|
bash scripts/run_tests.sh -t unit
|
|
```
|
|
|
|
Expected: All unit tests pass.
|
|
|
|
**Step 2: Run all integration tests**
|
|
|
|
Run:
|
|
```bash
|
|
bash scripts/run_tests.sh -t integration
|
|
```
|
|
|
|
Expected: All integration tests pass.
|
|
|
|
**Step 3: Run full test suite with coverage**
|
|
|
|
Run:
|
|
```bash
|
|
bash scripts/run_tests.sh
|
|
```
|
|
|
|
Expected: All tests pass, coverage >= 85%.
|
|
|
|
**Step 4: Commit if any fixes were needed**
|
|
|
|
If any tests failed and required fixes:
|
|
```bash
|
|
git add <fixed-files>
|
|
git commit -m "fix: address test failures from bug fix"
|
|
```
|
|
|
|
---
|
|
|
|
## Verification Checklist
|
|
|
|
After completing all tasks, verify:
|
|
|
|
- [ ] Unit tests pass for duplicate detection
|
|
- [ ] Unit tests pass for cross-job position continuity
|
|
- [ ] Integration tests pass for end-to-end scenarios
|
|
- [ ] All existing tests still pass
|
|
- [ ] Documentation updated with new behavior
|
|
- [ ] Code coverage >= 85%
|
|
- [ ] No regressions in job creation API
|
|
- [ ] Position queries work correctly for first trading day
|
|
- [ ] Position queries work correctly across job boundaries
|
|
- [ ] Warnings are properly logged and returned to API consumers
|
|
|
|
---
|
|
|
|
## Notes
|
|
|
|
**Design Decisions:**
|
|
|
|
1. **Skip vs Reject:** Chose to skip completed simulations automatically rather than rejecting the entire job. This provides better UX for overlapping date ranges.
|
|
|
|
2. **Cross-Job Queries:** Removed job_id filter from position queries to enable true portfolio continuity. This assumes model signatures are unique identifiers across all jobs.
|
|
|
|
3. **Warnings in Response:** Return warnings in create_job() response so API consumers can display skipped simulations to users.
|
|
|
|
**Edge Cases Handled:**
|
|
|
|
- All simulations already completed → Raise ValueError
|
|
- First trading day with no prior data → Return initial_cash
|
|
- Multiple jobs for same model → Use most recent completed date
|
|
- Partial job completion → Only skip fully completed model-days
|
|
|
|
**Future Enhancements:**
|
|
|
|
- Add `force_rerun` parameter to create_job() to override skip logic
|
|
- Add endpoint to query completion status before creating job
|
|
- Consider adding model-level position snapshots for faster queries
|