Commit ·
10150dc
0
Parent(s):
Initial MarketMind commit (Corrected Author)
Browse files- .gitignore +5 -0
- README.md +156 -0
- agents/__init__.py +1 -0
- agents/base_agent.py +83 -0
- agents/fundamental_agent.py +28 -0
- agents/market_maker_agent.py +20 -0
- agents/mean_reversion_agent.py +19 -0
- agents/momentum_agent.py +19 -0
- agents/noise_trader.py +19 -0
- app.py +565 -0
- dashboard/__init__.py +1 -0
- dashboard/app.py +154 -0
- dashboard/plots.py +97 -0
- engine/__init__.py +1 -0
- engine/market_state.py +42 -0
- engine/metrics.py +117 -0
- engine/order_book.py +248 -0
- engine/simulation.py +487 -0
- experiments/__init__.py +1 -0
- experiments/baseline_run.py +54 -0
- experiments/benchmark_vllm.py +86 -0
- experiments/momentum_heavy.py +52 -0
- experiments/no_market_maker.py +52 -0
- experiments/plot_utils.py +136 -0
- inference/__init__.py +1 -0
- inference/prompt_templates.py +36 -0
- inference/vllm_client.py +204 -0
- requirements.txt +14 -0
- run_simulation.py +68 -0
- tests/test_agents_smoke.py +52 -0
- tests/test_order_book.py +247 -0
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
output/
|
| 3 |
+
*.pyc
|
| 4 |
+
.pytest_cache/
|
| 5 |
+
.env
|
README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: MarketMind
|
| 3 |
+
emoji: ⚡
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 4.0.0
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# MarketMind
|
| 13 |
+
|
| 14 |
+
**Multi-Agent Financial Market Simulation**
|
| 15 |
+
*AMD Developer Hackathon | Track 1: AI Agents & Agentic Workflows | May 2026*
|
| 16 |
+
|
| 17 |
+
A multi-agent financial market simulation where competing LLM agents with distinct trading charters interact inside a continuous double auction (limit order book). The system does not predict markets — it *is* a market.
|
| 18 |
+
|
| 19 |
+
**The research question:**
|
| 20 |
+
> When a financial market is populated entirely by competing LLM agents with heterogeneous beliefs and strategies, does it self-organize toward efficiency — or does it bubble, crash, or fragment?
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
## 🏗 Architecture
|
| 25 |
+
|
| 26 |
+
```
|
| 27 |
+
┌─────────────────────────────────────────────────────┐
|
| 28 |
+
│ MarketMind System │
|
| 29 |
+
│ │
|
| 30 |
+
│ ┌──────────────┐ ┌──────────────────────────┐ │
|
| 31 |
+
│ │ Order Book │◄───│ Agent Dispatcher │ │
|
| 32 |
+
│ │ (CDA Engine)│ │ (round-robin tick loop) │ │
|
| 33 |
+
│ └──────┬───────┘ └──────────────────────────┘ │
|
| 34 |
+
│ │ ▲ │
|
| 35 |
+
│ │ market state │ orders │
|
| 36 |
+
│ ▼ │ │
|
| 37 |
+
│ ┌──────────────────────────────────────────────┐ │
|
| 38 |
+
│ │ Agent Pool (5 agents) │ │
|
| 39 |
+
│ │ │ │
|
| 40 |
+
│ │ [Momentum] [MeanRev] [Fundamental] │ │
|
| 41 |
+
│ │ [MarketMaker] [NoiseTrader] │ │
|
| 42 |
+
│ │ │ │
|
| 43 |
+
│ │ Each agent = LLM prompt + charter config │ │
|
| 44 |
+
│ └──────────────────────────────────────────────┘ │
|
| 45 |
+
│ │ │
|
| 46 |
+
│ │ inference requests │
|
| 47 |
+
│ ▼ │
|
| 48 |
+
│ ┌──────────────────────────────────────────────┐ │
|
| 49 |
+
│ │ vLLM Server (AMD MI300X, ROCm) │ │
|
| 50 |
+
│ │ Qwen2.5-7B-Instruct │ │
|
| 51 |
+
│ │ Concurrent batched requests │ │
|
| 52 |
+
│ └──────────────────────────────────────────────┘ │
|
| 53 |
+
│ │ │
|
| 54 |
+
│ ▼ │
|
| 55 |
+
│ ┌──────────────────────────────────────────────┐ │
|
| 56 |
+
│ │ Metrics Engine + Dashboard │ │
|
| 57 |
+
│ │ Price series / spread / volatility / │ │
|
| 58 |
+
│ │ crash detector / regime classifier │ │
|
| 59 |
+
│ └──────────────────────────────────────────────┘ │
|
| 60 |
+
└─────────────────────────────────────────────────────┘
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
---
|
| 64 |
+
|
| 65 |
+
## 🔬 Experiments & Findings
|
| 66 |
+
|
| 67 |
+
We ran three core experiments to observe emergent market behavior based solely on varying the agent composition. All experiments ran for 200 ticks.
|
| 68 |
+
|
| 69 |
+
### Experiment A — Baseline (Equal Mix)
|
| 70 |
+
*2 Momentum, 1 Mean-Reversion, 1 Fundamental, 1 Market Maker, 1 Noise*
|
| 71 |
+
- **Hypothesis:** Prices stay near fair value. Market is relatively efficient.
|
| 72 |
+
- **Result:** Validated. The market stayed largely efficient, oscillating tightly around the Fundamental agent's anchor price (100.0). The Fundamental agent emerged as the most profitable, while Momentum agents lost capital trading against noise.
|
| 73 |
+
|
| 74 |
+
### Experiment B — Momentum Overload (Bubble Test)
|
| 75 |
+
*4 Momentum, 1 Market Maker, 1 Noise (No Fundamental Anchor)*
|
| 76 |
+
- **Hypothesis:** Price trends away from fair value → bubble formation.
|
| 77 |
+
- **Result:** Validated. Without a fundamental anchor to fade the moves, momentum agents bought into each other's flow. Price skyrocketed from 100 to over 200 in 200 ticks, creating a massive, unstable bubble.
|
| 78 |
+
|
| 79 |
+
### Experiment C — No Market Maker (Liquidity Test)
|
| 80 |
+
*2 Momentum, 1 Mean-Reversion, 1 Fundamental, 1 Noise (No Market Maker)*
|
| 81 |
+
- **Hypothesis:** Spreads widen dramatically, liquidity fragmentation, possible crash.
|
| 82 |
+
- **Result:** Validated. Without a dedicated liquidity provider continuously quoting both sides, the order book rapidly dried up. The spread widened dramatically, and trade volume collapsed compared to the baseline.
|
| 83 |
+
|
| 84 |
+
---
|
| 85 |
+
|
| 86 |
+
## 🚀 AMD MI300X Hardware Advantage & Setup
|
| 87 |
+
|
| 88 |
+
MarketMind utilizes **vLLM on ROCm** on the AMD Developer Cloud. By dispatching 5–6 agents simultaneously each tick using `asyncio.gather`, we max out concurrent inference streams.
|
| 89 |
+
|
| 90 |
+
The **MI300X's massive 192GB HBM3 memory** means we never page-out the model weights between agent calls, maintaining exceptionally low latency despite heavy parallel throughput.
|
| 91 |
+
|
| 92 |
+
### Local Setup Instructions (AMD Developer Cloud)
|
| 93 |
+
|
| 94 |
+
```bash
|
| 95 |
+
# 1. Provision AMD MI300X Instance and install vLLM
|
| 96 |
+
pip install vllm
|
| 97 |
+
|
| 98 |
+
# 2. Launch vLLM Inference Server
|
| 99 |
+
python -m vllm.entrypoints.openai.api_server \
|
| 100 |
+
--model Qwen/Qwen2.5-7B-Instruct \
|
| 101 |
+
--dtype float16 \
|
| 102 |
+
--max-model-len 2048 \
|
| 103 |
+
--tensor-parallel-size 1 \
|
| 104 |
+
--port 8000
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
### Benchmarking
|
| 108 |
+
We provide a standalone benchmark script to measure the MI300X's concurrency speedup:
|
| 109 |
+
```bash
|
| 110 |
+
python experiments/benchmark_vllm.py --url http://localhost:8000/v1
|
| 111 |
+
```
|
| 112 |
+
*(Insert your benchmark latency and throughput results here before submission!)*
|
| 113 |
+
|
| 114 |
+
---
|
| 115 |
+
|
| 116 |
+
## 🌐 Hugging Face Spaces Deployment
|
| 117 |
+
|
| 118 |
+
As part of the AMD Developer Hackathon requirements, **MarketMind is fully optimized for 1-click deployment to Hugging Face Spaces**.
|
| 119 |
+
|
| 120 |
+
### Premium Glassmorphic UI
|
| 121 |
+
The root `app.py` features a stunning, custom-styled Streamlit interface tailored specifically for the HF Hub. It uses transparent Plotly charts, dynamic regime badging, and a premium dark-themed gradient background.
|
| 122 |
+
|
| 123 |
+
### Live Serverless HF API Integration
|
| 124 |
+
Because HF Spaces typically run on basic CPU tiers by default, the app contains an integrated **Live Hugging Face API Mode**. Users can:
|
| 125 |
+
1. Paste their Hugging Face API Token directly into the dashboard.
|
| 126 |
+
2. The UI natively bridges the Simulation Engine to the `api-inference.huggingface.co/v1/` endpoint using the OpenAI SDK format.
|
| 127 |
+
3. Live models (like `meta-llama/Llama-3.2-3B-Instruct`) will directly drive the financial agents over the API without requiring a dedicated GPU inside the space itself.
|
| 128 |
+
|
| 129 |
+
**To deploy your own Space:**
|
| 130 |
+
1. Create a new Streamlit Space on Hugging Face.
|
| 131 |
+
2. Push this repository's root directly to the space.
|
| 132 |
+
3. Streamlit will automatically find `app.py` and `requirements.txt` and launch the platform.
|
| 133 |
+
|
| 134 |
+
---
|
| 135 |
+
|
| 136 |
+
## 💻 Local Quickstart
|
| 137 |
+
|
| 138 |
+
### Prerequisites
|
| 139 |
+
```bash
|
| 140 |
+
pip install -r requirements.txt
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
### Run Simulation Offline (No LLM Required)
|
| 144 |
+
You can run the simulation locally using the deterministic offline heuristic mode.
|
| 145 |
+
```bash
|
| 146 |
+
python run_simulation.py --ticks 200
|
| 147 |
+
```
|
| 148 |
+
|
| 149 |
+
### Run Streamlit Dashboard
|
| 150 |
+
Visualize the live simulation playback and change agent parameters in real-time.
|
| 151 |
+
```bash
|
| 152 |
+
streamlit run dashboard/app.py
|
| 153 |
+
```
|
| 154 |
+
|
| 155 |
+
---
|
| 156 |
+
*Built for the AMD Developer Hackathon | Track 1: AI Agents & Agentic Workflows*
|
agents/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# agents package
|
agents/base_agent.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Base Agent — abstract class for all trading agents.
|
| 3 |
+
|
| 4 |
+
Each agent holds a charter (system prompt), position state, and cash.
|
| 5 |
+
On each tick, the simulation calls agent.observe() with market state
|
| 6 |
+
and the agent returns an Order (or None for hold).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from abc import ABC, abstractmethod
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
|
| 12 |
+
from engine.order_book import Order, Side
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class AgentState:
|
| 17 |
+
"""Mutable state tracked per agent across ticks."""
|
| 18 |
+
position: int = 0 # net units held (positive = long, negative = short)
|
| 19 |
+
cash: float = 10_000.0 # starting cash
|
| 20 |
+
total_pnl: float = 0.0 # realized PnL
|
| 21 |
+
trades_count: int = 0
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class BaseAgent(ABC):
|
| 25 |
+
"""
|
| 26 |
+
Abstract trading agent.
|
| 27 |
+
|
| 28 |
+
Subclasses must implement:
|
| 29 |
+
- charter: property returning the system prompt string
|
| 30 |
+
- agent_type: property returning a human-readable type name
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
def __init__(self, agent_id: str, initial_cash: float = 10_000.0):
|
| 34 |
+
self.agent_id = agent_id
|
| 35 |
+
self.state = AgentState(cash=initial_cash)
|
| 36 |
+
self.price_history: list[float] = []
|
| 37 |
+
|
| 38 |
+
@property
|
| 39 |
+
@abstractmethod
|
| 40 |
+
def charter(self) -> str:
|
| 41 |
+
"""System prompt defining this agent's trading strategy."""
|
| 42 |
+
...
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
@abstractmethod
|
| 46 |
+
def agent_type(self) -> str:
|
| 47 |
+
"""Human-readable agent type name (e.g., 'Momentum')."""
|
| 48 |
+
...
|
| 49 |
+
|
| 50 |
+
def update_price_history(self, price: float):
|
| 51 |
+
"""Called each tick to track price history for the agent's context window."""
|
| 52 |
+
self.price_history.append(price)
|
| 53 |
+
|
| 54 |
+
def update_fair_value(self, new_fv: float):
|
| 55 |
+
"""
|
| 56 |
+
Called each tick by the simulation to broadcast true macroeconomic value drifts.
|
| 57 |
+
Most agents ignore this (it's private info), but Fundamental agents use it.
|
| 58 |
+
"""
|
| 59 |
+
pass
|
| 60 |
+
|
| 61 |
+
def record_trade(self, side: Side, price: float, quantity: int):
|
| 62 |
+
"""
|
| 63 |
+
Update agent state after a trade execution.
|
| 64 |
+
Called by the simulation loop when a trade involves this agent.
|
| 65 |
+
"""
|
| 66 |
+
if side == Side.BUY:
|
| 67 |
+
self.state.position += quantity
|
| 68 |
+
self.state.cash -= price * quantity
|
| 69 |
+
elif side == Side.SELL:
|
| 70 |
+
self.state.position -= quantity
|
| 71 |
+
self.state.cash += price * quantity
|
| 72 |
+
self.state.trades_count += 1
|
| 73 |
+
|
| 74 |
+
def mark_to_market(self, current_price: float) -> float:
|
| 75 |
+
"""Calculate total PnL: cash + position value - initial cash."""
|
| 76 |
+
position_value = self.state.position * current_price
|
| 77 |
+
return self.state.cash + position_value - 10_000.0
|
| 78 |
+
|
| 79 |
+
def __repr__(self) -> str:
|
| 80 |
+
return (
|
| 81 |
+
f"{self.agent_type}(id={self.agent_id}, "
|
| 82 |
+
f"pos={self.state.position}, cash={self.state.cash:.2f})"
|
| 83 |
+
)
|
agents/fundamental_agent.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Fundamental Agent — trades based on private fair value estimate.
|
| 3 |
+
|
| 4 |
+
Charter: Buys below fair value, sells above. Patient, slow to act.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from agents.base_agent import BaseAgent
|
| 8 |
+
from inference.prompt_templates import get_fundamental_charter
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class FundamentalAgent(BaseAgent):
|
| 12 |
+
|
| 13 |
+
def __init__(self, agent_id: str, fair_value: float = 100.0, initial_cash: float = 10_000.0):
|
| 14 |
+
super().__init__(agent_id, initial_cash)
|
| 15 |
+
self.fair_value = fair_value
|
| 16 |
+
|
| 17 |
+
def update_fair_value(self, new_fv: float):
|
| 18 |
+
"""Called by the simulation engine if the asset's true value drifts."""
|
| 19 |
+
self.fair_value = new_fv
|
| 20 |
+
|
| 21 |
+
@property
|
| 22 |
+
def charter(self) -> str:
|
| 23 |
+
# Re-evaluate dynamically in case fair_value changed
|
| 24 |
+
return get_fundamental_charter(self.fair_value)
|
| 25 |
+
|
| 26 |
+
@property
|
| 27 |
+
def agent_type(self) -> str:
|
| 28 |
+
return "Fundamental"
|
agents/market_maker_agent.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Market Maker Agent — provides liquidity by quoting both sides.
|
| 3 |
+
|
| 4 |
+
Charter: Posts bid below mid, ask above mid. Manages inventory risk.
|
| 5 |
+
Per spec, this agent is called twice per tick (bid + ask).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from agents.base_agent import BaseAgent
|
| 9 |
+
from inference.prompt_templates import MARKET_MAKER_CHARTER
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class MarketMakerAgent(BaseAgent):
|
| 13 |
+
|
| 14 |
+
@property
|
| 15 |
+
def charter(self) -> str:
|
| 16 |
+
return MARKET_MAKER_CHARTER
|
| 17 |
+
|
| 18 |
+
@property
|
| 19 |
+
def agent_type(self) -> str:
|
| 20 |
+
return "MarketMaker"
|
agents/mean_reversion_agent.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Mean Reversion Agent — fades moves away from rolling mean.
|
| 3 |
+
|
| 4 |
+
Charter: Uses z-score thresholds against 10-tick rolling average.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from agents.base_agent import BaseAgent
|
| 8 |
+
from inference.prompt_templates import MEAN_REVERSION_CHARTER
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class MeanReversionAgent(BaseAgent):
|
| 12 |
+
|
| 13 |
+
@property
|
| 14 |
+
def charter(self) -> str:
|
| 15 |
+
return MEAN_REVERSION_CHARTER
|
| 16 |
+
|
| 17 |
+
@property
|
| 18 |
+
def agent_type(self) -> str:
|
| 19 |
+
return "MeanReversion"
|
agents/momentum_agent.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Momentum Agent — buys into rising prices, sells into falling.
|
| 3 |
+
|
| 4 |
+
Charter: Short memory window, high turnover, trend-following.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from agents.base_agent import BaseAgent
|
| 8 |
+
from inference.prompt_templates import MOMENTUM_CHARTER
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class MomentumAgent(BaseAgent):
|
| 12 |
+
|
| 13 |
+
@property
|
| 14 |
+
def charter(self) -> str:
|
| 15 |
+
return MOMENTUM_CHARTER
|
| 16 |
+
|
| 17 |
+
@property
|
| 18 |
+
def agent_type(self) -> str:
|
| 19 |
+
return "Momentum"
|
agents/noise_trader.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Noise Trader — acts on random signals to create market friction.
|
| 3 |
+
|
| 4 |
+
Charter: Random buy/sell within 1% of mid, small quantities.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from agents.base_agent import BaseAgent
|
| 8 |
+
from inference.prompt_templates import NOISE_TRADER_CHARTER
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class NoiseTrader(BaseAgent):
|
| 12 |
+
|
| 13 |
+
@property
|
| 14 |
+
def charter(self) -> str:
|
| 15 |
+
return NOISE_TRADER_CHARTER
|
| 16 |
+
|
| 17 |
+
@property
|
| 18 |
+
def agent_type(self) -> str:
|
| 19 |
+
return "NoiseTrader"
|
app.py
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MarketMind — Gradio Dashboard
|
| 3 |
+
A premium trading terminal UI for multi-agent financial market simulation.
|
| 4 |
+
Optimized for Hugging Face Spaces deployment.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import sys
|
| 8 |
+
import os
|
| 9 |
+
import time
|
| 10 |
+
import json
|
| 11 |
+
import pandas as pd
|
| 12 |
+
import numpy as np
|
| 13 |
+
import plotly.graph_objects as go
|
| 14 |
+
from plotly.subplots import make_subplots
|
| 15 |
+
import gradio as gr
|
| 16 |
+
|
| 17 |
+
# Ensure imports work from this directory
|
| 18 |
+
sys.path.insert(0, os.path.dirname(__file__))
|
| 19 |
+
|
| 20 |
+
from engine.simulation import SimulationEngine, SimulationConfig
|
| 21 |
+
from agents.momentum_agent import MomentumAgent
|
| 22 |
+
from agents.mean_reversion_agent import MeanReversionAgent
|
| 23 |
+
from agents.fundamental_agent import FundamentalAgent
|
| 24 |
+
from agents.market_maker_agent import MarketMakerAgent
|
| 25 |
+
from agents.noise_trader import NoiseTrader
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ─── AGENT BUILDER ────────────────────────────────────────────────
|
| 29 |
+
def build_agents(n_mom, n_mr, n_fund, n_noise, n_mm):
|
| 30 |
+
agents = []
|
| 31 |
+
for i in range(n_mom):
|
| 32 |
+
agents.append(MomentumAgent(f"momentum_{i+1}"))
|
| 33 |
+
for i in range(n_mr):
|
| 34 |
+
agents.append(MeanReversionAgent(f"meanrev_{i+1}"))
|
| 35 |
+
for i in range(n_fund):
|
| 36 |
+
agents.append(FundamentalAgent(f"fundamental_{i+1}", fair_value=100.0))
|
| 37 |
+
for i in range(n_noise):
|
| 38 |
+
agents.append(NoiseTrader(f"noise_{i+1}"))
|
| 39 |
+
for i in range(n_mm):
|
| 40 |
+
agents.append(MarketMakerAgent(f"marketmaker_{i+1}"))
|
| 41 |
+
return agents
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ─── CHART BUILDERS ───────────────────────────────────────────────
|
| 45 |
+
|
| 46 |
+
COLORS = {
|
| 47 |
+
"price": "#00d4ff",
|
| 48 |
+
"fair_value": "#ff3366",
|
| 49 |
+
"spread": "#ffaa00",
|
| 50 |
+
"volume": "#7c4dff",
|
| 51 |
+
"bg": "rgba(0,0,0,0)",
|
| 52 |
+
"grid": "rgba(255,255,255,0.04)",
|
| 53 |
+
"text": "#8892b0",
|
| 54 |
+
"agents": ["#00d4ff", "#00ff88", "#ff3366", "#ffaa00", "#7c4dff",
|
| 55 |
+
"#ff6b9d", "#c084fc", "#34d399", "#f87171", "#fbbf24"],
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def build_main_chart(ticks_data):
|
| 60 |
+
"""Build the primary price + volume + spread multi-panel chart."""
|
| 61 |
+
ticks = [r["tick"] for r in ticks_data]
|
| 62 |
+
prices = [r["mid_price"] if r["mid_price"] else 100.0 for r in ticks_data]
|
| 63 |
+
fair_vals = [r.get("true_fair_value", 100.0) for r in ticks_data]
|
| 64 |
+
spreads = [r["spread"] if r["spread"] else 0.0 for r in ticks_data]
|
| 65 |
+
volumes = [r["volume"] for r in ticks_data]
|
| 66 |
+
regimes = [r["regime"] for r in ticks_data]
|
| 67 |
+
|
| 68 |
+
fig = make_subplots(
|
| 69 |
+
rows=3, cols=1,
|
| 70 |
+
shared_xaxes=True,
|
| 71 |
+
vertical_spacing=0.03,
|
| 72 |
+
row_heights=[0.6, 0.2, 0.2],
|
| 73 |
+
subplot_titles=None,
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# Price line
|
| 77 |
+
fig.add_trace(go.Scatter(
|
| 78 |
+
x=ticks, y=prices,
|
| 79 |
+
mode="lines",
|
| 80 |
+
line=dict(color=COLORS["price"], width=2.5),
|
| 81 |
+
name="Market Price",
|
| 82 |
+
fill="tozeroy",
|
| 83 |
+
fillcolor="rgba(0, 212, 255, 0.05)",
|
| 84 |
+
), row=1, col=1)
|
| 85 |
+
|
| 86 |
+
# Fair value
|
| 87 |
+
fig.add_trace(go.Scatter(
|
| 88 |
+
x=ticks, y=fair_vals,
|
| 89 |
+
mode="lines",
|
| 90 |
+
line=dict(color=COLORS["fair_value"], width=1.5, dash="dot"),
|
| 91 |
+
name="Fair Value",
|
| 92 |
+
), row=1, col=1)
|
| 93 |
+
|
| 94 |
+
# Regime color bands
|
| 95 |
+
regime_colors = {"Efficient": "rgba(0,255,136,0.06)",
|
| 96 |
+
"Trending": "rgba(255,170,0,0.06)",
|
| 97 |
+
"Volatile": "rgba(255,51,102,0.06)",
|
| 98 |
+
"Crashed": "rgba(255,0,0,0.10)"}
|
| 99 |
+
prev_regime = regimes[0] if regimes else "Efficient"
|
| 100 |
+
band_start = ticks[0] if ticks else 0
|
| 101 |
+
for i, (t, reg) in enumerate(zip(ticks, regimes)):
|
| 102 |
+
if reg != prev_regime or i == len(ticks) - 1:
|
| 103 |
+
fig.add_vrect(
|
| 104 |
+
x0=band_start, x1=t,
|
| 105 |
+
fillcolor=regime_colors.get(prev_regime, "rgba(0,0,0,0)"),
|
| 106 |
+
layer="below", line_width=0, row=1, col=1,
|
| 107 |
+
)
|
| 108 |
+
band_start = t
|
| 109 |
+
prev_regime = reg
|
| 110 |
+
|
| 111 |
+
# Volume bars
|
| 112 |
+
fig.add_trace(go.Bar(
|
| 113 |
+
x=ticks, y=volumes,
|
| 114 |
+
marker_color=COLORS["volume"],
|
| 115 |
+
opacity=0.6,
|
| 116 |
+
name="Volume",
|
| 117 |
+
), row=2, col=1)
|
| 118 |
+
|
| 119 |
+
# Spread
|
| 120 |
+
fig.add_trace(go.Scatter(
|
| 121 |
+
x=ticks, y=spreads,
|
| 122 |
+
mode="lines",
|
| 123 |
+
line=dict(color=COLORS["spread"], width=2),
|
| 124 |
+
fill="tozeroy",
|
| 125 |
+
fillcolor="rgba(255,170,0,0.08)",
|
| 126 |
+
name="Spread",
|
| 127 |
+
), row=3, col=1)
|
| 128 |
+
|
| 129 |
+
# Layout
|
| 130 |
+
fig.update_layout(
|
| 131 |
+
template="plotly_dark",
|
| 132 |
+
paper_bgcolor=COLORS["bg"],
|
| 133 |
+
plot_bgcolor=COLORS["bg"],
|
| 134 |
+
font=dict(family="JetBrains Mono, monospace", color=COLORS["text"]),
|
| 135 |
+
height=620,
|
| 136 |
+
margin=dict(l=50, r=20, t=30, b=30),
|
| 137 |
+
legend=dict(
|
| 138 |
+
orientation="h", yanchor="bottom", y=1.02,
|
| 139 |
+
xanchor="right", x=1,
|
| 140 |
+
bgcolor="rgba(0,0,0,0)",
|
| 141 |
+
font=dict(size=11),
|
| 142 |
+
),
|
| 143 |
+
showlegend=True,
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
for row in range(1, 4):
|
| 147 |
+
fig.update_xaxes(
|
| 148 |
+
gridcolor=COLORS["grid"], zeroline=False,
|
| 149 |
+
showticklabels=(row == 3), row=row, col=1,
|
| 150 |
+
)
|
| 151 |
+
fig.update_yaxes(
|
| 152 |
+
gridcolor=COLORS["grid"], zeroline=False,
|
| 153 |
+
row=row, col=1,
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
fig.update_yaxes(title_text="Price", row=1, col=1)
|
| 157 |
+
fig.update_yaxes(title_text="Vol", row=2, col=1)
|
| 158 |
+
fig.update_yaxes(title_text="Spread", row=3, col=1)
|
| 159 |
+
fig.update_xaxes(title_text="Tick", row=3, col=1)
|
| 160 |
+
|
| 161 |
+
return fig
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def build_pnl_chart(pnl_data, agents):
|
| 165 |
+
"""Build the agent PnL leaderboard chart."""
|
| 166 |
+
fig = go.Figure()
|
| 167 |
+
|
| 168 |
+
agent_ids = [a.agent_id for a in agents]
|
| 169 |
+
for idx, aid in enumerate(agent_ids):
|
| 170 |
+
agent_rows = [r for r in pnl_data if r["agent_id"] == aid]
|
| 171 |
+
ticks = [r["tick"] for r in agent_rows]
|
| 172 |
+
pnls = [r["pnl"] for r in agent_rows]
|
| 173 |
+
fig.add_trace(go.Scatter(
|
| 174 |
+
x=ticks, y=pnls,
|
| 175 |
+
mode="lines",
|
| 176 |
+
line=dict(color=COLORS["agents"][idx % len(COLORS["agents"])], width=2),
|
| 177 |
+
name=aid,
|
| 178 |
+
))
|
| 179 |
+
|
| 180 |
+
fig.update_layout(
|
| 181 |
+
template="plotly_dark",
|
| 182 |
+
paper_bgcolor=COLORS["bg"],
|
| 183 |
+
plot_bgcolor=COLORS["bg"],
|
| 184 |
+
font=dict(family="JetBrains Mono, monospace", color=COLORS["text"]),
|
| 185 |
+
height=350,
|
| 186 |
+
margin=dict(l=50, r=20, t=30, b=30),
|
| 187 |
+
legend=dict(
|
| 188 |
+
orientation="h", yanchor="bottom", y=1.02,
|
| 189 |
+
xanchor="right", x=1,
|
| 190 |
+
bgcolor="rgba(0,0,0,0)",
|
| 191 |
+
font=dict(size=10),
|
| 192 |
+
),
|
| 193 |
+
yaxis_title="PnL ($)",
|
| 194 |
+
xaxis_title="Tick",
|
| 195 |
+
xaxis=dict(gridcolor=COLORS["grid"], zeroline=False),
|
| 196 |
+
yaxis=dict(gridcolor=COLORS["grid"], zeroline=False),
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
# Zero line
|
| 200 |
+
fig.add_hline(y=0, line_dash="dash", line_color="rgba(255,255,255,0.15)", line_width=1)
|
| 201 |
+
|
| 202 |
+
return fig
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def build_leaderboard(pnl_data, ticks_data):
|
| 206 |
+
"""Build final leaderboard as a DataFrame."""
|
| 207 |
+
if not pnl_data or not ticks_data:
|
| 208 |
+
return pd.DataFrame()
|
| 209 |
+
|
| 210 |
+
last_tick = ticks_data[-1]["tick"]
|
| 211 |
+
final_rows = [r for r in pnl_data if r["tick"] == last_tick]
|
| 212 |
+
|
| 213 |
+
records = []
|
| 214 |
+
for r in sorted(final_rows, key=lambda x: x["pnl"], reverse=True):
|
| 215 |
+
pnl = r["pnl"]
|
| 216 |
+
emoji = "🟢" if pnl > 0 else "🔴" if pnl < 0 else "⚪"
|
| 217 |
+
records.append({
|
| 218 |
+
"": emoji,
|
| 219 |
+
"Agent": r["agent_id"],
|
| 220 |
+
"Type": r["agent_type"],
|
| 221 |
+
"PnL": f"${pnl:+,.2f}",
|
| 222 |
+
"Position": r["position"],
|
| 223 |
+
"Trades": r["trades"],
|
| 224 |
+
})
|
| 225 |
+
|
| 226 |
+
return pd.DataFrame(records)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def build_stats_html(ticks_data, pnl_data, elapsed):
|
| 230 |
+
"""Build the live stats panel as HTML."""
|
| 231 |
+
if not ticks_data:
|
| 232 |
+
return "<p>No data</p>"
|
| 233 |
+
|
| 234 |
+
last = ticks_data[-1]
|
| 235 |
+
first_price = ticks_data[0]["mid_price"] or 100.0
|
| 236 |
+
last_price = last["mid_price"] or 100.0
|
| 237 |
+
pct_change = ((last_price - first_price) / first_price) * 100
|
| 238 |
+
total_volume = sum(r["volume"] for r in ticks_data)
|
| 239 |
+
total_trades = sum(r["trade_count"] for r in ticks_data)
|
| 240 |
+
avg_spread = np.mean([r["spread"] for r in ticks_data if r["spread"]]) if ticks_data else 0
|
| 241 |
+
regime = last.get("regime", "Unknown")
|
| 242 |
+
|
| 243 |
+
regime_colors = {
|
| 244 |
+
"Efficient": "#00ff88",
|
| 245 |
+
"Trending": "#ffaa00",
|
| 246 |
+
"Volatile": "#ff3366",
|
| 247 |
+
"Crashed": "#ff0000",
|
| 248 |
+
}
|
| 249 |
+
rc = regime_colors.get(regime, "#8892b0")
|
| 250 |
+
|
| 251 |
+
return f"""
|
| 252 |
+
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 12px;">
|
| 253 |
+
<div class="stat-card">
|
| 254 |
+
<div class="stat-label">FINAL PRICE</div>
|
| 255 |
+
<div class="stat-value">${last_price:,.2f}</div>
|
| 256 |
+
<div class="stat-delta" style="color: {'#00ff88' if pct_change >= 0 else '#ff3366'};">
|
| 257 |
+
{pct_change:+.2f}%
|
| 258 |
+
</div>
|
| 259 |
+
</div>
|
| 260 |
+
<div class="stat-card">
|
| 261 |
+
<div class="stat-label">REGIME</div>
|
| 262 |
+
<div class="stat-value" style="color: {rc};">{regime}</div>
|
| 263 |
+
</div>
|
| 264 |
+
<div class="stat-card">
|
| 265 |
+
<div class="stat-label">TOTAL TRADES</div>
|
| 266 |
+
<div class="stat-value">{total_trades:,}</div>
|
| 267 |
+
</div>
|
| 268 |
+
<div class="stat-card">
|
| 269 |
+
<div class="stat-label">VOLUME</div>
|
| 270 |
+
<div class="stat-value">{total_volume:,}</div>
|
| 271 |
+
</div>
|
| 272 |
+
<div class="stat-card">
|
| 273 |
+
<div class="stat-label">AVG SPREAD</div>
|
| 274 |
+
<div class="stat-value">{avg_spread:.4f}</div>
|
| 275 |
+
</div>
|
| 276 |
+
<div class="stat-card">
|
| 277 |
+
<div class="stat-label">SIM TIME</div>
|
| 278 |
+
<div class="stat-value">{elapsed:.1f}s</div>
|
| 279 |
+
</div>
|
| 280 |
+
</div>
|
| 281 |
+
"""
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
# ─── SIMULATION RUNNER ────────────────────────────────────────────
|
| 285 |
+
|
| 286 |
+
def run_simulation(n_mom, n_mr, n_fund, n_noise, n_mm,
|
| 287 |
+
num_ticks, warmup_ticks, volatility, use_llm, hf_token, hf_model,
|
| 288 |
+
progress=gr.Progress()):
|
| 289 |
+
"""Run the full simulation and return all visualization components."""
|
| 290 |
+
agents = build_agents(int(n_mom), int(n_mr), int(n_fund), int(n_noise), int(n_mm))
|
| 291 |
+
if not agents:
|
| 292 |
+
raise gr.Error("Add at least one agent to run the simulation.")
|
| 293 |
+
|
| 294 |
+
config = SimulationConfig(
|
| 295 |
+
num_ticks=int(num_ticks),
|
| 296 |
+
initial_price=100.0,
|
| 297 |
+
use_llm=use_llm,
|
| 298 |
+
vllm_base_url="https://api-inference.huggingface.co/v1" if use_llm else "http://localhost:8000/v1",
|
| 299 |
+
vllm_model=hf_model if use_llm else "Qwen/Qwen2.5-7B-Instruct",
|
| 300 |
+
log_to_csv=False,
|
| 301 |
+
base_volatility=volatility,
|
| 302 |
+
warmup_ticks=int(warmup_ticks),
|
| 303 |
+
enable_seed_liquidity=False, # pure LLM market
|
| 304 |
+
fee_per_trade=0.01
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
engine = SimulationEngine(agents, config)
|
| 308 |
+
|
| 309 |
+
if use_llm and hf_token and engine.llm_client:
|
| 310 |
+
import openai
|
| 311 |
+
engine.llm_client.client = openai.AsyncOpenAI(
|
| 312 |
+
base_url=config.vllm_base_url,
|
| 313 |
+
api_key=hf_token,
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
t0 = time.time()
|
| 317 |
+
|
| 318 |
+
# Use generator to yield live updates
|
| 319 |
+
for tick in engine.run_generator():
|
| 320 |
+
progress(tick / int(num_ticks), desc=f"Running tick {tick}/{num_ticks}...")
|
| 321 |
+
|
| 322 |
+
# Only yield UI updates every 10 ticks to prevent Gradio blocking
|
| 323 |
+
if tick % 10 == 0 or tick == int(num_ticks):
|
| 324 |
+
ticks_data = engine.csv_rows
|
| 325 |
+
pnl_data = engine.agent_pnl_rows
|
| 326 |
+
|
| 327 |
+
main_chart = build_main_chart(ticks_data)
|
| 328 |
+
pnl_chart = build_pnl_chart(pnl_data, agents)
|
| 329 |
+
leaderboard = build_leaderboard(pnl_data, ticks_data)
|
| 330 |
+
stats_html = build_stats_html(ticks_data, pnl_data, time.time() - t0)
|
| 331 |
+
|
| 332 |
+
yield main_chart, pnl_chart, leaderboard, stats_html
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
# ─── CUSTOM CSS ───────────────────────────────────────────────────
|
| 336 |
+
|
| 337 |
+
CUSTOM_CSS = """
|
| 338 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;800&family=JetBrains+Mono:wght@400;500&display=swap');
|
| 339 |
+
|
| 340 |
+
/* ── Global ─────────────────────────────────────── */
|
| 341 |
+
html, body {
|
| 342 |
+
background: #0a0b10 !important;
|
| 343 |
+
}
|
| 344 |
+
.gradio-container {
|
| 345 |
+
max-width: 100% !important;
|
| 346 |
+
font-family: 'Inter', sans-serif !important;
|
| 347 |
+
background: linear-gradient(160deg, #0a0b10 0%, #111827 50%, #0d1117 100%) !important;
|
| 348 |
+
min-height: 100vh;
|
| 349 |
+
}
|
| 350 |
+
.main {
|
| 351 |
+
background: transparent !important;
|
| 352 |
+
}
|
| 353 |
+
footer {
|
| 354 |
+
display: none !important;
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
/* ── Top Bar ────────────────────────────────────── */
|
| 358 |
+
.title-bar {
|
| 359 |
+
background: linear-gradient(135deg, rgba(0,212,255,0.08), rgba(124,77,255,0.08));
|
| 360 |
+
border: 1px solid rgba(0,212,255,0.12);
|
| 361 |
+
border-radius: 16px;
|
| 362 |
+
padding: 24px 32px;
|
| 363 |
+
margin-bottom: 16px;
|
| 364 |
+
position: relative;
|
| 365 |
+
overflow: hidden;
|
| 366 |
+
}
|
| 367 |
+
.title-bar::before {
|
| 368 |
+
content: '';
|
| 369 |
+
position: absolute;
|
| 370 |
+
top: 0; left: 0; right: 0;
|
| 371 |
+
height: 2px;
|
| 372 |
+
background: linear-gradient(90deg, #00d4ff, #7c4dff, #ff3366);
|
| 373 |
+
}
|
| 374 |
+
.title-bar h1 {
|
| 375 |
+
margin: 0 0 4px 0;
|
| 376 |
+
font-size: 2em;
|
| 377 |
+
font-weight: 800;
|
| 378 |
+
background: linear-gradient(135deg, #00d4ff 0%, #7c4dff 50%, #ff3366 100%);
|
| 379 |
+
-webkit-background-clip: text;
|
| 380 |
+
-webkit-text-fill-color: transparent;
|
| 381 |
+
letter-spacing: -1px;
|
| 382 |
+
}
|
| 383 |
+
.title-bar p {
|
| 384 |
+
margin: 0;
|
| 385 |
+
color: #8892b0;
|
| 386 |
+
font-size: 0.95em;
|
| 387 |
+
max-width: 700px;
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
/* ── Stat Cards ─────────────────────────────────── */
|
| 391 |
+
.stat-card {
|
| 392 |
+
background: rgba(17,24,39,0.7);
|
| 393 |
+
border: 1px solid rgba(255,255,255,0.06);
|
| 394 |
+
border-radius: 12px;
|
| 395 |
+
padding: 14px 16px;
|
| 396 |
+
text-align: center;
|
| 397 |
+
}
|
| 398 |
+
.stat-label {
|
| 399 |
+
font-family: 'JetBrains Mono', monospace;
|
| 400 |
+
font-size: 0.65em;
|
| 401 |
+
color: #5a6785;
|
| 402 |
+
letter-spacing: 1.5px;
|
| 403 |
+
text-transform: uppercase;
|
| 404 |
+
margin-bottom: 4px;
|
| 405 |
+
}
|
| 406 |
+
.stat-value {
|
| 407 |
+
font-family: 'JetBrains Mono', monospace;
|
| 408 |
+
font-size: 1.3em;
|
| 409 |
+
font-weight: 600;
|
| 410 |
+
color: #e2e8f0;
|
| 411 |
+
}
|
| 412 |
+
.stat-delta {
|
| 413 |
+
font-family: 'JetBrains Mono', monospace;
|
| 414 |
+
font-size: 0.85em;
|
| 415 |
+
font-weight: 500;
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
/* ── Panel Sections ─────────────────────────────── */
|
| 419 |
+
.panel-header {
|
| 420 |
+
font-family: 'JetBrains Mono', monospace;
|
| 421 |
+
font-size: 0.75em;
|
| 422 |
+
color: #00d4ff;
|
| 423 |
+
letter-spacing: 2px;
|
| 424 |
+
text-transform: uppercase;
|
| 425 |
+
margin: 16px 0 8px 0;
|
| 426 |
+
padding-bottom: 6px;
|
| 427 |
+
border-bottom: 1px solid rgba(0,212,255,0.15);
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
/* ── Gradio Overrides ───────────────────────────── */
|
| 431 |
+
.dark .block {
|
| 432 |
+
background: rgba(17,24,39,0.5) !important;
|
| 433 |
+
border: 1px solid rgba(255,255,255,0.05) !important;
|
| 434 |
+
border-radius: 12px !important;
|
| 435 |
+
}
|
| 436 |
+
.dark .label-wrap {
|
| 437 |
+
color: #8892b0 !important;
|
| 438 |
+
}
|
| 439 |
+
.dark input, .dark textarea, .dark select {
|
| 440 |
+
background: rgba(15,20,35,0.8) !important;
|
| 441 |
+
border: 1px solid rgba(255,255,255,0.08) !important;
|
| 442 |
+
color: #e2e8f0 !important;
|
| 443 |
+
border-radius: 8px !important;
|
| 444 |
+
}
|
| 445 |
+
.dark .primary {
|
| 446 |
+
background: linear-gradient(135deg, #00d4ff 0%, #7c4dff 100%) !important;
|
| 447 |
+
border: none !important;
|
| 448 |
+
font-weight: 600 !important;
|
| 449 |
+
letter-spacing: 0.5px !important;
|
| 450 |
+
transition: all 0.3s ease !important;
|
| 451 |
+
box-shadow: 0 4px 15px rgba(0,212,255,0.25) !important;
|
| 452 |
+
}
|
| 453 |
+
.dark .primary:hover {
|
| 454 |
+
box-shadow: 0 6px 25px rgba(0,212,255,0.4) !important;
|
| 455 |
+
transform: translateY(-1px) !important;
|
| 456 |
+
}
|
| 457 |
+
.dark table {
|
| 458 |
+
font-family: 'JetBrains Mono', monospace !important;
|
| 459 |
+
font-size: 0.85em !important;
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
/* ── Scrollbar ──────────────────────────────────── */
|
| 463 |
+
::-webkit-scrollbar { width: 6px; }
|
| 464 |
+
::-webkit-scrollbar-track { background: rgba(0,0,0,0.2); }
|
| 465 |
+
::-webkit-scrollbar-thumb { background: rgba(0,212,255,0.3); border-radius: 3px; }
|
| 466 |
+
"""
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
# ─── GRADIO APP ───────────────────────────────────────────────────
|
| 470 |
+
|
| 471 |
+
def create_app():
|
| 472 |
+
with gr.Blocks(
|
| 473 |
+
title="MarketMind | Multi-Agent Market Simulation",
|
| 474 |
+
) as app:
|
| 475 |
+
|
| 476 |
+
# ── Title Bar ──
|
| 477 |
+
gr.HTML("""
|
| 478 |
+
<div class="title-bar">
|
| 479 |
+
<h1>⚡ MarketMind</h1>
|
| 480 |
+
<p>Multi-agent financial market simulation powered by LLM agents competing inside a
|
| 481 |
+
continuous double auction. Adjust the agent composition to discover if the market
|
| 482 |
+
self-organizes to efficiency — or collapses into chaos.</p>
|
| 483 |
+
</div>
|
| 484 |
+
""")
|
| 485 |
+
|
| 486 |
+
with gr.Row():
|
| 487 |
+
# ══════════════════════════════════════════════
|
| 488 |
+
# LEFT PANEL — Controls
|
| 489 |
+
# ══════════════════════════════════════════════
|
| 490 |
+
with gr.Column(scale=1, min_width=280):
|
| 491 |
+
|
| 492 |
+
gr.HTML('<div class="panel-header">⚙ Engine</div>')
|
| 493 |
+
use_llm = gr.Checkbox(label="Live LLM Mode", value=False,
|
| 494 |
+
info="Use HF Serverless API for live inference")
|
| 495 |
+
hf_token = gr.Textbox(label="HF Token", type="password",
|
| 496 |
+
placeholder="hf_...", visible=False)
|
| 497 |
+
hf_model = gr.Textbox(label="Model", value="meta-llama/Llama-3.2-3B-Instruct",
|
| 498 |
+
visible=False)
|
| 499 |
+
|
| 500 |
+
use_llm.change(
|
| 501 |
+
lambda v: (gr.update(visible=v), gr.update(visible=v)),
|
| 502 |
+
inputs=[use_llm],
|
| 503 |
+
outputs=[hf_token, hf_model],
|
| 504 |
+
)
|
| 505 |
+
|
| 506 |
+
gr.HTML('<div class="panel-header">🧬 Agent Composition</div>')
|
| 507 |
+
n_mom = gr.Slider(0, 10, value=2, step=1, label="Momentum Traders")
|
| 508 |
+
n_mr = gr.Slider(0, 10, value=1, step=1, label="Mean Reversion")
|
| 509 |
+
n_fund = gr.Slider(0, 10, value=1, step=1, label="Fundamental")
|
| 510 |
+
n_noise = gr.Slider(0, 10, value=1, step=1, label="Noise Traders")
|
| 511 |
+
n_mm = gr.Slider(0, 5, value=1, step=1, label="Market Makers")
|
| 512 |
+
|
| 513 |
+
gr.HTML('<div class="panel-header">🔧 Parameters</div>')
|
| 514 |
+
num_ticks = gr.Slider(20, 500, value=150, step=10, label="Simulation Ticks")
|
| 515 |
+
warmup_ticks = gr.Slider(0, 50, value=15, step=5, label="Market Warm-up (Ticks)",
|
| 516 |
+
info="Establishing baseline before LLMs take over")
|
| 517 |
+
volatility = gr.Slider(0.0, 0.05, value=0.005, step=0.001,
|
| 518 |
+
label="Market Volatility")
|
| 519 |
+
|
| 520 |
+
run_btn = gr.Button("▶ Execute Simulation", variant="primary", size="lg")
|
| 521 |
+
|
| 522 |
+
# Stats panel (populated after simulation)
|
| 523 |
+
gr.HTML('<div class="panel-header">📊 Session Stats</div>')
|
| 524 |
+
stats_panel = gr.HTML("<p style='color:#5a6785;text-align:center;padding:20px;'>Run a simulation to see stats</p>")
|
| 525 |
+
|
| 526 |
+
# ══════════════════════════════════════════════
|
| 527 |
+
# RIGHT PANEL — Charts & Results
|
| 528 |
+
# ══════════════════════════════════════════════
|
| 529 |
+
with gr.Column(scale=3):
|
| 530 |
+
|
| 531 |
+
main_chart = gr.Plot(label="Market Overview", elem_classes=["chart-panel"])
|
| 532 |
+
pnl_chart = gr.Plot(label="Agent PnL Tracker")
|
| 533 |
+
leaderboard = gr.DataFrame(
|
| 534 |
+
label="🏆 Final Leaderboard",
|
| 535 |
+
interactive=False,
|
| 536 |
+
wrap=True,
|
| 537 |
+
)
|
| 538 |
+
|
| 539 |
+
# ── Wire up the button ──
|
| 540 |
+
run_btn.click(
|
| 541 |
+
fn=run_simulation,
|
| 542 |
+
inputs=[n_mom, n_mr, n_fund, n_noise, n_mm,
|
| 543 |
+
num_ticks, warmup_ticks, volatility, use_llm, hf_token, hf_model],
|
| 544 |
+
outputs=[main_chart, pnl_chart, leaderboard, stats_panel],
|
| 545 |
+
)
|
| 546 |
+
|
| 547 |
+
return app
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
# ─── ENTRY POINT ──────────────────────────────────────────────────
|
| 551 |
+
|
| 552 |
+
if __name__ == "__main__":
|
| 553 |
+
app = create_app()
|
| 554 |
+
app.launch(
|
| 555 |
+
server_name="0.0.0.0",
|
| 556 |
+
server_port=7860,
|
| 557 |
+
css=CUSTOM_CSS,
|
| 558 |
+
theme=gr.themes.Base(
|
| 559 |
+
primary_hue=gr.themes.colors.cyan,
|
| 560 |
+
secondary_hue=gr.themes.colors.purple,
|
| 561 |
+
neutral_hue=gr.themes.colors.slate,
|
| 562 |
+
font=gr.themes.GoogleFont("Inter"),
|
| 563 |
+
font_mono=gr.themes.GoogleFont("JetBrains Mono"),
|
| 564 |
+
),
|
| 565 |
+
)
|
dashboard/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# dashboard package
|
dashboard/app.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MarketMind Streamlit Dashboard.
|
| 3 |
+
|
| 4 |
+
Live playback of the market simulation.
|
| 5 |
+
Lets the user dynamically change agent composition and watch the emergent behavior.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import sys
|
| 9 |
+
import os
|
| 10 |
+
import time
|
| 11 |
+
import pandas as pd
|
| 12 |
+
import streamlit as st
|
| 13 |
+
|
| 14 |
+
# Ensure we can import from the rest of the project
|
| 15 |
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| 16 |
+
|
| 17 |
+
from engine.simulation import SimulationEngine, SimulationConfig
|
| 18 |
+
from agents.momentum_agent import MomentumAgent
|
| 19 |
+
from agents.mean_reversion_agent import MeanReversionAgent
|
| 20 |
+
from agents.fundamental_agent import FundamentalAgent
|
| 21 |
+
from agents.market_maker_agent import MarketMakerAgent
|
| 22 |
+
from agents.noise_trader import NoiseTrader
|
| 23 |
+
from dashboard.plots import plot_price_chart, plot_agent_pnl, plot_spread
|
| 24 |
+
|
| 25 |
+
st.set_page_config(page_title="MarketMind Simulation", layout="wide", page_icon="📈")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def build_agents(n_mom: int, n_mr: int, n_fund: int, n_noise: int, n_mm: int) -> list:
|
| 29 |
+
"""Build the agent pool based on slider inputs."""
|
| 30 |
+
agents = []
|
| 31 |
+
for i in range(n_mom):
|
| 32 |
+
agents.append(MomentumAgent(f"momentum_{i+1}"))
|
| 33 |
+
for i in range(n_mr):
|
| 34 |
+
agents.append(MeanReversionAgent(f"meanrev_{i+1}"))
|
| 35 |
+
for i in range(n_fund):
|
| 36 |
+
agents.append(FundamentalAgent(f"fundamental_{i+1}", fair_value=100.0))
|
| 37 |
+
for i in range(n_noise):
|
| 38 |
+
agents.append(NoiseTrader(f"noise_{i+1}"))
|
| 39 |
+
for i in range(n_mm):
|
| 40 |
+
agents.append(MarketMakerAgent(f"marketmaker_{i+1}"))
|
| 41 |
+
return agents
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def main():
|
| 45 |
+
st.title("📈 MarketMind: Agent-Based Market Simulation")
|
| 46 |
+
st.markdown("Observe emergent market behavior based on LLM agent composition.")
|
| 47 |
+
|
| 48 |
+
# Sidebar: Agent Composition
|
| 49 |
+
st.sidebar.header("⚙️ Agent Composition")
|
| 50 |
+
st.sidebar.markdown("Change the mix of traders to test market stability.")
|
| 51 |
+
|
| 52 |
+
n_mom = st.sidebar.slider("Momentum Traders", 0, 10, 2)
|
| 53 |
+
n_mr = st.sidebar.slider("Mean Reversion Traders", 0, 10, 1)
|
| 54 |
+
n_fund = st.sidebar.slider("Fundamental Traders (Anchor)", 0, 10, 1)
|
| 55 |
+
n_noise = st.sidebar.slider("Noise Traders", 0, 10, 1)
|
| 56 |
+
n_mm = st.sidebar.slider("Market Makers", 0, 5, 1)
|
| 57 |
+
|
| 58 |
+
st.sidebar.markdown("---")
|
| 59 |
+
num_ticks = st.sidebar.slider("Simulation Ticks", 50, 500, 150, step=50)
|
| 60 |
+
playback_speed = st.sidebar.slider("Playback Speed (ms)", 0, 200, 50, step=10)
|
| 61 |
+
|
| 62 |
+
if st.sidebar.button("🚀 Run Simulation", type="primary"):
|
| 63 |
+
run_simulation_and_play(n_mom, n_mr, n_fund, n_noise, n_mm, num_ticks, playback_speed)
|
| 64 |
+
else:
|
| 65 |
+
st.info("Configure your agents in the sidebar and click **Run Simulation**.")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def run_simulation_and_play(n_mom, n_mr, n_fund, n_noise, n_mm, num_ticks, playback_speed):
|
| 69 |
+
# Setup
|
| 70 |
+
agents = build_agents(n_mom, n_mr, n_fund, n_noise, n_mm)
|
| 71 |
+
if not agents:
|
| 72 |
+
st.error("You need at least one agent to run a simulation!")
|
| 73 |
+
return
|
| 74 |
+
|
| 75 |
+
config = SimulationConfig(
|
| 76 |
+
num_ticks=num_ticks,
|
| 77 |
+
initial_price=100.0,
|
| 78 |
+
use_llm=False, # Dashboard uses offline mode for fast iteration
|
| 79 |
+
log_to_csv=False,
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
engine = SimulationEngine(agents, config)
|
| 83 |
+
|
| 84 |
+
with st.spinner(f"Running simulation offline ({num_ticks} ticks)..."):
|
| 85 |
+
engine.run()
|
| 86 |
+
|
| 87 |
+
# Pre-extract data for playback
|
| 88 |
+
ticks_data = engine.csv_rows
|
| 89 |
+
pnl_data = engine.agent_pnl_rows
|
| 90 |
+
|
| 91 |
+
st.success(f"Simulation generated! Playing back...")
|
| 92 |
+
|
| 93 |
+
# Layout for playback
|
| 94 |
+
col1, col2 = st.columns([3, 1])
|
| 95 |
+
|
| 96 |
+
with col1:
|
| 97 |
+
price_placeholder = st.empty()
|
| 98 |
+
spread_placeholder = st.empty()
|
| 99 |
+
|
| 100 |
+
with col2:
|
| 101 |
+
regime_placeholder = st.empty()
|
| 102 |
+
st.markdown("### Agent Leaderboard")
|
| 103 |
+
leaderboard_placeholder = st.empty()
|
| 104 |
+
|
| 105 |
+
# Data structures for incremental plotting
|
| 106 |
+
curr_ticks = []
|
| 107 |
+
curr_prices = []
|
| 108 |
+
curr_spreads = []
|
| 109 |
+
curr_pnls = {agent.agent_id: [] for agent in agents}
|
| 110 |
+
|
| 111 |
+
# Playback Loop
|
| 112 |
+
for tick_idx in range(len(ticks_data)):
|
| 113 |
+
tick_info = ticks_data[tick_idx]
|
| 114 |
+
t = tick_info["tick"]
|
| 115 |
+
|
| 116 |
+
curr_ticks.append(t)
|
| 117 |
+
curr_prices.append(tick_info["mid_price"] if tick_info["mid_price"] is not None else 100.0)
|
| 118 |
+
curr_spreads.append(tick_info["spread"] if tick_info["spread"] is not None else 0.0)
|
| 119 |
+
|
| 120 |
+
# Update PnLs for this tick
|
| 121 |
+
tick_pnl_rows = [row for row in pnl_data if row["tick"] == t]
|
| 122 |
+
for row in tick_pnl_rows:
|
| 123 |
+
curr_pnls[row["agent_id"]].append(row["pnl"])
|
| 124 |
+
|
| 125 |
+
# Render charts every N ticks to save Streamlit rendering time (if very fast)
|
| 126 |
+
# or every tick if speed allows.
|
| 127 |
+
price_fig = plot_price_chart(curr_ticks, curr_prices, fair_value=100.0)
|
| 128 |
+
price_placeholder.plotly_chart(price_fig, use_container_width=True, key=f"p_{t}")
|
| 129 |
+
|
| 130 |
+
spread_fig = plot_spread(curr_ticks, curr_spreads)
|
| 131 |
+
spread_placeholder.plotly_chart(spread_fig, use_container_width=True, key=f"s_{t}")
|
| 132 |
+
|
| 133 |
+
# Update Regime
|
| 134 |
+
regime = tick_info["regime"]
|
| 135 |
+
color = "green" if regime == "Efficient" else "orange" if regime == "Trending" else "red"
|
| 136 |
+
regime_placeholder.markdown(f"### Market Regime: <span style='color:{color}'>{regime}</span>", unsafe_allow_html=True)
|
| 137 |
+
|
| 138 |
+
# Update Leaderboard
|
| 139 |
+
# Sort current agents by their latest PnL
|
| 140 |
+
current_leaderboard = sorted(
|
| 141 |
+
[{"Agent": row["agent_id"], "Type": row["agent_type"], "PnL": f"${row['pnl']:.2f}", "Pos": row["position"]} for row in tick_pnl_rows],
|
| 142 |
+
key=lambda x: float(x["PnL"].replace('$', '')),
|
| 143 |
+
reverse=True
|
| 144 |
+
)
|
| 145 |
+
df_leaderboard = pd.DataFrame(current_leaderboard)
|
| 146 |
+
leaderboard_placeholder.dataframe(df_leaderboard, use_container_width=True, hide_index=True)
|
| 147 |
+
|
| 148 |
+
# Pause for animation effect
|
| 149 |
+
if playback_speed > 0:
|
| 150 |
+
time.sleep(playback_speed / 1000.0)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
if __name__ == "__main__":
|
| 154 |
+
main()
|
dashboard/plots.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Plotly charting utilities for the Streamlit dashboard.
|
| 3 |
+
|
| 4 |
+
Generates interactive charts for the live dashboard playback:
|
| 5 |
+
1. Real-time price chart
|
| 6 |
+
2. Agent PnL leaderboard
|
| 7 |
+
3. Bid-ask spread chart
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import plotly.graph_objects as go
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def plot_price_chart(ticks: list[int], prices: list[float], true_fair_values: list[float] | None = None) -> go.Figure:
|
| 14 |
+
"""Live price series with a dynamic fair value reference line."""
|
| 15 |
+
fig = go.Figure()
|
| 16 |
+
|
| 17 |
+
fig.add_trace(go.Scatter(
|
| 18 |
+
x=ticks, y=prices,
|
| 19 |
+
mode="lines",
|
| 20 |
+
line=dict(color="#2196F3", width=2),
|
| 21 |
+
name="Mid Price"
|
| 22 |
+
))
|
| 23 |
+
|
| 24 |
+
# Fair value anchor line (dynamic)
|
| 25 |
+
if true_fair_values:
|
| 26 |
+
fig.add_trace(go.Scatter(
|
| 27 |
+
x=ticks,
|
| 28 |
+
y=true_fair_values,
|
| 29 |
+
mode="lines",
|
| 30 |
+
line=dict(color="#F44336", width=1, dash="dash"),
|
| 31 |
+
name="True Fair Value"
|
| 32 |
+
))
|
| 33 |
+
|
| 34 |
+
fig.update_layout(
|
| 35 |
+
title="Live Market Price",
|
| 36 |
+
xaxis_title="Tick",
|
| 37 |
+
yaxis_title="Price",
|
| 38 |
+
template="plotly_dark",
|
| 39 |
+
margin=dict(l=20, r=20, t=40, b=20),
|
| 40 |
+
height=300,
|
| 41 |
+
paper_bgcolor="rgba(0,0,0,0)",
|
| 42 |
+
plot_bgcolor="rgba(0,0,0,0)"
|
| 43 |
+
)
|
| 44 |
+
return fig
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def plot_agent_pnl(ticks: list[int], agent_pnls: dict[str, list[float]]) -> go.Figure:
|
| 48 |
+
"""Agent PnL over time."""
|
| 49 |
+
fig = go.Figure()
|
| 50 |
+
|
| 51 |
+
colors = ["#2196F3", "#4CAF50", "#F44336", "#FF9800", "#9C27B0", "#00BCD4"]
|
| 52 |
+
|
| 53 |
+
for i, (agent_id, pnls) in enumerate(agent_pnls.items()):
|
| 54 |
+
fig.add_trace(go.Scatter(
|
| 55 |
+
x=ticks, y=pnls,
|
| 56 |
+
mode="lines",
|
| 57 |
+
line=dict(color=colors[i % len(colors)], width=2),
|
| 58 |
+
name=agent_id
|
| 59 |
+
))
|
| 60 |
+
|
| 61 |
+
fig.update_layout(
|
| 62 |
+
title="Agent PnL (Mark-to-Market)",
|
| 63 |
+
xaxis_title="Tick",
|
| 64 |
+
yaxis_title="PnL",
|
| 65 |
+
template="plotly_dark",
|
| 66 |
+
margin=dict(l=20, r=20, t=40, b=20),
|
| 67 |
+
height=300,
|
| 68 |
+
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
|
| 69 |
+
paper_bgcolor="rgba(0,0,0,0)",
|
| 70 |
+
plot_bgcolor="rgba(0,0,0,0)"
|
| 71 |
+
)
|
| 72 |
+
return fig
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def plot_spread(ticks: list[int], spreads: list[float]) -> go.Figure:
|
| 76 |
+
"""Bid-ask spread over time."""
|
| 77 |
+
fig = go.Figure()
|
| 78 |
+
|
| 79 |
+
fig.add_trace(go.Scatter(
|
| 80 |
+
x=ticks, y=spreads,
|
| 81 |
+
mode="lines",
|
| 82 |
+
line=dict(color="#FF9800", width=2),
|
| 83 |
+
fill="tozeroy",
|
| 84 |
+
name="Spread"
|
| 85 |
+
))
|
| 86 |
+
|
| 87 |
+
fig.update_layout(
|
| 88 |
+
title="Bid-Ask Spread",
|
| 89 |
+
xaxis_title="Tick",
|
| 90 |
+
yaxis_title="Spread",
|
| 91 |
+
template="plotly_dark",
|
| 92 |
+
margin=dict(l=20, r=20, t=40, b=20),
|
| 93 |
+
height=200,
|
| 94 |
+
paper_bgcolor="rgba(0,0,0,0)",
|
| 95 |
+
plot_bgcolor="rgba(0,0,0,0)"
|
| 96 |
+
)
|
| 97 |
+
return fig
|
engine/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# engine package
|
engine/market_state.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Market State Serializer.
|
| 3 |
+
|
| 4 |
+
Converts order book snapshot + agent-specific state into a compact string
|
| 5 |
+
that the LLM reads as its "user" message each tick.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from engine.order_book import OrderBook
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def market_state_to_string(
|
| 12 |
+
book: OrderBook,
|
| 13 |
+
agent_id: str,
|
| 14 |
+
position: int,
|
| 15 |
+
cash: float,
|
| 16 |
+
price_history: list[float],
|
| 17 |
+
) -> str:
|
| 18 |
+
"""
|
| 19 |
+
Build the ~150-token market state string that agents receive each tick.
|
| 20 |
+
|
| 21 |
+
Includes: best bid, best ask, mid price, last trade price,
|
| 22 |
+
agent's position, agent's cash, last 10 prices.
|
| 23 |
+
"""
|
| 24 |
+
snap = book.snapshot()
|
| 25 |
+
|
| 26 |
+
bb = f"{snap['best_bid']:.2f}" if snap['best_bid'] is not None else "none"
|
| 27 |
+
ba = f"{snap['best_ask']:.2f}" if snap['best_ask'] is not None else "none"
|
| 28 |
+
mid = f"{snap['mid_price']:.2f}" if snap['mid_price'] is not None else "none"
|
| 29 |
+
spread = f"{snap['spread']:.4f}" if snap['spread'] is not None else "none"
|
| 30 |
+
last_price = f"{snap['last_trade_price']:.2f}" if snap['last_trade_price'] is not None else "none"
|
| 31 |
+
|
| 32 |
+
# Last 10 prices, formatted compact
|
| 33 |
+
recent = price_history[-10:] if price_history else []
|
| 34 |
+
price_str = ", ".join(f"{p:.2f}" for p in recent) if recent else "none"
|
| 35 |
+
|
| 36 |
+
lines = [
|
| 37 |
+
f"Best Bid: {bb} | Best Ask: {ba} | Mid: {mid} | Spread: {spread}",
|
| 38 |
+
f"Last Trade: {last_price}",
|
| 39 |
+
f"Recent Prices (last {len(recent)}): [{price_str}]",
|
| 40 |
+
f"Your Position: {position} units | Your Cash: {cash:.2f}",
|
| 41 |
+
]
|
| 42 |
+
return "\n".join(lines)
|
engine/metrics.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Metrics Engine.
|
| 3 |
+
|
| 4 |
+
Computes price series, spread, volatility, crash detection, and per-agent PnL.
|
| 5 |
+
Used by the simulation loop to track market health and by the dashboard for display.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import math
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass
|
| 13 |
+
class TickMetrics:
|
| 14 |
+
"""Metrics snapshot for a single tick."""
|
| 15 |
+
tick: int
|
| 16 |
+
mid_price: float | None
|
| 17 |
+
best_bid: float | None
|
| 18 |
+
best_ask: float | None
|
| 19 |
+
spread: float | None
|
| 20 |
+
trade_count: int
|
| 21 |
+
volume: int # total units traded this tick
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class MetricsEngine:
|
| 25 |
+
"""
|
| 26 |
+
Accumulates per-tick metrics and computes derived signals.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
def __init__(self, crash_threshold: float = 0.05, crash_window: int = 5):
|
| 30 |
+
self.tick_history: list[TickMetrics] = []
|
| 31 |
+
self.price_series: list[float] = [] # mid prices over time
|
| 32 |
+
self.crash_threshold = crash_threshold # >5% drop
|
| 33 |
+
self.crash_window = crash_window # in 5 ticks
|
| 34 |
+
self.crash_events: list[dict] = []
|
| 35 |
+
|
| 36 |
+
def record_tick(self, metrics: TickMetrics):
|
| 37 |
+
"""Record metrics for one tick."""
|
| 38 |
+
self.tick_history.append(metrics)
|
| 39 |
+
if metrics.mid_price is not None:
|
| 40 |
+
self.price_series.append(metrics.mid_price)
|
| 41 |
+
self._check_crash()
|
| 42 |
+
|
| 43 |
+
def _check_crash(self):
|
| 44 |
+
"""Detect crash: >threshold drop over crash_window ticks."""
|
| 45 |
+
if len(self.price_series) < self.crash_window + 1:
|
| 46 |
+
return
|
| 47 |
+
recent = self.price_series[-(self.crash_window + 1):]
|
| 48 |
+
pct_change = (recent[-1] - recent[0]) / recent[0]
|
| 49 |
+
if pct_change < -self.crash_threshold:
|
| 50 |
+
self.crash_events.append({
|
| 51 |
+
"tick": len(self.tick_history),
|
| 52 |
+
"drop_pct": pct_change * 100,
|
| 53 |
+
"from_price": recent[0],
|
| 54 |
+
"to_price": recent[-1],
|
| 55 |
+
})
|
| 56 |
+
|
| 57 |
+
def rolling_volatility(self, window: int = 10) -> float | None:
|
| 58 |
+
"""Rolling standard deviation of mid prices over last `window` ticks."""
|
| 59 |
+
if len(self.price_series) < window:
|
| 60 |
+
return None
|
| 61 |
+
recent = self.price_series[-window:]
|
| 62 |
+
mean = sum(recent) / len(recent)
|
| 63 |
+
variance = sum((p - mean) ** 2 for p in recent) / len(recent)
|
| 64 |
+
return math.sqrt(variance)
|
| 65 |
+
|
| 66 |
+
def rolling_mean(self, window: int = 10) -> float | None:
|
| 67 |
+
"""Rolling mean of mid prices."""
|
| 68 |
+
if len(self.price_series) < window:
|
| 69 |
+
return None
|
| 70 |
+
return sum(self.price_series[-window:]) / window
|
| 71 |
+
|
| 72 |
+
def classify_regime(self) -> str:
|
| 73 |
+
"""
|
| 74 |
+
Simple regime classifier based on current market conditions.
|
| 75 |
+
Returns: "Efficient" | "Trending" | "Volatile" | "Crashed"
|
| 76 |
+
"""
|
| 77 |
+
if self.crash_events and self.crash_events[-1]["tick"] >= len(self.tick_history) - 5:
|
| 78 |
+
return "Crashed"
|
| 79 |
+
|
| 80 |
+
vol = self.rolling_volatility()
|
| 81 |
+
if vol is None:
|
| 82 |
+
return "Efficient"
|
| 83 |
+
|
| 84 |
+
mean = self.rolling_mean()
|
| 85 |
+
if mean is None or mean == 0:
|
| 86 |
+
return "Efficient"
|
| 87 |
+
|
| 88 |
+
# Coefficient of variation
|
| 89 |
+
cv = vol / mean
|
| 90 |
+
|
| 91 |
+
if cv > 0.02:
|
| 92 |
+
return "Volatile"
|
| 93 |
+
|
| 94 |
+
# Check for trending: compare last 10 prices direction
|
| 95 |
+
if len(self.price_series) >= 10:
|
| 96 |
+
recent = self.price_series[-10:]
|
| 97 |
+
ups = sum(1 for i in range(1, len(recent)) if recent[i] > recent[i - 1])
|
| 98 |
+
downs = sum(1 for i in range(1, len(recent)) if recent[i] < recent[i - 1])
|
| 99 |
+
if ups >= 7 or downs >= 7:
|
| 100 |
+
return "Trending"
|
| 101 |
+
|
| 102 |
+
return "Efficient"
|
| 103 |
+
|
| 104 |
+
def summary(self) -> dict:
|
| 105 |
+
"""Summary stats for reporting."""
|
| 106 |
+
return {
|
| 107 |
+
"total_ticks": len(self.tick_history),
|
| 108 |
+
"total_trades": sum(t.trade_count for t in self.tick_history),
|
| 109 |
+
"total_volume": sum(t.volume for t in self.tick_history),
|
| 110 |
+
"crash_events": len(self.crash_events),
|
| 111 |
+
"current_regime": self.classify_regime(),
|
| 112 |
+
"current_volatility": self.rolling_volatility(),
|
| 113 |
+
"price_range": (
|
| 114 |
+
(min(self.price_series), max(self.price_series))
|
| 115 |
+
if self.price_series else None
|
| 116 |
+
),
|
| 117 |
+
}
|
engine/order_book.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Continuous Double Auction (CDA) Matching Engine.
|
| 3 |
+
|
| 4 |
+
Implements a limit order book with price-time priority matching.
|
| 5 |
+
This is the core market mechanism — all agent orders flow through here.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from dataclasses import dataclass, field
|
| 9 |
+
from enum import Enum
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class Side(Enum):
|
| 14 |
+
BUY = "buy"
|
| 15 |
+
SELL = "sell"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class Order:
|
| 20 |
+
"""A single limit order submitted by an agent."""
|
| 21 |
+
agent_id: str
|
| 22 |
+
side: Side
|
| 23 |
+
price: float
|
| 24 |
+
quantity: int
|
| 25 |
+
timestamp: int # tick number — used for time priority
|
| 26 |
+
|
| 27 |
+
def __post_init__(self):
|
| 28 |
+
if self.quantity <= 0:
|
| 29 |
+
raise ValueError(f"Order quantity must be positive, got {self.quantity}")
|
| 30 |
+
if self.price <= 0:
|
| 31 |
+
raise ValueError(f"Order price must be positive, got {self.price}")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass
|
| 35 |
+
class Trade:
|
| 36 |
+
"""A completed trade between two orders."""
|
| 37 |
+
tick: int
|
| 38 |
+
price: float
|
| 39 |
+
quantity: int
|
| 40 |
+
buyer_id: str
|
| 41 |
+
seller_id: str
|
| 42 |
+
aggressor_side: Side # who crossed the spread
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def value(self) -> float:
|
| 46 |
+
return self.price * self.quantity
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class OrderBook:
|
| 50 |
+
"""
|
| 51 |
+
Limit order book with price-time priority matching.
|
| 52 |
+
|
| 53 |
+
Bids sorted descending (best bid = highest price first).
|
| 54 |
+
Asks sorted ascending (best ask = lowest price first).
|
| 55 |
+
Within same price level, earlier orders match first (FIFO).
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
def __init__(self):
|
| 59 |
+
# Lists of Order, kept sorted after each insertion
|
| 60 |
+
self.bids: list[Order] = [] # sorted: highest price first, then earliest timestamp
|
| 61 |
+
self.asks: list[Order] = [] # sorted: lowest price first, then earliest timestamp
|
| 62 |
+
self.trade_log: list[Trade] = []
|
| 63 |
+
self._tick: int = 0
|
| 64 |
+
|
| 65 |
+
@property
|
| 66 |
+
def best_bid(self) -> Optional[float]:
|
| 67 |
+
"""Highest bid price, or None if no bids."""
|
| 68 |
+
return self.bids[0].price if self.bids else None
|
| 69 |
+
|
| 70 |
+
@property
|
| 71 |
+
def best_ask(self) -> Optional[float]:
|
| 72 |
+
"""Lowest ask price, or None if no asks."""
|
| 73 |
+
return self.asks[0].price if self.asks else None
|
| 74 |
+
|
| 75 |
+
@property
|
| 76 |
+
def mid_price(self) -> Optional[float]:
|
| 77 |
+
"""Midpoint between best bid and best ask, or None if either side is empty."""
|
| 78 |
+
if self.best_bid is not None and self.best_ask is not None:
|
| 79 |
+
return (self.best_bid + self.best_ask) / 2.0
|
| 80 |
+
return None
|
| 81 |
+
|
| 82 |
+
@property
|
| 83 |
+
def spread(self) -> Optional[float]:
|
| 84 |
+
"""Bid-ask spread, or None if either side is empty."""
|
| 85 |
+
if self.best_bid is not None and self.best_ask is not None:
|
| 86 |
+
return self.best_ask - self.best_bid
|
| 87 |
+
return None
|
| 88 |
+
|
| 89 |
+
def set_tick(self, tick: int):
|
| 90 |
+
"""Advance the internal tick clock. Called by the simulation loop."""
|
| 91 |
+
self._tick = tick
|
| 92 |
+
|
| 93 |
+
def submit_order(self, order: Order) -> list[Trade]:
|
| 94 |
+
"""
|
| 95 |
+
Submit an order to the book. Attempts to match immediately.
|
| 96 |
+
Any unmatched residual rests in the book.
|
| 97 |
+
|
| 98 |
+
Returns list of trades executed by this order (possibly empty).
|
| 99 |
+
"""
|
| 100 |
+
trades: list[Trade] = []
|
| 101 |
+
|
| 102 |
+
if order.side == Side.BUY:
|
| 103 |
+
trades = self._match_buy(order)
|
| 104 |
+
elif order.side == Side.SELL:
|
| 105 |
+
trades = self._match_sell(order)
|
| 106 |
+
|
| 107 |
+
self.trade_log.extend(trades)
|
| 108 |
+
return trades
|
| 109 |
+
|
| 110 |
+
def _match_buy(self, buy_order: Order) -> list[Trade]:
|
| 111 |
+
"""Match an incoming buy order against resting asks."""
|
| 112 |
+
trades: list[Trade] = []
|
| 113 |
+
remaining_qty = buy_order.quantity
|
| 114 |
+
|
| 115 |
+
while remaining_qty > 0 and self.asks:
|
| 116 |
+
best_ask_order = self.asks[0]
|
| 117 |
+
|
| 118 |
+
# Buy can only match if its price >= best ask price
|
| 119 |
+
if buy_order.price < best_ask_order.price:
|
| 120 |
+
break
|
| 121 |
+
|
| 122 |
+
# Determine fill quantity
|
| 123 |
+
fill_qty = min(remaining_qty, best_ask_order.quantity)
|
| 124 |
+
fill_price = best_ask_order.price # price-time priority: passive order's price
|
| 125 |
+
|
| 126 |
+
trade = Trade(
|
| 127 |
+
tick=self._tick,
|
| 128 |
+
price=fill_price,
|
| 129 |
+
quantity=fill_qty,
|
| 130 |
+
buyer_id=buy_order.agent_id,
|
| 131 |
+
seller_id=best_ask_order.agent_id,
|
| 132 |
+
aggressor_side=Side.BUY,
|
| 133 |
+
)
|
| 134 |
+
trades.append(trade)
|
| 135 |
+
|
| 136 |
+
remaining_qty -= fill_qty
|
| 137 |
+
best_ask_order.quantity -= fill_qty
|
| 138 |
+
|
| 139 |
+
# Remove fully filled ask
|
| 140 |
+
if best_ask_order.quantity == 0:
|
| 141 |
+
self.asks.pop(0)
|
| 142 |
+
|
| 143 |
+
# Rest any unfilled portion in the bid book
|
| 144 |
+
if remaining_qty > 0:
|
| 145 |
+
resting_order = Order(
|
| 146 |
+
agent_id=buy_order.agent_id,
|
| 147 |
+
side=Side.BUY,
|
| 148 |
+
price=buy_order.price,
|
| 149 |
+
quantity=remaining_qty,
|
| 150 |
+
timestamp=buy_order.timestamp,
|
| 151 |
+
)
|
| 152 |
+
self._insert_bid(resting_order)
|
| 153 |
+
|
| 154 |
+
return trades
|
| 155 |
+
|
| 156 |
+
def _match_sell(self, sell_order: Order) -> list[Trade]:
|
| 157 |
+
"""Match an incoming sell order against resting bids."""
|
| 158 |
+
trades: list[Trade] = []
|
| 159 |
+
remaining_qty = sell_order.quantity
|
| 160 |
+
|
| 161 |
+
while remaining_qty > 0 and self.bids:
|
| 162 |
+
best_bid_order = self.bids[0]
|
| 163 |
+
|
| 164 |
+
# Sell can only match if its price <= best bid price
|
| 165 |
+
if sell_order.price > best_bid_order.price:
|
| 166 |
+
break
|
| 167 |
+
|
| 168 |
+
# Determine fill quantity
|
| 169 |
+
fill_qty = min(remaining_qty, best_bid_order.quantity)
|
| 170 |
+
fill_price = best_bid_order.price # passive order's price
|
| 171 |
+
|
| 172 |
+
trade = Trade(
|
| 173 |
+
tick=self._tick,
|
| 174 |
+
price=fill_price,
|
| 175 |
+
quantity=fill_qty,
|
| 176 |
+
buyer_id=best_bid_order.agent_id,
|
| 177 |
+
seller_id=sell_order.agent_id,
|
| 178 |
+
aggressor_side=Side.SELL,
|
| 179 |
+
)
|
| 180 |
+
trades.append(trade)
|
| 181 |
+
|
| 182 |
+
remaining_qty -= fill_qty
|
| 183 |
+
best_bid_order.quantity -= fill_qty
|
| 184 |
+
|
| 185 |
+
# Remove fully filled bid
|
| 186 |
+
if best_bid_order.quantity == 0:
|
| 187 |
+
self.bids.pop(0)
|
| 188 |
+
|
| 189 |
+
# Rest any unfilled portion in the ask book
|
| 190 |
+
if remaining_qty > 0:
|
| 191 |
+
resting_order = Order(
|
| 192 |
+
agent_id=sell_order.agent_id,
|
| 193 |
+
side=Side.SELL,
|
| 194 |
+
price=sell_order.price,
|
| 195 |
+
quantity=remaining_qty,
|
| 196 |
+
timestamp=sell_order.timestamp,
|
| 197 |
+
)
|
| 198 |
+
self._insert_ask(resting_order)
|
| 199 |
+
|
| 200 |
+
return trades
|
| 201 |
+
|
| 202 |
+
def _insert_bid(self, order: Order):
|
| 203 |
+
"""Insert a bid order maintaining descending price, ascending timestamp order."""
|
| 204 |
+
import bisect
|
| 205 |
+
# For bids: we want descending price, ascending timestamp.
|
| 206 |
+
# bisect uses < operator, so we use a key that negates price but keeps timestamp positive.
|
| 207 |
+
bisect.insort(self.bids, order, key=lambda x: (-x.price, x.timestamp))
|
| 208 |
+
|
| 209 |
+
def _insert_ask(self, order: Order):
|
| 210 |
+
"""Insert an ask order maintaining ascending price, ascending timestamp order."""
|
| 211 |
+
import bisect
|
| 212 |
+
# For asks: we want ascending price, ascending timestamp.
|
| 213 |
+
bisect.insort(self.asks, order, key=lambda x: (x.price, x.timestamp))
|
| 214 |
+
|
| 215 |
+
def cancel_agent_orders(self, agent_id: str):
|
| 216 |
+
"""Remove all resting orders for a given agent. Used between ticks."""
|
| 217 |
+
self.bids = [o for o in self.bids if o.agent_id != agent_id]
|
| 218 |
+
self.asks = [o for o in self.asks if o.agent_id != agent_id]
|
| 219 |
+
|
| 220 |
+
def clear_book(self):
|
| 221 |
+
"""Remove all resting orders. Used for book reset between experiments."""
|
| 222 |
+
self.bids.clear()
|
| 223 |
+
self.asks.clear()
|
| 224 |
+
|
| 225 |
+
def snapshot(self) -> dict:
|
| 226 |
+
"""
|
| 227 |
+
Return a snapshot of the current order book state.
|
| 228 |
+
Used by market_state serializer to build the LLM prompt.
|
| 229 |
+
"""
|
| 230 |
+
return {
|
| 231 |
+
"best_bid": self.best_bid,
|
| 232 |
+
"best_ask": self.best_ask,
|
| 233 |
+
"mid_price": self.mid_price,
|
| 234 |
+
"spread": self.spread,
|
| 235 |
+
"bid_depth": sum(o.quantity for o in self.bids),
|
| 236 |
+
"ask_depth": sum(o.quantity for o in self.asks),
|
| 237 |
+
"bid_levels": len(self.bids),
|
| 238 |
+
"ask_levels": len(self.asks),
|
| 239 |
+
"last_trade_price": self.trade_log[-1].price if self.trade_log else None,
|
| 240 |
+
"last_trade_qty": self.trade_log[-1].quantity if self.trade_log else None,
|
| 241 |
+
"total_trades": len(self.trade_log),
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
def __repr__(self) -> str:
|
| 245 |
+
bb = f"{self.best_bid:.2f}" if self.best_bid else "---"
|
| 246 |
+
ba = f"{self.best_ask:.2f}" if self.best_ask else "---"
|
| 247 |
+
sp = f"{self.spread:.4f}" if self.spread else "---"
|
| 248 |
+
return f"OrderBook(bid={bb}, ask={ba}, spread={sp}, bids={len(self.bids)}, asks={len(self.asks)})"
|
engine/simulation.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Simulation Engine — the core tick loop.
|
| 3 |
+
|
| 4 |
+
Each tick:
|
| 5 |
+
1. Build market state string for each agent
|
| 6 |
+
2. Dispatch all agents concurrently (LLM or offline fallback)
|
| 7 |
+
3. Collect orders from responses
|
| 8 |
+
4. Submit orders to the order book (matching happens automatically)
|
| 9 |
+
5. Update agent positions and cash from executed trades
|
| 10 |
+
6. Record metrics
|
| 11 |
+
7. Log to CSV
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import asyncio
|
| 15 |
+
import csv
|
| 16 |
+
import json
|
| 17 |
+
import math
|
| 18 |
+
import os
|
| 19 |
+
import random
|
| 20 |
+
import time
|
| 21 |
+
from dataclasses import dataclass
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
from engine.order_book import OrderBook, Order, Side, Trade
|
| 25 |
+
from engine.market_state import market_state_to_string
|
| 26 |
+
from engine.metrics import MetricsEngine, TickMetrics
|
| 27 |
+
from agents.base_agent import BaseAgent
|
| 28 |
+
from agents.market_maker_agent import MarketMakerAgent
|
| 29 |
+
from inference.vllm_client import VLLMClient, LLMResponse, parse_llm_output
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class SimulationConfig:
|
| 34 |
+
"""Configuration for a simulation run."""
|
| 35 |
+
num_ticks: int = 100
|
| 36 |
+
initial_price: float = 100.0
|
| 37 |
+
use_llm: bool = False # False = offline deterministic mode
|
| 38 |
+
vllm_base_url: str = "http://localhost:8000/v1"
|
| 39 |
+
vllm_model: str = "Qwen/Qwen2.5-7B-Instruct"
|
| 40 |
+
output_dir: str = "output"
|
| 41 |
+
seed: int = 42
|
| 42 |
+
log_to_csv: bool = True
|
| 43 |
+
base_volatility: float = 0.005 # Random walk std dev for true fair value per tick
|
| 44 |
+
warmup_ticks: int = 15 # Ticks to run in offline mode before LLM takes over
|
| 45 |
+
enable_seed_liquidity: bool = False # Turned off by default to allow pure LLM market
|
| 46 |
+
fee_per_trade: float = 0.01 # Transaction cost to prevent wash trading
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class SimulationEngine:
|
| 50 |
+
"""
|
| 51 |
+
Core simulation loop.
|
| 52 |
+
|
| 53 |
+
Orchestrates: agents → LLM/offline → orders → order book → trades → metrics.
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
def __init__(self, agents: list[BaseAgent], config: SimulationConfig):
|
| 57 |
+
self.agents = agents
|
| 58 |
+
self.config = config
|
| 59 |
+
self.book = OrderBook()
|
| 60 |
+
self.metrics = MetricsEngine()
|
| 61 |
+
self.price_history: list[float] = [config.initial_price]
|
| 62 |
+
self.true_fair_value: float = config.initial_price
|
| 63 |
+
self.tick = 0
|
| 64 |
+
|
| 65 |
+
# LLM client (only initialized if use_llm=True)
|
| 66 |
+
self.llm_client: VLLMClient | None = None
|
| 67 |
+
if config.use_llm:
|
| 68 |
+
self.llm_client = VLLMClient(
|
| 69 |
+
base_url=config.vllm_base_url,
|
| 70 |
+
model=config.vllm_model,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
# CSV logging
|
| 74 |
+
self.csv_rows: list[dict] = []
|
| 75 |
+
self.trade_rows: list[dict] = []
|
| 76 |
+
self.agent_pnl_rows: list[dict] = []
|
| 77 |
+
|
| 78 |
+
# Seed for reproducibility
|
| 79 |
+
random.seed(config.seed)
|
| 80 |
+
|
| 81 |
+
# Latency tracking for AMD benchmarking
|
| 82 |
+
self.latencies: list[float] = []
|
| 83 |
+
|
| 84 |
+
def run(self):
|
| 85 |
+
"""Run the full simulation synchronously."""
|
| 86 |
+
for _ in self.run_generator():
|
| 87 |
+
pass
|
| 88 |
+
|
| 89 |
+
def run_generator(self):
|
| 90 |
+
"""Run the simulation as a generator, yielding after each tick. Useful for live UIs."""
|
| 91 |
+
print(f"Starting simulation: {self.config.num_ticks} ticks, "
|
| 92 |
+
f"{'LLM' if self.config.use_llm else 'offline'} mode, "
|
| 93 |
+
f"{len(self.agents)} agents")
|
| 94 |
+
print(f"Initial price: {self.config.initial_price}")
|
| 95 |
+
print("-" * 60)
|
| 96 |
+
|
| 97 |
+
self._seed_book()
|
| 98 |
+
|
| 99 |
+
# Run the async loop synchronously step-by-step to yield
|
| 100 |
+
loop = asyncio.new_event_loop()
|
| 101 |
+
asyncio.set_event_loop(loop)
|
| 102 |
+
|
| 103 |
+
try:
|
| 104 |
+
for tick in range(1, self.config.num_ticks + 1):
|
| 105 |
+
loop.run_until_complete(self._tick_logic(tick))
|
| 106 |
+
yield tick
|
| 107 |
+
finally:
|
| 108 |
+
loop.close()
|
| 109 |
+
|
| 110 |
+
if self.config.log_to_csv:
|
| 111 |
+
self._write_csvs()
|
| 112 |
+
|
| 113 |
+
self._print_summary()
|
| 114 |
+
|
| 115 |
+
async def _run_async(self):
|
| 116 |
+
"""Async tick loop (Legacy)."""
|
| 117 |
+
for tick in range(1, self.config.num_ticks + 1):
|
| 118 |
+
await self._tick_logic(tick)
|
| 119 |
+
|
| 120 |
+
async def _tick_logic(self, tick: int):
|
| 121 |
+
"""Logic for a single tick."""
|
| 122 |
+
self.tick = tick
|
| 123 |
+
self.book.set_tick(tick)
|
| 124 |
+
|
| 125 |
+
# --- Exogenous Volatility (Realism update) ---
|
| 126 |
+
# Drift the true macroeconomic fair value using a geometric random walk
|
| 127 |
+
drift = random.gauss(0, self.config.base_volatility)
|
| 128 |
+
self.true_fair_value *= (1 + drift)
|
| 129 |
+
self.true_fair_value = max(0.01, self.true_fair_value) # Prevent negative/zero prices
|
| 130 |
+
|
| 131 |
+
# Broadcast new macroeconomic reality to agents (only Fundamental agents care)
|
| 132 |
+
for agent in self.agents:
|
| 133 |
+
agent.update_fair_value(self.true_fair_value)
|
| 134 |
+
|
| 135 |
+
# 1. Dispatch all agents, collect orders
|
| 136 |
+
orders = await self._dispatch_agents()
|
| 137 |
+
|
| 138 |
+
# 2. Process intentional cancellations (agents submit "cancel" in orders)
|
| 139 |
+
# The mandatory wipe is removed to create a true CDA
|
| 140 |
+
|
| 141 |
+
# 3. Refresh seed liquidity if enabled
|
| 142 |
+
if self.config.enable_seed_liquidity:
|
| 143 |
+
self.book.cancel_agent_orders("_seed")
|
| 144 |
+
self._refresh_seed_liquidity()
|
| 145 |
+
|
| 146 |
+
# 4. Submit orders to the book
|
| 147 |
+
tick_trades: list[Trade] = []
|
| 148 |
+
for order in orders:
|
| 149 |
+
if order.side == "cancel":
|
| 150 |
+
# Special case for explicit cancel
|
| 151 |
+
self.book.cancel_agent_orders(order.agent_id)
|
| 152 |
+
else:
|
| 153 |
+
trades = self.book.submit_order(order)
|
| 154 |
+
tick_trades.extend(trades)
|
| 155 |
+
|
| 156 |
+
# 5. Update agent states from trades
|
| 157 |
+
for trade in tick_trades:
|
| 158 |
+
self._apply_trade(trade)
|
| 159 |
+
|
| 160 |
+
# 6. Record mid price (always append to keep continuous series)
|
| 161 |
+
mid = self.book.mid_price
|
| 162 |
+
effective_price = mid if mid is not None else self.price_history[-1]
|
| 163 |
+
self.price_history.append(effective_price)
|
| 164 |
+
for agent in self.agents:
|
| 165 |
+
agent.update_price_history(effective_price)
|
| 166 |
+
|
| 167 |
+
# 7. Record metrics
|
| 168 |
+
tick_metrics = TickMetrics(
|
| 169 |
+
tick=tick,
|
| 170 |
+
mid_price=mid,
|
| 171 |
+
best_bid=self.book.best_bid,
|
| 172 |
+
best_ask=self.book.best_ask,
|
| 173 |
+
spread=self.book.spread,
|
| 174 |
+
trade_count=len(tick_trades),
|
| 175 |
+
volume=sum(t.quantity for t in tick_trades),
|
| 176 |
+
)
|
| 177 |
+
self.metrics.record_tick(tick_metrics)
|
| 178 |
+
|
| 179 |
+
# 8. CSV row
|
| 180 |
+
self._record_csv_row(tick, tick_metrics, tick_trades)
|
| 181 |
+
|
| 182 |
+
# Progress
|
| 183 |
+
if tick % 10 == 0 or tick == 1:
|
| 184 |
+
regime = self.metrics.classify_regime()
|
| 185 |
+
price_str = f"{mid:.2f}" if mid else "---"
|
| 186 |
+
spread_str = f"{self.book.spread:.4f}" if self.book.spread else "---"
|
| 187 |
+
print(f" Tick {tick:4d} | Price: {price_str} | "
|
| 188 |
+
f"Spread: {spread_str} | Trades: {len(tick_trades)} | "
|
| 189 |
+
f"Regime: {regime}")
|
| 190 |
+
|
| 191 |
+
def _seed_book(self):
|
| 192 |
+
"""Place initial orders so the book isn't empty on tick 1."""
|
| 193 |
+
p = self.config.initial_price
|
| 194 |
+
self.book.set_tick(0)
|
| 195 |
+
# Seed bid and ask around initial price
|
| 196 |
+
self.book.submit_order(Order("_seed", Side.BUY, round(p * 0.995, 2), 10, 0))
|
| 197 |
+
self.book.submit_order(Order("_seed", Side.SELL, round(p * 1.005, 2), 10, 0))
|
| 198 |
+
|
| 199 |
+
def _refresh_seed_liquidity(self):
|
| 200 |
+
"""
|
| 201 |
+
Place thin background liquidity each tick.
|
| 202 |
+
Represents passive external market participants — prevents book
|
| 203 |
+
from fully drying up in experiments without a market maker.
|
| 204 |
+
"""
|
| 205 |
+
p = self.price_history[-1]
|
| 206 |
+
self.book.submit_order(Order("_seed", Side.BUY, round(p * 0.993, 2), 3, self.tick))
|
| 207 |
+
self.book.submit_order(Order("_seed", Side.SELL, round(p * 1.007, 2), 3, self.tick))
|
| 208 |
+
|
| 209 |
+
async def _dispatch_agents(self) -> list[Order]:
|
| 210 |
+
"""
|
| 211 |
+
Get orders from all agents for this tick.
|
| 212 |
+
Uses offline mode during warmup_ticks, then switches to LLM.
|
| 213 |
+
"""
|
| 214 |
+
if self.config.use_llm and self.tick > self.config.warmup_ticks:
|
| 215 |
+
return await self._dispatch_llm()
|
| 216 |
+
else:
|
| 217 |
+
return self._dispatch_offline()
|
| 218 |
+
|
| 219 |
+
# ── LLM mode ──────────────────────────────────────────────────
|
| 220 |
+
|
| 221 |
+
async def _dispatch_llm(self) -> list[Order]:
|
| 222 |
+
"""Dispatch agents via vLLM. Uses asyncio.gather for concurrency."""
|
| 223 |
+
assert self.llm_client is not None
|
| 224 |
+
|
| 225 |
+
requests: list[tuple[str, str, str]] = []
|
| 226 |
+
for agent in self.agents:
|
| 227 |
+
state_str = market_state_to_string(
|
| 228 |
+
self.book, agent.agent_id,
|
| 229 |
+
agent.state.position, agent.state.cash,
|
| 230 |
+
agent.price_history,
|
| 231 |
+
)
|
| 232 |
+
# All agents, including MarketMaker, now make a single batched call
|
| 233 |
+
requests.append((agent.agent_id, agent.charter, state_str))
|
| 234 |
+
|
| 235 |
+
t0 = time.perf_counter()
|
| 236 |
+
responses = await self.llm_client.batch_infer(requests)
|
| 237 |
+
batch_latency = (time.perf_counter() - t0) * 1000
|
| 238 |
+
self.latencies.append(batch_latency)
|
| 239 |
+
|
| 240 |
+
return self._responses_to_orders(responses)
|
| 241 |
+
|
| 242 |
+
# ── Offline mode (deterministic fallback) ─────────────────────
|
| 243 |
+
|
| 244 |
+
def _dispatch_offline(self) -> list[Order]:
|
| 245 |
+
"""
|
| 246 |
+
Deterministic order generation based on agent charter logic.
|
| 247 |
+
No LLM needed — used for local dev and testing.
|
| 248 |
+
"""
|
| 249 |
+
orders: list[Order] = []
|
| 250 |
+
# Fall back to last known price when book is empty
|
| 251 |
+
mid = self.book.mid_price or self.price_history[-1]
|
| 252 |
+
|
| 253 |
+
for agent in self.agents:
|
| 254 |
+
agent_orders = self._offline_agent_logic(agent, mid)
|
| 255 |
+
orders.extend(agent_orders)
|
| 256 |
+
|
| 257 |
+
return orders
|
| 258 |
+
|
| 259 |
+
def _offline_agent_logic(self, agent: BaseAgent, mid: float) -> list[Order]:
|
| 260 |
+
"""Generate orders using simple heuristics matching each agent's charter."""
|
| 261 |
+
orders: list[Order] = []
|
| 262 |
+
# Prepend a cancel order so offline heuristics don't infinitely stack orders
|
| 263 |
+
orders.append(Order(agent.agent_id, "cancel", 1.0, 1, self.tick))
|
| 264 |
+
|
| 265 |
+
prices = agent.price_history[-10:] if agent.price_history else [mid]
|
| 266 |
+
|
| 267 |
+
agent_type = agent.agent_type
|
| 268 |
+
|
| 269 |
+
if agent_type == "Momentum":
|
| 270 |
+
if len(prices) >= 3:
|
| 271 |
+
trend = prices[-1] - prices[-3]
|
| 272 |
+
if trend > 0.1:
|
| 273 |
+
price = round(mid * 1.002, 2)
|
| 274 |
+
orders.append(Order(agent.agent_id, Side.BUY, price, random.randint(1, 5), self.tick))
|
| 275 |
+
elif trend < -0.1:
|
| 276 |
+
price = round(mid * 0.998, 2)
|
| 277 |
+
orders.append(Order(agent.agent_id, Side.SELL, price, random.randint(1, 5), self.tick))
|
| 278 |
+
|
| 279 |
+
elif agent_type == "MeanReversion":
|
| 280 |
+
if len(prices) >= 5:
|
| 281 |
+
mean = sum(prices) / len(prices)
|
| 282 |
+
variance = sum((p - mean) ** 2 for p in prices) / len(prices)
|
| 283 |
+
std = math.sqrt(variance) if variance > 0 else 0.01
|
| 284 |
+
z = (mid - mean) / std if std > 0 else 0
|
| 285 |
+
if z > 1.5:
|
| 286 |
+
price = round(mid * 0.998, 2)
|
| 287 |
+
orders.append(Order(agent.agent_id, Side.SELL, price, random.randint(1, 4), self.tick))
|
| 288 |
+
elif z < -1.5:
|
| 289 |
+
price = round(mid * 1.002, 2)
|
| 290 |
+
orders.append(Order(agent.agent_id, Side.BUY, price, random.randint(1, 4), self.tick))
|
| 291 |
+
|
| 292 |
+
elif agent_type == "Fundamental":
|
| 293 |
+
from agents.fundamental_agent import FundamentalAgent
|
| 294 |
+
if isinstance(agent, FundamentalAgent):
|
| 295 |
+
fv = agent.fair_value
|
| 296 |
+
gap = (mid - fv) / fv
|
| 297 |
+
if gap < -0.03:
|
| 298 |
+
price = round(mid * 1.001, 2)
|
| 299 |
+
orders.append(Order(agent.agent_id, Side.BUY, price, random.randint(1, 3), self.tick))
|
| 300 |
+
elif gap > 0.03:
|
| 301 |
+
price = round(mid * 0.999, 2)
|
| 302 |
+
orders.append(Order(agent.agent_id, Side.SELL, price, random.randint(1, 3), self.tick))
|
| 303 |
+
|
| 304 |
+
elif agent_type == "MarketMaker":
|
| 305 |
+
# Always post both sides
|
| 306 |
+
bid_price = round(mid * 0.995, 2)
|
| 307 |
+
ask_price = round(mid * 1.005, 2)
|
| 308 |
+
qty = 5
|
| 309 |
+
if abs(agent.state.position) > 20:
|
| 310 |
+
qty = 2 # reduce size when inventory is large
|
| 311 |
+
orders.append(Order(agent.agent_id, Side.BUY, bid_price, qty, self.tick))
|
| 312 |
+
orders.append(Order(agent.agent_id, Side.SELL, ask_price, qty, self.tick))
|
| 313 |
+
|
| 314 |
+
elif agent_type == "NoiseTrader":
|
| 315 |
+
# Random action
|
| 316 |
+
action = random.choice(["buy", "sell", "hold"])
|
| 317 |
+
if action == "buy":
|
| 318 |
+
price = round(mid * random.uniform(0.995, 1.005), 2)
|
| 319 |
+
orders.append(Order(agent.agent_id, Side.BUY, price, random.randint(1, 5), self.tick))
|
| 320 |
+
elif action == "sell":
|
| 321 |
+
price = round(mid * random.uniform(0.995, 1.005), 2)
|
| 322 |
+
orders.append(Order(agent.agent_id, Side.SELL, price, random.randint(1, 5), self.tick))
|
| 323 |
+
|
| 324 |
+
return orders
|
| 325 |
+
|
| 326 |
+
# ── Response → Order conversion ───────────────────────────────
|
| 327 |
+
|
| 328 |
+
def _responses_to_orders(self, responses: dict[str, LLMResponse]) -> list[Order]:
|
| 329 |
+
"""Convert LLM responses to Order objects."""
|
| 330 |
+
orders: list[Order] = []
|
| 331 |
+
|
| 332 |
+
for req_id, resp in responses.items():
|
| 333 |
+
agent_id = req_id
|
| 334 |
+
|
| 335 |
+
if resp.action == "hold":
|
| 336 |
+
continue
|
| 337 |
+
|
| 338 |
+
items_to_process = []
|
| 339 |
+
if resp.action == "orders" and resp.orders:
|
| 340 |
+
items_to_process.extend(resp.orders)
|
| 341 |
+
else:
|
| 342 |
+
items_to_process.append({"action": resp.action, "price": resp.price, "quantity": resp.quantity})
|
| 343 |
+
|
| 344 |
+
for item in items_to_process:
|
| 345 |
+
action = item.get("action")
|
| 346 |
+
if action == "hold":
|
| 347 |
+
continue
|
| 348 |
+
|
| 349 |
+
if action == "cancel":
|
| 350 |
+
# We pass a dummy order with side="cancel" to signal the loop to cancel this agent's orders
|
| 351 |
+
orders.append(Order(agent_id=agent_id, side="cancel", price=1.0, quantity=1, timestamp=self.tick))
|
| 352 |
+
continue
|
| 353 |
+
|
| 354 |
+
side = Side.BUY if action == "buy" else Side.SELL
|
| 355 |
+
try:
|
| 356 |
+
order = Order(
|
| 357 |
+
agent_id=agent_id,
|
| 358 |
+
side=side,
|
| 359 |
+
price=item.get("price"),
|
| 360 |
+
quantity=item.get("quantity"),
|
| 361 |
+
timestamp=self.tick,
|
| 362 |
+
)
|
| 363 |
+
orders.append(order)
|
| 364 |
+
except ValueError:
|
| 365 |
+
# Invalid price/quantity — skip
|
| 366 |
+
continue
|
| 367 |
+
|
| 368 |
+
return orders
|
| 369 |
+
|
| 370 |
+
# ── Trade application ─────────────────────────────────────────
|
| 371 |
+
|
| 372 |
+
def _apply_trade(self, trade: Trade):
|
| 373 |
+
"""Update agent states after a trade."""
|
| 374 |
+
buyer = self._find_agent(trade.buyer_id)
|
| 375 |
+
seller = self._find_agent(trade.seller_id)
|
| 376 |
+
|
| 377 |
+
if buyer:
|
| 378 |
+
buyer.record_trade(Side.BUY, trade.price, trade.quantity)
|
| 379 |
+
buyer.state.cash -= self.config.fee_per_trade * trade.quantity
|
| 380 |
+
if seller:
|
| 381 |
+
seller.record_trade(Side.SELL, trade.price, trade.quantity)
|
| 382 |
+
seller.state.cash -= self.config.fee_per_trade * trade.quantity
|
| 383 |
+
|
| 384 |
+
# Log trade
|
| 385 |
+
self.trade_rows.append({
|
| 386 |
+
"tick": trade.tick,
|
| 387 |
+
"price": trade.price,
|
| 388 |
+
"quantity": trade.quantity,
|
| 389 |
+
"buyer": trade.buyer_id,
|
| 390 |
+
"seller": trade.seller_id,
|
| 391 |
+
"aggressor": trade.aggressor_side.value,
|
| 392 |
+
})
|
| 393 |
+
|
| 394 |
+
def _find_agent(self, agent_id: str) -> BaseAgent | None:
|
| 395 |
+
"""Find agent by ID. Returns None for seed orders."""
|
| 396 |
+
for agent in self.agents:
|
| 397 |
+
if agent.agent_id == agent_id:
|
| 398 |
+
return agent
|
| 399 |
+
return None # seed orders have agent_id="_seed"
|
| 400 |
+
|
| 401 |
+
# ── CSV logging ───────────────────────────────────────────────
|
| 402 |
+
|
| 403 |
+
def _record_csv_row(self, tick: int, metrics: TickMetrics, trades: list[Trade]):
|
| 404 |
+
"""Record a row for the tick-level CSV."""
|
| 405 |
+
self.csv_rows.append({
|
| 406 |
+
"tick": tick,
|
| 407 |
+
"mid_price": metrics.mid_price,
|
| 408 |
+
"best_bid": metrics.best_bid,
|
| 409 |
+
"best_ask": metrics.best_ask,
|
| 410 |
+
"spread": metrics.spread,
|
| 411 |
+
"trade_count": metrics.trade_count,
|
| 412 |
+
"volume": metrics.volume,
|
| 413 |
+
"regime": self.metrics.classify_regime(),
|
| 414 |
+
"true_fair_value": self.true_fair_value,
|
| 415 |
+
})
|
| 416 |
+
|
| 417 |
+
# Agent PnL snapshot
|
| 418 |
+
current_price = metrics.mid_price or self.config.initial_price
|
| 419 |
+
for agent in self.agents:
|
| 420 |
+
self.agent_pnl_rows.append({
|
| 421 |
+
"tick": tick,
|
| 422 |
+
"agent_id": agent.agent_id,
|
| 423 |
+
"agent_type": agent.agent_type,
|
| 424 |
+
"position": agent.state.position,
|
| 425 |
+
"cash": round(agent.state.cash, 2),
|
| 426 |
+
"pnl": round(agent.mark_to_market(current_price), 2),
|
| 427 |
+
"trades": agent.state.trades_count,
|
| 428 |
+
})
|
| 429 |
+
|
| 430 |
+
def _write_csvs(self):
|
| 431 |
+
"""Write all logged data to CSV files."""
|
| 432 |
+
out = Path(self.config.output_dir)
|
| 433 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 434 |
+
|
| 435 |
+
self._write_csv(out / "ticks.csv", self.csv_rows)
|
| 436 |
+
self._write_csv(out / "trades.csv", self.trade_rows)
|
| 437 |
+
self._write_csv(out / "agent_pnl.csv", self.agent_pnl_rows)
|
| 438 |
+
|
| 439 |
+
print(f"\nCSVs written to {out}/")
|
| 440 |
+
|
| 441 |
+
@staticmethod
|
| 442 |
+
def _write_csv(path: Path, rows: list[dict]):
|
| 443 |
+
if not rows:
|
| 444 |
+
return
|
| 445 |
+
with open(path, "w", newline="") as f:
|
| 446 |
+
writer = csv.DictWriter(f, fieldnames=rows[0].keys())
|
| 447 |
+
writer.writeheader()
|
| 448 |
+
writer.writerows(rows)
|
| 449 |
+
|
| 450 |
+
# ── Summary ───────────────────────────────────────────────────
|
| 451 |
+
|
| 452 |
+
def _print_summary(self):
|
| 453 |
+
"""Print end-of-simulation summary."""
|
| 454 |
+
print("\n" + "=" * 60)
|
| 455 |
+
print("SIMULATION COMPLETE")
|
| 456 |
+
print("=" * 60)
|
| 457 |
+
|
| 458 |
+
summary = self.metrics.summary()
|
| 459 |
+
print(f" Ticks: {summary['total_ticks']}")
|
| 460 |
+
print(f" Total Trades: {summary['total_trades']}")
|
| 461 |
+
print(f" Total Volume: {summary['total_volume']}")
|
| 462 |
+
print(f" Crash Events: {summary['crash_events']}")
|
| 463 |
+
print(f" Final Regime: {summary['current_regime']}")
|
| 464 |
+
if summary['price_range']:
|
| 465 |
+
lo, hi = summary['price_range']
|
| 466 |
+
print(f" Price Range: {lo:.2f} — {hi:.2f}")
|
| 467 |
+
if summary['current_volatility']:
|
| 468 |
+
print(f" Volatility: {summary['current_volatility']:.4f}")
|
| 469 |
+
|
| 470 |
+
# Agent leaderboard
|
| 471 |
+
current_price = self.price_history[-1] if self.price_history else self.config.initial_price
|
| 472 |
+
print(f"\n Agent PnL Leaderboard (mark-to-market at {current_price:.2f}):")
|
| 473 |
+
print(f" {'Agent':<25s} {'Type':<15s} {'Pos':>6s} {'Cash':>10s} {'PnL':>10s} {'Trades':>7s}")
|
| 474 |
+
print(f" {'-'*25} {'-'*15} {'-'*6} {'-'*10} {'-'*10} {'-'*7}")
|
| 475 |
+
|
| 476 |
+
ranked = sorted(self.agents, key=lambda a: a.mark_to_market(current_price), reverse=True)
|
| 477 |
+
for agent in ranked:
|
| 478 |
+
pnl = agent.mark_to_market(current_price)
|
| 479 |
+
print(f" {agent.agent_id:<25s} {agent.agent_type:<15s} "
|
| 480 |
+
f"{agent.state.position:>6d} {agent.state.cash:>10.2f} "
|
| 481 |
+
f"{pnl:>10.2f} {agent.state.trades_count:>7d}")
|
| 482 |
+
|
| 483 |
+
# Latency stats (for AMD benchmarking)
|
| 484 |
+
if self.latencies:
|
| 485 |
+
avg_lat = sum(self.latencies) / len(self.latencies)
|
| 486 |
+
print(f"\n Avg batch latency: {avg_lat:.1f} ms")
|
| 487 |
+
print(f" Throughput: {len(self.agents) / (avg_lat / 1000):.1f} decisions/sec")
|
experiments/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# experiments package
|
experiments/baseline_run.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Experiment A — Baseline Run.
|
| 3 |
+
|
| 4 |
+
Agent composition: 2 momentum + 1 mean-reversion + 1 fundamental + 1 market maker + 1 noise.
|
| 5 |
+
Hypothesis: Prices stay near fair value. Market is relatively efficient.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import sys
|
| 9 |
+
import os
|
| 10 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
| 11 |
+
|
| 12 |
+
from engine.simulation import SimulationEngine, SimulationConfig
|
| 13 |
+
from agents.momentum_agent import MomentumAgent
|
| 14 |
+
from agents.mean_reversion_agent import MeanReversionAgent
|
| 15 |
+
from agents.fundamental_agent import FundamentalAgent
|
| 16 |
+
from agents.market_maker_agent import MarketMakerAgent
|
| 17 |
+
from agents.noise_trader import NoiseTrader
|
| 18 |
+
from experiments.plot_utils import plot_experiment
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def run():
|
| 22 |
+
agents = [
|
| 23 |
+
MomentumAgent("momentum_1"),
|
| 24 |
+
MomentumAgent("momentum_2"),
|
| 25 |
+
MeanReversionAgent("meanrev_1"),
|
| 26 |
+
FundamentalAgent("fundamental_1", fair_value=100.0),
|
| 27 |
+
MarketMakerAgent("marketmaker_1"),
|
| 28 |
+
NoiseTrader("noise_1"),
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
config = SimulationConfig(
|
| 32 |
+
num_ticks=200,
|
| 33 |
+
initial_price=100.0,
|
| 34 |
+
use_llm=False,
|
| 35 |
+
output_dir="output/experiment_a_baseline",
|
| 36 |
+
seed=42,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
engine = SimulationEngine(agents, config)
|
| 40 |
+
engine.run()
|
| 41 |
+
|
| 42 |
+
# Generate plots
|
| 43 |
+
plot_experiment(
|
| 44 |
+
engine,
|
| 45 |
+
title="Experiment A — Baseline (Equal Mix)",
|
| 46 |
+
output_dir=config.output_dir,
|
| 47 |
+
fair_value=100.0,
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
return engine
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
if __name__ == "__main__":
|
| 54 |
+
run()
|
experiments/benchmark_vllm.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AMD MI300X Benchmarking Script.
|
| 3 |
+
|
| 4 |
+
Demonstrates the advantage of concurrent inference on AMD MI300X.
|
| 5 |
+
Runs a set of agent inferences sequentially vs. batched concurrently.
|
| 6 |
+
|
| 7 |
+
Run this against the live vLLM server to generate numbers for the README.
|
| 8 |
+
Usage: python experiments/benchmark_vllm.py --url http://localhost:8000/v1
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
import asyncio
|
| 13 |
+
import time
|
| 14 |
+
import sys
|
| 15 |
+
import os
|
| 16 |
+
|
| 17 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
| 18 |
+
from inference.vllm_client import VLLMClient
|
| 19 |
+
from inference.prompt_templates import MOMENTUM_CHARTER
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
async def main():
|
| 23 |
+
parser = argparse.ArgumentParser()
|
| 24 |
+
parser.add_argument("--url", type=str, default="http://localhost:8000/v1", help="vLLM server URL")
|
| 25 |
+
parser.add_argument("--model", type=str, default="Qwen/Qwen2.5-7B-Instruct", help="Model name")
|
| 26 |
+
parser.add_argument("--agents", type=int, default=5, help="Number of concurrent agents to simulate")
|
| 27 |
+
args = parser.parse_args()
|
| 28 |
+
|
| 29 |
+
client = VLLMClient(base_url=args.url, model=args.model)
|
| 30 |
+
|
| 31 |
+
# Dummy market state for benchmarking
|
| 32 |
+
dummy_state = """Best Bid: 99.50 | Best Ask: 100.50 | Mid: 100.00 | Spread: 1.0000
|
| 33 |
+
Last Trade: 100.00
|
| 34 |
+
Recent Prices (last 10): [100.00, 100.00, 100.00]
|
| 35 |
+
Your Position: 0 units | Your Cash: 10000.00"""
|
| 36 |
+
|
| 37 |
+
requests = [(f"agent_{i}", MOMENTUM_CHARTER, dummy_state) for i in range(args.agents)]
|
| 38 |
+
|
| 39 |
+
print(f"Connecting to vLLM at {args.url}")
|
| 40 |
+
print(f"Model: {args.model}")
|
| 41 |
+
print(f"Agents: {args.agents}")
|
| 42 |
+
print("-" * 50)
|
| 43 |
+
|
| 44 |
+
# 1. Sequential Test
|
| 45 |
+
print("Running SEQUENTIAL inference...")
|
| 46 |
+
seq_latencies = []
|
| 47 |
+
t_start_seq = time.perf_counter()
|
| 48 |
+
for req_id, sys_prompt, user_msg in requests:
|
| 49 |
+
resp = await client.infer(sys_prompt, user_msg)
|
| 50 |
+
seq_latencies.append(resp.latency_ms)
|
| 51 |
+
t_end_seq = time.perf_counter()
|
| 52 |
+
|
| 53 |
+
seq_total_time = t_end_seq - t_start_seq
|
| 54 |
+
seq_avg_latency = sum(seq_latencies) / len(seq_latencies)
|
| 55 |
+
|
| 56 |
+
# 2. Batched (Concurrent) Test
|
| 57 |
+
print("Running BATCHED ASYNC inference...")
|
| 58 |
+
t_start_batch = time.perf_counter()
|
| 59 |
+
responses = await client.batch_infer(requests)
|
| 60 |
+
t_end_batch = time.perf_counter()
|
| 61 |
+
|
| 62 |
+
batch_total_time = t_end_batch - t_start_batch
|
| 63 |
+
batch_avg_latency = sum(r.latency_ms for r in responses.values()) / len(responses)
|
| 64 |
+
|
| 65 |
+
print("\n" + "=" * 50)
|
| 66 |
+
print("AMD MI300X BENCHMARK RESULTS")
|
| 67 |
+
print("=" * 50)
|
| 68 |
+
print(f"Sequential Total Time: {seq_total_time:.3f} s")
|
| 69 |
+
print(f"Sequential Avg per Call: {seq_avg_latency:.1f} ms")
|
| 70 |
+
print(f"Sequential Throughput: {args.agents / seq_total_time:.2f} calls/sec")
|
| 71 |
+
print("-" * 50)
|
| 72 |
+
print(f"Batched Total Time: {batch_total_time:.3f} s")
|
| 73 |
+
print(f"Batched Avg per Call: {batch_avg_latency:.1f} ms (internal server time)")
|
| 74 |
+
print(f"Batched Throughput: {args.agents / batch_total_time:.2f} calls/sec")
|
| 75 |
+
print("-" * 50)
|
| 76 |
+
|
| 77 |
+
if batch_total_time > 0:
|
| 78 |
+
speedup = seq_total_time / batch_total_time
|
| 79 |
+
print(f"🚀 Concurrency Speedup: {speedup:.2f}x")
|
| 80 |
+
print("\nConclusion:")
|
| 81 |
+
print("Thanks to MI300X 192GB HBM3 memory bandwidth, vLLM easily handles")
|
| 82 |
+
print("large concurrent batch sizes without severe latency degradation.")
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
if __name__ == "__main__":
|
| 86 |
+
asyncio.run(main())
|
experiments/momentum_heavy.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Experiment B — Momentum Overload.
|
| 3 |
+
|
| 4 |
+
Agent composition: 4 momentum + 1 noise. No fundamental anchor, no market maker.
|
| 5 |
+
Hypothesis: Price trends away from fair value → bubble formation.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import sys
|
| 9 |
+
import os
|
| 10 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
| 11 |
+
|
| 12 |
+
from engine.simulation import SimulationEngine, SimulationConfig
|
| 13 |
+
from agents.momentum_agent import MomentumAgent
|
| 14 |
+
from agents.market_maker_agent import MarketMakerAgent
|
| 15 |
+
from agents.noise_trader import NoiseTrader
|
| 16 |
+
from experiments.plot_utils import plot_experiment
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def run():
|
| 20 |
+
agents = [
|
| 21 |
+
MomentumAgent("momentum_1"),
|
| 22 |
+
MomentumAgent("momentum_2"),
|
| 23 |
+
MomentumAgent("momentum_3"),
|
| 24 |
+
MomentumAgent("momentum_4"),
|
| 25 |
+
MarketMakerAgent("marketmaker_1"),
|
| 26 |
+
NoiseTrader("noise_1"),
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
config = SimulationConfig(
|
| 30 |
+
num_ticks=200,
|
| 31 |
+
initial_price=100.0,
|
| 32 |
+
use_llm=False,
|
| 33 |
+
output_dir="output/experiment_b_momentum",
|
| 34 |
+
seed=42,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
engine = SimulationEngine(agents, config)
|
| 38 |
+
engine.run()
|
| 39 |
+
|
| 40 |
+
# Generate plots
|
| 41 |
+
plot_experiment(
|
| 42 |
+
engine,
|
| 43 |
+
title="Experiment B — Momentum Overload (No Anchor)",
|
| 44 |
+
output_dir=config.output_dir,
|
| 45 |
+
fair_value=100.0,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
return engine
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
run()
|
experiments/no_market_maker.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Experiment C — No Market Maker.
|
| 3 |
+
|
| 4 |
+
Agent composition: 2 momentum + 1 mean-reversion + 1 fundamental + 1 noise. No market maker.
|
| 5 |
+
Hypothesis: Spreads widen dramatically, liquidity fragmentation, possible crash.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import sys
|
| 9 |
+
import os
|
| 10 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
| 11 |
+
|
| 12 |
+
from engine.simulation import SimulationEngine, SimulationConfig
|
| 13 |
+
from agents.momentum_agent import MomentumAgent
|
| 14 |
+
from agents.mean_reversion_agent import MeanReversionAgent
|
| 15 |
+
from agents.fundamental_agent import FundamentalAgent
|
| 16 |
+
from agents.noise_trader import NoiseTrader
|
| 17 |
+
from experiments.plot_utils import plot_experiment
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def run():
|
| 21 |
+
agents = [
|
| 22 |
+
MomentumAgent("momentum_1"),
|
| 23 |
+
MomentumAgent("momentum_2"),
|
| 24 |
+
MeanReversionAgent("meanrev_1"),
|
| 25 |
+
FundamentalAgent("fundamental_1", fair_value=100.0),
|
| 26 |
+
NoiseTrader("noise_1"),
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
config = SimulationConfig(
|
| 30 |
+
num_ticks=200,
|
| 31 |
+
initial_price=100.0,
|
| 32 |
+
use_llm=False,
|
| 33 |
+
output_dir="output/experiment_c_no_mm",
|
| 34 |
+
seed=42,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
engine = SimulationEngine(agents, config)
|
| 38 |
+
engine.run()
|
| 39 |
+
|
| 40 |
+
# Generate plots
|
| 41 |
+
plot_experiment(
|
| 42 |
+
engine,
|
| 43 |
+
title="Experiment C — No Market Maker (Liquidity Test)",
|
| 44 |
+
output_dir=config.output_dir,
|
| 45 |
+
fair_value=100.0,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
return engine
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
run()
|
experiments/plot_utils.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Plotting utilities for experiment results.
|
| 3 |
+
|
| 4 |
+
Generates static PNG plots for each experiment:
|
| 5 |
+
1. Price series with fair value line
|
| 6 |
+
2. Bid-ask spread over time
|
| 7 |
+
3. Agent PnL over time
|
| 8 |
+
4. Trade volume per tick
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
try:
|
| 15 |
+
import matplotlib
|
| 16 |
+
matplotlib.use("Agg") # Non-interactive backend
|
| 17 |
+
import matplotlib.pyplot as plt
|
| 18 |
+
HAS_MPL = True
|
| 19 |
+
except ImportError:
|
| 20 |
+
HAS_MPL = False
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def plot_experiment(engine, title: str, output_dir: str, fair_value: float = 100.0):
|
| 24 |
+
"""
|
| 25 |
+
Generate all experiment plots and save to output_dir.
|
| 26 |
+
Falls back to text summary if matplotlib is not installed.
|
| 27 |
+
"""
|
| 28 |
+
if not HAS_MPL:
|
| 29 |
+
print(f"[WARN] matplotlib not installed — skipping plots for {title}")
|
| 30 |
+
_text_summary(engine, title, output_dir)
|
| 31 |
+
return
|
| 32 |
+
|
| 33 |
+
out = Path(output_dir)
|
| 34 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 35 |
+
|
| 36 |
+
_plot_price_series(engine, title, fair_value, out)
|
| 37 |
+
_plot_spread(engine, title, out)
|
| 38 |
+
_plot_agent_pnl(engine, title, out)
|
| 39 |
+
_plot_volume(engine, title, out)
|
| 40 |
+
|
| 41 |
+
print(f"Plots saved to {out}/")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _plot_price_series(engine, title: str, fair_value: float, out: Path):
|
| 45 |
+
"""Mid price over time with fair value reference line."""
|
| 46 |
+
ticks = [m.tick for m in engine.metrics.tick_history if m.mid_price is not None]
|
| 47 |
+
prices = [m.mid_price for m in engine.metrics.tick_history if m.mid_price is not None]
|
| 48 |
+
|
| 49 |
+
fig, ax = plt.subplots(figsize=(12, 5))
|
| 50 |
+
ax.plot(ticks, prices, linewidth=1.5, color="#2196F3", label="Mid Price")
|
| 51 |
+
ax.axhline(y=fair_value, color="#F44336", linestyle="--", linewidth=1, alpha=0.7, label=f"Fair Value ({fair_value})")
|
| 52 |
+
ax.set_xlabel("Tick")
|
| 53 |
+
ax.set_ylabel("Price")
|
| 54 |
+
ax.set_title(f"{title}\nPrice Series")
|
| 55 |
+
ax.legend()
|
| 56 |
+
ax.grid(True, alpha=0.3)
|
| 57 |
+
fig.tight_layout()
|
| 58 |
+
fig.savefig(out / "price_series.png", dpi=150)
|
| 59 |
+
plt.close(fig)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _plot_spread(engine, title: str, out: Path):
|
| 63 |
+
"""Bid-ask spread over time."""
|
| 64 |
+
ticks = [m.tick for m in engine.metrics.tick_history if m.spread is not None]
|
| 65 |
+
spreads = [m.spread for m in engine.metrics.tick_history if m.spread is not None]
|
| 66 |
+
|
| 67 |
+
fig, ax = plt.subplots(figsize=(12, 4))
|
| 68 |
+
ax.fill_between(ticks, spreads, alpha=0.4, color="#FF9800")
|
| 69 |
+
ax.plot(ticks, spreads, linewidth=1, color="#E65100")
|
| 70 |
+
ax.set_xlabel("Tick")
|
| 71 |
+
ax.set_ylabel("Spread")
|
| 72 |
+
ax.set_title(f"{title}\nBid-Ask Spread")
|
| 73 |
+
ax.grid(True, alpha=0.3)
|
| 74 |
+
fig.tight_layout()
|
| 75 |
+
fig.savefig(out / "spread.png", dpi=150)
|
| 76 |
+
plt.close(fig)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _plot_agent_pnl(engine, title: str, out: Path):
|
| 80 |
+
"""Per-agent PnL over time."""
|
| 81 |
+
# Collect PnL per agent per tick
|
| 82 |
+
agent_data: dict[str, list[tuple[int, float]]] = {}
|
| 83 |
+
|
| 84 |
+
for row in engine.agent_pnl_rows:
|
| 85 |
+
key = f"{row['agent_id']} ({row['agent_type']})"
|
| 86 |
+
if key not in agent_data:
|
| 87 |
+
agent_data[key] = []
|
| 88 |
+
agent_data[key].append((row["tick"], row["pnl"]))
|
| 89 |
+
|
| 90 |
+
colors = ["#2196F3", "#4CAF50", "#F44336", "#FF9800", "#9C27B0", "#00BCD4", "#795548", "#607D8B"]
|
| 91 |
+
fig, ax = plt.subplots(figsize=(12, 5))
|
| 92 |
+
|
| 93 |
+
for i, (label, data) in enumerate(agent_data.items()):
|
| 94 |
+
ticks = [d[0] for d in data]
|
| 95 |
+
pnls = [d[1] for d in data]
|
| 96 |
+
color = colors[i % len(colors)]
|
| 97 |
+
ax.plot(ticks, pnls, linewidth=1.2, label=label, color=color)
|
| 98 |
+
|
| 99 |
+
ax.axhline(y=0, color="gray", linestyle="-", linewidth=0.5)
|
| 100 |
+
ax.set_xlabel("Tick")
|
| 101 |
+
ax.set_ylabel("PnL")
|
| 102 |
+
ax.set_title(f"{title}\nAgent PnL")
|
| 103 |
+
ax.legend(fontsize=8, loc="best")
|
| 104 |
+
ax.grid(True, alpha=0.3)
|
| 105 |
+
fig.tight_layout()
|
| 106 |
+
fig.savefig(out / "agent_pnl.png", dpi=150)
|
| 107 |
+
plt.close(fig)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _plot_volume(engine, title: str, out: Path):
|
| 111 |
+
"""Trade volume per tick."""
|
| 112 |
+
ticks = [m.tick for m in engine.metrics.tick_history]
|
| 113 |
+
volumes = [m.volume for m in engine.metrics.tick_history]
|
| 114 |
+
|
| 115 |
+
fig, ax = plt.subplots(figsize=(12, 3))
|
| 116 |
+
ax.bar(ticks, volumes, width=1.0, color="#4CAF50", alpha=0.7)
|
| 117 |
+
ax.set_xlabel("Tick")
|
| 118 |
+
ax.set_ylabel("Volume")
|
| 119 |
+
ax.set_title(f"{title}\nTrade Volume per Tick")
|
| 120 |
+
ax.grid(True, alpha=0.3, axis="y")
|
| 121 |
+
fig.tight_layout()
|
| 122 |
+
fig.savefig(out / "volume.png", dpi=150)
|
| 123 |
+
plt.close(fig)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _text_summary(engine, title: str, output_dir: str):
|
| 127 |
+
"""Fallback text summary when matplotlib is unavailable."""
|
| 128 |
+
out = Path(output_dir)
|
| 129 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 130 |
+
|
| 131 |
+
summary = engine.metrics.summary()
|
| 132 |
+
with open(out / "summary.txt", "w") as f:
|
| 133 |
+
f.write(f"{title}\n{'=' * len(title)}\n\n")
|
| 134 |
+
for k, v in summary.items():
|
| 135 |
+
f.write(f"{k}: {v}\n")
|
| 136 |
+
print(f"Text summary saved to {out}/summary.txt")
|
inference/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# inference package
|
inference/prompt_templates.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Charter prompt templates for all agent types.
|
| 3 |
+
|
| 4 |
+
Each charter is the system prompt the LLM receives.
|
| 5 |
+
Kept tight — every extra token costs latency on the MI300X.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
MOMENTUM_CHARTER = """You are a momentum trader. You buy when prices are rising and sell when they are falling.
|
| 9 |
+
Look at the last 10 prices. If the trend is up, submit a buy slightly above mid.
|
| 10 |
+
If the trend is down, submit a sell slightly below mid. If flat, hold.
|
| 11 |
+
Respond only with valid JSON: {"action": "buy"|"sell"|"hold"|"cancel", "price": <float>, "quantity": <int 1-10>}"""
|
| 12 |
+
|
| 13 |
+
MEAN_REVERSION_CHARTER = """You are a mean reversion trader. You believe prices revert to their rolling average.
|
| 14 |
+
If current mid price is more than 1.5 std above the 10-tick mean, sell.
|
| 15 |
+
If more than 1.5 std below, buy. Otherwise hold.
|
| 16 |
+
Respond only with valid JSON: {"action": "buy"|"sell"|"hold"|"cancel", "price": <float>, "quantity": <int 1-10>}"""
|
| 17 |
+
|
| 18 |
+
FUNDAMENTAL_CHARTER_TEMPLATE = """You are a fundamental value investor. Your private fair value estimate is {fair_value:.2f}.
|
| 19 |
+
If mid price is more than 3% below fair value, buy. If more than 3% above, sell. Otherwise hold.
|
| 20 |
+
Be patient — only act when the gap is significant.
|
| 21 |
+
Respond only with valid JSON: {{"action": "buy"|"sell"|"hold"|"cancel", "price": <float>, "quantity": <int 1-10>}}"""
|
| 22 |
+
|
| 23 |
+
MARKET_MAKER_CHARTER = """You are a market maker. Your job is to provide liquidity by always quoting both sides.
|
| 24 |
+
Post a bid 0.5% below mid and an ask 0.5% above mid. Reduce quantity if your inventory exceeds 20 units.
|
| 25 |
+
You must manage your resting orders; use "cancel" if needed.
|
| 26 |
+
Respond only with valid JSON containing a list of orders:
|
| 27 |
+
{"orders": [{"action": "buy"|"sell"|"cancel", "price": <float>, "quantity": <int>}]}"""
|
| 28 |
+
|
| 29 |
+
NOISE_TRADER_CHARTER = """You are a noise trader. You act on irrelevant signals.
|
| 30 |
+
Randomly buy or sell at a price within 1% of mid, quantity between 1 and 5.
|
| 31 |
+
Respond only with valid JSON: {"action": "buy"|"sell"|"hold"|"cancel", "price": <float>, "quantity": <int 1-10>}"""
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def get_fundamental_charter(fair_value: float) -> str:
|
| 35 |
+
"""Build a fundamental agent charter with a specific fair value."""
|
| 36 |
+
return FUNDAMENTAL_CHARTER_TEMPLATE.format(fair_value=fair_value)
|
inference/vllm_client.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Async vLLM inference client.
|
| 3 |
+
|
| 4 |
+
Wraps the OpenAI-compatible endpoint served by vLLM on AMD MI300X.
|
| 5 |
+
All agent calls go through here, batched via asyncio.gather().
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import asyncio
|
| 9 |
+
import json
|
| 10 |
+
import time
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
|
| 13 |
+
import openai
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class LLMResponse:
|
| 18 |
+
"""Parsed response from the LLM."""
|
| 19 |
+
action: str # "buy", "sell", "hold", "cancel"
|
| 20 |
+
price: float
|
| 21 |
+
quantity: int
|
| 22 |
+
raw_text: str
|
| 23 |
+
latency_ms: float
|
| 24 |
+
success: bool
|
| 25 |
+
orders: list[dict] = None # Added for multiple orders
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# Default hold response for when LLM returns garbage
|
| 29 |
+
HOLD_RESPONSE = LLMResponse(
|
| 30 |
+
action="hold", price=0.0, quantity=0,
|
| 31 |
+
raw_text="fallback_hold", latency_ms=0.0, success=False,
|
| 32 |
+
orders=[]
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def parse_llm_output(raw: str) -> dict | None:
|
| 37 |
+
"""
|
| 38 |
+
Parse the LLM's JSON output. Returns dict or None on failure.
|
| 39 |
+
Handles common LLM failure modes: markdown wrapping, trailing text.
|
| 40 |
+
"""
|
| 41 |
+
text = raw.strip()
|
| 42 |
+
|
| 43 |
+
# Strip markdown code fences if present
|
| 44 |
+
if text.startswith("```"):
|
| 45 |
+
lines = text.split("\n")
|
| 46 |
+
# Remove first and last lines (```json and ```)
|
| 47 |
+
lines = [l for l in lines if not l.strip().startswith("```")]
|
| 48 |
+
text = "\n".join(lines).strip()
|
| 49 |
+
|
| 50 |
+
try:
|
| 51 |
+
data = json.loads(text)
|
| 52 |
+
except json.JSONDecodeError:
|
| 53 |
+
# Try to find JSON object in the text
|
| 54 |
+
start = text.find("{")
|
| 55 |
+
end = text.rfind("}")
|
| 56 |
+
if start != -1 and end != -1 and end > start:
|
| 57 |
+
try:
|
| 58 |
+
data = json.loads(text[start:end + 1])
|
| 59 |
+
except json.JSONDecodeError:
|
| 60 |
+
return None
|
| 61 |
+
else:
|
| 62 |
+
return None
|
| 63 |
+
|
| 64 |
+
if "orders" in data and isinstance(data["orders"], list):
|
| 65 |
+
parsed_orders = []
|
| 66 |
+
for o in data["orders"]:
|
| 67 |
+
action = o.get("action", "").lower()
|
| 68 |
+
if action not in ("buy", "sell", "hold", "cancel"):
|
| 69 |
+
continue
|
| 70 |
+
if action in ("hold", "cancel"):
|
| 71 |
+
parsed_orders.append({"action": action, "price": 0.0, "quantity": 0})
|
| 72 |
+
else:
|
| 73 |
+
try:
|
| 74 |
+
price = float(o.get("price", 0))
|
| 75 |
+
quantity = int(o.get("quantity", 0))
|
| 76 |
+
if price > 0 and quantity > 0:
|
| 77 |
+
parsed_orders.append({"action": action, "price": round(price, 2), "quantity": min(quantity, 10)})
|
| 78 |
+
except (ValueError, TypeError):
|
| 79 |
+
continue
|
| 80 |
+
return {"orders": parsed_orders}
|
| 81 |
+
|
| 82 |
+
# Validate required fields
|
| 83 |
+
action = data.get("action", "").lower()
|
| 84 |
+
if action not in ("buy", "sell", "hold", "cancel"):
|
| 85 |
+
return None
|
| 86 |
+
|
| 87 |
+
if action in ("hold", "cancel"):
|
| 88 |
+
return {"action": action, "price": 0.0, "quantity": 0}
|
| 89 |
+
|
| 90 |
+
try:
|
| 91 |
+
price = float(data.get("price", 0))
|
| 92 |
+
quantity = int(data.get("quantity", 0))
|
| 93 |
+
except (ValueError, TypeError):
|
| 94 |
+
return None
|
| 95 |
+
|
| 96 |
+
if price <= 0 or quantity <= 0:
|
| 97 |
+
return None
|
| 98 |
+
|
| 99 |
+
# Clamp quantity to spec max
|
| 100 |
+
quantity = min(quantity, 10)
|
| 101 |
+
|
| 102 |
+
return {"action": action, "price": round(price, 2), "quantity": quantity}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class VLLMClient:
|
| 106 |
+
"""
|
| 107 |
+
Async client for vLLM's OpenAI-compatible API.
|
| 108 |
+
|
| 109 |
+
Usage:
|
| 110 |
+
client = VLLMClient(base_url="http://localhost:8000/v1")
|
| 111 |
+
responses = await client.batch_infer(requests)
|
| 112 |
+
"""
|
| 113 |
+
|
| 114 |
+
def __init__(
|
| 115 |
+
self,
|
| 116 |
+
base_url: str = "http://localhost:8000/v1",
|
| 117 |
+
api_key: str = "EMPTY",
|
| 118 |
+
model: str = "Qwen/Qwen2.5-7B-Instruct",
|
| 119 |
+
max_tokens: int = 64,
|
| 120 |
+
temperature: float = 0.3,
|
| 121 |
+
):
|
| 122 |
+
self.client = openai.AsyncOpenAI(base_url=base_url, api_key=api_key)
|
| 123 |
+
self.model = model
|
| 124 |
+
self.max_tokens = max_tokens
|
| 125 |
+
self.temperature = temperature
|
| 126 |
+
|
| 127 |
+
async def infer(self, system_prompt: str, user_message: str) -> LLMResponse:
|
| 128 |
+
"""Single inference call. Returns parsed LLMResponse."""
|
| 129 |
+
t0 = time.perf_counter()
|
| 130 |
+
try:
|
| 131 |
+
response = await self.client.chat.completions.create(
|
| 132 |
+
model=self.model,
|
| 133 |
+
messages=[
|
| 134 |
+
{"role": "system", "content": system_prompt},
|
| 135 |
+
{"role": "user", "content": user_message},
|
| 136 |
+
],
|
| 137 |
+
response_format={"type": "json_object"},
|
| 138 |
+
max_tokens=self.max_tokens,
|
| 139 |
+
temperature=self.temperature,
|
| 140 |
+
)
|
| 141 |
+
raw_text = response.choices[0].message.content or ""
|
| 142 |
+
latency_ms = (time.perf_counter() - t0) * 1000
|
| 143 |
+
|
| 144 |
+
parsed = parse_llm_output(raw_text)
|
| 145 |
+
if parsed is None:
|
| 146 |
+
return LLMResponse(
|
| 147 |
+
action="hold", price=0.0, quantity=0,
|
| 148 |
+
raw_text=raw_text, latency_ms=latency_ms, success=False,
|
| 149 |
+
orders=[]
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
if "orders" in parsed:
|
| 153 |
+
return LLMResponse(
|
| 154 |
+
action="orders", price=0.0, quantity=0,
|
| 155 |
+
raw_text=raw_text, latency_ms=latency_ms, success=True,
|
| 156 |
+
orders=parsed["orders"]
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
return LLMResponse(
|
| 160 |
+
action=parsed["action"],
|
| 161 |
+
price=parsed["price"],
|
| 162 |
+
quantity=parsed["quantity"],
|
| 163 |
+
raw_text=raw_text,
|
| 164 |
+
latency_ms=latency_ms,
|
| 165 |
+
success=True,
|
| 166 |
+
orders=[]
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
except Exception as e:
|
| 170 |
+
latency_ms = (time.perf_counter() - t0) * 1000
|
| 171 |
+
return LLMResponse(
|
| 172 |
+
action="hold", price=0.0, quantity=0,
|
| 173 |
+
raw_text=f"ERROR: {e}", latency_ms=latency_ms, success=False,
|
| 174 |
+
orders=[]
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
async def batch_infer(
|
| 178 |
+
self, requests: list[tuple[str, str, str]]
|
| 179 |
+
) -> dict[str, LLMResponse]:
|
| 180 |
+
"""
|
| 181 |
+
Batch inference for multiple agents concurrently.
|
| 182 |
+
|
| 183 |
+
Args:
|
| 184 |
+
requests: list of (agent_id, system_prompt, user_message)
|
| 185 |
+
|
| 186 |
+
Returns:
|
| 187 |
+
dict mapping agent_id → LLMResponse
|
| 188 |
+
"""
|
| 189 |
+
async def _call(agent_id: str, sys_prompt: str, user_msg: str):
|
| 190 |
+
resp = await self.infer(sys_prompt, user_msg)
|
| 191 |
+
return agent_id, resp
|
| 192 |
+
|
| 193 |
+
tasks = [_call(aid, sp, um) for aid, sp, um in requests]
|
| 194 |
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
| 195 |
+
|
| 196 |
+
output: dict[str, LLMResponse] = {}
|
| 197 |
+
for result in results:
|
| 198 |
+
if isinstance(result, Exception):
|
| 199 |
+
# Shouldn't happen since infer() catches exceptions, but be safe
|
| 200 |
+
continue
|
| 201 |
+
agent_id, response = result
|
| 202 |
+
output[agent_id] = response
|
| 203 |
+
|
| 204 |
+
return output
|
requirements.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MarketMind — Dependencies
|
| 2 |
+
# Core
|
| 3 |
+
openai>=1.0.0 # vLLM OpenAI-compatible client
|
| 4 |
+
|
| 5 |
+
# Dashboard
|
| 6 |
+
gradio>=4.0.0
|
| 7 |
+
plotly>=5.18.0
|
| 8 |
+
|
| 9 |
+
# Data
|
| 10 |
+
pandas>=2.1.0
|
| 11 |
+
numpy>=1.24.0
|
| 12 |
+
|
| 13 |
+
# Utilities
|
| 14 |
+
sortedcontainers>=2.4.0 # SortedList for order book
|
run_simulation.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MarketMind — Entry Point.
|
| 3 |
+
|
| 4 |
+
Run a multi-agent market simulation.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
python run_simulation.py # offline mode, 100 ticks
|
| 8 |
+
python run_simulation.py --ticks 200 # offline, 200 ticks
|
| 9 |
+
python run_simulation.py --llm # vLLM mode (requires server running)
|
| 10 |
+
python run_simulation.py --llm --url http://host:8000/v1
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
import sys
|
| 15 |
+
|
| 16 |
+
from engine.simulation import SimulationEngine, SimulationConfig
|
| 17 |
+
from agents.momentum_agent import MomentumAgent
|
| 18 |
+
from agents.mean_reversion_agent import MeanReversionAgent
|
| 19 |
+
from agents.fundamental_agent import FundamentalAgent
|
| 20 |
+
from agents.market_maker_agent import MarketMakerAgent
|
| 21 |
+
from agents.noise_trader import NoiseTrader
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def build_default_agents() -> list:
|
| 25 |
+
"""
|
| 26 |
+
Default agent composition: the baseline 5-agent mix.
|
| 27 |
+
Per spec Experiment A: 2 momentum + 1 mean-reversion + 1 fundamental + 1 noise.
|
| 28 |
+
Plus 1 market maker for liquidity.
|
| 29 |
+
"""
|
| 30 |
+
return [
|
| 31 |
+
MomentumAgent("momentum_1"),
|
| 32 |
+
MomentumAgent("momentum_2"),
|
| 33 |
+
MeanReversionAgent("meanrev_1"),
|
| 34 |
+
FundamentalAgent("fundamental_1", fair_value=100.0),
|
| 35 |
+
MarketMakerAgent("marketmaker_1"),
|
| 36 |
+
NoiseTrader("noise_1"),
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def main():
|
| 41 |
+
parser = argparse.ArgumentParser(description="MarketMind Simulation")
|
| 42 |
+
parser.add_argument("--ticks", type=int, default=100, help="Number of simulation ticks")
|
| 43 |
+
parser.add_argument("--price", type=float, default=100.0, help="Initial price")
|
| 44 |
+
parser.add_argument("--llm", action="store_true", help="Use vLLM inference (requires server)")
|
| 45 |
+
parser.add_argument("--url", type=str, default="http://localhost:8000/v1", help="vLLM server URL")
|
| 46 |
+
parser.add_argument("--model", type=str, default="Qwen/Qwen2.5-7B-Instruct", help="Model name")
|
| 47 |
+
parser.add_argument("--output", type=str, default="output", help="Output directory for CSVs")
|
| 48 |
+
parser.add_argument("--seed", type=int, default=42, help="Random seed for reproducibility")
|
| 49 |
+
args = parser.parse_args()
|
| 50 |
+
|
| 51 |
+
config = SimulationConfig(
|
| 52 |
+
num_ticks=args.ticks,
|
| 53 |
+
initial_price=args.price,
|
| 54 |
+
use_llm=args.llm,
|
| 55 |
+
vllm_base_url=args.url,
|
| 56 |
+
vllm_model=args.model,
|
| 57 |
+
output_dir=args.output,
|
| 58 |
+
seed=args.seed,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
agents = build_default_agents()
|
| 62 |
+
|
| 63 |
+
engine = SimulationEngine(agents, config)
|
| 64 |
+
engine.run()
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
if __name__ == "__main__":
|
| 68 |
+
main()
|
tests/test_agents_smoke.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Quick smoke test for all Phase 2 imports."""
|
| 2 |
+
import sys
|
| 3 |
+
sys.path.insert(0, ".")
|
| 4 |
+
|
| 5 |
+
from engine.order_book import OrderBook, Order, Side, Trade
|
| 6 |
+
from engine.market_state import market_state_to_string
|
| 7 |
+
from engine.metrics import MetricsEngine, TickMetrics
|
| 8 |
+
from agents.base_agent import BaseAgent, AgentState
|
| 9 |
+
from agents.momentum_agent import MomentumAgent
|
| 10 |
+
from agents.mean_reversion_agent import MeanReversionAgent
|
| 11 |
+
from agents.fundamental_agent import FundamentalAgent
|
| 12 |
+
from agents.market_maker_agent import MarketMakerAgent
|
| 13 |
+
from agents.noise_trader import NoiseTrader
|
| 14 |
+
from inference.prompt_templates import MOMENTUM_CHARTER, get_fundamental_charter
|
| 15 |
+
from inference.vllm_client import VLLMClient, parse_llm_output
|
| 16 |
+
|
| 17 |
+
# Instantiate each agent
|
| 18 |
+
agents = [
|
| 19 |
+
MomentumAgent("mom_1"),
|
| 20 |
+
MomentumAgent("mom_2"),
|
| 21 |
+
MeanReversionAgent("mr_1"),
|
| 22 |
+
FundamentalAgent("fund_1", fair_value=100.0),
|
| 23 |
+
MarketMakerAgent("mm_1"),
|
| 24 |
+
NoiseTrader("noise_1"),
|
| 25 |
+
]
|
| 26 |
+
for a in agents:
|
| 27 |
+
print(f" {a.agent_type:15s} id={a.agent_id}")
|
| 28 |
+
|
| 29 |
+
# Verify fundamental charter has fair value
|
| 30 |
+
f = FundamentalAgent("f_test", fair_value=42.50)
|
| 31 |
+
assert "42.50" in f.charter, f"Fair value not in charter: {f.charter[:80]}"
|
| 32 |
+
print(" Fundamental charter injection: OK")
|
| 33 |
+
|
| 34 |
+
# Test JSON parser
|
| 35 |
+
assert parse_llm_output('{"action":"buy","price":99.5,"quantity":3}') is not None
|
| 36 |
+
assert parse_llm_output('```json\n{"action":"sell","price":101,"quantity":5}\n```') is not None
|
| 37 |
+
assert parse_llm_output("garbage") is None
|
| 38 |
+
assert parse_llm_output('{"action":"hold"}')["action"] == "hold"
|
| 39 |
+
print(" LLM output parser: OK")
|
| 40 |
+
|
| 41 |
+
# Test market state serializer
|
| 42 |
+
book = OrderBook()
|
| 43 |
+
book.set_tick(1)
|
| 44 |
+
book.submit_order(Order("b1", Side.BUY, 99.0, 5, 1))
|
| 45 |
+
book.submit_order(Order("s1", Side.SELL, 101.0, 5, 2))
|
| 46 |
+
state_str = market_state_to_string(book, "m1", 0, 10000.0, [100.0, 99.5, 100.2])
|
| 47 |
+
assert "Best Bid: 99.00" in state_str
|
| 48 |
+
assert "Best Ask: 101.00" in state_str
|
| 49 |
+
assert "Your Position: 0 units" in state_str
|
| 50 |
+
print(" Market state serializer: OK")
|
| 51 |
+
|
| 52 |
+
print("\nAll Phase 2 checks passed.")
|
tests/test_order_book.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for the CDA Order Book engine.
|
| 3 |
+
|
| 4 |
+
Tests per Phase 1 spec:
|
| 5 |
+
- Crossing orders execute correctly
|
| 6 |
+
- Non-crossing orders rest in book
|
| 7 |
+
- Price-time priority
|
| 8 |
+
- Partial fills
|
| 9 |
+
- Trade log accuracy
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import sys
|
| 13 |
+
import os
|
| 14 |
+
|
| 15 |
+
# Add parent to path so we can import marketmind
|
| 16 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
| 17 |
+
|
| 18 |
+
from engine.order_book import OrderBook, Order, Side, Trade
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_non_crossing_orders_rest():
|
| 22 |
+
"""Non-crossing orders should rest in the book without matching."""
|
| 23 |
+
book = OrderBook()
|
| 24 |
+
book.set_tick(1)
|
| 25 |
+
|
| 26 |
+
# Bid at 99, Ask at 101 — no cross
|
| 27 |
+
bid = Order(agent_id="agent_a", side=Side.BUY, price=99.0, quantity=5, timestamp=1)
|
| 28 |
+
ask = Order(agent_id="agent_b", side=Side.SELL, price=101.0, quantity=5, timestamp=1)
|
| 29 |
+
|
| 30 |
+
trades_bid = book.submit_order(bid)
|
| 31 |
+
trades_ask = book.submit_order(ask)
|
| 32 |
+
|
| 33 |
+
assert trades_bid == [], "Non-crossing bid should not produce trades"
|
| 34 |
+
assert trades_ask == [], "Non-crossing ask should not produce trades"
|
| 35 |
+
assert book.best_bid == 99.0
|
| 36 |
+
assert book.best_ask == 101.0
|
| 37 |
+
assert book.mid_price == 100.0
|
| 38 |
+
assert book.spread == 2.0
|
| 39 |
+
assert len(book.bids) == 1
|
| 40 |
+
assert len(book.asks) == 1
|
| 41 |
+
print("✓ test_non_crossing_orders_rest")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_crossing_orders_execute():
|
| 45 |
+
"""When a buy crosses the ask, a trade should execute."""
|
| 46 |
+
book = OrderBook()
|
| 47 |
+
book.set_tick(1)
|
| 48 |
+
|
| 49 |
+
# Resting ask at 100
|
| 50 |
+
ask = Order(agent_id="seller", side=Side.SELL, price=100.0, quantity=5, timestamp=1)
|
| 51 |
+
book.submit_order(ask)
|
| 52 |
+
|
| 53 |
+
# Incoming buy at 100 — crosses the ask
|
| 54 |
+
buy = Order(agent_id="buyer", side=Side.BUY, price=100.0, quantity=5, timestamp=2)
|
| 55 |
+
trades = book.submit_order(buy)
|
| 56 |
+
|
| 57 |
+
assert len(trades) == 1
|
| 58 |
+
t = trades[0]
|
| 59 |
+
assert t.price == 100.0
|
| 60 |
+
assert t.quantity == 5
|
| 61 |
+
assert t.buyer_id == "buyer"
|
| 62 |
+
assert t.seller_id == "seller"
|
| 63 |
+
assert t.aggressor_side == Side.BUY
|
| 64 |
+
# Both sides fully filled — book should be empty
|
| 65 |
+
assert len(book.bids) == 0
|
| 66 |
+
assert len(book.asks) == 0
|
| 67 |
+
print("✓ test_crossing_orders_execute")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_partial_fill():
|
| 71 |
+
"""A larger buy should partially fill against a smaller ask, with residual resting."""
|
| 72 |
+
book = OrderBook()
|
| 73 |
+
book.set_tick(1)
|
| 74 |
+
|
| 75 |
+
# Resting ask: 3 units at 100
|
| 76 |
+
ask = Order(agent_id="seller", side=Side.SELL, price=100.0, quantity=3, timestamp=1)
|
| 77 |
+
book.submit_order(ask)
|
| 78 |
+
|
| 79 |
+
# Incoming buy: 5 units at 100 — should fill 3, rest 2
|
| 80 |
+
buy = Order(agent_id="buyer", side=Side.BUY, price=100.0, quantity=5, timestamp=2)
|
| 81 |
+
trades = book.submit_order(buy)
|
| 82 |
+
|
| 83 |
+
assert len(trades) == 1
|
| 84 |
+
assert trades[0].quantity == 3
|
| 85 |
+
assert len(book.asks) == 0 # ask fully consumed
|
| 86 |
+
assert len(book.bids) == 1 # residual buy rests
|
| 87 |
+
assert book.bids[0].quantity == 2
|
| 88 |
+
assert book.bids[0].agent_id == "buyer"
|
| 89 |
+
print("✓ test_partial_fill")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_price_priority():
|
| 93 |
+
"""Best price should match first (highest bid, lowest ask)."""
|
| 94 |
+
book = OrderBook()
|
| 95 |
+
book.set_tick(1)
|
| 96 |
+
|
| 97 |
+
# Two bids at different prices
|
| 98 |
+
bid_low = Order(agent_id="bidder_low", side=Side.BUY, price=98.0, quantity=5, timestamp=1)
|
| 99 |
+
bid_high = Order(agent_id="bidder_high", side=Side.BUY, price=100.0, quantity=5, timestamp=2)
|
| 100 |
+
book.submit_order(bid_low)
|
| 101 |
+
book.submit_order(bid_high)
|
| 102 |
+
|
| 103 |
+
assert book.best_bid == 100.0, f"Best bid should be 100, got {book.best_bid}"
|
| 104 |
+
|
| 105 |
+
# Incoming sell at 99 — should match the 100 bid (better price), not the 98 bid
|
| 106 |
+
sell = Order(agent_id="seller", side=Side.SELL, price=99.0, quantity=3, timestamp=3)
|
| 107 |
+
trades = book.submit_order(sell)
|
| 108 |
+
|
| 109 |
+
assert len(trades) == 1
|
| 110 |
+
assert trades[0].buyer_id == "bidder_high"
|
| 111 |
+
assert trades[0].price == 100.0 # fills at passive order's price
|
| 112 |
+
assert trades[0].quantity == 3
|
| 113 |
+
print("✓ test_price_priority")
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_time_priority():
|
| 117 |
+
"""At the same price level, earlier orders should fill first (FIFO)."""
|
| 118 |
+
book = OrderBook()
|
| 119 |
+
book.set_tick(1)
|
| 120 |
+
|
| 121 |
+
# Two asks at same price, different timestamps
|
| 122 |
+
ask_early = Order(agent_id="early_seller", side=Side.SELL, price=100.0, quantity=5, timestamp=1)
|
| 123 |
+
ask_late = Order(agent_id="late_seller", side=Side.SELL, price=100.0, quantity=5, timestamp=2)
|
| 124 |
+
book.submit_order(ask_early)
|
| 125 |
+
book.submit_order(ask_late)
|
| 126 |
+
|
| 127 |
+
# Buy 3 at 100 — should match the earlier ask
|
| 128 |
+
buy = Order(agent_id="buyer", side=Side.BUY, price=100.0, quantity=3, timestamp=3)
|
| 129 |
+
trades = book.submit_order(buy)
|
| 130 |
+
|
| 131 |
+
assert len(trades) == 1
|
| 132 |
+
assert trades[0].seller_id == "early_seller"
|
| 133 |
+
print("✓ test_time_priority")
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def test_multi_level_fill():
|
| 137 |
+
"""A large aggressive order should sweep through multiple price levels."""
|
| 138 |
+
book = OrderBook()
|
| 139 |
+
book.set_tick(1)
|
| 140 |
+
|
| 141 |
+
# Ask book: 3 @ 100, 5 @ 101, 2 @ 102
|
| 142 |
+
book.submit_order(Order("s1", Side.SELL, 100.0, 3, 1))
|
| 143 |
+
book.submit_order(Order("s2", Side.SELL, 101.0, 5, 2))
|
| 144 |
+
book.submit_order(Order("s3", Side.SELL, 102.0, 2, 3))
|
| 145 |
+
|
| 146 |
+
# Buy 7 @ 102 — should eat through 100 and 101 levels
|
| 147 |
+
buy = Order(agent_id="buyer", side=Side.BUY, price=102.0, quantity=7, timestamp=4)
|
| 148 |
+
trades = book.submit_order(buy)
|
| 149 |
+
|
| 150 |
+
assert len(trades) == 2
|
| 151 |
+
assert trades[0].price == 100.0 and trades[0].quantity == 3 # first level
|
| 152 |
+
assert trades[1].price == 101.0 and trades[1].quantity == 4 # partial second level
|
| 153 |
+
|
| 154 |
+
# s2 should have 1 unit remaining at 101
|
| 155 |
+
assert len(book.asks) == 2
|
| 156 |
+
assert book.asks[0].price == 101.0
|
| 157 |
+
assert book.asks[0].quantity == 1
|
| 158 |
+
assert book.asks[1].price == 102.0
|
| 159 |
+
# No residual buy (7 filled: 3 + 4)
|
| 160 |
+
assert len(book.bids) == 0
|
| 161 |
+
print("✓ test_multi_level_fill")
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def test_trade_log():
|
| 165 |
+
"""Trade log should accumulate all executed trades."""
|
| 166 |
+
book = OrderBook()
|
| 167 |
+
book.set_tick(1)
|
| 168 |
+
|
| 169 |
+
book.submit_order(Order("s1", Side.SELL, 100.0, 5, 1))
|
| 170 |
+
book.submit_order(Order("b1", Side.BUY, 100.0, 3, 2))
|
| 171 |
+
|
| 172 |
+
book.set_tick(2)
|
| 173 |
+
book.submit_order(Order("s2", Side.SELL, 99.0, 2, 3))
|
| 174 |
+
book.submit_order(Order("b2", Side.BUY, 99.0, 2, 4))
|
| 175 |
+
|
| 176 |
+
assert len(book.trade_log) == 2
|
| 177 |
+
assert book.trade_log[0].tick == 1
|
| 178 |
+
assert book.trade_log[1].tick == 2
|
| 179 |
+
print("✓ test_trade_log")
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def test_snapshot():
|
| 183 |
+
"""Snapshot should return correct book state."""
|
| 184 |
+
book = OrderBook()
|
| 185 |
+
book.set_tick(1)
|
| 186 |
+
|
| 187 |
+
book.submit_order(Order("b1", Side.BUY, 99.0, 10, 1))
|
| 188 |
+
book.submit_order(Order("s1", Side.SELL, 101.0, 8, 2))
|
| 189 |
+
|
| 190 |
+
snap = book.snapshot()
|
| 191 |
+
assert snap["best_bid"] == 99.0
|
| 192 |
+
assert snap["best_ask"] == 101.0
|
| 193 |
+
assert snap["mid_price"] == 100.0
|
| 194 |
+
assert snap["spread"] == 2.0
|
| 195 |
+
assert snap["bid_depth"] == 10
|
| 196 |
+
assert snap["ask_depth"] == 8
|
| 197 |
+
assert snap["last_trade_price"] is None # no trades yet
|
| 198 |
+
print("✓ test_snapshot")
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def test_cancel_agent_orders():
|
| 202 |
+
"""Canceling an agent's orders should remove only their orders."""
|
| 203 |
+
book = OrderBook()
|
| 204 |
+
book.set_tick(1)
|
| 205 |
+
|
| 206 |
+
book.submit_order(Order("a1", Side.BUY, 99.0, 5, 1))
|
| 207 |
+
book.submit_order(Order("a2", Side.BUY, 98.0, 5, 2))
|
| 208 |
+
book.submit_order(Order("a1", Side.SELL, 102.0, 5, 3))
|
| 209 |
+
|
| 210 |
+
book.cancel_agent_orders("a1")
|
| 211 |
+
|
| 212 |
+
assert len(book.bids) == 1
|
| 213 |
+
assert book.bids[0].agent_id == "a2"
|
| 214 |
+
assert len(book.asks) == 0
|
| 215 |
+
print("✓ test_cancel_agent_orders")
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def test_self_trade_prevention_not_required():
|
| 219 |
+
"""
|
| 220 |
+
Note: The spec doesn't require self-trade prevention.
|
| 221 |
+
Documenting that an agent CAN match against itself (this is fine in a simulation).
|
| 222 |
+
"""
|
| 223 |
+
book = OrderBook()
|
| 224 |
+
book.set_tick(1)
|
| 225 |
+
|
| 226 |
+
book.submit_order(Order("same_agent", Side.SELL, 100.0, 5, 1))
|
| 227 |
+
trades = book.submit_order(Order("same_agent", Side.BUY, 100.0, 3, 2))
|
| 228 |
+
|
| 229 |
+
# Self-trade is allowed in this simulation
|
| 230 |
+
assert len(trades) == 1
|
| 231 |
+
assert trades[0].buyer_id == "same_agent"
|
| 232 |
+
assert trades[0].seller_id == "same_agent"
|
| 233 |
+
print("✓ test_self_trade (allowed in simulation)")
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
if __name__ == "__main__":
|
| 237 |
+
test_non_crossing_orders_rest()
|
| 238 |
+
test_crossing_orders_execute()
|
| 239 |
+
test_partial_fill()
|
| 240 |
+
test_price_priority()
|
| 241 |
+
test_time_priority()
|
| 242 |
+
test_multi_level_fill()
|
| 243 |
+
test_trade_log()
|
| 244 |
+
test_snapshot()
|
| 245 |
+
test_cancel_agent_orders()
|
| 246 |
+
test_self_trade_prevention_not_required()
|
| 247 |
+
print("\n✅ All order book tests passed.")
|