Research·Market Simulation·Advanced

Agent Based Market Sim

Build an agent-based artificial market simulation populated with heterogeneous trading agents following diverse strategies to generate realistic emergent macro-level market dynamics and statistical properties from micro-level agent interaction rules for strategy robustness testing across market regimes.

quant-researchsimulation

Agent-Based Market Simulation — Research & Experimentation

Category: Research & Experimentation | Subcategory: Simulation


What This Notebook Does

Agent-based models (ABMs) simulate financial markets as the aggregate outcome of many individual agents — each following simple rules — rather than assuming the market reaches an equilibrium price. This bottom-up approach can reproduce emergent market phenomena that top-down models miss, including:

  • Fat-tailed return distributions: even agents with Gaussian noise produce crashes when herding behaviour kicks in
  • Volatility clustering: periods of high volatility cluster together because momentum agents amplify small moves
  • Flash crashes: a sudden liquidity vacuum created by simultaneous stop-loss triggers
  • Market microstructure effects: bid-ask spreads emerging from order flow

This notebook implements a simplified ABM with three agent types that represent common participant archetypes in crypto markets:

  1. Fundamentalists: buy when price is below a fundamental value, sell when above — they provide mean-reversion force
  2. Trend followers (chartists): buy when price is rising, sell when falling — they amplify momentum
  3. Noise traders: random order flow with no signal — they add volatility and prevent the market from being too predictable

This notebook:

  1. Defines all three agent types with parameterised behaviour rules
  2. Runs the market simulation for N_STEPS time steps
  3. Analyses the resulting price process for stylised facts
  4. Tests how changing agent composition affects market dynamics
  5. Visualises price path, return distribution, and agent activity
  6. Exports simulation results
[1]
!pip install numpy pandas matplotlib seaborn scipy --quiet
[2]
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats
import warnings

warnings.filterwarnings('ignore')
%matplotlib inline
plt.rcParams['figure.figsize'] = (14, 5)
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.spines.right'] = False
print('Imports ready.')
Imports ready.

Section 1 — Configuration

N_FUNDAMENTALISTS, N_TREND_FOLLOWERS, and N_NOISE_TRADERS determine the market composition. The ratio of these groups dramatically affects emergent dynamics: a market with many trend followers will have stronger momentum and more extreme crashes; a fundamentalist-dominated market is more efficient. FUNDAMENTAL_VALUE is the intrinsic value fundamentalists target.

[3]
N_STEPS             = 1000   # simulation steps (trading days)
N_FUNDAMENTALISTS   = 50
N_TREND_FOLLOWERS   = 100
N_NOISE_TRADERS     = 80
INITIAL_PRICE       = 30_000
FUNDAMENTAL_VALUE   = 32_000  # fundamentalists anchor on this

# Agent strength parameters
FUND_STRENGTH   = 0.003   # how hard fundamentalists push price toward fundamental value
TREND_LOOKBACK  = 5       # trend followers look back this many steps
TREND_STRENGTH  = 0.002   # how aggressively trend followers chase momentum
NOISE_SCALE     = 0.010   # noise trader daily order size

SEED = 42
print(f'Agents: {N_FUNDAMENTALISTS} fundamentalists, {N_TREND_FOLLOWERS} trend followers, '
      f'{N_NOISE_TRADERS} noise traders')
Agents: 50 fundamentalists, 100 trend followers, 80 noise traders

Section 2 — Agent Definitions

Each agent type is a function that takes the current price history and returns an order (positive = buy, negative = sell, measured in units of price impact). The fundamentalist order is proportional to the gap between current price and fundamental value. The trend follower order is proportional to the recent N-step price change. The noise trader places a random order with no information.

Agent Function Explanations

  • fundamentalist_order(price_history, fundamental, strength, rng): This function simulates a fundamentalist agent. It calculates an order based on the difference between the current market price and a predefined fundamental value. If the price is below the fundamental value, the agent places a buy order; if it's above, a sell order. The strength parameter controls how aggressively the agent pushes the price towards the fundamental value, and rng introduces a small amount of random noise to the order.

  • trend_follower_order(price_history, lookback, strength, rng): This function represents a trend-following agent. It examines the price movement over a specified lookback period to determine momentum. If the price has been rising, the agent places a buy order to follow the trend; if falling, a sell order. The strength dictates the aggressiveness of the trend-following behavior, and rng adds a random component.

  • noise_trader_order(scale, rng): This function models a noise trader. These agents place random buy or sell orders with no underlying signal or strategy. The scale parameter determines the typical size of these random orders, and rng generates the random order. Noise traders introduce unpredictable fluctuations into the market.

[4]
def fundamentalist_order(price_history, fundamental, strength, rng):
    """
    Order from a fundamentalist agent: buy if below fundamental value, sell if above.

    Parameters
    ----------
    price_history : list    Recent prices.
    fundamental   : float   Fundamental value.
    strength      : float   Order scaling factor.
    rng           : Generator  Random number generator (for noise).

    Returns
    -------
    float  Signed order (positive=buy, negative=sell).
    """
    price = price_history[-1]
    gap   = (fundamental - price) / fundamental
    return strength * gap * (1 + rng.standard_normal() * 0.2)


def trend_follower_order(price_history, lookback, strength, rng):
    """
    Order from a trend-following agent: extrapolate recent price momentum.

    Parameters
    ----------
    price_history : list    Recent prices (must have >= lookback+1 elements).
    lookback      : int     Window for momentum signal.
    strength      : float   Order scaling factor.
    rng           : Generator  Random number generator (for noise).

    Returns
    -------
    float  Signed order.
    """
    if len(price_history) <= lookback:
        return rng.standard_normal() * strength * 0.1
    past_return = (price_history[-1] - price_history[-lookback-1]) / price_history[-lookback-1]
    return strength * past_return * (1 + rng.standard_normal() * 0.3)


def noise_trader_order(scale, rng):
    """
    Random order from a noise trader.

    Parameters
    ----------
    scale : float     Standard deviation of order size.
    rng   : Generator  Random number generator.

    Returns
    -------
    float  Signed random order.
    """
    return rng.standard_normal() * scale


print('Agent functions defined.')
Agent functions defined.

Section 3 — Market Simulation Loop

At each time step: (1) each agent places an order, (2) all orders are aggregated into net order flow, (3) price is updated as P_new = P_old × (1 + net_flow). This is a simplified Walrasian price-clearing mechanism — positive net flow pushes price up, negative flow pushes it down. We also record the order contribution of each agent type to analyse market dynamics.

[5]
rng = np.random.default_rng(SEED)
prices       = [INITIAL_PRICE]
fund_flow    = []
trend_flow   = []
noise_flow   = []

for t in range(N_STEPS):
    # Aggregate orders from all agents
    f_total = sum(fundamentalist_order(prices, FUNDAMENTAL_VALUE, FUND_STRENGTH, rng)
                   for _ in range(N_FUNDAMENTALISTS))
    tr_total = sum(trend_follower_order(prices, TREND_LOOKBACK, TREND_STRENGTH, rng)
                    for _ in range(N_TREND_FOLLOWERS))
    n_total  = sum(noise_trader_order(NOISE_SCALE, rng)
                    for _ in range(N_NOISE_TRADERS))

    net_flow = f_total + tr_total + n_total
    new_price = prices[-1] * (1 + net_flow)
    prices.append(max(new_price, 1.0))  # price cannot go negative

    fund_flow.append(f_total)
    trend_flow.append(tr_total)
    noise_flow.append(n_total)

idx    = pd.date_range('2022-01-01', periods=N_STEPS+1, freq='B')
prices_s = pd.Series(prices, index=idx)
returns  = prices_s.pct_change().dropna()
print(f'Simulation complete: {N_STEPS} steps')
print(f'Ann Vol: {returns.std()*np.sqrt(252):.1%}  |  Kurtosis: {stats.kurtosis(returns):.2f}  |  Skew: {stats.skew(returns):.2f}')
Simulation complete: 1000 steps
Ann Vol: 218.7%  |  Kurtosis: 3.16  |  Skew: 0.05

Section 4 — Stylised Facts Validation

A good market model should reproduce the stylised facts of real return series: fat tails (excess kurtosis > 0), negative skewness (crashes are worse than rallies), and volatility clustering (large moves cluster together). We test all three. Volatility clustering is measured by the autocorrelation of squared returns — significant positive autocorrelation in squared returns is the ARCH effect.

[6]
from statsmodels.stats.diagnostic import acorr_ljungbox

kurt   = stats.kurtosis(returns)
skewn  = stats.skew(returns)
lb_sq  = acorr_ljungbox(returns**2, lags=[10], return_df=True)['lb_pvalue'].values[0]

print('=== Stylised Facts Check ===')
print(f'Excess Kurtosis:        {kurt:.2f}  (target > 0, fat tails)')
print(f'Skewness:               {skewn:.2f}  (negative = left tail)')
print(f'ARCH effect (LB p-val): {lb_sq:.4f}  (< 0.05 = volatility clustering)')
print(f'\nFat tails:          {"PASS" if kurt > 0 else "FAIL"}')
print(f'Vol clustering:     {"PASS" if lb_sq < 0.05 else "FAIL"}')
=== Stylised Facts Check ===
Excess Kurtosis:        3.16  (target > 0, fat tails)
Skewness:               0.05  (negative = left tail)
ARCH effect (LB p-val): 0.0000  (< 0.05 = volatility clustering)

Fat tails:          PASS
Vol clustering:     PASS

Section 5 — Visualisation

The left panel shows the simulated price path with stacked bars showing each agent type's contribution to order flow — this reveals when trend followers dominate (sustained uptrends) vs when fundamentalists prevail (sharp reversals back to fair value). The right panel compares the return distribution to a fitted normal curve, showing the fat tails produced by the model.

[7]
fig, axes = plt.subplots(2, 2, figsize=(14, 9))
fig.suptitle('Agent-Based Market Simulation', fontsize=13, fontweight='bold')

ax1 = axes[0, 0]
ax1.plot(prices_s.index, prices_s, color='#1976d2', lw=1.5)
ax1.axhline(FUNDAMENTAL_VALUE, color='red', ls='--', lw=1, label=f'Fundamental = {FUNDAMENTAL_VALUE:,.0f}')
ax1.set_ylabel('Price'); ax1.legend(fontsize=8)
ax1.set_title('Simulated Price Path')

ax2 = axes[0, 1]
t_range = range(min(200, N_STEPS))
ax2.fill_between(t_range, fund_flow[:200],  alpha=0.7, color='#43a047', label='Fundamentalists')
ax2.fill_between(t_range, trend_flow[:200], alpha=0.7, color='#e53935', label='Trend followers')
ax2.fill_between(t_range, noise_flow[:200], alpha=0.5, color='#9e9e9e', label='Noise traders')
ax2.axhline(0, color='black', lw=0.8, ls='--')
ax2.set_ylabel('Aggregate Order Flow')
ax2.legend(fontsize=8, loc='upper right')
ax2.set_title('Agent Order Flow (First 200 Steps)')

ax3 = axes[1, 0]
ax3.hist(returns, bins=60, density=True, color='#1976d2', alpha=0.7, label='Simulated')
x = np.linspace(returns.min(), returns.max(), 200)
ax3.plot(x, stats.norm.pdf(x, returns.mean(), returns.std()),
          color='black', lw=2, ls='--', label='Normal fit')
ax3.set_xlabel('Daily Return')
ax3.legend(fontsize=8)
ax3.set_title(f'Return Distribution (Kurt={kurt:.2f})')

ax4 = axes[1, 1]
roll_vol = returns.rolling(21).std() * np.sqrt(252)
ax4.plot(roll_vol.index, roll_vol, color='#fb8c00', lw=1.5)
ax4.set_ylabel('Rolling 21d Ann. Vol')
ax4.set_title('Volatility Clustering')

plt.tight_layout(); plt.show()
cell output

Section 6 — Export

Save the simulated price path and agent order flow time series. The order flow data is useful for microstructure research — for example, measuring the correlation between trend-follower order flow and next-period price change.

[8]
sim_out = pd.DataFrame({
    'price': prices_s.iloc[1:],
    'return': returns,
    'fund_flow': fund_flow,
    'trend_flow': trend_flow,
    'noise_flow': noise_flow
})
sim_out.to_csv('agent_based_market_sim.csv')
print('Saved: agent_based_market_sim.csv')
Saved: agent_based_market_sim.csv

Conclusion

This notebook demonstrates a simple agent-based market simulation, showcasing how emergent market phenomena can arise from the interactions of different agent types. By adjusting the composition and parameters of fundamentalists, trend followers, and noise traders, one can observe various market behaviors, including fat tails and volatility clustering.