ARKAISW commited on
Commit
10150dc
·
0 Parent(s):

Initial MarketMind commit (Corrected Author)

Browse files
.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.")