mirror of
https://github.com/Xe138/AI-Trader.git
synced 2026-04-02 01:27:24 -04:00
Compare commits
24 Commits
v0.4.0-alp
...
v0.4.1-alp
| Author | SHA1 | Date | |
|---|---|---|---|
| d749247af4 | |||
| 2178bbcdde | |||
| 872928a187 | |||
| 81ec0ec53b | |||
| ed6647ed66 | |||
| e689a78b3f | |||
| 60d89c8d3a | |||
| 7c4874715b | |||
| 6d30244fc9 | |||
| 0641ce554a | |||
| 0c6de5b74b | |||
| 0f49977700 | |||
| 27a824f4a6 | |||
| 3e50868a4d | |||
| e20dce7432 | |||
| 462de3adeb | |||
| 31e346ecbb | |||
| abb9cd0726 | |||
| 6d126db03c | |||
| 1e7bdb509b | |||
| a8d912bb4b | |||
| aa16480158 | |||
| 05620facc2 | |||
| 7c71a047bc |
63
CHANGELOG.md
63
CHANGELOG.md
@@ -7,7 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.0] - 2025-11-04
|
||||
## [0.4.1] - 2025-11-05
|
||||
|
||||
### Fixed
|
||||
- Fixed "No trading" message always displaying despite trading activity by initializing `IF_TRADE` to `True` (trades expected by default)
|
||||
- Resolved sporadic Pydantic validation errors for DeepSeek tool_calls arguments by switching to native `ChatDeepSeek` integration
|
||||
- Root cause: OpenAI compatibility layer did not consistently parse DeepSeek's tool_calls arguments from JSON strings to dicts
|
||||
- Solution: Use native `langchain-deepseek` package which properly handles DeepSeek's response format
|
||||
|
||||
### Added
|
||||
- Added `agent/model_factory.py` for provider-specific model creation
|
||||
- Added `langchain-deepseek>=0.1.20` dependency for native DeepSeek support
|
||||
- Added integration tests for DeepSeek tool calls argument parsing
|
||||
|
||||
### Changed
|
||||
- `BaseAgent` now uses model factory instead of direct `ChatOpenAI` instantiation
|
||||
- DeepSeek models (`deepseek/*`) now use `ChatDeepSeek` for native tool calling support
|
||||
- Removed obsolete `ToolCallArgsParsingWrapper` and related tests
|
||||
|
||||
## [0.4.0] - 2025-11-05
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
@@ -130,6 +148,49 @@ New `/results?reasoning=full` returns:
|
||||
- Test coverage increased with 36+ new comprehensive tests
|
||||
- Documentation updated with complete API reference and database schema details
|
||||
|
||||
### Fixed
|
||||
- **Critical:** Intra-day position tracking for sell-then-buy trades (e20dce7)
|
||||
- Sell proceeds now immediately available for subsequent buy orders within same trading session
|
||||
- ContextInjector maintains in-memory position state during trading sessions
|
||||
- Position updates accumulate after each successful trade
|
||||
- Enables agents to rebalance portfolios (sell + buy) in single session
|
||||
- Added 13 comprehensive tests for position tracking
|
||||
- **Critical:** Tool message extraction in conversation history (462de3a, abb9cd0)
|
||||
- Fixed bug where tool messages (buy/sell trades) were not captured when agent completed in single step
|
||||
- Tool extraction now happens BEFORE finish signal check
|
||||
- Reasoning summaries now accurately reflect actual trades executed
|
||||
- Resolves issue where summarizer saw 0 tools despite multiple trades
|
||||
- Reasoning summary generation improvements (6d126db)
|
||||
- Summaries now explicitly mention specific trades executed (symbols, quantities, actions)
|
||||
- Added TRADES EXECUTED section highlighting tool calls
|
||||
- Example: 'sold 1 GOOGL and 1 AMZN to reduce exposure' instead of 'maintain core holdings'
|
||||
- Final holdings calculation accuracy (a8d912b)
|
||||
- Final positions now calculated from actions instead of querying incomplete database records
|
||||
- Correctly handles first trading day with multiple trades
|
||||
- New `_calculate_final_position_from_actions()` method applies all trades to calculate final state
|
||||
- Holdings now persist correctly across all trading days
|
||||
- Added 3 comprehensive tests for final position calculation
|
||||
- Holdings persistence between trading days (aa16480)
|
||||
- Query now retrieves previous day's ending position as current day's starting position
|
||||
- Changed query from `date <=` to `date <` to prevent returning incomplete current-day records
|
||||
- Fixes empty starting_position/final_position in API responses despite successful trades
|
||||
- Updated tests to verify correct previous-day retrieval
|
||||
- Context injector trading_day_id synchronization (05620fa)
|
||||
- ContextInjector now updated with trading_day_id after record creation
|
||||
- Fixes "Trade failed: trading_day_id not found in runtime config" error
|
||||
- MCP tools now correctly receive trading_day_id via context injection
|
||||
- Schema migration compatibility fixes (7c71a04)
|
||||
- Updated position queries to use new trading_days schema instead of obsolete positions table
|
||||
- Removed obsolete add_no_trade_record_to_db function calls
|
||||
- Fixes "no such table: positions" error
|
||||
- Simplified _handle_trading_result logic
|
||||
- Database referential integrity (9da65c2)
|
||||
- Corrected Database default path from "data/trading.db" to "data/jobs.db"
|
||||
- Ensures all components use same database file
|
||||
- Fixes FOREIGN KEY constraint failures when creating trading_day records
|
||||
- Debug logging cleanup (1e7bdb5)
|
||||
- Removed verbose debug logging from ContextInjector for cleaner output
|
||||
|
||||
## [0.3.1] - 2025-11-03
|
||||
|
||||
### Fixed
|
||||
|
||||
16
CLAUDE.md
16
CLAUDE.md
@@ -167,12 +167,18 @@ bash main.sh
|
||||
### Agent System
|
||||
|
||||
**BaseAgent Key Methods:**
|
||||
- `initialize()`: Connect to MCP services, create AI model
|
||||
- `initialize()`: Connect to MCP services, create AI model via model factory
|
||||
- `run_trading_session(date)`: Execute single day's trading with retry logic
|
||||
- `run_date_range(init_date, end_date)`: Process all weekdays in range
|
||||
- `get_trading_dates()`: Resume from last date in position.jsonl
|
||||
- `register_agent()`: Create initial position file with $10,000 cash
|
||||
|
||||
**Model Factory:**
|
||||
The `create_model()` factory automatically selects the appropriate chat model:
|
||||
- `deepseek/*` models → `ChatDeepSeek` (native tool calling support)
|
||||
- `openai/*` models → `ChatOpenAI`
|
||||
- Other providers → `ChatOpenAI` (OpenAI-compatible endpoint)
|
||||
|
||||
**Adding Custom Agents:**
|
||||
1. Create new class inheriting from `BaseAgent`
|
||||
2. Add to `AGENT_REGISTRY` in `main.py`:
|
||||
@@ -452,4 +458,10 @@ The project uses a well-organized documentation structure:
|
||||
**Agent Doesn't Stop Trading:**
|
||||
- Agent must output `<FINISH_SIGNAL>` within `max_steps`
|
||||
- Increase `max_steps` if agent needs more reasoning time
|
||||
- Check `log.jsonl` for errors preventing completion
|
||||
- Check `log.jsonl` for errors preventing completion
|
||||
|
||||
**DeepSeek Pydantic Validation Errors:**
|
||||
- Error: "Input should be a valid dictionary [type=dict_type, input_value='...', input_type=str]"
|
||||
- Cause: Using `ChatOpenAI` for DeepSeek models (OpenAI compatibility layer issue)
|
||||
- Fix: Ensure `langchain-deepseek` is installed and basemodel uses `deepseek/` prefix
|
||||
- The model factory automatically uses `ChatDeepSeek` for native support
|
||||
@@ -12,7 +12,6 @@ from typing import Dict, List, Optional, Any
|
||||
from pathlib import Path
|
||||
|
||||
from langchain_mcp_adapters.client import MultiServerMCPClient
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain.agents import create_agent
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@@ -33,6 +32,7 @@ from tools.deployment_config import (
|
||||
from agent.context_injector import ContextInjector
|
||||
from agent.pnl_calculator import DailyPnLCalculator
|
||||
from agent.reasoning_summarizer import ReasoningSummarizer
|
||||
from agent.model_factory import create_model
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
@@ -126,7 +126,7 @@ class BaseAgent:
|
||||
# Initialize components
|
||||
self.client: Optional[MultiServerMCPClient] = None
|
||||
self.tools: Optional[List] = None
|
||||
self.model: Optional[ChatOpenAI] = None
|
||||
self.model: Optional[Any] = None
|
||||
self.agent: Optional[Any] = None
|
||||
|
||||
# Context injector for MCP tools
|
||||
@@ -211,14 +211,18 @@ class BaseAgent:
|
||||
self.model = MockChatModel(date="2025-01-01") # Date will be updated per session
|
||||
print(f"🤖 Using MockChatModel (DEV mode)")
|
||||
else:
|
||||
self.model = ChatOpenAI(
|
||||
model=self.basemodel,
|
||||
base_url=self.openai_base_url,
|
||||
# Use model factory for provider-specific implementations
|
||||
self.model = create_model(
|
||||
basemodel=self.basemodel,
|
||||
api_key=self.openai_api_key,
|
||||
max_retries=3,
|
||||
base_url=self.openai_base_url,
|
||||
temperature=0.7,
|
||||
timeout=30
|
||||
)
|
||||
print(f"🤖 Using {self.basemodel} (PROD mode)")
|
||||
|
||||
# Determine model type for logging
|
||||
model_class = self.model.__class__.__name__
|
||||
print(f"🤖 Using {self.basemodel} via {model_class} (PROD mode)")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"❌ Failed to initialize AI model: {e}")
|
||||
|
||||
@@ -319,6 +323,60 @@ class BaseAgent:
|
||||
print(f"⚠️ Could not get position from database: {e}")
|
||||
return {}, self.initial_cash
|
||||
|
||||
def _calculate_final_position_from_actions(
|
||||
self,
|
||||
trading_day_id: int,
|
||||
starting_cash: float
|
||||
) -> tuple[Dict[str, int], float]:
|
||||
"""
|
||||
Calculate final holdings and cash from starting position + actions.
|
||||
|
||||
This is the correct way to get end-of-day position: start with the
|
||||
starting position and apply all trades from the actions table.
|
||||
|
||||
Args:
|
||||
trading_day_id: The trading day ID
|
||||
starting_cash: Cash at start of day
|
||||
|
||||
Returns:
|
||||
(holdings_dict, final_cash) where holdings_dict maps symbol -> quantity
|
||||
"""
|
||||
from api.database import Database
|
||||
|
||||
db = Database()
|
||||
|
||||
# 1. Get starting holdings (from previous day's ending)
|
||||
starting_holdings_list = db.get_starting_holdings(trading_day_id)
|
||||
holdings = {h["symbol"]: h["quantity"] for h in starting_holdings_list}
|
||||
|
||||
# 2. Initialize cash
|
||||
cash = starting_cash
|
||||
|
||||
# 3. Get all actions for this trading day
|
||||
actions = db.get_actions(trading_day_id)
|
||||
|
||||
# 4. Apply each action to calculate final state
|
||||
for action in actions:
|
||||
symbol = action["symbol"]
|
||||
quantity = action["quantity"]
|
||||
price = action["price"]
|
||||
action_type = action["action_type"]
|
||||
|
||||
if action_type == "buy":
|
||||
# Add to holdings
|
||||
holdings[symbol] = holdings.get(symbol, 0) + quantity
|
||||
# Deduct from cash
|
||||
cash -= quantity * price
|
||||
|
||||
elif action_type == "sell":
|
||||
# Remove from holdings
|
||||
holdings[symbol] = holdings.get(symbol, 0) - quantity
|
||||
# Add to cash
|
||||
cash += quantity * price
|
||||
|
||||
# 5. Return final state
|
||||
return holdings, cash
|
||||
|
||||
def _calculate_portfolio_value(
|
||||
self,
|
||||
holdings: Dict[str, int],
|
||||
@@ -365,7 +423,7 @@ class BaseAgent:
|
||||
}
|
||||
|
||||
if tool_name:
|
||||
message["tool_name"] = tool_name
|
||||
message["name"] = tool_name # Use "name" not "tool_name" for consistency with summarizer
|
||||
if tool_input:
|
||||
message["tool_input"] = tool_input
|
||||
|
||||
@@ -479,6 +537,8 @@ Summary:"""
|
||||
# Update context injector with current trading date
|
||||
if self.context_injector:
|
||||
self.context_injector.today_date = today_date
|
||||
# Reset position state for new trading day (enables intra-day tracking)
|
||||
self.context_injector.reset_position()
|
||||
|
||||
# Clear conversation history for new trading day
|
||||
self.clear_conversation_history()
|
||||
@@ -538,6 +598,10 @@ Summary:"""
|
||||
from tools.general_tools import write_config_value
|
||||
write_config_value('TRADING_DAY_ID', trading_day_id)
|
||||
|
||||
# Update context_injector with trading_day_id for MCP tools
|
||||
if self.context_injector:
|
||||
self.context_injector.trading_day_id = trading_day_id
|
||||
|
||||
# 6. Run AI trading session
|
||||
action_count = 0
|
||||
|
||||
@@ -575,21 +639,28 @@ Summary:"""
|
||||
# Capture assistant response
|
||||
self._capture_message("assistant", agent_response)
|
||||
|
||||
# Check stop signal
|
||||
if STOP_SIGNAL in agent_response:
|
||||
print("✅ Received stop signal, trading session ended")
|
||||
print(agent_response)
|
||||
break
|
||||
|
||||
# Extract tool messages and count trade actions
|
||||
# Extract tool messages BEFORE checking stop signal
|
||||
# (agent may call tools AND return FINISH_SIGNAL in same response)
|
||||
tool_msgs = extract_tool_messages(response)
|
||||
print(f"[DEBUG] Extracted {len(tool_msgs)} tool messages from response")
|
||||
for tool_msg in tool_msgs:
|
||||
tool_name = getattr(tool_msg, 'name', None) or tool_msg.get('name') if isinstance(tool_msg, dict) else None
|
||||
tool_content = getattr(tool_msg, 'content', '') or tool_msg.get('content', '') if isinstance(tool_msg, dict) else str(tool_msg)
|
||||
|
||||
# Capture tool message to conversation history
|
||||
self._capture_message("tool", tool_content, tool_name=tool_name)
|
||||
|
||||
if tool_name in ['buy', 'sell']:
|
||||
action_count += 1
|
||||
|
||||
tool_response = '\n'.join([msg.content for msg in tool_msgs])
|
||||
|
||||
# Check stop signal AFTER processing tools
|
||||
if STOP_SIGNAL in agent_response:
|
||||
print("✅ Received stop signal, trading session ended")
|
||||
print(agent_response)
|
||||
break
|
||||
|
||||
# Prepare new messages
|
||||
new_messages = [
|
||||
{"role": "assistant", "content": agent_response},
|
||||
@@ -607,11 +678,26 @@ Summary:"""
|
||||
session_duration = time.time() - session_start
|
||||
|
||||
# 7. Generate reasoning summary
|
||||
# Debug: Log conversation history size
|
||||
print(f"\n[DEBUG] Generating summary from {len(self.conversation_history)} messages")
|
||||
assistant_msgs = [m for m in self.conversation_history if m.get('role') == 'assistant']
|
||||
tool_msgs = [m for m in self.conversation_history if m.get('role') == 'tool']
|
||||
print(f"[DEBUG] Assistant messages: {len(assistant_msgs)}, Tool messages: {len(tool_msgs)}")
|
||||
if assistant_msgs:
|
||||
first_assistant = assistant_msgs[0]
|
||||
print(f"[DEBUG] First assistant message preview: {first_assistant.get('content', '')[:200]}...")
|
||||
|
||||
summarizer = ReasoningSummarizer(model=self.model)
|
||||
summary = await summarizer.generate_summary(self.conversation_history)
|
||||
|
||||
# 8. Get current portfolio state from database
|
||||
current_holdings, current_cash = self._get_current_portfolio_state(today_date, job_id)
|
||||
# 8. Calculate final portfolio state from starting position + actions
|
||||
# NOTE: We must calculate from actions, not query database, because:
|
||||
# - On first day, database query returns empty (no previous day)
|
||||
# - This method applies all trades to get accurate final state
|
||||
current_holdings, current_cash = self._calculate_final_position_from_actions(
|
||||
trading_day_id=trading_day_id,
|
||||
starting_cash=starting_cash
|
||||
)
|
||||
|
||||
# 9. Save final holdings to database
|
||||
for symbol, quantity in current_holdings.items():
|
||||
@@ -660,8 +746,6 @@ Summary:"""
|
||||
|
||||
async def _handle_trading_result(self, today_date: str) -> None:
|
||||
"""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:
|
||||
@@ -669,23 +753,10 @@ Summary:"""
|
||||
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)
|
||||
|
||||
# Note: In new schema, trading_day record is created at session start
|
||||
# and updated at session end, so no separate no-trade record needed
|
||||
|
||||
def register_agent(self) -> None:
|
||||
"""Register new agent, create initial positions"""
|
||||
|
||||
@@ -3,15 +3,22 @@ Tool interceptor for injecting runtime context into MCP tool calls.
|
||||
|
||||
This interceptor automatically injects `signature` and `today_date` parameters
|
||||
into buy/sell tool calls to support concurrent multi-model simulations.
|
||||
|
||||
It also maintains in-memory position state to track cumulative changes within
|
||||
a single trading session, ensuring sell proceeds are immediately available for
|
||||
subsequent buy orders.
|
||||
"""
|
||||
|
||||
from typing import Any, Callable, Awaitable
|
||||
from typing import Any, Callable, Awaitable, Dict, Optional
|
||||
|
||||
|
||||
class ContextInjector:
|
||||
"""
|
||||
Intercepts tool calls to inject runtime context (signature, today_date).
|
||||
|
||||
Also maintains cumulative position state during trading session to ensure
|
||||
sell proceeds are immediately available for subsequent buys.
|
||||
|
||||
Usage:
|
||||
interceptor = ContextInjector(signature="gpt-5", today_date="2025-10-01")
|
||||
client = MultiServerMCPClient(config, tool_interceptors=[interceptor])
|
||||
@@ -34,6 +41,13 @@ class ContextInjector:
|
||||
self.job_id = job_id
|
||||
self.session_id = session_id # Deprecated but kept for compatibility
|
||||
self.trading_day_id = trading_day_id
|
||||
self._current_position: Optional[Dict[str, float]] = None
|
||||
|
||||
def reset_position(self) -> None:
|
||||
"""
|
||||
Reset position state (call at start of each trading day).
|
||||
"""
|
||||
self._current_position = None
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
@@ -43,6 +57,9 @@ class ContextInjector:
|
||||
"""
|
||||
Intercept tool call and inject context parameters.
|
||||
|
||||
For buy/sell operations, maintains cumulative position state to ensure
|
||||
sell proceeds are immediately available for subsequent buys.
|
||||
|
||||
Args:
|
||||
request: Tool call request containing name and arguments
|
||||
handler: Async callable to execute the actual tool
|
||||
@@ -52,10 +69,6 @@ class ContextInjector:
|
||||
"""
|
||||
# Inject context parameters for trade tools
|
||||
if request.name in ["buy", "sell"]:
|
||||
# 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}, self.trading_day_id={self.trading_day_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
|
||||
@@ -66,8 +79,18 @@ class ContextInjector:
|
||||
if self.trading_day_id:
|
||||
request.args["trading_day_id"] = self.trading_day_id
|
||||
|
||||
# Debug logging
|
||||
print(f"[ContextInjector] Tool: {request.name}, Args after injection: {request.args}")
|
||||
# Inject current position if we're tracking it
|
||||
if self._current_position is not None:
|
||||
request.args["_current_position"] = self._current_position
|
||||
|
||||
# Call the actual tool handler
|
||||
return await handler(request)
|
||||
result = await handler(request)
|
||||
|
||||
# Update position state after successful trade
|
||||
if request.name in ["buy", "sell"]:
|
||||
# Check if result is a valid position dict (not an error)
|
||||
if isinstance(result, dict) and "error" not in result and "CASH" in result:
|
||||
# Update our tracked position with the new state
|
||||
self._current_position = result.copy()
|
||||
|
||||
return result
|
||||
|
||||
68
agent/model_factory.py
Normal file
68
agent/model_factory.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Model factory for creating provider-specific chat models.
|
||||
|
||||
Supports multiple AI providers with native integrations where available:
|
||||
- DeepSeek: Uses ChatDeepSeek for native tool calling support
|
||||
- OpenAI: Uses ChatOpenAI
|
||||
- Others: Fall back to ChatOpenAI (OpenAI-compatible endpoints)
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_deepseek import ChatDeepSeek
|
||||
|
||||
|
||||
def create_model(
|
||||
basemodel: str,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
temperature: float,
|
||||
timeout: int
|
||||
) -> Any:
|
||||
"""
|
||||
Create appropriate chat model based on provider.
|
||||
|
||||
Args:
|
||||
basemodel: Model identifier (e.g., "deepseek/deepseek-chat", "openai/gpt-4")
|
||||
api_key: API key for the provider
|
||||
base_url: Base URL for API endpoint
|
||||
temperature: Sampling temperature (0-1)
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
Provider-specific chat model instance
|
||||
|
||||
Examples:
|
||||
>>> model = create_model("deepseek/deepseek-chat", "key", "url", 0.7, 30)
|
||||
>>> isinstance(model, ChatDeepSeek)
|
||||
True
|
||||
|
||||
>>> model = create_model("openai/gpt-4", "key", "url", 0.7, 30)
|
||||
>>> isinstance(model, ChatOpenAI)
|
||||
True
|
||||
"""
|
||||
# Extract provider from basemodel (format: "provider/model-name")
|
||||
provider = basemodel.split("/")[0].lower() if "/" in basemodel else "unknown"
|
||||
|
||||
if provider == "deepseek":
|
||||
# Use native ChatDeepSeek for DeepSeek models
|
||||
# Extract model name without provider prefix
|
||||
model_name = basemodel.split("/", 1)[1] if "/" in basemodel else basemodel
|
||||
|
||||
return ChatDeepSeek(
|
||||
model=model_name,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
temperature=temperature,
|
||||
timeout=timeout
|
||||
)
|
||||
else:
|
||||
# Use ChatOpenAI for OpenAI and OpenAI-compatible endpoints
|
||||
# (Anthropic, Google, Qwen, etc. via compatibility layer)
|
||||
return ChatOpenAI(
|
||||
model=basemodel,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
temperature=temperature,
|
||||
timeout=timeout
|
||||
)
|
||||
@@ -36,15 +36,17 @@ class ReasoningSummarizer:
|
||||
summary_prompt = f"""You are reviewing your own trading decisions for the day.
|
||||
Summarize your trading strategy and key decisions in 2-3 sentences.
|
||||
|
||||
IMPORTANT: Explicitly state what trades you executed (e.g., "sold 2 GOOGL shares" or "bought 10 NVDA shares"). If you made no trades, state that clearly.
|
||||
|
||||
Focus on:
|
||||
- What you analyzed
|
||||
- Why you made the trades you did
|
||||
- What specific trades you executed (buy/sell, symbols, quantities)
|
||||
- Why you made those trades
|
||||
- Your overall strategy for the day
|
||||
|
||||
Trading session log:
|
||||
{log_text}
|
||||
|
||||
Provide a concise summary:"""
|
||||
Provide a concise summary that includes the actual trades executed:"""
|
||||
|
||||
response = await self.model.ainvoke([
|
||||
{"role": "user", "content": summary_prompt}
|
||||
@@ -67,21 +69,39 @@ Provide a concise summary:"""
|
||||
reasoning_log: List of message dicts
|
||||
|
||||
Returns:
|
||||
Formatted text representation
|
||||
Formatted text representation with emphasis on trades
|
||||
"""
|
||||
# Debug: Log what we're formatting
|
||||
print(f"[DEBUG ReasoningSummarizer] Formatting {len(reasoning_log)} messages")
|
||||
assistant_count = sum(1 for m in reasoning_log if m.get('role') == 'assistant')
|
||||
tool_count = sum(1 for m in reasoning_log if m.get('role') == 'tool')
|
||||
print(f"[DEBUG ReasoningSummarizer] Breakdown: {assistant_count} assistant, {tool_count} tool")
|
||||
|
||||
formatted_parts = []
|
||||
trades_executed = []
|
||||
|
||||
for msg in reasoning_log:
|
||||
role = msg.get("role", "")
|
||||
content = msg.get("content", "")
|
||||
tool_name = msg.get("name", "")
|
||||
|
||||
if role == "assistant":
|
||||
# AI's thoughts
|
||||
formatted_parts.append(f"AI: {content[:200]}")
|
||||
elif role == "tool":
|
||||
# Tool results
|
||||
tool_name = msg.get("name", "tool")
|
||||
formatted_parts.append(f"{tool_name}: {content[:100]}")
|
||||
# Highlight trade tool calls
|
||||
if tool_name in ["buy", "sell"]:
|
||||
trades_executed.append(f"{tool_name.upper()}: {content[:150]}")
|
||||
formatted_parts.append(f"TRADE - {tool_name.upper()}: {content[:150]}")
|
||||
else:
|
||||
# Other tool results (search, price, etc.)
|
||||
formatted_parts.append(f"{tool_name}: {content[:100]}")
|
||||
|
||||
# Add summary of trades at the top
|
||||
if trades_executed:
|
||||
trade_summary = f"TRADES EXECUTED ({len(trades_executed)}):\n" + "\n".join(trades_executed)
|
||||
formatted_parts.insert(0, trade_summary)
|
||||
formatted_parts.insert(1, "\n--- FULL LOG ---")
|
||||
|
||||
return "\n".join(formatted_parts)
|
||||
|
||||
|
||||
@@ -28,16 +28,17 @@ def get_current_position_from_db(
|
||||
initial_cash: float = 10000.0
|
||||
) -> Tuple[Dict[str, float], int]:
|
||||
"""
|
||||
Get current position from database (new schema).
|
||||
Get starting position for current trading day from database (new schema).
|
||||
|
||||
Queries most recent trading_day record for this job+model up to date.
|
||||
Returns ending holdings and cash from that day.
|
||||
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.
|
||||
|
||||
Args:
|
||||
job_id: Job UUID
|
||||
model: Model signature
|
||||
date: Current trading date
|
||||
initial_cash: Initial cash if no prior data
|
||||
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:
|
||||
@@ -49,11 +50,11 @@ def get_current_position_from_db(
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# Query most recent trading_day up to date
|
||||
# Query most recent trading_day BEFORE current date (previous day's ending position)
|
||||
cursor.execute("""
|
||||
SELECT id, ending_cash
|
||||
FROM trading_days
|
||||
WHERE job_id = ? AND model = ? AND date <= ?
|
||||
WHERE job_id = ? AND model = ? AND date < ?
|
||||
ORDER BY date DESC
|
||||
LIMIT 1
|
||||
""", (job_id, model, date))
|
||||
@@ -90,7 +91,8 @@ def get_current_position_from_db(
|
||||
|
||||
|
||||
def _buy_impl(symbol: str, amount: int, signature: str = None, today_date: str = None,
|
||||
job_id: str = None, session_id: int = None, trading_day_id: int = None) -> Dict[str, Any]:
|
||||
job_id: str = None, session_id: int = None, trading_day_id: int = None,
|
||||
_current_position: Dict[str, float] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Internal buy implementation - accepts injected context parameters.
|
||||
|
||||
@@ -102,9 +104,13 @@ def _buy_impl(symbol: str, amount: int, signature: str = None, today_date: str =
|
||||
job_id: Job ID (injected)
|
||||
session_id: Session ID (injected, DEPRECATED)
|
||||
trading_day_id: Trading day ID (injected)
|
||||
_current_position: Current position state (injected by ContextInjector)
|
||||
|
||||
This function is not exposed to the AI model. It receives runtime context
|
||||
(signature, today_date, job_id, session_id, trading_day_id) from the ContextInjector.
|
||||
|
||||
The _current_position parameter enables intra-day position tracking, ensuring
|
||||
sell proceeds are immediately available for subsequent buys.
|
||||
"""
|
||||
# Validate required parameters
|
||||
if not job_id:
|
||||
@@ -120,7 +126,13 @@ def _buy_impl(symbol: str, amount: int, signature: str = None, today_date: str =
|
||||
|
||||
try:
|
||||
# Step 1: Get current position
|
||||
current_position, next_action_id = get_current_position_from_db(job_id, signature, today_date)
|
||||
# Use injected position if available (for intra-day tracking),
|
||||
# otherwise query database for starting position
|
||||
if _current_position is not None:
|
||||
current_position = _current_position
|
||||
next_action_id = 0 # Not used in new schema
|
||||
else:
|
||||
current_position, next_action_id = get_current_position_from_db(job_id, signature, today_date)
|
||||
|
||||
# Step 2: Get stock price
|
||||
try:
|
||||
@@ -185,7 +197,8 @@ def _buy_impl(symbol: str, amount: int, signature: str = None, today_date: str =
|
||||
|
||||
@mcp.tool()
|
||||
def buy(symbol: str, amount: int, signature: str = None, today_date: str = None,
|
||||
job_id: str = None, session_id: int = None, trading_day_id: int = None) -> Dict[str, Any]:
|
||||
job_id: str = None, session_id: int = None, trading_day_id: int = None,
|
||||
_current_position: Dict[str, float] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Buy stock shares.
|
||||
|
||||
@@ -198,14 +211,15 @@ def buy(symbol: str, amount: int, signature: str = None, today_date: str = None,
|
||||
- Success: {"CASH": remaining_cash, "SYMBOL": shares, ...}
|
||||
- Failure: {"error": error_message, ...}
|
||||
|
||||
Note: signature, today_date, job_id, session_id, trading_day_id are
|
||||
automatically injected by the system. Do not provide these parameters.
|
||||
Note: signature, today_date, job_id, session_id, trading_day_id, _current_position
|
||||
are automatically injected by the system. Do not provide these parameters.
|
||||
"""
|
||||
return _buy_impl(symbol, amount, signature, today_date, job_id, session_id, trading_day_id)
|
||||
return _buy_impl(symbol, amount, signature, today_date, job_id, session_id, trading_day_id, _current_position)
|
||||
|
||||
|
||||
def _sell_impl(symbol: str, amount: int, signature: str = None, today_date: str = None,
|
||||
job_id: str = None, session_id: int = None, trading_day_id: int = None) -> Dict[str, Any]:
|
||||
job_id: str = None, session_id: int = None, trading_day_id: int = None,
|
||||
_current_position: Dict[str, float] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Sell stock function - writes to SQLite database.
|
||||
|
||||
@@ -217,11 +231,15 @@ def _sell_impl(symbol: str, amount: int, signature: str = None, today_date: str
|
||||
job_id: Job UUID (injected by ContextInjector)
|
||||
session_id: Trading session ID (injected by ContextInjector, DEPRECATED)
|
||||
trading_day_id: Trading day ID (injected by ContextInjector)
|
||||
_current_position: Current position state (injected by ContextInjector)
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]:
|
||||
- Success: {"CASH": amount, symbol: quantity, ...}
|
||||
- Failure: {"error": message, ...}
|
||||
|
||||
The _current_position parameter enables intra-day position tracking, ensuring
|
||||
sell proceeds are immediately available for subsequent buys.
|
||||
"""
|
||||
# Validate required parameters
|
||||
if not job_id:
|
||||
@@ -237,7 +255,13 @@ def _sell_impl(symbol: str, amount: int, signature: str = None, today_date: str
|
||||
|
||||
try:
|
||||
# Step 1: Get current position
|
||||
current_position, next_action_id = get_current_position_from_db(job_id, signature, today_date)
|
||||
# Use injected position if available (for intra-day tracking),
|
||||
# otherwise query database for starting position
|
||||
if _current_position is not None:
|
||||
current_position = _current_position
|
||||
next_action_id = 0 # Not used in new schema
|
||||
else:
|
||||
current_position, next_action_id = get_current_position_from_db(job_id, signature, today_date)
|
||||
|
||||
# Step 2: Validate position exists
|
||||
if symbol not in current_position:
|
||||
@@ -297,7 +321,8 @@ def _sell_impl(symbol: str, amount: int, signature: str = None, today_date: str
|
||||
|
||||
@mcp.tool()
|
||||
def sell(symbol: str, amount: int, signature: str = None, today_date: str = None,
|
||||
job_id: str = None, session_id: int = None, trading_day_id: int = None) -> Dict[str, Any]:
|
||||
job_id: str = None, session_id: int = None, trading_day_id: int = None,
|
||||
_current_position: Dict[str, float] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Sell stock shares.
|
||||
|
||||
@@ -310,10 +335,10 @@ def sell(symbol: str, amount: int, signature: str = None, today_date: str = None
|
||||
- Success: {"CASH": remaining_cash, "SYMBOL": shares, ...}
|
||||
- Failure: {"error": error_message, ...}
|
||||
|
||||
Note: signature, today_date, job_id, session_id, trading_day_id are
|
||||
automatically injected by the system. Do not provide these parameters.
|
||||
Note: signature, today_date, job_id, session_id, trading_day_id, _current_position
|
||||
are automatically injected by the system. Do not provide these parameters.
|
||||
"""
|
||||
return _sell_impl(symbol, amount, signature, today_date, job_id, session_id, trading_day_id)
|
||||
return _sell_impl(symbol, amount, signature, today_date, job_id, session_id, trading_day_id, _current_position)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -80,7 +80,7 @@ class RuntimeConfigManager:
|
||||
initial_config = {
|
||||
"TODAY_DATE": date,
|
||||
"SIGNATURE": model_sig,
|
||||
"IF_TRADE": False,
|
||||
"IF_TRADE": True, # FIX: Trades are expected by default
|
||||
"JOB_ID": job_id,
|
||||
"TRADING_DAY_ID": trading_day_id
|
||||
}
|
||||
|
||||
@@ -0,0 +1,891 @@
|
||||
# Fix IF_TRADE Flag and DeepSeek Tool Calls Validation Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Fix two bugs: (1) "No trading" message always displayed despite trading activity, and (2) sporadic Pydantic validation errors for DeepSeek tool_calls arguments.
|
||||
|
||||
**Architecture:**
|
||||
- Issue #1: Change IF_TRADE initialization from False to True in runtime config manager
|
||||
- Issue #2: Replace ChatOpenAI with native ChatDeepSeek for DeepSeek models to eliminate OpenAI compatibility layer issues
|
||||
|
||||
**Tech Stack:** Python 3.10+, LangChain 1.0.2, langchain-deepseek, FastAPI, SQLite
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Fix IF_TRADE Initialization Bug
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/runtime_manager.py:80-86`
|
||||
- Test: `tests/unit/test_runtime_manager.py`
|
||||
- Verify: `agent/base_agent/base_agent.py:745-752`
|
||||
|
||||
**Root Cause:**
|
||||
`IF_TRADE` is initialized to `False` but never updated when trades execute. The design documents show it should initialize to `True` (trades are expected by default).
|
||||
|
||||
**Step 1: Write failing test for IF_TRADE initialization**
|
||||
|
||||
Add to `tests/unit/test_runtime_manager.py` after existing tests:
|
||||
|
||||
```python
|
||||
def test_create_runtime_config_if_trade_defaults_true(self):
|
||||
"""Test that IF_TRADE initializes to True (trades expected by default)"""
|
||||
manager = RuntimeConfigManager()
|
||||
|
||||
config_path = manager.create_runtime_config(
|
||||
date="2025-01-16",
|
||||
model_sig="test-model",
|
||||
job_id="test-job-123",
|
||||
trading_day_id=1
|
||||
)
|
||||
|
||||
try:
|
||||
# Read the config file
|
||||
with open(config_path, 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Verify IF_TRADE is True by default
|
||||
assert config["IF_TRADE"] is True, "IF_TRADE should initialize to True"
|
||||
finally:
|
||||
# Cleanup
|
||||
if os.path.exists(config_path):
|
||||
os.remove(config_path)
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `./venv/bin/python -m pytest tests/unit/test_runtime_manager.py::TestRuntimeConfigManager::test_create_runtime_config_if_trade_defaults_true -v`
|
||||
|
||||
Expected: FAIL with assertion error showing `IF_TRADE` is `False`
|
||||
|
||||
**Step 3: Fix IF_TRADE initialization**
|
||||
|
||||
In `api/runtime_manager.py`, change line 83:
|
||||
|
||||
```python
|
||||
# BEFORE (line 80-86):
|
||||
initial_config = {
|
||||
"TODAY_DATE": date,
|
||||
"SIGNATURE": model_sig,
|
||||
"IF_TRADE": False, # BUG: Should be True
|
||||
"JOB_ID": job_id,
|
||||
"TRADING_DAY_ID": trading_day_id
|
||||
}
|
||||
|
||||
# AFTER:
|
||||
initial_config = {
|
||||
"TODAY_DATE": date,
|
||||
"SIGNATURE": model_sig,
|
||||
"IF_TRADE": True, # FIX: Trades are expected by default
|
||||
"JOB_ID": job_id,
|
||||
"TRADING_DAY_ID": trading_day_id
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `./venv/bin/python -m pytest tests/unit/test_runtime_manager.py::TestRuntimeConfigManager::test_create_runtime_config_if_trade_defaults_true -v`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Update existing test expectations**
|
||||
|
||||
The existing test `test_create_runtime_config_creates_file` at line 66 expects `IF_TRADE` to be `False`. Update it:
|
||||
|
||||
```python
|
||||
# In test_create_runtime_config_creates_file, change line 66:
|
||||
# BEFORE:
|
||||
assert config["IF_TRADE"] is False
|
||||
|
||||
# AFTER:
|
||||
assert config["IF_TRADE"] is True
|
||||
```
|
||||
|
||||
**Step 6: Run all runtime_manager tests**
|
||||
|
||||
Run: `./venv/bin/python -m pytest tests/unit/test_runtime_manager.py -v`
|
||||
|
||||
Expected: All tests PASS
|
||||
|
||||
**Step 7: Verify integration test expectations**
|
||||
|
||||
Check `tests/integration/test_agent_pnl_integration.py` line 63:
|
||||
|
||||
```python
|
||||
# Current mock (line 62-66):
|
||||
mock_get_config.side_effect = lambda key: {
|
||||
"IF_TRADE": False, # This may need updating depending on test scenario
|
||||
"JOB_ID": "test-job",
|
||||
"TODAY_DATE": "2025-01-15",
|
||||
"SIGNATURE": "test-model"
|
||||
}.get(key)
|
||||
```
|
||||
|
||||
This test mocks a no-trade scenario, so `False` is correct here. No change needed.
|
||||
|
||||
**Step 8: Commit IF_TRADE fix**
|
||||
|
||||
```bash
|
||||
git add api/runtime_manager.py tests/unit/test_runtime_manager.py
|
||||
git commit -m "fix: initialize IF_TRADE to True (trades expected by default)
|
||||
|
||||
Root cause: IF_TRADE was initialized to False and never updated when
|
||||
trades executed, causing 'No trading' message to always display.
|
||||
|
||||
Design documents (2025-02-11-complete-schema-migration) specify
|
||||
IF_TRADE should start as True, with trades setting it to False only
|
||||
after completion.
|
||||
|
||||
Fixes sporadic issue where all trading sessions reported 'No trading'
|
||||
despite successful buy/sell actions."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add langchain-deepseek Dependency
|
||||
|
||||
**Files:**
|
||||
- Modify: `requirements.txt`
|
||||
- Verify: `venv/bin/pip list`
|
||||
|
||||
**Step 1: Add langchain-deepseek to requirements.txt**
|
||||
|
||||
Add after line 3 in `requirements.txt`:
|
||||
|
||||
```txt
|
||||
langchain==1.0.2
|
||||
langchain-openai==1.0.1
|
||||
langchain-mcp-adapters>=0.1.0
|
||||
langchain-deepseek>=0.1.20
|
||||
```
|
||||
|
||||
**Step 2: Install new dependency**
|
||||
|
||||
Run: `./venv/bin/pip install -r requirements.txt`
|
||||
|
||||
Expected: Successfully installs `langchain-deepseek` and its dependencies
|
||||
|
||||
**Step 3: Verify installation**
|
||||
|
||||
Run: `./venv/bin/pip show langchain-deepseek`
|
||||
|
||||
Expected: Shows package info with version >= 0.1.20
|
||||
|
||||
**Step 4: Commit dependency addition**
|
||||
|
||||
```bash
|
||||
git add requirements.txt
|
||||
git commit -m "deps: add langchain-deepseek for native DeepSeek support
|
||||
|
||||
Adds official LangChain DeepSeek integration to replace ChatOpenAI
|
||||
wrapper approach for DeepSeek models. Native integration provides:
|
||||
- Better tool_calls argument parsing
|
||||
- DeepSeek-specific error handling
|
||||
- No OpenAI compatibility layer issues
|
||||
|
||||
Version 0.1.20+ includes tool calling support for deepseek-chat."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Implement Model Provider Factory
|
||||
|
||||
**Files:**
|
||||
- Create: `agent/model_factory.py`
|
||||
- Test: `tests/unit/test_model_factory.py`
|
||||
|
||||
**Rationale:**
|
||||
Currently `base_agent.py` hardcodes model creation logic. Extract to factory pattern to support multiple providers (OpenAI, DeepSeek, Anthropic, etc.) with provider-specific handling.
|
||||
|
||||
**Step 1: Write failing test for model factory**
|
||||
|
||||
Create `tests/unit/test_model_factory.py`:
|
||||
|
||||
```python
|
||||
"""Unit tests for model factory - provider-specific model creation"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
from agent.model_factory import create_model
|
||||
|
||||
|
||||
class TestModelFactory:
|
||||
"""Tests for create_model factory function"""
|
||||
|
||||
@patch('agent.model_factory.ChatDeepSeek')
|
||||
def test_create_model_deepseek(self, mock_deepseek_class):
|
||||
"""Test that DeepSeek models use ChatDeepSeek"""
|
||||
mock_model = Mock()
|
||||
mock_deepseek_class.return_value = mock_model
|
||||
|
||||
result = create_model(
|
||||
basemodel="deepseek/deepseek-chat",
|
||||
api_key="test-key",
|
||||
base_url="https://api.deepseek.com",
|
||||
temperature=0.7,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# Verify ChatDeepSeek was called with correct params
|
||||
mock_deepseek_class.assert_called_once_with(
|
||||
model="deepseek-chat", # Extracted from "deepseek/deepseek-chat"
|
||||
api_key="test-key",
|
||||
base_url="https://api.deepseek.com",
|
||||
temperature=0.7,
|
||||
timeout=30
|
||||
)
|
||||
assert result == mock_model
|
||||
|
||||
@patch('agent.model_factory.ChatOpenAI')
|
||||
def test_create_model_openai(self, mock_openai_class):
|
||||
"""Test that OpenAI models use ChatOpenAI"""
|
||||
mock_model = Mock()
|
||||
mock_openai_class.return_value = mock_model
|
||||
|
||||
result = create_model(
|
||||
basemodel="openai/gpt-4",
|
||||
api_key="test-key",
|
||||
base_url="https://api.openai.com/v1",
|
||||
temperature=0.7,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# Verify ChatOpenAI was called with correct params
|
||||
mock_openai_class.assert_called_once_with(
|
||||
model="openai/gpt-4",
|
||||
api_key="test-key",
|
||||
base_url="https://api.openai.com/v1",
|
||||
temperature=0.7,
|
||||
timeout=30
|
||||
)
|
||||
assert result == mock_model
|
||||
|
||||
@patch('agent.model_factory.ChatOpenAI')
|
||||
def test_create_model_anthropic(self, mock_openai_class):
|
||||
"""Test that Anthropic models use ChatOpenAI (via compatibility)"""
|
||||
mock_model = Mock()
|
||||
mock_openai_class.return_value = mock_model
|
||||
|
||||
result = create_model(
|
||||
basemodel="anthropic/claude-sonnet-4.5",
|
||||
api_key="test-key",
|
||||
base_url="https://api.anthropic.com/v1",
|
||||
temperature=0.7,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# Verify ChatOpenAI was used (Anthropic via OpenAI-compatible endpoint)
|
||||
mock_openai_class.assert_called_once()
|
||||
assert result == mock_model
|
||||
|
||||
@patch('agent.model_factory.ChatOpenAI')
|
||||
def test_create_model_generic_provider(self, mock_openai_class):
|
||||
"""Test that unknown providers default to ChatOpenAI"""
|
||||
mock_model = Mock()
|
||||
mock_openai_class.return_value = mock_model
|
||||
|
||||
result = create_model(
|
||||
basemodel="custom/custom-model",
|
||||
api_key="test-key",
|
||||
base_url="https://api.custom.com",
|
||||
temperature=0.7,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# Should fall back to ChatOpenAI for unknown providers
|
||||
mock_openai_class.assert_called_once()
|
||||
assert result == mock_model
|
||||
|
||||
def test_create_model_deepseek_extracts_model_name(self):
|
||||
"""Test that DeepSeek model name is extracted correctly"""
|
||||
with patch('agent.model_factory.ChatDeepSeek') as mock_class:
|
||||
create_model(
|
||||
basemodel="deepseek/deepseek-chat-v3.1",
|
||||
api_key="key",
|
||||
base_url="url",
|
||||
temperature=0,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# Check that model param is just "deepseek-chat-v3.1"
|
||||
call_kwargs = mock_class.call_args[1]
|
||||
assert call_kwargs['model'] == "deepseek-chat-v3.1"
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `./venv/bin/python -m pytest tests/unit/test_model_factory.py -v`
|
||||
|
||||
Expected: FAIL with "ModuleNotFoundError: No module named 'agent.model_factory'"
|
||||
|
||||
**Step 3: Implement model factory**
|
||||
|
||||
Create `agent/model_factory.py`:
|
||||
|
||||
```python
|
||||
"""
|
||||
Model factory for creating provider-specific chat models.
|
||||
|
||||
Supports multiple AI providers with native integrations where available:
|
||||
- DeepSeek: Uses ChatDeepSeek for native tool calling support
|
||||
- OpenAI: Uses ChatOpenAI
|
||||
- Others: Fall back to ChatOpenAI (OpenAI-compatible endpoints)
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_deepseek import ChatDeepSeek
|
||||
|
||||
|
||||
def create_model(
|
||||
basemodel: str,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
temperature: float,
|
||||
timeout: int
|
||||
) -> Any:
|
||||
"""
|
||||
Create appropriate chat model based on provider.
|
||||
|
||||
Args:
|
||||
basemodel: Model identifier (e.g., "deepseek/deepseek-chat", "openai/gpt-4")
|
||||
api_key: API key for the provider
|
||||
base_url: Base URL for API endpoint
|
||||
temperature: Sampling temperature (0-1)
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
Provider-specific chat model instance
|
||||
|
||||
Examples:
|
||||
>>> model = create_model("deepseek/deepseek-chat", "key", "url", 0.7, 30)
|
||||
>>> isinstance(model, ChatDeepSeek)
|
||||
True
|
||||
|
||||
>>> model = create_model("openai/gpt-4", "key", "url", 0.7, 30)
|
||||
>>> isinstance(model, ChatOpenAI)
|
||||
True
|
||||
"""
|
||||
# Extract provider from basemodel (format: "provider/model-name")
|
||||
provider = basemodel.split("/")[0].lower() if "/" in basemodel else "unknown"
|
||||
|
||||
if provider == "deepseek":
|
||||
# Use native ChatDeepSeek for DeepSeek models
|
||||
# Extract model name without provider prefix
|
||||
model_name = basemodel.split("/", 1)[1] if "/" in basemodel else basemodel
|
||||
|
||||
return ChatDeepSeek(
|
||||
model=model_name,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
temperature=temperature,
|
||||
timeout=timeout
|
||||
)
|
||||
else:
|
||||
# Use ChatOpenAI for OpenAI and OpenAI-compatible endpoints
|
||||
# (Anthropic, Google, Qwen, etc. via compatibility layer)
|
||||
return ChatOpenAI(
|
||||
model=basemodel,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
temperature=temperature,
|
||||
timeout=timeout
|
||||
)
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `./venv/bin/python -m pytest tests/unit/test_model_factory.py -v`
|
||||
|
||||
Expected: All tests PASS
|
||||
|
||||
**Step 5: Commit model factory**
|
||||
|
||||
```bash
|
||||
git add agent/model_factory.py tests/unit/test_model_factory.py
|
||||
git commit -m "feat: add model factory for provider-specific chat models
|
||||
|
||||
Implements factory pattern to create appropriate chat model based on
|
||||
provider prefix in basemodel string.
|
||||
|
||||
Supported providers:
|
||||
- deepseek/*: Uses ChatDeepSeek (native tool calling)
|
||||
- openai/*: Uses ChatOpenAI
|
||||
- others: Fall back to ChatOpenAI (OpenAI-compatible)
|
||||
|
||||
This enables native DeepSeek integration while maintaining backward
|
||||
compatibility with existing OpenAI-compatible providers."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Integrate Model Factory into BaseAgent
|
||||
|
||||
**Files:**
|
||||
- Modify: `agent/base_agent/base_agent.py:146-220`
|
||||
- Test: `tests/unit/test_base_agent.py` (if exists) or manual verification
|
||||
|
||||
**Step 1: Import model factory in base_agent.py**
|
||||
|
||||
Add after line 33 in `agent/base_agent/base_agent.py`:
|
||||
|
||||
```python
|
||||
from agent.reasoning_summarizer import ReasoningSummarizer
|
||||
from agent.model_factory import create_model # ADD THIS
|
||||
```
|
||||
|
||||
**Step 2: Replace model creation logic**
|
||||
|
||||
Replace lines 208-220 in `agent/base_agent/base_agent.py`:
|
||||
|
||||
```python
|
||||
# BEFORE (lines 208-220):
|
||||
if is_dev_mode():
|
||||
from agent.mock_provider import MockChatModel
|
||||
self.model = MockChatModel(date="2025-01-01")
|
||||
print(f"🤖 Using MockChatModel (DEV mode)")
|
||||
else:
|
||||
self.model = ChatOpenAI(
|
||||
model=self.basemodel,
|
||||
base_url=self.openai_base_url,
|
||||
api_key=self.openai_api_key,
|
||||
temperature=0.7,
|
||||
timeout=30
|
||||
)
|
||||
print(f"🤖 Using {self.basemodel} (PROD mode)")
|
||||
|
||||
# AFTER:
|
||||
if is_dev_mode():
|
||||
from agent.mock_provider import MockChatModel
|
||||
self.model = MockChatModel(date="2025-01-01")
|
||||
print(f"🤖 Using MockChatModel (DEV mode)")
|
||||
else:
|
||||
# Use model factory for provider-specific implementations
|
||||
self.model = create_model(
|
||||
basemodel=self.basemodel,
|
||||
api_key=self.openai_api_key,
|
||||
base_url=self.openai_base_url,
|
||||
temperature=0.7,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# Determine model type for logging
|
||||
model_class = self.model.__class__.__name__
|
||||
print(f"🤖 Using {self.basemodel} via {model_class} (PROD mode)")
|
||||
```
|
||||
|
||||
**Step 3: Remove ChatOpenAI import if no longer used**
|
||||
|
||||
Check if `ChatOpenAI` is imported but no longer used in `base_agent.py`:
|
||||
|
||||
```python
|
||||
# If line ~11 has:
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
# And it's only used in the section we just replaced, remove it
|
||||
# (Keep if used elsewhere in file)
|
||||
```
|
||||
|
||||
**Step 4: Manual verification test**
|
||||
|
||||
Since this changes core agent initialization, test with actual execution:
|
||||
|
||||
Run: `DEPLOYMENT_MODE=DEV python main.py configs/default_config.json`
|
||||
|
||||
Expected:
|
||||
- Logs show "Using deepseek-chat-v3.1 via ChatDeepSeek (PROD mode)" for DeepSeek
|
||||
- Logs show "Using openai/gpt-5 via ChatOpenAI (PROD mode)" for OpenAI
|
||||
- No import errors or model creation failures
|
||||
|
||||
**Step 5: Run existing agent tests**
|
||||
|
||||
Run: `./venv/bin/python -m pytest tests/unit/ -k agent -v`
|
||||
|
||||
Expected: All agent-related tests still PASS (factory is transparent to existing behavior)
|
||||
|
||||
**Step 6: Commit model factory integration**
|
||||
|
||||
```bash
|
||||
git add agent/base_agent/base_agent.py
|
||||
git commit -m "refactor: use model factory in BaseAgent
|
||||
|
||||
Replaces direct ChatOpenAI instantiation with create_model() factory.
|
||||
|
||||
Benefits:
|
||||
- DeepSeek models now use native ChatDeepSeek
|
||||
- Other models continue using ChatOpenAI
|
||||
- Provider-specific optimizations in one place
|
||||
- Easier to add new providers
|
||||
|
||||
Logging now shows both model name and provider class for debugging."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add Integration Test for DeepSeek Tool Calls
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/integration/test_deepseek_tool_calls.py`
|
||||
- Reference: `agent_tools/tool_math.py` (math tool for testing)
|
||||
|
||||
**Rationale:**
|
||||
Verify that DeepSeek's tool_calls arguments are properly parsed to dicts without Pydantic validation errors.
|
||||
|
||||
**Step 1: Write integration test**
|
||||
|
||||
Create `tests/integration/test_deepseek_tool_calls.py`:
|
||||
|
||||
```python
|
||||
"""
|
||||
Integration test for DeepSeek tool calls argument parsing.
|
||||
|
||||
Tests that ChatDeepSeek properly converts tool_calls.arguments (JSON string)
|
||||
to tool_calls.args (dict) without Pydantic validation errors.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
from unittest.mock import patch, AsyncMock
|
||||
from langchain_core.messages import AIMessage
|
||||
from agent.model_factory import create_model
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDeepSeekToolCalls:
|
||||
"""Integration tests for DeepSeek tool calling"""
|
||||
|
||||
def test_create_model_returns_chat_deepseek_for_deepseek_models(self):
|
||||
"""Verify that DeepSeek models use ChatDeepSeek class"""
|
||||
# Skip if no DeepSeek API key available
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
pytest.skip("OPENAI_API_KEY not available")
|
||||
|
||||
model = create_model(
|
||||
basemodel="deepseek/deepseek-chat",
|
||||
api_key=os.getenv("OPENAI_API_KEY"),
|
||||
base_url=os.getenv("OPENAI_API_BASE"),
|
||||
temperature=0,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# Verify it's a ChatDeepSeek instance
|
||||
assert model.__class__.__name__ == "ChatDeepSeek"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deepseek_tool_calls_args_are_dicts(self):
|
||||
"""Test that DeepSeek tool_calls.args are dicts, not strings"""
|
||||
# Skip if no API key
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
pytest.skip("OPENAI_API_KEY not available")
|
||||
|
||||
# Create DeepSeek model
|
||||
model = create_model(
|
||||
basemodel="deepseek/deepseek-chat",
|
||||
api_key=os.getenv("OPENAI_API_KEY"),
|
||||
base_url=os.getenv("OPENAI_API_BASE"),
|
||||
temperature=0,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# Bind a simple math tool
|
||||
from langchain_core.tools import tool
|
||||
|
||||
@tool
|
||||
def add(a: float, b: float) -> float:
|
||||
"""Add two numbers"""
|
||||
return a + b
|
||||
|
||||
model_with_tools = model.bind_tools([add])
|
||||
|
||||
# Invoke with a query that should trigger tool call
|
||||
result = await model_with_tools.ainvoke(
|
||||
"What is 5 plus 3?"
|
||||
)
|
||||
|
||||
# Verify response is AIMessage
|
||||
assert isinstance(result, AIMessage)
|
||||
|
||||
# Verify tool_calls exist
|
||||
assert len(result.tool_calls) > 0, "Expected at least one tool call"
|
||||
|
||||
# Verify args are dicts, not strings
|
||||
for tool_call in result.tool_calls:
|
||||
assert isinstance(tool_call['args'], dict), \
|
||||
f"tool_calls.args should be dict, got {type(tool_call['args'])}"
|
||||
assert 'a' in tool_call['args'], "Missing expected arg 'a'"
|
||||
assert 'b' in tool_call['args'], "Missing expected arg 'b'"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deepseek_no_pydantic_validation_errors(self):
|
||||
"""Test that DeepSeek doesn't produce Pydantic validation errors"""
|
||||
# Skip if no API key
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
pytest.skip("OPENAI_API_KEY not available")
|
||||
|
||||
model = create_model(
|
||||
basemodel="deepseek/deepseek-chat",
|
||||
api_key=os.getenv("OPENAI_API_KEY"),
|
||||
base_url=os.getenv("OPENAI_API_BASE"),
|
||||
temperature=0,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
from langchain_core.tools import tool
|
||||
|
||||
@tool
|
||||
def multiply(a: float, b: float) -> float:
|
||||
"""Multiply two numbers"""
|
||||
return a * b
|
||||
|
||||
model_with_tools = model.bind_tools([multiply])
|
||||
|
||||
# This should NOT raise Pydantic validation errors
|
||||
try:
|
||||
result = await model_with_tools.ainvoke(
|
||||
"Calculate 7 times 8"
|
||||
)
|
||||
assert isinstance(result, AIMessage)
|
||||
except Exception as e:
|
||||
# Check that it's not a Pydantic validation error
|
||||
error_msg = str(e).lower()
|
||||
assert "validation error" not in error_msg, \
|
||||
f"Pydantic validation error occurred: {e}"
|
||||
assert "input should be a valid dictionary" not in error_msg, \
|
||||
f"tool_calls.args validation error occurred: {e}"
|
||||
# Re-raise if it's a different error
|
||||
raise
|
||||
```
|
||||
|
||||
**Step 2: Run test (may require API access)**
|
||||
|
||||
Run: `./venv/bin/python -m pytest tests/integration/test_deepseek_tool_calls.py -v`
|
||||
|
||||
Expected:
|
||||
- If API key available: Tests PASS
|
||||
- If no API key: Tests SKIPPED with message "OPENAI_API_KEY not available"
|
||||
|
||||
**Step 3: Commit integration test**
|
||||
|
||||
```bash
|
||||
git add tests/integration/test_deepseek_tool_calls.py
|
||||
git commit -m "test: add DeepSeek tool calls integration tests
|
||||
|
||||
Verifies that ChatDeepSeek properly handles tool_calls arguments:
|
||||
- Returns ChatDeepSeek for deepseek/* models
|
||||
- tool_calls.args are dicts (not JSON strings)
|
||||
- No Pydantic validation errors on args
|
||||
|
||||
Tests skip gracefully if API keys not available."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Update Documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md` (sections on model configuration and troubleshooting)
|
||||
- Modify: `CHANGELOG.md`
|
||||
|
||||
**Step 1: Update CLAUDE.md architecture section**
|
||||
|
||||
In `CLAUDE.md`, find the "Agent System" section (around line 125) and update:
|
||||
|
||||
```markdown
|
||||
**BaseAgent Key Methods:**
|
||||
- `initialize()`: Connect to MCP services, create AI model via model factory
|
||||
- `run_trading_session(date)`: Execute single day's trading with retry logic
|
||||
- `run_date_range(init_date, end_date)`: Process all weekdays in range
|
||||
- `get_trading_dates()`: Resume from last date in position.jsonl
|
||||
- `register_agent()`: Create initial position file with $10,000 cash
|
||||
|
||||
**Model Factory:**
|
||||
The `create_model()` factory automatically selects the appropriate chat model:
|
||||
- `deepseek/*` models → `ChatDeepSeek` (native tool calling support)
|
||||
- `openai/*` models → `ChatOpenAI`
|
||||
- Other providers → `ChatOpenAI` (OpenAI-compatible endpoint)
|
||||
|
||||
**Adding Custom Agents:**
|
||||
[existing content remains the same]
|
||||
```
|
||||
|
||||
**Step 2: Update CLAUDE.md common issues section**
|
||||
|
||||
In `CLAUDE.md`, find "Common Issues" (around line 420) and add:
|
||||
|
||||
```markdown
|
||||
**DeepSeek Pydantic Validation Errors:**
|
||||
- Error: "Input should be a valid dictionary [type=dict_type, input_value='...', input_type=str]"
|
||||
- Cause: Using `ChatOpenAI` for DeepSeek models (OpenAI compatibility layer issue)
|
||||
- Fix: Ensure `langchain-deepseek` is installed and basemodel uses `deepseek/` prefix
|
||||
- The model factory automatically uses `ChatDeepSeek` for native support
|
||||
```
|
||||
|
||||
**Step 3: Update CHANGELOG.md**
|
||||
|
||||
Add new version entry at top of `CHANGELOG.md`:
|
||||
|
||||
```markdown
|
||||
## [0.4.2] - 2025-11-05
|
||||
|
||||
### Fixed
|
||||
- Fixed "No trading" message always displaying despite trading activity by initializing `IF_TRADE` to `True` (trades expected by default)
|
||||
- Resolved sporadic Pydantic validation errors for DeepSeek tool_calls arguments by switching to native `ChatDeepSeek` integration
|
||||
|
||||
### Added
|
||||
- Added `agent/model_factory.py` for provider-specific model creation
|
||||
- Added `langchain-deepseek` dependency for native DeepSeek support
|
||||
- Added integration tests for DeepSeek tool calls argument parsing
|
||||
|
||||
### Changed
|
||||
- `BaseAgent` now uses model factory instead of direct `ChatOpenAI` instantiation
|
||||
- DeepSeek models (`deepseek/*`) now use `ChatDeepSeek` instead of OpenAI compatibility layer
|
||||
```
|
||||
|
||||
**Step 4: Commit documentation updates**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md CHANGELOG.md
|
||||
git commit -m "docs: update for IF_TRADE and DeepSeek fixes
|
||||
|
||||
- Document model factory architecture
|
||||
- Add troubleshooting for DeepSeek validation errors
|
||||
- Update changelog with version 0.4.2 fixes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: End-to-End Verification
|
||||
|
||||
**Files:**
|
||||
- Test: Full simulation with DeepSeek model
|
||||
- Verify: Logs show correct messages and no errors
|
||||
|
||||
**Step 1: Run simulation with DeepSeek in PROD mode**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
# Ensure API keys are set
|
||||
export OPENAI_API_KEY="your-deepseek-key"
|
||||
export OPENAI_API_BASE="https://api.deepseek.com/v1"
|
||||
export DEPLOYMENT_MODE=PROD
|
||||
|
||||
# Run short simulation (1 day)
|
||||
python main.py configs/default_config.json
|
||||
```
|
||||
|
||||
**Step 2: Verify expected behaviors**
|
||||
|
||||
Check logs for:
|
||||
|
||||
1. **Model initialization:**
|
||||
- ✅ Should show: "🤖 Using deepseek/deepseek-chat-v3.1 via ChatDeepSeek (PROD mode)"
|
||||
- ❌ Should NOT show: "via ChatOpenAI"
|
||||
|
||||
2. **Tool calls execution:**
|
||||
- ✅ Should show: "[DEBUG] Extracted X tool messages from response"
|
||||
- ❌ Should NOT show: "⚠️ Attempt 1 failed" with Pydantic validation errors
|
||||
- Note: If retries occur for other reasons (network, rate limits), that's OK
|
||||
|
||||
3. **Trading completion:**
|
||||
- ✅ Should show: "✅ Trading completed" (if trades occurred)
|
||||
- ❌ Should NOT show: "📊 No trading, maintaining positions" (if trades occurred)
|
||||
|
||||
**Step 3: Check database for trade records**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
sqlite3 data/jobs.db "SELECT job_id, date, model, status FROM trading_days ORDER BY created_at DESC LIMIT 5;"
|
||||
```
|
||||
|
||||
Expected: Recent records show `status='completed'` for DeepSeek runs
|
||||
|
||||
**Step 4: Verify position tracking**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
sqlite3 data/jobs.db "SELECT trading_day_id, action_type, symbol, quantity FROM actions WHERE trading_day_id IN (SELECT id FROM trading_days ORDER BY created_at DESC LIMIT 1);"
|
||||
```
|
||||
|
||||
Expected: Shows buy/sell actions if AI made trades
|
||||
|
||||
**Step 5: Run test suite**
|
||||
|
||||
Run full test suite to ensure no regressions:
|
||||
|
||||
```bash
|
||||
./venv/bin/python -m pytest tests/ -v --cov=. --cov-report=term-missing
|
||||
```
|
||||
|
||||
Expected: All tests PASS, coverage >= 85%
|
||||
|
||||
**Step 6: Final commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: verify end-to-end functionality after fixes
|
||||
|
||||
Confirmed:
|
||||
- DeepSeek models use ChatDeepSeek (no validation errors)
|
||||
- Trading completion shows correct message
|
||||
- Database tracking works correctly
|
||||
- All tests pass with good coverage"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
### Files Modified:
|
||||
1. `api/runtime_manager.py` - IF_TRADE initialization fix
|
||||
2. `requirements.txt` - Added langchain-deepseek dependency
|
||||
3. `agent/base_agent/base_agent.py` - Integrated model factory
|
||||
4. `tests/unit/test_runtime_manager.py` - Updated test expectations
|
||||
5. `CLAUDE.md` - Architecture and troubleshooting updates
|
||||
6. `CHANGELOG.md` - Version 0.4.2 release notes
|
||||
|
||||
### Files Created:
|
||||
1. `agent/model_factory.py` - Provider-specific model creation
|
||||
2. `tests/unit/test_model_factory.py` - Model factory tests
|
||||
3. `tests/integration/test_deepseek_tool_calls.py` - DeepSeek integration tests
|
||||
|
||||
### Testing Strategy:
|
||||
- Unit tests for IF_TRADE initialization
|
||||
- Unit tests for model factory provider routing
|
||||
- Integration tests for DeepSeek tool calls
|
||||
- End-to-end verification with real simulation
|
||||
- Full test suite regression check
|
||||
|
||||
### Verification Commands:
|
||||
```bash
|
||||
# Quick test (unit tests only)
|
||||
bash scripts/quick_test.sh
|
||||
|
||||
# Full test suite
|
||||
bash scripts/run_tests.sh
|
||||
|
||||
# End-to-end simulation
|
||||
DEPLOYMENT_MODE=PROD python main.py configs/default_config.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes for Engineer
|
||||
|
||||
**Key Architectural Changes:**
|
||||
- Factory pattern separates provider-specific logic from agent core
|
||||
- Native integrations preferred over compatibility layers
|
||||
- IF_TRADE semantics: True = trades expected, tools set to False after execution
|
||||
|
||||
**Why These Fixes Work:**
|
||||
1. **IF_TRADE**: Design always intended True initialization, False was a typo
|
||||
2. **DeepSeek**: Native ChatDeepSeek handles tool_calls parsing correctly, eliminating sporadic OpenAI compatibility layer bugs
|
||||
|
||||
**Testing Philosophy:**
|
||||
- @superpowers:test-driven-development - Write test first, watch it fail, implement, verify pass
|
||||
- @superpowers:verification-before-completion - Never claim "it works" without running verification
|
||||
- Each commit should leave the codebase in a working state
|
||||
|
||||
**If You Get Stuck:**
|
||||
- Check logs for exact error messages
|
||||
- Run pytest with `-vv` for verbose output
|
||||
- Use `git diff` to verify changes match plan
|
||||
- @superpowers:systematic-debugging - Never guess, always investigate root cause
|
||||
@@ -1,6 +1,7 @@
|
||||
langchain==1.0.2
|
||||
langchain-openai==1.0.1
|
||||
langchain-mcp-adapters>=0.1.0
|
||||
langchain-deepseek>=0.1.20
|
||||
fastmcp==2.12.5
|
||||
fastapi>=0.120.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
|
||||
118
tests/integration/test_deepseek_tool_calls.py
Normal file
118
tests/integration/test_deepseek_tool_calls.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Integration test for DeepSeek tool calls argument parsing.
|
||||
|
||||
Tests that ChatDeepSeek properly converts tool_calls.arguments (JSON string)
|
||||
to tool_calls.args (dict) without Pydantic validation errors.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
from unittest.mock import patch, AsyncMock
|
||||
from langchain_core.messages import AIMessage
|
||||
from agent.model_factory import create_model
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDeepSeekToolCalls:
|
||||
"""Integration tests for DeepSeek tool calling"""
|
||||
|
||||
def test_create_model_returns_chat_deepseek_for_deepseek_models(self):
|
||||
"""Verify that DeepSeek models use ChatDeepSeek class"""
|
||||
# Skip if no DeepSeek API key available
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
pytest.skip("OPENAI_API_KEY not available")
|
||||
|
||||
model = create_model(
|
||||
basemodel="deepseek/deepseek-chat",
|
||||
api_key=os.getenv("OPENAI_API_KEY"),
|
||||
base_url=os.getenv("OPENAI_API_BASE"),
|
||||
temperature=0,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# Verify it's a ChatDeepSeek instance
|
||||
assert model.__class__.__name__ == "ChatDeepSeek"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deepseek_tool_calls_args_are_dicts(self):
|
||||
"""Test that DeepSeek tool_calls.args are dicts, not strings"""
|
||||
# Skip if no API key
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
pytest.skip("OPENAI_API_KEY not available")
|
||||
|
||||
# Create DeepSeek model
|
||||
model = create_model(
|
||||
basemodel="deepseek/deepseek-chat",
|
||||
api_key=os.getenv("OPENAI_API_KEY"),
|
||||
base_url=os.getenv("OPENAI_API_BASE"),
|
||||
temperature=0,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# Bind a simple math tool
|
||||
from langchain_core.tools import tool
|
||||
|
||||
@tool
|
||||
def add(a: float, b: float) -> float:
|
||||
"""Add two numbers"""
|
||||
return a + b
|
||||
|
||||
model_with_tools = model.bind_tools([add])
|
||||
|
||||
# Invoke with a query that should trigger tool call
|
||||
result = await model_with_tools.ainvoke(
|
||||
"What is 5 plus 3?"
|
||||
)
|
||||
|
||||
# Verify response is AIMessage
|
||||
assert isinstance(result, AIMessage)
|
||||
|
||||
# Verify tool_calls exist
|
||||
assert len(result.tool_calls) > 0, "Expected at least one tool call"
|
||||
|
||||
# Verify args are dicts, not strings
|
||||
for tool_call in result.tool_calls:
|
||||
assert isinstance(tool_call['args'], dict), \
|
||||
f"tool_calls.args should be dict, got {type(tool_call['args'])}"
|
||||
assert 'a' in tool_call['args'], "Missing expected arg 'a'"
|
||||
assert 'b' in tool_call['args'], "Missing expected arg 'b'"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deepseek_no_pydantic_validation_errors(self):
|
||||
"""Test that DeepSeek doesn't produce Pydantic validation errors"""
|
||||
# Skip if no API key
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
pytest.skip("OPENAI_API_KEY not available")
|
||||
|
||||
model = create_model(
|
||||
basemodel="deepseek/deepseek-chat",
|
||||
api_key=os.getenv("OPENAI_API_KEY"),
|
||||
base_url=os.getenv("OPENAI_API_BASE"),
|
||||
temperature=0,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
from langchain_core.tools import tool
|
||||
|
||||
@tool
|
||||
def multiply(a: float, b: float) -> float:
|
||||
"""Multiply two numbers"""
|
||||
return a * b
|
||||
|
||||
model_with_tools = model.bind_tools([multiply])
|
||||
|
||||
# This should NOT raise Pydantic validation errors
|
||||
try:
|
||||
result = await model_with_tools.ainvoke(
|
||||
"Calculate 7 times 8"
|
||||
)
|
||||
assert isinstance(result, AIMessage)
|
||||
except Exception as e:
|
||||
# Check that it's not a Pydantic validation error
|
||||
error_msg = str(e).lower()
|
||||
assert "validation error" not in error_msg, \
|
||||
f"Pydantic validation error occurred: {e}"
|
||||
assert "input should be a valid dictionary" not in error_msg, \
|
||||
f"tool_calls.args validation error occurred: {e}"
|
||||
# Re-raise if it's a different error
|
||||
raise
|
||||
219
tests/unit/test_calculate_final_position.py
Normal file
219
tests/unit/test_calculate_final_position.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""Test _calculate_final_position_from_actions method."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from agent.base_agent.base_agent import BaseAgent
|
||||
from api.database import Database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_db():
|
||||
"""Create test database with schema."""
|
||||
db = Database(":memory:")
|
||||
|
||||
# Create jobs record
|
||||
db.connection.execute("""
|
||||
INSERT INTO jobs (job_id, config_path, status, date_range, models, created_at)
|
||||
VALUES ('test-job', 'test.json', 'running', '2025-10-07 to 2025-10-07', 'gpt-5', '2025-10-07T00:00:00Z')
|
||||
""")
|
||||
db.connection.commit()
|
||||
|
||||
return db
|
||||
|
||||
|
||||
def test_calculate_final_position_first_day_with_trades(test_db):
|
||||
"""Test calculating final position on first trading day with multiple trades."""
|
||||
|
||||
# Create trading_day for first day
|
||||
trading_day_id = test_db.create_trading_day(
|
||||
job_id='test-job',
|
||||
model='gpt-5',
|
||||
date='2025-10-07',
|
||||
starting_cash=10000.0,
|
||||
starting_portfolio_value=10000.0,
|
||||
daily_profit=0.0,
|
||||
daily_return_pct=0.0,
|
||||
ending_cash=10000.0, # Not yet calculated
|
||||
ending_portfolio_value=10000.0, # Not yet calculated
|
||||
days_since_last_trading=1
|
||||
)
|
||||
|
||||
# Add 15 buy actions (matching your real data)
|
||||
actions_data = [
|
||||
("MSFT", 3, 528.285, "buy"),
|
||||
("GOOGL", 6, 248.27, "buy"),
|
||||
("NVDA", 10, 186.23, "buy"),
|
||||
("LRCX", 6, 149.23, "buy"),
|
||||
("AVGO", 2, 337.025, "buy"),
|
||||
("AMZN", 5, 220.88, "buy"),
|
||||
("MSFT", 2, 528.285, "buy"), # Additional MSFT
|
||||
("AMD", 4, 214.85, "buy"),
|
||||
("CRWD", 1, 497.0, "buy"),
|
||||
("QCOM", 4, 169.9, "buy"),
|
||||
("META", 1, 717.72, "buy"),
|
||||
("NVDA", 20, 186.23, "buy"), # Additional NVDA
|
||||
("NVDA", 13, 186.23, "buy"), # Additional NVDA
|
||||
("NVDA", 20, 186.23, "buy"), # Additional NVDA
|
||||
("NVDA", 53, 186.23, "buy"), # Additional NVDA
|
||||
]
|
||||
|
||||
for symbol, quantity, price, action_type in actions_data:
|
||||
test_db.create_action(
|
||||
trading_day_id=trading_day_id,
|
||||
action_type=action_type,
|
||||
symbol=symbol,
|
||||
quantity=quantity,
|
||||
price=price
|
||||
)
|
||||
|
||||
test_db.connection.commit()
|
||||
|
||||
# Create BaseAgent instance
|
||||
agent = BaseAgent(signature="gpt-5", basemodel="anthropic/claude-sonnet-4", stock_symbols=[])
|
||||
|
||||
# Mock Database() to return our test_db
|
||||
with patch('api.database.Database', return_value=test_db):
|
||||
# Calculate final position
|
||||
holdings, cash = agent._calculate_final_position_from_actions(
|
||||
trading_day_id=trading_day_id,
|
||||
starting_cash=10000.0
|
||||
)
|
||||
|
||||
# Verify holdings
|
||||
assert holdings["MSFT"] == 5, f"Expected 5 MSFT (3+2) but got {holdings.get('MSFT', 0)}"
|
||||
assert holdings["GOOGL"] == 6, f"Expected 6 GOOGL but got {holdings.get('GOOGL', 0)}"
|
||||
assert holdings["NVDA"] == 116, f"Expected 116 NVDA (10+20+13+20+53) but got {holdings.get('NVDA', 0)}"
|
||||
assert holdings["LRCX"] == 6, f"Expected 6 LRCX but got {holdings.get('LRCX', 0)}"
|
||||
assert holdings["AVGO"] == 2, f"Expected 2 AVGO but got {holdings.get('AVGO', 0)}"
|
||||
assert holdings["AMZN"] == 5, f"Expected 5 AMZN but got {holdings.get('AMZN', 0)}"
|
||||
assert holdings["AMD"] == 4, f"Expected 4 AMD but got {holdings.get('AMD', 0)}"
|
||||
assert holdings["CRWD"] == 1, f"Expected 1 CRWD but got {holdings.get('CRWD', 0)}"
|
||||
assert holdings["QCOM"] == 4, f"Expected 4 QCOM but got {holdings.get('QCOM', 0)}"
|
||||
assert holdings["META"] == 1, f"Expected 1 META but got {holdings.get('META', 0)}"
|
||||
|
||||
# Verify cash (should be less than starting)
|
||||
assert cash < 10000.0, f"Cash should be less than $10,000 but got ${cash}"
|
||||
|
||||
# Calculate expected cash
|
||||
total_spent = sum(qty * price for _, qty, price, _ in actions_data)
|
||||
expected_cash = 10000.0 - total_spent
|
||||
assert abs(cash - expected_cash) < 0.01, f"Expected cash ${expected_cash} but got ${cash}"
|
||||
|
||||
|
||||
def test_calculate_final_position_with_previous_holdings(test_db):
|
||||
"""Test calculating final position when starting with existing holdings."""
|
||||
|
||||
# Create day 1 with ending holdings
|
||||
day1_id = test_db.create_trading_day(
|
||||
job_id='test-job',
|
||||
model='gpt-5',
|
||||
date='2025-10-06',
|
||||
starting_cash=10000.0,
|
||||
starting_portfolio_value=10000.0,
|
||||
daily_profit=0.0,
|
||||
daily_return_pct=0.0,
|
||||
ending_cash=8000.0,
|
||||
ending_portfolio_value=9500.0,
|
||||
days_since_last_trading=1
|
||||
)
|
||||
|
||||
# Add day 1 ending holdings
|
||||
test_db.create_holding(day1_id, "AAPL", 10)
|
||||
test_db.create_holding(day1_id, "MSFT", 5)
|
||||
|
||||
# Create day 2
|
||||
day2_id = test_db.create_trading_day(
|
||||
job_id='test-job',
|
||||
model='gpt-5',
|
||||
date='2025-10-07',
|
||||
starting_cash=8000.0,
|
||||
starting_portfolio_value=9500.0,
|
||||
daily_profit=0.0,
|
||||
daily_return_pct=0.0,
|
||||
ending_cash=8000.0,
|
||||
ending_portfolio_value=9500.0,
|
||||
days_since_last_trading=1
|
||||
)
|
||||
|
||||
# Add day 2 actions (buy more AAPL, sell some MSFT)
|
||||
test_db.create_action(day2_id, "buy", "AAPL", 5, 150.0)
|
||||
test_db.create_action(day2_id, "sell", "MSFT", 2, 500.0)
|
||||
|
||||
test_db.connection.commit()
|
||||
|
||||
# Create BaseAgent instance
|
||||
agent = BaseAgent(signature="gpt-5", basemodel="anthropic/claude-sonnet-4", stock_symbols=[])
|
||||
|
||||
# Mock Database() to return our test_db
|
||||
with patch('api.database.Database', return_value=test_db):
|
||||
# Calculate final position for day 2
|
||||
holdings, cash = agent._calculate_final_position_from_actions(
|
||||
trading_day_id=day2_id,
|
||||
starting_cash=8000.0
|
||||
)
|
||||
|
||||
# Verify holdings
|
||||
assert holdings["AAPL"] == 15, f"Expected 15 AAPL (10+5) but got {holdings.get('AAPL', 0)}"
|
||||
assert holdings["MSFT"] == 3, f"Expected 3 MSFT (5-2) but got {holdings.get('MSFT', 0)}"
|
||||
|
||||
# Verify cash
|
||||
# Started: 8000
|
||||
# Buy 5 AAPL @ 150 = -750
|
||||
# Sell 2 MSFT @ 500 = +1000
|
||||
# Final: 8000 - 750 + 1000 = 8250
|
||||
expected_cash = 8000.0 - (5 * 150.0) + (2 * 500.0)
|
||||
assert abs(cash - expected_cash) < 0.01, f"Expected cash ${expected_cash} but got ${cash}"
|
||||
|
||||
|
||||
def test_calculate_final_position_no_trades(test_db):
|
||||
"""Test calculating final position when no trades were executed."""
|
||||
|
||||
# Create day 1 with ending holdings
|
||||
day1_id = test_db.create_trading_day(
|
||||
job_id='test-job',
|
||||
model='gpt-5',
|
||||
date='2025-10-06',
|
||||
starting_cash=10000.0,
|
||||
starting_portfolio_value=10000.0,
|
||||
daily_profit=0.0,
|
||||
daily_return_pct=0.0,
|
||||
ending_cash=9000.0,
|
||||
ending_portfolio_value=10000.0,
|
||||
days_since_last_trading=1
|
||||
)
|
||||
|
||||
test_db.create_holding(day1_id, "AAPL", 10)
|
||||
|
||||
# Create day 2 with NO actions
|
||||
day2_id = test_db.create_trading_day(
|
||||
job_id='test-job',
|
||||
model='gpt-5',
|
||||
date='2025-10-07',
|
||||
starting_cash=9000.0,
|
||||
starting_portfolio_value=10000.0,
|
||||
daily_profit=0.0,
|
||||
daily_return_pct=0.0,
|
||||
ending_cash=9000.0,
|
||||
ending_portfolio_value=10000.0,
|
||||
days_since_last_trading=1
|
||||
)
|
||||
|
||||
# No actions added
|
||||
test_db.connection.commit()
|
||||
|
||||
# Create BaseAgent instance
|
||||
agent = BaseAgent(signature="gpt-5", basemodel="anthropic/claude-sonnet-4", stock_symbols=[])
|
||||
|
||||
# Mock Database() to return our test_db
|
||||
with patch('api.database.Database', return_value=test_db):
|
||||
# Calculate final position
|
||||
holdings, cash = agent._calculate_final_position_from_actions(
|
||||
trading_day_id=day2_id,
|
||||
starting_cash=9000.0
|
||||
)
|
||||
|
||||
# Verify holdings unchanged
|
||||
assert holdings["AAPL"] == 10, f"Expected 10 AAPL but got {holdings.get('AAPL', 0)}"
|
||||
|
||||
# Verify cash unchanged
|
||||
assert abs(cash - 9000.0) < 0.01, f"Expected cash $9000 but got ${cash}"
|
||||
192
tests/unit/test_context_injector.py
Normal file
192
tests/unit/test_context_injector.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""Test ContextInjector position tracking functionality."""
|
||||
|
||||
import pytest
|
||||
from agent.context_injector import ContextInjector
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def injector():
|
||||
"""Create a ContextInjector instance for testing."""
|
||||
return ContextInjector(
|
||||
signature="test-model",
|
||||
today_date="2025-01-15",
|
||||
job_id="test-job-123",
|
||||
trading_day_id=1
|
||||
)
|
||||
|
||||
|
||||
class MockRequest:
|
||||
"""Mock MCP tool request."""
|
||||
def __init__(self, name, args=None):
|
||||
self.name = name
|
||||
self.args = args or {}
|
||||
|
||||
|
||||
async def mock_handler_success(request):
|
||||
"""Mock handler that returns a successful position update."""
|
||||
# Simulate a successful trade returning updated position
|
||||
if request.name == "sell":
|
||||
return {
|
||||
"CASH": 1100.0,
|
||||
"AAPL": 7,
|
||||
"MSFT": 5
|
||||
}
|
||||
elif request.name == "buy":
|
||||
return {
|
||||
"CASH": 50.0,
|
||||
"AAPL": 7,
|
||||
"MSFT": 12
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
async def mock_handler_error(request):
|
||||
"""Mock handler that returns an error."""
|
||||
return {"error": "Insufficient cash"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_injector_initializes_with_no_position(injector):
|
||||
"""Test that ContextInjector starts with no position state."""
|
||||
assert injector._current_position is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_injector_reset_position(injector):
|
||||
"""Test that reset_position() clears position state."""
|
||||
# Set some position state
|
||||
injector._current_position = {"CASH": 5000.0, "AAPL": 10}
|
||||
|
||||
# Reset
|
||||
injector.reset_position()
|
||||
|
||||
assert injector._current_position is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_injector_injects_parameters(injector):
|
||||
"""Test that context parameters are injected into buy/sell requests."""
|
||||
request = MockRequest("buy", {"symbol": "AAPL", "amount": 10})
|
||||
|
||||
# Mock handler that just returns the request args
|
||||
async def handler(req):
|
||||
return req.args
|
||||
|
||||
result = await injector(request, handler)
|
||||
|
||||
# Verify context was injected
|
||||
assert result["signature"] == "test-model"
|
||||
assert result["today_date"] == "2025-01-15"
|
||||
assert result["job_id"] == "test-job-123"
|
||||
assert result["trading_day_id"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_injector_tracks_position_after_successful_trade(injector):
|
||||
"""Test that position state is updated after successful trades."""
|
||||
assert injector._current_position is None
|
||||
|
||||
# Execute a sell trade
|
||||
request = MockRequest("sell", {"symbol": "AAPL", "amount": 3})
|
||||
result = await injector(request, mock_handler_success)
|
||||
|
||||
# Verify position was updated
|
||||
assert injector._current_position is not None
|
||||
assert injector._current_position["CASH"] == 1100.0
|
||||
assert injector._current_position["AAPL"] == 7
|
||||
assert injector._current_position["MSFT"] == 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_injector_injects_current_position_on_subsequent_trades(injector):
|
||||
"""Test that current position is injected into subsequent trade requests."""
|
||||
# First trade - establish position
|
||||
request1 = MockRequest("sell", {"symbol": "AAPL", "amount": 3})
|
||||
await injector(request1, mock_handler_success)
|
||||
|
||||
# Second trade - should receive current position
|
||||
request2 = MockRequest("buy", {"symbol": "MSFT", "amount": 7})
|
||||
|
||||
async def verify_injection_handler(req):
|
||||
# Verify that _current_position was injected
|
||||
assert "_current_position" in req.args
|
||||
assert req.args["_current_position"]["CASH"] == 1100.0
|
||||
assert req.args["_current_position"]["AAPL"] == 7
|
||||
return mock_handler_success(req)
|
||||
|
||||
await injector(request2, verify_injection_handler)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_injector_does_not_update_position_on_error(injector):
|
||||
"""Test that position state is NOT updated when trade fails."""
|
||||
# First successful trade
|
||||
request1 = MockRequest("sell", {"symbol": "AAPL", "amount": 3})
|
||||
await injector(request1, mock_handler_success)
|
||||
|
||||
original_position = injector._current_position.copy()
|
||||
|
||||
# Second trade that fails
|
||||
request2 = MockRequest("buy", {"symbol": "MSFT", "amount": 100})
|
||||
result = await injector(request2, mock_handler_error)
|
||||
|
||||
# Verify position was NOT updated
|
||||
assert injector._current_position == original_position
|
||||
assert "error" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_injector_does_not_inject_position_for_non_trade_tools(injector):
|
||||
"""Test that position is not injected for non-buy/sell tools."""
|
||||
# Set up position state
|
||||
injector._current_position = {"CASH": 5000.0, "AAPL": 10}
|
||||
|
||||
# Call a non-trade tool
|
||||
request = MockRequest("search", {"query": "market news"})
|
||||
|
||||
async def verify_no_injection_handler(req):
|
||||
assert "_current_position" not in req.args
|
||||
return {"results": []}
|
||||
|
||||
await injector(request, verify_no_injection_handler)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_injector_full_trading_session_simulation(injector):
|
||||
"""Test full trading session with multiple trades and position tracking."""
|
||||
# Reset position at start of day
|
||||
injector.reset_position()
|
||||
assert injector._current_position is None
|
||||
|
||||
# Trade 1: Sell AAPL
|
||||
request1 = MockRequest("sell", {"symbol": "AAPL", "amount": 3})
|
||||
|
||||
async def handler1(req):
|
||||
# First trade should NOT have injected position
|
||||
assert req.args.get("_current_position") is None
|
||||
return {"CASH": 1100.0, "AAPL": 7}
|
||||
|
||||
result1 = await injector(request1, handler1)
|
||||
assert injector._current_position == {"CASH": 1100.0, "AAPL": 7}
|
||||
|
||||
# Trade 2: Buy MSFT (should use position from trade 1)
|
||||
request2 = MockRequest("buy", {"symbol": "MSFT", "amount": 7})
|
||||
|
||||
async def handler2(req):
|
||||
# Second trade SHOULD have injected position from trade 1
|
||||
assert req.args["_current_position"]["CASH"] == 1100.0
|
||||
assert req.args["_current_position"]["AAPL"] == 7
|
||||
return {"CASH": 50.0, "AAPL": 7, "MSFT": 7}
|
||||
|
||||
result2 = await injector(request2, handler2)
|
||||
assert injector._current_position == {"CASH": 50.0, "AAPL": 7, "MSFT": 7}
|
||||
|
||||
# Trade 3: Failed trade (should not update position)
|
||||
request3 = MockRequest("buy", {"symbol": "GOOGL", "amount": 100})
|
||||
|
||||
async def handler3(req):
|
||||
return {"error": "Insufficient cash", "cash_available": 50.0}
|
||||
|
||||
result3 = await injector(request3, handler3)
|
||||
# Position should remain unchanged after failed trade
|
||||
assert injector._current_position == {"CASH": 50.0, "AAPL": 7, "MSFT": 7}
|
||||
@@ -6,7 +6,7 @@ from api.database import Database
|
||||
|
||||
|
||||
def test_get_position_from_new_schema():
|
||||
"""Test position retrieval from trading_days + holdings."""
|
||||
"""Test position retrieval from trading_days + holdings (previous day)."""
|
||||
|
||||
# Create test database
|
||||
db = Database(":memory:")
|
||||
@@ -14,11 +14,11 @@ def test_get_position_from_new_schema():
|
||||
# Create prerequisite: jobs record
|
||||
db.connection.execute("""
|
||||
INSERT INTO jobs (job_id, config_path, status, date_range, models, created_at)
|
||||
VALUES ('test-job-123', 'test_config.json', 'running', '2025-01-15 to 2025-01-15', 'test-model', '2025-01-15T10:00:00Z')
|
||||
VALUES ('test-job-123', 'test_config.json', 'running', '2025-01-14 to 2025-01-16', 'test-model', '2025-01-14T10:00:00Z')
|
||||
""")
|
||||
db.connection.commit()
|
||||
|
||||
# Create trading_day with holdings
|
||||
# Create trading_day with holdings for 2025-01-15
|
||||
trading_day_id = db.create_trading_day(
|
||||
job_id='test-job-123',
|
||||
model='test-model',
|
||||
@@ -32,7 +32,7 @@ def test_get_position_from_new_schema():
|
||||
days_since_last_trading=0
|
||||
)
|
||||
|
||||
# Add ending holdings
|
||||
# Add ending holdings for 2025-01-15
|
||||
db.create_holding(trading_day_id, 'AAPL', 10)
|
||||
db.create_holding(trading_day_id, 'MSFT', 5)
|
||||
|
||||
@@ -48,18 +48,19 @@ def test_get_position_from_new_schema():
|
||||
trade_module.get_db_connection = mock_get_db_connection
|
||||
|
||||
try:
|
||||
# Query position
|
||||
# Query position for NEXT day (2025-01-16)
|
||||
# Should retrieve previous day's (2025-01-15) ending position
|
||||
position, action_id = get_current_position_from_db(
|
||||
job_id='test-job-123',
|
||||
model='test-model',
|
||||
date='2025-01-15'
|
||||
date='2025-01-16' # Query for day AFTER the trading_day record
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert position['AAPL'] == 10
|
||||
assert position['MSFT'] == 5
|
||||
assert position['CASH'] == 8000.0
|
||||
assert action_id == 2 # 2 holdings = 2 actions
|
||||
# Verify we got the previous day's ending position
|
||||
assert position['AAPL'] == 10, f"Expected 10 AAPL but got {position.get('AAPL', 0)}"
|
||||
assert position['MSFT'] == 5, f"Expected 5 MSFT but got {position.get('MSFT', 0)}"
|
||||
assert position['CASH'] == 8000.0, f"Expected cash $8000 but got ${position['CASH']}"
|
||||
assert action_id == 2, f"Expected 2 holdings but got {action_id}"
|
||||
finally:
|
||||
# Restore original function
|
||||
trade_module.get_db_connection = original_get_db_connection
|
||||
@@ -95,3 +96,99 @@ def test_get_position_first_day():
|
||||
# Restore original function
|
||||
trade_module.get_db_connection = original_get_db_connection
|
||||
db.connection.close()
|
||||
|
||||
|
||||
def test_get_position_retrieves_previous_day_not_current():
|
||||
"""Test that get_current_position_from_db queries PREVIOUS day's ending, not current day.
|
||||
|
||||
This is the critical fix: when querying for day 2's starting position,
|
||||
it should return day 1's ending position, NOT day 2's (incomplete) position.
|
||||
"""
|
||||
|
||||
db = Database(":memory:")
|
||||
|
||||
# Create prerequisite: jobs record
|
||||
db.connection.execute("""
|
||||
INSERT INTO jobs (job_id, config_path, status, date_range, models, created_at)
|
||||
VALUES ('test-job-123', 'test_config.json', 'running', '2025-10-01 to 2025-10-03', 'gpt-5', '2025-10-01T10:00:00Z')
|
||||
""")
|
||||
db.connection.commit()
|
||||
|
||||
# Day 1: Create complete trading day with holdings
|
||||
day1_id = db.create_trading_day(
|
||||
job_id='test-job-123',
|
||||
model='gpt-5',
|
||||
date='2025-10-02',
|
||||
starting_cash=10000.0,
|
||||
starting_portfolio_value=10000.0,
|
||||
daily_profit=0.0,
|
||||
daily_return_pct=0.0,
|
||||
ending_cash=2500.0, # After buying stocks
|
||||
ending_portfolio_value=10000.0,
|
||||
days_since_last_trading=1
|
||||
)
|
||||
|
||||
# Day 1 ending holdings (7 AMZN, 5 GOOGL, 6 MU, 3 QCOM, 4 MSFT, 1 CRWD, 10 NVDA, 3 AVGO)
|
||||
db.create_holding(day1_id, 'AMZN', 7)
|
||||
db.create_holding(day1_id, 'GOOGL', 5)
|
||||
db.create_holding(day1_id, 'MU', 6)
|
||||
db.create_holding(day1_id, 'QCOM', 3)
|
||||
db.create_holding(day1_id, 'MSFT', 4)
|
||||
db.create_holding(day1_id, 'CRWD', 1)
|
||||
db.create_holding(day1_id, 'NVDA', 10)
|
||||
db.create_holding(day1_id, 'AVGO', 3)
|
||||
|
||||
# Day 2: Create incomplete trading day (just started, no holdings yet)
|
||||
day2_id = db.create_trading_day(
|
||||
job_id='test-job-123',
|
||||
model='gpt-5',
|
||||
date='2025-10-03',
|
||||
starting_cash=2500.0, # From day 1 ending
|
||||
starting_portfolio_value=10000.0,
|
||||
daily_profit=0.0,
|
||||
daily_return_pct=0.0,
|
||||
ending_cash=2500.0, # Not finalized yet
|
||||
ending_portfolio_value=10000.0, # Not finalized yet
|
||||
days_since_last_trading=1
|
||||
)
|
||||
# NOTE: No holdings created for day 2 yet (trading in progress)
|
||||
|
||||
db.connection.commit()
|
||||
|
||||
# Mock get_db_connection to return our test db
|
||||
import agent_tools.tool_trade as trade_module
|
||||
original_get_db_connection = trade_module.get_db_connection
|
||||
|
||||
def mock_get_db_connection(path):
|
||||
return db.connection
|
||||
|
||||
trade_module.get_db_connection = mock_get_db_connection
|
||||
|
||||
try:
|
||||
# Query starting position for day 2 (2025-10-03)
|
||||
# This should return day 1's ending position, NOT day 2's incomplete position
|
||||
position, action_id = get_current_position_from_db(
|
||||
job_id='test-job-123',
|
||||
model='gpt-5',
|
||||
date='2025-10-03'
|
||||
)
|
||||
|
||||
# Verify we got day 1's ending position (8 holdings)
|
||||
assert position['CASH'] == 2500.0, f"Expected cash $2500 but got ${position['CASH']}"
|
||||
assert position['AMZN'] == 7, f"Expected 7 AMZN but got {position.get('AMZN', 0)}"
|
||||
assert position['GOOGL'] == 5, f"Expected 5 GOOGL but got {position.get('GOOGL', 0)}"
|
||||
assert position['MU'] == 6, f"Expected 6 MU but got {position.get('MU', 0)}"
|
||||
assert position['QCOM'] == 3, f"Expected 3 QCOM but got {position.get('QCOM', 0)}"
|
||||
assert position['MSFT'] == 4, f"Expected 4 MSFT but got {position.get('MSFT', 0)}"
|
||||
assert position['CRWD'] == 1, f"Expected 1 CRWD but got {position.get('CRWD', 0)}"
|
||||
assert position['NVDA'] == 10, f"Expected 10 NVDA but got {position.get('NVDA', 0)}"
|
||||
assert position['AVGO'] == 3, f"Expected 3 AVGO but got {position.get('AVGO', 0)}"
|
||||
assert action_id == 8, f"Expected 8 holdings but got {action_id}"
|
||||
|
||||
# Verify total holdings count (should NOT include day 2's empty holdings)
|
||||
assert len(position) == 9, f"Expected 9 items (8 stocks + CASH) but got {len(position)}"
|
||||
|
||||
finally:
|
||||
# Restore original function
|
||||
trade_module.get_db_connection = original_get_db_connection
|
||||
db.connection.close()
|
||||
|
||||
108
tests/unit/test_model_factory.py
Normal file
108
tests/unit/test_model_factory.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Unit tests for model factory - provider-specific model creation"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
from agent.model_factory import create_model
|
||||
|
||||
|
||||
class TestModelFactory:
|
||||
"""Tests for create_model factory function"""
|
||||
|
||||
@patch('agent.model_factory.ChatDeepSeek')
|
||||
def test_create_model_deepseek(self, mock_deepseek_class):
|
||||
"""Test that DeepSeek models use ChatDeepSeek"""
|
||||
mock_model = Mock()
|
||||
mock_deepseek_class.return_value = mock_model
|
||||
|
||||
result = create_model(
|
||||
basemodel="deepseek/deepseek-chat",
|
||||
api_key="test-key",
|
||||
base_url="https://api.deepseek.com",
|
||||
temperature=0.7,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# Verify ChatDeepSeek was called with correct params
|
||||
mock_deepseek_class.assert_called_once_with(
|
||||
model="deepseek-chat", # Extracted from "deepseek/deepseek-chat"
|
||||
api_key="test-key",
|
||||
base_url="https://api.deepseek.com",
|
||||
temperature=0.7,
|
||||
timeout=30
|
||||
)
|
||||
assert result == mock_model
|
||||
|
||||
@patch('agent.model_factory.ChatOpenAI')
|
||||
def test_create_model_openai(self, mock_openai_class):
|
||||
"""Test that OpenAI models use ChatOpenAI"""
|
||||
mock_model = Mock()
|
||||
mock_openai_class.return_value = mock_model
|
||||
|
||||
result = create_model(
|
||||
basemodel="openai/gpt-4",
|
||||
api_key="test-key",
|
||||
base_url="https://api.openai.com/v1",
|
||||
temperature=0.7,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# Verify ChatOpenAI was called with correct params
|
||||
mock_openai_class.assert_called_once_with(
|
||||
model="openai/gpt-4",
|
||||
api_key="test-key",
|
||||
base_url="https://api.openai.com/v1",
|
||||
temperature=0.7,
|
||||
timeout=30
|
||||
)
|
||||
assert result == mock_model
|
||||
|
||||
@patch('agent.model_factory.ChatOpenAI')
|
||||
def test_create_model_anthropic(self, mock_openai_class):
|
||||
"""Test that Anthropic models use ChatOpenAI (via compatibility)"""
|
||||
mock_model = Mock()
|
||||
mock_openai_class.return_value = mock_model
|
||||
|
||||
result = create_model(
|
||||
basemodel="anthropic/claude-sonnet-4.5",
|
||||
api_key="test-key",
|
||||
base_url="https://api.anthropic.com/v1",
|
||||
temperature=0.7,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# Verify ChatOpenAI was used (Anthropic via OpenAI-compatible endpoint)
|
||||
mock_openai_class.assert_called_once()
|
||||
assert result == mock_model
|
||||
|
||||
@patch('agent.model_factory.ChatOpenAI')
|
||||
def test_create_model_generic_provider(self, mock_openai_class):
|
||||
"""Test that unknown providers default to ChatOpenAI"""
|
||||
mock_model = Mock()
|
||||
mock_openai_class.return_value = mock_model
|
||||
|
||||
result = create_model(
|
||||
basemodel="custom/custom-model",
|
||||
api_key="test-key",
|
||||
base_url="https://api.custom.com",
|
||||
temperature=0.7,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# Should fall back to ChatOpenAI for unknown providers
|
||||
mock_openai_class.assert_called_once()
|
||||
assert result == mock_model
|
||||
|
||||
def test_create_model_deepseek_extracts_model_name(self):
|
||||
"""Test that DeepSeek model name is extracted correctly"""
|
||||
with patch('agent.model_factory.ChatDeepSeek') as mock_class:
|
||||
create_model(
|
||||
basemodel="deepseek/deepseek-chat-v3.1",
|
||||
api_key="key",
|
||||
base_url="url",
|
||||
temperature=0,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# Check that model param is just "deepseek-chat-v3.1"
|
||||
call_kwargs = mock_class.call_args[1]
|
||||
assert call_kwargs['model'] == "deepseek-chat-v3.1"
|
||||
@@ -63,7 +63,7 @@ class TestRuntimeConfigCreation:
|
||||
|
||||
assert config["TODAY_DATE"] == "2025-01-16"
|
||||
assert config["SIGNATURE"] == "gpt-5"
|
||||
assert config["IF_TRADE"] is False
|
||||
assert config["IF_TRADE"] is True
|
||||
assert config["JOB_ID"] == "test-job-123"
|
||||
|
||||
def test_create_runtime_config_unique_paths(self):
|
||||
@@ -108,6 +108,32 @@ class TestRuntimeConfigCreation:
|
||||
# Config file should exist
|
||||
assert os.path.exists(config_path)
|
||||
|
||||
def test_create_runtime_config_if_trade_defaults_true(self):
|
||||
"""Test that IF_TRADE initializes to True (trades expected by default)"""
|
||||
from api.runtime_manager import RuntimeConfigManager
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
manager = RuntimeConfigManager(data_dir=temp_dir)
|
||||
|
||||
config_path = manager.create_runtime_config(
|
||||
job_id="test-job-123",
|
||||
model_sig="test-model",
|
||||
date="2025-01-16",
|
||||
trading_day_id=1
|
||||
)
|
||||
|
||||
try:
|
||||
# Read the config file
|
||||
with open(config_path, 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Verify IF_TRADE is True by default
|
||||
assert config["IF_TRADE"] is True, "IF_TRADE should initialize to True"
|
||||
finally:
|
||||
# Cleanup
|
||||
if os.path.exists(config_path):
|
||||
os.remove(config_path)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRuntimeConfigCleanup:
|
||||
|
||||
@@ -295,3 +295,190 @@ def test_sell_writes_to_actions_table(test_db, monkeypatch):
|
||||
assert row[1] == 'AAPL'
|
||||
assert row[2] == 5
|
||||
assert row[3] == 160.0
|
||||
|
||||
|
||||
def test_intraday_position_tracking_sell_then_buy(test_db, monkeypatch):
|
||||
"""Test that sell proceeds are immediately available for subsequent buys."""
|
||||
db, trading_day_id = test_db
|
||||
|
||||
# Setup: Create starting position with AAPL shares and limited cash
|
||||
db.create_holding(trading_day_id, 'AAPL', 10)
|
||||
db.connection.commit()
|
||||
|
||||
# Create a mock connection wrapper
|
||||
class MockConnection:
|
||||
def __init__(self, real_conn):
|
||||
self.real_conn = real_conn
|
||||
|
||||
def cursor(self):
|
||||
return self.real_conn.cursor()
|
||||
|
||||
def commit(self):
|
||||
return self.real_conn.commit()
|
||||
|
||||
def rollback(self):
|
||||
return self.real_conn.rollback()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
mock_conn = MockConnection(db.connection)
|
||||
monkeypatch.setattr('agent_tools.tool_trade.get_db_connection',
|
||||
lambda x: mock_conn)
|
||||
|
||||
# Mock get_current_position_from_db to return starting position
|
||||
monkeypatch.setattr('agent_tools.tool_trade.get_current_position_from_db',
|
||||
lambda job_id, sig, date: ({'CASH': 500.0, 'AAPL': 10}, 0))
|
||||
|
||||
monkeypatch.setenv('RUNTIME_ENV_PATH', '/tmp/test_runtime_intraday.json')
|
||||
|
||||
import json
|
||||
with open('/tmp/test_runtime_intraday.json', 'w') as f:
|
||||
json.dump({
|
||||
'TODAY_DATE': '2025-01-15',
|
||||
'SIGNATURE': 'test-model',
|
||||
'JOB_ID': 'test-job-123',
|
||||
'TRADING_DAY_ID': trading_day_id
|
||||
}, f)
|
||||
|
||||
# Mock prices: AAPL sells for 200, MSFT costs 150
|
||||
def mock_get_prices(date, symbols):
|
||||
if 'AAPL' in symbols:
|
||||
return {'AAPL_price': 200.0}
|
||||
elif 'MSFT' in symbols:
|
||||
return {'MSFT_price': 150.0}
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr('agent_tools.tool_trade.get_open_prices', mock_get_prices)
|
||||
|
||||
# Step 1: Sell 3 shares of AAPL for 600.0
|
||||
# Starting cash: 500.0, proceeds: 600.0, new cash: 1100.0
|
||||
result_sell = _sell_impl(
|
||||
symbol='AAPL',
|
||||
amount=3,
|
||||
signature='test-model',
|
||||
today_date='2025-01-15',
|
||||
job_id='test-job-123',
|
||||
trading_day_id=trading_day_id,
|
||||
_current_position=None # Use database position (starting position)
|
||||
)
|
||||
|
||||
assert 'error' not in result_sell, f"Sell should succeed: {result_sell}"
|
||||
assert result_sell['CASH'] == 1100.0, "Cash should be 500 + (3 * 200) = 1100"
|
||||
assert result_sell['AAPL'] == 7, "AAPL shares should be 10 - 3 = 7"
|
||||
|
||||
# Step 2: Buy 7 shares of MSFT for 1050.0 using the position from the sell
|
||||
# This should work because we pass the updated position from step 1
|
||||
result_buy = _buy_impl(
|
||||
symbol='MSFT',
|
||||
amount=7,
|
||||
signature='test-model',
|
||||
today_date='2025-01-15',
|
||||
job_id='test-job-123',
|
||||
trading_day_id=trading_day_id,
|
||||
_current_position=result_sell # Use position from sell
|
||||
)
|
||||
|
||||
assert 'error' not in result_buy, f"Buy should succeed with sell proceeds: {result_buy}"
|
||||
assert result_buy['CASH'] == 50.0, "Cash should be 1100 - (7 * 150) = 50"
|
||||
assert result_buy['MSFT'] == 7, "MSFT shares should be 7"
|
||||
assert result_buy['AAPL'] == 7, "AAPL shares should still be 7"
|
||||
|
||||
# Verify both actions were recorded
|
||||
cursor = db.connection.execute("""
|
||||
SELECT action_type, symbol, quantity, price
|
||||
FROM actions
|
||||
WHERE trading_day_id = ?
|
||||
ORDER BY created_at
|
||||
""", (trading_day_id,))
|
||||
|
||||
actions = cursor.fetchall()
|
||||
assert len(actions) == 2, "Should have 2 actions (sell + buy)"
|
||||
assert actions[0][0] == 'sell' and actions[0][1] == 'AAPL'
|
||||
assert actions[1][0] == 'buy' and actions[1][1] == 'MSFT'
|
||||
|
||||
|
||||
def test_intraday_tracking_without_position_injection_fails(test_db, monkeypatch):
|
||||
"""Test that without position injection, sell proceeds are NOT available for subsequent buys."""
|
||||
db, trading_day_id = test_db
|
||||
|
||||
# Setup: Create starting position with AAPL shares and limited cash
|
||||
db.create_holding(trading_day_id, 'AAPL', 10)
|
||||
db.connection.commit()
|
||||
|
||||
# Create a mock connection wrapper
|
||||
class MockConnection:
|
||||
def __init__(self, real_conn):
|
||||
self.real_conn = real_conn
|
||||
|
||||
def cursor(self):
|
||||
return self.real_conn.cursor()
|
||||
|
||||
def commit(self):
|
||||
return self.real_conn.commit()
|
||||
|
||||
def rollback(self):
|
||||
return self.real_conn.rollback()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
mock_conn = MockConnection(db.connection)
|
||||
monkeypatch.setattr('agent_tools.tool_trade.get_db_connection',
|
||||
lambda x: mock_conn)
|
||||
|
||||
# Mock get_current_position_from_db to ALWAYS return starting position
|
||||
# (simulating the old buggy behavior)
|
||||
monkeypatch.setattr('agent_tools.tool_trade.get_current_position_from_db',
|
||||
lambda job_id, sig, date: ({'CASH': 500.0, 'AAPL': 10}, 0))
|
||||
|
||||
monkeypatch.setenv('RUNTIME_ENV_PATH', '/tmp/test_runtime_no_injection.json')
|
||||
|
||||
import json
|
||||
with open('/tmp/test_runtime_no_injection.json', 'w') as f:
|
||||
json.dump({
|
||||
'TODAY_DATE': '2025-01-15',
|
||||
'SIGNATURE': 'test-model',
|
||||
'JOB_ID': 'test-job-123',
|
||||
'TRADING_DAY_ID': trading_day_id
|
||||
}, f)
|
||||
|
||||
# Mock prices
|
||||
def mock_get_prices(date, symbols):
|
||||
if 'AAPL' in symbols:
|
||||
return {'AAPL_price': 200.0}
|
||||
elif 'MSFT' in symbols:
|
||||
return {'MSFT_price': 150.0}
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr('agent_tools.tool_trade.get_open_prices', mock_get_prices)
|
||||
|
||||
# Step 1: Sell 3 shares of AAPL
|
||||
result_sell = _sell_impl(
|
||||
symbol='AAPL',
|
||||
amount=3,
|
||||
signature='test-model',
|
||||
today_date='2025-01-15',
|
||||
job_id='test-job-123',
|
||||
trading_day_id=trading_day_id,
|
||||
_current_position=None # Don't inject position (old behavior)
|
||||
)
|
||||
|
||||
assert 'error' not in result_sell, "Sell should succeed"
|
||||
|
||||
# Step 2: Try to buy 7 shares of MSFT WITHOUT passing updated position
|
||||
# This should FAIL because it will query the database and get the original 500.0 cash
|
||||
result_buy = _buy_impl(
|
||||
symbol='MSFT',
|
||||
amount=7,
|
||||
signature='test-model',
|
||||
today_date='2025-01-15',
|
||||
job_id='test-job-123',
|
||||
trading_day_id=trading_day_id,
|
||||
_current_position=None # Don't inject position (old behavior)
|
||||
)
|
||||
|
||||
# This should fail with insufficient cash
|
||||
assert 'error' in result_buy, "Buy should fail without position injection"
|
||||
assert result_buy['error'] == 'Insufficient cash', f"Expected insufficient cash error, got: {result_buy}"
|
||||
assert result_buy['cash_available'] == 500.0, "Should see original cash, not updated cash"
|
||||
|
||||
@@ -337,12 +337,12 @@ def get_today_init_position_from_db(
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# Get most recent position before today
|
||||
# Get most recent trading day 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
|
||||
SELECT id, ending_cash
|
||||
FROM trading_days
|
||||
WHERE job_id = ? AND model = ? AND date < ?
|
||||
ORDER BY date DESC
|
||||
LIMIT 1
|
||||
""", (job_id, modelname, today_date))
|
||||
|
||||
@@ -353,15 +353,15 @@ def get_today_init_position_from_db(
|
||||
logger.info(f"No previous position found for {modelname}, returning initial cash")
|
||||
return {"CASH": 10000.0}
|
||||
|
||||
position_id, cash = row
|
||||
trading_day_id, cash = row
|
||||
position_dict = {"CASH": cash}
|
||||
|
||||
# Get holdings for this position
|
||||
# Get holdings for this trading day
|
||||
cursor.execute("""
|
||||
SELECT symbol, quantity
|
||||
FROM holdings
|
||||
WHERE position_id = ?
|
||||
""", (position_id,))
|
||||
WHERE trading_day_id = ?
|
||||
""", (trading_day_id,))
|
||||
|
||||
for symbol, quantity in cursor.fetchall():
|
||||
position_dict[symbol] = quantity
|
||||
|
||||
Reference in New Issue
Block a user