Compare commits

...

11 Commits

Author SHA1 Message Date
1095798320 docs: finalize v0.3.0 changelog for release
Consolidated all unreleased changes into v0.3.0 release dated 2025-11-03.

Key additions:
- Development Mode with mock AI provider
- Config Override System for Docker
- Async Price Download (non-blocking)
- Resume Mode (idempotent execution)
- Reasoning Logs API (GET /reasoning)
- Project rebrand to AI-Trader-Server

Includes comprehensive bug fixes for context injection, simulation
re-runs, database reliability, and configuration handling.
2025-11-03 00:19:12 -05:00
e590cdc13b fix: prevent already-completed simulations from re-running
Previously, when re-running a job with some model-days already completed:
- _prepare_data() marked them as "skipped" with error="Already completed"
- But _execute_date() didn't check the skip list before launching executors
- ModelDayExecutor would start, change status to "running", and never complete
- Job would hang with status="running" and pending count > 0

Fixed by:
- _prepare_data() now returns completion_skips: {model: {dates}}
- _execute_date() receives completion_skips and filters out already-completed models
- Skipped model-days are not submitted to ThreadPoolExecutor
- Job completes correctly, skipped model-days remain with status="skipped"

This ensures idempotent job behavior - re-running a job only executes
model-days that haven't completed yet.

Fixes #73
2025-11-03 00:03:57 -05:00
c74747d1d4 fix: revert **kwargs approach - FastMCP doesn't support it
Root cause: FastMCP uses inspect module to generate tool schemas from function
signatures. **kwargs prevents FastMCP from determining parameter types, causing
tool registration to fail.

Fix: Keep explicit parameters with defaults (signature=None, today_date=None, etc.)
but document in docstring that they are auto-injected.

This preserves:
- ContextInjector always overrides values (defense-in-depth from v0.3.0-alpha.40)
- FastMCP can generate proper tool schema
- Parameters visible to AI, but with clear documentation they're automatic

Trade-off: AI can still see the parameters, but documentation instructs not to provide them.
Combined with ContextInjector override, AI-provided values are ignored anyway.

Fixes TradeTools service crash on startup.
2025-11-02 23:41:00 -05:00
96f6b78a93 refactor: hide context parameters from AI model tool schema
Prevent AI hallucination of runtime parameters by hiding them from the tool schema.

Architecture:
- Public tool functions (buy/sell) only expose symbol and amount to AI
- Use **kwargs to accept hidden parameters (signature, job_id, today_date, session_id)
- Internal _impl functions contain the actual business logic
- ContextInjector injects parameters into kwargs (invisible to AI)

Benefits:
- AI cannot see or hallucinate signature/job_id/session_id parameters
- Cleaner tool schema focuses on trading-relevant parameters only
- Defense-in-depth: ContextInjector still overrides any provided values
- More maintainable: clear separation of public API vs internal implementation

Example AI sees:
  buy(symbol: str, amount: int) -> dict

Actual execution:
  buy(symbol="AAPL", amount=10, signature="gpt-5", job_id="...", ...)

Fixes #TBD
2025-11-02 23:34:07 -05:00
6c395f740d fix: always override context parameters in ContextInjector
Root cause: AI models were hallucinating signature/job_id/today_date values
and passing them in tool calls. The ContextInjector was checking
"if param not in request.args" before injecting, which failed when AI
provided (incorrect) values.

Fix: Always override context parameters, never trust AI-provided values.

Evidence from logs:
- ContextInjector had correct values (self.signature=gpt-5, job_id=6dabd9e6...)
- But AI was passing signature=None or hallucinated values like "fundamental-bot-v1"
- After injection, args showed the AI's (wrong) values, not the interceptor's

This ensures runtime context is ALWAYS injected regardless of what the AI sends.

Fixes #TBD
2025-11-02 23:30:49 -05:00
618943b278 debug: add self attribute logging to ContextInjector.__call__
Log ContextInjector instance ID and attribute values at entry to __call__()
to diagnose why attributes appear as None during tool invocation despite
being set correctly during set_context().

This will reveal whether:
- Multiple ContextInjector instances exist
- Attributes are being overwritten/cleared
- Wrong instance is being invoked
2025-11-02 23:17:52 -05:00
1c19eea29a debug: add comprehensive diagnostic logging for ContextInjector flow
Add instrumentation at component boundaries to trace where ContextInjector values become None:
- ModelDayExecutor: Log ContextInjector creation and set_context() invocation
- BaseAgent.set_context(): Log entry, client creation, tool reload, completion
- Includes object IDs to verify instance identity across boundaries

Part of systematic debugging investigation for issue #TBD.
2025-11-02 23:05:40 -05:00
e968434062 fix: reload tools after context injection and prevent database locking
Critical fixes for ContextInjector and database concurrency:

1. ContextInjector Not Working:
   - Made set_context() async to reload tools after recreating MCP client
   - Tools from old client (without interceptor) were still being used
   - Now tools are reloaded from new client with interceptor active
   - This ensures buy/sell calls properly receive injected parameters

2. Database Locking:
   - Closed main connection before _write_results_to_db() opens new one
   - SQLite doesn't handle concurrent write connections well
   - Prevents "database is locked" error during position writes

Changes:
- agent/base_agent/base_agent.py:
  - async def set_context() instead of def set_context()
  - Added: self.tools = await self.client.get_tools()
- api/model_day_executor.py:
  - await agent.set_context(context_injector)
  - conn.close() before _write_results_to_db()

Root Cause:
When recreating the MCP client with tool_interceptors, the old tools
were still cached in self.tools and being passed to the AI agent.
The interceptor was never invoked, so job_id/signature/date were missing.
2025-11-02 22:42:17 -05:00
4c1d23a7c8 fix: correct get_db_path() usage to pass base database path
The get_db_path() function requires a base_db_path argument
to properly resolve PROD vs DEV database paths. Updated all
calls to pass "data/jobs.db" as the base path.

Changes:
- agent_tools/tool_trade.py: Fix 3 occurrences (lines 33, 113, 236)
- tools/price_tools.py: Fix 2 occurrences in new database functions
- Remove unused get_db_path import from tool_trade.py

This fixes TypeError when running simulations:
  get_db_path() missing 1 required positional argument: 'base_db_path'

The get_db_connection() function internally calls get_db_path()
to resolve the correct database path based on DEPLOYMENT_MODE.
2025-11-02 22:26:45 -05:00
027b4bd8e4 refactor: implement database-only position tracking with lazy context injection
This commit migrates the system to database-only position storage,
eliminating file-based position.jsonl dependencies and fixing
ContextInjector initialization timing issues.

Key Changes:

1. ContextInjector Lifecycle Refactor:
   - Remove ContextInjector creation from BaseAgent.__init__()
   - Add BaseAgent.set_context() method for post-initialization injection
   - Update ModelDayExecutor to create ContextInjector with correct trading day date
   - Ensures ContextInjector receives actual trading date instead of init_date
   - Includes session_id injection for proper database linking

2. Database Position Functions:
   - Implement get_today_init_position_from_db() for querying previous positions
   - Implement add_no_trade_record_to_db() for no-trade day handling
   - Both functions query SQLite directly (positions + holdings tables)
   - Handle first trading day case with initial cash return
   - Include comprehensive error handling and logging

3. System Integration:
   - Update get_agent_system_prompt() to use database queries
   - Update _handle_trading_result() to write no-trade records to database
   - Remove dependencies on position.jsonl file reading/writing
   - Use deployment_config for automatic prod/dev database resolution

Data Flow:
- ModelDayExecutor creates runtime config and trading session
- Agent initialized without context
- ContextInjector created with (signature, date, job_id, session_id)
- Context injected via set_context()
- System prompt queries database for yesterday's position
- Trade tools write directly to database
- No-trade handler creates database records

Fixes:
- ContextInjector no longer receives None values
- No FileNotFoundError for missing position.jsonl files
- Database is single source of truth for position tracking
- Session linking maintained across all position records

Design: docs/plans/2025-02-11-database-position-tracking-design.md
2025-11-02 22:20:01 -05:00
7a734d265b docs: add database-only position tracking design
Created comprehensive design document addressing:
- ContextInjector initialization timing issues
- Migration from file-based to database-only position tracking
- Complete data flow and integration strategy
- Testing and validation approach

Design resolves two critical simulation failures:
1. ContextInjector receiving None values for trade tool parameters
2. FileNotFoundError when accessing position.jsonl files

Ready for implementation.
2025-11-02 22:11:00 -05:00
9 changed files with 889 additions and 71 deletions

View File

@@ -7,13 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- **Dev Mode Warning in Docker** - DEV mode startup warning now displays correctly in Docker logs
- Added FastAPI `@app.on_event("startup")` handler to trigger warning on API server startup
- Previously only appeared when running `python api/main.py` directly (not via uvicorn)
- Docker compose now includes `DEPLOYMENT_MODE` and `PRESERVE_DEV_DATA` environment variables
## [0.3.0] - 2025-11-03
## [0.3.0] - 2025-10-31
### Added - Development & Testing Features
- **Development Mode** - Mock AI provider for cost-free testing
- `DEPLOYMENT_MODE=DEV` enables mock AI responses with deterministic stock rotation
- Isolated dev database (`trading_dev.db`) separate from production data
- `PRESERVE_DEV_DATA=true` option to prevent dev database reset on startup
- No AI API costs during development and testing
- All API responses include `deployment_mode` field
- Startup warning displayed when running in DEV mode
- **Config Override System** - Docker configuration merging
- Place custom configs in `user-configs/` directory
- Startup merges user config with default config
- Comprehensive validation with clear error messages
- Volume mount: `./user-configs:/app/user-configs`
### Added - Enhanced API Features
- **Async Price Download** - Non-blocking data preparation
- `POST /simulate/trigger` no longer blocks on price downloads
- New job status: `downloading_data` during data preparation
- Warnings field in status response for download issues
- Better user experience for large date ranges
- **Resume Mode** - Idempotent simulation execution
- Jobs automatically skip already-completed model-days
- Safe to re-run jobs without duplicating work
- `status="skipped"` for already-completed executions
- Error-free job completion when partial results exist
- **Reasoning Logs API** - Access AI decision-making history
- `GET /reasoning` endpoint for querying reasoning logs
- Filter by job_id, model_name, date, include_full_conversation
- Includes conversation history and tool usage
- Database-only storage (no JSONL files)
- AI-powered summary generation for reasoning sessions
- **Job Skip Status** - Enhanced job status tracking
- New status: `skipped` for already-completed model-days
- Better differentiation between pending, running, and skipped
- Accurate job completion detection
### Added - Price Data Management & On-Demand Downloads
- **SQLite Price Data Storage** - Replaced JSONL files with relational database
@@ -83,13 +113,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Windmill integration patterns and examples
### Changed
- **Project Rebrand** - AI-Trader renamed to AI-Trader-Server
- Updated all documentation for new project name
- Updated Docker images to ghcr.io/xe138/ai-trader-server
- Updated GitHub Actions workflows
- Updated README, CHANGELOG, and all user guides
- **Architecture** - Transformed from batch-only to API-first service with database persistence
- **Data Storage** - Migrated from JSONL files to SQLite relational database
- Price data now stored in `price_data` table instead of `merged.jsonl`
- Tools/price_tools.py updated to query database
- Position data remains in database (already migrated in earlier versions)
- Position data fully migrated to database-only storage (removed JSONL dependencies)
- Trade tools now read/write from database tables with lazy context injection
- **Deployment** - Simplified to single API-only Docker service (REST API is new in v0.3.0)
- **Logging** - Removed duplicate MCP service log files for cleaner output
- **Configuration** - Simplified environment variable configuration
- **Added:** `DEPLOYMENT_MODE` (PROD/DEV) for environment control
- **Added:** `PRESERVE_DEV_DATA` (default: false) to keep dev data between runs
- **Added:** `AUTO_DOWNLOAD_PRICE_DATA` (default: true) - Enable on-demand downloads
- **Added:** `MAX_SIMULATION_DAYS` (default: 30) - Maximum date range size
- **Added:** `API_PORT` for host port mapping (default: 8080, customizable for port conflicts)
@@ -137,6 +176,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Monitoring** - Health checks and status tracking
- **Persistence** - SQLite database survives container restarts
### Fixed
- **Context Injection** - Runtime parameters correctly injected into MCP tools
- ContextInjector always overrides AI-provided parameters (defense-in-depth)
- Hidden context parameters from AI tool schema to prevent hallucination
- Resolved database locking issues with concurrent tool calls
- Proper async handling of tool reloading after context injection
- **Simulation Re-runs** - Prevent duplicate execution of completed model-days
- Fixed job hanging when re-running partially completed simulations
- `_execute_date()` now skips already-completed model-days
- Job completion status correctly reflects skipped items
- **Agent Initialization** - Correct parameter passing in API mode
- Fixed BaseAgent initialization parameters in ModelDayExecutor
- Resolved async execution and position storage issues
- **Database Reliability** - Various improvements for concurrent access
- Fixed column existence checks before creating indexes
- Proper database path resolution in dev mode (prevents recursive _dev suffix)
- Module-level database initialization for uvicorn reliability
- Fixed database locking during concurrent writes
- Improved error handling in buy/sell functions
- **Configuration** - Improved config handling
- Use enabled field from config to determine which models run
- Use config models when empty models list provided
- Correct handling of merged runtime configs in containers
- Proper get_db_path() usage to pass base database path
- **Docker** - Various deployment improvements
- Removed non-existent data scripts from Dockerfile
- Proper respect for dev mode in entrypoint database initialization
- Correct closure usage to capture db_path in lifespan context manager
### Breaking Changes
- **Batch Mode Removed** - All simulations now run through REST API
- v0.2.0 used sequential batch execution via Docker entrypoint
@@ -147,7 +215,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `merged.jsonl` no longer used (replaced by `price_data` table)
- Automatic on-demand downloads eliminate need for manual data fetching
- **Configuration Variables Changed**
- Added: `AUTO_DOWNLOAD_PRICE_DATA`, `MAX_SIMULATION_DAYS`, `API_PORT`
- Added: `DEPLOYMENT_MODE`, `PRESERVE_DEV_DATA`, `AUTO_DOWNLOAD_PRICE_DATA`, `MAX_SIMULATION_DAYS`, `API_PORT`
- Removed: `RUNTIME_ENV_PATH`, MCP service ports, `WEB_HTTP_PORT`
- MCP services now use fixed internal ports (not exposed to host)

View File

@@ -173,21 +173,13 @@ class BaseAgent:
print("⚠️ OpenAI base URL not set, using default")
try:
# Get job_id from runtime config if available (API mode)
from tools.general_tools import get_config_value
job_id = get_config_value("JOB_ID") # Returns None if not in API mode
# Context injector will be set later via set_context() method
self.context_injector = None
# Create context injector for injecting signature and today_date into tool calls
self.context_injector = ContextInjector(
signature=self.signature,
today_date=self.init_date, # Will be updated per trading session
job_id=job_id # Will be None in standalone mode, populated in API mode
)
# Create MCP client with interceptor
# Create MCP client without interceptors initially
self.client = MultiServerMCPClient(
self.mcp_config,
tool_interceptors=[self.context_injector]
tool_interceptors=[]
)
# Get tools
@@ -229,6 +221,40 @@ class BaseAgent:
print(f"✅ Agent {self.signature} initialization completed")
async def set_context(self, context_injector: "ContextInjector") -> None:
"""
Inject ContextInjector after initialization.
This allows the ContextInjector to be created with the correct
trading day date and session_id after the agent is initialized.
Args:
context_injector: Configured ContextInjector instance with
correct signature, today_date, job_id, session_id
"""
print(f"[DEBUG] set_context() ENTRY: Received context_injector with signature={context_injector.signature}, date={context_injector.today_date}, job_id={context_injector.job_id}, session_id={context_injector.session_id}")
self.context_injector = context_injector
print(f"[DEBUG] set_context(): Set self.context_injector, id={id(self.context_injector)}")
# Recreate MCP client with the interceptor
# Note: We need to recreate because MultiServerMCPClient doesn't have add_interceptor()
print(f"[DEBUG] set_context(): Creating new MCP client with interceptor, id={id(context_injector)}")
self.client = MultiServerMCPClient(
self.mcp_config,
tool_interceptors=[context_injector]
)
print(f"[DEBUG] set_context(): MCP client created")
# CRITICAL: Reload tools from new client so they use the interceptor
print(f"[DEBUG] set_context(): Reloading tools...")
self.tools = await self.client.get_tools()
print(f"[DEBUG] set_context(): Tools reloaded, count={len(self.tools)}")
print(f"✅ Context injected: signature={context_injector.signature}, "
f"date={context_injector.today_date}, job_id={context_injector.job_id}, "
f"session_id={context_injector.session_id}")
def _capture_message(self, role: str, content: str, tool_name: str = None, tool_input: str = None) -> None:
"""
Capture a message in conversation history.
@@ -429,18 +455,32 @@ Summary:"""
await self._handle_trading_result(today_date)
async def _handle_trading_result(self, today_date: str) -> None:
"""Handle trading results"""
"""Handle trading results with database writes."""
from tools.price_tools import add_no_trade_record_to_db
if_trade = get_config_value("IF_TRADE")
if if_trade:
write_config_value("IF_TRADE", False)
print("✅ Trading completed")
else:
print("📊 No trading, maintaining positions")
try:
add_no_trade_record(today_date, self.signature)
except NameError as e:
print(f"❌ NameError: {e}")
raise
# Get context from runtime config
job_id = get_config_value("JOB_ID")
session_id = self.context_injector.session_id if self.context_injector else None
if not job_id or not session_id:
raise ValueError("Missing JOB_ID or session_id for no-trade record")
# Write no-trade record to database
add_no_trade_record_to_db(
today_date,
self.signature,
job_id,
session_id
)
write_config_value("IF_TRADE", False)
def register_agent(self) -> None:

View File

@@ -49,14 +49,16 @@ class ContextInjector:
"""
# Inject context parameters for trade tools
if request.name in ["buy", "sell"]:
# Add signature and today_date to args if not present
if "signature" not in request.args:
request.args["signature"] = self.signature
if "today_date" not in request.args:
request.args["today_date"] = self.today_date
if "job_id" not in request.args and self.job_id:
# Debug: Log self attributes BEFORE injection
print(f"[ContextInjector.__call__] ENTRY: id={id(self)}, self.signature={self.signature}, self.today_date={self.today_date}, self.job_id={self.job_id}, self.session_id={self.session_id}")
print(f"[ContextInjector.__call__] Args BEFORE injection: {request.args}")
# ALWAYS inject/override context parameters (don't trust AI-provided values)
request.args["signature"] = self.signature
request.args["today_date"] = self.today_date
if self.job_id:
request.args["job_id"] = self.job_id
if "session_id" not in request.args and self.session_id:
if self.session_id:
request.args["session_id"] = self.session_id
# Debug logging

View File

@@ -7,7 +7,6 @@ project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, project_root)
from tools.price_tools import get_open_prices
import json
from tools.deployment_config import get_db_path
from api.database import get_db_connection
from datetime import datetime
mcp = FastMCP("TradeTools")
@@ -30,7 +29,7 @@ def get_current_position_from_db(job_id: str, model: str, date: str) -> Tuple[Di
Raises:
Exception: If database query fails
"""
db_path = get_db_path()
db_path = "data/jobs.db"
conn = get_db_connection(db_path)
cursor = conn.cursor()
@@ -83,24 +82,13 @@ def get_current_position_from_db(job_id: str, model: str, date: str) -> Tuple[Di
conn.close()
@mcp.tool()
def buy(symbol: str, amount: int, signature: str = None, today_date: str = None,
job_id: str = None, session_id: int = None) -> Dict[str, Any]:
def _buy_impl(symbol: str, amount: int, signature: str = None, today_date: str = None,
job_id: str = None, session_id: int = None) -> Dict[str, Any]:
"""
Buy stock function - writes to SQLite database.
Internal buy implementation - accepts injected context parameters.
Args:
symbol: Stock symbol (e.g., "AAPL", "MSFT")
amount: Number of shares to buy (positive integer)
signature: Model signature (injected by ContextInjector)
today_date: Trading date YYYY-MM-DD (injected by ContextInjector)
job_id: Job UUID (injected by ContextInjector)
session_id: Trading session ID (injected by ContextInjector)
Returns:
Dict[str, Any]:
- Success: {"CASH": amount, symbol: quantity, ...}
- Failure: {"error": message, ...}
This function is not exposed to the AI model. It receives runtime context
(signature, today_date, job_id, session_id) from the ContextInjector.
"""
# Validate required parameters
if not job_id:
@@ -110,7 +98,7 @@ def buy(symbol: str, amount: int, signature: str = None, today_date: str = None,
if not today_date:
return {"error": "Missing required parameter: today_date"}
db_path = get_db_path()
db_path = "data/jobs.db"
conn = get_db_connection(db_path)
cursor = conn.cursor()
@@ -207,8 +195,29 @@ def buy(symbol: str, amount: int, signature: str = None, today_date: str = None,
@mcp.tool()
def sell(symbol: str, amount: int, signature: str = None, today_date: str = None,
job_id: str = None, session_id: int = None) -> Dict[str, Any]:
def buy(symbol: str, amount: int, signature: str = None, today_date: str = None,
job_id: str = None, session_id: int = None) -> Dict[str, Any]:
"""
Buy stock shares.
Args:
symbol: Stock symbol (e.g., "AAPL", "MSFT", "GOOGL")
amount: Number of shares to buy (positive integer)
Returns:
Dict[str, Any]:
- Success: {"CASH": remaining_cash, "SYMBOL": shares, ...}
- Failure: {"error": error_message, ...}
Note: signature, today_date, job_id, session_id are automatically injected by the system.
Do not provide these parameters - they will be added automatically.
"""
# Delegate to internal implementation
return _buy_impl(symbol, amount, signature, today_date, job_id, session_id)
def _sell_impl(symbol: str, amount: int, signature: str = None, today_date: str = None,
job_id: str = None, session_id: int = None) -> Dict[str, Any]:
"""
Sell stock function - writes to SQLite database.
@@ -233,7 +242,7 @@ def sell(symbol: str, amount: int, signature: str = None, today_date: str = None
if not today_date:
return {"error": "Missing required parameter: today_date"}
db_path = get_db_path()
db_path = "data/jobs.db"
conn = get_db_connection(db_path)
cursor = conn.cursor()
@@ -328,6 +337,28 @@ def sell(symbol: str, amount: int, signature: str = None, today_date: str = None
conn.close()
@mcp.tool()
def sell(symbol: str, amount: int, signature: str = None, today_date: str = None,
job_id: str = None, session_id: int = None) -> Dict[str, Any]:
"""
Sell stock shares.
Args:
symbol: Stock symbol (e.g., "AAPL", "MSFT", "GOOGL")
amount: Number of shares to sell (positive integer)
Returns:
Dict[str, Any]:
- Success: {"CASH": remaining_cash, "SYMBOL": shares, ...}
- Failure: {"error": error_message, ...}
Note: signature, today_date, job_id, session_id are automatically injected by the system.
Do not provide these parameters - they will be added automatically.
"""
# Delegate to internal implementation
return _sell_impl(symbol, amount, signature, today_date, job_id, session_id)
if __name__ == "__main__":
port = int(os.getenv("TRADE_HTTP_PORT", "8002"))
mcp.run(transport="streamable-http", port=port)

View File

@@ -129,12 +129,21 @@ class ModelDayExecutor:
# Set environment variable for agent to use isolated config
os.environ["RUNTIME_ENV_PATH"] = self.runtime_config_path
# Initialize agent
# Initialize agent (without context)
agent = await self._initialize_agent()
# Update context injector with session_id
if hasattr(agent, 'context_injector') and agent.context_injector:
agent.context_injector.session_id = session_id
# Create and inject context with correct values
from agent.context_injector import ContextInjector
context_injector = ContextInjector(
signature=self.model_sig,
today_date=self.date, # Current trading day
job_id=self.job_id,
session_id=session_id
)
logger.info(f"[DEBUG] ModelDayExecutor: Created ContextInjector with signature={self.model_sig}, date={self.date}, job_id={self.job_id}, session_id={session_id}")
logger.info(f"[DEBUG] ModelDayExecutor: Calling await agent.set_context()")
await agent.set_context(context_injector)
logger.info(f"[DEBUG] ModelDayExecutor: set_context() completed")
# Run trading session
logger.info(f"Running trading session for {self.model_sig} on {self.date}")
@@ -149,10 +158,13 @@ class ModelDayExecutor:
# Update session summary
await self._update_session_summary(cursor, session_id, conversation, agent)
# Store positions (pass session_id)
self._write_results_to_db(agent, session_id)
# Commit and close connection before _write_results_to_db opens a new one
conn.commit()
conn.close()
conn = None # Mark as closed
# Store positions (pass session_id) - this opens its own connection
self._write_results_to_db(agent, session_id)
# Update status to completed
self.job_manager.update_job_detail_status(

View File

@@ -90,7 +90,7 @@ class SimulationWorker:
logger.info(f"Starting job {self.job_id}: {len(date_range)} dates, {len(models)} models")
# NEW: Prepare price data (download if needed)
available_dates, warnings = self._prepare_data(date_range, models, config_path)
available_dates, warnings, completion_skips = self._prepare_data(date_range, models, config_path)
if not available_dates:
error_msg = "No trading dates available after price data preparation"
@@ -100,7 +100,7 @@ class SimulationWorker:
# Execute available dates only
for date in available_dates:
logger.info(f"Processing date {date} with {len(models)} models")
self._execute_date(date, models, config_path)
self._execute_date(date, models, config_path, completion_skips)
# Job completed - determine final status
progress = self.job_manager.get_job_progress(self.job_id)
@@ -145,7 +145,8 @@ class SimulationWorker:
"error": error_msg
}
def _execute_date(self, date: str, models: List[str], config_path: str) -> None:
def _execute_date(self, date: str, models: List[str], config_path: str,
completion_skips: Dict[str, Set[str]] = None) -> None:
"""
Execute all models for a single date in parallel.
@@ -153,14 +154,24 @@ class SimulationWorker:
date: Trading date (YYYY-MM-DD)
models: List of model signatures to execute
config_path: Path to configuration file
completion_skips: {model: {dates}} of already-completed model-days to skip
Uses ThreadPoolExecutor to run all models concurrently for this date.
Waits for all models to complete before returning.
Skips models that have already completed this date.
"""
if completion_skips is None:
completion_skips = {}
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
# Submit all model executions for this date
futures = []
for model in models:
# Skip if this model-day was already completed
if date in completion_skips.get(model, set()):
logger.debug(f"Skipping {model} on {date} (already completed)")
continue
future = executor.submit(
self._execute_model_day,
date,
@@ -397,7 +408,10 @@ class SimulationWorker:
config_path: Path to configuration file
Returns:
Tuple of (available_dates, warnings)
Tuple of (available_dates, warnings, completion_skips)
- available_dates: Dates to process
- warnings: Warning messages
- completion_skips: {model: {dates}} of already-completed model-days
"""
from api.price_data_manager import PriceDataManager
@@ -456,7 +470,7 @@ class SimulationWorker:
self.job_manager.update_job_status(self.job_id, "running")
logger.info(f"Job {self.job_id}: Starting execution - {len(dates_to_process)} dates, {len(models)} models")
return dates_to_process, warnings
return dates_to_process, warnings, completion_skips
def get_job_info(self) -> Dict[str, Any]:
"""

View File

@@ -0,0 +1,476 @@
# Database-Only Position Tracking Design
**Date:** 2025-02-11
**Status:** Approved
**Version:** 1.0
## Problem Statement
Two critical issues prevent simulations from running:
1. **ContextInjector receives None values**: The ContextInjector shows `{'signature': None, 'today_date': None, 'job_id': None, 'session_id': None}` when injecting parameters into trade tool calls, causing trade validation to fail.
2. **File-based position tracking still in use**: System prompt builder and no-trade handler attempt to read/write position.jsonl files that no longer exist after SQLite migration.
## Root Cause Analysis
### Issue 1: ContextInjector Initialization Timing
**Problem Chain:**
- `BaseAgent.__init__()` creates `ContextInjector` with `self.init_date`
- `init_date` is the START of simulation date range (e.g., "2025-10-13"), not current trading day ("2025-10-01")
- Runtime config contains correct values (`TODAY_DATE="2025-10-01"`, `SIGNATURE="gpt-5"`, `JOB_ID="dc488e87..."`), but BaseAgent doesn't use them during initialization
- ContextInjector is created before the trading session, so it doesn't know the correct date
**Evidence:**
```
ai-trader-app | [ContextInjector] Tool: buy, Args after injection: {'symbol': 'MSFT', 'amount': 1, 'signature': None, 'today_date': None, 'job_id': None, 'session_id': None}
```
### Issue 2: Mixed Storage Architecture
**Problem Chain:**
- Trade tools (tool_trade.py) query/write to SQLite database
- System prompt builder calls `get_today_init_position()` which reads position.jsonl files
- No-trade handler calls `add_no_trade_record()` which writes to position.jsonl files
- Files don't exist because we migrated to database-only storage
**Evidence:**
```
FileNotFoundError: [Errno 2] No such file or directory: '/app/data/agent_data/gpt-5/position/position.jsonl'
```
## Design Solution
### Architecture Principles
1. **Database-only position storage**: All position queries and writes go through SQLite
2. **Lazy context injection**: Create ContextInjector after runtime config is written and session is created
3. **Real-time database queries**: System prompt builder queries database directly, no file caching
4. **Clean initialization order**: Config → Database → Agent → Context → Session
### Component Changes
#### 1. ContextInjector Lifecycle Refactor
**BaseAgent Changes:**
Remove ContextInjector creation from `__init__()`:
```python
# OLD (in __init__)
self.context_injector = ContextInjector(
signature=self.signature,
today_date=self.init_date, # WRONG: uses start date
job_id=job_id
)
self.client = MultiServerMCPClient(
self.mcp_config,
tool_interceptors=[self.context_injector]
)
# NEW (in __init__)
self.context_injector = None
self.client = MultiServerMCPClient(
self.mcp_config,
tool_interceptors=[] # Empty initially
)
```
Add new method `set_context()`:
```python
def set_context(self, context_injector: ContextInjector) -> None:
"""Inject ContextInjector after initialization.
Args:
context_injector: Configured ContextInjector instance
"""
self.context_injector = context_injector
self.client.add_interceptor(context_injector)
```
**ModelDayExecutor Changes:**
Create and inject ContextInjector after agent initialization:
```python
async def execute_async(self) -> Dict[str, Any]:
# ... create session, initialize position ...
# Set RUNTIME_ENV_PATH
os.environ["RUNTIME_ENV_PATH"] = self.runtime_config_path
# Initialize agent (without context)
agent = await self._initialize_agent()
# Create context injector with correct values
context_injector = ContextInjector(
signature=self.model_sig,
today_date=self.date, # CORRECT: current trading day
job_id=self.job_id,
session_id=session_id
)
# Inject context into agent
agent.set_context(context_injector)
# Run trading session
session_result = await agent.run_trading_session(self.date)
```
#### 2. Database Position Query Functions
**New Functions (tools/price_tools.py):**
```python
def get_today_init_position_from_db(
today_date: str,
modelname: str,
job_id: str
) -> Dict[str, float]:
"""
Query yesterday's position from database.
Args:
today_date: Current trading date (YYYY-MM-DD)
modelname: Model signature
job_id: Job UUID
Returns:
Position dict: {"AAPL": 50, "MSFT": 30, "CASH": 5000.0}
If no position exists: {"CASH": 10000.0} (initial cash)
"""
from tools.deployment_config import get_db_path
from api.database import get_db_connection
db_path = get_db_path()
conn = get_db_connection(db_path)
cursor = conn.cursor()
try:
# Get most recent position before today
cursor.execute("""
SELECT p.id, p.cash
FROM positions p
WHERE p.job_id = ? AND p.model = ? AND p.date < ?
ORDER BY p.date DESC, p.action_id DESC
LIMIT 1
""", (job_id, modelname, today_date))
row = cursor.fetchone()
if not row:
# First day - return initial cash
return {"CASH": 10000.0} # TODO: Read from config
position_id, cash = row
position_dict = {"CASH": cash}
# Get holdings for this position
cursor.execute("""
SELECT symbol, quantity
FROM holdings
WHERE position_id = ?
""", (position_id,))
for symbol, quantity in cursor.fetchall():
position_dict[symbol] = quantity
return position_dict
finally:
conn.close()
def add_no_trade_record_to_db(
today_date: str,
modelname: str,
job_id: str,
session_id: int
) -> None:
"""
Create no-trade position record in database.
Args:
today_date: Current trading date (YYYY-MM-DD)
modelname: Model signature
job_id: Job UUID
session_id: Trading session ID
"""
from tools.deployment_config import get_db_path
from api.database import get_db_connection
from agent_tools.tool_trade import get_current_position_from_db
from datetime import datetime
db_path = get_db_path()
conn = get_db_connection(db_path)
cursor = conn.cursor()
try:
# Get current position
current_position, next_action_id = get_current_position_from_db(
job_id, modelname, today_date
)
# Calculate portfolio value
# (Reuse logic from tool_trade.py)
cash = current_position.get("CASH", 0.0)
portfolio_value = cash
# Add stock values
for symbol, qty in current_position.items():
if symbol != "CASH":
try:
from tools.price_tools import get_open_prices
price = get_open_prices(today_date, [symbol])[f'{symbol}_price']
portfolio_value += qty * price
except KeyError:
pass
# Get previous value for P&L
cursor.execute("""
SELECT portfolio_value
FROM positions
WHERE job_id = ? AND model = ? AND date < ?
ORDER BY date DESC, action_id DESC
LIMIT 1
""", (job_id, modelname, today_date))
row = cursor.fetchone()
previous_value = row[0] if row else 10000.0
daily_profit = portfolio_value - previous_value
daily_return_pct = (daily_profit / previous_value * 100) if previous_value > 0 else 0
# Insert position record
created_at = datetime.utcnow().isoformat() + "Z"
cursor.execute("""
INSERT INTO positions (
job_id, date, model, action_id, action_type,
cash, portfolio_value, daily_profit, daily_return_pct,
session_id, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
job_id, today_date, modelname, next_action_id, "no_trade",
cash, portfolio_value, daily_profit, daily_return_pct,
session_id, created_at
))
position_id = cursor.lastrowid
# Insert holdings (unchanged from previous position)
for symbol, qty in current_position.items():
if symbol != "CASH":
cursor.execute("""
INSERT INTO holdings (position_id, symbol, quantity)
VALUES (?, ?, ?)
""", (position_id, symbol, qty))
conn.commit()
except Exception as e:
conn.rollback()
raise
finally:
conn.close()
```
#### 3. System Prompt Builder Updates
**Modified Function (prompts/agent_prompt.py):**
```python
def get_agent_system_prompt(today_date: str, signature: str) -> str:
"""Build system prompt with database position queries."""
from tools.general_tools import get_config_value
print(f"signature: {signature}")
print(f"today_date: {today_date}")
# Get job_id from runtime config
job_id = get_config_value("JOB_ID")
if not job_id:
raise ValueError("JOB_ID not found in runtime config")
# Query database for yesterday's position
today_init_position = get_today_init_position_from_db(
today_date, signature, job_id
)
# Get prices (unchanged)
yesterday_buy_prices, yesterday_sell_prices = get_yesterday_open_and_close_price(
today_date, all_nasdaq_100_symbols
)
today_buy_price = get_open_prices(today_date, all_nasdaq_100_symbols)
yesterday_profit = get_yesterday_profit(
today_date, yesterday_buy_prices, yesterday_sell_prices, today_init_position
)
return agent_system_prompt.format(
date=today_date,
positions=today_init_position,
STOP_SIGNAL=STOP_SIGNAL,
yesterday_close_price=yesterday_sell_prices,
today_buy_price=today_buy_price,
yesterday_profit=yesterday_profit
)
```
#### 4. No-Trade Handler Updates
**Modified Method (agent/base_agent/base_agent.py):**
```python
async def _handle_trading_result(self, today_date: str) -> None:
"""Handle trading results with database writes."""
from tools.general_tools import get_config_value
from tools.price_tools import add_no_trade_record_to_db
if_trade = get_config_value("IF_TRADE")
if if_trade:
write_config_value("IF_TRADE", False)
print("✅ Trading completed")
else:
print("📊 No trading, maintaining positions")
# Get context from runtime config
job_id = get_config_value("JOB_ID")
session_id = self.context_injector.session_id if self.context_injector else None
if not job_id or not session_id:
raise ValueError("Missing JOB_ID or session_id for no-trade record")
# Write no-trade record to database
add_no_trade_record_to_db(
today_date,
self.signature,
job_id,
session_id
)
write_config_value("IF_TRADE", False)
```
### Data Flow Summary
**Complete Execution Sequence:**
1. `ModelDayExecutor.__init__()`:
- Create runtime config file with TODAY_DATE, SIGNATURE, JOB_ID
2. `ModelDayExecutor.execute_async()`:
- Create trading_sessions record → get session_id
- Initialize starting position (if first day)
- Set RUNTIME_ENV_PATH environment variable
- Initialize agent (without ContextInjector)
- Create ContextInjector(date, model_sig, job_id, session_id)
- Call agent.set_context(context_injector)
- Run trading session
3. `BaseAgent.run_trading_session()`:
- Build system prompt → queries database for yesterday's position
- AI agent analyzes and decides
- Calls buy/sell tools → ContextInjector injects parameters
- Trade tools write to database
- If no trade: add_no_trade_record_to_db()
4. Position Query Flow:
- System prompt needs yesterday's position
- `get_today_init_position_from_db(today_date, signature, job_id)`
- Query: `SELECT positions + holdings WHERE job_id=? AND model=? AND date<? ORDER BY date DESC, action_id DESC LIMIT 1`
- Reconstruct position dict from results
- Return to system prompt builder
### Testing Strategy
**Critical Test Cases:**
1. **First Trading Day**
- No previous position in database
- Returns `{"CASH": 10000.0}`
- System prompt shows available cash
- Initial position created with action_id=0
2. **Subsequent Trading Days**
- Query finds previous position
- System prompt shows yesterday's holdings
- Action_id increments properly
3. **No-Trade Days**
- Agent outputs `<FINISH_SIGNAL>` without trading
- `add_no_trade_record_to_db()` creates record
- Holdings unchanged
- Portfolio value calculated
4. **ContextInjector Values**
- All parameters non-None
- Debug log shows correct injection
- Trade tools validate successfully
**Edge Cases:**
- Multiple models, same job (different signatures)
- Date gaps (weekends) - query finds Friday on Monday
- Mid-simulation restart - resumes from last position
- Empty holdings (only CASH)
**Validation Points:**
- Log ContextInjector values at injection
- Log database query results
- Verify initial position created
- Check session_id links positions
## Implementation Checklist
### Phase 1: ContextInjector Refactor
- [ ] Remove ContextInjector creation from BaseAgent.__init__()
- [ ] Add BaseAgent.set_context() method
- [ ] Update ModelDayExecutor to create and inject ContextInjector
- [ ] Add debug logging for injected values
### Phase 2: Database Position Functions
- [ ] Implement get_today_init_position_from_db()
- [ ] Implement add_no_trade_record_to_db()
- [ ] Add database error handling
- [ ] Add logging for query results
### Phase 3: Integration
- [ ] Update get_agent_system_prompt() to use database queries
- [ ] Update _handle_trading_result() to use database writes
- [ ] Remove/deprecate old file-based functions
- [ ] Test first trading day scenario
- [ ] Test subsequent trading days
- [ ] Test no-trade scenario
### Phase 4: Validation
- [ ] Run full simulation and verify ContextInjector logs
- [ ] Verify initial cash appears in system prompt
- [ ] Verify trades execute successfully
- [ ] Verify no-trade records created
- [ ] Check database for correct position records
## Rollback Plan
If issues arise:
1. Revert ContextInjector changes (keep in __init__)
2. Temporarily pass correct date via environment variable
3. Keep file-based functions as fallback
4. Debug database queries in isolation
## Success Criteria
1. ContextInjector logs show all non-None values
2. System prompt displays initial $10,000 cash
3. Trade tools successfully execute buy/sell operations
4. No FileNotFoundError exceptions
5. Database contains correct position records
6. AI agent can complete full trading day
## Notes
- File-based functions marked as deprecated but not removed (backward compatibility)
- Database queries use deployment_config for automatic prod/dev resolution
- Initial cash value should eventually be read from config, not hardcoded
- Consider adding database connection pooling for performance

View File

@@ -68,14 +68,24 @@ When you think your task is complete, output
def get_agent_system_prompt(today_date: str, signature: str) -> str:
print(f"signature: {signature}")
print(f"today_date: {today_date}")
# Get job_id from runtime config
job_id = get_config_value("JOB_ID")
if not job_id:
raise ValueError("JOB_ID not found in runtime config")
# Query database for yesterday's position
from tools.price_tools import get_today_init_position_from_db
today_init_position = get_today_init_position_from_db(today_date, signature, job_id)
# Get yesterday's buy and sell prices
yesterday_buy_prices, yesterday_sell_prices = get_yesterday_open_and_close_price(today_date, all_nasdaq_100_symbols)
today_buy_price = get_open_prices(today_date, all_nasdaq_100_symbols)
today_init_position = get_today_init_position(today_date, signature)
yesterday_profit = get_yesterday_profit(today_date, yesterday_buy_prices, yesterday_sell_prices, today_init_position)
return agent_system_prompt.format(
date=today_date,
positions=today_init_position,
date=today_date,
positions=today_init_position,
STOP_SIGNAL=STOP_SIGNAL,
yesterday_close_price=yesterday_sell_prices,
today_buy_price=today_buy_price,

View File

@@ -299,7 +299,172 @@ def add_no_trade_record(today_date: str, modelname: str):
with position_file.open("a", encoding="utf-8") as f:
f.write(json.dumps(save_item) + "\n")
return
return
def get_today_init_position_from_db(
today_date: str,
modelname: str,
job_id: str
) -> Dict[str, float]:
"""
Query yesterday's position from SQLite database.
Args:
today_date: Current trading date (YYYY-MM-DD)
modelname: Model signature
job_id: Job UUID
Returns:
Position dict: {"AAPL": 50, "MSFT": 30, "CASH": 5000.0}
If no position exists: {"CASH": 10000.0} (initial cash)
"""
import logging
from api.database import get_db_connection
logger = logging.getLogger(__name__)
db_path = "data/jobs.db"
conn = get_db_connection(db_path)
cursor = conn.cursor()
try:
# Get most recent position before today
cursor.execute("""
SELECT p.id, p.cash
FROM positions p
WHERE p.job_id = ? AND p.model = ? AND p.date < ?
ORDER BY p.date DESC, p.action_id DESC
LIMIT 1
""", (job_id, modelname, today_date))
row = cursor.fetchone()
if not row:
# First day - return initial cash
logger.info(f"No previous position found for {modelname}, returning initial cash")
return {"CASH": 10000.0}
position_id, cash = row
position_dict = {"CASH": cash}
# Get holdings for this position
cursor.execute("""
SELECT symbol, quantity
FROM holdings
WHERE position_id = ?
""", (position_id,))
for symbol, quantity in cursor.fetchall():
position_dict[symbol] = quantity
logger.debug(f"Loaded position for {modelname}: {position_dict}")
return position_dict
except Exception as e:
logger.error(f"Database error in get_today_init_position_from_db: {e}")
raise
finally:
conn.close()
def add_no_trade_record_to_db(
today_date: str,
modelname: str,
job_id: str,
session_id: int
) -> None:
"""
Create no-trade position record in SQLite database.
Args:
today_date: Current trading date (YYYY-MM-DD)
modelname: Model signature
job_id: Job UUID
session_id: Trading session ID
"""
import logging
from api.database import get_db_connection
from agent_tools.tool_trade import get_current_position_from_db
from datetime import datetime
logger = logging.getLogger(__name__)
db_path = "data/jobs.db"
conn = get_db_connection(db_path)
cursor = conn.cursor()
try:
# Get current position
current_position, next_action_id = get_current_position_from_db(
job_id, modelname, today_date
)
# Calculate portfolio value
cash = current_position.get("CASH", 0.0)
portfolio_value = cash
# Add stock values
for symbol, qty in current_position.items():
if symbol != "CASH":
try:
price = get_open_prices(today_date, [symbol])[f'{symbol}_price']
portfolio_value += qty * price
except KeyError:
logger.warning(f"Price not found for {symbol} on {today_date}")
pass
# Get previous value for P&L
cursor.execute("""
SELECT portfolio_value
FROM positions
WHERE job_id = ? AND model = ? AND date < ?
ORDER BY date DESC, action_id DESC
LIMIT 1
""", (job_id, modelname, today_date))
row = cursor.fetchone()
previous_value = row[0] if row else 10000.0
daily_profit = portfolio_value - previous_value
daily_return_pct = (daily_profit / previous_value * 100) if previous_value > 0 else 0
# Insert position record
created_at = datetime.utcnow().isoformat() + "Z"
cursor.execute("""
INSERT INTO positions (
job_id, date, model, action_id, action_type,
cash, portfolio_value, daily_profit, daily_return_pct,
session_id, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
job_id, today_date, modelname, next_action_id, "no_trade",
cash, portfolio_value, daily_profit, daily_return_pct,
session_id, created_at
))
position_id = cursor.lastrowid
# Insert holdings (unchanged from previous position)
for symbol, qty in current_position.items():
if symbol != "CASH":
cursor.execute("""
INSERT INTO holdings (position_id, symbol, quantity)
VALUES (?, ?, ?)
""", (position_id, symbol, qty))
conn.commit()
logger.info(f"Created no-trade record for {modelname} on {today_date}")
except Exception as e:
conn.rollback()
logger.error(f"Database error in add_no_trade_record_to_db: {e}")
raise
finally:
conn.close()
if __name__ == "__main__":
today_date = get_config_value("TODAY_DATE")