fix(live): sort the cash-only leverage sentinel below every number, not into the reject branch
#1285 closed three real narrowing bypasses (a stringified number widening a numeric limit, bool passing the numeric guard, and an unguarded instrument whitelist) but treated "none" as an invalid non-numeric type. "none" is this module's own default and the FLOOR of the leverage axis, so the gate refused the most conservative mandate a user can commit while the equivalent 1.0 was accepted: _profile_fits_ceilings({"leverage": "none"}, {"leverage": 2.0}) main: True #1285: False _normalize_leverage maps the sentinel (and None) to 1.0, a real non-bool number to itself, and everything else to None so both call sites still fail closed. _resolve_profile and _profile_fits_ceilings now compare on that one scale, which keeps every refusal #1285 added ("10" is still not a number, True is still not a number, a widened whitelist is still refused) and restores cash-only as a valid narrowing. A cash-only CEILING still forbids real leverage, since it normalizes to 1.0 on the same scale. Tests gain the column the branch was missing: every H19 case asserted a refusal, which cannot distinguish "blocks the right things" from "blocks everything", and that is how this regression survived two rounds of green CI. Added are the adjustments that must still commit (2.0 -> "none", 2.0 -> 1.0, unchanged, and a narrowed whitelist subset) and an eight-row ceiling table covering both sides. Verified by mutation: reverting the sentinel branch of _normalize_leverage turns five of the new tests red.
This commit is contained in:
1 parent
0d80e4b329
commit
8555a05aea
2 files changed
+133
-4
No files matched your search
@@ -54,6 +54,35 @@ def _is_real_number(value: Any) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
|
||||
|
||||
#: The leverage axis is the one clamped limit whose FLOOR is spelled as a word:
|
||||
#: ``"none"`` means cash only, and it is this module's own default
|
||||
#: (``leverage_raw = profile.get("leverage", "none")`` below). It is therefore
|
||||
#: not an "invalid non-numeric type" — it is the most conservative value on the
|
||||
#: axis, and it has to sort BELOW every number rather than fall into a
|
||||
#: fail-closed reject branch. Without this, narrowing 2x -> cash-only is refused
|
||||
#: while the equivalent 2x -> 1.0 is accepted, so the gate rejects the safest
|
||||
#: option a user can pick.
|
||||
_LEVERAGE_FLOOR_SENTINELS = ("none", None)
|
||||
|
||||
|
||||
def _normalize_leverage(value: Any) -> float | None:
|
||||
"""Return leverage as a comparable float, or ``None`` when it is not one.
|
||||
|
||||
Args:
|
||||
value: Raw leverage from a profile, ceiling snapshot or adjustment.
|
||||
|
||||
Returns:
|
||||
``1.0`` for the cash-only sentinel, the value itself for a real
|
||||
(non-bool) number, and ``None`` for anything else — a string like
|
||||
``"10"``, a bool, or any other type — so the caller fails closed.
|
||||
"""
|
||||
if value is None or (isinstance(value, str) and value.strip().casefold() == "none"):
|
||||
return 1.0
|
||||
if _is_real_number(value):
|
||||
return float(value)
|
||||
return None
|
||||
|
||||
|
||||
#: Maps every accepted alias of a clamped limit to its CANONICAL name. The
|
||||
#: proposal profile, the ceiling snapshot, and the clamp in
|
||||
#: ``propose_mandate_tool`` all use slightly different human-vs-schema spellings
|
||||
@@ -267,7 +296,25 @@ def _resolve_profile(
|
||||
if key not in resolved:
|
||||
raise CommitError(f"adjustment {key!r} is not a field of the selected profile")
|
||||
current = resolved[key]
|
||||
if _is_real_number(current):
|
||||
if key == "leverage" and (
|
||||
current in _LEVERAGE_FLOOR_SENTINELS or value in _LEVERAGE_FLOOR_SENTINELS
|
||||
):
|
||||
# Cash-only sits at the floor of this axis, so compare on the
|
||||
# normalized scale instead of by type. Narrowing 2x -> "none"
|
||||
# is the safest adjustment there is and must commit.
|
||||
current_lev = _normalize_leverage(current)
|
||||
new_lev = _normalize_leverage(value)
|
||||
if current_lev is None or new_lev is None:
|
||||
raise CommitError(
|
||||
f"adjustment {key!r}={value!r} has an invalid type for the rendered limit "
|
||||
f"{current!r}; leverage narrowing requires a number or \"none\""
|
||||
)
|
||||
if new_lev > current_lev:
|
||||
raise CommitError(
|
||||
f"adjustment {key!r}={value!r} widens the rendered limit {current!r}; "
|
||||
"widening must go through a fresh proposal"
|
||||
)
|
||||
elif _is_real_number(current):
|
||||
if not _is_real_number(value):
|
||||
raise CommitError(
|
||||
f"adjustment {key!r}={value!r} has an invalid type for the rendered limit "
|
||||
@@ -325,9 +372,15 @@ def _profile_fits_ceilings(profile: Mapping[str, Any], ceilings: Mapping[str, An
|
||||
if key not in prof:
|
||||
continue
|
||||
prof_value = prof[key]
|
||||
if key == "leverage" and ceiling_value == "none":
|
||||
# Cash-only ceiling forbids any leverage other than "none".
|
||||
if prof_value is True or prof_value is False or prof_value not in ("none", None, 1, 1.0):
|
||||
if key == "leverage":
|
||||
# One scale for both sides: "none" is the floor (1.0), not a type
|
||||
# error. A cash-only ceiling therefore still forbids any real
|
||||
# leverage, and a cash-only PROFILE still fits a numeric ceiling.
|
||||
ceiling_lev = _normalize_leverage(ceiling_value)
|
||||
prof_lev = _normalize_leverage(prof_value)
|
||||
if ceiling_lev is None or prof_lev is None:
|
||||
return False
|
||||
if prof_lev > ceiling_lev:
|
||||
return False
|
||||
elif key == "allowed_instruments":
|
||||
# A whitelist may only be narrowed, never widened at commit time.
|
||||
|
||||
@@ -672,3 +672,79 @@ def test_registry_has_propose_tool_but_no_mandate_writer() -> None:
|
||||
assert "propose_mandate_profiles" in names
|
||||
for forbidden in ("commit_mandate", "set_mandate", "write_mandate", "authorize_live"):
|
||||
assert forbidden not in names, f"{forbidden} must not be a registered tool"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The other column: adjustments and profiles that must STILL be accepted.
|
||||
#
|
||||
# Every H19 test above asserts a refusal. A gate tested only for what it blocks
|
||||
# reads identically whether it blocks the right things or everything, which is
|
||||
# how the cash-only regression survived nine green checks twice. These pin the
|
||||
# open side of the same gate, and "none" is the case that matters: it is this
|
||||
# module's own default and the floor of the leverage axis, not an invalid type.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"rendered, adjusted",
|
||||
[
|
||||
(2.0, "none"), # cash only: the safest narrowing a user can make
|
||||
(2.0, 1.0), # the same thing spelled as a number
|
||||
(2.0, 2.0), # unchanged
|
||||
("none", "none"), # already cash only, unchanged
|
||||
],
|
||||
)
|
||||
def test_leverage_narrowing_still_commits(rendered, adjusted) -> None:
|
||||
proposal = {
|
||||
"proposal_id": "p-open",
|
||||
"profiles": [{"ordinal": 1, "label": "conservative", "leverage": rendered}],
|
||||
}
|
||||
|
||||
resolved = mandate_commit._resolve_profile(proposal, 1, {"leverage": adjusted})
|
||||
|
||||
assert resolved["leverage"] == adjusted
|
||||
|
||||
|
||||
def test_leverage_widening_from_cash_only_is_still_refused() -> None:
|
||||
"""The floor sorts below every number in BOTH directions."""
|
||||
proposal = {
|
||||
"proposal_id": "p-widen",
|
||||
"profiles": [{"ordinal": 1, "label": "cash", "leverage": "none"}],
|
||||
}
|
||||
|
||||
with pytest.raises(mandate_commit.CommitError, match="widens"):
|
||||
mandate_commit._resolve_profile(proposal, 1, {"leverage": 2.0})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["10", "1", True, False, [2.0]])
|
||||
def test_leverage_adjustment_of_an_invalid_type_is_still_refused(bad) -> None:
|
||||
"""Normalizing the sentinel must not re-open the H19 type hole."""
|
||||
proposal = {
|
||||
"proposal_id": "p-type",
|
||||
"profiles": [{"ordinal": 1, "label": "conservative", "leverage": 2.0}],
|
||||
}
|
||||
|
||||
with pytest.raises(mandate_commit.CommitError):
|
||||
mandate_commit._resolve_profile(proposal, 1, {"leverage": bad})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"profile_leverage, ceiling_leverage, fits",
|
||||
[
|
||||
("none", 2.0, True), # cash-only profile under a leveraged ceiling
|
||||
(1.0, 2.0, True),
|
||||
(2.0, 2.0, True),
|
||||
(4.0, 2.0, False),
|
||||
("none", "none", True), # cash-only under a cash-only ceiling
|
||||
(2.0, "none", False), # leverage under a cash-only ceiling
|
||||
("10", 2.0, False), # a stringified number is still not a number
|
||||
(True, 2.0, False), # bool is still not a number
|
||||
],
|
||||
)
|
||||
def test_leverage_ceiling_table(profile_leverage, ceiling_leverage, fits) -> None:
|
||||
assert (
|
||||
mandate_commit._profile_fits_ceilings(
|
||||
{"leverage": profile_leverage}, {"leverage": ceiling_leverage}
|
||||
)
|
||||
is fits
|
||||
)
|
||||
Reference in new issue
Block a user