doxa-ai 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
doxa_ai-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.4
2
+ Name: doxa-ai
3
+ Version: 0.1.0
4
+ Summary: Doxa backend engine and CLI
5
+ Author: VincenzoManto
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: click
9
+
10
+ # Doxa
11
+
12
+ Backend engine and CLI for Doxa.
13
+
14
+ ## Installazione locale
15
+
16
+ ```bash
17
+ pip install .
18
+ ```
19
+
20
+ ## Utilizzo
21
+
22
+ ```bash
23
+ doxa run
24
+ ```
@@ -0,0 +1,15 @@
1
+ # Doxa
2
+
3
+ Backend engine and CLI for Doxa.
4
+
5
+ ## Installazione locale
6
+
7
+ ```bash
8
+ pip install .
9
+ ```
10
+
11
+ ## Utilizzo
12
+
13
+ ```bash
14
+ doxa run
15
+ ```
@@ -0,0 +1,21 @@
1
+ [tool.setuptools.packages.find]
2
+ where = ["src"]
3
+ [build-system]
4
+ requires = ["setuptools", "wheel"]
5
+ build-backend = "setuptools.build_meta"
6
+
7
+ [project]
8
+ name = "doxa-ai"
9
+ version = "0.1.0"
10
+ description = "Doxa backend engine and CLI"
11
+ authors = [
12
+ { name = "VincenzoManto" }
13
+ ]
14
+ readme = "README.md"
15
+ requires-python = ">=3.8"
16
+ dependencies = [
17
+ "click"
18
+ ]
19
+
20
+ [project.scripts]
21
+ doxa = "doxa.cli:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ # Doxa package init
@@ -0,0 +1,14 @@
1
+ import click
2
+
3
+ @click.group()
4
+ def main():
5
+ """Doxa CLI entry point."""
6
+ pass
7
+
8
+ @main.command()
9
+ def run():
10
+ """Run the Doxa backend engine."""
11
+ click.echo("Doxa engine running...")
12
+
13
+ if __name__ == "__main__":
14
+ main()
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.4
2
+ Name: doxa-ai
3
+ Version: 0.1.0
4
+ Summary: Doxa backend engine and CLI
5
+ Author: VincenzoManto
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: click
9
+
10
+ # Doxa
11
+
12
+ Backend engine and CLI for Doxa.
13
+
14
+ ## Installazione locale
15
+
16
+ ```bash
17
+ pip install .
18
+ ```
19
+
20
+ ## Utilizzo
21
+
22
+ ```bash
23
+ doxa run
24
+ ```
@@ -0,0 +1,17 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/doxa/__init__.py
4
+ src/doxa/cli.py
5
+ src/doxa_ai.egg-info/PKG-INFO
6
+ src/doxa_ai.egg-info/SOURCES.txt
7
+ src/doxa_ai.egg-info/dependency_links.txt
8
+ src/doxa_ai.egg-info/entry_points.txt
9
+ src/doxa_ai.egg-info/requires.txt
10
+ src/doxa_ai.egg-info/top_level.txt
11
+ tests/test_agent_economics.py
12
+ tests/test_api.py
13
+ tests/test_engine_smoke.py
14
+ tests/test_market_engine.py
15
+ tests/test_relation_graph.py
16
+ tests/test_scenarios.py
17
+ tests/test_world_events.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ doxa = doxa.cli:main
@@ -0,0 +1 @@
1
+ click
@@ -0,0 +1 @@
1
+ doxa
@@ -0,0 +1,73 @@
1
+ import pytest
2
+
3
+ from engine.agents.AgentEconomics import AgentEconomics
4
+
5
+
6
+ def test_compute_utility_uses_reference_prices_when_provided():
7
+ economics = AgentEconomics()
8
+ portfolio = {"credits": 10.0, "gold": 2.0, "panic": 0.5}
9
+
10
+ utility = economics.compute_utility(
11
+ portfolio,
12
+ {"credits": 1.0, "gold": 5.0, "panic": 0.0},
13
+ )
14
+
15
+ assert utility == 20.0
16
+
17
+
18
+ def test_simulate_portfolio_delta_does_not_mutate_original():
19
+ economics = AgentEconomics()
20
+ portfolio = {"credits": 10.0, "gold": 2.0}
21
+
22
+ simulated = economics.simulate_portfolio_delta(portfolio, {"gold": -1.0, "corn": 4.0})
23
+
24
+ assert portfolio == {"credits": 10.0, "gold": 2.0}
25
+ assert simulated == {"credits": 10.0, "gold": 1.0, "corn": 4.0}
26
+
27
+
28
+ def test_evaluate_trade_utility_returns_positive_delta_for_favorable_trade():
29
+ economics = AgentEconomics()
30
+ portfolio = {"credits": 10.0, "gold": 1.0}
31
+ reference_prices = {"credits": 1.0, "gold": 5.0, "corn": 2.5}
32
+
33
+ delta = economics.evaluate_trade_utility(
34
+ portfolio,
35
+ {"credits": 4.0},
36
+ {"corn": 3.0},
37
+ reference_prices,
38
+ )
39
+
40
+ assert delta == 3.5
41
+
42
+
43
+ def test_evaluate_order_utility_handles_bid_and_ask():
44
+ economics = AgentEconomics()
45
+ portfolio = {"credits": 20.0, "gold": 4.0}
46
+ reference_prices = {"credits": 1.0, "gold": 6.0}
47
+
48
+ bid_delta = economics.evaluate_order_utility(
49
+ portfolio,
50
+ "bid",
51
+ "gold",
52
+ quantity=2.0,
53
+ price=5.0,
54
+ reference_prices=reference_prices,
55
+ )
56
+ ask_delta = economics.evaluate_order_utility(
57
+ portfolio,
58
+ "ask",
59
+ "gold",
60
+ quantity=2.0,
61
+ price=7.0,
62
+ reference_prices=reference_prices,
63
+ )
64
+
65
+ assert bid_delta == 2.0
66
+ assert ask_delta == 2.0
67
+
68
+
69
+ def test_evaluate_order_utility_rejects_invalid_side():
70
+ economics = AgentEconomics()
71
+
72
+ with pytest.raises(ValueError):
73
+ economics.evaluate_order_utility({"credits": 10.0}, "hold", "gold", 1.0, 5.0)
@@ -0,0 +1,92 @@
1
+ from fastapi.testclient import TestClient
2
+
3
+ import api
4
+
5
+
6
+ class StubEngine:
7
+ def __init__(self):
8
+ self.env = type("Env", (), {"pending_trades": {}})()
9
+ self.loaded_paths = []
10
+
11
+ def record_event(self, payload):
12
+ return payload
13
+
14
+ def make_ws_snapshot(self):
15
+ return None
16
+
17
+ def get_status(self):
18
+ return {"state": "idle", "epoch": 0, "step": 0}
19
+
20
+ def get_markets(self):
21
+ return {"gold": {"resource": "gold", "price": 10.0}}
22
+
23
+ def get_market_orderbook(self, resource, depth):
24
+ if resource != "gold":
25
+ return None
26
+ return {"resource": resource, "depth": depth, "bids": [], "asks": []}
27
+
28
+ def get_market_price_history(self, resource):
29
+ return {"resource": resource, "history": [10.0]} if resource == "gold" else None
30
+
31
+ def get_relations(self):
32
+ return [{"source": "alice", "target": "bob", "trust": 0.6, "type": "neutral"}]
33
+
34
+ def validate_yaml(self, yaml_text):
35
+ if not yaml_text.strip():
36
+ raise ValueError("empty config")
37
+ return {"valid": True, "config": {"raw": yaml_text}}
38
+
39
+ def load_config_path(self, path):
40
+ self.loaded_paths.append(path)
41
+ return {"source": {"kind": "path", "value": path}}
42
+
43
+ def update_config_text(self, yaml_text):
44
+ if yaml_text == "busy":
45
+ raise RuntimeError("engine busy")
46
+ return {"source": {"kind": "text", "value": "api"}}
47
+
48
+
49
+ def _make_client(monkeypatch):
50
+ stub = StubEngine()
51
+ monkeypatch.setattr(api, "engine", stub)
52
+ monkeypatch.setattr(api, "publish_event", lambda payload: payload)
53
+ client = TestClient(api.app)
54
+ return client, stub
55
+
56
+
57
+ def test_status_markets_and_relations_endpoints(monkeypatch):
58
+ client, _stub = _make_client(monkeypatch)
59
+
60
+ assert client.get("/api/status").json()["state"] == "idle"
61
+ assert "gold" in client.get("/api/markets").json()["markets"]
62
+ assert client.get("/api/relations").json()["relations"][0]["source"] == "alice"
63
+
64
+
65
+ def test_market_orderbook_returns_404_for_unknown_market(monkeypatch):
66
+ client, _stub = _make_client(monkeypatch)
67
+
68
+ response = client.get("/api/markets/corn/orderbook")
69
+
70
+ assert response.status_code == 404
71
+ assert response.json()["error"] == "No market for 'corn'"
72
+
73
+
74
+ def test_validate_and_load_config_endpoints(monkeypatch):
75
+ client, stub = _make_client(monkeypatch)
76
+
77
+ validate_response = client.post("/api/config/validate", json={"yaml_text": "actors: []"})
78
+ load_response = client.post("/api/config/load", json={"path": "scenarios/hormuz.yaml"})
79
+
80
+ assert validate_response.status_code == 200
81
+ assert validate_response.json()["valid"] is True
82
+ assert load_response.status_code == 200
83
+ assert stub.loaded_paths == ["scenarios/hormuz.yaml"]
84
+
85
+
86
+ def test_update_config_returns_conflict_on_runtime_error(monkeypatch):
87
+ client, _stub = _make_client(monkeypatch)
88
+
89
+ response = client.put("/api/config", json={"yaml_text": "busy"})
90
+
91
+ assert response.status_code == 409
92
+ assert response.json()["detail"] == "engine busy"
@@ -0,0 +1,37 @@
1
+ from importlib import import_module
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+
7
+ REPO_ROOT = Path(__file__).resolve().parents[2]
8
+ SCENARIO_FILES = sorted((REPO_ROOT / "scenarios").glob("*.yaml"))
9
+
10
+
11
+ class DummyChatbot:
12
+ def __init__(self, engine):
13
+ self.engine = engine
14
+
15
+
16
+ @pytest.fixture
17
+ def doxa_engine_module(monkeypatch):
18
+ module = import_module("engine.DoxaEngine")
19
+ monkeypatch.setattr(module, "DoxaChatbot", DummyChatbot)
20
+ monkeypatch.setattr(module.DoxaEngine, "startOllama", lambda self: None)
21
+ return module
22
+
23
+
24
+ def test_scenario_directory_contains_baseline_hormuz():
25
+ assert (REPO_ROOT / "scenarios" / "hormuz.yaml").exists()
26
+
27
+
28
+ def test_engine_can_instantiate_against_all_launch_scenarios(doxa_engine_module):
29
+ assert SCENARIO_FILES, "Expected launch scenarios under scenarios/"
30
+
31
+ for scenario_path in SCENARIO_FILES:
32
+ yaml_text = scenario_path.read_text(encoding="utf-8")
33
+ engine = doxa_engine_module.DoxaEngine(yaml_text, log_verbose=False)
34
+
35
+ assert engine.raw_config["actors"]
36
+ assert engine.get_config()["config"]["actors"]
37
+ assert engine.validate_yaml(yaml_text)["valid"] is True
@@ -0,0 +1,554 @@
1
+ """
2
+ test_market_engine.py
3
+ ---------------------
4
+ Comprehensive unit tests for market.MarketEngine.
5
+
6
+ Coverage
7
+ ~~~~~~~~
8
+ * Order reservation (bid/ask) — insufficient-funds rejection
9
+ * Continuous (FIFO) clearing — exact fill, partial fill, price policies
10
+ * Cancel + refund
11
+ * Expire + refund (TTL)
12
+ * Call-auction clearing — volume-maximising uniform price
13
+ * Market orders — slippage pricing + immediate clear
14
+ * Per-market lock independence — reads on different markets don't serialise
15
+ * Thread-safety stress — concurrent bids/asks from many threads
16
+ """
17
+
18
+ import threading
19
+ import time
20
+ from market.MarketEngine import MarketEngine # type: ignore[import]
21
+
22
+
23
+ # ──────────────────────────────────────────────────────────────────────────────
24
+ # Helpers
25
+ # ──────────────────────────────────────────────────────────────────────────────
26
+
27
+ def _make_engine(resources=None, clearing="continuous", extra_cfg=None):
28
+ """Return a MarketEngine with one market per resource name.
29
+
30
+ Args:
31
+ resources: list of resource names (default: ["gold"])
32
+ clearing: "continuous", "on_order", or "call_auction"
33
+ extra_cfg: dict of extra per-market config items (e.g. execution_price_policy)
34
+ """
35
+ resources = resources or ["gold"]
36
+ cfg = {}
37
+ if extra_cfg:
38
+ cfg.update(extra_cfg)
39
+ markets_cfg = [
40
+ {
41
+ "resource": r,
42
+ "currency": "credits",
43
+ "initial_price": 10.0,
44
+ "min_price": 1.0,
45
+ "max_price": 1000.0,
46
+ "clearing": clearing,
47
+ **cfg,
48
+ }
49
+ for r in resources
50
+ ]
51
+ return MarketEngine(markets_cfg)
52
+
53
+
54
+ def _portfolios(specs):
55
+ """Build a shared portfolios dict from a list of (agent_id, dict) pairs."""
56
+ return {agent_id: dict(holdings) for agent_id, holdings in specs}
57
+
58
+
59
+ # ──────────────────────────────────────────────────────────────────────────────
60
+ # 1. Order submission — reservation
61
+ # ──────────────────────────────────────────────────────────────────────────────
62
+
63
+ class TestOrderReservation:
64
+
65
+ def test_bid_reserves_credits(self):
66
+ engine = _make_engine()
67
+ portfolios = _portfolios([("alice", {"credits": 100, "gold": 0})])
68
+
69
+ result = engine.add_order("alice", "bid", "gold", qty := 5, price := 10.0, portfolios)
70
+
71
+ assert result.startswith("SUCCESS")
72
+ # reserved = price * qty = 50
73
+ assert portfolios["alice"]["credits"] == pytest_approx(50.0, rel=1e-6)
74
+
75
+ def test_ask_reserves_resource(self):
76
+ engine = _make_engine()
77
+ portfolios = _portfolios([("bob", {"credits": 0, "gold": 20})])
78
+
79
+ result = engine.add_order("bob", "ask", "gold", 8, 10.0, portfolios)
80
+
81
+ assert result.startswith("SUCCESS")
82
+ assert portfolios["bob"]["gold"] == pytest_approx(12.0, rel=1e-6)
83
+
84
+ def test_bid_insufficient_credits_rejected(self):
85
+ engine = _make_engine()
86
+ portfolios = _portfolios([("poor", {"credits": 10, "gold": 0})])
87
+
88
+ result = engine.add_order("poor", "bid", "gold", 10, 10.0, portfolios)
89
+
90
+ assert result.startswith("FAILED")
91
+ assert portfolios["poor"]["credits"] == pytest_approx(10.0) # no change
92
+
93
+ def test_ask_insufficient_resource_rejected(self):
94
+ engine = _make_engine()
95
+ portfolios = _portfolios([("broke", {"credits": 1000, "gold": 2})])
96
+
97
+ result = engine.add_order("broke", "ask", "gold", 5, 10.0, portfolios)
98
+
99
+ assert result.startswith("FAILED")
100
+ assert portfolios["broke"]["gold"] == pytest_approx(2.0)
101
+
102
+ def test_no_market_rejected(self):
103
+ engine = _make_engine(resources=["gold"])
104
+ portfolios = _portfolios([("x", {"credits": 500})])
105
+
106
+ result = engine.add_order("x", "bid", "corn", 1, 5.0, portfolios)
107
+
108
+ assert result.startswith("FAILED")
109
+ assert "No market" in result
110
+
111
+ def test_price_out_of_bounds_rejected(self):
112
+ engine = _make_engine()
113
+ portfolios = _portfolios([("x", {"credits": 50000})])
114
+
115
+ result = engine.add_order("x", "bid", "gold", 1, 2000.0, portfolios)
116
+
117
+ assert result.startswith("FAILED")
118
+ assert "range" in result.lower()
119
+
120
+ def test_invalid_side_rejected(self):
121
+ engine = _make_engine()
122
+ portfolios = _portfolios([("x", {"credits": 100})])
123
+
124
+ result = engine.add_order("x", "bork", "gold", 1, 10.0, portfolios)
125
+
126
+ assert result.startswith("FAILED")
127
+
128
+
129
+ # ──────────────────────────────────────────────────────────────────────────────
130
+ # 2. Continuous (FIFO) clearing
131
+ # ──────────────────────────────────────────────────────────────────────────────
132
+
133
+ class TestContinuousClearing:
134
+
135
+ def _setup_cross(self, bid_price=12.0, ask_price=10.0, qty=5):
136
+ """Return (engine, portfolios) with a crossing bid+ask ready to match."""
137
+ engine = _make_engine()
138
+ portfolios = _portfolios([
139
+ ("buyer", {"credits": 200, "gold": 0}),
140
+ ("seller", {"credits": 0, "gold": 20}),
141
+ ])
142
+ engine.add_order("buyer", "bid", "gold", qty, bid_price, portfolios)
143
+ engine.add_order("seller", "ask", "gold", qty, ask_price, portfolios)
144
+ return engine, portfolios
145
+
146
+ def test_exact_fill(self):
147
+ engine, portfolios = self._setup_cross(bid_price=12.0, ask_price=10.0, qty=5)
148
+
149
+ fills = engine.clear_market("gold", portfolios, tick=1)
150
+
151
+ assert len(fills) == 1
152
+ f = fills[0]
153
+ assert f["fill_qty"] == pytest_approx(5.0)
154
+ assert f["buyer"] == "buyer"
155
+ assert f["seller"] == "seller"
156
+ # Buyer receives gold
157
+ assert portfolios["buyer"]["gold"] == pytest_approx(5.0)
158
+ # Seller receives credits at fill_price * qty
159
+ fill_price = f["fill_price"]
160
+ assert portfolios["seller"]["credits"] == pytest_approx(fill_price * 5.0)
161
+ # Buyer gets surplus from overpaying back
162
+ expected_surplus = (12.0 - fill_price) * 5.0
163
+ assert portfolios["buyer"]["credits"] == pytest_approx(200 - 12.0 * 5 + expected_surplus)
164
+
165
+ def test_partial_fill(self):
166
+ engine = _make_engine()
167
+ portfolios = _portfolios([
168
+ ("buyer", {"credits": 200, "gold": 0}),
169
+ ("seller", {"credits": 0, "gold": 20}),
170
+ ])
171
+ engine.add_order("buyer", "bid", "gold", 10, 12.0, portfolios)
172
+ engine.add_order("seller", "ask", "gold", 3, 10.0, portfolios)
173
+
174
+ fills = engine.clear_market("gold", portfolios, tick=1)
175
+
176
+ assert len(fills) == 1
177
+ assert fills[0]["fill_qty"] == pytest_approx(3.0)
178
+ assert portfolios["buyer"]["gold"] == pytest_approx(3.0)
179
+
180
+ def test_no_match_when_bid_below_ask(self):
181
+ engine = _make_engine()
182
+ portfolios = _portfolios([
183
+ ("buyer", {"credits": 200, "gold": 0}),
184
+ ("seller", {"credits": 0, "gold": 20}),
185
+ ])
186
+ engine.add_order("buyer", "bid", "gold", 5, 8.0, portfolios)
187
+ engine.add_order("seller", "ask", "gold", 5, 12.0, portfolios)
188
+
189
+ fills = engine.clear_market("gold", portfolios, tick=1)
190
+
191
+ assert fills == []
192
+ assert portfolios["buyer"]["gold"] == 0 # nothing traded
193
+
194
+ def test_price_policy_resting(self):
195
+ engine = _make_engine(extra_cfg={"execution_price_policy": "resting"})
196
+ portfolios = _portfolios([
197
+ ("buyer", {"credits": 300, "gold": 0}),
198
+ ("seller", {"credits": 0, "gold": 10}),
199
+ ])
200
+ engine.add_order("seller", "ask", "gold", 5, 10.0, portfolios) # resting
201
+ engine.add_order("buyer", "bid", "gold", 5, 15.0, portfolios) # aggressor
202
+
203
+ fills = engine.clear_market("gold", portfolios, tick=1)
204
+
205
+ assert len(fills) == 1
206
+ # Resting is the ask (arrived first), so fill_price should be ask price
207
+ assert fills[0]["fill_price"] == pytest_approx(10.0)
208
+
209
+ def test_price_policy_midpoint(self):
210
+ engine = _make_engine(extra_cfg={"execution_price_policy": "midpoint"})
211
+ portfolios = _portfolios([
212
+ ("buyer", {"credits": 300, "gold": 0}),
213
+ ("seller", {"credits": 0, "gold": 10}),
214
+ ])
215
+ engine.add_order("seller", "ask", "gold", 5, 10.0, portfolios)
216
+ engine.add_order("buyer", "bid", "gold", 5, 14.0, portfolios)
217
+
218
+ fills = engine.clear_market("gold", portfolios, tick=1)
219
+
220
+ assert len(fills) == 1
221
+ assert fills[0]["fill_price"] == pytest_approx(12.0)
222
+
223
+ def test_price_policy_aggressive(self):
224
+ engine = _make_engine(extra_cfg={"execution_price_policy": "aggressive"})
225
+ portfolios = _portfolios([
226
+ ("buyer", {"credits": 300, "gold": 0}),
227
+ ("seller", {"credits": 0, "gold": 10}),
228
+ ])
229
+ engine.add_order("seller", "ask", "gold", 5, 10.0, portfolios) # resting
230
+ engine.add_order("buyer", "bid", "gold", 5, 14.0, portfolios) # aggressor
231
+
232
+ fills = engine.clear_market("gold", portfolios, tick=1)
233
+
234
+ assert len(fills) == 1
235
+ # Aggressor (bid) sets the price
236
+ assert fills[0]["fill_price"] == pytest_approx(14.0)
237
+
238
+ def test_on_order_clearing_fires_immediately(self):
239
+ """With clearing='on_order', submission alone should trigger matching."""
240
+ engine = _make_engine(clearing="on_order")
241
+ portfolios = _portfolios([
242
+ ("buyer", {"credits": 200, "gold": 0}),
243
+ ("seller", {"credits": 0, "gold": 20}),
244
+ ])
245
+ engine.add_order("seller", "ask", "gold", 5, 10.0, portfolios)
246
+
247
+ # Placing the bid with clearing=on_order triggers clear immediately
248
+ engine.add_order("buyer", "bid", "gold", 5, 12.0, portfolios)
249
+
250
+ # Gold should be delivered without calling clear_market explicitly
251
+ assert portfolios["buyer"]["gold"] == pytest_approx(5.0)
252
+
253
+
254
+ # ──────────────────────────────────────────────────────────────────────────────
255
+ # 3. Cancel + refund
256
+ # ──────────────────────────────────────────────────────────────────────────────
257
+
258
+ class TestCancel:
259
+
260
+ def test_cancel_bid_refunds_credits(self):
261
+ engine = _make_engine()
262
+ portfolios = _portfolios([("alice", {"credits": 100, "gold": 0})])
263
+ result = engine.add_order("alice", "bid", "gold", 5, 10.0, portfolios)
264
+ order_id = result.split(":")[1].strip().split(" ")[0]
265
+
266
+ engine.cancel_order(order_id, "alice", portfolios)
267
+
268
+ # All credits returned
269
+ assert portfolios["alice"]["credits"] == pytest_approx(100.0)
270
+
271
+ def test_cancel_ask_refunds_resource(self):
272
+ engine = _make_engine()
273
+ portfolios = _portfolios([("bob", {"credits": 0, "gold": 10})])
274
+ result = engine.add_order("bob", "ask", "gold", 7, 10.0, portfolios)
275
+ order_id = result.split(":")[1].strip().split(" ")[0]
276
+
277
+ engine.cancel_order(order_id, "bob", portfolios)
278
+
279
+ assert portfolios["bob"]["gold"] == pytest_approx(10.0)
280
+
281
+ def test_cancel_wrong_owner_rejected(self):
282
+ engine = _make_engine()
283
+ portfolios = _portfolios([
284
+ ("alice", {"credits": 100, "gold": 0}),
285
+ ("eve", {"credits": 100, "gold": 0}),
286
+ ])
287
+ result = engine.add_order("alice", "bid", "gold", 5, 10.0, portfolios)
288
+ order_id = result.split(":")[1].strip().split(" ")[0]
289
+
290
+ cancel_result = engine.cancel_order(order_id, "eve", portfolios)
291
+
292
+ assert cancel_result.startswith("FAILED")
293
+
294
+ def test_cancel_filled_order_rejected(self):
295
+ engine = _make_engine(clearing="on_order")
296
+ portfolios = _portfolios([
297
+ ("buyer", {"credits": 200, "gold": 0}),
298
+ ("seller", {"credits": 0, "gold": 10}),
299
+ ])
300
+ r1 = engine.add_order("seller", "ask", "gold", 5, 10.0, portfolios)
301
+ r2 = engine.add_order("buyer", "bid", "gold", 5, 12.0, portfolios)
302
+ ask_id = r1.split(":")[1].strip().split(" ")[0]
303
+
304
+ cancel_result = engine.cancel_order(ask_id, "seller", portfolios)
305
+
306
+ # Already filled; cancel should fail
307
+ assert cancel_result.startswith("FAILED")
308
+
309
+
310
+ # ──────────────────────────────────────────────────────────────────────────────
311
+ # 4. TTL expiry
312
+ # ──────────────────────────────────────────────────────────────────────────────
313
+
314
+ class TestExpiry:
315
+
316
+ def _place_limit_with_ttl(self, engine, portfolios, ttl=2):
317
+ """Add a limit order and manually set its TTL for expiry testing."""
318
+ result = engine.add_order("alice", "bid", "gold", 5, 10.0, portfolios, tick=0)
319
+ order_id = result.split(":")[1].strip().split(" ")[0]
320
+ with engine._lock:
321
+ engine._order_index[order_id].ttl = ttl
322
+ return order_id
323
+
324
+ def test_order_not_expired_before_ttl(self):
325
+ engine = _make_engine()
326
+ portfolios = _portfolios([("alice", {"credits": 100, "gold": 0})])
327
+ order_id = self._place_limit_with_ttl(engine, portfolios, ttl=3)
328
+
329
+ engine.expire_orders(tick=2, portfolios=portfolios)
330
+
331
+ # Under TTL: credits still reserved
332
+ assert portfolios["alice"]["credits"] == pytest_approx(50.0)
333
+
334
+ def test_order_expired_at_ttl_refunds(self):
335
+ engine = _make_engine()
336
+ portfolios = _portfolios([("alice", {"credits": 100, "gold": 0})])
337
+ order_id = self._place_limit_with_ttl(engine, portfolios, ttl=2)
338
+
339
+ engine.expire_orders(tick=2, portfolios=portfolios)
340
+
341
+ # TTL reached: credits returned
342
+ assert portfolios["alice"]["credits"] == pytest_approx(100.0)
343
+
344
+
345
+ # ──────────────────────────────────────────────────────────────────────────────
346
+ # 5. Call-auction clearing
347
+ # ──────────────────────────────────────────────────────────────────────────────
348
+
349
+ class TestCallAuction:
350
+
351
+ def test_call_auction_matches_at_clearing_price(self):
352
+ engine = _make_engine(clearing="call_auction")
353
+ portfolios = _portfolios([
354
+ ("buyer1", {"credits": 500, "gold": 0}),
355
+ ("buyer2", {"credits": 500, "gold": 0}),
356
+ ("seller", {"credits": 0, "gold": 20}),
357
+ ])
358
+ engine.add_order("buyer1", "bid", "gold", 5, 12.0, portfolios)
359
+ engine.add_order("buyer2", "bid", "gold", 5, 11.0, portfolios)
360
+ engine.add_order("seller", "ask", "gold", 8, 10.0, portfolios)
361
+
362
+ fills = engine.clear_market("gold", portfolios, tick=1)
363
+
364
+ assert len(fills) > 0
365
+ total_qty = sum(f["fill_qty"] for f in fills)
366
+ assert total_qty == pytest_approx(8.0, rel=1e-6)
367
+ # All fills at the same uniform price
368
+ assert all(f["fill_price"] == fills[0]["fill_price"] for f in fills)
369
+
370
+ def test_call_auction_no_overlap_no_fill(self):
371
+ engine = _make_engine(clearing="call_auction")
372
+ portfolios = _portfolios([
373
+ ("buyer", {"credits": 200, "gold": 0}),
374
+ ("seller", {"credits": 0, "gold": 10}),
375
+ ])
376
+ engine.add_order("buyer", "bid", "gold", 5, 8.0, portfolios)
377
+ engine.add_order("seller", "ask", "gold", 5, 12.0, portfolios)
378
+
379
+ fills = engine.clear_market("gold", portfolios, tick=1)
380
+
381
+ assert fills == []
382
+
383
+
384
+ # ──────────────────────────────────────────────────────────────────────────────
385
+ # 6. Market orders
386
+ # ──────────────────────────────────────────────────────────────────────────────
387
+
388
+ class TestMarketOrders:
389
+
390
+ def test_market_buy_sweeps_liquidity(self):
391
+ engine = _make_engine()
392
+ portfolios = _portfolios([
393
+ ("buyer", {"credits": 500, "gold": 0}),
394
+ ("seller", {"credits": 0, "gold": 20}),
395
+ ])
396
+ engine.add_order("seller", "ask", "gold", 5, 10.0, portfolios)
397
+
398
+ result = engine.add_market_order("buyer", "bid", "gold", 5, portfolios, tick=1)
399
+
400
+ assert result.startswith("SUCCESS")
401
+ assert portfolios["buyer"]["gold"] == pytest_approx(5.0)
402
+
403
+ def test_market_sell_sweeps_demand(self):
404
+ engine = _make_engine()
405
+ portfolios = _portfolios([
406
+ ("buyer", {"credits": 500, "gold": 0}),
407
+ ("seller", {"credits": 0, "gold": 20}),
408
+ ])
409
+ engine.add_order("buyer", "bid", "gold", 5, 10.0, portfolios)
410
+
411
+ result = engine.add_market_order("seller", "ask", "gold", 5, portfolios, tick=1)
412
+
413
+ assert result.startswith("SUCCESS")
414
+ assert portfolios["seller"]["credits"] > 0
415
+
416
+
417
+ # ──────────────────────────────────────────────────────────────────────────────
418
+ # 7. get_price / get_order_book
419
+ # ──────────────────────────────────────────────────────────────────────────────
420
+
421
+ class TestReadPaths:
422
+
423
+ def test_get_price_returns_initial_price(self):
424
+ engine = _make_engine()
425
+ assert engine.get_price("gold") == pytest_approx(10.0)
426
+
427
+ def test_get_price_updates_after_fill(self):
428
+ engine = _make_engine(clearing="on_order")
429
+ portfolios = _portfolios([
430
+ ("buyer", {"credits": 300, "gold": 0}),
431
+ ("seller", {"credits": 0, "gold": 10}),
432
+ ])
433
+ engine.add_order("seller", "ask", "gold", 5, 8.0, portfolios)
434
+ engine.add_order("buyer", "bid", "gold", 5, 12.0, portfolios)
435
+
436
+ p = engine.get_price("gold")
437
+ # fill price is 8.0 (resting ask) by default policy
438
+ assert p == pytest_approx(8.0)
439
+
440
+ def test_get_price_unknown_resource_is_none(self):
441
+ engine = _make_engine()
442
+ assert engine.get_price("corn") is None
443
+
444
+ def test_get_order_book_structure(self):
445
+ engine = _make_engine()
446
+ portfolios = _portfolios([("alice", {"credits": 200, "gold": 10})])
447
+ engine.add_order("alice", "bid", "gold", 5, 9.0, portfolios)
448
+ engine.add_order("alice", "ask", "gold", 3, 11.0, portfolios)
449
+
450
+ book = engine.get_order_book("gold", depth=5)
451
+
452
+ assert book is not None
453
+ assert "bids" in book and "asks" in book
454
+ assert book["resource"] == "gold"
455
+ assert len(book["bids"]) > 0
456
+ assert len(book["asks"]) > 0
457
+
458
+ def test_get_order_book_unknown_resource_is_none(self):
459
+ engine = _make_engine()
460
+ assert engine.get_order_book("corn") is None
461
+
462
+ def test_per_market_lock_reads_different_resources(self):
463
+ """Reads on different markets should not share the same RLock instance."""
464
+ engine = _make_engine(resources=["gold", "corn"])
465
+ assert engine._market_locks["gold"] is not engine._market_locks["corn"]
466
+
467
+
468
+ # ──────────────────────────────────────────────────────────────────────────────
469
+ # 8. Thread-safety stress test
470
+ # ──────────────────────────────────────────────────────────────────────────────
471
+
472
+ class TestThreadSafety:
473
+
474
+ def test_concurrent_bids_and_asks_no_exception(self):
475
+ """Many threads placing orders simultaneously must not corrupt state."""
476
+ n_agents = 8
477
+ n_orders_each = 10
478
+ engine = _make_engine(clearing="continuous")
479
+
480
+ portfolios = {
481
+ f"buyer_{i}": {"credits": 5000, "gold": 0}
482
+ for i in range(n_agents)
483
+ }
484
+ portfolios.update({
485
+ f"seller_{i}": {"credits": 0, "gold": 5000}
486
+ for i in range(n_agents)
487
+ })
488
+
489
+ errors = []
490
+
491
+ def place_and_clear(i):
492
+ try:
493
+ for j in range(n_orders_each):
494
+ price = 10.0 + (i % 3)
495
+ engine.add_order(f"buyer_{i}", "bid", "gold", 1, price + 2, portfolios)
496
+ engine.add_order(f"seller_{i}", "ask", "gold", 1, price, portfolios)
497
+ engine.clear_market("gold", portfolios, tick=j)
498
+ except Exception as exc:
499
+ errors.append(exc)
500
+
501
+ threads = [threading.Thread(target=place_and_clear, args=(i,)) for i in range(n_agents)]
502
+ for t in threads:
503
+ t.start()
504
+ for t in threads:
505
+ t.join(timeout=10)
506
+
507
+ assert errors == [], f"Thread safety violations: {errors}"
508
+
509
+ def test_concurrent_price_reads_with_writes(self):
510
+ """Price reads and order writes from separate threads must not raise."""
511
+ engine = _make_engine(clearing="continuous")
512
+ portfolios = {f"a{i}": {"credits": 5000, "gold": 5000} for i in range(4)}
513
+ stop_flag = threading.Event()
514
+ read_errors = []
515
+ write_errors = []
516
+
517
+ def writer():
518
+ for k in range(50):
519
+ try:
520
+ engine.add_order(f"a{k % 4}", "bid", "gold", 1, 10.0, portfolios, tick=k)
521
+ engine.clear_market("gold", portfolios, tick=k)
522
+ except Exception as e:
523
+ write_errors.append(e)
524
+
525
+ def reader():
526
+ while not stop_flag.is_set():
527
+ try:
528
+ engine.get_price("gold")
529
+ except Exception as e:
530
+ read_errors.append(e)
531
+ stop_flag.set()
532
+
533
+ r = threading.Thread(target=reader, daemon=True)
534
+ w = threading.Thread(target=writer)
535
+ r.start()
536
+ w.start()
537
+ w.join(timeout=5)
538
+ stop_flag.set()
539
+ r.join(timeout=1)
540
+
541
+ assert read_errors == [], f"Read errors under concurrent writes: {read_errors}"
542
+ assert write_errors == [], f"Write errors: {write_errors}"
543
+
544
+
545
+ # ──────────────────────────────────────────────────────────────────────────────
546
+ # Compatibility shim — makes `pytest_approx` available as a bare name
547
+ # ──────────────────────────────────────────────────────────────────────────────
548
+
549
+ try:
550
+ from pytest import approx as pytest_approx # type: ignore[import]
551
+ except ImportError:
552
+ # Fallback for environments where pytest isn't installed yet
553
+ def pytest_approx(value, rel=1e-6, abs=None): # type: ignore[override]
554
+ return value
@@ -0,0 +1,75 @@
1
+ from relations.RelationGraph import RelationGraph # type: ignore[import]
2
+
3
+
4
+ def test_init_from_yaml_loads_explicit_relations_and_keeps_lazy_neutral_defaults():
5
+ graph = RelationGraph()
6
+ graph.init_from_yaml(
7
+ [
8
+ {"source": "alice", "target": "bob", "trust": 0.8, "type": "ally"},
9
+ ],
10
+ ["alice", "bob", "carol"],
11
+ )
12
+
13
+ assert graph.get_trust("alice", "bob") == 0.8
14
+ assert graph.get_rel_type("alice", "bob") == "ally"
15
+ assert graph.get_trust("bob", "alice") == 0.5
16
+ assert graph.get_rel_type("bob", "alice") == "neutral"
17
+
18
+
19
+ def test_update_trust_creates_missing_edges_and_reclassifies_labels():
20
+ graph = RelationGraph()
21
+
22
+ graph.update_trust("alice", "bob", 0.3)
23
+ assert graph.get_trust("alice", "bob") == 0.8
24
+ assert graph.get_rel_type("alice", "bob") == "ally"
25
+
26
+ graph.update_trust("alice", "carol", -0.15)
27
+ assert graph.get_trust("alice", "carol") == 0.35
28
+ assert graph.get_rel_type("alice", "carol") == "rival"
29
+
30
+ graph.update_trust("alice", "dave", -0.3)
31
+ assert graph.get_trust("alice", "dave") == 0.2
32
+ assert graph.get_rel_type("alice", "dave") == "enemy"
33
+
34
+
35
+ def test_update_trust_clamps_values_to_unit_interval():
36
+ graph = RelationGraph()
37
+
38
+ graph.update_trust("alice", "bob", 2.0)
39
+ graph.update_trust("carol", "dave", -2.0)
40
+
41
+ assert graph.get_trust("alice", "bob") == 1.0
42
+ assert graph.get_trust("carol", "dave") == 0.0
43
+
44
+
45
+ def test_decay_all_moves_values_toward_neutral_without_overshooting():
46
+ graph = RelationGraph()
47
+ graph.init_from_yaml(
48
+ [
49
+ {"source": "alice", "target": "bob", "trust": 0.9, "type": "ally"},
50
+ {"source": "bob", "target": "alice", "trust": 0.2, "type": "enemy"},
51
+ ],
52
+ ["alice", "bob"],
53
+ )
54
+
55
+ graph.decay_all(0.25)
56
+ assert graph.get_trust("alice", "bob") == 0.65
57
+ assert graph.get_trust("bob", "alice") == 0.45
58
+
59
+ graph.decay_all(0.25)
60
+ assert graph.get_trust("alice", "bob") == 0.5
61
+ assert graph.get_trust("bob", "alice") == 0.5
62
+
63
+
64
+ def test_to_list_serializes_relation_records_with_rounded_trust():
65
+ graph = RelationGraph()
66
+ graph.init_from_yaml(
67
+ [
68
+ {"source": "alice", "target": "bob", "trust": 0.67891, "type": "neutral"},
69
+ ],
70
+ ["alice", "bob"],
71
+ )
72
+
73
+ assert graph.to_list() == [
74
+ {"source": "alice", "target": "bob", "trust": 0.6789, "type": "neutral"}
75
+ ]
@@ -0,0 +1,33 @@
1
+ from pathlib import Path
2
+
3
+ import yaml
4
+
5
+
6
+ REPO_ROOT = Path(__file__).resolve().parents[2]
7
+ SCENARIO_FILES = sorted((REPO_ROOT / "scenarios").glob("*.yaml"))
8
+
9
+
10
+ def test_scenario_files_exist():
11
+ assert SCENARIO_FILES, "Expected at least one scenario YAML file"
12
+ for scenario_path in SCENARIO_FILES:
13
+ assert scenario_path.exists(), f"Missing scenario file: {scenario_path}"
14
+
15
+
16
+ def test_scenario_files_are_valid_yaml():
17
+ for scenario_path in SCENARIO_FILES:
18
+ with scenario_path.open("r", encoding="utf-8") as handle:
19
+ data = yaml.safe_load(handle)
20
+
21
+ assert isinstance(data, dict), f"Scenario must load as a mapping: {scenario_path.name}"
22
+ assert "global_rules" in data, f"Missing global_rules in {scenario_path.name}"
23
+ assert "actors" in data, f"Missing actors in {scenario_path.name}"
24
+ assert isinstance(data["actors"], list) and data["actors"], f"actors must be a non-empty list in {scenario_path.name}"
25
+
26
+
27
+ def test_scenario_actor_ids_are_unique_per_file():
28
+ for scenario_path in SCENARIO_FILES:
29
+ with scenario_path.open("r", encoding="utf-8") as handle:
30
+ data = yaml.safe_load(handle)
31
+
32
+ actor_ids = [actor["id"] for actor in data["actors"]]
33
+ assert len(actor_ids) == len(set(actor_ids)), f"Duplicate actor ids in {scenario_path.name}"
@@ -0,0 +1,105 @@
1
+ from events.WorldEventScheduler import WorldEventScheduler # type: ignore[import]
2
+ from market.MarketEngine import MarketEngine # type: ignore[import]
3
+ from relations.RelationGraph import RelationGraph # type: ignore[import]
4
+
5
+
6
+ def _make_market_engine():
7
+ return MarketEngine(
8
+ [
9
+ {
10
+ "resource": "gold",
11
+ "currency": "credits",
12
+ "initial_price": 10.0,
13
+ "min_price": 1.0,
14
+ "max_price": 100.0,
15
+ "clearing": "per_step",
16
+ }
17
+ ]
18
+ )
19
+
20
+
21
+ def test_shock_event_applies_delta_once():
22
+ scheduler = WorldEventScheduler(
23
+ [
24
+ {
25
+ "name": "panic_spike",
26
+ "type": "shock",
27
+ "trigger": {"tick": 2},
28
+ "effect": {"targets": "all", "resource": "panic", "delta": 0.2},
29
+ }
30
+ ]
31
+ )
32
+ portfolios = {"alice": {"panic": 0.0}}
33
+ agents = {"alice": object()}
34
+
35
+ assert scheduler.tick(portfolios, agents, None, None, None, 1) == []
36
+ fired = scheduler.tick(portfolios, agents, None, None, None, 2)
37
+ assert len(fired) == 1
38
+ assert portfolios["alice"]["panic"] == 0.2
39
+ assert scheduler.tick(portfolios, agents, None, None, None, 3) == []
40
+ assert portfolios["alice"]["panic"] == 0.2
41
+
42
+
43
+ def test_trend_event_applies_rate_for_full_duration():
44
+ scheduler = WorldEventScheduler(
45
+ [
46
+ {
47
+ "name": "relief_flow",
48
+ "type": "trend",
49
+ "trigger": {"tick": 1},
50
+ "duration": 3,
51
+ "effect": {"targets": "all", "resource": "food", "rate": 1.5},
52
+ }
53
+ ]
54
+ )
55
+ portfolios = {"alice": {"food": 0.0}, "bob": {"food": 1.0}}
56
+ agents = {"alice": object(), "bob": object()}
57
+
58
+ assert len(scheduler.tick(portfolios, agents, None, None, None, 1)) == 1
59
+ assert len(scheduler.tick(portfolios, agents, None, None, None, 2)) == 1
60
+ assert len(scheduler.tick(portfolios, agents, None, None, None, 3)) == 1
61
+ assert scheduler.tick(portfolios, agents, None, None, None, 4) == []
62
+ assert portfolios["alice"]["food"] == 4.5
63
+ assert portfolios["bob"]["food"] == 5.5
64
+
65
+
66
+ def test_conditional_event_updates_market_and_trust_once():
67
+ scheduler = WorldEventScheduler(
68
+ [
69
+ {
70
+ "name": "ceasefire",
71
+ "type": "conditional",
72
+ "trigger": {
73
+ "condition": {
74
+ "resource": "panic",
75
+ "operator": "gt",
76
+ "threshold": 0.5,
77
+ "scope": "any_agent",
78
+ }
79
+ },
80
+ "effect": {
81
+ "targets": ["beta"],
82
+ "market": "gold",
83
+ "price_multiplier": 1.5,
84
+ "trust_source": "alpha",
85
+ "trust_delta": 0.2,
86
+ },
87
+ }
88
+ ]
89
+ )
90
+ portfolios = {"alpha": {"panic": 0.1}, "beta": {"panic": 0.7}}
91
+ agents = {"alpha": object(), "beta": object()}
92
+ market_engine = _make_market_engine()
93
+ relation_graph = RelationGraph()
94
+ relation_graph.init_from_yaml(
95
+ [{"source": "alpha", "target": "beta", "trust": 0.5, "type": "neutral"}],
96
+ ["alpha", "beta"],
97
+ )
98
+
99
+ fired = scheduler.tick(portfolios, agents, market_engine, relation_graph, None, 1)
100
+ assert len(fired) == 1
101
+ assert market_engine.get_price("gold") == 15.0
102
+ assert relation_graph.get_trust("alpha", "beta") == 0.7
103
+
104
+ assert scheduler.tick(portfolios, agents, market_engine, relation_graph, None, 2) == []
105
+ assert market_engine.get_price("gold") == 15.0