feat: initial commit - EvoTraders project

量化交易多智能体系统,包含:
- 分析师、投资组合经理、风险经理等智能体
- 股票分析、投资组合管理、风险控制工具
- React 前端界面
- FastAPI 后端服务

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-03-13 04:34:06 +08:00
commit 12de93aa30
115 changed files with 29304 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# This file can be empty
"""Utility modules for the application."""

View 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
View 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

View 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
View 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
View 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_file("leaderboard") or []
updated_leaderboard = update_leaderboard_with_evaluations(
leaderboard,
all_evaluations,
)
self.storage.save_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,
}

View File

@@ -0,0 +1,348 @@
# -*- coding: utf-8 -*-
"""
Terminal Dashboard - Persistent unified panel using Rich Live
"""
# pylint: disable=R0915,R0912
import logging
import threading
import time
from typing import Any, Dict, List, Optional
from rich.console import Console
from rich.live import Live
from rich.panel import Panel
from rich.table import Table
logger = logging.getLogger(__name__)
class TerminalDashboard:
"""Unified persistent terminal dashboard"""
def __init__(self, console: Console = None):
self.console = console or Console()
self.live: Optional[Live] = None
# Config state
self.mode = "live"
self.config_name = ""
self.host = "0.0.0.0"
self.port = 8765
self.poll_interval = 10
self.trigger_time = "now"
self.mock = False
self.enable_memory = False
self.local_time = ""
self.nyse_time = ""
self.start_date = ""
self.end_date = ""
self.tickers: List[str] = []
self.initial_cash = 100000.0
# Trading state
self.current_date = "-"
self.status = "Initializing"
self.total_value = 0.0
self.cash = 0.0
self.pnl_pct = 0.0
self.holdings: List[Dict] = []
self.trades: List[Dict] = []
self.days_completed = 0
self.days_total = 0
# Progress message (last line)
self.progress = ""
self._dots_index = 0
self._animator_running = False
self._animator_thread: Optional[threading.Thread] = None
def set_config(
self,
mode: str,
config_name: str,
host: str,
port: int,
poll_interval: int,
trigger_time: str = "now",
mock: bool = False,
enable_memory: bool = False,
local_time: str = "",
nyse_time: str = "",
start_date: str = "",
end_date: str = "",
tickers: List[str] = None,
initial_cash: float = 100000.0,
):
"""Set configuration state"""
self.mode = mode
self.config_name = config_name
self.host = host
self.port = port
self.poll_interval = poll_interval
self.trigger_time = trigger_time
self.mock = mock
self.enable_memory = enable_memory
self.local_time = local_time
self.nyse_time = nyse_time
self.start_date = start_date
self.end_date = end_date
self.tickers = tickers or []
self.initial_cash = initial_cash
self.total_value = initial_cash
self.cash = initial_cash
def _build_panel(self) -> Panel:
"""Build the unified dashboard panel"""
# Main grid
main_table = Table.grid(padding=(0, 2))
main_table.add_column(width=28)
main_table.add_column(width=22)
main_table.add_column(width=22)
# Left: Config + Status
left = Table.grid(padding=(0, 0))
left.add_column()
# Mode line
if self.mode == "backtest":
mode_str = "[cyan]Backtest[/cyan]"
elif self.mock:
mode_str = "[yellow]MOCK[/yellow]"
else:
mode_str = "[green]LIVE[/green]"
left.add_row(f"[bold]Mode:[/bold] {mode_str}")
left.add_row(f"[dim]Config:[/dim] {self.config_name}")
left.add_row(f"[dim]Server:[/dim] {self.host}:{self.port}")
if self.mode == "live" and self.nyse_time:
left.add_row(f"[dim]NYSE:[/dim] {self.nyse_time[:19]}")
trigger_display = (
"[green]NOW[/green]"
if self.trigger_time == "now"
else self.trigger_time
)
left.add_row(f"[dim]Trigger:[/dim] {trigger_display}")
# Status
left.add_row("")
status_style = "green" if self.status == "Running" else "yellow"
left.add_row(
"[bold]Status:[/bold] "
f"[{status_style}]{self.status}[/{status_style}]",
)
if self.mode == "backtest":
left.add_row(
f"[dim]Backtesting Period:[/dim] {self.days_total} days\n"
f" {self.start_date} -> {self.end_date}",
)
left.add_row(f"[dim]Current Date:[/dim] {self.current_date}")
# Middle: Portfolio
mid = Table.grid(padding=(0, 0))
mid.add_column()
pnl_style = "green" if self.pnl_pct >= 0 else "red"
mid.add_row("[bold]Portfolio[/bold]")
mid.add_row(f"NAV: [bold]${self.total_value:,.0f}[/bold]")
mid.add_row(f"Cash: ${self.cash:,.0f}")
mid.add_row(f"P&L: [{pnl_style}]{self.pnl_pct:+.2f}%[/{pnl_style}]")
# Positions
mid.add_row("")
mid.add_row("[bold]Positions[/bold]")
stock_holdings = [
h for h in self.holdings if h.get("ticker") != "CASH"
]
if stock_holdings:
for h in stock_holdings[:7]:
qty = h.get("quantity", 0)
ticker = h.get("ticker", "")[:5]
val = h.get("marketValue", 0)
qty_str = f"{qty:+d}" if qty != 0 else "0"
mid.add_row(
f"[cyan]{ticker:<5}[/cyan] {qty_str:>5} ${val:>7,.0f}",
)
if len(stock_holdings) > 7:
mid.add_row(f"[dim]+{len(stock_holdings) - 7} more[/dim]")
else:
mid.add_row("[dim]No positions[/dim]")
# Right: Recent Trades
right = Table.grid(padding=(0, 0))
right.add_column()
right.add_row("[bold]Recent Trades[/bold]")
if self.trades:
for t in self.trades[:10]:
side = t.get("side", "")
ticker = t.get("ticker", "")[:5]
qty = t.get("qty", 0)
if side == "LONG":
side_str = "[green]L[/green]"
elif side == "SHORT":
side_str = "[red]S[/red]"
else:
side_str = "[dim]H[/dim]"
right.add_row(f"{side_str} [cyan]{ticker:<5}[/cyan] {qty:>4}")
if len(self.trades) > 10:
right.add_row(f"[dim]+{len(self.trades) - 10} more[/dim]")
else:
right.add_row("[dim]No trades[/dim]")
main_table.add_row(left, mid, right)
# Outer table to add progress line at bottom
outer = Table.grid(padding=(0, 0))
outer.add_column()
outer.add_row(main_table)
# Progress line (last row) with animated dots
if self.progress:
DOTS_FRAMES = [" ", ". ", ".. ", "..."]
dots = DOTS_FRAMES[self._dots_index % len(DOTS_FRAMES)]
outer.add_row("")
outer.add_row(f"[dim]> {self.progress}{dots}[/dim]")
# Build panel
title = "[bold cyan]EvoTraders[/bold cyan]"
if self.mode == "backtest":
title += " [dim]Backtest[/dim]"
elif self.mock:
title += " [dim]Mock[/dim]"
else:
title += " [dim]Live[/dim]"
return Panel(
outer,
title=title,
border_style="cyan",
padding=(0, 1),
)
def _run_animator(self):
"""Background thread to animate the dots"""
while self._animator_running:
time.sleep(0.3)
if self.progress and self.live:
self._dots_index += 1
self.live.update(self._build_panel())
def start(self):
"""Start the live dashboard display"""
self.live = Live(
self._build_panel(),
console=self.console,
refresh_per_second=4,
vertical_overflow="visible",
)
self.live.start()
# Start animator thread
self._animator_running = True
self._animator_thread = threading.Thread(
target=self._run_animator,
daemon=True,
)
self._animator_thread.start()
def stop(self):
"""Stop the live dashboard"""
self._animator_running = False
if self._animator_thread:
self._animator_thread.join(timeout=0.5)
self._animator_thread = None
if self.live:
self.live.stop()
self.live = None
def update(
self,
date: str = None,
status: str = None,
portfolio: Dict[str, Any] = None,
holdings: List[Dict] = None,
trades: List[Dict] = None,
days_completed: int = None,
days_total: int = None,
):
"""Update dashboard state and refresh display"""
if date:
self.current_date = date
if status:
self.status = status
if days_completed is not None:
self.days_completed = days_completed
if days_total is not None:
self.days_total = days_total
if portfolio:
self.total_value = portfolio.get(
"totalAssetValue",
0,
) or portfolio.get(
"total_value",
self.initial_cash,
)
self.cash = portfolio.get("cashPosition", 0) or portfolio.get(
"cash",
self.initial_cash,
)
if self.total_value > 0 and self.initial_cash > 0:
self.pnl_pct = (
(self.total_value - self.initial_cash) / self.initial_cash
) * 100
if holdings is not None:
self.holdings = holdings
if trades is not None:
self.trades = trades
if self.live:
self.live.update(self._build_panel())
def log(self, msg: str, also_log: bool = True):
"""
Update progress message and refresh panel
Args:
msg: Progress message to display
also_log: Whether to also write to logger (default True)
"""
self.progress = msg
if also_log:
logger.info(msg)
if self.live:
self.live.update(self._build_panel())
def print_final_summary(self):
"""Print final summary when dashboard stops"""
pnl_style = "green" if self.pnl_pct >= 0 else "red"
if self.mode == "backtest":
msg = (
f"[bold]Backtest Complete[/bold] | "
f"Days: {self.days_completed} | "
f"NAV: ${self.total_value:,.0f} | "
f"Return: [{pnl_style}]{self.pnl_pct:+.2f}%[/{pnl_style}]"
)
else:
msg = (
f"[bold]Session End[/bold] | "
f"NAV: ${self.total_value:,.0f} | "
f"P&L: [{pnl_style}]{self.pnl_pct:+.2f}%[/{pnl_style}]"
)
self.console.print(Panel(msg, border_style="green"))
# Global instance
_dashboard: Optional[TerminalDashboard] = None
def get_dashboard() -> TerminalDashboard:
"""Get or create global dashboard instance"""
global _dashboard
if _dashboard is None:
_dashboard = TerminalDashboard()
return _dashboard

View 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