Skip to main content

zero_engine_client/
models.rs

1//! Typed response shapes mirrored from the engine's FastAPI surface.
2//!
3//! Each type is **narrow on purpose.** We deserialize only the fields
4//! the CLI actually renders; extra fields are tolerated via
5//! `#[serde(flatten)]` `extra`, so the engine can evolve without
6//! breaking us, and missing fields surface as `Option::None`.
7
8use std::collections::BTreeMap;
9
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13// ─── /  ────────────────────────────────────────────────────────────
14
15/// Response shape of `GET /` — unauthenticated version probe.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Root {
18    pub name: String,
19    pub version: String,
20    pub status: String,
21    pub ts: Option<String>,
22}
23
24// ─── /health  ──────────────────────────────────────────────────────
25
26/// Response shape of `GET /health` — unauthenticated.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct Health {
29    /// `"ok"` or `"degraded"`.
30    pub status: String,
31    #[serde(default)]
32    pub components: BTreeMap<String, ComponentHealth>,
33    #[serde(default)]
34    pub dependencies: BTreeMap<String, String>,
35    #[serde(default)]
36    pub circuit_breakers: BTreeMap<String, String>,
37    #[serde(default)]
38    pub risk: RiskSummary,
39    #[serde(default)]
40    pub recovery: Option<RecoveryStatus>,
41    #[serde(default)]
42    pub ws_connections: u64,
43}
44
45/// Runtime recovery state emitted by the paper engine after journal replay.
46#[derive(Debug, Clone, Default, Serialize, Deserialize)]
47#[serde(default)]
48pub struct RecoveryStatus {
49    pub status: Option<String>,
50    pub source: Option<String>,
51    pub durable: bool,
52    pub journal_path: Option<String>,
53    pub decisions_recovered: Option<u32>,
54    pub fills_recovered: Option<u32>,
55    pub rejections_recovered: Option<u32>,
56    pub positions_recovered: Option<u32>,
57    pub last_decision_at: Option<String>,
58    pub current_decisions: Option<u32>,
59    pub current_fills: Option<u32>,
60    pub current_rejections: Option<u32>,
61    pub current_positions: Option<u32>,
62    #[serde(flatten)]
63    pub extra: BTreeMap<String, Value>,
64}
65
66// ─── /hl/status  ───────────────────────────────────────────────────
67
68#[derive(Debug, Clone, Default, Serialize, Deserialize)]
69#[serde(default)]
70pub struct HyperliquidStatus {
71    pub enabled: bool,
72    pub exchange: Option<String>,
73    pub endpoint: Option<String>,
74    pub coins: Option<u32>,
75    pub mids: BTreeMap<String, f64>,
76    pub secrets_required: Option<bool>,
77    pub reason: Option<String>,
78    #[serde(flatten)]
79    pub extra: BTreeMap<String, Value>,
80}
81
82// ─── /hl/account and /hl/reconcile ────────────────────────────────
83
84#[derive(Debug, Clone, Default, Serialize, Deserialize)]
85#[serde(default)]
86pub struct HyperliquidAccount {
87    pub schema_version: String,
88    pub exchange: String,
89    pub user: String,
90    pub as_of: Option<String>,
91    pub account_value: Option<f64>,
92    pub margin_used: Option<f64>,
93    pub withdrawable: Option<f64>,
94    pub positions: Vec<HyperliquidAccountPosition>,
95    pub open_orders: Vec<Value>,
96    #[serde(flatten)]
97    pub extra: BTreeMap<String, Value>,
98}
99
100#[derive(Debug, Clone, Default, Serialize, Deserialize)]
101#[serde(default)]
102pub struct HyperliquidAccountPosition {
103    pub symbol: String,
104    pub side: String,
105    pub quantity: f64,
106    pub entry_price: f64,
107    pub position_value: f64,
108    pub unrealized_pnl: f64,
109    pub margin_used: f64,
110    #[serde(flatten)]
111    pub extra: BTreeMap<String, Value>,
112}
113
114#[derive(Debug, Clone, Default, Serialize, Deserialize)]
115#[serde(default)]
116pub struct HyperliquidReconciliation {
117    pub schema_version: String,
118    pub exchange: String,
119    pub status: String,
120    pub risk_increasing_allowed: bool,
121    pub reason: String,
122    pub as_of: Option<String>,
123    pub drifts: Vec<HyperliquidReconciliationDrift>,
124    #[serde(flatten)]
125    pub extra: BTreeMap<String, Value>,
126}
127
128#[derive(Debug, Clone, Default, Serialize, Deserialize)]
129#[serde(default)]
130pub struct HyperliquidReconciliationDrift {
131    pub code: String,
132    pub severity: String,
133    pub symbol: Option<String>,
134    pub reason: String,
135    pub local_quantity: Option<f64>,
136    pub exchange_quantity: Option<f64>,
137    #[serde(flatten)]
138    pub extra: BTreeMap<String, Value>,
139}
140
141// ─── /market/quote  ────────────────────────────────────────────────
142
143#[derive(Debug, Clone, Default, Serialize, Deserialize)]
144#[serde(default)]
145pub struct MarketQuote {
146    pub symbol: String,
147    pub price: f64,
148    pub source: String,
149    pub as_of: Option<String>,
150    pub mode: Option<String>,
151    pub live: bool,
152    #[serde(flatten)]
153    pub extra: BTreeMap<String, Value>,
154}
155
156// ─── /live/preflight ───────────────────────────────────────────────
157
158#[derive(Debug, Clone, Default, Serialize, Deserialize)]
159#[serde(default)]
160pub struct LivePreflight {
161    pub schema_version: String,
162    pub exchange: String,
163    pub mode: String,
164    pub ready: bool,
165    pub live_mode: String,
166    pub controls_ready: bool,
167    pub checks: Vec<LivePreflightCheck>,
168    #[serde(flatten)]
169    pub extra: BTreeMap<String, Value>,
170}
171
172#[derive(Debug, Clone, Default, Serialize, Deserialize)]
173#[serde(default)]
174pub struct LivePreflightCheck {
175    pub name: String,
176    pub status: String,
177    pub note: String,
178    #[serde(flatten)]
179    pub extra: BTreeMap<String, Value>,
180}
181
182// ─── /live/certification ──────────────────────────────────────────
183
184#[derive(Debug, Clone, Default, Serialize, Deserialize)]
185#[serde(default)]
186pub struct LiveCertification {
187    pub schema_version: String,
188    pub mode: String,
189    pub passed: bool,
190    pub live_start_certified: bool,
191    pub summary: BTreeMap<String, Value>,
192    pub drills: Vec<LiveCertificationDrill>,
193    pub evidence_requirements: Vec<String>,
194    #[serde(flatten)]
195    pub extra: BTreeMap<String, Value>,
196}
197
198#[derive(Debug, Clone, Default, Serialize, Deserialize)]
199#[serde(default)]
200pub struct LiveCertificationDrill {
201    pub name: String,
202    pub status: String,
203    pub note: String,
204    pub evidence: BTreeMap<String, Value>,
205    #[serde(flatten)]
206    pub extra: BTreeMap<String, Value>,
207}
208
209// ─── /live/evidence ───────────────────────────────────────────────
210
211#[derive(Debug, Clone, Default, Serialize, Deserialize)]
212#[serde(default)]
213pub struct LiveEvidence {
214    pub schema_version: String,
215    pub generated_at: Option<String>,
216    pub mode: String,
217    pub live_mode: String,
218    pub ready: bool,
219    pub risk_increasing_allowed: bool,
220    pub operator_context: OperatorContext,
221    pub summary: BTreeMap<String, Value>,
222    pub artifacts: Vec<LiveEvidenceArtifact>,
223    pub canary_rule: BTreeMap<String, Value>,
224    pub privacy: BTreeMap<String, Value>,
225    pub evidence_hash: String,
226    pub signature: BTreeMap<String, Value>,
227    #[serde(flatten)]
228    pub extra: BTreeMap<String, Value>,
229}
230
231#[derive(Debug, Clone, Default, Serialize, Deserialize)]
232#[serde(default)]
233pub struct LiveEvidenceArtifact {
234    pub name: String,
235    pub schema_version: String,
236    pub status: String,
237    pub hash: String,
238    pub included: String,
239    #[serde(flatten)]
240    pub extra: BTreeMap<String, Value>,
241}
242
243// ─── /live/canary-policy ──────────────────────────────────────────
244
245#[derive(Debug, Clone, Default, Serialize, Deserialize)]
246#[serde(default)]
247pub struct LiveCanaryPolicy {
248    pub schema_version: String,
249    pub policy_version: String,
250    pub generated_at: Option<String>,
251    pub mode: String,
252    pub summary: LiveCanaryPolicySummary,
253    pub policy: BTreeMap<String, Value>,
254    pub phases: Vec<LiveCanaryPolicyPhase>,
255    pub recommendation: LiveCanaryRecommendation,
256    pub operator_context: OperatorContext,
257    pub request: Option<BTreeMap<String, Value>>,
258    pub privacy: BTreeMap<String, Value>,
259    #[serde(flatten)]
260    pub extra: BTreeMap<String, Value>,
261}
262
263#[derive(Debug, Clone, Default, Serialize, Deserialize)]
264#[allow(clippy::struct_excessive_bools)] // mirrors the public policy summary packet.
265#[serde(default)]
266pub struct LiveCanaryPolicySummary {
267    pub ready_for_canary: bool,
268    pub policy_armed: bool,
269    pub live_order_attempted: bool,
270    pub live_order_accepted: bool,
271    pub receipts_accepted: u64,
272    pub exchange_evidence_attached: bool,
273    pub publishable_canary_evidence: bool,
274    pub refusal_evidence_qualified: bool,
275    pub qualified: bool,
276    pub next_step: String,
277    #[serde(flatten)]
278    pub extra: BTreeMap<String, Value>,
279}
280
281#[derive(Debug, Clone, Default, Serialize, Deserialize)]
282#[serde(default)]
283pub struct LiveCanaryPolicyPhase {
284    pub name: String,
285    pub status: String,
286    pub detail: String,
287    #[serde(flatten)]
288    pub extra: BTreeMap<String, Value>,
289}
290
291#[derive(Debug, Clone, Default, Serialize, Deserialize)]
292#[serde(default)]
293pub struct LiveCanaryRecommendation {
294    pub action: String,
295    pub risk_direction: String,
296    pub reason: String,
297    #[serde(flatten)]
298    pub extra: BTreeMap<String, Value>,
299}
300
301// ─── /runtime/parity ──────────────────────────────────────────────
302
303#[derive(Debug, Clone, Default, Serialize, Deserialize)]
304#[allow(clippy::struct_excessive_bools)] // mirrors the public claim-boundary packet.
305#[serde(default)]
306pub struct RuntimeParity {
307    pub schema_version: String,
308    pub available: bool,
309    pub ok: bool,
310    pub mode: String,
311    pub source: Option<String>,
312    pub generated_at: Option<String>,
313    pub cycles_requested: u64,
314    pub cycles_run: u64,
315    pub paper_only: bool,
316    pub places_live_orders: bool,
317    pub paper: RuntimeParityPaper,
318    pub live_shadow: RuntimeParityLiveShadow,
319    pub feedback: RuntimeParityFeedback,
320    pub certification: LiveCertification,
321    pub checks: BTreeMap<String, Value>,
322    pub claim_boundary: BTreeMap<String, Value>,
323    #[serde(flatten)]
324    pub extra: BTreeMap<String, Value>,
325}
326
327#[derive(Debug, Clone, Default, Serialize, Deserialize)]
328#[serde(default)]
329pub struct RuntimeParityPaper {
330    pub decisions: u64,
331    pub fills: u64,
332    pub rejections: u64,
333    pub open_positions: u64,
334    #[serde(flatten)]
335    pub extra: BTreeMap<String, Value>,
336}
337
338#[derive(Debug, Clone, Default, Serialize, Deserialize)]
339#[serde(default)]
340pub struct RuntimeParityLiveShadow {
341    pub mode: String,
342    pub accepted: u64,
343    pub refused: u64,
344    pub adapter_orders_placed: u64,
345    pub records: Vec<Value>,
346    #[serde(flatten)]
347    pub extra: BTreeMap<String, Value>,
348}
349
350#[derive(Debug, Clone, Default, Serialize, Deserialize)]
351#[serde(default)]
352pub struct RuntimeParityFeedback {
353    pub schema_version: String,
354    pub cycles: u64,
355    pub sample_size: u64,
356    pub fills: u64,
357    pub rejections: u64,
358    pub rejection_rate: f64,
359    pub by_rejection_reason: BTreeMap<String, u64>,
360    pub by_rejection_symbol: BTreeMap<String, u64>,
361    pub items: Vec<Value>,
362    #[serde(flatten)]
363    pub extra: BTreeMap<String, Value>,
364}
365
366// ─── /live/receipts ───────────────────────────────────────────────
367
368#[derive(Debug, Clone, Default, Serialize, Deserialize)]
369#[serde(default)]
370pub struct LiveExecutionReceipts {
371    pub schema_version: String,
372    pub generated_at: Option<String>,
373    pub mode: String,
374    pub operator_context: OperatorContext,
375    pub summary: BTreeMap<String, Value>,
376    pub receipts: Vec<LiveExecutionReceipt>,
377    pub privacy: BTreeMap<String, Value>,
378    pub receipts_hash: String,
379    #[serde(flatten)]
380    pub extra: BTreeMap<String, Value>,
381}
382
383#[derive(Debug, Clone, Default, Serialize, Deserialize)]
384#[serde(default)]
385pub struct LiveExecutionReceipt {
386    pub schema_version: String,
387    pub accepted: bool,
388    pub status: String,
389    pub reason: String,
390    pub as_of: Option<f64>,
391    pub request: BTreeMap<String, Value>,
392    pub request_hash: String,
393    pub operator_context_hash: Option<String>,
394    pub trace_hash: Option<String>,
395    pub idempotency_hash: Option<String>,
396    pub venue_ack_hash: Option<String>,
397    pub receipt_hash: String,
398    #[serde(flatten)]
399    pub extra: BTreeMap<String, Value>,
400}
401
402// ─── /live/cockpit ────────────────────────────────────────────────
403
404#[derive(Debug, Clone, Default, Serialize, Deserialize)]
405#[serde(default)]
406pub struct LiveCockpit {
407    pub schema_version: String,
408    pub generated_at: Option<String>,
409    pub mode: String,
410    pub live_mode: String,
411    pub ready: bool,
412    pub controls_ready: bool,
413    pub risk_increasing_allowed: bool,
414    pub next_action: String,
415    pub operator_context: OperatorContext,
416    pub preflight: LiveCockpitPreflight,
417    pub immune: LiveCockpitImmune,
418    pub reconciliation: LiveCockpitReconciliation,
419    pub certification: LiveCockpitCertification,
420    pub heartbeat: LiveCockpitHeartbeat,
421    pub live_records: LiveCockpitRecords,
422    pub operator_actions: BTreeMap<String, Value>,
423    #[serde(flatten)]
424    pub extra: BTreeMap<String, Value>,
425}
426
427#[derive(Debug, Clone, Default, Serialize, Deserialize)]
428#[serde(default)]
429pub struct OperatorContext {
430    pub schema_version: Option<String>,
431    pub operator_id: String,
432    pub handle: String,
433    pub role: String,
434    pub scope: String,
435    pub source: Option<String>,
436    #[serde(flatten)]
437    pub extra: BTreeMap<String, Value>,
438}
439
440#[derive(Debug, Clone, Default, Serialize, Deserialize)]
441#[serde(default)]
442pub struct LiveCockpitPreflight {
443    pub schema_version: String,
444    pub ready: bool,
445    pub live_mode: String,
446    pub controls_ready: bool,
447    pub summary: BTreeMap<String, Value>,
448    pub failed_checks: Vec<LivePreflightCheck>,
449    #[serde(flatten)]
450    pub extra: BTreeMap<String, Value>,
451}
452
453#[derive(Debug, Clone, Default, Serialize, Deserialize)]
454#[serde(default)]
455pub struct LiveCockpitImmune {
456    pub schema_version: String,
457    pub risk_increasing_allowed: bool,
458    pub summary: BTreeMap<String, Value>,
459    pub open_breakers: Vec<ImmuneBreaker>,
460    #[serde(flatten)]
461    pub extra: BTreeMap<String, Value>,
462}
463
464#[derive(Debug, Clone, Default, Serialize, Deserialize)]
465#[serde(default)]
466pub struct LiveCockpitReconciliation {
467    pub schema_version: String,
468    pub status: String,
469    pub risk_increasing_allowed: bool,
470    pub reason: String,
471    pub drifts: u64,
472    #[serde(flatten)]
473    pub extra: BTreeMap<String, Value>,
474}
475
476#[derive(Debug, Clone, Default, Serialize, Deserialize)]
477#[serde(default)]
478pub struct LiveCockpitCertification {
479    pub schema_version: String,
480    pub mode: String,
481    pub passed: bool,
482    pub live_start_certified: bool,
483    pub summary: BTreeMap<String, Value>,
484    pub failed_drills: Vec<LiveCertificationDrill>,
485    #[serde(flatten)]
486    pub extra: BTreeMap<String, Value>,
487}
488
489#[derive(Debug, Clone, Default, Serialize, Deserialize)]
490#[serde(default)]
491pub struct LiveCockpitHeartbeat {
492    pub configured: bool,
493    pub expired: bool,
494    pub last_heartbeat_at: Option<f64>,
495    pub timeout_s: Option<f64>,
496    #[serde(flatten)]
497    pub extra: BTreeMap<String, Value>,
498}
499
500#[derive(Debug, Clone, Default, Serialize, Deserialize)]
501#[serde(default)]
502pub struct LiveCockpitRecords {
503    pub total: u64,
504    pub accepted: u64,
505    pub refused: u64,
506    pub exchange_error: u64,
507    pub recent: Vec<Value>,
508    #[serde(flatten)]
509    pub extra: BTreeMap<String, Value>,
510}
511
512// ─── /immune ──────────────────────────────────────────────────────
513
514#[derive(Debug, Clone, Default, Serialize, Deserialize)]
515#[serde(default)]
516pub struct ImmuneReport {
517    pub schema_version: String,
518    pub generated_at: Option<String>,
519    pub mode: String,
520    pub risk_increasing_allowed: bool,
521    pub summary: BTreeMap<String, Value>,
522    pub breakers: Vec<ImmuneBreaker>,
523    #[serde(flatten)]
524    pub extra: BTreeMap<String, Value>,
525}
526
527#[derive(Debug, Clone, Default, Serialize, Deserialize)]
528#[serde(default)]
529pub struct ImmuneBreaker {
530    pub name: String,
531    pub status: String,
532    pub blocks_risk: bool,
533    pub severity: String,
534    pub reason: String,
535    pub evidence: BTreeMap<String, Value>,
536    #[serde(flatten)]
537    pub extra: BTreeMap<String, Value>,
538}
539
540// ─── /live/* control POSTs ─────────────────────────────────────────
541
542/// Response body for live risk-reduction controls.
543///
544/// The live control endpoints intentionally use a broad response envelope:
545/// `/live/kill`, `/live/pause`, `/live/resume`, `/live/heartbeat`, and
546/// `/live/flatten` share `ok` / `reason`, while endpoint-specific details
547/// such as `exchange_cancel`, `exchange_dead_man`, and flatten `orders` stay
548/// in `extra`. This keeps the CLI honest without forcing it to mirror every
549/// exchange adapter field.
550#[derive(Debug, Clone, Default, Serialize, Deserialize)]
551#[serde(default)]
552pub struct LiveControlResponse {
553    pub ok: bool,
554    pub state: Option<String>,
555    pub reason: Option<String>,
556    pub orders: Vec<Value>,
557    pub operator_context: Option<OperatorContext>,
558    pub action: Option<String>,
559    pub risk_direction: Option<String>,
560    #[serde(flatten)]
561    pub extra: BTreeMap<String, Value>,
562}
563
564#[derive(Debug, Clone, Serialize, Deserialize)]
565pub struct ComponentHealth {
566    pub status: String,
567    pub last_seen: Option<String>,
568    #[serde(default)]
569    pub age_s: Option<f64>,
570}
571
572impl ComponentHealth {
573    #[must_use]
574    pub fn is_healthy(&self) -> bool {
575        self.status == "healthy"
576    }
577
578    #[must_use]
579    pub fn is_dead(&self) -> bool {
580        self.status == "dead"
581    }
582}
583
584#[derive(Debug, Clone, Default, Serialize, Deserialize)]
585pub struct RiskSummary {
586    pub equity: Option<f64>,
587    pub drawdown_pct: Option<f64>,
588    #[serde(default)]
589    pub kill_all: bool,
590}
591
592impl Health {
593    #[must_use]
594    pub fn is_ok(&self) -> bool {
595        self.status == "ok"
596    }
597
598    #[must_use]
599    pub fn component_counts(&self) -> ComponentCounts {
600        let mut c = ComponentCounts::default();
601        for comp in self.components.values() {
602            match comp.status.as_str() {
603                "healthy" => c.healthy += 1,
604                "stale" => c.stale += 1,
605                "dead" => c.dead += 1,
606                _ => c.unknown += 1,
607            }
608        }
609        c
610    }
611}
612
613#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
614pub struct ComponentCounts {
615    pub healthy: u32,
616    pub stale: u32,
617    pub dead: u32,
618    pub unknown: u32,
619}
620
621// ─── /positions  ───────────────────────────────────────────────────
622
623/// A single open position.
624#[derive(Debug, Clone, Default, Serialize, Deserialize)]
625#[serde(default)]
626pub struct Position {
627    /// Engine emits `coin` (`TRX`, `BTC`, …); the spec + REST
628    /// variants call this `symbol`. Alias covers the WS shape
629    /// and the raw `positions.json` bus file.
630    #[serde(alias = "coin")]
631    pub symbol: String,
632    /// `"long"` | `"short"`. Engine `_bus_poller` emits
633    /// `direction: "LONG" | "SHORT"`; alias covers it.
634    #[serde(alias = "direction")]
635    pub side: String,
636    /// Engine's raw `positions.json` uses `size_coins` for the
637    /// coin-quantity field; REST surfaces `size` directly.
638    #[serde(alias = "size_coins")]
639    pub size: f64,
640    #[serde(alias = "entry_price")]
641    pub entry: f64,
642    pub mark: Option<f64>,
643    pub unrealized_pnl: Option<f64>,
644    pub unrealized_r: Option<f64>,
645    pub stop: Option<f64>,
646    pub target: Option<f64>,
647    pub lens_id: Option<String>,
648    pub age_s: Option<f64>,
649    #[serde(flatten)]
650    pub extra: BTreeMap<String, Value>,
651}
652
653/// `GET /positions` envelope. Engine returns a list, optionally
654/// wrapped in `{positions: [...]}` depending on handler version; we
655/// accept both.
656#[derive(Debug, Clone, Default, Serialize, Deserialize)]
657pub struct Positions {
658    #[serde(alias = "positions", default)]
659    pub items: Vec<Position>,
660    #[serde(default)]
661    pub account_value: Option<f64>,
662    #[serde(default)]
663    pub total_unrealized_pnl: Option<f64>,
664}
665
666// ─── /risk  ────────────────────────────────────────────────────────
667
668/// `GET /risk` summary. Field names mirror the engine's real wire
669/// shape (see `engine/zero/api.py::get_risk` and the `risk.json`
670/// fixture captured under `tests/fixtures/`).
671///
672/// Historical note: older mock fixtures used `daily_loss_pct`,
673/// `exposure_pct`, `kill_all`, `circuit_breaker_active`,
674/// `concurrent_positions`, and `max_concurrent`. The live engine
675/// emits none of those; they were removed to stop the CLI from
676/// silently rendering `—` for fields that never existed. Current
677/// render code derives percentages from dollar amounts where
678/// necessary (see `Risk::daily_loss_pct`, `Risk::drawdown_pct`).
679// Four `bool` fields trip `clippy::struct_excessive_bools`. The
680// suggested refactor — collapse into a state-machine enum — would
681// require the CLI to interpret and re-emit the engine's halt
682// classification rather than echo it. That is exactly the kind of
683// re-derivation the honesty-bar rejects: the engine's wire shape
684// has four distinct halt booleans (`halted`, `global_halt`,
685// `stop_failure_halt`, `capital_floor_hit`) because they are
686// produced by four independent code paths on the engine side.
687// Folding them into a single enum here would force a lossy
688// projection at the deserialize boundary and then the CLI would
689// have to re-split them everywhere they are rendered (status bar
690// halt reason, heat read-out, `/risk` line). The struct mirrors
691// the wire; `Risk::is_halted()` / `halt_reason()` provide the
692// derived views callers actually want.
693#[allow(clippy::struct_excessive_bools)]
694#[derive(Debug, Clone, Default, Serialize, Deserialize)]
695#[serde(default)]
696pub struct Risk {
697    pub account_value: Option<f64>,
698    pub drawdown_pct: Option<f64>,
699    pub daily_pnl_usd: Option<f64>,
700    pub daily_loss_usd: Option<f64>,
701    pub peak_equity: Option<f64>,
702    pub peak_equity_30d: Option<f64>,
703    pub open_count: Option<u32>,
704    pub halted: bool,
705    pub global_halt: bool,
706    pub stop_failure_halt: bool,
707    pub capital_floor_hit: bool,
708    pub halt_reason: Option<String>,
709    pub halt_until: Option<String>,
710    pub updated_at: Option<String>,
711    pub daily_loss_since: Option<String>,
712    pub last_drawdown_alert_pct: Option<f64>,
713    pub per_runner: BTreeMap<String, Value>,
714    #[serde(flatten)]
715    pub extra: BTreeMap<String, Value>,
716}
717
718impl Risk {
719    /// True when the engine has stopped accepting new risk, for any
720    /// reason the wire format exposes. The engine currently sets a
721    /// single `halted` bool plus a pair of more-specific flags; any
722    /// of them should land the operator on an alert line.
723    #[must_use]
724    pub fn is_halted(&self) -> bool {
725        self.halted || self.global_halt || self.stop_failure_halt
726    }
727
728    /// Daily loss as a percent of peak equity, derived from the
729    /// two dollar fields the engine publishes. Returns `None` if
730    /// peak equity is missing or zero so callers can render `—`
731    /// rather than a bogus zero.
732    #[must_use]
733    pub fn daily_loss_pct(&self) -> Option<f64> {
734        let loss = self.daily_loss_usd?;
735        let peak = self.peak_equity.or(self.peak_equity_30d)?;
736        if peak <= 0.0 {
737            return None;
738        }
739        Some((loss / peak) * 100.0)
740    }
741}
742
743// ─── /regime  ──────────────────────────────────────────────────────
744
745/// `GET /regime` — either per-coin or whole-market depending on
746/// query. The engine returns a loose shape with `regime`, `confidence`,
747/// and auxiliary fields; we capture the core and flatten the rest.
748#[derive(Debug, Clone, Default, Serialize, Deserialize)]
749#[serde(default)]
750pub struct Regime {
751    /// `"TREND_LONG"` | `"TREND_SHORT"` | `"CHOP"` | `"VOL_EXPAND"` | ...
752    pub regime: Option<String>,
753    pub confidence: Option<f64>,
754    pub coin: Option<String>,
755    #[serde(flatten)]
756    pub extra: BTreeMap<String, Value>,
757}
758
759// ─── /brief  ───────────────────────────────────────────────────────
760
761/// `GET /brief` — the engine's situational readout. Shape matches the
762/// real wire payload (see `tests/fixtures/brief.json`): a timestamp, a
763/// fear-greed reading, open positions + their snapshots, recent
764/// signals, coins approaching a gate, and the last macro cycle
765/// summary. The CLI renders a concise header line and optionally
766/// expands into the lists.
767///
768/// Historical note: earlier struct advertised `headline`/`summary`
769/// fields. The engine never sent them; the CLI always rendered
770/// "(engine has no briefing right now)". Those two fields are gone.
771#[derive(Debug, Clone, Default, Serialize, Deserialize)]
772#[serde(default)]
773pub struct Brief {
774    pub timestamp: Option<String>,
775    pub fear_greed: Option<i64>,
776    pub open_positions: Option<u32>,
777    pub positions: Vec<Position>,
778    pub recent_signals: Vec<Value>,
779    pub approaching: Vec<Value>,
780    pub last_cycle: Value,
781    #[serde(flatten)]
782    pub extra: BTreeMap<String, Value>,
783}
784
785impl Brief {
786    /// Best-effort "is there anything to tell the operator?" signal.
787    /// Returns true when any of the narrative lists have at least one
788    /// item or a fear-greed reading is available. A fully-empty brief
789    /// deserves the honest "nothing right now" line; anything else
790    /// should render the data the engine actually sent.
791    #[must_use]
792    pub fn has_content(&self) -> bool {
793        self.fear_greed.is_some()
794            || self.open_positions.is_some_and(|n| n > 0)
795            || !self.positions.is_empty()
796            || !self.recent_signals.is_empty()
797            || !self.approaching.is_empty()
798            || !self.last_cycle.is_null()
799                && self.last_cycle.as_object().is_some_and(|o| !o.is_empty())
800    }
801}
802
803// ─── /evaluate/{coin}  ─────────────────────────────────────────────
804
805/// One layer in an `/evaluate/{coin}` response. The engine emits a
806/// numbered list; each entry includes whether the layer passed, an
807/// arbitrary `value` payload (scalar or nested object), and a
808/// human-readable `detail` string that already summarizes the
809/// layer's decision.
810#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
811#[serde(default)]
812pub struct EvaluationLayer {
813    pub layer: String,
814    pub passed: bool,
815    pub value: Value,
816    pub detail: String,
817}
818
819/// `GET /evaluate/{coin}` — the gate-level verdict for a single
820/// coin. The real engine returns a flat object with per-layer
821/// decisions (see `tests/fixtures/evaluate_sol.json`); this struct
822/// mirrors that exactly.
823///
824/// Legacy fields (`verdict`, `rationale`, `gates`, `as_of`) were
825/// removed — they were artifacts of a mock that never matched the
826/// engine. Call sites should read the real fields below and derive
827/// a verdict from `direction` + `conviction` when they need a
828/// single-word summary.
829#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
830#[serde(default)]
831pub struct Evaluation {
832    pub coin: Option<String>,
833    pub price: Option<f64>,
834    pub consensus: Option<i64>,
835    pub conviction: Option<f64>,
836    /// `"LONG"` | `"SHORT"` | `"NONE"`.
837    pub direction: Option<String>,
838    pub regime: Option<String>,
839    pub layers: Vec<EvaluationLayer>,
840    pub data_fresh: Option<bool>,
841    pub timestamp: Option<String>,
842    #[serde(flatten)]
843    pub extra: BTreeMap<String, Value>,
844}
845
846impl Evaluation {
847    /// Human-readable verdict derived from the real fields. Matches
848    /// the legacy `verdict` string values used by existing render
849    /// code: `"PASS"` when direction is actionable, `"HOLD"` when
850    /// every layer passed but direction is `"NONE"`, and
851    /// `"REJECT"` when any layer failed.
852    #[must_use]
853    pub fn verdict(&self) -> &'static str {
854        if self.layers.iter().any(|l| !l.passed) {
855            "REJECT"
856        } else if self
857            .direction
858            .as_deref()
859            .is_some_and(|d| d.eq_ignore_ascii_case("LONG") || d.eq_ignore_ascii_case("SHORT"))
860        {
861            "PASS"
862        } else {
863            "HOLD"
864        }
865    }
866}
867
868// ─── /pulse  ───────────────────────────────────────────────────────
869
870/// One entry in the engine's pulse stream. Events are semi-free-form;
871/// the client only consumes `kind`, `coin`, `message`, `ts`.
872#[derive(Debug, Clone, Default, Serialize, Deserialize)]
873#[serde(default)]
874pub struct PulseEvent {
875    pub kind: Option<String>,
876    pub coin: Option<String>,
877    pub message: Option<String>,
878    pub ts: Option<String>,
879    pub severity: Option<String>,
880    #[serde(flatten)]
881    pub extra: BTreeMap<String, Value>,
882}
883
884/// `GET /pulse` envelope.
885#[derive(Debug, Clone, Default, Serialize, Deserialize)]
886pub struct Pulse {
887    #[serde(alias = "pulse", alias = "events", default)]
888    pub items: Vec<PulseEvent>,
889}
890
891// ─── /approaching  ─────────────────────────────────────────────────
892
893/// A coin approaching an entry gate.
894#[derive(Debug, Clone, Default, Serialize, Deserialize)]
895#[serde(default)]
896pub struct Approaching {
897    pub coin: String,
898    pub direction: Option<String>,
899    pub distance_to_gate: Option<f64>,
900    pub gate: Option<String>,
901    pub ts: Option<String>,
902    #[serde(flatten)]
903    pub extra: BTreeMap<String, Value>,
904}
905
906/// `GET /approaching` envelope.
907#[derive(Debug, Clone, Default, Serialize, Deserialize)]
908pub struct ApproachingFeed {
909    #[serde(alias = "approaching", alias = "items", default)]
910    pub items: Vec<Approaching>,
911}
912
913// ─── /rejections  ──────────────────────────────────────────────────
914
915/// A single rejection record.
916#[derive(Debug, Clone, Default, Serialize, Deserialize)]
917#[serde(default)]
918pub struct Rejection {
919    pub coin: Option<String>,
920    pub direction: Option<String>,
921    pub stage: Option<String>,
922    pub reason: Option<String>,
923    pub ts: Option<String>,
924    #[serde(flatten)]
925    pub extra: BTreeMap<String, Value>,
926}
927
928/// `GET /rejections` envelope.
929#[derive(Debug, Clone, Default, Serialize, Deserialize)]
930pub struct RejectionsFeed {
931    #[serde(alias = "rejections", alias = "items", default)]
932    pub items: Vec<Rejection>,
933}
934
935// ─── /v2/status  ───────────────────────────────────────────────────
936
937/// Engine confidence sub-object on `/v2/status`.
938#[derive(Debug, Clone, Default, Serialize, Deserialize)]
939#[serde(default)]
940pub struct V2Confidence {
941    /// 0..=100 integer score.
942    pub score: Option<f64>,
943    /// `"low"` | `"medium"` | `"high"` | ...
944    pub level: Option<String>,
945}
946
947/// Market sub-object on `/v2/status`.
948#[derive(Debug, Clone, Default, Serialize, Deserialize)]
949#[serde(default)]
950pub struct V2Market {
951    pub regime: Option<String>,
952    pub health: Option<f64>,
953    pub signal: Option<String>,
954    pub prediction: Option<String>,
955    pub fear_greed: Option<i64>,
956    pub coins_tradeable: Option<u32>,
957    #[serde(flatten)]
958    pub extra: BTreeMap<String, Value>,
959}
960
961/// Positions sub-object on `/v2/status`.
962#[derive(Debug, Clone, Default, Serialize, Deserialize)]
963#[serde(default)]
964pub struct V2Positions {
965    pub open: Option<u32>,
966    pub unrealized_pnl: Option<f64>,
967    pub equity: Option<f64>,
968    #[serde(flatten)]
969    pub extra: BTreeMap<String, Value>,
970}
971
972/// Today-summary sub-object on `/v2/status`.
973#[derive(Debug, Clone, Default, Serialize, Deserialize)]
974#[serde(default)]
975pub struct V2Today {
976    pub trades: Option<u32>,
977    pub wins: Option<u32>,
978    pub pnl: Option<f64>,
979    pub streak: Option<i32>,
980    pub sizing_mult: Option<f64>,
981    #[serde(flatten)]
982    pub extra: BTreeMap<String, Value>,
983}
984
985/// `GET /v2/status` — the condensed engine summary used by the
986/// status bar. Shape mirrors the live wire format (see
987/// `tests/fixtures/v2_status.json`): a nested object with
988/// confidence/market/positions/today sub-objects, plus two list
989/// fields the engine uses for signals and blind spots.
990///
991/// Historical note: the previous CLI model declared flat fields
992/// (`engine_confidence`, `regime`, `equity`, `drawdown_pct`) at the
993/// top level. The engine never emitted those names; every
994/// `Option<…>` deserialized to `None`, so the status bar and
995/// `/status` command always rendered em-dashes. Accessors below
996/// (`regime()`, `engine_confidence()`, `equity()`, etc.) preserve
997/// the original call-site ergonomics while reading the real shape.
998#[derive(Debug, Clone, Default, Serialize, Deserialize)]
999#[serde(default)]
1000pub struct V2Status {
1001    pub confidence: V2Confidence,
1002    pub market: V2Market,
1003    pub positions: V2Positions,
1004    pub today: V2Today,
1005    pub approaching: Vec<Value>,
1006    pub blind_spots: Vec<Value>,
1007    pub alert: Option<Value>,
1008    pub recovery: Option<RecoveryStatus>,
1009    pub ts: Option<String>,
1010    /// Hyperliquid per-minute API rate the engine is seeing, as
1011    /// reported alongside `/v2/status`. `None` when the engine
1012    /// has not yet surfaced the field — the TUI renders `hl:?`
1013    /// in metadata color (same honest-rendering rule as `ops:?`
1014    /// before the classifier reports). Once the engine-side cut
1015    /// lands (a separate track from M2_PLAN §2), the field
1016    /// populates and the segment starts showing `hl:N/M`.
1017    ///
1018    /// Serde is tolerant here: the field is optional on the
1019    /// wire, so older engines deserializing into a newer CLI
1020    /// keep rendering `hl:?` without a decode error.
1021    pub hl_rate: Option<HlRate>,
1022    #[serde(flatten)]
1023    pub extra: BTreeMap<String, Value>,
1024}
1025
1026/// Hyperliquid API rate snapshot, optionally reported by the
1027/// engine on `/v2/status`.
1028///
1029/// `used` is the number of requests counted against the rolling
1030/// one-minute window; `cap` is the engine's own per-operator cap
1031/// (today 240/min — see `engine/zero/shared/http.py::_HL_GLOBAL_MAX`).
1032/// The widget renders `hl:<used>/<cap>` with the same tri-color
1033/// thresholds as the CLI-side rate bucket, so an operator reads
1034/// CLI-side pressure and Hyperliquid-side pressure from two
1035/// visually consistent segments.
1036///
1037/// The engine is the source of truth for both numbers — the CLI
1038/// never computes them locally.
1039#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1040pub struct HlRate {
1041    pub used: u32,
1042    pub cap: u32,
1043}
1044
1045impl V2Status {
1046    /// Market regime text (e.g. `"SHORT MARKET. 6 of 7 coins …"`).
1047    #[must_use]
1048    pub fn regime(&self) -> Option<&str> {
1049        self.market.regime.as_deref()
1050    }
1051
1052    /// Engine confidence as a 0..=100 score. Historically the CLI
1053    /// expected a 0..=1 float; the live engine reports a 0..=100
1054    /// integer, so renderers that format with `{:.2}` need to
1055    /// switch to `{:.0}` or display it as a score instead of a
1056    /// probability.
1057    #[must_use]
1058    pub fn engine_confidence(&self) -> Option<f64> {
1059        self.confidence.score
1060    }
1061
1062    /// Qualitative confidence level (`"low" | "medium" | "high"`).
1063    #[must_use]
1064    pub fn confidence_level(&self) -> Option<&str> {
1065        self.confidence.level.as_deref()
1066    }
1067
1068    /// Current account equity.
1069    #[must_use]
1070    pub fn equity(&self) -> Option<f64> {
1071        self.positions.equity
1072    }
1073
1074    /// Count of open positions.
1075    #[must_use]
1076    pub fn open(&self) -> Option<u32> {
1077        self.positions.open
1078    }
1079
1080    /// Aggregate unrealized PnL across open positions.
1081    #[must_use]
1082    pub fn unrealized_pnl(&self) -> Option<f64> {
1083        self.positions.unrealized_pnl
1084    }
1085
1086    /// `/v2/status` itself does not surface drawdown — the engine
1087    /// moved that to `/risk`. Kept as an accessor for call-site
1088    /// parity; always returns `None`.
1089    #[must_use]
1090    #[allow(clippy::unused_self)]
1091    pub fn drawdown_pct(&self) -> Option<f64> {
1092        None
1093    }
1094}
1095
1096/// Response shape for `POST /operator/events` (ADR-016).
1097///
1098/// `accepted` — the number of events the engine appended to its
1099/// classifier log (matches the number sent on success; on batch
1100/// rejection the engine returns 400 so this value is never partial).
1101///
1102/// `snapshot` — the post-ingest classifier snapshot. Returned so the
1103/// caller can reflect any label / friction / state-vector change
1104/// without a follow-up `GET /operator/state`; saves a round-trip and
1105/// guarantees the snapshot the caller acts on is the one the engine
1106/// computed *after* the event landed.
1107#[derive(Debug, Clone, Deserialize, Serialize)]
1108pub struct OperatorEventsAccepted {
1109    pub accepted: u32,
1110    pub snapshot: zero_operator_state::Snapshot,
1111}
1112
1113// ─── /execute (POST) ───────────────────────────────────────────────
1114//
1115// M2_PLAN §7 pins this as the first live-trade surface the CLI can
1116// actually speak to. The request carries a coin, a side, a size,
1117// and an **idempotency key** the client mints per `/execute`
1118// invocation; the engine deduplicates by that key within a short
1119// window so a CLI retry (we don't auto-retry `/execute`, but an
1120// operator hitting `↑ Enter` after a timeout will) cannot place a
1121// second fill. The key is serialized into the body *and* mirrored
1122// into an `X-Idempotency-Key` HTTP header so engine-side proxies
1123// that log headers but redact bodies still see the dedupe key.
1124//
1125// `Side` is `"buy" | "sell"` on the wire. We expose a small enum
1126// rather than a `String` so a future `"reduce_only"` addition
1127// lands as an explicit parse failure on the CLI side (a typed
1128// refusal the operator can see) rather than as a silent mis-tag.
1129
1130/// Direction of an `/execute` request. Wire format is the lowercase
1131/// variant name via `serde(rename_all = "lowercase")`; see
1132/// [`Self::as_wire`] for a stable string helper used by the doctor
1133/// row + the `(paper)` suffix renderer.
1134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1135#[serde(rename_all = "lowercase")]
1136pub enum ExecuteSide {
1137    Buy,
1138    Sell,
1139}
1140
1141impl ExecuteSide {
1142    #[must_use]
1143    pub const fn as_wire(self) -> &'static str {
1144        match self {
1145            Self::Buy => "buy",
1146            Self::Sell => "sell",
1147        }
1148    }
1149}
1150
1151/// Request body for `POST /execute`.
1152///
1153/// `size` is the notional **base-asset** quantity (coins, not USD);
1154/// the engine resolves the USD notional against the current mid so
1155/// the CLI does not have to round-trip mark data to place an order.
1156/// A future `size_usd: Option<f64>` column will land as an additive
1157/// field — the `#[serde(default)]` + narrow deserialization means
1158/// older engines tolerate extra fields and older CLIs tolerate
1159/// missing fields.
1160///
1161/// `idempotency_key` is required. The typed helper
1162/// [`crate::HttpClient::post_execute`] mints one per call via
1163/// `uuid::Uuid::new_v4` so callers cannot forget.
1164#[derive(Debug, Clone, Serialize, Deserialize)]
1165pub struct ExecuteRequest {
1166    pub coin: String,
1167    pub side: ExecuteSide,
1168    pub size: f64,
1169    pub idempotency_key: String,
1170}
1171
1172/// Response body for `POST /execute`.
1173///
1174/// `accepted` is the engine's tri-state: the order was composed and
1175/// sent to the exchange (or the paper adapter). `simulated` is the
1176/// paper-mode discriminator — engine truth, not a local guess. The
1177/// CLI suffixes the operator-visible line with `(paper)` whenever
1178/// this field is `true`, so the operator can never be fooled into
1179/// thinking a paper fill was a live fill.
1180///
1181/// `fill_id` is `None` until the exchange returns an ack; for paper
1182/// fills the engine synthesizes a deterministic string so the CLI
1183/// still has something to grep.
1184///
1185/// Extra fields land in `extra` verbatim; the engine is free to
1186/// add `slippage_bps`, `fee_bps`, etc. without breaking the CLI.
1187#[derive(Debug, Clone, Serialize, Deserialize)]
1188pub struct ExecuteResponse {
1189    pub accepted: bool,
1190    #[serde(default)]
1191    pub simulated: bool,
1192    #[serde(default)]
1193    pub fill_id: Option<String>,
1194    #[serde(default)]
1195    pub coin: Option<String>,
1196    #[serde(default)]
1197    pub side: Option<ExecuteSide>,
1198    #[serde(default)]
1199    pub size: Option<f64>,
1200    #[serde(default)]
1201    pub reason: Option<String>,
1202    #[serde(flatten)]
1203    pub extra: BTreeMap<String, Value>,
1204}
1205
1206// ─── /auto/toggle (POST) ───────────────────────────────────────────
1207//
1208// Flips the engine's Auto-mode flag. Request carries the desired
1209// state; response echoes the engine's **new** state (not the
1210// requested state — if friction refused the flip the operator sees
1211// `state: off` + an explanation, rather than a silent no-op with a
1212// mis-optimistic local UI).
1213
1214/// Request body for `POST /auto/toggle`. `enabled = true` asks the
1215/// engine to enter Auto-mode (Plan-mode auto-accept); `false` asks
1216/// it to fall back to operator-confirm. The engine may refuse; read
1217/// the reply's `state` to see what actually landed.
1218#[derive(Debug, Clone, Serialize, Deserialize)]
1219pub struct AutoToggleRequest {
1220    pub enabled: bool,
1221}
1222
1223/// Response body for `POST /auto/toggle`.
1224///
1225/// `state` is the engine's post-call Auto-mode state. `simulated`
1226/// is the paper-mode discriminator — in paper mode the flip is a
1227/// bookkeeping change but the downstream fills stay simulated, so
1228/// the CLI tags the operator-visible confirmation with `(paper)`
1229/// to keep that distinction loud.
1230///
1231/// `reason` carries an engine-provided explanation on refusal
1232/// (e.g. "operator state is TILT"); `None` on the happy path.
1233#[derive(Debug, Clone, Serialize, Deserialize)]
1234pub struct AutoToggleResponse {
1235    pub state: AutoState,
1236    #[serde(default)]
1237    pub simulated: bool,
1238    #[serde(default)]
1239    pub reason: Option<String>,
1240    #[serde(flatten)]
1241    pub extra: BTreeMap<String, Value>,
1242}
1243
1244/// Wire representation of the engine's Auto-mode state, mirrored
1245/// from `zero_commands::AutoState` but narrow on purpose: the
1246/// engine client speaks in its own vocabulary so a dispatcher
1247/// refactor on the CLI side does not reshape the HTTP surface.
1248#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1249#[serde(rename_all = "lowercase")]
1250pub enum AutoState {
1251    On,
1252    Off,
1253}
1254
1255impl AutoState {
1256    #[must_use]
1257    pub const fn as_wire(self) -> &'static str {
1258        match self {
1259            Self::On => "on",
1260            Self::Off => "off",
1261        }
1262    }
1263}
1264
1265#[cfg(test)]
1266mod wire_compat_tests {
1267    //! Tests that pin the deserialization of real engine bus-
1268    //! file shapes. Regression test captured after Session 10
1269    //! debugging found that `positions.json` uses
1270    //! `coin`/`direction`/`entry_price`/`size_coins` instead of
1271    //! the spec'd `symbol`/`side`/`entry`/`size`. If the engine
1272    //! later unifies on one shape, the aliases stay harmless.
1273    use super::*;
1274
1275    #[test]
1276    fn positions_bus_file_shape_parses_with_aliases() {
1277        // Trimmed from a live `positions.json` as of 2026-04-22.
1278        let raw = r#"{
1279            "updated_at": "2026-04-22T12:19:29.563466+00:00",
1280            "positions": [
1281                {
1282                    "coin": "TRX",
1283                    "direction": "LONG",
1284                    "entry_price": 0.33444,
1285                    "size_coins": 149.0,
1286                    "size_usd": 49.83156,
1287                    "stop_loss_pct": 0.025,
1288                    "id": "TRX_LONG_1776857828",
1289                    "strategy": "production",
1290                    "lens_id": "lens_flow"
1291                },
1292                {
1293                    "coin": "BTC",
1294                    "direction": "SHORT",
1295                    "entry_price": 63450.0,
1296                    "size_coins": 0.0012,
1297                    "size_usd": 76.14
1298                }
1299            ]
1300        }"#;
1301        let parsed: Positions = serde_json::from_str(raw).expect("engine shape must parse");
1302        assert_eq!(parsed.items.len(), 2);
1303        assert_eq!(parsed.items[0].symbol, "TRX");
1304        assert_eq!(parsed.items[0].side, "LONG");
1305        assert!((parsed.items[0].size - 149.0).abs() < f64::EPSILON);
1306        assert!((parsed.items[0].entry - 0.33444).abs() < f64::EPSILON);
1307        assert_eq!(parsed.items[0].lens_id.as_deref(), Some("lens_flow"));
1308        // Extra engine-only fields land in `extra` via the
1309        // `#[serde(flatten)] extra` catch-all, so nothing gets
1310        // silently dropped.
1311        assert!(parsed.items[0].extra.contains_key("size_usd"));
1312    }
1313
1314    #[test]
1315    fn risk_bus_file_shape_parses() {
1316        let raw = r#"{
1317            "account_value": 581.49647,
1318            "updated_at": "2026-04-22T12:19:29.564814+00:00",
1319            "daily_pnl_usd": 0.0,
1320            "daily_loss_usd": 0.0,
1321            "global_halt": false,
1322            "halted": false,
1323            "drawdown_pct": 9.24,
1324            "peak_equity": 613.450419,
1325            "peak_equity_30d": 640.7,
1326            "open_count": 2
1327        }"#;
1328        let parsed: Risk = serde_json::from_str(raw).expect("risk shape must parse");
1329        assert_eq!(parsed.account_value, Some(581.49647));
1330        assert_eq!(parsed.drawdown_pct, Some(9.24));
1331        assert_eq!(parsed.open_count, Some(2));
1332        assert!(!parsed.halted);
1333    }
1334}