fix(trading): block sizing on unusable quotes
This commit is contained in:
@@ -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. |
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user