This repository contains the source code for a trading simulation environment powered by Large Language Models (LLMs).
This project simulates a financial market where agents, powered by LLMs, make trading decisions.
This repository accompanies the research paper:
"Can Large Language Models Trade? Testing Financial Theories with LLM Agents in Market Simulations"
Author: Alejandro Lopez-Lira (University of Florida - Department of Finance, Insurance and Real Estate)
Date: April 14, 2025
This paper presents a realistic simulated stock market where large language models (LLMs) act as heterogeneous competing trading agents. The open-source framework incorporates a persistent order book with market and limit orders, partial fills, dividends, and equilibrium clearing alongside agents with varied strategies, information sets, and endowments. Agents submit standardized decisions using structured outputs and function calls while expressing their reasoning in natural language. Three findings emerge: First, LLMs demonstrate consistent strategy adherence and can function as value investors, momentum traders, or market makers per their instructions. Second, market dynamics exhibit features of real financial markets, including price discovery, bubbles, underreaction, and strategic liquidity provision. Third, the framework enables analysis of LLMs' responses to varying market conditions, similar to partial dependence plots in machine-learning interpretability. The framework allows simulating financial theories without closed-form solutions, creating experimental designs that would be costly with human participants, and establishing how prompts can generate correlated behaviors affecting market stability.
@article{lopez2025llm,
title={Can Large Language Models Trade? Testing Financial Theories with LLM Agents in Market Simulations},
author={Lopez-Lira, Alejandro},
year={2025},
month={April},
day={14},
url={https://ssrn.com/abstract=5217340},
doi={10.2139/ssrn.5217340}
}Available at SSRN: https://ssrn.com/abstract=5217340
Keywords: LLM Agents, Agent-Based Markets, Experimental Finance, AI Trading, Multi-Agent Systems
-
Clone the repository:
git clone https://github.com/alejandroll10/llm_trading_sim.git cd llm_trading_sim -
Create a virtual environment: It is highly recommended to use a virtual environment. For example, with conda:
conda create -n llm_trading python=3.11 conda activate llm_trading
-
Install dependencies:
pip install -r requirements.txt
For development (running the test suite, call-graph utilities), install the package in editable mode with the dev extras instead:
pip install -e .[dev]
-
Configure LLM Provider:
Step 1 - Add API Key:
Create a
.envfile in the project root:OPENAI_API_KEY="sk-..."- UF Hypergator: Get virtual key from https://api.ai.it.ufl.edu/ui/
- OpenAI: Get API key from https://platform.openai.com/api-keys
- DeepInfra: Set
DEEPINFRA_TOKENinstead ofOPENAI_API_KEY
Step 2 - Select Endpoint & Model:
Copy the example config and edit it (the copy is gitignored, so your local settings never end up in a commit):
cp src/llm_config.example.py src/llm_config.py
Option A - UF Hypergator (Free for UF users):
LLM_BASE_URL = "https://api.ai.it.ufl.edu/v1" LLM_MODEL = "gpt-oss-120b"
Option B - OpenAI (Paid service):
LLM_BASE_URL = None # None = use OpenAI's default endpoint LLM_MODEL = "gpt-4o-2024-11-20"
Any OpenAI-compatible endpoint works (DeepInfra, local vLLM, ...); see
src/llm_config.example.pyfor more options. Ifsrc/llm_config.pydoes not exist, the UF Hypergator defaults are used.Verified Working Models:
- UF Hypergator:
llama-3.1-70b-instruct✅,llama-3.3-70b-instruct✅,gpt-oss-20b✅,gpt-oss-120b✅ - OpenAI:
gpt-4o✅,gpt-4o-2024-11-20✅
Note: Smaller models like
llama-3.1-8b-instructfail structured output validation. Use 70B+ models for reliable results.
To run the simulation, you can execute the run_base_sim.py script from the src/ directory.
-
List Available Scenarios: To see a list of all available scenarios and their descriptions, run:
python3 src/run_base_sim.py --list
-
Run a Specific Scenario: To run a specific scenario, provide its name as a command-line argument. For example, to run the
price_discovery_above_fundamentalscenario:python3 src/run_base_sim.py price_discovery_above_fundamental
If you run the script without any arguments, it will display a list of available scenario names.
Simulation results, including plots and data, will be saved in the
logs/directory. -
Run a Robustness Sweep:
src/run_sweep.pyruns one scenario across a grid of seeds × temperatures × models × prompt/param variants, with a resumable manifest and a pre-launch cost estimate.src/aggregate_sweep.pycollects the per-cell CSVs into tidy panels withcell_id,seed,temperature,model,variant, andprompt_familymetadata columns (prompt_familyis the clustering key for inference — runs sharing a prompt family are not independent draws).# Sweep seeds x temperatures, then aggregate python3 src/run_sweep.py simple_mixed_traders --seeds 42 7 13 --temperatures 0.0 0.5 --yes python3 src/aggregate_sweep.py logs/sweeps/<sweep_name>
Prompt-family variant packs (
sweeps/variants/): checked-in JSON packs for robustness across prompt wordings, consumed via--variants-file. Each pack has one unmodified control plus variants that setSYSTEM_PROMPT_OVERRIDES(agent type → replacement system prompt) and optionallyFUNDAMENTAL_INFO_MODE:paraphrases_<persona>.json— 4 semantically equivalent paraphrases each fordefault,value,momentum,market_maker,optimistic,profit_maximizerpersona_families.json— index-matched paraphrases applied to all six personas at once (for mixed compositions)framing_advisor.json— trader vs. financial-advisor framing × explicit vs. masked economic context (advisor rewrites exist only for the six workhorse personas, so use compositions drawn from those)objective_framing_a3.json— the same value strategy under different stated objectives (maximize wealth / follow the strategy even at a loss / maximize risk-adjusted return / no objective)
python3 src/run_sweep.py prompt_variant_smoke --variants-file sweeps/variants/paraphrases_value.json --dry-run
The packs are generated artifacts — edit
scripts/generate_prompt_variant_packs.py(the single source of truth) and rerun it;tests/test_prompt_variants.pyfails if the JSON drifts from the generator. -
Run the Estimators: Each module under
src/analysis/reads the aggregated panels and writes tables + figures intologs/sweeps/<sweep_name>/aggregated/. Every estimate is reported as a distribution across the seed × temperature grid withprompt_familyandmodelpreserved, and regressions use cluster-robust standard errors.python3 src/analysis/a1_estimators.py logs/sweeps/<sweep_name> # valuation error, anchoring, learning curve python3 src/analysis/belief_action_estimators.py logs/sweeps/<sweep_name> # price/direction coherence, forecast skill, belief-message divergence python3 src/analysis/impact_estimators.py logs/sweeps/<sweep_name> # price-impact lambdas + size coherence
impact_estimators.pyadditionally parsesdata/rendered_prompts.jsonl— the exact per-round prompt each LLM agent received — to recover the order book the agent actually saw, including any per-agent depth truncation. Runs recorded before that logging existed still produce every other estimate; only the seen-book benchmark is skipped.
The simulation operates in discrete rounds. The following steps occur in each round:
- Update Market State: The simulation updates the market context, including the fundamental price and any potential dividend payments for the upcoming round.
- Collect Agent Decisions: Each agent analyzes the current market state and their own internal state to decide whether to place a buy, sell, or hold order.
- Match Orders: The matching engine resolves the collected orders, executing trades and determining the new market price.
- Record Round Data: All data from the round, including trades, prices, and agent decisions, is recorded.
- Pay Dividends/Interest: Any scheduled dividends or interest payments are distributed to the agents.
This lifecycle is orchestrated by the execute_round method in src/base_sim.py.
Round lifecycle hooks: custom behavior can attach to the lifecycle without
editing the orchestrator. Register callables on a simulation instance before
calling run(); they are invoked as hook(sim, round_number):
sim = BaseSimulation(...)
sim.register_before_round(lambda sim, r: ...) # runs before each round
sim.register_after_round(lambda sim, r: ...) # runs after each round's updates
sim.run()- Order Book: Persistent order book with market and limit orders
- Price Discovery: Dynamic price formation through order matching
- Partial Fills: Orders can be partially executed
- Dividends: Configurable dividend payments
- Multi-Stock Support: Trade multiple securities simultaneously
The dividend process can change mid-run at scheduled round boundaries, enabling out-of-distribution robustness experiments: do agents' valuations update toward the new regime, and does the price re-converge?
Configuration:
"DIVIDEND_PARAMS": {
'base_dividend': 1.4,
'dividend_probability': 0.5,
'dividend_variation': 1.0,
# From round 5 (0-indexed) onward, base_dividend drops to 1.0.
# Each entry overrides the base params (entries do not stack).
'regime_schedule': [
{'round': 5, 'base_dividend': 1.0},
],
# Optional contrast cell: announce the shift to agents (default: silent)
'announce_regime_shifts': False,
}Conventions:
- The fundamental value follows the piecewise no-arbitrage path
FV_t = (E[d_t] + FV_{t+1}) / (1+r), computed per round and recorded inmarket_data.csv(fundamental_pricereflects the active regime). - Redemption (finite horizon) equals the terminal-regime fundamental
E[d_last]/r, so the fundamental is constant within the terminal regime segment. - Shifts are unannounced by default: use
FUNDAMENTAL_INFO_MODE = "realizations_only"so agents can only infer the change from realized dividends. Info modes that reveal model parameters truthfully show the active regime. Settingannounce_regime_shifts: trueadds a notice (without revealing the new parameters) to agents' dividend info from the shift round onward. - The simulation verifier checks every round that the recorded fundamental matches the scheduled path.
- Not yet supported in multi-stock mode.
Example Scenario: test_regime_shift - deterministic agents with an unannounced shift at round 5 (E[d] 1.4 → 1.0, fundamental 21.73 → 20.00)
Agents can borrow cash to amplify their long positions, enabling research on leveraged trading strategies and risk management.
Configuration:
scenario = {
"leverage_enabled": True,
"AGENT_PARAMS": {
'deterministic_params': {
'momentum_trader': {
'leverage_ratio': 2.0, # Allow 2x leverage
'initial_margin': 0.5, # 50% down payment required
'maintenance_margin': 0.25, # 25% minimum margin (liquidation threshold)
}
}
},
"leverage_interest_rate": 0.05, # 5% annual interest on borrowed cash
"cash_lending_pool": 1000000, # Optional: limit available lending pool
}Features:
- Automatic borrowing when placing orders beyond available cash
- Margin calls with forced liquidation when positions fall below maintenance margin
- Per-round interest charges on borrowed cash
- Full visibility for LLM agents (leverage metrics included in observations)
Example Scenarios:
test_leverage- Deterministic agents with 2x leveragetest_leverage_llm- LLM agents using leverage strategically
Agents can borrow shares to sell short, enabling research on bearish strategies and market dynamics.
Configuration:
scenario = {
"AGENT_PARAMS": {
'allow_short_selling': True,
'margin_requirement': 0.5, # 50% margin for shorts
},
"LENDABLE_SHARES": 10000, # Total shares available to borrow
}Features:
- Borrow shares from lending pool to sell short
- Margin calls when short positions become underwater
- Per-round borrowing fees
- Works with both single and multi-stock scenarios
The simulation supports multiple agent types with different trading strategies:
LLM Agent Types (prompt files in src/agents/prompts/, one .md per persona):
- default: Balanced, analytical trader
- speculator: Risk-seeking, momentum-focused
- optimistic: Bullish bias, sees upside potential
- pessimistic: Bearish bias, focuses on risks
- short_seller: Actively shorts overvalued assets
- leverage_trader: Uses maximum leverage for amplified returns
- long_short: Pairs trading - long undervalued, short overvalued simultaneously
Deterministic Agent Types (defined in src/agents/deterministic/):
- buy_trader / sell_trader: Simple directional traders
- margin_buyer: Uses leverage to buy aggressively
- multi_stock_buy_agent / multi_stock_sell_agent: Multi-stock traders
Agents can be configured with memory and social messaging capabilities:
"MEMORY_ENABLED": True, # Agents can write notes_to_self between rounds
"SOCIAL_ENABLED": True, # Agents can post messages to a shared feedWhen enabled, agents receive their previous notes and can read messages from other agents, enabling more sophisticated multi-round strategies and emergent social dynamics.
Run the unit test suite (no API key needed; also runs in CI on every push/PR):
pip install pytest # or: pip install -e .[dev]
pytest tests/ -qRun the health check script to verify all features work correctly end-to-end (this one runs real simulations):
# Quick test (single-stock scenarios only, ~5 minutes)
python scripts/health_check.py --quick
# Full test (all 8 systematic scenarios, ~15 minutes)
python scripts/health_check.py
# Verbose output
python scripts/health_check.py --verboseThe health check verifies:
- ✅ Trading execution (trades happen)
- ✅ Short selling (borrowed_shares > 0)
- ✅ Leverage (borrowed_cash > 0)
- ✅ Multi-stock mode
| Scenario | Leverage | Short Selling | Multi-Stock |
|---|---|---|---|
single_basic |
❌ | ❌ | ❌ |
single_short |
❌ | ✅ | ❌ |
single_leverage |
✅ | ❌ | ❌ |
single_leverage_short |
✅ | ✅ | ❌ |
multi_basic |
❌ | ❌ | ✅ |
multi_short |
❌ | ✅ | ✅ |
multi_leverage |
✅ | ❌ | ✅ |
multi_leverage_short |
✅ | ✅ | ✅ |
Scenarios live in the src/scenarios/ package and are auto-discovered —
no registration edits are needed. Scenario names must be unique across all
modules and config files (duplicates raise at import time). There are two
ways to add one:
Option A - Config file (no Python needed): drop a .yaml (or .json)
file into src/scenarios/configs/:
# src/scenarios/configs/my_custom_scenario.yaml
name: my_custom_scenario
description: "A custom scenario for testing a new agent type."
parameters: # deep-merged over DEFAULT_PARAMS
NUM_ROUNDS: 5
AGENT_PARAMS:
agent_composition:
value: 1
market_maker: 1See src/scenarios/configs/example_yaml_scenario.yaml for a working example
and src/scenarios/config_loader.py for the full format (including several
scenarios per file).
Option B - Python module: create (or extend) a module in src/scenarios/
that defines a module-level SCENARIOS dict. Build parameters with
merge_params, which deep-merges your overrides onto DEFAULT_PARAMS — you
only state what differs, and nested defaults are inherited instead of
restated:
# src/scenarios/my_scenarios.py
from .base import SimulationScenario, DEFAULT_PARAMS, merge_params
SCENARIOS = {
"my_custom_scenario": SimulationScenario(
name="my_custom_scenario",
description="A custom scenario for testing a new agent type.",
parameters=merge_params(DEFAULT_PARAMS, {
"NUM_ROUNDS": 5,
"AGENT_PARAMS": {
"agent_composition": {
"value": 1,
"market_maker": 1,
},
},
}),
),
}Notes on merge_params: nested dicts merge recursively, but
agent_composition and STOCKS replace wholesale (an override is a complete
specification, so default agent types don't leak in). Every type named in
agent_composition is validated against the agent registry at simulation
construction, so typos fail immediately with the list of known types.
After adding your scenario, run it by name as described above:
python3 src/run_base_sim.py my_custom_scenarioLLM personality (no code): drop a prompt file at
src/agents/prompts/<type_id>.md — the filename is the type string used in
agent_composition:
---
name: My Persona
---
You are a trader who ...Everything after the closing --- line is the system prompt, byte-exact
(minus the file's final newline). All persona files are loaded into the
AGENT_TYPES registry at import. Existing persona prompts are pinned by
sha256 in tests/test_persona_prompts.py because prompt text drives paper
results — if you deliberately edit a prompt, update its hash there in the
same commit (new personas just need an entry added).
Deterministic (rule-based) agent (two edits):
- Create a class under
src/agents/deterministic/subclassingBaseAgentand implementingmake_decision()(seebuy_agent.pyfor a template). - Register it in
DETERMINISTIC_AGENTSinsrc/agents/deterministic/deterministic_registry.py.
The unified registry (src/agents/registry.py) mirrors deterministic types
into AGENT_TYPES automatically — no third placeholder edit is needed.