Initial commit of integrated agent system
This commit is contained in:
4
backend/utils/__init__.py
Normal file
4
backend/utils/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# This file can be empty
|
||||
|
||||
"""Utility modules for the application."""
|
||||
449
backend/utils/analyst_tracker.py
Normal file
449
backend/utils/analyst_tracker.py
Normal file
@@ -0,0 +1,449 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Analyst Performance Tracker
|
||||
Tracks analyst predictions and calculates win rates for leaderboard
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnalystPerformanceTracker:
|
||||
"""
|
||||
Tracks analyst predictions and evaluates accuracy
|
||||
|
||||
Workflow:
|
||||
1. Record analyst predictions for each ticker before market close
|
||||
2. After market close, evaluate predictions against actual returns
|
||||
3. Update leaderboard with win rates and statistics
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.daily_predictions = {}
|
||||
|
||||
def record_analyst_predictions(
|
||||
self,
|
||||
final_predictions: List[Dict[str, Any]],
|
||||
):
|
||||
"""
|
||||
Record predictions from analysts for the current trading day
|
||||
|
||||
Args:
|
||||
final_predictions: List of structured prediction results
|
||||
Format: [
|
||||
{
|
||||
'agent': 'analyst_name',
|
||||
'predictions': [
|
||||
{'ticker': 'AAPL', '
|
||||
direction': 'up',
|
||||
'confidence': 0.75},
|
||||
...
|
||||
]
|
||||
},
|
||||
...
|
||||
]
|
||||
tickers: List of tickers being analyzed
|
||||
"""
|
||||
self.daily_predictions = {}
|
||||
|
||||
direction_mapping = {
|
||||
"up": "long",
|
||||
"down": "short",
|
||||
"neutral": "hold",
|
||||
}
|
||||
|
||||
for result in final_predictions:
|
||||
analyst_id = result.get("agent")
|
||||
if not analyst_id:
|
||||
continue
|
||||
|
||||
predictions = result.get("predictions", [])
|
||||
|
||||
self.daily_predictions[analyst_id] = {}
|
||||
|
||||
for pred in predictions:
|
||||
ticker = pred.get("ticker")
|
||||
direction = pred.get("direction", "neutral")
|
||||
|
||||
if ticker:
|
||||
signal = direction_mapping.get(direction, "hold")
|
||||
self.daily_predictions[analyst_id][ticker] = signal
|
||||
|
||||
def evaluate_predictions(
|
||||
self,
|
||||
open_prices: Optional[Dict[str, float]],
|
||||
close_prices: Dict[str, float],
|
||||
date: str,
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Evaluate analyst predictions against actual market moves
|
||||
|
||||
Args:
|
||||
open_prices: Opening prices for each ticker
|
||||
close_prices: Closing prices for each ticker
|
||||
date: Trading date string (YYYY-MM-DD)
|
||||
|
||||
Returns:
|
||||
Dict mapping analyst_id to evaluation results
|
||||
"""
|
||||
evaluation_results = {}
|
||||
|
||||
# Map internal signal types to frontend display names
|
||||
signal_display_map = {
|
||||
"long": "bull",
|
||||
"short": "bear",
|
||||
"hold": "neutral",
|
||||
}
|
||||
|
||||
for analyst_id, predictions in self.daily_predictions.items():
|
||||
correct_long = 0
|
||||
correct_short = 0
|
||||
incorrect_long = 0
|
||||
incorrect_short = 0
|
||||
unknown_long = 0
|
||||
unknown_short = 0
|
||||
hold_count = 0
|
||||
|
||||
# Individual signal records for frontend display
|
||||
individual_signals: List[Dict[str, Any]] = []
|
||||
|
||||
for ticker, prediction in predictions.items():
|
||||
open_price = open_prices.get(ticker, 0)
|
||||
close_price = close_prices.get(ticker, 0)
|
||||
|
||||
signal_type = signal_display_map.get(prediction, "neutral")
|
||||
|
||||
# Cannot evaluate if prices are missing
|
||||
if open_price <= 0 or close_price <= 0:
|
||||
if prediction == "long":
|
||||
unknown_long += 1
|
||||
elif prediction == "short":
|
||||
unknown_short += 1
|
||||
|
||||
individual_signals.append(
|
||||
{
|
||||
"ticker": ticker,
|
||||
"signal": signal_type,
|
||||
"date": date,
|
||||
"is_correct": "unknown",
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
actual_return = (close_price - open_price) / open_price
|
||||
|
||||
if prediction == "long":
|
||||
is_correct = actual_return > 0
|
||||
if is_correct:
|
||||
correct_long += 1
|
||||
else:
|
||||
incorrect_long += 1
|
||||
|
||||
individual_signals.append(
|
||||
{
|
||||
"ticker": ticker,
|
||||
"signal": signal_type,
|
||||
"date": date,
|
||||
"is_correct": is_correct,
|
||||
},
|
||||
)
|
||||
|
||||
elif prediction == "short":
|
||||
is_correct = actual_return < 0
|
||||
if is_correct:
|
||||
correct_short += 1
|
||||
else:
|
||||
incorrect_short += 1
|
||||
|
||||
individual_signals.append(
|
||||
{
|
||||
"ticker": ticker,
|
||||
"signal": signal_type,
|
||||
"date": date,
|
||||
"is_correct": is_correct,
|
||||
},
|
||||
)
|
||||
|
||||
elif prediction == "hold":
|
||||
hold_count += 1
|
||||
individual_signals.append(
|
||||
{
|
||||
"ticker": ticker,
|
||||
"signal": signal_type,
|
||||
"date": date,
|
||||
"is_correct": None,
|
||||
},
|
||||
)
|
||||
|
||||
total_long = correct_long + incorrect_long + unknown_long
|
||||
total_short = correct_short + incorrect_short + unknown_short
|
||||
evaluated_long = correct_long + incorrect_long
|
||||
evaluated_short = correct_short + incorrect_short
|
||||
total_evaluated = evaluated_long + evaluated_short
|
||||
correct_predictions = correct_long + correct_short
|
||||
|
||||
win_rate = (
|
||||
correct_predictions / total_evaluated
|
||||
if total_evaluated > 0
|
||||
else None
|
||||
)
|
||||
|
||||
evaluation_results[analyst_id] = {
|
||||
"total_predictions": total_evaluated,
|
||||
"correct_predictions": correct_predictions,
|
||||
"win_rate": win_rate,
|
||||
"bull": {
|
||||
"n": total_long,
|
||||
"win": correct_long,
|
||||
"unknown": unknown_long,
|
||||
},
|
||||
"bear": {
|
||||
"n": total_short,
|
||||
"win": correct_short,
|
||||
"unknown": unknown_short,
|
||||
},
|
||||
"hold": hold_count,
|
||||
"signals": individual_signals,
|
||||
}
|
||||
|
||||
return evaluation_results
|
||||
|
||||
def clear_daily_predictions(self):
|
||||
"""Clear predictions after evaluation"""
|
||||
self.daily_predictions = {}
|
||||
|
||||
def _process_single_pm_decision(
|
||||
self,
|
||||
_ticker: str,
|
||||
decision: Dict,
|
||||
open_price: float,
|
||||
close_price: float,
|
||||
_date: str,
|
||||
) -> Tuple[str, Optional[bool], str]:
|
||||
"""
|
||||
Process a single PM decision and evaluate correctness
|
||||
|
||||
Returns:
|
||||
Tuple of (prediction, is_correct, signal_type)
|
||||
"""
|
||||
action = decision.get("action", "hold")
|
||||
|
||||
# Convert action to prediction format
|
||||
if action in ["buy", "long"]:
|
||||
prediction = "long"
|
||||
elif action in ["sell", "short"]:
|
||||
prediction = "short"
|
||||
else:
|
||||
prediction = "hold"
|
||||
|
||||
signal_display_map = {
|
||||
"long": "bull",
|
||||
"short": "bear",
|
||||
"hold": "neutral",
|
||||
}
|
||||
signal_type = signal_display_map.get(prediction, "neutral")
|
||||
|
||||
# Handle invalid prices
|
||||
if open_price <= 0 or close_price <= 0:
|
||||
return prediction, None, signal_type
|
||||
|
||||
# Evaluate correctness
|
||||
actual_return = (close_price - open_price) / open_price
|
||||
|
||||
if prediction == "long":
|
||||
is_correct = actual_return > 0
|
||||
elif prediction == "short":
|
||||
is_correct = actual_return < 0
|
||||
else: # hold
|
||||
is_correct = None
|
||||
|
||||
return prediction, is_correct, signal_type
|
||||
|
||||
def evaluate_pm_decisions(
|
||||
self,
|
||||
pm_decisions: Dict[str, Dict],
|
||||
open_prices: Optional[Dict[str, float]],
|
||||
close_prices: Dict[str, float],
|
||||
date: str,
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Evaluate PM's trading decisions against actual market moves
|
||||
|
||||
Args:
|
||||
pm_decisions: PM decisions {ticker: {action, quantity, ...}}
|
||||
open_prices: Opening prices for each ticker
|
||||
close_prices: Closing prices for each ticker
|
||||
date: Trading date string (YYYY-MM-DD)
|
||||
|
||||
Returns:
|
||||
Dict with 'portfolio_manager' key containing evaluation results
|
||||
"""
|
||||
if not pm_decisions or not open_prices or not close_prices:
|
||||
return {}
|
||||
|
||||
correct_long = 0
|
||||
correct_short = 0
|
||||
incorrect_long = 0
|
||||
incorrect_short = 0
|
||||
unknown_long = 0
|
||||
unknown_short = 0
|
||||
hold_count = 0
|
||||
|
||||
individual_signals: List[Dict[str, Any]] = []
|
||||
|
||||
for ticker, decision in pm_decisions.items():
|
||||
open_price = open_prices.get(ticker, 0)
|
||||
close_price = close_prices.get(ticker, 0)
|
||||
|
||||
(
|
||||
prediction,
|
||||
is_correct,
|
||||
signal_type,
|
||||
) = self._process_single_pm_decision(
|
||||
ticker,
|
||||
decision,
|
||||
open_price,
|
||||
close_price,
|
||||
date,
|
||||
)
|
||||
|
||||
if is_correct is None and (open_price <= 0 or close_price <= 0):
|
||||
if prediction == "long":
|
||||
unknown_long += 1
|
||||
elif prediction == "short":
|
||||
unknown_short += 1
|
||||
individual_signals.append(
|
||||
{
|
||||
"ticker": ticker,
|
||||
"signal": signal_type,
|
||||
"date": date,
|
||||
"is_correct": "unknown",
|
||||
},
|
||||
)
|
||||
elif prediction == "hold":
|
||||
hold_count += 1
|
||||
individual_signals.append(
|
||||
{
|
||||
"ticker": ticker,
|
||||
"signal": signal_type,
|
||||
"date": date,
|
||||
"is_correct": None,
|
||||
},
|
||||
)
|
||||
else:
|
||||
if prediction == "long":
|
||||
if is_correct:
|
||||
correct_long += 1
|
||||
else:
|
||||
incorrect_long += 1
|
||||
else:
|
||||
if is_correct:
|
||||
correct_short += 1
|
||||
else:
|
||||
incorrect_short += 1
|
||||
|
||||
individual_signals.append(
|
||||
{
|
||||
"ticker": ticker,
|
||||
"signal": signal_type,
|
||||
"date": date,
|
||||
"is_correct": is_correct,
|
||||
},
|
||||
)
|
||||
|
||||
total_long = correct_long + incorrect_long + unknown_long
|
||||
total_short = correct_short + incorrect_short + unknown_short
|
||||
evaluated_long = correct_long + incorrect_long
|
||||
evaluated_short = correct_short + incorrect_short
|
||||
total_evaluated = evaluated_long + evaluated_short
|
||||
correct_predictions = correct_long + correct_short
|
||||
|
||||
win_rate = (
|
||||
correct_predictions / total_evaluated
|
||||
if total_evaluated > 0
|
||||
else None
|
||||
)
|
||||
|
||||
return {
|
||||
"portfolio_manager": {
|
||||
"total_predictions": total_evaluated,
|
||||
"correct_predictions": correct_predictions,
|
||||
"win_rate": win_rate,
|
||||
"bull": {
|
||||
"n": total_long,
|
||||
"win": correct_long,
|
||||
"unknown": unknown_long,
|
||||
},
|
||||
"bear": {
|
||||
"n": total_short,
|
||||
"win": correct_short,
|
||||
"unknown": unknown_short,
|
||||
},
|
||||
"hold": hold_count,
|
||||
"signals": individual_signals,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def update_leaderboard_with_evaluations(
|
||||
leaderboard: List[Dict[str, Any]],
|
||||
evaluations: Dict[str, Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Update leaderboard with new evaluation results
|
||||
|
||||
Args:
|
||||
leaderboard: Current leaderboard data
|
||||
evaluations: Evaluation results for the day
|
||||
|
||||
Returns:
|
||||
Updated leaderboard
|
||||
"""
|
||||
for entry in leaderboard:
|
||||
agent_id = entry.get("agentId")
|
||||
if not agent_id or agent_id not in evaluations:
|
||||
continue
|
||||
|
||||
eval_result = evaluations[agent_id]
|
||||
|
||||
# Update aggregate stats
|
||||
entry["bull"]["n"] += eval_result["bull"]["n"]
|
||||
entry["bull"]["win"] += eval_result["bull"]["win"]
|
||||
entry["bull"]["unknown"] = (
|
||||
entry["bull"].get("unknown", 0) + eval_result["bull"]["unknown"]
|
||||
)
|
||||
entry["bear"]["n"] += eval_result["bear"]["n"]
|
||||
entry["bear"]["win"] += eval_result["bear"]["win"]
|
||||
entry["bear"]["unknown"] = (
|
||||
entry["bear"].get("unknown", 0) + eval_result["bear"]["unknown"]
|
||||
)
|
||||
|
||||
# Calculate win rate based on evaluated signals only
|
||||
# evaluated = total - unknown
|
||||
evaluated_bull = entry["bull"]["n"] - entry["bull"]["unknown"]
|
||||
evaluated_bear = entry["bear"]["n"] - entry["bear"]["unknown"]
|
||||
total_evaluated = evaluated_bull + evaluated_bear
|
||||
total_wins = entry["bull"]["win"] + entry["bear"]["win"]
|
||||
|
||||
if total_evaluated > 0:
|
||||
entry["winRate"] = round(total_wins / total_evaluated, 4)
|
||||
|
||||
# Add individual signal records
|
||||
if "signals" not in entry:
|
||||
entry["signals"] = []
|
||||
|
||||
for signal in eval_result.get("signals", []):
|
||||
entry["signals"].append(signal)
|
||||
|
||||
# Keep only recent signals (e.g., last 100 individual signals)
|
||||
entry["signals"] = entry["signals"][-100:]
|
||||
|
||||
# Re-rank analysts by win rate (rank starts from 1)
|
||||
analyst_entries = [e for e in leaderboard if e.get("rank") is not None]
|
||||
analyst_entries.sort(key=lambda e: e.get("winRate", 0), reverse=True)
|
||||
for idx, entry in enumerate(analyst_entries):
|
||||
entry["rank"] = idx + 1 # Rank 1 = highest win rate (gold medal)
|
||||
|
||||
return leaderboard
|
||||
405
backend/utils/baselines.py
Normal file
405
backend/utils/baselines.py
Normal file
@@ -0,0 +1,405 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Baseline Strategy Calculators
|
||||
Tracks performance of simple baseline strategies for comparison
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Tuple, TypedDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Portfolio(TypedDict):
|
||||
cash: float
|
||||
positions: Dict[str, float]
|
||||
|
||||
|
||||
class BaselineCalculator:
|
||||
"""
|
||||
Calculates baseline strategy returns for comparison
|
||||
|
||||
Strategies:
|
||||
1. Equal-weight: Allocate equal weight to all tickers
|
||||
2. Market-cap-weighted: Allocate proportional to market cap
|
||||
3. Simple momentum: Monthly rebalance,
|
||||
long top 50% momentum, short bottom 50%
|
||||
"""
|
||||
|
||||
def __init__(self, initial_capital: float = 100000.0):
|
||||
self.initial_capital = initial_capital
|
||||
|
||||
self.equal_weight_portfolio: Portfolio = {"cash": 0.0, "positions": {}}
|
||||
self.market_cap_portfolio: Portfolio = {"cash": 0.0, "positions": {}}
|
||||
self.momentum_portfolio: Portfolio = {
|
||||
"cash": initial_capital,
|
||||
"positions": {},
|
||||
}
|
||||
|
||||
self.equal_weight_initialized = False
|
||||
self.market_cap_initialized = False
|
||||
self.momentum_last_rebalance_date = None
|
||||
|
||||
def calculate_equal_weight_value(
|
||||
self,
|
||||
tickers: List[str],
|
||||
open_prices: Dict[str, float],
|
||||
close_prices: Dict[str, float],
|
||||
) -> float:
|
||||
"""
|
||||
Calculate equal-weight portfolio value
|
||||
|
||||
On first call, initialize positions with equal allocation using
|
||||
open prices. Subsequently, mark-to-market existing positions
|
||||
using close prices.
|
||||
|
||||
Args:
|
||||
tickers: List of stock tickers
|
||||
open_prices: Opening prices (used for initial purchase)
|
||||
close_prices: Closing prices (used for valuation)
|
||||
"""
|
||||
if not self.equal_weight_initialized:
|
||||
allocation_per_ticker = self.initial_capital / len(tickers)
|
||||
self.equal_weight_portfolio["cash"] = 0.0
|
||||
for ticker in tickers:
|
||||
price = open_prices.get(ticker, 0) # Use OPEN price for buying
|
||||
if price > 0:
|
||||
shares = allocation_per_ticker / price
|
||||
self.equal_weight_portfolio["positions"][ticker] = shares
|
||||
logger.info(
|
||||
f"Equal Weight: Initialized {ticker} with "
|
||||
f"{shares:.2f} shares @ ${price:.2f} (open)",
|
||||
)
|
||||
self.equal_weight_initialized = True
|
||||
|
||||
total_value = self.equal_weight_portfolio["cash"]
|
||||
positions: Dict[str, float] = self.equal_weight_portfolio["positions"]
|
||||
for ticker, shares in positions.items():
|
||||
price = close_prices.get(ticker, 0)
|
||||
total_value += shares * price
|
||||
|
||||
return total_value
|
||||
|
||||
def calculate_market_cap_weighted_value(
|
||||
self,
|
||||
tickers: List[str],
|
||||
open_prices: Dict[str, float],
|
||||
close_prices: Dict[str, float],
|
||||
market_caps: Dict[str, float],
|
||||
) -> float:
|
||||
"""
|
||||
Calculate market-cap-weighted portfolio value
|
||||
|
||||
On first call, initialize positions weighted by market cap using
|
||||
open prices. Subsequently, mark-to-market existing positions
|
||||
using close prices.
|
||||
|
||||
Args:
|
||||
tickers: List of stock tickers
|
||||
open_prices: Opening prices (used for initial purchase)
|
||||
close_prices: Closing prices (used for valuation)
|
||||
market_caps: Market capitalization for each ticker
|
||||
"""
|
||||
if not self.market_cap_initialized:
|
||||
total_market_cap = sum(market_caps.get(t, 0) for t in tickers)
|
||||
if total_market_cap <= 0:
|
||||
logger.warning("No market cap data, using equal weight")
|
||||
return self.calculate_equal_weight_value(
|
||||
tickers,
|
||||
open_prices,
|
||||
close_prices,
|
||||
)
|
||||
|
||||
self.market_cap_portfolio["cash"] = 0.0
|
||||
for ticker in tickers:
|
||||
market_cap = market_caps.get(ticker, 0)
|
||||
price = open_prices.get(ticker, 0) # Use OPEN price for buying
|
||||
if market_cap > 0 and price > 0:
|
||||
weight = market_cap / total_market_cap
|
||||
allocation = self.initial_capital * weight
|
||||
shares = allocation / price
|
||||
self.market_cap_portfolio["positions"][ticker] = shares
|
||||
logger.info(
|
||||
f"Market Cap Weighted: Initialized {ticker} with "
|
||||
f"{shares:.2f} shares @ ${price:.2f} (open), "
|
||||
f"weight={weight:.2%}",
|
||||
)
|
||||
self.market_cap_initialized = True
|
||||
|
||||
total_value = self.market_cap_portfolio["cash"]
|
||||
positions: Dict[str, float] = self.market_cap_portfolio["positions"]
|
||||
for ticker, shares in positions.items():
|
||||
price = close_prices.get(ticker, 0)
|
||||
total_value += shares * price
|
||||
|
||||
return total_value
|
||||
|
||||
def calculate_momentum_value(
|
||||
self,
|
||||
tickers: List[str],
|
||||
open_prices: Dict[str, float],
|
||||
close_prices: Dict[str, float],
|
||||
momentum_scores: Dict[str, float],
|
||||
date: str,
|
||||
rebalance: bool = False,
|
||||
) -> float:
|
||||
"""
|
||||
Calculate momentum strategy portfolio value
|
||||
|
||||
Strategy: Monthly rebalance
|
||||
- Long top 50% momentum stocks
|
||||
- Short bottom 50% momentum stocks (if shorting enabled)
|
||||
- Equal weight within each group
|
||||
|
||||
Args:
|
||||
tickers: List of tickers
|
||||
open_prices: Opening prices (used for rebalancing trades)
|
||||
close_prices: Closing prices (used for valuation)
|
||||
momentum_scores: Momentum scores for each ticker
|
||||
date: Current date (YYYY-MM-DD)
|
||||
rebalance: Force rebalance if True
|
||||
"""
|
||||
should_rebalance = rebalance
|
||||
if self.momentum_last_rebalance_date is None:
|
||||
should_rebalance = True
|
||||
elif not rebalance:
|
||||
last_date = datetime.strptime(
|
||||
self.momentum_last_rebalance_date,
|
||||
"%Y-%m-%d",
|
||||
)
|
||||
current_date = datetime.strptime(date, "%Y-%m-%d")
|
||||
if (current_date.year, current_date.month) != (
|
||||
last_date.year,
|
||||
last_date.month,
|
||||
):
|
||||
should_rebalance = True
|
||||
|
||||
if should_rebalance:
|
||||
self._rebalance_momentum_portfolio(
|
||||
tickers,
|
||||
open_prices,
|
||||
momentum_scores,
|
||||
)
|
||||
self.momentum_last_rebalance_date = date
|
||||
|
||||
total_value = self.momentum_portfolio["cash"]
|
||||
positions: Dict[str, float] = self.momentum_portfolio["positions"]
|
||||
for ticker, shares in positions.items():
|
||||
price = close_prices.get(ticker, 0)
|
||||
total_value += shares * price
|
||||
|
||||
return total_value
|
||||
|
||||
def _rebalance_momentum_portfolio(
|
||||
self,
|
||||
tickers: List[str],
|
||||
prices: Dict[str, float],
|
||||
momentum_scores: Dict[str, float],
|
||||
):
|
||||
"""Rebalance momentum portfolio based on current momentum scores"""
|
||||
current_value = self.momentum_portfolio["cash"]
|
||||
for ticker, shares in self.momentum_portfolio["positions"].items():
|
||||
price = prices.get(ticker, 0)
|
||||
current_value += shares * price
|
||||
|
||||
self.momentum_portfolio["positions"] = {}
|
||||
|
||||
sorted_tickers = sorted(
|
||||
tickers,
|
||||
key=lambda t: momentum_scores.get(t, 0),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
mid_point = len(sorted_tickers) // 2
|
||||
long_tickers = (
|
||||
sorted_tickers[:mid_point] if mid_point > 0 else sorted_tickers
|
||||
)
|
||||
|
||||
if len(long_tickers) == 0:
|
||||
self.momentum_portfolio["cash"] = current_value
|
||||
return
|
||||
|
||||
allocation_per_ticker = current_value / len(long_tickers)
|
||||
used_capital = 0.0
|
||||
|
||||
for ticker in long_tickers:
|
||||
price = prices.get(ticker, 0)
|
||||
if price > 0:
|
||||
shares = allocation_per_ticker / price
|
||||
self.momentum_portfolio["positions"][ticker] = shares
|
||||
used_capital += allocation_per_ticker
|
||||
|
||||
self.momentum_portfolio["cash"] = current_value - used_capital
|
||||
|
||||
def get_all_baseline_values(
|
||||
self,
|
||||
tickers: List[str],
|
||||
open_prices: Dict[str, float],
|
||||
close_prices: Dict[str, float],
|
||||
market_caps: Dict[str, float],
|
||||
momentum_scores: Dict[str, float],
|
||||
date: str,
|
||||
rebalance_momentum: bool = False,
|
||||
) -> Dict[str, float]:
|
||||
"""
|
||||
Get all baseline portfolio values in one call
|
||||
|
||||
Args:
|
||||
tickers: List of stock tickers
|
||||
open_prices: Opening prices (used for initial purchase/rebalancing)
|
||||
close_prices: Closing prices (used for valuation)
|
||||
market_caps: Market caps for each ticker
|
||||
momentum_scores: Momentum scores for rebalancing
|
||||
date: Current date
|
||||
rebalance_momentum: Whether to rebalance momentum portfolio
|
||||
|
||||
Returns:
|
||||
Dict with keys: equal_weight, market_cap_weighted, momentum
|
||||
"""
|
||||
equal_weight_value = self.calculate_equal_weight_value(
|
||||
tickers,
|
||||
open_prices,
|
||||
close_prices,
|
||||
)
|
||||
market_cap_value = self.calculate_market_cap_weighted_value(
|
||||
tickers,
|
||||
open_prices,
|
||||
close_prices,
|
||||
market_caps,
|
||||
)
|
||||
momentum_value = self.calculate_momentum_value(
|
||||
tickers,
|
||||
open_prices,
|
||||
close_prices,
|
||||
momentum_scores,
|
||||
date,
|
||||
rebalance_momentum,
|
||||
)
|
||||
|
||||
return {
|
||||
"equal_weight": equal_weight_value,
|
||||
"market_cap_weighted": market_cap_value,
|
||||
"momentum": momentum_value,
|
||||
}
|
||||
|
||||
def export_state(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Export calculator state for persistence
|
||||
|
||||
Returns:
|
||||
Dictionary containing all portfolio states for serialization
|
||||
"""
|
||||
return {
|
||||
"baseline_state": {
|
||||
"initialized": self.equal_weight_initialized,
|
||||
"initial_allocation": dict(
|
||||
self.equal_weight_portfolio["positions"],
|
||||
),
|
||||
},
|
||||
"baseline_vw_state": {
|
||||
"initialized": self.market_cap_initialized,
|
||||
"initial_allocation": dict(
|
||||
self.market_cap_portfolio["positions"],
|
||||
),
|
||||
},
|
||||
"momentum_state": {
|
||||
"positions": dict(self.momentum_portfolio["positions"]),
|
||||
"cash": self.momentum_portfolio["cash"],
|
||||
"initialized": self.momentum_last_rebalance_date is not None,
|
||||
"last_rebalance_date": self.momentum_last_rebalance_date,
|
||||
},
|
||||
}
|
||||
|
||||
def load_state(self, state: Dict[str, Any]):
|
||||
"""
|
||||
Load calculator state from persistence
|
||||
|
||||
Args:
|
||||
state: Dictionary containing baseline_state, baseline_vw_state,
|
||||
momentum_state from storage
|
||||
"""
|
||||
# Load equal-weight state
|
||||
baseline_state = state.get("baseline_state", {})
|
||||
if baseline_state.get("initialized", False):
|
||||
self.equal_weight_initialized = True
|
||||
self.equal_weight_portfolio["positions"] = dict(
|
||||
baseline_state.get("initial_allocation", {}),
|
||||
)
|
||||
self.equal_weight_portfolio["cash"] = 0.0
|
||||
logger.info(
|
||||
f"Restored equal-weight portfolio with "
|
||||
f"{len(self.equal_weight_portfolio['positions'])} positions",
|
||||
)
|
||||
|
||||
# Load market-cap-weighted state
|
||||
baseline_vw_state = state.get("baseline_vw_state", {})
|
||||
if baseline_vw_state.get("initialized", False):
|
||||
self.market_cap_initialized = True
|
||||
self.market_cap_portfolio["positions"] = dict(
|
||||
baseline_vw_state.get("initial_allocation", {}),
|
||||
)
|
||||
self.market_cap_portfolio["cash"] = 0.0
|
||||
logger.info(
|
||||
f"Restored market-cap portfolio with "
|
||||
f"{len(self.market_cap_portfolio['positions'])} positions",
|
||||
)
|
||||
|
||||
# Load momentum state
|
||||
momentum_state = state.get("momentum_state", {})
|
||||
if momentum_state.get("initialized", False):
|
||||
self.momentum_portfolio["positions"] = dict(
|
||||
momentum_state.get("positions", {}),
|
||||
)
|
||||
self.momentum_portfolio["cash"] = momentum_state.get(
|
||||
"cash",
|
||||
self.initial_capital,
|
||||
)
|
||||
self.momentum_last_rebalance_date = momentum_state.get(
|
||||
"last_rebalance_date",
|
||||
)
|
||||
logger.info(
|
||||
f"Restored momentum portfolio with "
|
||||
f"{len(self.momentum_portfolio['positions'])} positions, "
|
||||
f"last rebalance: {self.momentum_last_rebalance_date}",
|
||||
)
|
||||
|
||||
|
||||
def calculate_momentum_scores(
|
||||
tickers: List[str],
|
||||
prices_history: Dict[str, List[Tuple[str, float]]],
|
||||
lookback_days: int = 20,
|
||||
) -> Dict[str, float]:
|
||||
"""
|
||||
Calculate momentum scores for tickers
|
||||
|
||||
Args:
|
||||
tickers: List of tickers
|
||||
prices_history: Dict mapping ticker to list of (date, price) tuples
|
||||
lookback_days: Number of days to calculate momentum
|
||||
|
||||
Returns:
|
||||
Dict mapping ticker to momentum score (percentage return)
|
||||
"""
|
||||
momentum_scores = {}
|
||||
|
||||
for ticker in tickers:
|
||||
history = prices_history.get(ticker, [])
|
||||
if len(history) < 2:
|
||||
momentum_scores[ticker] = 0.0
|
||||
continue
|
||||
|
||||
sorted_history = sorted(history, key=lambda x: x[0])
|
||||
|
||||
if len(sorted_history) < lookback_days:
|
||||
start_price = sorted_history[0][1]
|
||||
end_price = sorted_history[-1][1]
|
||||
else:
|
||||
start_price = sorted_history[-lookback_days][1]
|
||||
end_price = sorted_history[-1][1]
|
||||
|
||||
if start_price > 0:
|
||||
momentum_scores[ticker] = (end_price - start_price) / start_price
|
||||
else:
|
||||
momentum_scores[ticker] = 0.0
|
||||
|
||||
return momentum_scores
|
||||
321
backend/utils/msg_adapter.py
Normal file
321
backend/utils/msg_adapter.py
Normal file
@@ -0,0 +1,321 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Message Adapter - Converts AgentScope Msg to frontend JSON format
|
||||
Ensures compatibility with existing frontend without modifications
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agentscope.message import Msg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FrontendAdapter:
|
||||
"""
|
||||
Adapter to convert AgentScope messages to frontend-compatible format
|
||||
|
||||
Frontend expects specific message types:
|
||||
- agent: Agent thinking/analysis messages
|
||||
- team_summary: Portfolio summary with equity curves
|
||||
- team_holdings: Current portfolio holdings
|
||||
- team_stats: Portfolio statistics
|
||||
- team_trades: Trade history
|
||||
- team_leaderboard: Agent performance rankings
|
||||
- price_update: Real-time price updates
|
||||
- system: System notifications
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def parse(msg: Msg) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Parse AgentScope Msg to frontend format
|
||||
|
||||
Args:
|
||||
msg: AgentScope Msg object
|
||||
|
||||
Returns:
|
||||
Dictionary in frontend format, or None if message should be skipped
|
||||
"""
|
||||
if msg is None:
|
||||
return None
|
||||
|
||||
# Determine message type based on metadata or content
|
||||
msg_type = FrontendAdapter._determine_type(msg)
|
||||
|
||||
if msg_type == "agent":
|
||||
return FrontendAdapter._format_agent_msg(msg)
|
||||
elif msg_type == "portfolio_update":
|
||||
return FrontendAdapter._format_portfolio_msg(msg)
|
||||
elif msg_type == "system":
|
||||
return FrontendAdapter._format_system_msg(msg)
|
||||
else:
|
||||
# Default: treat as agent message
|
||||
return FrontendAdapter._format_agent_msg(msg)
|
||||
|
||||
@staticmethod
|
||||
def _determine_type(msg: Msg) -> str:
|
||||
"""Determine frontend message type from Msg"""
|
||||
# Check metadata for explicit type
|
||||
if hasattr(msg, "metadata") and msg.metadata:
|
||||
if "type" in msg.metadata:
|
||||
return msg.metadata["type"]
|
||||
|
||||
# Check if message contains portfolio update
|
||||
if "portfolio" in msg.metadata:
|
||||
return "portfolio_update"
|
||||
|
||||
# Check message name/role
|
||||
if msg.name == "system":
|
||||
return "system"
|
||||
|
||||
# Default to agent message
|
||||
return "agent"
|
||||
|
||||
@staticmethod
|
||||
def _format_agent_msg(msg: object) -> Dict[str, Any]:
|
||||
"""
|
||||
Format agent message for frontend
|
||||
|
||||
Args:
|
||||
msg: Either AgentScope Msg or dict from pipeline results
|
||||
|
||||
Frontend expects:
|
||||
{
|
||||
"type": "agent",
|
||||
"role_key": "analyst_id",
|
||||
"content": "message text",
|
||||
"timestamp": "ISO timestamp"
|
||||
}
|
||||
"""
|
||||
# Handle dict from pipeline results
|
||||
if isinstance(msg, dict):
|
||||
name = msg.get("agent", "unknown")
|
||||
content = msg.get("content", "")
|
||||
else:
|
||||
# Handle Msg object
|
||||
name = msg.name
|
||||
content = msg.content
|
||||
|
||||
return {
|
||||
"type": "agent",
|
||||
"role_key": name,
|
||||
"content": content
|
||||
if isinstance(content, str)
|
||||
else json.dumps(content),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _format_portfolio_msg(msg: Msg) -> Dict[str, Any]:
|
||||
"""
|
||||
Format portfolio update message
|
||||
|
||||
This typically generates multiple frontend messages:
|
||||
- team_summary
|
||||
- team_holdings
|
||||
- team_stats
|
||||
- team_trades (if trades were executed)
|
||||
"""
|
||||
metadata = msg.metadata or {}
|
||||
portfolio = metadata.get("portfolio", {})
|
||||
|
||||
messages: List[Dict[str, Any]] = []
|
||||
|
||||
# Generate holdings message
|
||||
holdings = FrontendAdapter.build_holdings(portfolio)
|
||||
if holdings:
|
||||
messages.append(
|
||||
{
|
||||
"type": "team_holdings",
|
||||
"data": holdings,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
# Generate stats message
|
||||
stats = FrontendAdapter.build_stats(portfolio)
|
||||
if stats:
|
||||
messages.append(
|
||||
{
|
||||
"type": "team_stats",
|
||||
"data": stats,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
# Generate trades message if execution logs exist
|
||||
execution_logs = metadata.get("execution_logs", [])
|
||||
if execution_logs:
|
||||
trades = FrontendAdapter.build_trades(execution_logs)
|
||||
messages.append(
|
||||
{
|
||||
"type": "team_trades",
|
||||
"mode": "incremental",
|
||||
"data": trades,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
# Return composite message
|
||||
return {
|
||||
"type": "composite",
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _format_system_msg(msg: Msg) -> Dict[str, Any]:
|
||||
"""Format system message"""
|
||||
return {
|
||||
"type": "system",
|
||||
"content": msg.content
|
||||
if isinstance(msg.content, str)
|
||||
else json.dumps(msg.content),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def build_holdings(
|
||||
portfolio: Dict[str, Any],
|
||||
prices: Dict[str, float] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Build holdings array from portfolio state"""
|
||||
holdings = []
|
||||
prices = prices or {}
|
||||
|
||||
positions = portfolio.get("positions", {})
|
||||
cash = portfolio.get("cash", 0.0)
|
||||
|
||||
# Calculate total value using current prices
|
||||
total_value = cash
|
||||
for ticker, position in positions.items():
|
||||
long_shares = position.get("long", 0)
|
||||
short_shares = position.get("short", 0)
|
||||
price = prices.get(ticker) or position.get("avg_price", 0)
|
||||
total_value += (long_shares - short_shares) * price
|
||||
|
||||
# Build holdings for each position
|
||||
for ticker, position in positions.items():
|
||||
long_shares = position.get("long", 0)
|
||||
short_shares = position.get("short", 0)
|
||||
avg_price = position.get("avg_price", 0)
|
||||
current_price = prices.get(ticker) or avg_price
|
||||
|
||||
net_shares = long_shares - short_shares
|
||||
if net_shares == 0:
|
||||
continue
|
||||
|
||||
market_value = net_shares * current_price
|
||||
weight = market_value / total_value if total_value > 0 else 0
|
||||
|
||||
holdings.append(
|
||||
{
|
||||
"ticker": ticker,
|
||||
"quantity": net_shares,
|
||||
"avg": avg_price,
|
||||
"currentPrice": current_price,
|
||||
"marketValue": market_value,
|
||||
"weight": weight,
|
||||
},
|
||||
)
|
||||
|
||||
# Add cash as a holding
|
||||
if cash > 0:
|
||||
holdings.append(
|
||||
{
|
||||
"ticker": "CASH",
|
||||
"quantity": 1,
|
||||
"avg": cash,
|
||||
"currentPrice": cash,
|
||||
"marketValue": cash,
|
||||
"weight": cash / total_value if total_value > 0 else 0,
|
||||
},
|
||||
)
|
||||
|
||||
return holdings
|
||||
|
||||
@staticmethod
|
||||
def build_stats(
|
||||
portfolio: Dict[str, Any],
|
||||
prices: Dict[str, float] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build stats dictionary from portfolio"""
|
||||
prices = prices or {}
|
||||
positions = portfolio.get("positions", {})
|
||||
cash = portfolio.get("cash", 0.0)
|
||||
margin_used = portfolio.get("margin_used", 0.0)
|
||||
|
||||
# Calculate total value using current prices
|
||||
total_value = cash
|
||||
for ticker, position in positions.items():
|
||||
long_shares = position.get("long", 0)
|
||||
short_shares = position.get("short", 0)
|
||||
price = prices.get(ticker) or position.get("avg_price", 0)
|
||||
total_value += (long_shares - short_shares) * price
|
||||
|
||||
# Calculate ticker weights
|
||||
ticker_weights = {}
|
||||
for ticker, position in positions.items():
|
||||
long_shares = position.get("long", 0)
|
||||
short_shares = position.get("short", 0)
|
||||
price = prices.get(ticker) or position.get("avg_price", 0)
|
||||
|
||||
market_value = (long_shares - short_shares) * price
|
||||
if market_value != 0:
|
||||
ticker_weights[ticker] = (
|
||||
market_value / total_value if total_value > 0 else 0
|
||||
)
|
||||
|
||||
# Calculate total return
|
||||
initial_cash = portfolio.get("initial_cash", 100000.0)
|
||||
total_return = (
|
||||
((total_value - initial_cash) / initial_cash * 100)
|
||||
if initial_cash > 0
|
||||
else 0.0
|
||||
)
|
||||
|
||||
return {
|
||||
"totalAssetValue": round(total_value, 2),
|
||||
"totalReturn": round(total_return, 2),
|
||||
"cashPosition": round(cash, 2),
|
||||
"tickerWeights": ticker_weights,
|
||||
"marginUsed": round(margin_used, 2),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def build_trades(execution_logs: List[str]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Build trades array from execution logs
|
||||
|
||||
Frontend expects:
|
||||
[{
|
||||
"ts": 1234567890,
|
||||
"ticker": "AAPL",
|
||||
"side": "LONG",
|
||||
"qty": 100,
|
||||
"price": 150.0,
|
||||
"reason": "Buy signal"
|
||||
}, ...]
|
||||
"""
|
||||
trades = []
|
||||
timestamp = int(datetime.now().timestamp() * 1000)
|
||||
|
||||
for log in execution_logs:
|
||||
# Parse execution log (simplified - should use structured data)
|
||||
if "Executed" in log:
|
||||
# Extract trade details from log string
|
||||
# in real implementation, pass structured data
|
||||
trades.append(
|
||||
{
|
||||
"ts": timestamp,
|
||||
"ticker": "UNKNOWN", # Should parse from log
|
||||
"side": "LONG", # Should parse from log
|
||||
"qty": 0, # Should parse from log
|
||||
"price": 0.0, # Should parse from log
|
||||
"reason": log,
|
||||
},
|
||||
)
|
||||
|
||||
return trades
|
||||
140
backend/utils/progress.py
Normal file
140
backend/utils/progress.py
Normal file
@@ -0,0 +1,140 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.live import Live
|
||||
from rich.style import Style
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
class AgentProgress:
|
||||
"""Manages progress tracking for multiple agents."""
|
||||
|
||||
def __init__(self):
|
||||
self.agent_status = {}
|
||||
self.table = Table(show_header=False, box=None, padding=(0, 1))
|
||||
self.live = Live(self.table, console=console, refresh_per_second=4)
|
||||
self.started = False
|
||||
self.update_handlers = []
|
||||
|
||||
def register_handler(
|
||||
self,
|
||||
handler: Callable[[str, Optional[str], str], None],
|
||||
):
|
||||
"""Register a handler to be called when agent status updates."""
|
||||
self.update_handlers.append(handler)
|
||||
return handler # Return handler to support use as decorator
|
||||
|
||||
def unregister_handler(
|
||||
self,
|
||||
handler: Callable[[str, Optional[str], str], None],
|
||||
):
|
||||
"""Unregister a previously registered handler."""
|
||||
if handler in self.update_handlers:
|
||||
self.update_handlers.remove(handler)
|
||||
|
||||
def start(self):
|
||||
"""Start the progress display."""
|
||||
if not self.started:
|
||||
self.live.start()
|
||||
self.started = True
|
||||
|
||||
def stop(self):
|
||||
"""Stop the progress display."""
|
||||
if self.started:
|
||||
self.live.stop()
|
||||
self.started = False
|
||||
|
||||
def update_status(
|
||||
self,
|
||||
agent_name: str,
|
||||
ticker: Optional[str] = None,
|
||||
status: str = "",
|
||||
analysis: Optional[str] = None,
|
||||
):
|
||||
"""Update the status of an agent."""
|
||||
if agent_name not in self.agent_status:
|
||||
self.agent_status[agent_name] = {"status": "", "ticker": None}
|
||||
|
||||
if ticker:
|
||||
self.agent_status[agent_name]["ticker"] = ticker
|
||||
if status:
|
||||
self.agent_status[agent_name]["status"] = status
|
||||
if analysis:
|
||||
self.agent_status[agent_name]["analysis"] = analysis
|
||||
|
||||
# Set the timestamp as UTC datetime
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
self.agent_status[agent_name]["timestamp"] = timestamp
|
||||
|
||||
# Notify all registered handlers
|
||||
for handler in self.update_handlers:
|
||||
handler(agent_name, ticker, status, analysis, timestamp)
|
||||
|
||||
self._refresh_display()
|
||||
|
||||
def get_all_status(self):
|
||||
"""Get the current status of all agents as a dictionary."""
|
||||
return {
|
||||
agent_name: {
|
||||
"ticker": info["ticker"],
|
||||
"status": info["status"],
|
||||
"display_name": self._get_display_name(agent_name),
|
||||
}
|
||||
for agent_name, info in self.agent_status.items()
|
||||
}
|
||||
|
||||
def _get_display_name(self, agent_name: str) -> str:
|
||||
"""Convert agent_name to a display-friendly format."""
|
||||
return agent_name.replace("_agent", "").replace("_", " ").title()
|
||||
|
||||
def _refresh_display(self):
|
||||
"""Refresh the progress display."""
|
||||
self.table.columns.clear()
|
||||
self.table.add_column(width=100)
|
||||
|
||||
# Sort Risk Management and Portfolio Management at the bottom
|
||||
def sort_key(item):
|
||||
agent_name = item[0]
|
||||
if "risk_manager" in agent_name:
|
||||
return (2, agent_name)
|
||||
elif "portfolio_manager" in agent_name:
|
||||
return (3, agent_name)
|
||||
else:
|
||||
return (1, agent_name)
|
||||
|
||||
for agent_name, info in sorted(
|
||||
self.agent_status.items(),
|
||||
key=sort_key,
|
||||
):
|
||||
status = info["status"]
|
||||
ticker = info["ticker"]
|
||||
# Create the status text with appropriate styling
|
||||
if status.lower() == "done":
|
||||
style = Style(color="green", bold=True)
|
||||
symbol = "✓"
|
||||
elif status.lower() == "error":
|
||||
style = Style(color="red", bold=True)
|
||||
symbol = "✗"
|
||||
else:
|
||||
style = Style(color="yellow")
|
||||
symbol = "⋯"
|
||||
|
||||
agent_display = self._get_display_name(agent_name)
|
||||
status_text = Text()
|
||||
status_text.append(f"{symbol} ", style=style)
|
||||
status_text.append(f"{agent_display:<20}", style=Style(bold=True))
|
||||
|
||||
if ticker:
|
||||
status_text.append(f"[{ticker}] ", style=Style(color="cyan"))
|
||||
status_text.append(status, style=style)
|
||||
|
||||
self.table.add_row(status_text)
|
||||
|
||||
|
||||
# Create a global instance
|
||||
progress = AgentProgress()
|
||||
362
backend/utils/settlement.py
Normal file
362
backend/utils/settlement.py
Normal file
@@ -0,0 +1,362 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Settlement Coordinator
|
||||
Unified daily settlement logic for agent portfolio, baselines, and analyst tracking
|
||||
"""
|
||||
# flake8: noqa: E501
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from backend.services.storage import StorageService
|
||||
from backend.utils.analyst_tracker import (
|
||||
AnalystPerformanceTracker,
|
||||
update_leaderboard_with_evaluations,
|
||||
)
|
||||
from backend.utils.baselines import (
|
||||
BaselineCalculator,
|
||||
calculate_momentum_scores,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SettlementCoordinator:
|
||||
"""
|
||||
Coordinates daily settlement after market close
|
||||
|
||||
Responsibilities:
|
||||
1. Calculate agent portfolio P&L
|
||||
2. Update baseline portfolios (equal-weight, market-cap, momentum)
|
||||
3. Evaluate analyst predictions and update leaderboard
|
||||
4. Update summary.json with all portfolio values
|
||||
5. Persist state to storage
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
storage: "StorageService",
|
||||
initial_capital: float = 100000.0,
|
||||
):
|
||||
self.storage = storage
|
||||
self.initial_capital = initial_capital
|
||||
self.baseline_calculator = BaselineCalculator(initial_capital)
|
||||
self.analyst_tracker = AnalystPerformanceTracker()
|
||||
|
||||
self.price_history: Dict[str, List[tuple]] = {}
|
||||
|
||||
# Load persisted state from storage
|
||||
self._load_persisted_state()
|
||||
|
||||
def _load_persisted_state(self):
|
||||
"""
|
||||
Load persisted baseline and price history state from storage
|
||||
|
||||
This restores the baseline calculator state so that backtest/live mode
|
||||
can resume from where it left off.
|
||||
"""
|
||||
internal_state = self.storage.load_internal_state()
|
||||
|
||||
# Load baseline calculator state
|
||||
baseline_state = {
|
||||
"baseline_state": internal_state.get("baseline_state", {}),
|
||||
"baseline_vw_state": internal_state.get("baseline_vw_state", {}),
|
||||
"momentum_state": internal_state.get("momentum_state", {}),
|
||||
}
|
||||
self.baseline_calculator.load_state(baseline_state)
|
||||
|
||||
# Load price history for momentum calculation
|
||||
saved_price_history = internal_state.get("price_history", {})
|
||||
if saved_price_history:
|
||||
# Convert saved format back to list of tuples
|
||||
for ticker, history in saved_price_history.items():
|
||||
converted_history = []
|
||||
for entry in history:
|
||||
if isinstance(entry, dict):
|
||||
converted_history.append(
|
||||
(entry["date"], entry["price"]),
|
||||
)
|
||||
elif isinstance(entry, (list, tuple)) and len(entry) >= 2:
|
||||
converted_history.append((entry[0], entry[1]))
|
||||
else:
|
||||
continue
|
||||
self.price_history[ticker] = converted_history
|
||||
logger.info(
|
||||
f"Restored price history for {len(self.price_history)} tickers",
|
||||
)
|
||||
|
||||
def _save_persisted_state(self):
|
||||
"""
|
||||
Save baseline and price history state to storage
|
||||
|
||||
This persists the baseline calculator state so that backtest/live mode
|
||||
can resume from where it left off after restart.
|
||||
"""
|
||||
internal_state = self.storage.load_internal_state()
|
||||
|
||||
# Export baseline calculator state
|
||||
baseline_state = self.baseline_calculator.export_state()
|
||||
internal_state["baseline_state"] = baseline_state["baseline_state"]
|
||||
internal_state["baseline_vw_state"] = baseline_state[
|
||||
"baseline_vw_state"
|
||||
]
|
||||
internal_state["momentum_state"] = baseline_state["momentum_state"]
|
||||
|
||||
# Save price history (convert tuples to dicts for JSON serialization)
|
||||
price_history_serializable = {}
|
||||
for ticker, history in self.price_history.items():
|
||||
price_history_serializable[ticker] = [
|
||||
{"date": date, "price": price} for date, price in history
|
||||
]
|
||||
internal_state["price_history"] = price_history_serializable
|
||||
|
||||
self.storage.save_internal_state(internal_state)
|
||||
logger.info("Persisted baseline calculator and price history state")
|
||||
|
||||
def record_analyst_predictions(
|
||||
self,
|
||||
final_predictions: List[Dict[str, Any]],
|
||||
):
|
||||
"""
|
||||
Record structured analyst predictions before market close
|
||||
|
||||
Args:
|
||||
final_predictions: Structured prediction results from analysts
|
||||
Format: [
|
||||
{
|
||||
'agent': 'analyst_name',
|
||||
'predictions': [
|
||||
{'ticker': 'AAPL', 'direction': 'up', 'confidence': 0.75},
|
||||
...
|
||||
]
|
||||
},
|
||||
...
|
||||
]
|
||||
tickers: List of tickers being analyzed
|
||||
"""
|
||||
self.analyst_tracker.record_analyst_predictions(final_predictions)
|
||||
|
||||
def update_price_history(
|
||||
self,
|
||||
date: str,
|
||||
prices: Dict[str, float],
|
||||
):
|
||||
"""
|
||||
Update price history for momentum calculation
|
||||
|
||||
Args:
|
||||
date: Trading date (YYYY-MM-DD)
|
||||
prices: Current prices for each ticker
|
||||
"""
|
||||
for ticker, price in prices.items():
|
||||
if ticker not in self.price_history:
|
||||
self.price_history[ticker] = []
|
||||
self.price_history[ticker].append((date, price))
|
||||
|
||||
self.price_history[ticker] = self.price_history[ticker][-60:]
|
||||
|
||||
def run_daily_settlement(
|
||||
self,
|
||||
date: str,
|
||||
tickers: List[str],
|
||||
open_prices: Optional[Dict[str, float]],
|
||||
close_prices: Dict[str, float],
|
||||
market_caps: Dict[str, float],
|
||||
agent_portfolio: Dict[str, Any],
|
||||
analyst_results: List[Dict[str, Any]], # pylint: disable=W0613
|
||||
pm_decisions: Optional[Dict[str, Dict]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Run complete daily settlement
|
||||
|
||||
Args:
|
||||
date: Trading date (YYYY-MM-DD)
|
||||
tickers: List of tickers
|
||||
open_prices: Opening prices
|
||||
close_prices: Closing prices
|
||||
market_caps: Market caps for each ticker
|
||||
agent_portfolio: Current agent portfolio state
|
||||
analyst_results: Analyst analysis results
|
||||
pm_decisions: PM's trading decisions
|
||||
|
||||
Returns:
|
||||
Settlement results including all portfolio values and evaluations
|
||||
"""
|
||||
logger.info(f"Running daily settlement for {date}")
|
||||
|
||||
self.update_price_history(date, close_prices)
|
||||
|
||||
momentum_scores = calculate_momentum_scores(
|
||||
tickers,
|
||||
self.price_history,
|
||||
lookback_days=20,
|
||||
)
|
||||
|
||||
rebalance_momentum = self._should_rebalance_momentum(date)
|
||||
|
||||
baseline_values = self.baseline_calculator.get_all_baseline_values(
|
||||
tickers=tickers,
|
||||
open_prices=open_prices if open_prices else close_prices,
|
||||
close_prices=close_prices,
|
||||
market_caps=market_caps,
|
||||
momentum_scores=momentum_scores,
|
||||
date=date,
|
||||
rebalance_momentum=rebalance_momentum,
|
||||
)
|
||||
|
||||
logger.info(f"Baseline values calculated: {baseline_values}")
|
||||
|
||||
agent_value = self.storage.calculate_portfolio_value(
|
||||
agent_portfolio,
|
||||
close_prices,
|
||||
)
|
||||
|
||||
analyst_evaluations = self.analyst_tracker.evaluate_predictions(
|
||||
open_prices,
|
||||
close_prices,
|
||||
date,
|
||||
)
|
||||
|
||||
pm_evaluations = {}
|
||||
if pm_decisions:
|
||||
pm_evaluations = self.analyst_tracker.evaluate_pm_decisions(
|
||||
pm_decisions,
|
||||
open_prices,
|
||||
close_prices,
|
||||
date,
|
||||
)
|
||||
|
||||
all_evaluations = {**analyst_evaluations, **pm_evaluations}
|
||||
|
||||
leaderboard = self.storage.load_export_file("leaderboard") or []
|
||||
updated_leaderboard = update_leaderboard_with_evaluations(
|
||||
leaderboard,
|
||||
all_evaluations,
|
||||
)
|
||||
self.storage.save_export_file("leaderboard", updated_leaderboard)
|
||||
|
||||
self._update_summary_with_baselines(
|
||||
date,
|
||||
agent_value,
|
||||
baseline_values,
|
||||
)
|
||||
|
||||
self.analyst_tracker.clear_daily_predictions()
|
||||
|
||||
# Persist baseline calculator and price history state
|
||||
self._save_persisted_state()
|
||||
|
||||
return {
|
||||
"date": date,
|
||||
"agent_portfolio_value": agent_value,
|
||||
"baseline_values": baseline_values,
|
||||
"analyst_evaluations": analyst_evaluations,
|
||||
"baselines_updated": True,
|
||||
"leaderboard_updated": True,
|
||||
}
|
||||
|
||||
def _should_rebalance_momentum(self, date: str) -> bool:
|
||||
"""
|
||||
Check if momentum portfolio should rebalance
|
||||
|
||||
Returns True if it's a new month
|
||||
"""
|
||||
last_rebalance = self.baseline_calculator.momentum_last_rebalance_date
|
||||
if last_rebalance is None:
|
||||
return True
|
||||
|
||||
last_date = datetime.strptime(last_rebalance, "%Y-%m-%d")
|
||||
current_date = datetime.strptime(date, "%Y-%m-%d")
|
||||
|
||||
return (current_date.year, current_date.month) != (
|
||||
last_date.year,
|
||||
last_date.month,
|
||||
)
|
||||
|
||||
def _update_summary_with_baselines(
|
||||
self,
|
||||
date: str,
|
||||
agent_value: float,
|
||||
baseline_values: Dict[str, float],
|
||||
):
|
||||
"""
|
||||
Update summary.json with agent and baseline portfolio values
|
||||
|
||||
NOTE: History updates are now handled centrally by storage.update_dashboard_after_cycle()
|
||||
to ensure all histories (equity, baseline, baseline_vw, momentum) stay synchronized.
|
||||
baseline_values are returned in run_daily_settlement() and passed to storage.
|
||||
|
||||
Args:
|
||||
date: Trading date (used for backtest-compatible timestamps)
|
||||
agent_value: Agent portfolio value
|
||||
baseline_values: Baseline portfolio values
|
||||
"""
|
||||
# History updates are now handled by storage.update_dashboard_after_cycle()
|
||||
# which receives baseline_values from settlement_result and updates all histories together.
|
||||
# This ensures equity and baseline data points are always synchronized.
|
||||
|
||||
def update_intraday_values(
|
||||
self,
|
||||
tickers: List[str],
|
||||
current_prices: Dict[str, float],
|
||||
market_caps: Dict[str, float],
|
||||
agent_portfolio: Dict[str, Any],
|
||||
) -> Dict[str, float]:
|
||||
"""
|
||||
Update portfolio values with current prices (for live mode intraday updates)
|
||||
|
||||
Args:
|
||||
tickers: List of tickers
|
||||
current_prices: Current prices
|
||||
market_caps: Market caps
|
||||
agent_portfolio: Current agent portfolio
|
||||
|
||||
Returns:
|
||||
Dict with current portfolio values
|
||||
"""
|
||||
agent_value = self.storage.calculate_portfolio_value(
|
||||
agent_portfolio,
|
||||
current_prices,
|
||||
)
|
||||
|
||||
equal_weight = self.baseline_calculator.calculate_equal_weight_value(
|
||||
tickers,
|
||||
current_prices,
|
||||
current_prices,
|
||||
)
|
||||
market_cap = (
|
||||
self.baseline_calculator.calculate_market_cap_weighted_value(
|
||||
tickers,
|
||||
current_prices,
|
||||
current_prices,
|
||||
market_caps,
|
||||
)
|
||||
)
|
||||
|
||||
momentum_scores = calculate_momentum_scores(
|
||||
tickers,
|
||||
self.price_history,
|
||||
lookback_days=20,
|
||||
)
|
||||
|
||||
last_date = (
|
||||
list(self.price_history.values())[0][-1][0]
|
||||
if self.price_history
|
||||
else ""
|
||||
)
|
||||
|
||||
momentum = self.baseline_calculator.calculate_momentum_value(
|
||||
tickers,
|
||||
current_prices,
|
||||
current_prices,
|
||||
momentum_scores,
|
||||
date=last_date,
|
||||
rebalance=False,
|
||||
)
|
||||
|
||||
return {
|
||||
"agent": agent_value,
|
||||
"equal_weight": equal_weight,
|
||||
"market_cap_weighted": market_cap,
|
||||
"momentum": momentum,
|
||||
}
|
||||
772
backend/utils/trade_executor.py
Normal file
772
backend/utils/trade_executor.py
Normal file
@@ -0,0 +1,772 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Trading Execution Engine - Supports Two Modes
|
||||
1. Signal mode: Only records directional signal decisions
|
||||
2. Portfolio mode: Executes specific trades and tracks positions
|
||||
"""
|
||||
# flake8: noqa: E501
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class DirectionSignalRecorder:
|
||||
"""Direction signal recorder, records daily investment direction decisions"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize direction signal recorder"""
|
||||
self.signal_log = [] # Record all directional signal history
|
||||
|
||||
def record_direction_signals(
|
||||
self,
|
||||
decisions: Dict[str, Dict[str, Any]],
|
||||
current_date: str = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Record Portfolio Manager's directional signal decisions
|
||||
|
||||
Args:
|
||||
decisions: PM's direction decisions {ticker: {action, confidence, reasoning}}
|
||||
current_date: Current date (used for backtest compatibility)
|
||||
|
||||
Returns:
|
||||
Signal recording report
|
||||
"""
|
||||
if current_date is None:
|
||||
current_date = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# Use provided date for timestamp (backtest compatible)
|
||||
timestamp = f"{current_date}T09:30:00"
|
||||
|
||||
signal_report: Dict[str, Any] = {
|
||||
"recorded_signals": {},
|
||||
"date": current_date,
|
||||
"timestamp": timestamp,
|
||||
"total_signals": len(decisions),
|
||||
}
|
||||
|
||||
print(
|
||||
f"\n📊 Recording directional signal decisions for {current_date}...",
|
||||
)
|
||||
|
||||
# Record directional signal for each ticker
|
||||
for ticker, decision in decisions.items():
|
||||
action = decision.get("action", "hold")
|
||||
confidence = decision.get("confidence", 0)
|
||||
reasoning = decision.get("reasoning", "")
|
||||
|
||||
# Record signal
|
||||
signal_record = {
|
||||
"ticker": ticker,
|
||||
"action": action,
|
||||
"confidence": confidence,
|
||||
"reasoning": reasoning,
|
||||
"date": current_date,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
|
||||
self.signal_log.append(signal_record)
|
||||
signal_report["recorded_signals"][ticker] = {
|
||||
"action": action,
|
||||
"confidence": confidence,
|
||||
}
|
||||
|
||||
# Display signal
|
||||
action_emoji = {"long": "📈", "short": "📉", "hold": "➖"}
|
||||
emoji = action_emoji.get(action, "❓")
|
||||
print(
|
||||
f" {emoji} {ticker}: {action.upper()} (Confidence: {confidence}%) - {reasoning}",
|
||||
)
|
||||
|
||||
print(f"\n✅ Recorded directional signals for {len(decisions)} stocks")
|
||||
|
||||
return signal_report
|
||||
|
||||
def get_signal_summary(self) -> Dict[str, Any]:
|
||||
"""Get signal recording summary"""
|
||||
return {
|
||||
"total_signals": len(self.signal_log),
|
||||
"signal_log": self.signal_log,
|
||||
}
|
||||
|
||||
|
||||
def parse_pm_decisions(pm_output: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Parse Portfolio Manager output format
|
||||
|
||||
Args:
|
||||
pm_output: PM's raw output
|
||||
|
||||
Returns:
|
||||
Standardized decision format
|
||||
"""
|
||||
if isinstance(pm_output, dict) and "decisions" in pm_output:
|
||||
return pm_output["decisions"]
|
||||
elif isinstance(pm_output, dict):
|
||||
# If directly a decision dictionary
|
||||
return pm_output
|
||||
else:
|
||||
print(f"Warning: Unable to parse PM output format: {type(pm_output)}")
|
||||
return {}
|
||||
|
||||
|
||||
class PortfolioTradeExecutor:
|
||||
"""Portfolio mode trade executor, executes specific trades and tracks positions"""
|
||||
|
||||
portfolio: Dict[str, Any]
|
||||
trade_history: List[Dict[str, Any]]
|
||||
portfolio_history: List[Dict[str, Any]]
|
||||
|
||||
def __init__(self, initial_portfolio: Optional[Dict[str, Any]] = None):
|
||||
"""
|
||||
Initialize Portfolio trade executor
|
||||
|
||||
Args:
|
||||
initial_portfolio: Initial portfolio state
|
||||
"""
|
||||
|
||||
if initial_portfolio is None:
|
||||
self.portfolio = {
|
||||
"cash": 100000.0,
|
||||
"positions": {},
|
||||
# Default 0.0 (short selling disabled)
|
||||
"margin_requirement": 0.0,
|
||||
"margin_used": 0.0,
|
||||
}
|
||||
else:
|
||||
self.portfolio = deepcopy(initial_portfolio)
|
||||
|
||||
self.trade_history = [] # Trade history
|
||||
self.portfolio_history = [] # Portfolio history
|
||||
|
||||
def execute_trade(
|
||||
self,
|
||||
ticker: str,
|
||||
action: str,
|
||||
quantity: int,
|
||||
price: float,
|
||||
current_date: str = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute a single trade
|
||||
|
||||
Args:
|
||||
ticker: Stock ticker
|
||||
action: Trade action (long/short/hold)
|
||||
quantity: Number of shares
|
||||
price: Current price
|
||||
current_date: Trade date
|
||||
|
||||
Returns:
|
||||
Trade result dictionary
|
||||
"""
|
||||
if current_date is None:
|
||||
current_date = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
if action == "hold" or quantity == 0:
|
||||
return {"status": "success", "message": "No trade needed"}
|
||||
|
||||
if price <= 0:
|
||||
return {"status": "failed", "reason": "Invalid price"}
|
||||
|
||||
result = self._execute_single_trade(
|
||||
ticker=ticker,
|
||||
action=action,
|
||||
target_quantity=quantity,
|
||||
price=price,
|
||||
date=current_date,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def execute_trades(
|
||||
self,
|
||||
decisions: Dict[str, Dict[str, Any]],
|
||||
current_prices: Dict[str, float],
|
||||
current_date: str = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute trading decisions and update positions
|
||||
|
||||
Args:
|
||||
decisions: {ticker: {action, quantity, confidence, reasoning}}
|
||||
current_prices: {ticker: current_price}
|
||||
current_date: Current date (used for backtest compatibility)
|
||||
|
||||
Returns:
|
||||
Trade execution report
|
||||
"""
|
||||
if current_date is None:
|
||||
current_date = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# Use provided date for timestamp (backtest compatible)
|
||||
timestamp = f"{current_date}T09:30:00"
|
||||
|
||||
execution_report: Dict[str, Any] = {
|
||||
"date": current_date,
|
||||
"timestamp": timestamp,
|
||||
"executed_trades": [],
|
||||
"failed_trades": [],
|
||||
"portfolio_before": deepcopy(self.portfolio),
|
||||
"portfolio_after": None,
|
||||
}
|
||||
|
||||
print(f"\n💼 Executing Portfolio trades for {current_date}...")
|
||||
|
||||
# Execute trades for each ticker
|
||||
for ticker, decision in decisions.items():
|
||||
action = decision.get("action", "hold")
|
||||
quantity = decision.get("quantity", 0)
|
||||
|
||||
if action == "hold" or quantity == 0:
|
||||
continue
|
||||
|
||||
price = current_prices.get(ticker, 0)
|
||||
if price <= 0:
|
||||
execution_report["failed_trades"].append(
|
||||
{
|
||||
"ticker": ticker,
|
||||
"action": action,
|
||||
"quantity": quantity,
|
||||
"reason": "No valid price data",
|
||||
},
|
||||
)
|
||||
print(
|
||||
f" ❌ {ticker}: Unable to execute {action} - No valid price",
|
||||
)
|
||||
continue
|
||||
|
||||
# Execute trade
|
||||
trade_result = self._execute_single_trade(
|
||||
ticker,
|
||||
action,
|
||||
quantity,
|
||||
price,
|
||||
current_date,
|
||||
)
|
||||
if trade_result["status"] == "success":
|
||||
execution_report["executed_trades"].append(trade_result)
|
||||
|
||||
trades_info = ", ".join(trade_result.get("trades", []))
|
||||
print(
|
||||
f" ✔ {ticker}: {action} Target {quantity} shares "
|
||||
f"({trades_info}) @ ${price:.2f}",
|
||||
)
|
||||
else:
|
||||
execution_report["failed_trades"].append(trade_result)
|
||||
print(
|
||||
f" ✗ {ticker}: Unable to execute {action} - {trade_result['reason']}",
|
||||
)
|
||||
|
||||
# Record final portfolio state
|
||||
execution_report["portfolio_after"] = deepcopy(self.portfolio)
|
||||
self.portfolio_history.append(
|
||||
{
|
||||
"date": current_date,
|
||||
"portfolio": deepcopy(self.portfolio),
|
||||
},
|
||||
)
|
||||
|
||||
# Calculate portfolio value
|
||||
portfolio_value = self._calculate_portfolio_value(current_prices)
|
||||
execution_report["portfolio_value"] = portfolio_value
|
||||
|
||||
print("\n✔ Trade execution completed:")
|
||||
print(f" Success: {len(execution_report['executed_trades'])} trades")
|
||||
print(f" Failed: {len(execution_report['failed_trades'])} trades")
|
||||
print(f" Portfolio value: ${portfolio_value:,.2f}")
|
||||
print(f" Cash balance: ${self.portfolio['cash']:,.2f}")
|
||||
|
||||
return execution_report
|
||||
|
||||
def _execute_single_trade(
|
||||
self,
|
||||
ticker: str,
|
||||
action: str,
|
||||
target_quantity: int,
|
||||
price: float,
|
||||
date: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute single trade - Incremental mode
|
||||
|
||||
Args:
|
||||
ticker: Stock ticker
|
||||
action: long(add position)/short(reduce position)/hold
|
||||
target_quantity: Incremental quantity (long=buy shares, short=sell shares)
|
||||
price: Current price
|
||||
date: Trade date
|
||||
"""
|
||||
|
||||
# Ensure position exists
|
||||
if ticker not in self.portfolio["positions"]:
|
||||
self.portfolio["positions"][ticker] = {
|
||||
"long": 0,
|
||||
"short": 0,
|
||||
"long_cost_basis": 0.0,
|
||||
"short_cost_basis": 0.0,
|
||||
}
|
||||
|
||||
position = self.portfolio["positions"][ticker]
|
||||
current_long = position["long"]
|
||||
current_short = position["short"]
|
||||
|
||||
trades_executed = [] # Record actually executed trade steps
|
||||
|
||||
if action == "long":
|
||||
result = self._execute_long_action(
|
||||
ticker,
|
||||
target_quantity,
|
||||
price,
|
||||
date,
|
||||
current_long,
|
||||
current_short,
|
||||
trades_executed,
|
||||
)
|
||||
if result["status"] == "failed":
|
||||
return result
|
||||
|
||||
elif action == "short":
|
||||
result = self._execute_short_action(
|
||||
ticker,
|
||||
target_quantity,
|
||||
price,
|
||||
date,
|
||||
current_long,
|
||||
current_short,
|
||||
trades_executed,
|
||||
)
|
||||
if result["status"] == "failed":
|
||||
return result
|
||||
|
||||
elif action == "hold":
|
||||
print(f"\n⏸️ {ticker} Position unchanged: {current_long} shares")
|
||||
|
||||
# Record trade with backtest-compatible timestamp
|
||||
trade_record = {
|
||||
"status": "success",
|
||||
"ticker": ticker,
|
||||
"action": action,
|
||||
"target_quantity": target_quantity,
|
||||
"price": price,
|
||||
"trades": trades_executed,
|
||||
"date": date,
|
||||
"timestamp": f"{date}T09:30:00",
|
||||
}
|
||||
|
||||
self.trade_history.append(trade_record)
|
||||
|
||||
return trade_record
|
||||
|
||||
def _execute_long_action(
|
||||
self,
|
||||
ticker: str,
|
||||
target_quantity: int,
|
||||
price: float,
|
||||
date: str,
|
||||
current_long: int,
|
||||
current_short: int,
|
||||
trades_executed: list,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute long action: Buy shares or cover shorts first"""
|
||||
print(
|
||||
f"\n📈 {ticker} Long operation: Current Long {current_long}, "
|
||||
f"Short {current_short} → Target quantity {target_quantity}",
|
||||
)
|
||||
|
||||
if target_quantity <= 0:
|
||||
print(" ⏸️ Quantity is 0, no trade needed")
|
||||
return {"status": "success"}
|
||||
|
||||
remaining = target_quantity
|
||||
|
||||
# If has short position, cover first
|
||||
if current_short > 0:
|
||||
cover_qty = min(remaining, current_short)
|
||||
print(f" 1️⃣ Cover short: {cover_qty} shares")
|
||||
cover_result = self._cover_short_position(
|
||||
ticker,
|
||||
cover_qty,
|
||||
price,
|
||||
date,
|
||||
)
|
||||
if cover_result["status"] == "failed":
|
||||
return cover_result
|
||||
trades_executed.append(f"Cover {cover_qty} shares")
|
||||
remaining -= cover_qty
|
||||
|
||||
# If still has remaining quantity, buy long
|
||||
if remaining > 0:
|
||||
print(f" 2️⃣ Buy long: {remaining} shares")
|
||||
buy_result = self._buy_long_position(
|
||||
ticker,
|
||||
remaining,
|
||||
price,
|
||||
date,
|
||||
)
|
||||
if buy_result["status"] == "failed":
|
||||
return buy_result
|
||||
trades_executed.append(f"Buy {remaining} shares")
|
||||
|
||||
# Display final result
|
||||
final_long = self.portfolio["positions"][ticker]["long"]
|
||||
final_short = self.portfolio["positions"][ticker]["short"]
|
||||
print(
|
||||
f" ✅ Final state: Long {final_long} shares, Short {final_short} shares",
|
||||
)
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
def _execute_short_action(
|
||||
self,
|
||||
ticker: str,
|
||||
target_quantity: int,
|
||||
price: float,
|
||||
date: str,
|
||||
current_long: int,
|
||||
current_short: int,
|
||||
trades_executed: list,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute short action: Sell long positions first, then short if needed"""
|
||||
print(
|
||||
f"\n📉 {ticker} Short operation (quantity={target_quantity} shares):",
|
||||
)
|
||||
print(
|
||||
f" Current state: Long {current_long} shares, Short {current_short} shares",
|
||||
)
|
||||
|
||||
if target_quantity <= 0:
|
||||
print(" ⏸️ Quantity is 0, no trade needed")
|
||||
return {"status": "success"}
|
||||
|
||||
remaining_quantity = target_quantity
|
||||
|
||||
# Step 1: If there are long positions, sell first
|
||||
if current_long > 0:
|
||||
sell_quantity = min(remaining_quantity, current_long)
|
||||
print(f" 1️⃣ Sell long: {sell_quantity} shares")
|
||||
sell_result = self._sell_long_position(
|
||||
ticker,
|
||||
sell_quantity,
|
||||
price,
|
||||
date,
|
||||
)
|
||||
if sell_result["status"] == "failed":
|
||||
return sell_result
|
||||
trades_executed.append(f"Sell {sell_quantity} shares")
|
||||
remaining_quantity -= sell_quantity
|
||||
|
||||
# Step 2: If there's remaining quantity, establish or increase short position
|
||||
if remaining_quantity > 0:
|
||||
print(f" 2️⃣ Short: {remaining_quantity} shares")
|
||||
short_result = self._open_short_position(
|
||||
ticker,
|
||||
remaining_quantity,
|
||||
price,
|
||||
date,
|
||||
)
|
||||
if short_result["status"] == "failed":
|
||||
return short_result
|
||||
trades_executed.append(f"Short {remaining_quantity} shares")
|
||||
|
||||
# Display final result
|
||||
final_long = self.portfolio["positions"][ticker]["long"]
|
||||
final_short = self.portfolio["positions"][ticker]["short"]
|
||||
print(
|
||||
f" ✅ Final state: Long {final_long} shares, Short {final_short} shares",
|
||||
)
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
def _buy_long_position(
|
||||
self,
|
||||
ticker: str,
|
||||
quantity: int,
|
||||
price: float,
|
||||
_date: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Buy long position"""
|
||||
position = self.portfolio["positions"][ticker]
|
||||
trade_value = quantity * price
|
||||
|
||||
if self.portfolio["cash"] < trade_value:
|
||||
return {
|
||||
"status": "failed",
|
||||
"ticker": ticker,
|
||||
"action": "buy",
|
||||
"quantity": quantity,
|
||||
"price": price,
|
||||
"reason": f"Insufficient cash (needed: ${trade_value:.2f}, available: "
|
||||
f"${self.portfolio['cash']:.2f})",
|
||||
}
|
||||
|
||||
# Update position cost basis
|
||||
old_long = position["long"]
|
||||
old_cost_basis = position["long_cost_basis"]
|
||||
new_long = old_long + quantity
|
||||
|
||||
# 🐛 Debug info
|
||||
print(f" 🔍 Buy {ticker}:")
|
||||
print(f" Old position: {old_long} shares @ ${old_cost_basis:.2f}")
|
||||
print(f" Buy: {quantity} shares @ ${price:.2f}")
|
||||
print(f" New position: {new_long} shares")
|
||||
|
||||
if new_long > 0:
|
||||
new_cost_basis = (
|
||||
(old_long * old_cost_basis) + (quantity * price)
|
||||
) / new_long
|
||||
print(
|
||||
f" New cost: ${new_cost_basis:.2f} = "
|
||||
f"(({old_long} × ${old_cost_basis:.2f}) + "
|
||||
f"({quantity} × ${price:.2f})) / {new_long}",
|
||||
)
|
||||
position["long_cost_basis"] = new_cost_basis
|
||||
position["long"] = new_long
|
||||
|
||||
# Deduct cash
|
||||
self.portfolio["cash"] -= trade_value
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
def _sell_long_position(
|
||||
self,
|
||||
ticker: str,
|
||||
quantity: int,
|
||||
price: float,
|
||||
_date: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Sell long position"""
|
||||
position = self.portfolio["positions"][ticker]
|
||||
|
||||
if position["long"] < quantity:
|
||||
return {
|
||||
"status": "failed",
|
||||
"ticker": ticker,
|
||||
"action": "sell",
|
||||
"quantity": quantity,
|
||||
"price": price,
|
||||
"reason": f"Insufficient long position (holding: {position['long']},"
|
||||
f" trying to sell: {quantity})",
|
||||
}
|
||||
|
||||
# Reduce position
|
||||
position["long"] -= quantity
|
||||
if position["long"] == 0:
|
||||
position["long_cost_basis"] = 0.0
|
||||
|
||||
# Increase cash
|
||||
trade_value = quantity * price
|
||||
self.portfolio["cash"] += trade_value
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
def _open_short_position(
|
||||
self,
|
||||
ticker: str,
|
||||
quantity: int,
|
||||
price: float,
|
||||
_date: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Open short position"""
|
||||
position = self.portfolio["positions"][ticker]
|
||||
trade_value = quantity * price
|
||||
margin_needed = trade_value * self.portfolio["margin_requirement"]
|
||||
|
||||
if self.portfolio["cash"] < margin_needed:
|
||||
return {
|
||||
"status": "failed",
|
||||
"ticker": ticker,
|
||||
"action": "short",
|
||||
"quantity": quantity,
|
||||
"price": price,
|
||||
"reason": f"Insufficient margin (needed: ${margin_needed:.2f}, "
|
||||
f"available: ${self.portfolio['cash']:.2f})",
|
||||
}
|
||||
|
||||
# Update position cost basis
|
||||
old_short = position["short"]
|
||||
old_cost_basis = position["short_cost_basis"]
|
||||
new_short = old_short + quantity
|
||||
if new_short > 0:
|
||||
position["short_cost_basis"] = (
|
||||
(old_short * old_cost_basis) + (quantity * price)
|
||||
) / new_short
|
||||
position["short"] = new_short
|
||||
|
||||
# Increase cash (short sale proceeds) and margin used
|
||||
self.portfolio["cash"] += trade_value - margin_needed
|
||||
self.portfolio["margin_used"] += margin_needed
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
def _cover_short_position(
|
||||
self,
|
||||
ticker: str,
|
||||
quantity: int,
|
||||
price: float,
|
||||
_date: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Cover short position"""
|
||||
position = self.portfolio["positions"][ticker]
|
||||
|
||||
if position["short"] < quantity:
|
||||
return {
|
||||
"status": "failed",
|
||||
"ticker": ticker,
|
||||
"action": "cover",
|
||||
"quantity": quantity,
|
||||
"price": price,
|
||||
"reason": f"Insufficient short position (holding: {position['short']}, "
|
||||
f"trying to cover: {quantity})",
|
||||
}
|
||||
|
||||
# Calculate released margin - 🔧 FIX: Use cost_basis instead of current price
|
||||
trade_value = quantity * price
|
||||
cost_basis = position["short_cost_basis"]
|
||||
margin_released = (
|
||||
quantity * cost_basis * self.portfolio["margin_requirement"]
|
||||
)
|
||||
|
||||
# Reduce position
|
||||
position["short"] -= quantity
|
||||
if position["short"] == 0:
|
||||
position["short_cost_basis"] = 0.0
|
||||
|
||||
# Deduct cash (buy to cover) and release margin
|
||||
self.portfolio["cash"] -= trade_value
|
||||
self.portfolio["cash"] += margin_released
|
||||
self.portfolio["margin_used"] -= margin_released
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
def _calculate_portfolio_value(
|
||||
self,
|
||||
current_prices: Dict[str, float],
|
||||
) -> float:
|
||||
"""Calculate total portfolio value (net liquidation value)"""
|
||||
# Add margin_used back because it's frozen cash, not lost money
|
||||
total_value = self.portfolio["cash"] + self.portfolio["margin_used"]
|
||||
|
||||
for ticker, position in self.portfolio["positions"].items():
|
||||
if ticker in current_prices:
|
||||
price = current_prices[ticker]
|
||||
# Add long position value
|
||||
total_value += position["long"] * price
|
||||
# Subtract short position value (liability)
|
||||
total_value -= position["short"] * price
|
||||
|
||||
return total_value
|
||||
|
||||
def get_portfolio_summary(
|
||||
self,
|
||||
current_prices: Dict[str, float],
|
||||
) -> Dict[str, Any]:
|
||||
"""Get portfolio summary"""
|
||||
portfolio_value = self._calculate_portfolio_value(current_prices)
|
||||
|
||||
positions_summary = []
|
||||
for ticker, position in self.portfolio["positions"].items():
|
||||
if position["long"] > 0 or position["short"] > 0:
|
||||
price = current_prices.get(ticker, 0)
|
||||
long_value = position["long"] * price
|
||||
short_value = position["short"] * price
|
||||
|
||||
positions_summary.append(
|
||||
{
|
||||
"ticker": ticker,
|
||||
"long_shares": position["long"],
|
||||
"short_shares": position["short"],
|
||||
"long_value": long_value,
|
||||
"short_value": short_value,
|
||||
"long_cost_basis": position["long_cost_basis"],
|
||||
"short_cost_basis": position["short_cost_basis"],
|
||||
"long_pnl": (
|
||||
long_value
|
||||
- (position["long"] * position["long_cost_basis"])
|
||||
if position["long"] > 0
|
||||
else 0
|
||||
),
|
||||
"short_pnl": (
|
||||
(position["short"] * position["short_cost_basis"])
|
||||
- short_value
|
||||
if position["short"] > 0
|
||||
else 0
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"portfolio_value": portfolio_value,
|
||||
"cash": self.portfolio["cash"],
|
||||
"margin_used": self.portfolio["margin_used"],
|
||||
"positions": positions_summary,
|
||||
"total_trades": len(self.trade_history),
|
||||
}
|
||||
|
||||
|
||||
def execute_trading_decisions(
|
||||
pm_decisions: Dict[str, Any],
|
||||
current_date: str = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Convenience function to record directional signal decisions (Signal mode)
|
||||
|
||||
Args:
|
||||
pm_decisions: PM's direction decisions
|
||||
current_date: Current date (optional)
|
||||
|
||||
Returns:
|
||||
Signal recording report
|
||||
"""
|
||||
# Parse PM decisions
|
||||
decisions = parse_pm_decisions(pm_decisions)
|
||||
|
||||
# Create direction signal recorder
|
||||
recorder = DirectionSignalRecorder()
|
||||
|
||||
# Record directional signals
|
||||
signal_report = recorder.record_direction_signals(decisions, current_date)
|
||||
|
||||
return signal_report
|
||||
|
||||
|
||||
def execute_portfolio_trades(
|
||||
pm_decisions: Dict[str, Any],
|
||||
current_prices: Dict[str, float],
|
||||
portfolio: Dict[str, Any],
|
||||
current_date: str = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute Portfolio mode trading decisions
|
||||
|
||||
Args:
|
||||
pm_decisions: PM's trading decisions
|
||||
current_prices: Current prices
|
||||
portfolio: Current portfolio state
|
||||
current_date: Current date (optional)
|
||||
|
||||
Returns:
|
||||
Trade execution report and updated portfolio
|
||||
"""
|
||||
# Parse PM decisions
|
||||
decisions = parse_pm_decisions(pm_decisions)
|
||||
|
||||
# Create Portfolio trade executor
|
||||
executor = PortfolioTradeExecutor(initial_portfolio=portfolio)
|
||||
|
||||
# Execute trades
|
||||
execution_report = executor.execute_trades(
|
||||
decisions,
|
||||
current_prices,
|
||||
current_date,
|
||||
)
|
||||
|
||||
# Add portfolio summary
|
||||
execution_report["portfolio_summary"] = executor.get_portfolio_summary(
|
||||
current_prices,
|
||||
)
|
||||
|
||||
# Return updated portfolio
|
||||
execution_report["updated_portfolio"] = executor.portfolio
|
||||
|
||||
return execution_report
|
||||
Reference in New Issue
Block a user