Skip to content

Commit eb0ee0d

Browse files
committed
Harden ABIDES runtime isolation
1 parent 7da9406 commit eb0ee0d

6 files changed

Lines changed: 215 additions & 30 deletions

File tree

codeclash/arenas/abides/abides.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ class ABIDESArena(CodeArena):
2929
"sims_per_round": 3,
3030
"market_minutes": 5,
3131
"background_agents": 3,
32+
"validation_timeout": 10,
33+
"player_timeout": 60,
3234
"timeout": 240,
3335
}
3436

@@ -68,7 +70,8 @@ def validate_code(self, agent: Player) -> tuple[bool, str | None]:
6870
" log_orders=False,\n"
6971
" random_state=np.random.RandomState(seed=1),\n"
7072
")\n"
71-
"PY"
73+
"PY",
74+
timeout=int(self._game_arg("validation_timeout")),
7275
)
7376
if import_check["returncode"] != 0:
7477
return (
@@ -87,11 +90,13 @@ def execute_round(self, agents: list[Player]) -> None:
8790
"python",
8891
"run_abides.py",
8992
"--sims",
90-
str(self.game_config.get("sims_per_round", self.default_args["sims_per_round"])),
93+
str(self._game_arg("sims_per_round")),
9194
"--market-minutes",
9295
str(self._game_arg("market_minutes")),
9396
"--background-agents",
9497
str(self._game_arg("background_agents")),
98+
"--player-timeout",
99+
str(self._game_arg("player_timeout")),
95100
"--output",
96101
str(self.log_env / RESULTS_JSON),
97102
*agent_args,
@@ -109,9 +114,20 @@ def get_results(self, agents: list[Player], round_num: int, stats: RoundStats):
109114
if not result_file.exists():
110115
self.logger.error(f"Missing result file: {result_file}")
111116
stats.winner = RESULT_TIE
117+
stats.scores = {agent.name: CRASH_SCORE for agent in agents}
112118
for agent in agents:
113-
stats.scores[agent.name] = 0.0
114-
stats.player_stats[agent.name].score = 0.0
119+
stats.player_stats[agent.name].score = CRASH_SCORE
120+
stats.details.append(
121+
json.dumps(
122+
{
123+
"player": agent.name,
124+
"score": CRASH_SCORE,
125+
"status": "error",
126+
"error": f"missing ABIDES result file: {result_file}",
127+
},
128+
sort_keys=True,
129+
)
130+
)
115131
return
116132

117133
with open(result_file) as f:

codeclash/arenas/abides/runtime/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,7 @@ from agent.ValueAgent import ValueAgent as MyAgent
1010

1111
The arena runs compact ABIDES market simulations and scores agents by average mark-to-market profit
1212
across identical seeded market worlds.
13+
Scores are computed from exchange execution messages, so editing `self.holdings` directly does not
14+
create scored profit.
1315
Some upstream ABIDES agents keep default behavior behind exact-class checks. If you subclass one of
1416
those agents, override the relevant hooks instead of relying on an empty subclass.

codeclash/arenas/abides/runtime/run_abides.py

Lines changed: 134 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
import contextlib
33
import importlib.util
44
import json
5+
import multiprocessing as mp
56
import os
7+
import queue
68
import random
79
import re
10+
import sys
811
import tempfile
912
import traceback
1013
from pathlib import Path
@@ -42,11 +45,17 @@ def safe_module_name(player_name: str, sim_idx: int | None = None) -> str:
4245

4346

4447
def load_agent_class(player_name: str, path: str, *, sim_idx: int | None = None):
48+
agent_dir = str(Path(path).resolve().parent)
49+
sys.path.insert(0, agent_dir)
4550
spec = importlib.util.spec_from_file_location(safe_module_name(player_name, sim_idx), path)
4651
if spec is None or spec.loader is None:
4752
raise RuntimeError(f"Could not load module spec from {path}")
4853
module = importlib.util.module_from_spec(spec)
49-
spec.loader.exec_module(module)
54+
try:
55+
spec.loader.exec_module(module)
56+
finally:
57+
with contextlib.suppress(ValueError):
58+
sys.path.remove(agent_dir)
5059
if not hasattr(module, "MyAgent"):
5160
raise RuntimeError(f"{path} does not define MyAgent")
5261
agent_class = module.MyAgent
@@ -178,6 +187,7 @@ def make_world_agents(agent_classes: dict[str, type], *, sim_idx: int, market_mi
178187
agent_id += 1
179188

180189
player_agents = {}
190+
player_ids = {}
181191
player_names = list(agent_classes.keys())
182192
if player_names:
183193
offset = sim_idx % len(player_names)
@@ -186,22 +196,41 @@ def make_world_agents(agent_classes: dict[str, type], *, sim_idx: int, market_mi
186196
agent = make_player_agent(agent_classes[player_name], player_name, agent_id)
187197
agents.append(agent)
188198
player_agents[player_name] = agent
199+
player_ids[agent_id] = player_name
189200
agent_id += 1
190201

191202
for agent in agents:
192203
agent.log_to_file = False
193204

205+
ledgers = {player: {"CASH": STARTING_CASH, SYMBOL: 0} for player in player_agents}
206+
original_exchange_send = exchange.sendMessage
207+
208+
def scored_exchange_send(*args, **kwargs):
209+
recipient_id = args[0] if args else kwargs.get("recipientID", kwargs.get("recipient_id"))
210+
msg = args[1] if len(args) > 1 else kwargs.get("msg")
211+
player = player_ids.get(recipient_id)
212+
if player and getattr(msg, "body", {}).get("msg") == "ORDER_EXECUTED":
213+
order = msg.body["order"]
214+
quantity = int(order.quantity)
215+
signed_quantity = quantity if order.is_buy_order else -quantity
216+
ledgers[player][SYMBOL] += signed_quantity
217+
ledgers[player]["CASH"] -= signed_quantity * int(order.fill_price)
218+
return original_exchange_send(*args, **kwargs)
219+
220+
exchange.sendMessage = scored_exchange_send
221+
194222
return {
195223
"agents": agents,
196224
"player_agents": player_agents,
225+
"ledgers": ledgers,
197226
"exchange": exchange,
198227
"historical_date": historical_date,
199228
"mkt_close": mkt_close,
200229
"oracle": oracle,
201230
}
202231

203232

204-
def score_player(agent: TradingAgent, final_price: int) -> tuple[float, dict]:
233+
def score_player(agent: TradingAgent, ledger: dict[str, int], final_price: int) -> tuple[float, dict]:
205234
if getattr(agent, "_codeclash_error", None):
206235
return CRASH_SCORE, {
207236
"status": "error",
@@ -210,8 +239,8 @@ def score_player(agent: TradingAgent, final_price: int) -> tuple[float, dict]:
210239
}
211240

212241
try:
213-
cash = int(agent.holdings.get("CASH", 0))
214-
shares = int(agent.holdings.get(SYMBOL, 0))
242+
cash = int(ledger.get("CASH", 0))
243+
shares = int(ledger.get(SYMBOL, 0))
215244
score = float(cash + shares * final_price - STARTING_CASH)
216245
except Exception as exc:
217246
return CRASH_SCORE, {
@@ -259,7 +288,8 @@ def run_player_market(
259288

260289
final_price = int(world["exchange"].order_books[SYMBOL].last_trade)
261290
agent = world["player_agents"][player]
262-
score, score_detail = score_player(agent, final_price)
291+
ledger = world["ledgers"][player]
292+
score, score_detail = score_player(agent, ledger, final_price)
263293
return {
264294
"score": score,
265295
"detail": {
@@ -272,33 +302,108 @@ def run_player_market(
272302
}
273303

274304

275-
def run_market(agent_paths: dict[str, str], *, sim_idx: int, market_minutes: int, background_agents: int) -> dict:
276-
scores = {}
277-
details = []
278-
for player, path in agent_paths.items():
279-
try:
280-
agent_class = load_agent_class(player, path, sim_idx=sim_idx)
281-
result = run_player_market(
282-
player,
283-
agent_class,
284-
sim_idx=sim_idx,
285-
market_minutes=market_minutes,
286-
background_agents=background_agents,
305+
def run_player_market_worker(
306+
result_queue: mp.Queue,
307+
player: str,
308+
path: str,
309+
*,
310+
sim_idx: int,
311+
market_minutes: int,
312+
background_agents: int,
313+
) -> None:
314+
try:
315+
agent_class = load_agent_class(player, path, sim_idx=sim_idx)
316+
result_queue.put(
317+
run_player_market(
318+
player, agent_class, sim_idx=sim_idx, market_minutes=market_minutes, background_agents=background_agents
287319
)
288-
scores[player] = result["score"]
289-
details.append(result["detail"])
290-
except Exception as exc:
291-
scores[player] = CRASH_SCORE
292-
details.append(
293-
{
320+
)
321+
except BaseException as exc:
322+
result_queue.put(
323+
{
324+
"score": CRASH_SCORE,
325+
"detail": {
294326
"sim": sim_idx,
295327
"player": player,
296328
"score": CRASH_SCORE,
297329
"status": "error",
298330
"error": f"{type(exc).__name__}: {exc}",
299331
"traceback": traceback.format_exc(limit=5),
300-
}
301-
)
332+
},
333+
}
334+
)
335+
336+
337+
def run_player_market_isolated(
338+
player: str,
339+
path: str,
340+
*,
341+
sim_idx: int,
342+
market_minutes: int,
343+
background_agents: int,
344+
player_timeout: int,
345+
) -> dict:
346+
ctx = mp.get_context("spawn")
347+
result_queue = ctx.Queue()
348+
process = ctx.Process(
349+
target=run_player_market_worker,
350+
args=(result_queue, player, path),
351+
kwargs={
352+
"sim_idx": sim_idx,
353+
"market_minutes": market_minutes,
354+
"background_agents": background_agents,
355+
},
356+
)
357+
process.start()
358+
process.join(player_timeout)
359+
if process.is_alive():
360+
process.terminate()
361+
process.join(2)
362+
if process.is_alive():
363+
process.kill()
364+
process.join()
365+
return {
366+
"score": CRASH_SCORE,
367+
"detail": {
368+
"sim": sim_idx,
369+
"player": player,
370+
"score": CRASH_SCORE,
371+
"status": "error",
372+
"error": f"player simulation exceeded {player_timeout}s timeout",
373+
},
374+
}
375+
376+
try:
377+
return result_queue.get_nowait()
378+
except queue.Empty:
379+
return {
380+
"score": CRASH_SCORE,
381+
"detail": {
382+
"sim": sim_idx,
383+
"player": player,
384+
"score": CRASH_SCORE,
385+
"status": "error",
386+
"error": f"player simulation exited with code {process.exitcode} and no result",
387+
},
388+
}
389+
390+
391+
def run_market(
392+
agent_paths: dict[str, str], *, sim_idx: int, market_minutes: int, background_agents: int, player_timeout: int
393+
) -> dict:
394+
scores = {}
395+
details = []
396+
for player, path in agent_paths.items():
397+
result = run_player_market_isolated(
398+
player,
399+
path,
400+
sim_idx=sim_idx,
401+
market_minutes=market_minutes,
402+
background_agents=background_agents,
403+
player_timeout=player_timeout,
404+
)
405+
scores[player] = result["score"]
406+
details.append(result["detail"])
302407

303408
return {"scores": scores, "details": details}
304409

@@ -320,6 +425,7 @@ def main() -> None:
320425
parser.add_argument("--sims", type=int, default=3)
321426
parser.add_argument("--market-minutes", type=int, default=5)
322427
parser.add_argument("--background-agents", type=int, default=3)
428+
parser.add_argument("--player-timeout", type=int, default=60)
323429
parser.add_argument("--output", required=True)
324430
args = parser.parse_args()
325431

@@ -329,6 +435,8 @@ def main() -> None:
329435
parser.error("--market-minutes must be at least 1")
330436
if args.background_agents < 0:
331437
parser.error("--background-agents cannot be negative")
438+
if args.player_timeout < 1:
439+
parser.error("--player-timeout must be at least 1")
332440

333441
agent_names = [name for name, _ in args.agent]
334442
if len(agent_names) != len(set(agent_names)):
@@ -345,6 +453,7 @@ def main() -> None:
345453
sim_idx=sim_idx,
346454
market_minutes=args.market_minutes,
347455
background_agents=args.background_agents,
456+
player_timeout=args.player_timeout,
348457
)
349458
except Exception as exc:
350459
result = {

configs/examples/ABIDES__dummy__r1__s2.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ game:
66
args:
77
market_minutes: 5
88
background_agents: 3
9+
validation_timeout: 10
10+
player_timeout: 60
911
timeout: 240
1012
players:
1113
- agent: dummy

docs/reference/arenas/abides.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ game:
5454
args:
5555
market_minutes: 5
5656
background_agents: 3
57+
validation_timeout: 10
58+
player_timeout: 60
5759
timeout: 240
5860
players:
5961
- agent: dummy
@@ -67,7 +69,8 @@ players:
6769
The arena runs `sims_per_round` independent ABIDES market seeds. For each seed, every submitted
6870
CodeClash trading agent is evaluated in its own matching ABIDES market world with an exchange, a
6971
market maker, and background zero-intelligence traders. The final CodeClash score is the player's
70-
average mark-to-market profit across simulations.
72+
average mark-to-market profit across simulations. Scoring is derived from exchange execution
73+
messages rather than mutable fields on the submitted agent object.
7174

7275
## Smoke Test
7376

0 commit comments

Comments
 (0)