Skip to content

Commit 52348e8

Browse files
feat(eval): information-disclosure difficulty axis (obs richness x regime grid)
Orthogonal to regime-based difficulty: ObservationRichness (DataPoor/Standard/DataRich) parameterizes lookback + fundamentals/news disclosure to measure information efficiency / data-poverty robustness. Fully additive (default byte-identical, no CONTRACT_VERSION bump), leak-free preserved under every tier. From the EdgeBench/SForge scan.
2 parents 31bd498 + 6bdeb1a commit 52348e8

8 files changed

Lines changed: 670 additions & 31 deletions

File tree

EVALUATION.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,38 @@ within-tier gap cannot see. The Rust core exposes the protocol primitive
8181
sibling of `train_test_split`). Reporting a `calm -> hard` and a `calm -> extreme` transfer
8282
gap alongside the within-tier generalization gap is strictly stronger evidence of robustness.
8383

84+
## Information disclosure (an orthogonal difficulty axis)
85+
86+
`distribution_mode` sets how adversarial the price *path* is; it says nothing about how
87+
much of that market the agent is allowed to *see*. Those are independent axes. A Calm panel
88+
shown through a 3-bar window with no fundamentals or news can be genuinely harder than an
89+
Extreme panel shown through a 50-bar history with full context: the first measures
90+
**information efficiency** and robustness to **data poverty** (the real trading axis of
91+
"information edge") which the regime tiers structurally cannot probe. A scenario is
92+
therefore a point on a 2-D grid: `(distribution_mode × observation_richness)`.
93+
94+
The shared-book market (`PyMarketClearing` / `EndogenousMarketEnv`) takes a `richness`
95+
tier orthogonal to `distribution_mode`:
96+
97+
| Tier | Trailing lookback | Fundamentals | News |
98+
|---|---|---|---|
99+
| `data_poor` | 3 bars | no | no |
100+
| `standard` | 20 bars | no | no |
101+
| `data_rich` | 50 bars | yes | yes |
102+
103+
`standard` is the historical default disclosure, so a scenario built without a `richness`
104+
setting is byte-identical to `standard`: the axis is strictly additive (no
105+
`CONTRACT_VERSION` bump). Richer disclosure only ever surfaces **more past / contextual**
106+
information: the extra bars, the derived `fundamentals` (`trailing_return`, `window_high`,
107+
`window_low`), and the `news` headline are all computed from the same leak-free trailing
108+
closes (`<= t`), so the point-in-time invariant holds at every tier, and no tier reveals a
109+
future bar. Sweeping the same regime across the three tiers isolates how much of an agent's
110+
edge is real signal-processing versus a dependence on being handed a rich observation; a
111+
policy whose deflated Sharpe collapses from `data_rich` to `data_poor` on an otherwise
112+
identical panel is riding disclosure, not skill. Report the tier alongside the
113+
`distribution_mode` (Rust: `MarketClearing::from_dataset_with_richness` /
114+
`RichnessTier`; Python: `PyMarketClearing(..., richness=...)`).
115+
84116
## Statistical confidence (is A > B beyond seed noise?)
85117

86118
Deflation handles overfit-luck and pass^k handles per-run reliability, but a leaderboard has

crates/openoutcry-py/python/openoutcry/market_env.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,11 @@
4343
small documented JSON interface:
4444
4545
* ``PyMarketClearing(n_symbols, n_days, seed, n_agents, capital, kyle_lambda, eta,
46-
volume_scale, distribution_mode)``
46+
volume_scale, distribution_mode, richness)``. ``richness`` (``data_poor`` | ``standard``
47+
| ``data_rich``) is the information-disclosure difficulty axis, orthogonal to
48+
``distribution_mode``: it sets how much of the market each observation surfaces (trailing
49+
lookback + optional fundamentals/news), never revealing a future bar. ``standard`` is the
50+
historical default disclosure.
4751
* ``reset_market() -> json``: ``{symbols, n_agents, n_bars, start_bar, cursor, capital,
4852
observations:[MarketObservation, ...]}`` (observations in canonical agent order).
4953
* ``step_market(orders_json) -> json``: ``orders_json`` is a JSON array of shape
@@ -120,6 +124,7 @@ def __init__(
120124
volume_scale: float = 1.0,
121125
vol_scale: float = 0.0,
122126
distribution_mode: str = "calm",
127+
richness: str = "standard",
123128
max_weight: float = 1.0,
124129
allow_short: bool = True,
125130
) -> None:
@@ -141,6 +146,7 @@ def __init__(
141146
self._volume_scale = float(volume_scale)
142147
self._vol_scale = float(vol_scale)
143148
self._distribution_mode = str(distribution_mode)
149+
self._richness = str(richness)
144150
self._max_weight = float(max_weight)
145151
self._allow_short = bool(allow_short)
146152

@@ -174,17 +180,32 @@ def _build_market(self) -> None:
174180
volume_scale=self._volume_scale,
175181
vol_scale=self._vol_scale,
176182
distribution_mode=self._distribution_mode,
183+
richness=self._richness,
177184
)
178185
try:
179186
self._market = PyMarketClearing(**kwargs)
187+
return
180188
except TypeError:
181-
# The native binding predates the vol_scale param (parallel-build interim). Fall
182-
# back to the legacy signature only when vol scaling is off, so default behavior
183-
# is unchanged; a requested vol_scale > 0 still surfaces the error.
184-
if self._vol_scale != 0.0:
185-
raise
186-
kwargs.pop("vol_scale")
189+
pass
190+
# An older native binding may predate the newest optional params (richness, then
191+
# vol_scale). Drop them only when they sit at their defaults, so default behavior is
192+
# unchanged; a non-default request for a missing param still surfaces the error.
193+
if self._richness != "standard":
194+
raise TypeError(
195+
"the native binding predates the 'richness' parameter (needs a rebuild)"
196+
)
197+
kwargs.pop("richness")
198+
try:
187199
self._market = PyMarketClearing(**kwargs)
200+
return
201+
except TypeError:
202+
pass
203+
if self._vol_scale != 0.0:
204+
raise TypeError(
205+
"the native binding predates the 'vol_scale' parameter (needs a rebuild)"
206+
)
207+
kwargs.pop("vol_scale")
208+
self._market = PyMarketClearing(**kwargs)
188209

189210
def _build_spaces(self) -> None:
190211
if not _HAS_GYM: # pragma: no cover - gymnasium is a hard dep of the package

crates/openoutcry-py/src/lib.rs

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use openoutcry::market::{MarketClearing, MarketParams};
1313
use openoutcry::vec_env::AutoresetMode;
1414
use openoutcry::{
1515
generate_scenario, CostModel, Dataset, Decision, DistributionMode, LaneConfig, Mandate,
16-
ScenarioSpec, TradingEnv as CoreEnv, VecTradingEnv as CoreVecEnv, Window,
16+
RichnessTier, ScenarioSpec, TradingEnv as CoreEnv, VecTradingEnv as CoreVecEnv, Window,
1717
};
1818
use pyo3::exceptions::PyValueError;
1919
use pyo3::prelude::*;
@@ -36,6 +36,20 @@ fn parse_distribution_mode(mode: &str) -> PyResult<DistributionMode> {
3636
}
3737
}
3838

39+
/// Parse the wire `richness` label into a [`RichnessTier`], the information-disclosure
40+
/// difficulty axis orthogonal to `distribution_mode`. `standard` is the historical default
41+
/// disclosure, so an unset richness reproduces the prior observations byte-for-byte.
42+
fn parse_richness_tier(richness: &str) -> PyResult<RichnessTier> {
43+
match richness {
44+
"data_poor" => Ok(RichnessTier::DataPoor),
45+
"standard" => Ok(RichnessTier::Standard),
46+
"data_rich" => Ok(RichnessTier::DataRich),
47+
other => Err(PyValueError::new_err(format!(
48+
"unknown richness {other:?} (expected data_poor | standard | data_rich)"
49+
))),
50+
}
51+
}
52+
3953
/// Build the synthetic dataset for a tier: `Calm` is the mild panel; `Hard`/`Extreme`
4054
/// post-process that same seeded panel (see `openoutcry::generate_scenario`).
4155
fn build_dataset(n_symbols: usize, n_days: usize, seed: u64, mode: DistributionMode) -> Dataset {
@@ -608,6 +622,7 @@ impl PyMarketClearing {
608622
volume_scale = 1.0,
609623
vol_scale = 0.0,
610624
distribution_mode = "calm",
625+
richness = "standard",
611626
))]
612627
#[allow(clippy::too_many_arguments)]
613628
fn new(
@@ -621,13 +636,16 @@ impl PyMarketClearing {
621636
volume_scale: f64,
622637
vol_scale: f64,
623638
distribution_mode: &str,
639+
richness: &str,
624640
) -> PyResult<Self> {
625641
if n_agents < 1 {
626642
return Err(PyValueError::new_err("n_agents must be >= 1"));
627643
}
628644
let mode = parse_distribution_mode(distribution_mode)?;
645+
let tier = parse_richness_tier(richness)?;
629646
let data = build_dataset(n_symbols, n_days, seed, mode);
630-
let inner = MarketClearing::from_dataset(&data, n_agents, capital);
647+
let inner =
648+
MarketClearing::from_dataset_with_richness(&data, n_agents, capital, tier.richness());
631649
let params = MarketParams {
632650
lambda: kyle_lambda,
633651
eta,
@@ -646,6 +664,19 @@ impl PyMarketClearing {
646664
self.seed
647665
}
648666

667+
/// The active observation-richness disclosure as a JSON object
668+
/// `{lookback, fundamentals, news}`, the information-poverty difficulty axis.
669+
#[getter]
670+
fn richness(&self) -> String {
671+
let r = self.inner.richness();
672+
serde_json::json!({
673+
"lookback": r.lookback,
674+
"fundamentals": r.fundamentals,
675+
"news": r.news,
676+
})
677+
.to_string()
678+
}
679+
649680
#[getter]
650681
fn symbols(&self) -> Vec<String> {
651682
self.inner.symbols().to_vec()
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""The information-disclosure difficulty axis (observation richness).
2+
3+
These exercise the native ``PyMarketClearing`` directly (no pettingzoo needed): the
4+
``richness`` tier is orthogonal to ``distribution_mode`` and controls how much of the
5+
market each observation surfaces. ``standard`` must reproduce the historical disclosure
6+
byte-for-byte; ``data_poor`` withholds bars and optional fields; ``data_rich`` surfaces
7+
more bars plus fundamentals and news. None of them ever reveal a future bar.
8+
"""
9+
10+
import json
11+
12+
import pytest
13+
14+
from openoutcry.openoutcry_py import PyMarketClearing
15+
16+
17+
def _rollout(market, orders, steps=6):
18+
"""The reset observations plus each step's observations, as parsed JSON."""
19+
log = [json.loads(market.reset_market())["observations"]]
20+
for _ in range(steps):
21+
result = json.loads(market.step_market(json.dumps(orders)))
22+
log.append(result["observations"])
23+
if result["done"]:
24+
break
25+
return log
26+
27+
28+
def _flat_orders(n_agents, n_symbols):
29+
return [[0.0] * n_symbols for _ in range(n_agents)]
30+
31+
32+
def test_default_richness_is_standard_and_byte_identical():
33+
"""Omitting ``richness`` and passing ``standard`` clear a byte-identical stream."""
34+
common = dict(n_symbols=3, n_days=60, seed=4, n_agents=2, capital=1.0)
35+
orders = [[0.3, 0.3, 0.3], [0.3, 0.3, 0.3]]
36+
default_market = PyMarketClearing(**common)
37+
standard_market = PyMarketClearing(**common, richness="standard")
38+
assert _rollout(default_market, orders) == _rollout(standard_market, orders)
39+
40+
41+
def test_richness_getter_reports_the_active_disclosure():
42+
poor = json.loads(PyMarketClearing(n_symbols=2, n_days=40, richness="data_poor").richness)
43+
std = json.loads(PyMarketClearing(n_symbols=2, n_days=40, richness="standard").richness)
44+
rich = json.loads(PyMarketClearing(n_symbols=2, n_days=40, richness="data_rich").richness)
45+
assert poor == {"lookback": 3, "fundamentals": False, "news": False}
46+
assert std == {"lookback": 20, "fundamentals": False, "news": False}
47+
assert rich == {"lookback": 50, "fundamentals": True, "news": True}
48+
49+
50+
def test_data_poor_withholds_bars_and_optional_fields():
51+
market = PyMarketClearing(n_symbols=2, n_days=60, seed=7, n_agents=2, richness="data_poor")
52+
obs = json.loads(market.reset_market())["observations"]
53+
for agent_obs in obs:
54+
for snap in agent_obs["symbols"]:
55+
assert len(snap["close_history"]) <= 3
56+
assert snap["fundamentals"] == {}
57+
assert snap["news"] == []
58+
59+
60+
def test_data_rich_surfaces_more_bars_and_populates_fields():
61+
rich = PyMarketClearing(n_symbols=2, n_days=120, seed=9, n_agents=2, richness="data_rich")
62+
standard = PyMarketClearing(n_symbols=2, n_days=120, seed=9, n_agents=2, richness="standard")
63+
rich_obs = json.loads(rich.reset_market())["observations"]
64+
std_obs = json.loads(standard.reset_market())["observations"]
65+
for ro, so in zip(rich_obs, std_obs):
66+
for rs, ss in zip(ro["symbols"], so["symbols"]):
67+
assert len(rs["close_history"]) > len(ss["close_history"])
68+
assert len(rs["close_history"]) <= 50
69+
assert set(rs["fundamentals"]) == {"trailing_return", "window_high", "window_low"}
70+
assert len(rs["news"]) == 1
71+
assert rs["symbol"] in rs["news"][0]
72+
73+
74+
def test_every_tier_is_leak_free_last_close_is_this_bars_cleared_mid():
75+
"""Under every tier the last surfaced close equals this bar's cleared mid (never a
76+
future bar), and the surfaced window never exceeds the cleared-bar count."""
77+
for tier in ("data_poor", "standard", "data_rich"):
78+
market = PyMarketClearing(
79+
n_symbols=3, n_days=80, seed=2, n_agents=2, richness=tier
80+
)
81+
meta = json.loads(market.reset_market())
82+
cleared_bars = meta["start_bar"]
83+
orders = _flat_orders(2, 3)
84+
while True:
85+
result = json.loads(market.step_market(json.dumps(orders)))
86+
cleared_bars += 1
87+
mids = result["cleared_mids"]
88+
for agent_obs in result["observations"]:
89+
for s, snap in enumerate(agent_obs["symbols"]):
90+
assert snap["close_history"][-1] == mids[s]
91+
assert len(snap["close_history"]) <= cleared_bars
92+
if result["done"]:
93+
break
94+
95+
96+
def test_unknown_richness_raises():
97+
with pytest.raises(ValueError, match="unknown richness"):
98+
PyMarketClearing(n_symbols=2, n_days=40, richness="bogus")

crates/openoutcry/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ pub use scenario_gen::{
3333
ScenarioSpec,
3434
};
3535

36+
// --- Information-disclosure difficulty (the axis orthogonal to the regime tiers) -----------
37+
38+
pub mod richness;
39+
pub use richness::{ObservationRichness, RichnessTier, DEFAULT_LOOKBACK};
40+
3641
// --- Adaptive difficulty-targeting curriculum (Prioritized Level Replay) -------------------
3742

3843
pub mod curriculum;

0 commit comments

Comments
 (0)