Populated mode
By default nothing in a tradefloor market reacts to an agent. It trades against a market maker, latent depth and the model's own flow, so ten copies of a strategy each earn what one earns, and a programme that buys at the same minutes every day pays what a randomized one pays. Populated mode adds background traders to the same order book. They see the prices your agents see, pay the same costs and react to what your agents do, so a run can ask whether an edge survives other traders. It is in tradefloor 0.10.0 and later.
Pass a tf.Population as population= to tf.evaluate, tf.World or tf.Engine. Leave it out and the run is in isolated mode, the default, which is unchanged. Every code block on this page runs on its own.
Isolated and populated
In isolated mode every agent meets the same market to the bit, so a difference between two agents is a difference between their strategies. tf.evaluate and tf.rank have always worked this way, and a run without population= is the run it was before populated mode existed, on the same preset.
In populated mode the background traders react to each agent's trading, so the market an agent meets depends on what it does. The run is still reproducible: the same seed, universe, model and population give the same market. But two strategies in it no longer face identical markets, so a gap between them mixes strategy and reaction. tf.rank takes no population=, because its paired sign test needs every agent on the same market draw on each seed. Rank strategies in isolated mode and use populated mode to test one of them against other traders.
| Isolated (default) | Populated | |
|---|---|---|
| Background traders | none | the population you pass |
| Reproducible from seed and inputs | yes | yes |
| Agents meet identical markets | yes | no |
tf.rank | yes | refused |
Under tf.evaluate each agent trades alone with the population in its own copy of the market, and the untraded baseline gets a copy with the population too. In a World with several agents, the agents and the population share one market.
A first populated run
The same two baselines on the same seed, once in isolated mode and twice with the crowded population:
import tradefloor as tf
def entrants():
baselines = tf.reference_agents()
return {"momentum": baselines["momentum"],
"buy_and_hold": baselines["buy_and_hold"]}
universe = tf.Universe.random(20, seed=7)
crowded = tf.Population.crowded()
isolated = tf.evaluate(entrants(), seed=7, universe=universe, days=20)
populated = tf.evaluate(entrants(), seed=7, universe=universe, days=20,
population=crowded)
again = tf.evaluate(entrants(), seed=7, universe=universe, days=20,
population=crowded)
for name in ("momentum", "buy_and_hold"):
print(f"{name:13} isolated {isolated[name].pnl:>9,.0f} "
f"populated {populated[name].pnl:>9,.0f}")
print("reproducible:", populated["momentum"].as_dict() == again["momentum"].as_dict())
print("population:", populated["momentum"].population_fingerprint)momentum isolated -82,196 populated -77,150 buy_and_hold isolated -1,194 populated -1,217 reproducible: True population: pop-ccdb13174be4
The background traders move prices, so even buy-and-hold, which trades once, ends with a different P&L. Running the populated evaluation a second time gives the same scorecards. This is one seed over 20 days, so the gap between the two modes says nothing on its own about whether momentum does better or worse beside other traders.
The shipped populations
| Population | Participants |
|---|---|
Population.standard() | A trend follower on the five-day move, a mean reverter on the one-day move, a liquidity provider and a flow detector |
Population.crowded() | The standard four, with the trend follower deciding every 130 ticks and five competing flow detectors in place of one, plus a reversal crowd and three momentum crowd members |
Each participant sets a target position per name from what the market shows and trades toward it with market orders. Sizes are shares of the name's daily volume.
| Kind | Trades |
|---|---|
trend | Long a name that has risen over lookback sessions, in proportion to the move over its daily sigma, up to size |
reversion | The same signal turned round. At a lookback of one it trades the one-day reversal. |
liquidity | Leans against short moves while the VIX is calm, at full size at or below vix_calm (15) and not at all at or above vix_stress (35) |
detector | Learns the agents' net taker flow per name and minute of the session, buys lead ticks ahead of flow it expects and sells hold ticks after. It adds to a position only where the quoted spread is at most max_spread of the name's daily sigma. |
crowd | The ranked signal baselines.Momentum or baselines.MeanReversion trades, long the top_k best names and short the top_k worst. With stop above zero it sells out after a loss and buys back recover of its book each session. |
In the crowded population the reversal crowd checks the one-day reversal every 15 ticks, so it reaches a new loser before a rule that decides every 65 ticks. The momentum crowd trades the five-day momentum in the last 15 ticks before the close, so a rule that rebalances at the next open trades at the price the crowd left. The three momentum members carry loss limits spread from half to one and a half times stop, so a loss that stops one out can carry the others after it.
A participant acts at the start of a tick, before the market moves, and takes no random draw. Its orders take the market maker's levels and the latent depth, and can fill an agent's resting limit order, with population:<name> as the counterparty on the agent's fill. The background traders keep their own ledger, separate from any agent's.
The background traders' ledger
Engine.population_report() returns each participant's ledger: per name its position, cash, shares and dollars traded and P&L, then its totals. Here two baselines share one market with the standard population:
import tradefloor as tf
universe = tf.Universe.random(20, seed=7)
baselines = tf.reference_agents()
world = tf.World(seed=7, universe=universe,
agents={"momentum": baselines["momentum"],
"buy_and_hold": baselines["buy_and_hold"]},
population=tf.Population.standard())
world.run(20)
for row in world.engine.population_report():
print(f"{row['name']:10} orders {int(row['orders']):>5} "
f"traded ${row['notional'] / 1e6:>7,.1f}m P&L ${row['pnl'] / 1e6:>6,.2f}m")
manifest = tf.RunManifest.from_json(world.manifest().to_json())
print("manifest population:", manifest.population.fingerprint)
rebuilt = manifest.reproduce() # raises on any mismatch
print("reproduced:", rebuilt.state_hash() == world.engine.state_hash())trend orders 485 traded $1,257.6m P&L $ -2.30m reversion orders 1654 traded $3,631.9m P&L $ -8.96m liquidity orders 3023 traded $3,804.2m P&L $ -4.03m detector orders 0 traded $ 0.0m P&L $ 0.00m manifest population: pop-2196192b43ca reproduced: True
The trend follower, mean reverter and liquidity provider lost money over these 20 days. They trade with market orders, so each pays the spread on every trade. The detector sent no orders in this run. It trades only ahead of agent flow that recurs at the same minutes of each session, and only on names with a quoted spread narrow enough for max_spread.
Records and fingerprints
A population is immutable and has a fingerprint, pop- and twelve hex digits of a sha256 over its participants. The name is a label for people and is left out, so two populations with the same participants are the same market. A custom population is a list of participants:
import tradefloor as tf
from tradefloor.population import Participant
mine = tf.Population([Participant.trend(size=0.02), Participant.detector()],
name="mine")
print(mine)
print(mine == tf.Population(mine.participants, name="renamed"))Population('mine', pop-c97362658f40, [trend:trend, detector:detector])
TrueEvery record of a populated run carries the population:
| Record | What it holds |
|---|---|
Scorecard | population_fingerprint, empty for an isolated run |
Engine | The population_fingerprint property, population_spec() and population_report() |
RunManifest | population, the participants in full, and a population entry in fingerprints. reproduce() rebuilds the market with them. |
Checkpoint | population, so resume() and branch() rebuild with it |
An engine's state snapshot carries the population's state under its fingerprint, so a snapshot restores only into an engine built with the same population. A World fork carries the population's state too.
Measured effects
tf.population.MEASURED holds what populated mode was measured to do, read with Population.crowded() on pt-v21. The checks are in tools/calibration/population_checks.py in the library repository, and each comparison is paired on the seed.
import tradefloor as tf
measured = tf.population.MEASURED
print(measured["model"], measured["population"], measured["programme_cost_excess"],
measured["runtime_ratio"])pt-v21 crowded 0.024 1.5
| Check | Result | Read on |
|---|---|---|
| The one-day reversal and the five-day momentum, beside crowds trading the same signals | Each rule's edge decays as other traders trade its signal | Seeds 92001 to 92030, 20 names, 60 sessions |
| A crowded exit | When the momentum crowd hits its loss limit and sells out, holders of the five-day momentum signal lose 0.06 of a daily standard deviation that day and get it back over the following week | Seeds 92001 to 92030 |
| A predictable programme's cost | About 2.4% more than in isolated mode. Van Kervel and Menkveld (2019) report 169% in real markets. | Seeds 201 to 230 |
| A 12-day programme's cost per share over a 1-day one's (AC1) | 2.176, inside the band 1.8 to 5.0 | Seeds 201 to 230, 4 names |
| One programme split over four labels against one label (AC2) | 1.000, inside the band 0.8 to 1.25 | Seeds 201 to 230, 4 names |
| Lag-1 return autocorrelation | Moves by about +0.006 | 40 names, seeds 101 to 103, 252 sessions |
| Run time | About 1.5 times an isolated run | 60 sessions on 40 names, traded |
Impact in tradefloor is mostly transient, so a front-runner has less to trade ahead of than in a real market. Treat the cost of being front-run in populated mode as a floor. The participants all act at the same instant and post no resting orders, so populated mode has no latency or queue position. The realism checks in How it is measured were run in isolated mode.
Over MCP
evaluate_strategies, run_stress_scenario, open_session and start_job on the local MCP server take population, "standard" or "crowded". A populated result names the population and its fingerprint in provenance and adds a caveat with the measured figures above. rank_strategies refuses a population for the reason tf.rank does.
Next steps
- Compare strategies ranks agents across seeds in isolated mode.
- World runs several agents in one market.