]> Piment Noir Git Repositories - freqai-strategies.git/commitdiff
fix(reforcexy): align reward costs and model selection (#294)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Tue, 20 Oct 2026 21:06:24 +0000 (23:06 +0200)
committerGitHub <noreply@github.com>
Tue, 20 Oct 2026 21:06:24 +0000 (23:06 +0200)
Retain temporal costs on invalid actions and select policies on non-PBRS evaluation scores.

Advance reward compatibility, correct terminal analytical transitions and synthetic duration bounds, and reuse canonical architecture and analytical definitions.

Update the mathematical reference, behavioral regressions and BasedPyright snapshot.

15 files changed:
ReforceXY/.basedpyright/diagnostics.json
ReforceXY/README.md
ReforceXY/reward_space_analysis/README.md
ReforceXY/reward_space_analysis/reward_space_analysis.py
ReforceXY/reward_space_analysis/tests/README.md
ReforceXY/reward_space_analysis/tests/api/test_api_helpers.py
ReforceXY/reward_space_analysis/tests/components/test_reward_components.py
ReforceXY/reward_space_analysis/tests/constants.py
ReforceXY/reward_space_analysis/tests/pbrs/test_pbrs.py
ReforceXY/reward_space_analysis/tests/robustness/test_robustness.py
ReforceXY/tests/test_model_optuna_lifecycle.py
ReforceXY/tests/test_model_optuna_storage.py
ReforceXY/tests/test_model_reward_contracts.py [new file with mode: 0644]
ReforceXY/tests/test_model_training_observations.py
ReforceXY/user_data/freqaimodels/ReforceXY.py

index 146010e4e9e0658e1a9ec7525d7df582b447fe12..fa570c2c31dee9d5cc50f55b1ac6947ef38381da 100644 (file)
     },
     {
       "endCharacter": 5,
-      "endLine": 2210,
+      "endLine": 2235,
       "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
       "message": "No overloads for \"cut\" match the provided arguments",
       "rule": "reportCallIssue",
       "severity": "error",
       "startCharacter": 17,
-      "startLine": 2205
+      "startLine": 2230
     },
     {
       "endCharacter": 21,
-      "endLine": 2207,
+      "endLine": 2232,
       "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
       "message": "Argument of type \"_Array1D[Any]\" cannot be assigned to parameter \"bins\" of type \"int | Sequence[float] | Index[int] | Index[float] | IntervalIndex[Interval[Any]] | Series[Any]\" in function \"cut\"\n  Type \"_Array1D[Any]\" is not assignable to type \"int | Sequence[float] | Index[int] | Index[float] | IntervalIndex[Interval[Any]] | Series[Any]\"\n    \"ndarray[tuple[int], dtype[Any]]\" is not assignable to \"int\"\n    \"ndarray[tuple[int], dtype[Any]]\" is not assignable to \"Sequence[float]\"\n    \"ndarray[tuple[int], dtype[Any]]\" is not assignable to \"Index[int]\"\n    \"ndarray[tuple[int], dtype[Any]]\" is not assignable to \"Index[float]\"\n    \"ndarray[tuple[int], dtype[Any]]\" is not assignable to \"IntervalIndex[Interval[Any]]\"\n    \"ndarray[tuple[int], dtype[Any]]\" is not assignable to \"Series[Any]\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 13,
-      "startLine": 2207
+      "startLine": 2232
     },
     {
       "endCharacter": 5,
-      "endLine": 2273,
+      "endLine": 2298,
       "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
       "message": "Type \"dict[bytes, bytes] | dict[str, RewardParamValue]\" is not assignable to declared type \"RewardParams\"\n  Type \"dict[bytes, bytes] | dict[str, RewardParamValue]\" is not assignable to type \"RewardParams\"\n    \"dict[bytes, bytes]\" is not assignable to \"dict[str, RewardParamValue]\"\n      Type parameter \"_KT@dict\" is invariant, but \"bytes\" is not the same as \"str\"\n      Type parameter \"_VT@dict\" is invariant, but \"bytes\" is not the same as \"RewardParamValue\"",
       "rule": "reportAssignmentType",
       "severity": "error",
       "startCharacter": 34,
-      "startLine": 2269
+      "startLine": 2294
     },
     {
       "endCharacter": 43,
-      "endLine": 2270,
+      "endLine": 2295,
       "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
       "message": "No overloads for \"__init__\" match the provided arguments",
       "rule": "reportCallIssue",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 2270
+      "startLine": 2295
     },
     {
       "endCharacter": 42,
-      "endLine": 2270,
+      "endLine": 2295,
       "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"iterable\" of type \"Iterable[list[bytes]]\" in function \"__init__\"\n  Type \"Any | None\" is not assignable to type \"Iterable[list[bytes]]\"\n    \"None\" is incompatible with protocol \"Iterable[list[bytes]]\"\n      \"__iter__\" is not present",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 13,
-      "startLine": 2270
+      "startLine": 2295
     },
     {
       "endCharacter": 60,
-      "endLine": 2532,
+      "endLine": 2557,
       "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
       "message": "Cannot access attribute \"importances_mean\" for class \"dict[Unknown, Bunch]\"\n  Attribute \"importances_mean\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 44,
-      "startLine": 2532
+      "startLine": 2557
     },
     {
       "endCharacter": 58,
-      "endLine": 2533,
+      "endLine": 2558,
       "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
       "message": "Cannot access attribute \"importances_std\" for class \"dict[Unknown, Bunch]\"\n  Attribute \"importances_std\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 43,
-      "startLine": 2533
+      "startLine": 2558
     },
     {
       "endCharacter": 88,
-      "endLine": 2552,
+      "endLine": 2577,
       "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
       "message": "Cannot access attribute \"columns\" for class \"NDArray[Unknown]\"\n  Attribute \"columns\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 81,
-      "startLine": 2552
+      "startLine": 2577
     },
     {
       "endCharacter": 88,
-      "endLine": 2552,
+      "endLine": 2577,
       "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
       "message": "Cannot access attribute \"columns\" for class \"list[Unknown]\"\n  Attribute \"columns\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 81,
-      "startLine": 2552
+      "startLine": 2577
     },
     {
       "endCharacter": 17,
-      "endLine": 2561,
+      "endLine": 2586,
       "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
       "message": "Object of type \"None\" cannot be called",
       "rule": "reportOptionalCall",
       "severity": "error",
       "startCharacter": 28,
-      "startLine": 2555
+      "startLine": 2580
     },
     {
       "endCharacter": 29,
-      "endLine": 3162,
+      "endLine": 3187,
       "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
       "message": "No overloads for \"ptp\" match the provided arguments",
       "rule": "reportCallIssue",
       "severity": "error",
       "startCharacter": 11,
-      "startLine": 3162
+      "startLine": 3187
     },
     {
       "endCharacter": 28,
-      "endLine": 3162,
+      "endLine": 3187,
       "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
       "message": "Argument of type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" cannot be assigned to parameter \"a\" of type \"_ArrayLikeNumeric_co\" in function \"ptp\"\n  Type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" is not assignable to type \"_ArrayLikeNumeric_co\"\n    Type \"ExtensionArray\" is not assignable to type \"_ArrayLikeNumeric_co\"\n      \"ExtensionArray\" is incompatible with protocol \"_SupportsArray[dtype[number[Any, Any] | numpy.bool[builtins.bool] | object_ | timedelta64[Any]]]\"\n        \"__array__\" is not present\n      \"ExtensionArray\" is incompatible with protocol \"_NestedSequence[_SupportsArray[dtype[number[Any, Any] | numpy.bool[builtins.bool] | object_ | timedelta64[Any]]]]\"\n        \"__reversed__\" is not present\n        \"count\" is not present\n        \"index\" is not present\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 18,
-      "startLine": 3162
+      "startLine": 3187
     },
     {
       "endCharacter": 65,
-      "endLine": 3171,
+      "endLine": 3196,
       "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
       "message": "Cannot access attribute \"mean\" for class \"Categorical[object]\"\n  Attribute \"mean\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 61,
-      "startLine": 3171
+      "startLine": 3196
     },
     {
       "endCharacter": 65,
-      "endLine": 3171,
+      "endLine": 3196,
       "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
       "message": "Cannot access attribute \"mean\" for class \"ExtensionArray\"\n  Attribute \"mean\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 61,
-      "startLine": 3171
+      "startLine": 3196
     },
     {
       "endCharacter": 5,
-      "endLine": 4083,
+      "endLine": 4108,
       "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
       "message": "Type \"dict[bytes, bytes] | dict[str, RewardParamValue]\" is not assignable to declared type \"RewardParams\"\n  Type \"dict[bytes, bytes] | dict[str, RewardParamValue]\" is not assignable to type \"RewardParams\"\n    \"dict[bytes, bytes]\" is not assignable to \"dict[str, RewardParamValue]\"\n      Type parameter \"_KT@dict\" is invariant, but \"bytes\" is not the same as \"str\"\n      Type parameter \"_VT@dict\" is invariant, but \"bytes\" is not the same as \"RewardParamValue\"",
       "rule": "reportAssignmentType",
       "severity": "error",
       "startCharacter": 34,
-      "startLine": 4079
+      "startLine": 4104
     },
     {
       "endCharacter": 43,
-      "endLine": 4080,
+      "endLine": 4105,
       "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
       "message": "No overloads for \"__init__\" match the provided arguments",
       "rule": "reportCallIssue",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 4080
+      "startLine": 4105
     },
     {
       "endCharacter": 42,
-      "endLine": 4080,
+      "endLine": 4105,
       "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"iterable\" of type \"Iterable[list[bytes]]\" in function \"__init__\"\n  Type \"Any | None\" is not assignable to type \"Iterable[list[bytes]]\"\n    \"None\" is incompatible with protocol \"Iterable[list[bytes]]\"\n      \"__iter__\" is not present",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 13,
-      "startLine": 4080
+      "startLine": 4105
     },
     {
       "endCharacter": 22,
-      "endLine": 1086,
+      "endLine": 1122,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Cannot assign to attribute \"train_env\" for class \"ReforceXY*\"\n  Expression of type \"VecEnv\" cannot be assigned to attribute \"train_env\" of class \"ReforceXY\"\n    Type \"VecEnv\" is not assignable to type \"VecMonitor | SubprocVecEnv | Env[Unknown, Unknown]\"\n      \"VecEnv\" is not assignable to \"VecMonitor\"\n      \"VecEnv\" is not assignable to \"SubprocVecEnv\"\n      \"VecEnv\" is not assignable to \"Env[Unknown, Unknown]\"",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 13,
-      "startLine": 1086
+      "startLine": 1122
     },
     {
       "endCharacter": 37,
-      "endLine": 1086,
+      "endLine": 1122,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Cannot assign to attribute \"eval_env\" for class \"ReforceXY*\"\n  Expression of type \"VecEnv | None\" cannot be assigned to attribute \"eval_env\" of class \"ReforceXY\"\n    Type \"VecEnv | None\" is not assignable to type \"VecMonitor | SubprocVecEnv | Env[Unknown, Unknown]\"\n      Type \"VecEnv\" is not assignable to type \"VecMonitor | SubprocVecEnv | Env[Unknown, Unknown]\"\n        \"VecEnv\" is not assignable to \"VecMonitor\"\n        \"VecEnv\" is not assignable to \"SubprocVecEnv\"\n        \"VecEnv\" is not assignable to \"Env[Unknown, Unknown]\"",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 29,
-      "startLine": 1086
+      "startLine": 1122
     },
     {
       "endCharacter": 24,
-      "endLine": 1347,
+      "endLine": 1384,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"VecEnv\" cannot be assigned to parameter \"eval_env\" of type \"BaseEnvironment\" in function \"__init__\"\n  \"VecEnv\" is not assignable to \"BaseEnvironment\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 16,
-      "startLine": 1347
+      "startLine": 1384
     },
     {
       "endCharacter": 38,
-      "endLine": 1580,
+      "endLine": 1617,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"obj\" of type \"Sized\" in function \"len\"\n  Type \"Any | None\" is not assignable to type \"Sized\"\n    \"None\" is incompatible with protocol \"Sized\"\n      \"__len__\" is not present",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 30,
-      "startLine": 1580
+      "startLine": 1617
     },
     {
       "endCharacter": 36,
-      "endLine": 1584,
+      "endLine": 1621,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"obj\" of type \"Sized\" in function \"len\"\n  Type \"Any | None\" is not assignable to type \"Sized\"\n    \"None\" is incompatible with protocol \"Sized\"\n      \"__len__\" is not present",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 29,
-      "startLine": 1584
+      "startLine": 1621
     },
     {
       "endCharacter": 80,
-      "endLine": 1587,
+      "endLine": 1624,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"timeframe\" of type \"str\" in function \"steps_to_days\"\n  Type \"Any | None\" is not assignable to type \"str\"\n    \"None\" is not assignable to \"str\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 52,
-      "startLine": 1587
+      "startLine": 1624
     },
     {
       "endCharacter": 78,
-      "endLine": 1588,
+      "endLine": 1625,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"timeframe\" of type \"str\" in function \"steps_to_days\"\n  Type \"Any | None\" is not assignable to type \"str\"\n    \"None\" is not assignable to \"str\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 50,
-      "startLine": 1588
+      "startLine": 1625
     },
     {
       "endCharacter": 80,
-      "endLine": 1589,
+      "endLine": 1626,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"timeframe\" of type \"str\" in function \"steps_to_days\"\n  Type \"Any | None\" is not assignable to type \"str\"\n    \"None\" is not assignable to \"str\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 52,
-      "startLine": 1589
+      "startLine": 1626
     },
     {
       "endCharacter": 56,
-      "endLine": 1673,
+      "endLine": 1710,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"VecMonitor | SubprocVecEnv | Env[Unknown, Unknown]\" cannot be assigned to parameter \"eval_env\" of type \"VecEnv | None\" in function \"get_callbacks\"\n  Type \"VecMonitor | SubprocVecEnv | Env[Unknown, Unknown]\" is not assignable to type \"VecEnv | None\"\n    Type \"Env[Unknown, Unknown]\" is not assignable to type \"VecEnv | None\"\n      \"Env[Unknown, Unknown]\" is not assignable to \"VecEnv\"\n      \"Env[Unknown, Unknown]\" is not assignable to \"None\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 43,
-      "startLine": 1673
+      "startLine": 1710
     },
     {
       "endCharacter": 84,
-      "endLine": 1693,
+      "endLine": 1730,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"float | list[float]\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"float | list[float]\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"list[float]\" is not assignable to type \"ConvertibleToFloat\"\n      \"list[float]\" is not assignable to \"str\"\n      \"list[float]\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"list[float]\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"list[float]\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 67,
-      "startLine": 1693
+      "startLine": 1730
     },
     {
       "endCharacter": 84,
-      "endLine": 1693,
+      "endLine": 1730,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"float | list[float]\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"float | list[float]\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"list[float]\" is not assignable to type \"ConvertibleToFloat\"\n      \"list[float]\" is not assignable to \"str\"\n      \"list[float]\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"list[float]\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"list[float]\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 67,
-      "startLine": 1693
+      "startLine": 1730
     },
     {
       "endCharacter": 44,
-      "endLine": 2771,
+      "endLine": 2808,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "\"best_trial_params\" is possibly unbound",
       "rule": "reportPossiblyUnboundVariable",
       "severity": "error",
       "startCharacter": 27,
-      "startLine": 2771
+      "startLine": 2808
     },
     {
       "endCharacter": 12,
-      "endLine": 2791,
+      "endLine": 2828,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Parameter declaration \"seed\" is obscured by a declaration of the same name",
       "rule": "reportRedeclaration",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 2791
+      "startLine": 2828
     },
     {
       "endCharacter": 16,
-      "endLine": 2792,
+      "endLine": 2829,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Parameter declaration \"env_info\" is obscured by a declaration of the same name",
       "rule": "reportRedeclaration",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 2792
+      "startLine": 2829
     },
     {
       "endCharacter": 51,
-      "endLine": 2837,
+      "endLine": 2874,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"list[() -> BaseEnvironment]\" cannot be assigned to parameter \"env_fns\" of type \"list[() -> Env[Unknown, Unknown]]\" in function \"__init__\"\n  \"list[() -> BaseEnvironment]\" is not assignable to \"list[() -> Env[Unknown, Unknown]]\"\n    Type parameter \"_T@list\" is invariant, but \"() -> BaseEnvironment\" is not the same as \"() -> Env[Unknown, Unknown]\"\n    Consider switching from \"list\" to \"Sequence\" which is covariant",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 42,
-      "startLine": 2837
+      "startLine": 2874
     },
     {
       "endCharacter": 53,
-      "endLine": 2843,
+      "endLine": 2880,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"list[() -> BaseEnvironment]\" cannot be assigned to parameter \"env_fns\" of type \"list[() -> Env[Unknown, Unknown]]\" in function \"__init__\"\n  \"list[() -> BaseEnvironment]\" is not assignable to \"list[() -> Env[Unknown, Unknown]]\"\n    Type parameter \"_T@list\" is invariant, but \"() -> BaseEnvironment\" is not the same as \"() -> Env[Unknown, Unknown]\"\n    Consider switching from \"list\" to \"Sequence\" which is covariant",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 45,
-      "startLine": 2843
+      "startLine": 2880
     },
     {
       "endCharacter": 22,
-      "endLine": 2906,
+      "endLine": 2943,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Operator \"*\" not supported for \"None\"",
       "rule": "reportOptionalOperand",
       "severity": "error",
       "startCharacter": 15,
-      "startLine": 2906
+      "startLine": 2943
     },
     {
       "endCharacter": 96,
-      "endLine": 2908,
+      "endLine": 2945,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Operator \"*\" not supported for \"None\"",
       "rule": "reportOptionalOperand",
       "severity": "error",
       "startCharacter": 89,
-      "startLine": 2908
+      "startLine": 2945
     },
     {
       "endCharacter": 23,
-      "endLine": 2911,
+      "endLine": 2948,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Operator \"*\" not supported for \"None\"",
       "rule": "reportOptionalOperand",
       "severity": "error",
       "startCharacter": 16,
-      "startLine": 2911
+      "startLine": 2948
     },
     {
       "endCharacter": 98,
-      "endLine": 2913,
+      "endLine": 2950,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Operator \"*\" not supported for \"None\"",
       "rule": "reportOptionalOperand",
       "severity": "error",
       "startCharacter": 91,
-      "startLine": 2913
+      "startLine": 2950
     },
     {
       "endCharacter": 44,
-      "endLine": 2925,
+      "endLine": 2962,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Operator \"*\" not supported for types \"Any | None\" and \"Any | int | None\"\n  Operator \"*\" not supported for types \"None\" and \"None\"\n  Operator \"*\" not supported for types \"None\" and \"int\"",
       "rule": "reportOperatorIssue",
       "severity": "error",
       "startCharacter": 15,
-      "startLine": 2925
+      "startLine": 2962
     },
     {
       "endCharacter": 133,
-      "endLine": 2927,
+      "endLine": 2964,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Operator \"*\" not supported for types \"Any | None\" and \"Any | int | None\"\n  Operator \"*\" not supported for types \"None\" and \"None\"\n  Operator \"*\" not supported for types \"None\" and \"int\"",
       "rule": "reportOperatorIssue",
       "severity": "error",
       "startCharacter": 106,
-      "startLine": 2927
+      "startLine": 2964
     },
     {
       "endCharacter": 30,
-      "endLine": 2930,
+      "endLine": 2967,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Operator \">\" not supported for \"None\"",
       "rule": "reportOptionalOperand",
       "severity": "error",
       "startCharacter": 15,
-      "startLine": 2930
+      "startLine": 2967
     },
     {
       "endCharacter": 54,
-      "endLine": 2996,
+      "endLine": 3033,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "\"is_pruned\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 45,
-      "startLine": 2996
+      "startLine": 3033
     },
     {
       "endCharacter": 67,
-      "endLine": 2999,
+      "endLine": 3036,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "\"use_masking\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 56,
-      "startLine": 2999
+      "startLine": 3036
     },
     {
       "endCharacter": 60,
-      "endLine": 3007,
+      "endLine": 3044,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "\"update_best_reward\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 42,
-      "startLine": 3007
+      "startLine": 3044
     },
     {
       "endCharacter": 84,
-      "endLine": 3007,
+      "endLine": 3044,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"float | list[float]\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"float | list[float]\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"list[float]\" is not assignable to type \"ConvertibleToFloat\"\n      \"list[float]\" is not assignable to \"str\"\n      \"list[float]\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"list[float]\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"list[float]\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 67,
-      "startLine": 3007
+      "startLine": 3044
     },
     {
       "endCharacter": 84,
-      "endLine": 3007,
+      "endLine": 3044,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"float | list[float]\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"float | list[float]\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"list[float]\" is not assignable to type \"ConvertibleToFloat\"\n      \"list[float]\" is not assignable to \"str\"\n      \"list[float]\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"list[float]\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"list[float]\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 67,
-      "startLine": 3007
+      "startLine": 3044
     },
     {
       "endCharacter": 46,
-      "endLine": 3061,
+      "endLine": 3098,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "\"is_pruned\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 37,
-      "startLine": 3061
+      "startLine": 3098
     },
     {
       "endCharacter": 57,
-      "endLine": 3064,
+      "endLine": 3101,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "\"best_mean_reward\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 41,
-      "startLine": 3064
+      "startLine": 3101
     },
     {
       "endCharacter": 37,
-      "endLine": 3074,
+      "endLine": 3111,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Cannot assign to attribute \"train_env\" for class \"ReforceXY*\"\n  Type \"None\" is not assignable to type \"VecMonitor | SubprocVecEnv | Env[Unknown, Unknown]\"\n    \"None\" is not assignable to \"VecMonitor\"\n    \"None\" is not assignable to \"SubprocVecEnv\"\n    \"None\" is not assignable to \"Env[Unknown, Unknown]\"",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 33,
-      "startLine": 3074
+      "startLine": 3111
     },
     {
       "endCharacter": 36,
-      "endLine": 3079,
+      "endLine": 3116,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Cannot assign to attribute \"eval_env\" for class \"ReforceXY*\"\n  Type \"None\" is not assignable to type \"VecMonitor | SubprocVecEnv | Env[Unknown, Unknown]\"\n    \"None\" is not assignable to \"VecMonitor\"\n    \"None\" is not assignable to \"SubprocVecEnv\"\n    \"None\" is not assignable to \"Env[Unknown, Unknown]\"",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 32,
-      "startLine": 3079
+      "startLine": 3116
     },
     {
       "endCharacter": 7,
-      "endLine": 3124,
+      "endLine": 3161,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Declaration \"MyRLEnv\" is obscured by a declaration of the same name",
       "rule": "reportRedeclaration",
       "severity": "error",
       "startCharacter": 0,
-      "startLine": 3124
+      "startLine": 3161
     },
     {
       "endCharacter": 36,
-      "endLine": 3136,
+      "endLine": 3173,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Declaration \"_last_closed_trade_tick\" is obscured by a declaration of the same name",
       "rule": "reportRedeclaration",
       "severity": "error",
       "startCharacter": 13,
-      "startLine": 3136
+      "startLine": 3173
     },
     {
       "endCharacter": 13,
-      "endLine": 3728,
+      "endLine": 3765,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Method \"reset\" overrides class \"BaseEnvironment\" in an incompatible manner\n  Return type mismatch: base method returns type \"tuple[DataFrame, dict[Unknown, Unknown]]\", override returns type \"tuple[NDArray[float32], dict[str, Any]]\"\n    \"tuple[NDArray[float32], dict[str, Any]]\" is not assignable to \"tuple[DataFrame, dict[Unknown, Unknown]]\"\n      Tuple entry 1 is incorrect type\n        \"ndarray[_AnyShape, dtype[float32]]\" is not assignable to \"DataFrame\"",
       "rule": "reportIncompatibleMethodOverride",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 3728
+      "startLine": 3765
     },
     {
       "endCharacter": 26,
-      "endLine": 3749,
+      "endLine": 3786,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Type \"tuple[DataFrame, dict[Unknown, Unknown]]\" is not assignable to return type \"tuple[NDArray[float32], dict[str, Any]]\"\n  \"DataFrame\" is not assignable to \"ndarray[_AnyShape, dtype[float32]]\"",
       "rule": "reportReturnType",
       "severity": "error",
       "startCharacter": 15,
-      "startLine": 3749
+      "startLine": 3786
     },
     {
       "endCharacter": 55,
-      "endLine": 4057,
+      "endLine": 4091,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"float | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"float | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 44,
-      "startLine": 4057
+      "startLine": 4091
     },
     {
       "endCharacter": 55,
-      "endLine": 4057,
+      "endLine": 4091,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"float | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"float | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 44,
-      "startLine": 4057
+      "startLine": 4091
     },
     {
       "endCharacter": 59,
-      "endLine": 4081,
+      "endLine": 4115,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"float | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"float | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 48,
-      "startLine": 4081
+      "startLine": 4115
     },
     {
       "endCharacter": 59,
-      "endLine": 4081,
+      "endLine": 4115,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"float | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"float | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 48,
-      "startLine": 4081
+      "startLine": 4115
     },
     {
       "endCharacter": 24,
-      "endLine": 4110,
+      "endLine": 4144,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Method \"_get_observation\" overrides class \"BaseEnvironment\" in an incompatible manner\n  Return type mismatch: base method returns type \"DataFrame\", override returns type \"NDArray[float32]\"\n    \"ndarray[_AnyShape, dtype[float32]]\" is not assignable to \"DataFrame\"",
       "rule": "reportIncompatibleMethodOverride",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 4110
+      "startLine": 4144
     },
     {
       "endCharacter": 12,
-      "endLine": 4203,
+      "endLine": 4237,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Method \"step\" overrides class \"Base5ActionRLEnv\" in an incompatible manner\n  Return type mismatch: base method returns type \"tuple[DataFrame, float, bool, Literal[False], dict[str, Unknown]]\", override returns type \"tuple[NDArray[float32], float, bool, bool, dict[str, Any]]\"\n    \"tuple[NDArray[float32], float, bool, bool, dict[str, Any]]\" is not assignable to \"tuple[DataFrame, float, bool, Literal[False], dict[str, Unknown]]\"\n      Tuple entry 1 is incorrect type\n        \"ndarray[_AnyShape, dtype[float32]]\" is not assignable to \"DataFrame\"",
       "rule": "reportIncompatibleMethodOverride",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 4203
+      "startLine": 4237
     },
     {
       "endCharacter": 20,
-      "endLine": 4393,
+      "endLine": 4432,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Method \"action_masks\" overrides class \"BaseEnvironment\" in an incompatible manner\n  Return type mismatch: base method returns type \"list[bool]\", override returns type \"NDArray[bool_]\"\n    \"ndarray[_AnyShape, dtype[bool_]]\" is not assignable to \"list[bool]\"",
       "rule": "reportIncompatibleMethodOverride",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 4393
+      "startLine": 4432
     },
     {
       "endCharacter": 40,
-      "endLine": 4542,
+      "endLine": 4581,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "\"Figure\" is not exported from module \"matplotlib.pyplot\"",
       "rule": "reportPrivateImportUsage",
       "severity": "error",
       "startCharacter": 34,
-      "startLine": 4542
+      "startLine": 4581
     },
     {
       "endCharacter": 54,
-      "endLine": 5027,
+      "endLine": 5066,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 45,
-      "startLine": 5027
+      "startLine": 5066
     },
     {
       "endCharacter": 54,
-      "endLine": 5027,
+      "endLine": 5066,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 45,
-      "startLine": 5027
+      "startLine": 5066
     },
     {
       "endCharacter": 50,
-      "endLine": 5039,
+      "endLine": 5078,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"object\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"object\" is not assignable to type \"ConvertibleToFloat\"\n    \"object\" is not assignable to \"str\"\n    \"object\" is incompatible with protocol \"Buffer\"\n      \"__buffer__\" is not present\n    \"object\" is incompatible with protocol \"SupportsFloat\"\n      \"__float__\" is not present\n    \"object\" is incompatible with protocol \"SupportsIndex\"\n      \"__index__\" is not present",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 37,
-      "startLine": 5039
+      "startLine": 5078
     },
     {
       "endCharacter": 72,
-      "endLine": 5050,
+      "endLine": 5089,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"float | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"float | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 70,
-      "startLine": 5050
+      "startLine": 5089
     },
     {
       "endCharacter": 72,
-      "endLine": 5050,
+      "endLine": 5089,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"float | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"float | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 70,
-      "startLine": 5050
+      "startLine": 5089
     },
     {
       "endCharacter": 73,
-      "endLine": 5059,
+      "endLine": 5098,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"float | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"float | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 71,
-      "startLine": 5059
+      "startLine": 5098
     },
     {
       "endCharacter": 73,
-      "endLine": 5059,
+      "endLine": 5098,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"float | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"float | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 71,
-      "startLine": 5059
+      "startLine": 5098
     },
     {
       "endCharacter": 58,
-      "endLine": 5068,
+      "endLine": 5107,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 56,
-      "startLine": 5068
+      "startLine": 5107
     },
     {
       "endCharacter": 58,
-      "endLine": 5068,
+      "endLine": 5107,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 56,
-      "startLine": 5068
+      "startLine": 5107
     },
     {
       "endCharacter": 50,
-      "endLine": 5299,
+      "endLine": 5338,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Mapping[Unknown, Unknown]\" cannot be assigned to parameter \"src\" of type \"dict[str, Any]\" in function \"deepmerge\"\n  \"Mapping[Unknown, Unknown]\" is not assignable to \"dict[str, Any]\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 49,
-      "startLine": 5299
+      "startLine": 5338
     },
     {
       "endCharacter": 20,
-      "endLine": 5479,
+      "endLine": 5497,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 18,
-      "startLine": 5479
+      "startLine": 5497
     },
     {
       "endCharacter": 20,
-      "endLine": 5479,
+      "endLine": 5497,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 18,
-      "startLine": 5479
+      "startLine": 5497
     },
     {
       "endCharacter": 59,
-      "endLine": 5484,
+      "endLine": 5502,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 31,
-      "startLine": 5484
+      "startLine": 5502
     },
     {
       "endCharacter": 59,
-      "endLine": 5484,
+      "endLine": 5502,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 31,
-      "startLine": 5484
+      "startLine": 5502
     },
     {
       "endCharacter": 65,
-      "endLine": 5485,
+      "endLine": 5503,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 34,
-      "startLine": 5485
+      "startLine": 5503
     },
     {
       "endCharacter": 65,
-      "endLine": 5485,
+      "endLine": 5503,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 34,
-      "startLine": 5485
+      "startLine": 5503
     },
     {
       "endCharacter": 63,
-      "endLine": 5488,
+      "endLine": 5506,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 34,
-      "startLine": 5488
+      "startLine": 5506
     },
     {
       "endCharacter": 63,
-      "endLine": 5488,
+      "endLine": 5506,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 34,
-      "startLine": 5488
+      "startLine": 5506
     },
     {
       "endCharacter": 61,
-      "endLine": 5490,
+      "endLine": 5508,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 32,
-      "startLine": 5490
+      "startLine": 5508
     },
     {
       "endCharacter": 61,
-      "endLine": 5490,
+      "endLine": 5508,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 32,
-      "startLine": 5490
+      "startLine": 5508
     },
     {
       "endCharacter": 67,
-      "endLine": 5491,
+      "endLine": 5509,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 36,
-      "startLine": 5491
+      "startLine": 5509
     },
     {
       "endCharacter": 67,
-      "endLine": 5491,
+      "endLine": 5509,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 36,
-      "startLine": 5491
+      "startLine": 5509
     },
     {
       "endCharacter": 73,
-      "endLine": 5492,
+      "endLine": 5510,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 39,
-      "startLine": 5492
+      "startLine": 5510
     },
     {
       "endCharacter": 73,
-      "endLine": 5492,
+      "endLine": 5510,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 39,
-      "startLine": 5492
+      "startLine": 5510
     },
     {
       "endCharacter": 61,
-      "endLine": 5493,
+      "endLine": 5511,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 33,
-      "startLine": 5493
+      "startLine": 5511
     },
     {
       "endCharacter": 61,
-      "endLine": 5493,
+      "endLine": 5511,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 33,
-      "startLine": 5493
+      "startLine": 5511
     },
     {
       "endCharacter": 89,
-      "endLine": 5500,
+      "endLine": 5518,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 52,
-      "startLine": 5500
+      "startLine": 5518
     },
     {
       "endCharacter": 89,
-      "endLine": 5500,
+      "endLine": 5518,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 52,
-      "startLine": 5500
+      "startLine": 5518
     },
     {
       "endCharacter": 83,
-      "endLine": 5501,
+      "endLine": 5519,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 49,
-      "startLine": 5501
+      "startLine": 5519
     },
     {
       "endCharacter": 83,
-      "endLine": 5501,
+      "endLine": 5519,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 49,
-      "startLine": 5501
+      "startLine": 5519
     },
     {
       "endCharacter": 65,
-      "endLine": 5527,
+      "endLine": 5545,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 34,
-      "startLine": 5527
+      "startLine": 5545
     },
     {
       "endCharacter": 65,
-      "endLine": 5527,
+      "endLine": 5545,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 34,
-      "startLine": 5527
+      "startLine": 5545
     },
     {
       "endCharacter": 67,
-      "endLine": 5529,
+      "endLine": 5547,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 35,
-      "startLine": 5529
+      "startLine": 5547
     },
     {
       "endCharacter": 67,
-      "endLine": 5529,
+      "endLine": 5547,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 35,
-      "startLine": 5529
+      "startLine": 5547
     },
     {
       "endCharacter": 87,
-      "endLine": 5532,
+      "endLine": 5550,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 46,
-      "startLine": 5532
+      "startLine": 5550
     },
     {
       "endCharacter": 87,
-      "endLine": 5532,
+      "endLine": 5550,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 46,
-      "startLine": 5532
+      "startLine": 5550
     },
     {
       "endCharacter": 93,
-      "endLine": 5533,
+      "endLine": 5551,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 49,
-      "startLine": 5533
+      "startLine": 5551
     },
     {
       "endCharacter": 93,
-      "endLine": 5533,
+      "endLine": 5551,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 49,
-      "startLine": 5533
+      "startLine": 5551
     },
     {
       "endCharacter": 89,
-      "endLine": 5534,
+      "endLine": 5552,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 47,
-      "startLine": 5534
+      "startLine": 5552
     },
     {
       "endCharacter": 89,
-      "endLine": 5534,
+      "endLine": 5552,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 47,
-      "startLine": 5534
+      "startLine": 5552
     },
     {
       "endCharacter": 89,
-      "endLine": 5535,
+      "endLine": 5553,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 46,
-      "startLine": 5535
+      "startLine": 5553
     },
     {
       "endCharacter": 89,
-      "endLine": 5535,
+      "endLine": 5553,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 46,
-      "startLine": 5535
+      "startLine": 5553
     },
     {
       "endCharacter": 75,
-      "endLine": 5536,
+      "endLine": 5554,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 39,
-      "startLine": 5536
+      "startLine": 5554
     },
     {
       "endCharacter": 75,
-      "endLine": 5536,
+      "endLine": 5554,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 39,
-      "startLine": 5536
+      "startLine": 5554
     },
     {
       "endCharacter": 33,
index 04208f4a076729ca61feb8b0ba78ac3ae334effd..017c55ad03e0a9d492407322314ede8d7b73dc7e 100644 (file)
@@ -190,12 +190,13 @@ evaluation are outside this timeout. The supplied zero timeout disables the HPO
 timeout. Evaluation environments/episodes also cost resources; a larger budget
 is not evidence of better economics.
 
-The objective maximizes the best mean deterministic evaluation **episode reward**
-across periodic and final evaluations. Progress may display the latest mean
-reward, which is not the selected best mean reward. The objective is not net
-portfolio profit, Sharpe or Sortino. The holdout participates in model/HPO
-selection; reserve a separate unseen market period for economic confirmation
-under the [evaluation protocol](../docs/evaluation.md).
+The objective maximizes the best mean deterministic evaluation **episode score**
+across periodic and final evaluations. This score excludes PBRS shaping but
+retains enabled non-PBRS additives. The same score selects checkpoints and
+controls no-improvement stopping. Progress may display the latest mean, not the
+selected best mean. The objective is not net portfolio profit, Sharpe or
+Sortino. The holdout participates in model/HPO selection; reserve a separate
+unseen market period under the [evaluation protocol](../docs/evaluation.md).
 
 Optuna trains fresh candidates in current-window feature coordinates. Sampled
 parameters override supplied model parameters during trials. Hyperband prunes
@@ -234,12 +235,13 @@ Continual learning trains an independent copy of the deployed policy with its
 fitted feature pipeline. DQN/QRDQN deployments each persist their replay buffer;
 it is loaded only when continual training starts. Missing or incompatible replay
 data prevents continuation but does not prevent inference. The deployment
-generation covers frozen feature coordinates and PBRS reward semantics. Otherwise
-compatible older generations, including `chronological-frozen-pipelines-v2`, support
-inference but cannot continue training. Reset trained models or use a new
-`freqai.identifier` to migrate. Training disables `shuffle_after_split`. HPO
-studies and saved best parameters are reused only when their objective identity
-matches.
+generation covers frozen feature coordinates and reward semantics, including
+idle/hold costs on invalid actions. Otherwise compatible older generations,
+including `chronological-frozen-pipelines-v2` and
+`chronological-frozen-pipelines-pbrs-v3`, support inference but cannot continue
+training. Reset trained models or use a new `freqai.identifier` to migrate.
+Training disables `shuffle_after_split`. HPO studies and saved best parameters
+are reused only when their objective identity matches.
 
 Backtests continue only from a saved policy whose training cutoff precedes
 the current window's end and its last available candle boundary. A later
@@ -345,7 +347,7 @@ Moving base images can change them; record the runtime version.
 | freqai.rl_config.model_reward_parameters.rr                         | Schema: 1; direct constructor requires the member                      | Numeric reward-to-risk factor                                                                                      | 2                                                                           | The schema injects an omitted member into an existing reward map; direct environment construction without it raises. Native reward target uses rr × profit_aim.                                                                                                                                                                                                                |
 | freqai.rl_config.model_reward_parameters.profit_aim                 | Schema: 0.025; direct constructor requires the member                  | Numeric profit target                                                                                              | 0.025                                                                       | The schema injects an omitted member into an existing reward map; direct environment construction without it raises. This reward target does not place a live take-profit order.                                                                                                                                                                                               |
 | freqai.rl_config.model_reward_parameters.max_trade_duration_candles | 128                                                                    | Integer-convertible value; no local range validation                                                               | 96                                                                          | Duration scale for reward penalties, not a live timeout. The schema accepts the top-level homonym `freqai.rl_config.max_trade_duration_candles`, but ReforceXY ignores it; only this reward-map member takes effect.                                                                                                                                                           |
-| freqai.rl_config.model_reward_parameters.max_idle_duration_candles  | 4 × effective max_trade_duration_candles                               | Integer-convertible value; no local range validation                                                               | Omitted (384 from the supplied trade scale)                                 | Idle duration scale for reward penalties, not a live timer.                                                                                                                                                                                                                                                                                                                    |
+| freqai.rl_config.model_reward_parameters.max_idle_duration_candles  | 4 × effective max_trade_duration_candles                               | None or integer-convertible value; no local range validation                                                       | Omitted (384 from the supplied trade scale)                                 | Missing/None derives the scale; explicit zero uses denominator 1. Reward scale, not a live timer.                                                                                                                                                                                                                                                                              |
 
 ### HPO settings
 
@@ -416,7 +418,9 @@ Rewards combine the fill-time base components with a potential-based shaping
 delta over the returned next observation. Termination liquidates any remaining
 position once and sets terminal shaping to `-prev_potential`, even for tiny
 nonzero potentials. `get_env_history()` returns one metrics/price row per transition.
-Its `execution_tick` is the transition/action/fill
+History `reward` remains the shaped training reward; `reward_selection` records
+the separate evaluation contribution without PBRS.
+The transition's `execution_tick` is the action/fill
 key before the tick increment; its `tick` is the returned post-increment price and
 observation row (normally `execution_tick + 1`). Exit-efficiency extrema include
 the fee-adjusted PnL at entry and subsequent retained market marks. Ordered
index 018c9ce18d1bb650df935325c4bc64157db1cad1..3436c7dd1c6db26e4695e7db89f507c8f5d01d1d 100644 (file)
@@ -269,9 +269,10 @@ artifacts are written.
   (Simulation-only).
 - **`--trading_mode`** (spot|margin|futures, default: spot) – spot: no shorts;
   margin/futures: shorts enabled. (Simulation-only).
-- **`--max_duration_ratio`** (float, default: 2.5) – Upper multiple for sampled
-  trade durations (idle derived). (Simulation-only; not in reward params; cannot
-  be set via `--params`).
+- **`--max_duration_ratio`** (float, default: 2.5) – Synthetic trade-duration
+  bound: `max(1, trunc(max_trade_duration_candles * max_duration_ratio))`
+  candles. At the bound, sampling chooses the valid exit with probability 1.
+  This is not a runtime exit timeout. (Simulation-only; not in `--params`).
 - **`--pnl_base_std`** (float, default: 0.02) – Base standard deviation for
   synthetic PnL generation (pre-scaling). (Simulation-only).
 - **`--pnl_duration_vol_scale`** (float, default: 0.5) – Additional PnL
@@ -293,10 +294,11 @@ be overridden via `--params`.
   0.03=3%).
 - **`--risk_reward_ratio`** (float, default: 2.0) – Risk-reward multiplier.
 - **`--action_masking`** (bare flag, default: true) – Disable via
-  `--params action_masking=false`. With masking enabled, sample
-  only valid actions. When disabled, sample an invalid action with 10% probability
-  and apply the configured invalid-action penalty. Invalid actions leave the held
-  position unchanged, except for an independent terminal liquidation.
+  `--params action_masking=false`. Masking samples only valid actions. Without
+  masking, invalid actions have 10% probability except at the forced duration
+  exit. They retain the current position's time cost and add the configured
+  invalid-action penalty. An independent terminal liquidation can still close
+  the position.
 
 ### Reward & Shaping
 
@@ -338,8 +340,9 @@ Adjustment metadata for finite native durations records the final normalized
 integer. Strict validation checks a numeric gamma in its native precision before
 normalizing it.
 Simulation consumes extended integer durations without a binary64 cap. Ordinary
-fractional duration caps retain float-product truncation; extended caps use the
-supplied ratio's exact rational value. Duration relationship tables keep candle
+fractional duration bounds use float-product truncation; extended bounds use the
+supplied ratio's exact rational value. Both have a one-candle physical minimum.
+Duration relationship tables keep candle
 units when their float bounds and labels are safe. At larger scales, the same
 12 right-closed uniform bins use exact assignment and explicitly declared
 `duration / max_trade_duration_candles` coordinates; the first bin includes zero.
@@ -384,6 +387,34 @@ the first named group (`pnl+`) has higher rewards than the second (`pnl-`).
 | `base_factor`    | 100.0   | Base reward scale           |
 | `invalid_action` | -2.0    | Penalty for invalid actions |
 
+**Reward composition:**
+
+```text
+R_base      = invalid_penalty + idle_penalty + hold_penalty + exit_reward
+R_train     = R_base + reward_shaping + entry_additive + exit_additive
+R_selection = R_base + entry_additive + exit_additive
+```
+
+The invalid penalty is `invalid_action` for an invalid unmasked action, and zero otherwise.
+
+Time costs use the pre-action duration when the action leaves the position
+unchanged, including invalid actions. A valid entry has no idle cost; a valid
+exit has no hold cost. `exit_reward = pnl * exit_factor` on a valid exit.
+Episode termination also adds a liquidation exit reward and any enabled exit
+additive at the next marked price if a position remains open.
+
+Training uses `R_train`. Runtime evaluation sums `R_selection` without discounting
+for checkpoint selection, HPO and no-improvement stopping. The synthetic `reward`
+column remains `R_train`; it is not the runtime selection score or portfolio
+profit. Canonical mode suppresses both optional additives.
+
+For transition-faithful Python API calculations, supply the actual `next_context`
+after time advances and before terminal liquidation. It is required, with the
+resulting position's side, whenever `terminated=True` leaves a position open.
+Missing or wrong-side terminal states raise `ValueError`. Without a nonterminal
+`next_context`, the helper evaluates its local state approximation, not an
+observed market transition.
+
 #### Exit Factor
 
 The exit factor is computed as:
@@ -391,6 +422,9 @@ The exit factor is computed as:
 `exit_factor` = `base_factor` · `pnl_target_coefficient` ·
 `efficiency_coefficient` · `time_attenuation_coefficient`
 
+Time attenuation reduces the magnitude of both profitable and losing exit
+rewards. Holding costs are separate.
+
 ##### PnL Target
 
 | Parameter                       | Default | Description                   |
@@ -490,17 +524,35 @@ where `kernel_function` depends on `exit_attenuation_mode`. See
 
 #### Duration Penalties
 
-| Parameter                    | Default | Description                                                                                 |
-| ---------------------------- | ------- | ------------------------------------------------------------------------------------------- |
-| `max_trade_duration_candles` | 128     | Trade duration cap                                                                          |
-| `max_idle_duration_candles`  | None    | Idle hazard threshold; missing/None derives 4× trade cap; idle clock keeps counting past it |
-| `idle_penalty_ratio`         | 1.0     | Idle penalty ratio                                                                          |
-| `idle_penalty_power`         | 1.025   | Idle penalty exponent                                                                       |
-| `hold_penalty_ratio`         | 1.0     | Hold penalty ratio                                                                          |
-| `hold_penalty_power`         | 1.025   | Hold penalty exponent                                                                       |
+| Parameter                    | Default | Description                                                                   |
+| ---------------------------- | ------- | ----------------------------------------------------------------------------- |
+| `max_trade_duration_candles` | 128     | Trade-duration scale and overtime threshold; not an exit timeout              |
+| `max_idle_duration_candles`  | None    | Idle scale; missing/None derives 4× the trade scale; the clock keeps counting |
+| `idle_penalty_ratio`         | 1.0     | Idle penalty ratio                                                            |
+| `idle_penalty_power`         | 1.025   | Idle penalty exponent                                                         |
+| `hold_penalty_ratio`         | 1.0     | Hold penalty ratio                                                            |
+| `hold_penalty_power`         | 1.025   | Hold penalty exponent                                                         |
 
-An explicit zero threshold uses denominator 1 and does not disable the idle
-penalty.
+Let `B = base_factor`, `a = profit_aim`, `q = risk_reward_ratio`,
+`D = max(1, max_trade_duration_candles)` and
+`I = max(1, effective max_idle_duration_candles)`. The time-cost scale is
+`C = B * a / q`, distinct from the PnL target `a * q`.
+
+For unchanged positions:
+
+```text
+idle_penalty = -C * idle_penalty_ratio * (idle_duration / I)^idle_penalty_power
+
+hold_penalty = 0                                                   if trade_duration / D < 1
+             = -C * hold_penalty_ratio
+               * (trade_duration / D - 1)^hold_penalty_power        otherwise
+```
+
+Idle applies while neutral; hold applies while long or short. Invalid no-op
+actions keep these costs, even when `invalid_action=0`. Durations are candles,
+not elapsed seconds. An explicit idle scale of zero uses denominator 1; it
+does not disable the cost. With exponent zero, `0^0 = 1`, so the hold cost
+already applies at its threshold.
 
 #### Validation
 
@@ -572,9 +624,9 @@ where:
 - `m_dur = 1.0` if `r_pnl ≥ 0` (profit side)
 - `m_dur = risk_reward_ratio` if `r_pnl < 0` (loss side)
 
-The loss-side duration multiplier (`m_dur = risk_reward_ratio`) scales the
-duration penalty when holding losing positions, encouraging faster exits from
-losses compared to symmetric treatment.
+The loss-side multiplier changes the duration term of the potential, not the
+base holding cost or optional additives. It does not establish faster exits
+or better financial outcomes for a fitted policy.
 
 #### Entry Additive (Optional)
 
@@ -586,6 +638,18 @@ losses compared to symmetric treatment.
 | `entry_additive_transform_pnl`      | tanh    | PnL transform         |
 | `entry_additive_transform_duration` | tanh    | Duration transform    |
 
+Both optional additives use the same signal, with `k = entry` or `exit`:
+
+```text
+A_k(pnl, r_dur) = base_factor * k_additive_ratio * 0.5
+                 * [T_pnl(k_additive_gain * r_pnl)
+                    + sign_eps(r_pnl) * T_dur(k_additive_gain * max(r_dur, 0))]
+```
+
+Use `r_pnl` and `sign_eps` from the hold-potential definition, and the selected
+additive's transforms. There is no loss-side `risk_reward_ratio` multiplier.
+Entry uses fee-aware PnL at the fill with `r_dur = 0`.
+
 #### Exit Additive (Optional)
 
 | Parameter                          | Default | Description          |
@@ -596,6 +660,11 @@ losses compared to symmetric treatment.
 | `exit_additive_transform_pnl`      | tanh    | PnL transform        |
 | `exit_additive_transform_duration` | tanh    | Duration transform   |
 
+Exit uses PnL and trade duration at the exit fill, with
+`r_dur = trade_duration / max(1, max_trade_duration_candles)`. A terminal
+liquidation uses the actual next marked PnL and advanced duration instead.
+Additives are zero when disabled and are always suppressed in canonical mode.
+
 ### Exit Attenuation Kernels
 
 `r` = duration ratio and `grace` = `exit_plateau_grace`.
@@ -764,8 +833,9 @@ the manifest is produced after reporting succeeds.
 
 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.
+With masking disabled, ordinary draws include the 90% valid-action mass; they
+are not conditional on drawing a valid action. At the synthetic duration bound,
+the corresponding valid exit has probability 1 and invalid actions are not drawn.
 
 ### Manifest (`manifest.json`)
 
index be956b6ca413b1e5d24fc2846191a9315957d9af..eec2d6b28b98302d19ff6ee144004ecb5ba75bbf 100644 (file)
@@ -929,7 +929,7 @@ def _compute_time_attenuation_coefficient(
     """
     Calculate time-based attenuation coefficient using configurable strategy.
 
-    Returns a coefficient (typically in range [0.5, 2.0]) to multiply with base_factor.
+    Returns a coefficient in [0, 1] for validated parameters.
     """
     if duration_ratio < 0.0:
         duration_ratio = 0.0
@@ -975,7 +975,7 @@ def _compute_time_attenuation_coefficient(
                     stacklevel=2,
                 )
                 alpha = 1.0
-        return 1.0 / math.pow(1.0 + dr, alpha)
+        return math.pow(1.0 + dr, -alpha)
 
     def _half_life_kernel(dr: float) -> float:
         hl = _get_float_param(params, "exit_half_life")
@@ -1101,7 +1101,9 @@ def _compute_pnl_target_coefficient(
     """
     Compute PnL target coefficient based on PnL/target ratio using tanh.
 
-    Returns a coefficient (typically 0.5-2.0) to be multiplied with base_factor.
+    For nonnegative tunables, amplification ranges from 1 to ``1 + win_reward_factor``
+    for gains, or ``1 + win_reward_factor * rr`` for losses. Here ``rr`` is the positive
+    ``risk_reward_ratio``, or ``RISK_REWARD_RATIO_DEFAULT`` otherwise.
     The coefficient rewards trades that exceed pnl_target and penalizes losses
     beyond the risk/reward threshold.
 
@@ -1112,7 +1114,7 @@ def _compute_pnl_target_coefficient(
         risk_reward_ratio: Risk/reward ratio for loss penalty calculation
 
     Returns:
-        float: Coefficient ≥ 0.0 (typically 0.5-2.0 range)
+        float: Nonnegative amplification coefficient for nonnegative tunables.
     """
     pnl_target_coefficient = 1.0
 
@@ -1342,6 +1344,11 @@ def calculate_reward(
         Whether to apply action masking (affects invalid action penalty)
     prev_potential : float, optional
         Previous state potential Φ(s), by default np.nan
+    next_context : RewardContext, optional
+        Actual marked state after advancing time, before terminal liquidation.
+        Required when a terminal transition leaves a position open.
+    terminated : bool
+        Close any resulting open position and clear the terminal potential.
 
     Returns
     -------
@@ -1371,10 +1378,9 @@ def calculate_reward(
         short_allowed=short_allowed,
     )
 
-    base_reward: float | None = None
+    base_reward = 0.0
     if not is_valid and not action_masking:
         breakdown.invalid_penalty = _get_float_param(params, "invalid_action")
-        base_reward = breakdown.invalid_penalty
 
     base_factor = _get_float_param(params, "base_factor", base_factor)
 
@@ -1396,35 +1402,31 @@ def calculate_reward(
         context.trade_duration, max_trade_duration_candles
     )
 
-    # Base reward calculation
-    if base_reward is None:
-        if context.action == Actions.Neutral:
-            if context.position == Positions.Neutral:
-                base_reward = _idle_penalty(context, idle_factor, params)
-                breakdown.idle_penalty = base_reward
-            elif context.position in (Positions.Long, Positions.Short):
-                base_reward = _hold_penalty(context, hold_factor, params)
-                breakdown.hold_penalty = base_reward
-            else:
-                base_reward = 0.0
-        else:
-            is_exit_action = (
-                context.action == Actions.Long_exit and context.position == Positions.Long
-            ) or (context.action == Actions.Short_exit and context.position == Positions.Short)
-            if is_exit_action:
-                base_reward = _compute_exit_reward(
-                    base_factor,
-                    pnl_target,
-                    current_duration_ratio,
-                    context,
-                    params,
-                    risk_reward_ratio,
-                )
-                breakdown.exit_component = base_reward
-                breakdown.exit_pnl = context.current_pnl
-            else:
-                base_reward = 0.0
+    # Time advances even when an invalid action leaves the physical state unchanged.
+    if context.action == Actions.Neutral or not is_valid:
+        if context.position == Positions.Neutral:
+            base_reward = _idle_penalty(context, idle_factor, params)
+            breakdown.idle_penalty = base_reward
+        elif context.position in (Positions.Long, Positions.Short):
+            base_reward = _hold_penalty(context, hold_factor, params)
+            breakdown.hold_penalty = base_reward
+    else:
+        is_exit_action = (
+            context.action == Actions.Long_exit and context.position == Positions.Long
+        ) or (context.action == Actions.Short_exit and context.position == Positions.Short)
+        if is_exit_action:
+            base_reward = _compute_exit_reward(
+                base_factor,
+                pnl_target,
+                current_duration_ratio,
+                context,
+                params,
+                risk_reward_ratio,
+            )
+            breakdown.exit_component = base_reward
+            breakdown.exit_pnl = context.current_pnl
 
+    base_reward += breakdown.invalid_penalty
     breakdown.base_reward = float(base_reward)
 
     # === PBRS INTEGRATION ===
@@ -1486,23 +1488,26 @@ def calculate_reward(
         next_duration_ratio = _compute_duration_ratio(
             next_context.trade_duration, max_trade_duration_candles
         )
-    terminal_context = next_context if next_context is not None else context
     breakdown.terminal_liquidation = bool(
         terminated and next_position in (Positions.Long, Positions.Short)
     )
     if breakdown.terminal_liquidation:
+        if next_context is None or next_context.position != next_position:
+            raise ValueError(
+                "next_context with the resulting open position is required for terminal liquidation"
+            )
         liquidation_reward = _compute_exit_reward(
             base_factor,
             pnl_target,
             next_duration_ratio,
-            terminal_context,
+            next_context,
             params,
             risk_reward_ratio,
         )
         base_reward += liquidation_reward
         breakdown.base_reward = float(base_reward)
         breakdown.exit_component += liquidation_reward
-        breakdown.exit_pnl = terminal_context.current_pnl
+        breakdown.exit_pnl = next_context.current_pnl
 
     # Resolve effective PBRS and additive enablement for every transition.
     exit_mode = _resolve_exit_potential_mode(
@@ -1512,11 +1517,10 @@ def calculate_reward(
     )
 
     hold_potential_enabled = _get_bool_param(params, "hold_potential_enabled")
-    entry_additive_enabled = (
-        False if exit_mode == "canonical" else _get_bool_param(params, "entry_additive_enabled")
-    )
-    exit_additive_enabled = (
-        False if exit_mode == "canonical" else _get_bool_param(params, "exit_additive_enabled")
+    entry_additive_enabled, exit_additive_enabled, _ = _resolve_additive_enablement(
+        exit_mode,
+        _get_bool_param(params, "entry_additive_enabled"),
+        _get_bool_param(params, "exit_additive_enabled"),
     )
 
     pbrs_enabled = bool(hold_potential_enabled or entry_additive_enabled or exit_additive_enabled)
@@ -1558,7 +1562,7 @@ def calculate_reward(
         )
         if breakdown.terminal_liquidation and exit_additive_enabled:
             exit_additive += _compute_exit_additive(
-                terminal_context.current_pnl, pnl_target, next_duration_ratio, params, base_factor
+                next_pnl, pnl_target, next_duration_ratio, params, base_factor
             )
         if terminated:
             next_potential = 0.0
@@ -1669,7 +1673,16 @@ def _sample_action(
     idle_duration: int,
     max_idle_duration_candles: int,
     action_masking: bool = True,
+    max_trade_duration_cap: int | None = None,
 ) -> tuple[Actions, float, float, float]:
+    if (
+        max_trade_duration_cap is not None
+        and position in (Positions.Long, Positions.Short)
+        and trade_duration >= max_trade_duration_cap
+    ):
+        action = Actions.Long_exit if position == Positions.Long else Actions.Short_exit
+        return action, float("nan"), 1.0, float("nan")
+
     entry_prob, exit_prob, neutral_prob = _sampling_probabilities(
         position,
         short_allowed=short_allowed,
@@ -1813,7 +1826,7 @@ def simulate_samples(
     max_idle_duration_candles = get_max_idle_duration_candles(
         params, max_trade_duration_candles=max_trade_duration_candles
     )
-    max_trade_duration_cap = _scale_duration(max_trade_duration_candles, max_duration_ratio)
+    max_trade_duration_cap = max(1, _scale_duration(max_trade_duration_candles, max_duration_ratio))
 
     samples: list[dict[str, float | None]] = []
     prev_potential: float = 0.0
@@ -1842,6 +1855,7 @@ def simulate_samples(
             idle_duration=idle_duration,
             max_idle_duration_candles=max_idle_duration_candles,
             action_masking=action_masking,
+            max_trade_duration_cap=max_trade_duration_cap,
         )
 
         context = RewardContext(
@@ -1875,7 +1889,7 @@ def simulate_samples(
                 idle_duration = 0
                 entry_open = current_open
         if position in (Positions.Long, Positions.Short):
-            trade_duration = min(trade_duration + 1, max_trade_duration_cap)
+            trade_duration += 1
         else:
             idle_duration += 1
         # Simulate synthetic open-price movement.
@@ -2064,8 +2078,12 @@ def _validate_simulation_invariants(df: pd.DataFrame, params: RewardParams) -> N
     # INVARIANT 1: masked sampling must only emit legal actions; unmasked
     # actions may be invalid but cannot change position before terminal liquidation.
     if _get_bool_param(params, "action_masking", True):
-        long_exits = df[(df["action"] == 2.0) & (df["position"] != 1.0)]
-        short_exits = df[(df["action"] == 4.0) & (df["position"] != 0.0)]
+        long_exits = df[
+            (df["action"] == Actions.Long_exit.value) & (df["position"] != Positions.Long.value)
+        ]
+        short_exits = df[
+            (df["action"] == Actions.Short_exit.value) & (df["position"] != Positions.Short.value)
+        ]
         if len(long_exits) > 0:
             raise AssertionError(f"Sim: {len(long_exits)} Long_exit actions without Long position")
         if len(short_exits) > 0:
@@ -2073,8 +2091,13 @@ def _validate_simulation_invariants(df: pd.DataFrame, params: RewardParams) -> N
                 f"Sim: {len(short_exits)} Short_exit actions without Short position"
             )
 
-        long_entries = df[(df["action"] == 1.0) & (df["position"] != 0.5)]
-        short_entries = df[(df["action"] == 3.0) & (df["position"] != 0.5)]
+        long_entries = df[
+            (df["action"] == Actions.Long_enter.value) & (df["position"] != Positions.Neutral.value)
+        ]
+        short_entries = df[
+            (df["action"] == Actions.Short_enter.value)
+            & (df["position"] != Positions.Neutral.value)
+        ]
         if len(long_entries) > 0:
             raise AssertionError(
                 f"Sim: {len(long_entries)} Long_enter actions without Neutral position"
@@ -2093,20 +2116,22 @@ def _validate_simulation_invariants(df: pd.DataFrame, params: RewardParams) -> N
             raise AssertionError(f"Sim: {len(changed_on_invalid)} invalid actions changed position")
 
     # INVARIANT 2: Duration logic
-    neutral_with_trade = df[(df["position"] == 0.5) & (df["trade_duration"] > 0)]
+    neutral_with_trade = df[
+        (df["position"] == Positions.Neutral.value) & (df["trade_duration"] > 0)
+    ]
     if len(neutral_with_trade) > 0:
         raise AssertionError(
             f"Sim: {len(neutral_with_trade)} Neutral positions with non-zero trade_duration"
         )
 
-    inpos_with_idle = df[(df["position"] != 0.5) & (df["idle_duration"] > 0)]
+    inpos_with_idle = df[(df["position"] != Positions.Neutral.value) & (df["idle_duration"] > 0)]
     if len(inpos_with_idle) > 0:
         raise AssertionError(
             f"Sim: {len(inpos_with_idle)} in-position samples with idle_duration > 0"
         )
 
     # INVARIANT 3: Neutral states have zero PnL (simulation design)
-    neutral_with_pnl = df[(df["position"] == 0.5) & (df["pnl"].abs() > eps_pnl)]
+    neutral_with_pnl = df[(df["position"] == Positions.Neutral.value) & (df["pnl"].abs() > eps_pnl)]
     if len(neutral_with_pnl) > 0:
         raise AssertionError(f"Sim: {len(neutral_with_pnl)} Neutral positions with non-zero pnl")
 
@@ -2330,7 +2355,7 @@ def _compute_representativity_stats(
     pnl_target = _resolve_pnl_target(profit_aim, risk_reward_ratio)
     total = len(df)
     # Map numeric position codes to readable labels to avoid casting Neutral (0.5) to 0
-    pos_label_map = {0.0: "Short", 0.5: "Neutral", 1.0: "Long"}
+    pos_label_map = {position.value: position.name for position in Positions}
     pos_labeled = df["position"].map(pos_label_map)
     pos_counts = (
         pos_labeled.value_counts().reindex(["Short", "Neutral", "Long"]).fillna(0).astype(int)
@@ -3925,7 +3950,7 @@ def build_argument_parser() -> argparse.ArgumentParser:
         "--max_duration_ratio",
         type=float,
         default=2.5,
-        help="Multiple of max duration used when sampling trade/idle durations.",
+        help="Synthetic trade-duration bound multiplier; positions exit at the bound (minimum one candle).",
     )
     parser.add_argument(
         "--pnl_base_std",
@@ -4808,8 +4833,7 @@ def write_complete_statistical_analysis(
                 f.write("| Feature | KL Div | JS Dist | Wasserstein | KS Stat | KS p-value |\n")
                 f.write("|---------|--------|---------|-------------|---------|------------|\n")
 
-                features = ["pnl", "trade_duration", "idle_duration"]
-                for feature in features:
+                for feature in _DISTRIBUTION_SHIFT_FEATURES:
                     kl = distribution_shift.get(f"{feature}_kl_divergence", np.nan)
                     js = distribution_shift.get(f"{feature}_js_distance", np.nan)
                     ws = distribution_shift.get(f"{feature}_wasserstein", np.nan)
index 019753318ed60ed7afd7ff7b182e30bfdb7f3194..f9d22901df60192da6c84c04b65f6e162122ac39 100644 (file)
@@ -188,7 +188,7 @@ Columns:
 | ID                                            | Category   | Description                                                                                        | Owning test                                                                                            | Notes                                                                                                                                                                          |
 | --------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
 | report-additives-deterministic-092            | components | Additives deterministic report section                                                             | components/test_additives.py::test_additive_activation_deterministic_contribution                      | Integration/PBRS may reference outcome non-owning                                                                                                                              |
-| robustness-decomposition-integrity-101        | robustness | Single active core component equals total reward under mutually exclusive scenarios                | robustness/test_robustness.py::test_decomposition_integrity                                            | Scenarios: idle, hold, exit, invalid; non-owning refs integration/test_reward_calculation.py                                                                                   |
+| robustness-decomposition-integrity-101        | robustness | Selected scenarios isolate idle, hold, exit and pre-overtime invalid costs                         | robustness/test_robustness.py::test_decomposition_integrity                                            | Invalid overtime costs are covered by components-invalid-time-costs-160                                                                                                        |
 | robustness-exit-mode-fallback-102             | robustness | Unknown exit_attenuation_mode falls back to linear w/ warning                                      | robustness/test_robustness.py::test_robustness_102_unknown_exit_mode_fallback_linear                   |                                                                                                                                                                                |
 | robustness-negative-grace-clamp-103           | robustness | Negative exit_plateau_grace clamps to 0.0 w/ warning                                               | robustness/test_robustness.py::test_robustness_103_negative_plateau_grace_clamped                      |                                                                                                                                                                                |
 | robustness-invalid-power-tau-104              | robustness | Invalid power tau falls back alpha=1.0 w/ warning                                                  | robustness/test_robustness.py::test_robustness_104_invalid_power_tau_fallback_alpha_one                |                                                                                                                                                                                |
@@ -231,20 +231,22 @@ Columns:
 | 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                                                                                                                |
 | statistics-report-inference-projection-146    | statistics | Constant confidence intervals require declared independence, even with an empty hypothesis map     | statistics/test_statistics.py::test_report_projects_constant_intervals_only_with_declared_independence | Numeric projection; True → False → default rewrites the same report; non-owning bounds reference: statistics-constant-dist-exact-ci-113a                                       |
 | statistics-report-feature-availability-147    | statistics | Complete importance exports require a fitted model and finite means and standard deviations        | statistics/test_statistics.py::test_report_certifies_feature_importance_only_from_finite_estimates     | Real finite-zero, single-feature, empty-feature and one-test-row states; numerical CSV and availability.                                                                       |
-
-| cli-effective-reward-boundaries-145 | cli | Effective profit-target fallback and explicit zero idle denominator reach artifacts | cli/test_cli_params_and_csv.py::test_zero_target_and_idle_cap_reach_rewards_and_manifest | Metadata and near-target market coverage agree with runtime boundaries |
-| pbrs-native-integer-durations-150 | pbrs | Native integer durations and adjustment metadata retain exact values | pbrs/test_pbrs.py::test_native_integer_durations_preserve_exact_values | Python, signed NumPy and unsigned NumPy integers; strict and relaxed |
-| pbrs-native-duration-bounds-151 | pbrs | Floating duration bounds are checked before truncation without narrowing | pbrs/test_pbrs.py::test_native_duration_bounds_precede_truncation | Extended-precision cases run when supported by the platform |
-| pbrs-native-gamma-reward-148 | pbrs | Finite out-of-range native gamma clamps reach the public reward | pbrs/test_pbrs.py::test_native_finite_gamma_clamps_the_public_reward | Extended-range cases run when supported by the platform |
-| pbrs-relaxed-native-gamma-149 | pbrs | Relaxed native gamma clamps reach rewards while strict validation rejects | pbrs/test_pbrs.py::test_relaxed_native_gamma_clamps_before_reward_calculation | Preserves direct, strict and relaxed boundary contracts |
-| api-native-duration-consumers-152 | api | Validated extended native durations reach real trajectories and finite rewards | api/test_api_helpers.py::test_simulation_consumes_extended_native_duration_without_narrowing | Includes the actual exit hazard and terminal state |
-| api-fractional-duration-cap-153 | api | Ordinary fractional caps retain truncation and rounded boundaries | api/test_api_helpers.py::test_simulation_preserves_small_fractional_duration_caps | Includes zero cap and one-third / one-seventh ratios |
-| api-nonfinite-duration-fallback-154 | api | Explicit non-finite native durations preserve configured/default idle-cap precedence | api/test_api_helpers.py::test_nonfinite_explicit_duration_uses_configured_then_default_idle_cap | Python and NumPy NaN and both infinities |
-| api-unbounded-duration-report-155 | api | Public reports retain exact duration configuration beyond integer-string digit limits | api/test_api_helpers.py::test_public_report_preserves_unbounded_duration_configuration | Leaves interpreter globals and boolean override semantics unchanged |
-| statistics-extended-duration-mass-156 | statistics | Large-duration bins retain all observations, reward means and declared ratio units | statistics/test_statistics.py::test_extended_duration_relationship_bins_preserve_mass_and_reward_means | Actual simulator output |
-| statistics-exact-duration-boundary-157 | statistics | Adjacent integer observations at extended right-closed boundaries stay distinguishable | statistics/test_statistics.py::test_extended_duration_bins_distinguish_exact_right_closed_boundaries | Includes outside-range clipping |
-| statistics-candle-duration-units-158 | statistics | Ordinary duration relationship tables retain candle-valued boundaries | statistics/test_statistics.py::test_ordinary_duration_relationship_bins_retain_candle_units | Unchanged ordinary output scale |
-| statistics-nullable-duration-bins-159 | statistics | Nullable numeric duration observations remain excluded from aggregates at extended scales | statistics/test_statistics.py::test_extended_duration_bins_preserve_nullable_observation_semantics | Counts and reward means use only nonmissing observations |
+| cli-effective-reward-boundaries-145           | cli        | Effective profit-target fallback and explicit zero idle denominator reach artifacts                | cli/test_cli_params_and_csv.py::test_zero_target_and_idle_cap_reach_rewards_and_manifest               | Metadata and near-target market coverage agree with runtime boundaries                                                                                                         |
+| pbrs-native-integer-durations-150             | pbrs       | Native integer durations and adjustment metadata retain exact values                               | pbrs/test_pbrs.py::test_native_integer_durations_preserve_exact_values                                 | Python, signed NumPy and unsigned NumPy integers; strict and relaxed                                                                                                           |
+| pbrs-native-duration-bounds-151               | pbrs       | Floating duration bounds are checked before truncation without narrowing                           | pbrs/test_pbrs.py::test_native_duration_bounds_precede_truncation                                      | Extended-precision cases run when supported by the platform                                                                                                                    |
+| pbrs-native-gamma-reward-148                  | pbrs       | Finite out-of-range native gamma clamps reach the public reward                                    | pbrs/test_pbrs.py::test_native_finite_gamma_clamps_the_public_reward                                   | Extended-range cases run when supported by the platform                                                                                                                        |
+| pbrs-relaxed-native-gamma-149                 | pbrs       | Relaxed native gamma clamps reach rewards while strict validation rejects                          | pbrs/test_pbrs.py::test_relaxed_native_gamma_clamps_before_reward_calculation                          | Preserves direct, strict and relaxed boundary contracts                                                                                                                        |
+| api-native-duration-consumers-152             | api        | Validated extended native durations reach real trajectories and finite rewards                     | api/test_api_helpers.py::test_simulation_consumes_extended_native_duration_without_narrowing           | Includes the actual exit hazard and terminal state                                                                                                                             |
+| api-nonfinite-duration-fallback-154           | api        | Explicit non-finite native durations preserve configured/default idle-cap precedence               | api/test_api_helpers.py::test_nonfinite_explicit_duration_uses_configured_then_default_idle_cap        | Python and NumPy NaN and both infinities                                                                                                                                       |
+| api-unbounded-duration-report-155             | api        | Public reports retain exact duration configuration beyond integer-string digit limits              | api/test_api_helpers.py::test_public_report_preserves_unbounded_duration_configuration                 | Leaves interpreter globals and boolean override semantics unchanged                                                                                                            |
+| statistics-extended-duration-mass-156         | statistics | Large-duration bins retain all observations, reward means and declared ratio units                 | statistics/test_statistics.py::test_extended_duration_relationship_bins_preserve_mass_and_reward_means | Actual simulator output                                                                                                                                                        |
+| statistics-exact-duration-boundary-157        | statistics | Adjacent integer observations at extended right-closed boundaries stay distinguishable             | statistics/test_statistics.py::test_extended_duration_bins_distinguish_exact_right_closed_boundaries   | Includes outside-range clipping                                                                                                                                                |
+| statistics-candle-duration-units-158          | statistics | Ordinary duration relationship tables retain candle-valued boundaries                              | statistics/test_statistics.py::test_ordinary_duration_relationship_bins_retain_candle_units            | Unchanged ordinary output scale                                                                                                                                                |
+| statistics-nullable-duration-bins-159         | statistics | Nullable numeric duration observations remain excluded from aggregates at extended scales          | statistics/test_statistics.py::test_extended_duration_bins_preserve_nullable_observation_semantics     | Counts and reward means use only nonmissing observations                                                                                                                       |
+| components-invalid-time-costs-160             | components | Invalid no-ops retain the current state's idle or hold cost                                        | components/test_reward_components.py::test_invalid_actions_add_to_the_unchanged_states_time_cost       | Neutral, long and short states                                                                                                                                                 |
+| components-power-subnormal-161                | components | Power attenuation retains representable subnormals and underflows without changing kernels         | components/test_reward_components.py::test_power_kernel_retains_subnormal_values_and_true_underflow    | No absolute tolerance masking                                                                                                                                                  |
+| pbrs-physical-duration-bound-162              | pbrs       | Synthetic duration bounds produce valid exits without freezing the physical clock                  | pbrs/test_pbrs.py::test_synthetic_duration_cap_closes_trades_without_freezing_the_clock                | Includes fractional one-candle minimum, unmasked sampling and terminal PBRS                                                                                                    |
+| pbrs-terminal-next-state-163                  | pbrs       | Terminal liquidation requires the actual marked next state                                         | pbrs/test_pbrs.py::test_terminal_liquidation_requires_the_actual_marked_next_state                     | Both sides, fees, missing and wrong-side states                                                                                                                                |
 
 ### Non-Owning Smoke / Reference Checks
 
index 2318d343de6ba68dce9e1a403b418a7aef1ee9b1..3308c82b86b474e7ad61204e797d4dbf0686754e 100644 (file)
@@ -98,19 +98,6 @@ class TestAPIAndHelpers(RewardSpaceTestBase):
                 self.assertTrue(df.iloc[-1]["terminated"])
                 self.assertEqual(df.iloc[-1]["next_position"], Positions.Neutral.value)
 
-    def test_simulation_preserves_small_fractional_duration_caps(self):
-        """Keep ordinary float-product truncation, including zero and rounded boundaries."""
-        for duration, ratio, expected in ((1, 0.5, 0), (3, 1 / 3, 1), (7, 1 / 7, 1), (10, 0.3, 3)):
-            with self.subTest(duration=duration, ratio=ratio):
-                df = simulate_samples_with_defaults(
-                    self.base_params(max_trade_duration_candles=duration),
-                    num_samples=8,
-                    seed=42,
-                    trading_mode="spot",
-                    max_duration_ratio=ratio,
-                )
-                self.assertEqual(df["next_trade_duration"].max(), expected)
-
     def test_public_report_preserves_unbounded_duration_configuration(self):
         """The real report retains exact integer configuration beyond Python digit limits."""
         duration = 10**4900
@@ -334,8 +321,7 @@ class TestAPIAndHelpers(RewardSpaceTestBase):
         values = (
             df[["sample_entry_prob", "sample_exit_prob", "sample_neutral_prob"]].stack().dropna()
         )
-        prob_upper_bound = SCENARIOS.API_PROBABILITY_UPPER_BOUND
-        self.assertTrue(((values >= 0.0) & (values <= prob_upper_bound)).all())
+        self.assertTrue(((values >= 0.0) & (values <= 1.0)).all())
 
     def test_unmasked_simulation_samples_invalid_actions_without_changing_position(self):
         """Unmasked actions expose penalties without inventing trades or wrong-side exits."""
@@ -354,7 +340,15 @@ class TestAPIAndHelpers(RewardSpaceTestBase):
                 self.assertGreater(len(invalid), 0)
                 self.assertLess(len(invalid), len(df))
                 self.assertTrue((invalid["reward_invalid"] == params["invalid_action"]).all())
-                self.assertTrue((invalid["reward_base"] == params["invalid_action"]).all())
+                np.testing.assert_allclose(
+                    invalid["reward_base"],
+                    invalid["reward_invalid"]
+                    + invalid["reward_idle"]
+                    + invalid["reward_hold"]
+                    + invalid["reward_exit"],
+                    atol=TOLERANCE.IDENTITY_STRICT,
+                    rtol=0.0,
+                )
                 wrong_exit = invalid.loc[
                     (
                         (
@@ -616,11 +610,12 @@ class TestPrivateFunctions(RewardSpaceTestBase):
         self.assertAlmostEqualFloat(
             breakdown.total,
             breakdown.invalid_penalty
+            + breakdown.hold_penalty
             + breakdown.reward_shaping
             + breakdown.entry_additive
             + breakdown.exit_additive,
             tolerance=TOLERANCE.IDENTITY_RELAXED,
-            msg="Total should equal invalid penalty plus shaping/additives",
+            msg="Invalid actions retain the current position's time cost",
         )
 
     def test_new_invariant_and_warn_parameters(self):
index 851b79a86534a4e12ed353da438929accca4e5bc..97a101669b2984acdffa36b03c5a1f172e470deb 100644 (file)
@@ -13,6 +13,7 @@ from reward_space_analysis import (
     _compute_efficiency_coefficient,
     _compute_hold_potential,
     _compute_pnl_target_coefficient,
+    _compute_time_attenuation_coefficient,
     _get_exit_factor,
     _get_float_param,
     get_max_idle_duration_candles,
@@ -39,6 +40,59 @@ pytestmark = pytest.mark.components
 
 
 class TestRewardComponents(RewardSpaceTestBase):
+    def test_invalid_actions_add_to_the_unchanged_states_time_cost(self):
+        """Retain the physical time cost when an invalid action cannot trade."""
+        params = self.base_params(hold_potential_enabled=False)
+        trade_duration = 3 * params["max_trade_duration_candles"]
+        idle_duration = 3 * get_max_idle_duration_candles(params)
+        for position, invalid_action in (
+            (Positions.Neutral, Actions.Long_exit),
+            (Positions.Long, Actions.Long_enter),
+            (Positions.Short, Actions.Short_enter),
+        ):
+            with self.subTest(position=position):
+                common = {
+                    "pnl": 0.0,
+                    "trade_duration": trade_duration,
+                    "idle_duration": idle_duration,
+                    "max_unrealized_profit": 0.0,
+                    "min_unrealized_profit": 0.0,
+                    "position": position,
+                }
+                valid = calculate_reward_with_defaults(
+                    self.make_ctx(**common, action=Actions.Neutral), params, action_masking=False
+                )
+                invalid = calculate_reward_with_defaults(
+                    self.make_ctx(**common, action=invalid_action), params, action_masking=False
+                )
+                self.assertLess(valid.base_reward, params["invalid_action"])
+                self.assertAlmostEqualFloat(
+                    invalid.base_reward,
+                    valid.base_reward + params["invalid_action"],
+                    tolerance=TOLERANCE.IDENTITY_STRICT,
+                )
+                self.assertEqual(invalid.idle_penalty, valid.idle_penalty)
+                self.assertEqual(invalid.hold_penalty, valid.hold_penalty)
+
+    def test_power_kernel_retains_subnormal_values_and_true_underflow(self):
+        """Avoid intermediate overflow without substituting a different kernel."""
+        params = self.base_params(
+            exit_attenuation_mode="power",
+            exit_plateau=False,
+            exit_power_tau=SCENARIOS.POWER_SUBNORMAL_TAU,
+        )
+        ratio = SCENARIOS.POWER_SUBNORMAL_DURATION
+        expected = math.exp(
+            math.log(SCENARIOS.POWER_SUBNORMAL_TAU) / math.log(2) * math.log1p(ratio)
+        )
+        actual = _compute_time_attenuation_coefficient(ratio, params)
+        self.assertGreater(actual, 0.0)
+        # Absolute tolerances would accept the erroneous linear fallback.
+        self.assertTrue(math.isclose(actual, expected, rel_tol=TOLERANCE.RELATIVE, abs_tol=0.0))
+        self.assertEqual(
+            _compute_time_attenuation_coefficient(SCENARIOS.POWER_UNDERFLOW_DURATION, params), 0.0
+        )
+
     def test_hold_potential_computation_finite(self):
         """Test hold potential computation returns finite values."""
         params = {
index d2e704abf6cc2d2f74c2e2939f0e89d7c5627a89..c6bccf332fcb74c642000a1ddb695c26d7c2487c 100644 (file)
@@ -336,8 +336,14 @@ class TestScenarios:
         API_ENTRY_RATE_DRAWS: Draw count for entry-rate estimation (2000)
         API_MAX_TRADE_DURATION_CANDLES: Max trade duration used in API simulation tests (40)
         API_MAX_DURATION_RATIO: Max duration ratio used in API simulation tests (1.5)
-        API_PROBABILITY_UPPER_BOUND: Upper bound for exposed sampling probabilities (0.9)
         API_EXTREME_BASE_FACTOR: Extreme base_factor used to trigger warning paths (10000000.0)
+        CLOCK_TRADE_SCALE: One-candle reward scale exposing the synthetic clock boundary
+        CLOCK_MIN_DURATION_RATIO: Fractional cap requiring a one-candle physical minimum (0.5)
+        CLOCK_DURATION_RATIO: Two-candle synthetic cap after ordinary truncation (2.5)
+        TERMINAL_ENTRY_FEE: Symmetric fee used by terminal liquidation oracles (0.001)
+        POWER_SUBNORMAL_TAU: Strictly accepted power-kernel tau (1e-6)
+        POWER_SUBNORMAL_DURATION: Ratio with a representable subnormal result (1e16)
+        POWER_UNDERFLOW_DURATION: Ratio whose power-kernel result underflows to zero (1e18)
 
         # CLI smoke parameters
         CLI_NUM_SAMPLES_STANDARD: Default CLI sample size for smoke runs (200)
@@ -375,8 +381,14 @@ class TestScenarios:
     API_ENTRY_RATE_DRAWS: int = 2000
     API_MAX_TRADE_DURATION_CANDLES: int = 40
     API_MAX_DURATION_RATIO: float = 1.5
-    API_PROBABILITY_UPPER_BOUND: float = 0.9
     API_EXTREME_BASE_FACTOR: float = 10_000_000.0
+    CLOCK_TRADE_SCALE: int = 1
+    CLOCK_MIN_DURATION_RATIO: float = 0.5
+    CLOCK_DURATION_RATIO: float = 2.5
+    TERMINAL_ENTRY_FEE: float = 0.001
+    POWER_SUBNORMAL_TAU: float = 1e-6
+    POWER_SUBNORMAL_DURATION: float = 1e16
+    POWER_UNDERFLOW_DURATION: float = 1e18
 
     # CLI smoke parameters
     CLI_NUM_SAMPLES_STANDARD: int = 200
index 0366119780547fa8d65eb3ec86f686868d1ca795..89c6913d58c76ed99a100ccaca0bda53ff720f54 100644 (file)
@@ -50,6 +50,115 @@ pytestmark = pytest.mark.pbrs
 class TestSimulationParity(RewardSpaceTestBase):
     """Synthetic durations and reports follow runtime transition semantics."""
 
+    def test_synthetic_duration_cap_closes_trades_without_freezing_the_clock(self):
+        """Bound physical trade duration with valid exits and truthful probabilities."""
+        scale = SCENARIOS.CLOCK_TRADE_SCALE
+        params = self.base_params(
+            max_trade_duration_candles=scale,
+            action_masking=False,
+            hold_potential_enabled=True,
+            exit_potential_mode="canonical",
+        )
+        for ratio in (SCENARIOS.CLOCK_MIN_DURATION_RATIO, SCENARIOS.CLOCK_DURATION_RATIO):
+            with self.subTest(ratio=ratio):
+                cap = max(1, int(scale * ratio))
+                df = simulate_samples(
+                    params=params,
+                    num_samples=SCENARIOS.SAMPLE_SIZE_LARGE,
+                    seed=SEEDS.BASE,
+                    base_factor=PARAMS.BASE_FACTOR,
+                    profit_aim=PARAMS.PROFIT_AIM,
+                    risk_reward_ratio=PARAMS.RISK_REWARD_RATIO,
+                    max_duration_ratio=ratio,
+                    trading_mode="futures",
+                    pnl_base_std=PARAMS.PNL_STD,
+                    pnl_duration_vol_scale=PARAMS.PNL_DUR_VOL_SCALE,
+                )
+                age = forced_exits = 0
+                for row in df.itertuples():
+                    self.assertEqual(row.trade_duration, age)
+                    if row.position != Positions.Neutral.value and age == cap:
+                        expected = (
+                            Actions.Long_exit
+                            if row.position == Positions.Long.value
+                            else Actions.Short_exit
+                        )
+                        self.assertEqual(row.action, expected.value)
+                        self.assertEqual(row.sample_exit_prob, 1.0)
+                        self.assertEqual(row.is_invalid, 0.0)
+                        self.assertEqual(row.next_position, Positions.Neutral.value)
+                        forced_exits += 1
+                    if row.next_position == Positions.Neutral.value:
+                        age = 0
+                    elif row.position == Positions.Neutral.value:
+                        age = 1
+                    else:
+                        age += 1
+                    self.assertEqual(row.next_trade_duration, age)
+                    self.assertLessEqual(age, cap)
+                self.assertGreater(forced_exits, 0)
+                verification = reward_space_analysis.verify_pbrs_trajectory(
+                    df, float(params["potential_gamma"])
+                )
+                self.assertTrue(verification["verified"], verification)
+
+    def test_terminal_liquidation_requires_the_actual_marked_next_state(self):
+        """Reject absent/wrong-side states and retain fee-aware terminal entry losses."""
+        fee = SCENARIOS.TERMINAL_ENTRY_FEE
+        params = self.base_params(
+            base_factor=PARAMS.BASE_FACTOR,
+            entry_fee_rate=fee,
+            exit_fee_rate=fee,
+            hold_potential_enabled=False,
+            efficiency_weight=0.0,
+        )
+        for side, action, pnl in (
+            (Positions.Long, Actions.Long_enter, 1 / (1 + fee) ** 2 - 1),
+            (Positions.Short, Actions.Short_enter, 1 - (1 + fee) ** 2),
+        ):
+            with self.subTest(side=side):
+                context = self.make_ctx(
+                    pnl=0.0,
+                    trade_duration=0,
+                    idle_duration=0,
+                    max_unrealized_profit=0.0,
+                    min_unrealized_profit=0.0,
+                    position=Positions.Neutral,
+                    action=action,
+                )
+                with self.assertRaisesRegex(ValueError, "next_context"):
+                    calculate_reward_with_defaults(context, params, terminated=True)
+                wrong = self.make_ctx(
+                    pnl=0.0,
+                    trade_duration=0,
+                    idle_duration=0,
+                    max_unrealized_profit=0.0,
+                    min_unrealized_profit=0.0,
+                    position=Positions.Neutral,
+                    action=Actions.Neutral,
+                )
+                with self.assertRaisesRegex(ValueError, "next_context"):
+                    calculate_reward_with_defaults(
+                        context, params, terminated=True, next_context=wrong
+                    )
+                next_context = self.make_ctx(
+                    pnl=pnl,
+                    trade_duration=1,
+                    idle_duration=0,
+                    max_unrealized_profit=pnl,
+                    min_unrealized_profit=pnl,
+                    position=side,
+                    action=Actions.Neutral,
+                )
+                result = calculate_reward_with_defaults(
+                    context, params, terminated=True, next_context=next_context
+                )
+                self.assertTrue(result.terminal_liquidation)
+                self.assertAlmostEqualFloat(
+                    result.total, PARAMS.BASE_FACTOR * pnl, tolerance=TOLERANCE.IDENTITY_STRICT
+                )
+                self.assertEqual(result.exit_pnl, pnl)
+
     def test_neutral_terminal_closes_stored_potential(self):
         context = reward_space_analysis.RewardContext(
             current_pnl=0.0,
index 149b2e5d7dd1aa7f0b39ff7344cd3dd4f57c12af..f56b01616f8868b4be8e6fa28481542298197181 100644 (file)
@@ -41,7 +41,7 @@ class TestRewardRobustnessAndBoundaries(RewardSpaceTestBase):
 
     # Owns invariant: robustness-decomposition-integrity-101 (robustness category)
     def test_decomposition_integrity(self):
-        """reward must equal the single active core component under mutually exclusive scenarios (idle/hold/exit/invalid)."""
+        """Isolate idle, hold, exit and pre-overtime invalid costs in selected scenarios."""
         scenarios = [
             {
                 "ctx": self.make_ctx(
index 930917df1888fdfd5f2ed7690f992de0468699b1..c78f10e6d16b51c8c42ad813d4a0264fcc2aba99 100644 (file)
@@ -139,7 +139,11 @@ class OptunaLifecycleTest(QaTestCase):
                     self.assertNotEqual(result["gamma"], 0.91)
 
     def test_incompatible_or_missing_study_identity_discards_old_trials(self):
-        for identity in (None, "terminal-liquidation-risk-normalized-trained-policy-v3"):
+        for identity in (
+            None,
+            "terminal-liquidation-risk-normalized-trained-policy-v3",
+            "terminal-liquidation-risk-normalized-trained-policy-v5",
+        ):
             with self.subTest(identity=identity):
                 model = self.model()
                 study = optuna.create_study(
index 45e97f6ce987090fa2d4af41aa96c86cf130327c..41a648c7704076507f5e0ac2d2a9ae0ffd9347fe 100644 (file)
@@ -207,6 +207,10 @@ class OptunaStorageTest(QaTestCase):
             {},
             {"params": {"gamma": 0.1}},
             {"objective_identity": "legacy", "params": {"gamma": 0.1}},
+            {
+                "objective_identity": "terminal-liquidation-risk-normalized-trained-policy-v5",
+                "params": {"gamma": 0.1},
+            },
             {"objective_identity": ReforceXY._OPTUNA_OBJECTIVE_IDENTITY, "params": []},
         ):
             with self.subTest(payload=payload):
diff --git a/ReforceXY/tests/test_model_reward_contracts.py b/ReforceXY/tests/test_model_reward_contracts.py
new file mode 100644 (file)
index 0000000..46f9302
--- /dev/null
@@ -0,0 +1,297 @@
+"""Reward costs, numerical boundaries and the native model-selection objective."""
+
+import math
+import tempfile
+from types import SimpleNamespace
+
+import numpy as np
+import pandas as pd
+from qa_support import PAIR, QaTestCase, model_config
+from sb3_contrib.common.maskable.evaluation import evaluate_policy
+
+from ReforceXY.user_data.freqaimodels.ReforceXY import (
+    Actions,
+    MyRLEnv,
+    Positions,
+    ReforceXY,
+)
+
+
+class _TradeOrWaitPolicy:
+    """Choose an observable long round trip, or remain in cash."""
+
+    def __init__(self, trade):
+        self.trade = trade
+
+    def predict(self, observation, state=None, **kwargs):
+        positions = observation[:, -1, -2]
+        actions = (
+            np.where(
+                positions == Positions.Neutral.value,
+                Actions.Long_enter.value,
+                Actions.Long_exit.value,
+            )
+            if self.trade
+            else np.full(len(observation), Actions.Neutral.value)
+        )
+        return actions, state
+
+
+class RewardContractsTest(QaTestCase):
+    def env(self, *, opens=None, **parameters):
+        prices = pd.DataFrame({"open": [100.0] * 16 if opens is None else opens})
+        env = MyRLEnv(
+            df=prices.copy(),
+            prices=prices,
+            df_raw=prices.copy(),
+            window_size=1,
+            reward_kwargs={"rr": 2.0, "profit_aim": 0.03},
+            fee=0.0,
+            can_short=True,
+            config={
+                "stake_amount": "unlimited",
+                "freqai": {
+                    "rl_config": {
+                        "add_state_info": True,
+                        "max_training_drawdown_pct": 0.99,
+                        "model_reward_parameters": {
+                            "max_trade_duration_candles": 1,
+                            "max_idle_duration_candles": 1,
+                            "hold_potential_enabled": False,
+                            **parameters,
+                        },
+                    }
+                },
+            },
+            live=True,
+        )
+        self.addCleanup(env.close)
+        env.reset(seed=42)
+        return env
+
+    def test_invalid_noop_keeps_time_cost_for_every_position(self):
+        for entry, invalid in (
+            (None, Actions.Long_exit),
+            (Actions.Long_enter, Actions.Long_enter),
+            (Actions.Short_enter, Actions.Short_enter),
+        ):
+            for penalty in (0.0, -2.0):
+                with self.subTest(entry=entry, penalty=penalty):
+                    valid = self.env(invalid_action=penalty)
+                    invalid_env = self.env(invalid_action=penalty)
+                    for env in (valid, invalid_env):
+                        if entry is not None:
+                            env.step(entry.value)
+                        for _ in range(3):
+                            env.step(Actions.Neutral.value)
+                    obs, reward, _, _, info = valid.step(Actions.Neutral.value)
+                    bad_obs, bad_reward, _, _, bad_info = invalid_env.step(invalid.value)
+                    np.testing.assert_array_equal(bad_obs, obs)
+                    self.assertLess(reward, 0.0)
+                    self.assertAlmostEqual(bad_reward, reward + penalty)
+                    self.assertEqual(bad_info["reward_idle"], info["reward_idle"])
+                    self.assertEqual(bad_info["reward_hold"], info["reward_hold"])
+                    self.assertEqual(invalid_env._total_profit, valid._total_profit)
+                    self.assertEqual(len(invalid_env.trade_history), len(valid.trade_history))
+
+    def test_valid_entry_and_exit_do_not_pay_noop_costs(self):
+        env = self.env()
+        for _ in range(3):
+            env.step(Actions.Neutral.value)
+        _, reward, _, _, info = env.step(Actions.Long_enter.value)
+        self.assertEqual(reward, 0.0)
+        self.assertEqual(info["reward_idle"], 0.0)
+        for _ in range(3):
+            env.step(Actions.Neutral.value)
+        _, reward, _, _, info = env.step(Actions.Long_exit.value)
+        self.assertEqual(reward, 0.0)
+        self.assertEqual(info["reward_hold"], 0.0)
+
+    def test_null_idle_scale_derives_trade_scale_but_zero_remains_explicit(self):
+        for idle_scale, denominator in ((None, 8), (0, 1)):
+            with self.subTest(idle_scale=idle_scale):
+                env = self.env(max_trade_duration_candles=2, max_idle_duration_candles=idle_scale)
+                for _ in range(3):
+                    env.step(Actions.Neutral.value)
+                _, reward, _, _, _ = env.step(Actions.Neutral.value)
+                self.assertAlmostEqual(reward, -1.5 * (3 / denominator) ** 1.025)
+
+    def test_power_exit_preserves_python_and_numpy_tau(self):
+        for tau in (0.25, np.float32(0.25), np.longdouble(0.25), np.int64(1)):
+            with self.subTest(tau_type=type(tau)):
+                env = self.env(
+                    opens=[100.0, 100.0, 105.0, 105.0],
+                    exit_attenuation_mode="power",
+                    exit_plateau=False,
+                    exit_power_tau=tau,
+                )
+                env.step(Actions.Long_enter.value)
+                _, reward, terminated, _, _ = env.step(Actions.Long_exit.value)
+                self.assertTrue(terminated)
+                # At duration ratio one, K(1)=tau; the fee-free exit has base reward 7.5.
+                self.assertAlmostEqual(reward, 7.5 * float(tau))
+
+    def test_power_underflow_does_not_switch_to_a_linear_kernel(self):
+        env = self.env()
+        params = {"exit_attenuation_mode": "power", "exit_plateau": False, "exit_power_tau": 1e-6}
+        ratio = 1e16
+        expected = math.exp(math.log(1e-6) / math.log(2) * math.log1p(ratio))
+        actual = env._compute_time_attenuation_coefficient(ratio, params)
+        self.assertGreater(actual, 0.0)
+        # Subnormal rounding needs relative tolerance; an absolute epsilon would hide the defect.
+        self.assertTrue(math.isclose(actual, expected, rel_tol=1e-4, abs_tol=0.0))
+        self.assertEqual(env._compute_time_attenuation_coefficient(1e18, params), 0.0)
+
+    def test_native_evaluation_preserves_base_ranking_without_losing_training_shaping(self):
+        for ratio in (0.4, 1e16):
+            for stacking in (0, 2):
+                with (
+                    self.subTest(ratio=ratio, stacking=stacking),
+                    tempfile.TemporaryDirectory() as temp,
+                ):
+                    config = model_config(
+                        temp,
+                        freqai={
+                            "model_training_parameters": {"gamma": 0.8},
+                            "rl_config": {
+                                "add_state_info": True,
+                                "frame_stacking": stacking,
+                                "model_reward_parameters": {
+                                    "profit_aim": 0.025,
+                                    "hold_potential_enabled": True,
+                                    "hold_potential_ratio": ratio,
+                                    "max_trade_duration_candles": 1,
+                                    "hold_potential_transform_pnl": "clip",
+                                    "hold_potential_transform_duration": "clip",
+                                    "idle_penalty_ratio": 0.0,
+                                },
+                            },
+                        },
+                    )
+                    model = ReforceXY(config=config)
+                    model.live = True
+                    self.addCleanup(model.close_envs)
+                    prices = pd.DataFrame({"open": [100.0, 100.0, 105.0, 105.0]})
+                    # Use the real factory and VecMonitor, not a replacement evaluation implementation.
+                    model.set_train_and_eval_environments(
+                        {"train_features": prices.copy(), "test_features": prices.copy()},
+                        prices,
+                        prices,
+                        SimpleNamespace(pair=PAIR),
+                    )
+                    model.train_env.set_attr("fee", 0.0)
+                    model.eval_env.set_attr("fee", 0.0)
+                    trade_score, _ = evaluate_policy(
+                        _TradeOrWaitPolicy(True), model.eval_env, n_eval_episodes=1, warn=False
+                    )
+                    cash_score, _ = evaluate_policy(
+                        _TradeOrWaitPolicy(False), model.eval_env, n_eval_episodes=1, warn=False
+                    )
+                    self.assertAlmostEqual(trade_score, 7.5)
+                    self.assertEqual(cash_score, 0.0)
+                    model.train_env.reset()
+                    _, rewards, _, _ = model.train_env.step([Actions.Long_enter.value])
+                    np.testing.assert_allclose(rewards, [0.8 * 100 * ratio], rtol=1e-6)
+                    model.close_envs()
+
+    def test_evaluation_retains_entry_and_terminal_exit_additives(self):
+        with tempfile.TemporaryDirectory() as temp:
+            fee = 0.001
+            config = model_config(
+                temp,
+                freqai={
+                    "rl_config": {
+                        "add_state_info": True,
+                        "model_reward_parameters": {
+                            "hold_potential_enabled": True,
+                            "exit_potential_mode": "non_canonical",
+                            "entry_additive_enabled": True,
+                            "exit_additive_enabled": True,
+                            "max_trade_duration_candles": 1,
+                        },
+                    }
+                },
+            )
+            model = ReforceXY(config=config)
+            model.live = True
+            self.addCleanup(model.close_envs)
+            prices = pd.DataFrame({"open": [100.0] * 3})
+            model.set_train_and_eval_environments(
+                {"train_features": prices.copy(), "test_features": prices.copy()},
+                prices,
+                prices,
+                SimpleNamespace(pair=PAIR),
+            )
+            model.eval_env.set_attr("fee", fee)
+            score, _ = evaluate_policy(
+                _TradeOrWaitPolicy(True), model.eval_env, n_eval_episodes=1, warn=False
+            )
+            pnl = 1 / (1 + fee) ** 2 - 1
+            entry_additive = 6.25 * 0.5 * math.tanh(pnl / 0.06)
+            terminal_additive = 6.25 * 0.5 * (math.tanh(pnl / 0.06) - math.tanh(1.0))
+            self.assertAlmostEqual(score, 100 * pnl + entry_additive + terminal_additive, places=6)
+
+    def test_monitored_scores_retain_small_returns_between_large_exits_after_reset(self):
+        for multiprocessing in (False, True):
+            with (
+                self.subTest(multiprocessing=multiprocessing),
+                tempfile.TemporaryDirectory() as temp,
+            ):
+                base = 2**25
+                small_mark = 150.0 * (1.0 + 1.0 / base)
+                config = model_config(
+                    temp,
+                    freqai={
+                        "rl_config": {
+                            "n_eval_envs": 2 if multiprocessing else 1,
+                            "eval_multiprocessing": multiprocessing,
+                            "model_reward_parameters": {
+                                "base_factor": base,
+                                "win_reward_factor": 0.0,
+                                "efficiency_weight": 0.0,
+                                "idle_penalty_ratio": 0.0,
+                            },
+                        }
+                    },
+                )
+                model = ReforceXY(config=config)
+                model.live = True
+                self.addCleanup(model.close_envs)
+                prices = pd.DataFrame(
+                    {
+                        "open": [
+                            100.0,
+                            100.0,
+                            150.0,
+                            150.0,
+                            small_mark,
+                            small_mark,
+                            1.5 * small_mark,
+                        ]
+                    }
+                )
+                model.set_train_and_eval_environments(
+                    {"train_features": prices.copy(), "test_features": prices.copy()},
+                    prices,
+                    prices,
+                    SimpleNamespace(pair=PAIR),
+                )
+                model.eval_env.set_attr("fee", 0.0)
+                for _ in range(2):
+                    model.eval_env.reset()
+                    for action in (
+                        Actions.Long_enter,
+                        Actions.Long_exit,
+                        Actions.Long_enter,
+                        Actions.Long_exit,
+                        Actions.Short_enter,
+                    ):
+                        _, _, done, infos = model.eval_env.step(
+                            [action.value] * model.eval_env.num_envs
+                        )
+                    self.assertTrue(done.all())
+                    # Three exit rewards are 2**24, 1 and -2**24; equity remains positive.
+                    for info in infos:
+                        self.assertEqual(info["episode"]["r"], 1.0)
+                        self.assertGreater(info["total_profit"], 0.0)
+                model.close_envs()
index 0b6221f07cbf563b9bb2462ee1fbc4d1a9dbd2e7..751e872284552a31110a7e0a2a834bab781cc0ba 100644 (file)
@@ -97,23 +97,27 @@ class TrainingObservationsTest(QaTestCase):
                 metadata_path = dk.data_path / f"{dk.model_filename}_{METADATA}.json"
                 metadata = json.loads(metadata_path.read_text())
                 current_generation = metadata[model._DEPLOYMENT_COORDINATE_MARKER_KEY]
-                metadata[model._DEPLOYMENT_COORDINATE_MARKER_KEY] = (
-                    "chronological-frozen-pipelines-v2"
-                )
-                metadata_path.write_text(json.dumps(metadata))
-                for cached in (False, True):
-                    with self.subTest(legacy_cached=cached):
-                        model.dd.model_dictionary.clear()
-                        model.dd.meta_data_dictionary.clear()
-                        if cached:
-                            inference = model.dd.load_data(dk.pair, dk)
-                            action, _ = inference.predict(
-                                np.zeros(inference.observation_space.shape, dtype=np.float32),
-                                deterministic=True,
-                            )
-                            self.assertTrue(inference.action_space.contains(action))
-                        with self.assertRaisesRegex(DependencyException, "Reset trained models"):
-                            model._resolve_deployment_state(dk, dk.pair)
+                for legacy_generation in (
+                    "chronological-frozen-pipelines-v2",
+                    "chronological-frozen-pipelines-pbrs-v3",
+                ):
+                    metadata[model._DEPLOYMENT_COORDINATE_MARKER_KEY] = legacy_generation
+                    metadata_path.write_text(json.dumps(metadata))
+                    for cached in (False, True):
+                        with self.subTest(
+                            legacy_generation=legacy_generation, legacy_cached=cached
+                        ):
+                            model.dd.model_dictionary.clear()
+                            model.dd.meta_data_dictionary.clear()
+                            if cached:
+                                inference = model.dd.load_data(dk.pair, dk)
+                                action, _ = inference.predict(
+                                    np.zeros(inference.observation_space.shape, dtype=np.float32),
+                                    deterministic=True,
+                                )
+                                self.assertTrue(inference.action_space.contains(action))
+                            with self.assertRaises(DependencyException):
+                                model._resolve_deployment_state(dk, dk.pair)
                 metadata[model._DEPLOYMENT_COORDINATE_MARKER_KEY] = current_generation
                 metadata_path.write_text(json.dumps(metadata))
                 model.dd.meta_data_dictionary.clear()
index 6cb2410a8ee24afa700987571397c05edb6a1193..5598ba6380b924247386a265fadacf95a6d71412 100644 (file)
@@ -85,9 +85,45 @@ from stable_baselines3.common.vec_env import (
     DummyVecEnv,
     SubprocVecEnv,
     VecEnv,
+    VecEnvWrapper,
     VecFrameStack,
     VecMonitor,
 )
+from stable_baselines3.common.vec_env.base_vec_env import VecEnvObs, VecEnvStepReturn
+
+
+class _SelectionRewardVecEnv(VecEnvWrapper):
+    """Expose non-PBRS evaluation rewards before VecMonitor aggregates episodes.
+
+    The repository does not use VecNormalize. Adding it requires aligning train
+    and evaluation wrapper chains for SB3's normalization synchronization.
+    """
+
+    def reset(self) -> VecEnvObs:
+        return self.venv.reset()
+
+    def step_wait(self) -> VecEnvStepReturn:
+        observations, _, dones, infos = self.venv.step_wait()
+        rewards = np.fromiter(
+            (float(info["reward_selection"]) for info in infos),
+            dtype=np.float64,
+            count=self.num_envs,
+        )
+        return observations, rewards, dones, infos
+
+
+class _RewardVecMonitor(VecMonitor):
+    """Accumulate native episode scores without narrowing to float32."""
+
+    def __init__(self, venv: VecEnv):
+        super().__init__(venv)
+        self.episode_returns = np.zeros(self.num_envs, dtype=np.float64)
+
+    def reset(self) -> VecEnvObs:
+        observations = super().reset()
+        # VecMonitor.reset recreates float32 storage; keep native reward precision.
+        self.episode_returns = np.zeros(self.num_envs, dtype=np.float64)
+        return observations
 
 
 def _update_eval_best_reward(callback: Any, mean_reward: float, model: Any) -> None:
@@ -437,7 +473,7 @@ class ReforceXY(BaseReinforcementLearningModel):
     _LOG_2: Final[float] = math.log(2.0)
 
     _DEPLOYMENT_COORDINATE_MARKER_KEY: Final[str] = "reforcexy_deployment_coordinates"
-    _DEPLOYMENT_COORDINATE_GENERATION: Final[str] = "chronological-frozen-pipelines-pbrs-v3"
+    _DEPLOYMENT_COORDINATE_GENERATION: Final[str] = "chronological-frozen-pipelines-pbrs-v4"
 
     DEFAULT_BASE_FACTOR: Final[float] = 100.0
     DEFAULT_DISCOUNT_GAMMA: Final[float] = 0.95
@@ -539,12 +575,14 @@ class ReforceXY(BaseReinforcementLearningModel):
         *_OPTIMIZER_CLASSES_OPTUNA,
         "adam",
     )
-    _NET_ARCH_SIZES: Final[tuple[NetArchSize, ...]] = (
-        "small",
-        "medium",
-        "large",
-        "extra_large",
-    )
+    # Shared by named architectures and plain-configuration fallbacks.
+    _NET_ARCH_WIDTHS: Final[dict[NetArchSize, int]] = {
+        "small": 128,
+        "medium": 256,
+        "large": 512,
+        "extra_large": 1024,
+    }
+    _NET_ARCH_SIZES: Final[tuple[NetArchSize, ...]] = tuple(_NET_ARCH_WIDTHS)
     _STORAGE_BACKENDS: Final[tuple[StorageBackend, ...]] = ("sqlite", "file")
     _SAMPLERS: Final[_Samplers] = _Samplers()
     _JOURNAL_TAIL_PROBE_BYTES: Final[int] = 64 * 1024
@@ -561,9 +599,7 @@ class ReforceXY(BaseReinforcementLearningModel):
     _QUARANTINE_TIE_BREAK_LIMIT: Final[int] = 99
     _BEST_PARAMS_LOCK_FILENAME: Final[str] = ".hyperopt-best-params.lock"
     # Bump on objective changes that make persisted trials or best params incompatible.
-    _OPTUNA_OBJECTIVE_IDENTITY: Final[str] = (
-        "terminal-liquidation-risk-normalized-trained-policy-v5"
-    )
+    _OPTUNA_OBJECTIVE_IDENTITY: Final[str] = "unshaped-evaluation-temporal-costs-trained-policy-v6"
     _PPO_N_STEPS: Final[tuple[int, ...]] = (512, 1024, 2048, 4096)
     _PPO_N_STEPS_MIN: Final[int] = min(_PPO_N_STEPS)
     _PPO_N_STEPS_MAX: Final[int] = max(_PPO_N_STEPS)
@@ -1143,7 +1179,8 @@ class ReforceXY(BaseReinforcementLearningModel):
         if not model_params.get("policy_kwargs"):
             model_params["policy_kwargs"] = {}
 
-        default_net_arch: list[int] = [128, 128]
+        default_width = ReforceXY._NET_ARCH_WIDTHS[ReforceXY._NET_ARCH_SIZES[0]]
+        default_net_arch: list[int] = [default_width, default_width]
         net_arch: list[int] | dict[str, list[int]] | NetArchSize = model_params.get(
             "policy_kwargs", {}
         ).get("net_arch", default_net_arch)
@@ -2850,9 +2887,9 @@ class ReforceXY(BaseReinforcementLearningModel):
                 if eval_env is not None:
                     eval_env = VecFrameStack(eval_env, n_stack=self.frame_stacking)
 
-            train_env = VecMonitor(train_env)
+            train_env = _RewardVecMonitor(train_env)
             if eval_env is not None:
-                eval_env = VecMonitor(eval_env)
+                eval_env = _RewardVecMonitor(_SelectionRewardVecEnv(eval_env))
 
             return train_env, eval_env
         except BaseException:
@@ -3160,11 +3197,11 @@ class MyRLEnv(Base5ActionRLEnv):
                 ReforceXY.DEFAULT_MAX_TRADE_DURATION_CANDLES,
             )
         )
+        max_idle_duration = model_reward_parameters.get("max_idle_duration_candles")
         self.max_idle_duration_candles: int = int(
-            model_reward_parameters.get(
-                "max_idle_duration_candles",
-                ReforceXY.DEFAULT_IDLE_DURATION_MULTIPLIER * self.max_trade_duration_candles,
-            )
+            ReforceXY.DEFAULT_IDLE_DURATION_MULTIPLIER * self.max_trade_duration_candles
+            if max_idle_duration is None
+            else max_idle_duration
         )
         # === PBRS COMMON PARAMETERS ===
         self._potential_gamma = ReforceXY._resolve_discount_gamma(
@@ -3796,12 +3833,12 @@ class MyRLEnv(Base5ActionRLEnv):
 
         def _power(dr: float, p: Mapping[str, Any]) -> float:
             tau = p.get("exit_power_tau")
-            if isinstance(tau, (int, float)):
+            if isinstance(tau, (int, float, np.integer, np.floating)):
                 tau = float(tau)
                 alpha = -math.log(tau) / ReforceXY._LOG_2 if 0.0 < tau <= 1.0 else 1.0
             else:
                 alpha = 1.0
-            return 1.0 / math.pow(1.0 + dr, alpha)
+            return math.pow(1.0 + dr, -alpha)
 
         def _half_life(dr: float, p: Mapping[str, Any]) -> float:
             hl = float(p.get("exit_half_life", ReforceXY.DEFAULT_EXIT_HALF_LIFE))
@@ -3925,7 +3962,8 @@ class MyRLEnv(Base5ActionRLEnv):
         self, pnl: float, pnl_target: float, model_reward_parameters: Mapping[str, Any]
     ) -> float:
         """
-        Compute PnL target coefficient (typically 0.5-2.0) using tanh on PnL/target ratio.
+        Compute PnL amplification: [1, 1 + win_reward_factor] for gains,
+        and [1, 1 + win_reward_factor * rr] for losses with nonnegative tunables.
         """
         pnl_target_coefficient = 1.0
 
@@ -4019,10 +4057,9 @@ class MyRLEnv(Base5ActionRLEnv):
         # 1. Invalid action
         if not self.action_masking and not self._is_valid(action):
             self.tensorboard_log("invalid", category="actions")
-            base_reward = float(
+            self._last_invalid_penalty = float(
                 model_reward_parameters.get("invalid_action", ReforceXY.DEFAULT_INVALID_ACTION)
             )
-            self._last_invalid_penalty = float(base_reward)
 
         max_trade_duration = max(1, self.max_trade_duration_candles)
         trade_duration = self.get_trade_duration()
@@ -4032,13 +4069,10 @@ class MyRLEnv(Base5ActionRLEnv):
         )
         idle_factor = base_factor * (self.profit_aim / self.rr)
         hold_factor = idle_factor
+        unchanged_position = not self.is_tradesignal(action)
 
         # 2. Idle penalty
-        if (
-            base_reward is None
-            and action == Actions.Neutral.value
-            and self._position == Positions.Neutral
-        ):
+        if base_reward is None and unchanged_position and self._position == Positions.Neutral:
             max_idle_duration = max(1, self.max_idle_duration_candles)
             idle_penalty_ratio = float(
                 model_reward_parameters.get(
@@ -4061,7 +4095,7 @@ class MyRLEnv(Base5ActionRLEnv):
         if (
             base_reward is None
             and self._position in (Positions.Short, Positions.Long)
-            and action == Actions.Neutral.value
+            and unchanged_position
         ):
             hold_penalty_ratio = float(
                 model_reward_parameters.get(
@@ -4106,7 +4140,7 @@ class MyRLEnv(Base5ActionRLEnv):
         if base_reward is None:
             base_reward = 0.0
 
-        return base_reward
+        return base_reward + self._last_invalid_penalty
 
     def _get_observation(self) -> NDArray[np.float32]:
         start_idx = max(0, self._current_tick - self.window_size)
@@ -4212,6 +4246,7 @@ class MyRLEnv(Base5ActionRLEnv):
         execution_tick = self._current_tick
         exit_pnl = None
         reward = self.calculate_reward(action)
+        selection_reward = reward
         trade_type = self.execute_trade(action)
         entry_pnl = self.get_unrealized_profit() if previous_position == Positions.Neutral else 0.0
         if trade_type is not None and self._position in (Positions.Long, Positions.Short):
@@ -4258,6 +4293,7 @@ class MyRLEnv(Base5ActionRLEnv):
             exit_additive_scale=self._exit_additive_ratio * base_factor,
         )
         reward += shaping + entry_additive + exit_additive
+        selection_reward += entry_additive + exit_additive
         terminated = self.is_terminated()
         terminal_liquidation = terminated and self._position in (Positions.Long, Positions.Short)
         if terminal_liquidation:
@@ -4274,6 +4310,7 @@ class MyRLEnv(Base5ActionRLEnv):
                 self.rl_config.get("model_reward_parameters", {}),
             )
             reward += liquidation_reward
+            selection_reward += liquidation_reward
             self._last_exit_reward += liquidation_reward
             if self._exit_additive_enabled and not self.is_pbrs_invariant_mode():
                 liquidation_additive = self._compute_exit_additive(
@@ -4283,6 +4320,7 @@ class MyRLEnv(Base5ActionRLEnv):
                     self._exit_additive_ratio * base_factor,
                 )
                 reward += liquidation_additive
+                selection_reward += liquidation_additive
                 self._last_exit_additive += liquidation_additive
                 self._total_exit_additive += liquidation_additive
             exit_pnl = terminal_pnl
@@ -4333,6 +4371,7 @@ class MyRLEnv(Base5ActionRLEnv):
             "reward_hold": round(self._last_hold_penalty, 5),
             "reward_exit": round(self._last_exit_reward, 5),
             "reward": round(reward, 5),
+            "reward_selection": selection_reward,
             "total_reward": round(self.total_reward, 5),
             "pbrs_invariant": self.is_pbrs_invariant_mode(),
             "idle_duration": idle_duration,
@@ -5389,34 +5428,13 @@ def get_schedule(
 
 
 def get_net_arch(model_type: str, net_arch_type: NetArchSize) -> list[int] | dict[str, list[int]]:
-    """
-    Get network architecture
-    """
+    """Build only the selected architecture, falling back to the smallest size."""
+    width = ReforceXY._NET_ARCH_WIDTHS.get(
+        net_arch_type, ReforceXY._NET_ARCH_WIDTHS[ReforceXY._NET_ARCH_SIZES[0]]
+    )
     if ReforceXY._MODEL_TYPES[0] in model_type:  # "PPO"
-        return {
-            ReforceXY._NET_ARCH_SIZES[0]: {
-                "pi": [128, 128],
-                "vf": [128, 128],
-            },  # "small"
-            ReforceXY._NET_ARCH_SIZES[1]: {
-                "pi": [256, 256],
-                "vf": [256, 256],
-            },  # "medium"
-            ReforceXY._NET_ARCH_SIZES[2]: {
-                "pi": [512, 512],
-                "vf": [512, 512],
-            },  # "large"
-            ReforceXY._NET_ARCH_SIZES[3]: {
-                "pi": [1024, 1024],
-                "vf": [1024, 1024],
-            },  # "extra_large"
-        }.get(net_arch_type, {"pi": [128, 128], "vf": [128, 128]})
-    return {
-        ReforceXY._NET_ARCH_SIZES[0]: [128, 128],  # "small"
-        ReforceXY._NET_ARCH_SIZES[1]: [256, 256],  # "medium"
-        ReforceXY._NET_ARCH_SIZES[2]: [512, 512],  # "large"
-        ReforceXY._NET_ARCH_SIZES[3]: [1024, 1024],  # "extra_large"
-    }.get(net_arch_type, [128, 128])
+        return {"pi": [width, width], "vf": [width, width]}
+    return [width, width]
 
 
 def get_activation_fn(