fix(trading): block sizing on unusable quotes

This commit is contained in:
Emerson Gomes
2026-08-18 13:09:21 -05:00
parent a443c71c8d
commit f2d7932ceb
3 changed files with 70 additions and 7 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ mtdata-cli trade_risk_analyze EURUSD --direction long \
| `strict_risk` | `true` | Return `suggested_volume=0.0` if the broker minimum lot would exceed the requested sizing risk. |
| `include_pending` | `true` | Include contingent stop-loss risk from pending orders in portfolio totals. |
| `direction` | — | `long`/`short` (aliases accepted) for the proposed trade. |
| `entry` | — | Proposed entry price. With `symbol`+`stop_loss` but no entry, it is resolved from the latest tick (ask for long, bid for short, mid otherwise). A closed/stale quote is labeled `last_available_tick_*` and `sizing_reference_only`; refresh it before submission. |
| `entry` | — | Proposed entry price. With `symbol`+`stop_loss` but no entry, it is resolved from the latest tick (ask for long, bid for short, mid otherwise). A locked, stale, closed, or otherwise non-live quote is retained only for geometry context: the candidate returns `quote_not_live_ready`, no `suggested_volume`, and must be refreshed before sizing. Provide an explicit entry only for research-only geometry. |
| `stop_loss` | — | Proposed stop (alias `sl`). Required to compute risk-based volume. |
| `take_profit` | — | Optional target (alias `tp`) for reward/risk context. |
+34 -2
View File
@@ -782,8 +782,8 @@ def _resolve_live_trade_risk_entry(
if not live_quote:
quote_context["sizing_reference_only"] = True
quote_context["sizing_warning"] = (
"Position sizing uses the last available non-live quote as a reference; "
"refresh the entry before submitting an order."
"The last available non-live quote is retained as a geometry reference; "
"refresh the quote before requesting a sizing recommendation."
)
bid = _positive_trade_price(tick_value(tick, "bid"))
@@ -1106,6 +1106,38 @@ def _apply_trade_candidate_outcome(result: Dict[str, Any]) -> Dict[str, Any]:
candidate_status = str(evaluation.get("status") or "").strip().lower()
if candidate_status == "valid":
quote_context = result.get("quote_context")
if (
isinstance(quote_context, dict)
and quote_context.get("sizing_reference_only") is True
and quote_context.get("usable_for_live_trading") is not True
):
reason = str(
quote_context.get("warning")
or quote_context.get("sizing_warning")
or "The resolved entry quote is not usable for live trading."
)
result.update(
{
"success": False,
"candidate_valid": False,
"candidate_status": "blocked",
"error_code": "quote_not_live_ready",
"error": reason,
"portfolio_snapshot_status": "available",
"position_sizing_error": _build_position_sizing_error(
code="quote_not_live_ready",
reason=reason,
entry=evaluation.get("entry"),
remediation=(
"Refresh the quote and rerun trade_risk_analyze, or provide "
"an explicit entry for research-only geometry."
),
),
}
)
result.pop("position_sizing", None)
return result
result["candidate_valid"] = True
result["candidate_status"] = "valid"
return result
@@ -632,7 +632,7 @@ def test_trade_risk_analyze_rejects_direction_inference_inside_spread() -> None:
assert "position_sizing" not in out
def test_trade_risk_analyze_sizes_from_stale_tick_as_reference_only() -> None:
def test_trade_risk_analyze_blocks_sizing_from_stale_reference_quote() -> None:
mt5 = MagicMock()
mt5.account_info.return_value = SimpleNamespace(equity=1000.0, currency="USD")
mt5.positions_get.return_value = []
@@ -654,10 +654,41 @@ def test_trade_risk_analyze_sizes_from_stale_tick_as_reference_only() -> None:
assert out["quote_context"]["usable_for_live_trading"] is False
assert out["quote_context"]["freshness_state"] == "stale"
assert out["quote_context"]["sizing_reference_only"] is True
assert "last available non-live quote" in out["quote_context"]["sizing_warning"]
assert out["position_sizing"]["entry"] == 100.2
assert out["position_sizing"]["entry_source"] == "last_available_tick_ask"
assert "geometry reference" in out["quote_context"]["sizing_warning"]
assert out["trade_evaluation"]["entry_source"] == "last_available_tick_ask"
assert out["trade_evaluation"]["status"] == "valid"
assert out["success"] is False
assert out["candidate_valid"] is False
assert out["candidate_status"] == "blocked"
assert out["error_code"] == "quote_not_live_ready"
assert out["position_sizing_error"]["code"] == "quote_not_live_ready"
assert "position_sizing" not in out
def test_trade_risk_analyze_blocks_sizing_from_locked_quote() -> None:
mt5 = MagicMock()
mt5.account_info.return_value = SimpleNamespace(equity=1000.0, currency="USD")
mt5.positions_get.return_value = []
mt5.symbol_info.return_value = _make_symbol_info()
mt5.symbol_info_tick.return_value = SimpleNamespace(
bid=100.0,
ask=100.0,
time=time.time(),
)
with _patched_mt5_module(mt5):
out = trade_risk_analyze(
symbol="BTCUSD",
direction="long",
sizing=_fixed_sizing(1.0),
stop_loss=95.0,
)
assert out["quote_context"]["spread_quality"] == "locked"
assert out["quote_context"]["usable_for_live_trading"] is False
assert out["candidate_valid"] is False
assert out["error_code"] == "quote_not_live_ready"
assert "position_sizing" not in out
def test_trade_risk_analyze_keeps_exposure_analysis_with_partial_sizing_params() -> None: