Files
AI-Trader/docs/plans/2025-11-03-daily-pnl-results-api-design.md

585 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Daily P&L Calculation & Results API Refactor - Design Document
**Date:** 2025-11-03
**Status:** Approved - Ready for Implementation
---
## Problem Statement
The current results API returns data in an action-centric format where every trade action is a separate record. This has several issues:
1. **Incorrect Daily Metrics:** `daily_profit` and `daily_return_pct` always return 0
2. **Data Structure:** Multiple position records per day with redundant portfolio snapshots
3. **API Design:** Separate `/results` and `/reasoning` endpoints that should be unified
4. **Missing Context:** No clear distinction between starting/ending positions for a day
**Example of Current Incorrect Output:**
```json
{
"daily_profit": 0,
"daily_return_pct": 0,
"portfolio_value": 10062.15
}
```
Even though portfolio clearly changed from $9,957.96 to $10,062.15.
---
## Solution Design
### Core Principles
1. **Day-Centric Data Model:** Each trading day is the primary unit, not individual actions
2. **Ledger-Based Holdings:** Use snapshot approach (ending holdings only) for performance
3. **Calculate P&L at Market Open:** Value yesterday's holdings at today's prices
4. **Unified API:** Single `/results` endpoint with optional reasoning parameter
5. **AI-Generated Summaries:** Create summaries during simulation, not on-demand
---
## Database Schema (Normalized)
### trading_days Table
**Purpose:** Core table for each model-day execution with daily metrics
```sql
CREATE TABLE trading_days (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT NOT NULL,
model TEXT NOT NULL,
date TEXT NOT NULL,
-- Starting state (cash only, holdings from previous day)
starting_cash REAL NOT NULL,
starting_portfolio_value REAL NOT NULL,
-- Daily performance metrics
daily_profit REAL NOT NULL,
daily_return_pct REAL NOT NULL,
-- Ending state (cash only, holdings in separate table)
ending_cash REAL NOT NULL,
ending_portfolio_value REAL NOT NULL,
-- Reasoning
reasoning_summary TEXT,
reasoning_full TEXT, -- JSON array
-- Metadata
total_actions INTEGER DEFAULT 0,
session_duration_seconds REAL,
days_since_last_trading INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
UNIQUE(job_id, model, date),
FOREIGN KEY (job_id) REFERENCES jobs(job_id)
);
CREATE INDEX idx_trading_days_lookup ON trading_days(job_id, model, date);
```
### holdings Table
**Purpose:** Ending portfolio snapshots (starting holdings derived from previous day)
```sql
CREATE TABLE holdings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trading_day_id INTEGER NOT NULL,
symbol TEXT NOT NULL,
quantity INTEGER NOT NULL,
FOREIGN KEY (trading_day_id) REFERENCES trading_days(id) ON DELETE CASCADE,
UNIQUE(trading_day_id, symbol)
);
CREATE INDEX idx_holdings_day ON holdings(trading_day_id);
```
**Key Design Decision:** Only store ending holdings. Starting holdings = previous day's ending holdings.
### actions Table
**Purpose:** Trade execution ledger
```sql
CREATE TABLE actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trading_day_id INTEGER NOT NULL,
action_type TEXT NOT NULL, -- 'buy', 'sell', 'no_trade'
symbol TEXT,
quantity INTEGER,
price REAL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (trading_day_id) REFERENCES trading_days(id) ON DELETE CASCADE
);
CREATE INDEX idx_actions_day ON actions(trading_day_id);
```
---
## Daily P&L Calculation Logic
### When to Calculate
**Timing:** At the start of each trading day, after loading current market prices.
### Calculation Method
```python
def calculate_daily_pnl(previous_day, current_date, current_prices):
"""
Calculate P&L by valuing yesterday's holdings at today's prices.
Args:
previous_day: {
"date": "2025-01-15",
"ending_cash": 9000.0,
"ending_portfolio_value": 10000.0,
"holdings": [{"symbol": "AAPL", "quantity": 10}]
}
current_date: "2025-01-16"
current_prices: {"AAPL": 150.0}
Returns:
{
"daily_profit": 500.0,
"daily_return_pct": 5.0,
"starting_portfolio_value": 10500.0,
"days_since_last_trading": 1
}
"""
if previous_day is None:
# First trading day
return {
"daily_profit": 0.0,
"daily_return_pct": 0.0,
"starting_portfolio_value": initial_cash,
"days_since_last_trading": 0
}
# Value previous holdings at current prices
current_value = cash
for holding in previous_holdings:
current_value += holding["quantity"] * current_prices[holding["symbol"]]
# Calculate P&L
previous_value = previous_day["ending_portfolio_value"]
daily_profit = current_value - previous_value
daily_return_pct = (daily_profit / previous_value) * 100
return {
"daily_profit": daily_profit,
"daily_return_pct": daily_return_pct,
"starting_portfolio_value": current_value,
"days_since_last_trading": calculate_day_gap(previous_day["date"], current_date)
}
```
### Key Insight: P&L from Price Changes, Not Trades
**Important:** Since all trades within a day use the same day's prices, portfolio value doesn't change between trades. P&L only changes when moving to the next day with new prices.
**Example:**
- Friday close: Hold 10 AAPL at $100 = $1000 total
- Monday open: AAPL now $110
- Monday P&L: 10 × ($110 - $100) = **+$100 profit**
- All Monday trades use $110 price, so P&L remains constant for that day
---
## Weekend/Holiday Handling
### Problem
Trading days are not consecutive calendar days:
- Friday → Monday (3-day gap)
- Before holidays (4+ day gaps)
### Solution
Use `ORDER BY date DESC LIMIT 1` to find **most recent trading day**, not just previous calendar date.
```sql
SELECT td_prev.id
FROM trading_days td_current
JOIN trading_days td_prev ON
td_prev.job_id = td_current.job_id AND
td_prev.model = td_current.model AND
td_prev.date < td_current.date
WHERE td_current.id = ?
ORDER BY td_prev.date DESC
LIMIT 1
```
This automatically handles:
- Normal weekdays (1 day gap)
- Weekends (3 day gap)
- Long weekends (4+ day gap)
---
## Reasoning Summary Generation
### When to Generate
**Timing:** After trading session completes, before storing final results.
### Implementation
```python
async def generate_reasoning_summary(reasoning_log, ai_model):
"""
Use same AI model to summarize its own trading decisions.
Prompt: "Summarize your trading strategy and key decisions in 2-3 sentences."
"""
try:
summary = await ai_model.ainvoke([{
"role": "user",
"content": build_summary_prompt(reasoning_log)
}])
return extract_content(summary)
except Exception as e:
# Fallback: Statistical summary
return f"Executed {trade_count} trades using {search_count} searches."
```
### Model Choice
**Use same model that did the trading** (Option A from brainstorming):
- Pro: Consistency, model summarizing its own reasoning
- Pro: Simpler configuration
- Con: Extra API cost per day (acceptable for quality)
---
## Unified Results API
### Endpoint Design
```
GET /results?job_id={id}&model={sig}&date={date}&reasoning={level}
```
**Parameters:**
- `job_id` (optional) - Filter by job
- `model` (optional) - Filter by model
- `date` (optional) - Filter by date
- `reasoning` (optional) - `none` (default), `summary`, `full`
### Response Structure
```json
{
"count": 2,
"results": [
{
"date": "2025-10-06",
"model": "gpt-5",
"job_id": "d8b52033-...",
"starting_position": {
"holdings": [
{"symbol": "AMZN", "quantity": 11},
{"symbol": "MSFT", "quantity": 10}
],
"cash": 100.0,
"portfolio_value": 9900.0
},
"daily_metrics": {
"profit": 57.96,
"return_pct": 0.585,
"days_since_last_trading": 1
},
"trades": [
{
"action_type": "buy",
"symbol": "NVDA",
"quantity": 12,
"price": 186.23,
"created_at": "2025-10-06T14:30:00Z"
}
],
"final_position": {
"holdings": [
{"symbol": "AMZN", "quantity": 11},
{"symbol": "MSFT", "quantity": 10},
{"symbol": "NVDA", "quantity": 12}
],
"cash": 114.86,
"portfolio_value": 9957.96
},
"metadata": {
"total_actions": 1,
"session_duration_seconds": 45.2,
"completed_at": "2025-10-06T14:31:00Z"
},
"reasoning": null // or summary string or full array
}
]
}
```
### Reasoning Levels
**`reasoning=none`** (default)
- `"reasoning": null`
- Fastest, no DB lookup of reasoning fields
**`reasoning=summary`**
- `"reasoning": "Analyzed AAPL earnings. Bought 10 shares..."`
- Pre-generated AI summary (2-3 sentences)
**`reasoning=full`**
- `"reasoning": [{role: "assistant", content: "..."}, {...}]`
- Complete conversation log (JSON array)
---
## Implementation Flow
### Simulation Execution (per model-day)
```python
async def run_trading_session(date):
# 1. Get previous trading day data
previous_day = db.get_previous_trading_day(job_id, model, date)
# 2. Load today's prices
current_prices = get_prices_for_date(date)
# 3. Calculate daily P&L
pnl_metrics = calculate_daily_pnl(previous_day, date, current_prices)
# 4. Create trading_day record
trading_day_id = db.create_trading_day(
job_id, model, date,
starting_cash=cash,
starting_portfolio_value=pnl_metrics["starting_portfolio_value"],
daily_profit=pnl_metrics["daily_profit"],
daily_return_pct=pnl_metrics["daily_return_pct"],
# ... other fields
)
# 5. Run AI trading session
reasoning_log = []
for step in range(max_steps):
response = await ai_model.ainvoke(messages)
reasoning_log.append(response)
# Extract and execute trades
trades = extract_trades(response)
for trade in trades:
execute_trade(trade)
db.create_action(trading_day_id, trade)
if "<FINISH_SIGNAL>" in response:
break
# 6. Generate reasoning summary
summary = await generate_reasoning_summary(reasoning_log, ai_model)
# 7. Save final holdings
for symbol, quantity in holdings.items():
db.create_holding(trading_day_id, symbol, quantity)
# 8. Update trading_day with completion data
db.update_trading_day(
trading_day_id,
ending_cash=cash,
ending_portfolio_value=calculate_portfolio_value(),
reasoning_summary=summary,
reasoning_full=json.dumps(reasoning_log)
)
```
---
## Error Handling & Edge Cases
### First Trading Day
**Scenario:** No previous day exists
**Solution:** Return zero P&L, starting value = initial cash
```python
if previous_day is None:
return {
"daily_profit": 0.0,
"daily_return_pct": 0.0,
"starting_portfolio_value": initial_cash
}
```
### Weekend Gaps
**Scenario:** Friday → Monday (no trading Sat/Sun)
**Solution:** Query finds Friday as previous day automatically
**Metadata:** `days_since_last_trading: 3`
### Missing Price Data
**Scenario:** Holdings contain symbol with no price
**Solution:** Raise `ValueError` with clear message
```python
if symbol not in prices:
raise ValueError(f"Missing price data for {symbol} on {date}")
```
### Reasoning Summary Failure
**Scenario:** AI API fails when generating summary
**Solution:** Fallback to statistical summary
```python
return f"Executed {trade_count} trades using {search_count} searches. Full log available."
```
### Interrupted Trading Day
**Scenario:** Simulation crashes mid-day
**Solution:** Mark trading_day as failed, preserve partial actions for debugging
```python
db.execute("UPDATE trading_days SET status='failed', error_message=? WHERE id=?")
# Keep partial action records
```
---
## Migration Strategy
### Chosen Approach: Clean Break
**Decision:** Delete old `positions` table, start fresh with new schema.
**Rationale:**
- Simpler than data migration
- Acceptable for development phase
- Clean slate ensures no legacy issues
**Implementation:**
```python
def migrate_clean_database():
db.execute("DROP TABLE IF EXISTS positions")
create_trading_days_schema(db)
```
---
## Testing Strategy
### Unit Tests
- Daily P&L calculation logic
- First day (zero P&L)
- Positive/negative returns
- Weekend gaps
- Multiple holdings
- Database helper methods
- Create trading_day
- Get previous trading day
- Get starting/ending holdings
### Integration Tests
- BaseAgent P&L integration
- First day creates record with zero P&L
- Second day calculates P&L from price changes
- Results API
- Response structure
- Reasoning parameter variations
- Filtering by job_id, model, date
### End-to-End Tests
- Complete simulation workflow
- Multi-day simulation
- Verify holdings chain across days
- Verify P&L calculations
- Verify reasoning summaries
### Performance Tests
- Query speed with large datasets
- Reasoning inclusion impact on response time
---
## Success Criteria
**Functional Requirements:**
1. Daily P&L shows non-zero values when portfolio changes
2. Weekend gaps handled correctly (finds Friday when starting Monday)
3. Results API returns day-centric structure
4. Reasoning available at 3 levels (none/summary/full)
5. Holdings chain correctly across days
6. First day shows zero P&L
**Technical Requirements:**
1. Test coverage >85%
2. No data duplication (normalized schema)
3. API response time <2s for 100 days
4. Database auto-initializes new schema
5. Old positions table removed
**Documentation:**
1. API reference updated
2. Database schema documented
3. Implementation plan created
4. Migration guide provided
---
## Implementation Estimate
**Total Time:** 8-12 hours for experienced developer
**Breakdown:**
- Task 1: Database schema migration (1-2h)
- Task 2: Database helpers (1h)
- Task 3: P&L calculator (1h)
- Task 4: Reasoning summarizer (1h)
- Task 5: BaseAgent integration (2h)
- Task 6: Results API endpoint (1-2h)
- Task 7-11: Testing, docs, cleanup (2-3h)
---
## Future Enhancements (Not in Scope)
- Historical P&L charts
- Configurable summary model (cheaper alternative)
- Streaming reasoning logs
- P&L breakdown by position
- Benchmarking against indices
---
## References
- Implementation Plan: `docs/plans/2025-11-03-daily-pnl-results-api-refactor.md`
- Database Schema: `docs/developer/database-schema.md`
- API Reference: `API_REFERENCE.md`
---
**Status:** ✅ Design Approved - Ready for Implementation
**Next Step:** Execute implementation plan task-by-task