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

7
backend/core/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
"""Core pipeline and orchestration logic"""
from .pipeline import TradingPipeline
from .state_sync import StateSync
__all__ = ["TradingPipeline", "StateSync"]

1263
backend/core/pipeline.py Normal file

File diff suppressed because it is too large Load Diff

263
backend/core/scheduler.py Normal file
View File

@@ -0,0 +1,263 @@
# -*- coding: utf-8 -*-
"""
Scheduler - Market-aware trigger system for trading cycles
"""
import asyncio
import logging
from datetime import datetime, timedelta
from typing import Any, Callable, Optional
from zoneinfo import ZoneInfo
import pandas_market_calendars as mcal
logger = logging.getLogger(__name__)
# NYSE timezone for US stock trading
NYSE_TZ = ZoneInfo("America/New_York")
NYSE_CALENDAR = mcal.get_calendar("NYSE")
class Scheduler:
"""
Market-aware scheduler for live trading.
Uses NYSE timezone and trading calendar.
"""
def __init__(
self,
mode: str = "daily",
trigger_time: Optional[str] = None,
interval_minutes: Optional[int] = None,
config: Optional[dict] = None,
):
self.mode = mode
self.trigger_time = trigger_time or "09:30" # NYSE timezone
self.trigger_now = self.trigger_time == "now"
self.interval_minutes = interval_minutes or 60
self.config = config or {}
self.running = False
self._task: Optional[asyncio.Task] = None
def _now_nyse(self) -> datetime:
"""Get current time in NYSE timezone"""
return datetime.now(NYSE_TZ)
def _is_trading_day(self, date: datetime) -> bool:
"""Check if date is a NYSE trading day"""
date_str = date.strftime("%Y-%m-%d")
valid_days = NYSE_CALENDAR.valid_days(
start_date=date_str,
end_date=date_str,
)
return len(valid_days) > 0
def _next_trading_day(self, from_date: datetime) -> datetime:
"""Find the next trading day from given date"""
check_date = from_date
for _ in range(10): # Max 10 days ahead (handles holidays)
if self._is_trading_day(check_date):
return check_date
check_date += timedelta(days=1)
return check_date
async def start(self, callback: Callable):
"""Start scheduler"""
if self.running:
logger.warning("Scheduler already running")
return
self.running = True
if self.mode == "daily":
self._task = asyncio.create_task(self._run_daily(callback))
elif self.mode == "intraday":
self._task = asyncio.create_task(self._run_intraday(callback))
else:
raise ValueError(f"Unknown scheduler mode: {self.mode}")
logger.info(
f"Scheduler started: mode={self.mode}, timezone=America/New_York",
)
async def _run_daily(self, callback: Callable):
"""Run once per trading day at specified time (NYSE timezone)"""
first_run = True
while self.running:
now = self._now_nyse()
# Handle "now" trigger - run immediately on first iteration
if self.trigger_now and first_run:
first_run = False
current_date = now.strftime("%Y-%m-%d")
logger.info(f"Triggering immediately for {current_date}")
await callback(date=current_date)
# After immediate run, stop (one-shot mode)
self.running = False
break
target_time = datetime.strptime(self.trigger_time, "%H:%M").time()
# Calculate next trigger datetime
if now.time() < target_time:
next_run = now.replace(
hour=target_time.hour,
minute=target_time.minute,
second=0,
microsecond=0,
)
else:
next_run = (now + timedelta(days=1)).replace(
hour=target_time.hour,
minute=target_time.minute,
second=0,
microsecond=0,
)
# Skip to next trading day
next_run = self._next_trading_day(next_run)
next_run = next_run.replace(
hour=target_time.hour,
minute=target_time.minute,
second=0,
microsecond=0,
)
wait_seconds = (next_run - now).total_seconds()
logger.info(
f"Next trigger: {next_run.strftime('%Y-%m-%d %H:%M %Z')} "
f"(in {wait_seconds/3600:.1f} hours)",
)
await asyncio.sleep(wait_seconds)
current_date = self._now_nyse().strftime("%Y-%m-%d")
logger.info(f"Triggering daily cycle for {current_date}")
await callback(date=current_date)
async def _run_intraday(self, callback: Callable):
"""Run every N minutes (for future use)"""
while self.running:
now = self._now_nyse()
current_date = now.strftime("%Y-%m-%d")
if self._is_trading_day(now):
logger.info(f"Triggering intraday cycle for {current_date}")
await callback(date=current_date)
await asyncio.sleep(self.interval_minutes * 60)
def stop(self):
"""Stop scheduler"""
self.running = False
if self._task:
self._task.cancel()
self._task = None
logger.info("Scheduler stopped")
class BacktestScheduler:
"""Backtest Scheduler - Runs through historical trading dates"""
def __init__(
self,
start_date: str,
end_date: str,
trading_calendar: Optional[Any] = None,
delay_between_days: float = 0.1,
):
self.start_date = start_date
self.end_date = end_date
self.trading_calendar = trading_calendar
self.delay_between_days = delay_between_days
self.running = False
self._task: Optional[asyncio.Task] = None
self._dates: list = []
def get_trading_dates(self) -> list:
"""Get list of trading dates in the backtest period"""
import pandas as pd
start = pd.to_datetime(self.start_date)
end = pd.to_datetime(self.end_date)
if self.trading_calendar:
calendar = mcal.get_calendar(self.trading_calendar)
trading_dates = calendar.valid_days(
start_date=self.start_date,
end_date=self.end_date,
)
dates = [d.strftime("%Y-%m-%d") for d in trading_dates]
else:
all_dates = pd.date_range(start=start, end=end, freq="D")
dates = [
d.strftime("%Y-%m-%d") for d in all_dates if d.weekday() < 5
]
self._dates = dates
return dates
async def start(self, callback: Callable):
"""Start async backtest scheduler"""
if self.running:
logger.warning("Backtest scheduler already running")
return
self.running = True
dates = self.get_trading_dates()
logger.info(
f"Starting backtest: {self.start_date} to {self.end_date} "
f"({len(dates)} trading days)",
)
self._task = asyncio.create_task(self._run_async(callback, dates))
async def _run_async(self, callback: Callable, dates: list):
"""Run backtest asynchronously"""
for i, date in enumerate(dates, 1):
if not self.running:
break
logger.info(f"[{i}/{len(dates)}] Processing {date}")
await callback(date=date)
if self.delay_between_days > 0:
await asyncio.sleep(self.delay_between_days)
logger.info("Backtest complete")
self.running = False
def run(self, callback: Callable, **kwargs):
"""Run backtest synchronously through all trading dates"""
dates = self.get_trading_dates()
results = []
logger.info(
f"Starting backtest: {self.start_date} to {self.end_date} "
f"({len(dates)} trading days)",
)
for i, date in enumerate(dates, 1):
logger.info(f"[{i}/{len(dates)}] Processing {date}")
result = callback(date=date, **kwargs)
results.append({"date": date, "result": result})
logger.info("Backtest complete")
return results
def stop(self):
"""Stop backtest scheduler"""
self.running = False
if self._task:
self._task.cancel()
self._task = None
logger.info("Backtest scheduler stopped")
def get_total_days(self) -> int:
"""Get total number of trading days"""
if not self._dates:
self.get_trading_dates()
return len(self._dates)

476
backend/core/state_sync.py Normal file
View File

@@ -0,0 +1,476 @@
# -*- coding: utf-8 -*-
"""
StateSync - Centralized state synchronization between agents and frontend
Handles real-time updates, persistence, and replay support
"""
# pylint: disable=R0904
import asyncio
import logging
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional
from ..services.storage import StorageService
logger = logging.getLogger(__name__)
class StateSync:
"""
Central event dispatcher for agent-frontend synchronization
Responsibilities:
1. Receive events from agents/pipeline
2. Persist to storage (feed_history)
3. Broadcast to frontend via WebSocket
4. Support replay from saved state
"""
def __init__(
self,
storage: StorageService,
broadcast_fn: Optional[Callable] = None,
):
"""
Initialize StateSync
Args:
storage: Storage service for persistence
broadcast_fn: Async broadcast function - async def broadcast(event: dict) # noqa: E501
"""
self.storage = storage
self._broadcast_fn = broadcast_fn
self._state: Dict[str, Any] = {}
self._enabled = True
self._simulation_date: Optional[str] = None # For backtest timestamps
def set_simulation_date(self, date: str):
"""Set current simulation date for backtest-compatible timestamps"""
self._simulation_date = date
def _get_timestamp_ms(self) -> int:
"""
Get timestamp in milliseconds.
Uses simulation date if set (backtest mode), otherwise current time.
"""
if self._simulation_date:
# Parse date and use market close time (16:00) for backtest
dt = datetime.strptime(
f"{self._simulation_date}",
"%Y-%m-%d",
)
return int(dt.timestamp() * 1000)
return int(datetime.now().timestamp() * 1000)
def load_state(self):
"""Load server state from storage"""
self._state = self.storage.load_server_state()
self.storage.update_server_state_from_dashboard(self._state)
logger.info(
f"StateSync loaded: {len(self._state.get('feed_history', []))} feeds", # noqa: E501
)
def save_state(self):
"""Save current state to storage"""
self.storage.save_server_state(self._state)
@property
def state(self) -> Dict[str, Any]:
"""Get current state"""
return self._state
def set_broadcast_fn(self, fn: Callable):
"""Set broadcast function (supports late binding)"""
self._broadcast_fn = fn
def update_state(self, key: str, value: Any):
"""Update a state field"""
self._state[key] = value
async def emit(self, event: Dict[str, Any], persist: bool = True):
"""
Emit an event - persists and broadcasts
Args:
event: Event dictionary, must contain "type"
persist: Whether to persist to feed_history
"""
if not self._enabled:
return
# Ensure timestamp exists (use simulation date if in backtest mode)
if "timestamp" not in event:
if self._simulation_date:
event["timestamp"] = f"{self._simulation_date}"
else:
event["timestamp"] = datetime.now().isoformat()
# Persist to feed_history
if persist:
self.storage.add_feed_message(self._state, event)
self.save_state()
# Broadcast to frontend
if self._broadcast_fn:
await self._broadcast_fn(event)
# ========== Agent Events ==========
async def on_agent_complete(
self,
agent_id: str,
content: str,
**extra,
):
"""
Called when an agent finishes its reply
Args:
agent_id: Agent identifier (e.g., "fundamentals_analyst")
content: Agent's output content
**extra: Additional fields to include
"""
ts_ms = self._get_timestamp_ms()
await self.emit(
{
"type": "agent_message",
"agentId": agent_id,
"content": content,
"ts": ts_ms,
**extra,
},
)
logger.info(f"Agent complete: {agent_id}")
async def on_memory_retrieved(
self,
agent_id: str,
content: str,
):
"""
Called when long-term memory is retrieved for an agent
Args:
agent_id: Agent identifier
content: Retrieved memory content
"""
ts_ms = self._get_timestamp_ms()
await self.emit(
{
"type": "memory",
"agentId": agent_id,
"content": content,
"ts": ts_ms,
},
)
logger.info(f"Memory retrieved for: {agent_id}")
# ========== Conference Events ==========
async def on_conference_start(self, title: str, date: str):
"""Called when conference discussion starts"""
ts_ms = self._get_timestamp_ms()
await self.emit(
{
"type": "conference_start",
"title": title,
"date": date,
"ts": ts_ms,
},
)
logger.info(f"Conference started: {title}")
async def on_conference_cycle_start(self, cycle: int, total_cycles: int):
"""Called when a conference cycle starts"""
await self.emit(
{
"type": "conference_cycle_start",
"cycle": cycle,
"totalCycles": total_cycles,
},
persist=False,
)
async def on_conference_message(self, agent_id: str, content: str):
"""Called when an agent speaks during conference"""
ts_ms = self._get_timestamp_ms()
await self.emit(
{
"type": "conference_message",
"agentId": agent_id,
"content": content,
"ts": ts_ms,
},
)
async def on_conference_cycle_end(self, cycle: int):
"""Called when a conference cycle ends"""
await self.emit(
{
"type": "conference_cycle_end",
"cycle": cycle,
},
persist=False,
)
async def on_conference_end(self):
"""Called when conference discussion ends"""
ts_ms = self._get_timestamp_ms()
await self.emit(
{
"type": "conference_end",
"ts": ts_ms,
},
)
logger.info("Conference ended")
# ========== Cycle Events ==========
async def on_cycle_start(self, date: str):
"""Called at start of trading cycle"""
self._state["current_date"] = date
self._state["status"] = "running"
self.set_simulation_date(
date,
) # Set for backtest-compatible timestamps
await self.emit(
{
"type": "day_start",
"date": date,
"progress": self._calculate_progress(),
},
)
# await self.emit(
# {
# "type": "system",
# "content": f"Starting trading analysis for {date}",
# },
# )
async def on_cycle_end(self, date: str, portfolio_summary: Dict = None):
"""Called at end of trading cycle"""
# Update completed count
self._state["trading_days_completed"] = (
self._state.get("trading_days_completed", 0) + 1
)
# Broadcast team_summary if available
if portfolio_summary:
summary_data = {
"type": "team_summary",
"balance": portfolio_summary.get(
"balance",
portfolio_summary.get("total_value", 0),
),
"pnlPct": portfolio_summary.get(
"pnlPct",
portfolio_summary.get("pnl_percent", 0),
),
"equity": portfolio_summary.get("equity", []),
"baseline": portfolio_summary.get("baseline", []),
"baseline_vw": portfolio_summary.get("baseline_vw", []),
"momentum": portfolio_summary.get("momentum", []),
}
# Include live returns if available
if portfolio_summary.get("equity_return"):
summary_data["equity_return"] = portfolio_summary[
"equity_return"
]
if portfolio_summary.get("baseline_return"):
summary_data["baseline_return"] = portfolio_summary[
"baseline_return"
]
if portfolio_summary.get("baseline_vw_return"):
summary_data["baseline_vw_return"] = portfolio_summary[
"baseline_vw_return"
]
if portfolio_summary.get("momentum_return"):
summary_data["momentum_return"] = portfolio_summary[
"momentum_return"
]
if "portfolio" not in self._state:
self._state["portfolio"] = {}
self._state["portfolio"].update(
{
"total_value": summary_data["balance"],
"pnl_percent": summary_data["pnlPct"],
"equity": summary_data["equity"],
"baseline": summary_data["baseline"],
"baseline_vw": summary_data["baseline_vw"],
"momentum": summary_data["momentum"],
},
)
if summary_data.get("equity_return"):
self._state["portfolio"]["equity_return"] = summary_data[
"equity_return"
]
if summary_data.get("baseline_return"):
self._state["portfolio"]["baseline_return"] = summary_data[
"baseline_return"
]
if summary_data.get("baseline_vw_return"):
self._state["portfolio"]["baseline_vw_return"] = summary_data[
"baseline_vw_return"
]
if summary_data.get("momentum_return"):
self._state["portfolio"]["momentum_return"] = summary_data[
"momentum_return"
]
await self.emit(summary_data, persist=True)
await self.emit(
{
"type": "day_complete",
"date": date,
"progress": self._calculate_progress(),
},
)
self.save_state()
# ========== Portfolio Events ==========
async def on_holdings_update(self, holdings: List[Dict]):
"""Called when holdings change"""
self._state["holdings"] = holdings
await self.emit(
{
"type": "team_holdings",
"data": holdings,
},
persist=False,
) # Holdings change frequently, don't store all in feed_history
async def on_trades_executed(self, trades: List[Dict]):
"""Called when trades are executed"""
# Update state with new trades
existing = self._state.get("trades", [])
self._state["trades"] = trades + existing
await self.emit(
{
"type": "team_trades",
"mode": "incremental",
"data": trades,
},
persist=False,
)
async def on_stats_update(self, stats: Dict):
"""Called when stats are updated"""
self._state["stats"] = stats
await self.emit(
{
"type": "team_stats",
"data": stats,
},
persist=False,
)
async def on_leaderboard_update(self, leaderboard: List[Dict]):
"""Called when leaderboard is updated"""
self._state["leaderboard"] = leaderboard
await self.emit(
{
"type": "team_leaderboard",
"data": leaderboard,
},
persist=False,
)
# ========== System Events ==========
async def on_system_message(self, content: str):
"""Emit a system message"""
await self.emit(
{
"type": "system",
"content": content,
},
)
# ========== Replay Support ==========
async def replay_feed_history(self, delay_ms: int = 100):
"""
Replay events from feed_history
Useful for: frontend reconnection or restoring from saved state
"""
feed_history = self._state.get("feed_history", [])
# feed_history is newest-first, need to reverse for chronological replay # noqa: E501
for event in reversed(feed_history):
if self._broadcast_fn:
await self._broadcast_fn(event)
await asyncio.sleep(delay_ms / 1000)
logger.info(f"Replayed {len(feed_history)} events")
def get_initial_state_payload(
self,
include_dashboard: bool = True,
) -> Dict[str, Any]:
"""
Build initial state payload for new client connections
Args:
include_dashboard: Whether to load dashboard files
Returns:
Dictionary suitable for sending to frontend
"""
payload = {
"server_mode": self._state.get("server_mode", "live"),
"is_mock_mode": self._state.get("is_mock_mode", False),
"is_backtest": self._state.get("is_backtest", False),
"feed_history": self._state.get("feed_history", []),
"current_date": self._state.get("current_date"),
"trading_days_total": self._state.get("trading_days_total", 0),
"trading_days_completed": self._state.get(
"trading_days_completed",
0,
),
"holdings": self._state.get("holdings", []),
"trades": self._state.get("trades", []),
"stats": self._state.get("stats", {}),
"leaderboard": self._state.get("leaderboard", []),
"portfolio": self._state.get("portfolio", {}),
"realtime_prices": self._state.get("realtime_prices", {}),
}
if include_dashboard:
payload["dashboard"] = {
"summary": self.storage.load_file("summary"),
"holdings": self.storage.load_file("holdings"),
"stats": self.storage.load_file("stats"),
"trades": self.storage.load_file("trades"),
"leaderboard": self.storage.load_file("leaderboard"),
}
return payload
def _calculate_progress(self) -> float:
"""Calculate backtest progress percentage"""
total = self._state.get("trading_days_total", 0)
completed = self._state.get("trading_days_completed", 0)
return completed / total if total > 0 else 0.0
def set_backtest_dates(self, dates: List[str]):
"""Set total trading days for backtest progress tracking"""
self._state["trading_days_total"] = len(dates)
self._state["trading_days_completed"] = 0