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 +24 -0
- doxa_ai-0.1.0/README.md +15 -0
- doxa_ai-0.1.0/pyproject.toml +21 -0
- doxa_ai-0.1.0/setup.cfg +4 -0
- doxa_ai-0.1.0/src/doxa/__init__.py +1 -0
- doxa_ai-0.1.0/src/doxa/cli.py +14 -0
- doxa_ai-0.1.0/src/doxa_ai.egg-info/PKG-INFO +24 -0
- doxa_ai-0.1.0/src/doxa_ai.egg-info/SOURCES.txt +17 -0
- doxa_ai-0.1.0/src/doxa_ai.egg-info/dependency_links.txt +1 -0
- doxa_ai-0.1.0/src/doxa_ai.egg-info/entry_points.txt +2 -0
- doxa_ai-0.1.0/src/doxa_ai.egg-info/requires.txt +1 -0
- doxa_ai-0.1.0/src/doxa_ai.egg-info/top_level.txt +1 -0
- doxa_ai-0.1.0/tests/test_agent_economics.py +73 -0
- doxa_ai-0.1.0/tests/test_api.py +92 -0
- doxa_ai-0.1.0/tests/test_engine_smoke.py +37 -0
- doxa_ai-0.1.0/tests/test_market_engine.py +554 -0
- doxa_ai-0.1.0/tests/test_relation_graph.py +75 -0
- doxa_ai-0.1.0/tests/test_scenarios.py +33 -0
- doxa_ai-0.1.0/tests/test_world_events.py +105 -0
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
|
+
```
|
doxa_ai-0.1.0/README.md
ADDED
|
@@ -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"
|
doxa_ai-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Doxa package init
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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
|