From: Jérôme Benoit Date: Wed, 23 Sep 2026 22:49:11 +0000 (+0200) Subject: fix(reforcexy): report marginal unmasked action probabilities X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=3f4a4c5fd5297da6c76b897f7fbd9ac4009e829a;p=freqai-strategies.git fix(reforcexy): report marginal unmasked action probabilities --- diff --git a/ReforceXY/reward_space_analysis/README.md b/ReforceXY/reward_space_analysis/README.md index 7d55875..0df5b6f 100644 --- a/ReforceXY/reward_space_analysis/README.md +++ b/ReforceXY/reward_space_analysis/README.md @@ -555,6 +555,11 @@ descriptive. | `partial_dependence_*.csv` | Partial dependence data | | `manifest.json` | Runtime manifest (simulation + reward params + hash) | +The `sample_entry_prob`, `sample_exit_prob`, and `sample_neutral_prob` columns in +`reward_samples.csv` report marginal probabilities of valid actions when applicable. +With masking disabled, these probabilities include the 90% valid-action mass; +they are not conditional on drawing a valid action. + ### Manifest (`manifest.json`) | Field | Type | Description | diff --git a/ReforceXY/reward_space_analysis/reward_space_analysis.py b/ReforceXY/reward_space_analysis/reward_space_analysis.py index f056d4d..ed6b312 100644 --- a/ReforceXY/reward_space_analysis/reward_space_analysis.py +++ b/ReforceXY/reward_space_analysis/reward_space_analysis.py @@ -1633,6 +1633,9 @@ def _sample_action( weights.extend( [_SAMPLE_INVALID_ACTION_PROBABILITY / len(invalid_choices)] * len(invalid_choices) ) + entry_prob *= valid_mass + exit_prob *= valid_mass + neutral_prob *= valid_mass action = rng.choices(choices, weights=weights, k=1)[0] return action, entry_prob, exit_prob, neutral_prob diff --git a/ReforceXY/reward_space_analysis/tests/README.md b/ReforceXY/reward_space_analysis/tests/README.md index 48f0453..474849e 100644 --- a/ReforceXY/reward_space_analysis/tests/README.md +++ b/ReforceXY/reward_space_analysis/tests/README.md @@ -227,6 +227,7 @@ Columns: | statistics-rank-direction-141 | statistics | Rank-biserial effect follows the named first-group advantage | statistics/test_statistics.py::test_pnl_rank_biserial_direction_matches_named_first_group | Checks both directions | | statistics-bootstrap-count-142 | statistics | Zero and negative resample counts fail for variable and constant data | statistics/test_statistics.py::test_bootstrap_rejects_nonpositive_resample_count | Rejects missing bootstrap | | integration-finite-report-143 | integration | Reports distinguish unusable real values from missing real episodes | integration/test_report_formatting.py::test_report_distinguishes_missing_real_episodes_from_unusable_observations | Section and summary agree | +| api-unmasked-sample-probabilities-144 | api | Unmasked sample probabilities match marginal valid-action frequencies | api/test_api_helpers.py::test_unmasked_sampling_probabilities_match_action_frequencies | Spot/futures entries, long/short exits, and neutral probability | ### Non-Owning Smoke / Reference Checks diff --git a/ReforceXY/reward_space_analysis/tests/api/test_api_helpers.py b/ReforceXY/reward_space_analysis/tests/api/test_api_helpers.py index e1e976e..2b76b19 100644 --- a/ReforceXY/reward_space_analysis/tests/api/test_api_helpers.py +++ b/ReforceXY/reward_space_analysis/tests/api/test_api_helpers.py @@ -83,6 +83,41 @@ class TestAPIAndHelpers(RewardSpaceTestBase): ) self.assertGreater(high_idle_rate_spot, low_idle_rate_spot) + def test_unmasked_sampling_probabilities_match_action_frequencies(self): + """Reported probabilities describe valid actions, not their conditional hazards.""" + draws = SCENARIOS.API_ENTRY_RATE_DRAWS + # Four standard errors using the maximum Bernoulli variance. + tolerance = 4 * math.sqrt(0.25 / draws) + cases = ( + (Positions.Neutral, False, (Actions.Long_enter,), 1), + (Positions.Neutral, True, (Actions.Long_enter, Actions.Short_enter), 1), + (Positions.Long, True, (Actions.Long_exit,), 2), + (Positions.Short, True, (Actions.Short_exit,), 2), + ) + for position, short_allowed, actions, probability_index in cases: + with self.subTest(position=position, short_allowed=short_allowed): + rng = random.Random(SEEDS.REPRODUCIBILITY) + samples = [ + _sample_action( + position, + rng, + short_allowed=short_allowed, + trade_duration=SCENARIOS.API_IDLE_DURATION_HIGH, + max_trade_duration_candles=SCENARIOS.API_MAX_IDLE_DURATION_CANDLES, + idle_duration=SCENARIOS.API_IDLE_DURATION_HIGH, + max_idle_duration_candles=SCENARIOS.API_MAX_IDLE_DURATION_CANDLES, + action_masking=False, + ) + for _ in range(draws) + ] + observed = sum(sample[0] in actions for sample in samples) / draws + self.assertAlmostEqual(samples[0][probability_index], observed, delta=tolerance) + if position == Positions.Neutral: + observed_neutral = ( + sum(sample[0] == Actions.Neutral for sample in samples) / draws + ) + self.assertAlmostEqual(samples[0][3], observed_neutral, delta=tolerance) + def test_parse_overrides(self): """Overrides accept canonical keys and reject unsupported keys.""" result = parse_overrides(