# TUI Live Data Wiring — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Replace all hardcoded mock data in `fxt watch` with real gRPC streaming data from the API Gateway, using poll-to-stream adapters in the gateway for RPCs that lack native streaming. **Architecture:** 9 new streaming RPCs added to protos, implemented as poll-to-stream adapters in the API Gateway proxies. 3 existing unimplemented monitoring streams get real implementations. TUI gets a `DataFetcher` module that opens all streams and sends `StateUpdate` enum variants through an mpsc channel to the event loop, which owns `AppState` exclusively. **Tech Stack:** Rust, tonic 0.14, async-stream, tokio-stream, ratatui 0.29, crossterm 0.28 --- ## Stream Inventory ### New streaming RPCs (to be added to protos + gateway adapters): | # | Proto | New RPC | Wraps | Interval | Cockpit | |---|---|---|---|---|---| | 1 | `broker_gateway.proto` | `StreamAccountState` | `GetAccountState` | 3s | Trading | | 2 | `broker_gateway.proto` | `StreamSessionStatus` | `GetSessionStatus` | 5s | Trading | | 3 | `risk.proto` | `StreamCircuitBreakerStatus` | `GetCircuitBreakerStatus` | 2s | Risk | | 4 | `risk.proto` | `StreamRiskMetrics` | `GetRiskMetrics` | 3s | Risk | | 5 | `data_acquisition.proto` | `StreamDownloadStatus` | `ListDownloadJobs` | 5s | Data | | 6 | `ml.proto` | `StreamModelStatus` | `GetModelStatus` | 5s | Overview | | 7 | `trading_agent.proto` | `StreamAgentStatus` | `GetAgentStatus` | 3s | Overview | | 8 | `trading.proto` | `StreamPortfolioSummary` | `GetPortfolioSummary` | 3s | Trading | | 9 | `trading.proto` | `StreamOrderBook` | `GetOrderBook` | 1s | Trading | ### Existing unimplemented monitoring streams (implement in monitoring_handler.rs): | # | RPC | Status | Cockpit | |---|---|---|---| | 10 | `monitoring.StreamSystemStatus` | Proto exists, returns `Status::unimplemented` | Services | | 11 | `monitoring.StreamMetrics` | Proto exists, returns `Status::unimplemented` | Services | | 12 | `monitoring.StreamAlerts` | Proto exists, returns `Status::unimplemented` | Services | ### Existing working streams (already proxied, just need TUI clients): | Proto | Stream | Cockpit | |---|---|---| | `trading.proto` | `StreamPositions` | Trading | | `trading.proto` | `StreamOrders` | Trading | | `trading.proto` | `StreamExecutions` | Trading | | `trading.proto` | `StreamMarketData` | Data | | `risk.proto` | `StreamVaRUpdates` | Risk | | `risk.proto` | `StreamRiskAlerts` | Risk | | `monitoring.proto` | `StreamTrainingMetrics` | Training | | `ml.proto` | `StreamModelMetrics` | Training | | `ml.proto` | `StreamSignalStrength` | Overview | | `trading_agent.proto` | `StreamAgentActivity` | Overview | --- ## Task 1: Add 9 new streaming RPCs to proto files **Files:** - Modify: `proto/broker_gateway.proto` - Modify: `proto/risk.proto` - Modify: `proto/data_acquisition.proto` - Modify: `proto/ml.proto` - Modify: `proto/trading_agent.proto` - Modify: `proto/trading.proto` **What:** Add `rpc Stream*` methods to existing service definitions. Each new stream reuses existing request/response types (no new messages needed). Use empty request messages for streams that don't need parameters. **Changes per proto:** **`broker_gateway.proto`** — add after `HealthCheck` RPC: ```protobuf // Server-streaming: polls GetAccountState at gateway level rpc StreamAccountState(StreamAccountStateRequest) returns (stream GetAccountStateResponse); // Server-streaming: polls GetSessionStatus at gateway level rpc StreamSessionStatus(StreamSessionStatusRequest) returns (stream GetSessionStatusResponse); } message StreamAccountStateRequest { uint32 interval_seconds = 1; // 0 = server default (3s) } message StreamSessionStatusRequest { uint32 interval_seconds = 1; // 0 = server default (5s) } ``` **`risk.proto`** — add after `GetCircuitBreakerStatus` RPC: ```protobuf // Server-streaming: polls GetCircuitBreakerStatus at gateway level rpc StreamCircuitBreakerStatus(StreamCircuitBreakerStatusRequest) returns (stream GetCircuitBreakerStatusResponse); // Server-streaming: polls GetRiskMetrics at gateway level rpc StreamRiskMetrics(StreamRiskMetricsRequest) returns (stream GetRiskMetricsResponse); } message StreamCircuitBreakerStatusRequest { uint32 interval_seconds = 1; // 0 = server default (2s) } message StreamRiskMetricsRequest { uint32 interval_seconds = 1; // 0 = server default (3s) } ``` **`data_acquisition.proto`** — add after `HealthCheck` RPC: ```protobuf // Server-streaming: polls ListDownloadJobs at gateway level rpc StreamDownloadStatus(StreamDownloadStatusRequest) returns (stream ListDownloadJobsResponse); } message StreamDownloadStatusRequest { uint32 interval_seconds = 1; // 0 = server default (5s) } ``` **`ml.proto`** — add after `StreamSignalStrength` RPC: ```protobuf // Server-streaming: polls GetModelStatus at gateway level rpc StreamModelStatus(StreamModelStatusRequest) returns (stream GetModelStatusResponse); } message StreamModelStatusRequest { string model_name = 1; // empty = all models uint32 interval_seconds = 2; // 0 = server default (5s) } ``` **`trading_agent.proto`** — add after `HealthCheck` RPC: ```protobuf // Server-streaming: polls GetAgentStatus at gateway level rpc StreamAgentStatus(StreamAgentStatusRequest) returns (stream GetAgentStatusResponse); } message StreamAgentStatusRequest { uint32 interval_seconds = 1; // 0 = server default (3s) } ``` **`trading.proto`** — add after `GetRegimeTransitions` RPC: ```protobuf // Server-streaming: polls GetPortfolioSummary at gateway level rpc StreamPortfolioSummary(StreamPortfolioSummaryRequest) returns (stream GetPortfolioSummaryResponse); // Server-streaming: polls GetOrderBook at gateway level rpc StreamOrderBook(StreamOrderBookRequest) returns (stream GetOrderBookResponse); } message StreamPortfolioSummaryRequest { uint32 interval_seconds = 1; // 0 = server default (3s) } message StreamOrderBookRequest { string symbol = 1; int32 depth = 2; uint32 interval_seconds = 3; // 0 = server default (1s) } ``` **Verify:** `SQLX_OFFLINE=true cargo check -p fxt 2>&1 | head -5` — new proto types should compile but gateway will have errors (expected — server trait now requires new methods). **Commit:** `feat(proto): add 9 streaming RPCs for TUI live data` --- ## Task 2: Gateway stream adapters — broker_gateway + data_acquisition **Files:** - Modify: `services/api_gateway/src/grpc/broker_gateway_proxy.rs` - Modify: `services/api_gateway/src/grpc/data_acquisition_proxy.rs` **What:** Add poll-to-stream adapter methods to existing proxy structs. Each adapter spawns an `async_stream::stream!` loop that polls the backend unary RPC and yields responses. **Pattern** (used by all gateway stream adapters): ```rust use std::time::Duration; // In the impl block, add stream type alias: type StreamAccountStateStream = Pin> + Send>>; // Then the method: async fn stream_account_state( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let interval_secs = if req.interval_seconds == 0 { 3 } else { req.interval_seconds.clamp(1, 60) }; let mut client = self.client.clone(); let stream = async_stream::stream! { let mut interval = tokio::time::interval(Duration::from_secs(u64::from(interval_secs))); loop { interval.tick().await; match client.get_account_state(Request::new(GetAccountStateRequest {})).await { Ok(resp) => yield Ok(resp.into_inner()), Err(e) => { error!("Backend GetAccountState failed: {}", e); yield Err(e); break; } } } }; Ok(Response::new(Box::pin(stream))) } ``` **broker_gateway_proxy.rs** — add 2 methods: - `stream_account_state` (default interval 3s, polls `get_account_state`) - `stream_session_status` (default interval 5s, polls `get_session_status`) - Add `use std::time::Duration;` at top **data_acquisition_proxy.rs** — add 1 method: - `stream_download_status` (default interval 5s, polls `list_download_jobs` with empty request) - Add `use std::time::Duration;` at top **Verify:** `SQLX_OFFLINE=true cargo check -p api_gateway 2>&1 | grep "error\[" | head -10` — broker_gateway and data_acquisition errors should be gone. Other proxy errors expected. **Commit:** `feat(api_gateway): add broker_gateway + data_acquisition stream adapters` --- ## Task 3: Gateway stream adapters — risk + ml **Files:** - Modify: `services/api_gateway/src/grpc/risk_proxy.rs` - Modify: `services/api_gateway/src/grpc/ml_proxy.rs` **risk_proxy.rs** — add 2 methods: - `stream_circuit_breaker_status` (default interval 2s, polls `get_circuit_breaker_status`) - `stream_risk_metrics` (default interval 3s, polls `get_risk_metrics`) - Import: `use std::time::Duration;` **ml_proxy.rs** — add 1 method: - `stream_model_status` (default interval 5s, polls `get_model_status`) - Forward `model_name` from the stream request to each `GetModelStatusRequest` - Import: `use std::time::Duration;` **Verify:** `SQLX_OFFLINE=true cargo check -p api_gateway 2>&1 | grep "error\[" | head -10` **Commit:** `feat(api_gateway): add risk + ml stream adapters` --- ## Task 4: Gateway stream adapters — trading + trading_agent **Files:** - Modify: `services/api_gateway/src/grpc/trading_direct_proxy.rs` - Modify: `services/api_gateway/src/grpc/trading_agent_proxy.rs` **trading_direct_proxy.rs** — add 2 methods: - `stream_portfolio_summary` (default interval 3s, polls `get_portfolio_summary`) - `stream_order_book` (default interval 1s, polls `get_order_book`, forwards `symbol` + `depth`) - Import: `use std::time::Duration;` **trading_agent_proxy.rs** — add 1 method: - `stream_agent_status` (default interval 3s, polls `get_agent_status`) - Import: `use std::time::Duration;` **Verify:** `SQLX_OFFLINE=true cargo check -p api_gateway 2>&1 | grep "error\[" | head -10` **Commit:** `feat(api_gateway): add trading + trading_agent stream adapters` --- ## Task 5: Implement monitoring handler streams **Files:** - Modify: `services/api_gateway/src/grpc/monitoring_handler.rs` **What:** Replace 3 `Status::unimplemented` stubs with real implementations. Follow the existing `stream_training_metrics` pattern (clone internal state, `async_stream::stream!` loop). **`stream_system_status`** (replaces stub at ~line 497): - Clone `self.backends`, `self.start_time` - In stream loop: fan-out health checks (same logic as `get_system_status`), yield `SystemStatusEvent` wrapping the `GetSystemStatusResponse` fields - Default interval from request or 5s **`stream_metrics`** (replaces stub at ~line 609): - Clone `self.prom` - In stream loop: query configured metric names from Prometheus, yield `MetricsEvent` wrapping metric list - Default interval from request or 5s - Use fixed metric list: `["cpu_usage", "memory_usage", "gpu_utilization"]` **`stream_alerts`** (replaces stub at ~line 639): - This is a placeholder — Prometheus has an alertmanager API but the handler doesn't currently scrape alerts - Implement as a stream that yields an empty `AlertEvent` each interval (no-data is better than unimplemented) - Default interval from request or 10s **Verify:** `SQLX_OFFLINE=true cargo check -p api_gateway` — should compile with 0 errors now **Commit:** `feat(api_gateway): implement monitoring stream RPCs` --- ## Task 6: Verify full gateway build **Verify:** ```bash SQLX_OFFLINE=true cargo check -p api_gateway SQLX_OFFLINE=true cargo test -p api_gateway --lib SQLX_OFFLINE=true cargo clippy -p api_gateway -- -D warnings ``` All must pass: 0 errors, 0 clippy warnings. **Commit (if any fixups needed):** `fix(api_gateway): clippy and test fixes for stream adapters` --- ## Task 7: TUI state.rs — ConnectionStatus, empty defaults, new data types **Files:** - Modify: `bin/fxt/src/tui/state.rs` **What:** 1. Add `ConnectionStatus` enum 2. Add missing data types needed by streams (`OrderData`, `VaRData`, `AlertData`, etc.) 3. Change `AppState::default()` to start with empty vecs (not mock data) 4. Add `connection_status: ConnectionStatus` field to `AppState` **Add to top of state.rs:** ```rust /// Connection state for the gRPC streaming layer. #[derive(Debug, Clone)] pub enum ConnectionStatus { Connecting, Connected, Reconnecting { attempt: u32 }, Error(String), } impl Default for ConnectionStatus { fn default() -> Self { Self::Connecting } } ``` **Replace** `impl Default for AppState` — all vecs become `Vec::new()`, account defaults to zeros, risk defaults to zeros. Add `connection_status: ConnectionStatus::default()`. **Add** any new sub-struct types needed for streams not covered by existing types (e.g., `AlertData`, `VaRData` if not already present). Keep minimal — only fields the cockpits actually render. **Verify:** `SQLX_OFFLINE=true cargo check -p fxt` **Commit:** `refactor(fxt): empty TUI state defaults + ConnectionStatus` --- ## Task 8: Create TUI data_fetcher.rs **Files:** - Create: `bin/fxt/src/tui/data_fetcher.rs` - Modify: `bin/fxt/src/tui/mod.rs` (add `pub mod data_fetcher;`) **What:** Core streaming data layer. `StateUpdate` enum + `DataFetcher` struct. **`StateUpdate` enum** — one variant per data source, carrying the parsed TUI-layer types: ```rust pub enum StateUpdate { // Trading cockpit Positions(Vec), Executions(Vec), AccountState(AccountData), PortfolioSummary(/* fields */), SessionStatus(/* fields */), // Training cockpit TrainingSessions(Vec), Gpu(GpuData), // Services cockpit Services(Vec), SystemResources(SystemResources), ClusterEvents(Vec), // Risk cockpit CircuitBreakers(Vec), PositionLimits(Vec), RiskMetrics(/* drawdown, VaR */), // Data cockpit DataFeeds(Vec), DownloadJobs(Vec), // Meta Connection(ConnectionStatus), } ``` **`DataFetcher` struct:** ```rust pub struct DataFetcher { tx: mpsc::UnboundedSender, cancel: tokio_util::sync::CancellationToken, } impl DataFetcher { pub fn new( client: &FoxhuntClient, tx: mpsc::UnboundedSender, ) -> Self { ... } /// Spawn all stream tasks. Returns immediately. pub fn start(&self, client: &FoxhuntClient) { ... } /// Cancel all streams gracefully. pub fn stop(&self) { self.cancel.cancel(); } } ``` **Each stream task** follows this pattern: ```rust fn spawn_positions_stream(client: &FoxhuntClient, tx: UnboundedSender, cancel: CancellationToken) { let mut trading = client.trading(); tokio::spawn(async move { let mut backoff = Duration::from_secs(1); loop { match trading.stream_positions(Request::new(StreamPositionsRequest {})).await { Ok(response) => { backoff = Duration::from_secs(1); // reset on success let mut stream = response.into_inner(); loop { tokio::select! { _ = cancel.cancelled() => return, msg = stream.message() => { match msg { Ok(Some(event)) => { let positions = convert_positions(event); let _ = tx.send(StateUpdate::Positions(positions)); } Ok(None) => break, // stream ended, reconnect Err(e) => { let _ = tx.send(StateUpdate::Connection( ConnectionStatus::Error(format!("positions: {e}")) )); break; } } } } } } Err(e) => { let _ = tx.send(StateUpdate::Connection( ConnectionStatus::Reconnecting { attempt: backoff.as_secs() as u32 } )); } } // Exponential backoff before reconnect tokio::select! { _ = cancel.cancelled() => return, _ = tokio::time::sleep(backoff) => {} } backoff = (backoff * 2).min(Duration::from_secs(30)); } }); } ``` **Conversion functions** — map proto types to TUI state types. One per stream. Keep simple: extract fields, convert enums to strings, format numbers. **Spawn order in `start()`:** monitoring first (service health), then trading, risk, training, data, agent — so the user sees connection status early. **Dependencies:** Add `tokio-util` (for `CancellationToken`) to `bin/fxt/Cargo.toml` if not already present. **Verify:** `SQLX_OFFLINE=true cargo check -p fxt` **Commit:** `feat(fxt): add TUI DataFetcher with streaming gRPC clients` --- ## Task 9: Wire event_loop.rs + watch.rs **Files:** - Modify: `bin/fxt/src/tui/event_loop.rs` - Modify: `bin/fxt/src/commands/watch.rs` **event_loop.rs changes:** 1. Change `run` signature: ```rust pub async fn run(api_url: &str) -> anyhow::Result<()> { ``` (remove the `_` prefix from `api_url`) 2. Inside `run()`, before `event_loop()`: ```rust // Connect to API Gateway let client = FoxhuntClient::connect(api_url).await?; // Auto-refresh auth token let storage = FileTokenStorage::new()?; let manager = AuthTokenManager::new(storage); if manager.needs_refresh().await { let login_client = LoginClient::new(client.channel()); if login_client.silent_login(&manager).await? { eprintln!("Token refreshed."); } } // Start streaming data layer let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); let fetcher = DataFetcher::new(&client, tx); fetcher.start(&client); ``` 3. Pass `rx` to `event_loop()`. 4. Inside `event_loop()`, at the start of each loop iteration, drain the channel: ```rust // Drain all pending state updates while let Ok(update) = rx.try_recv() { apply_update(&mut state, update); } ``` 5. Add `apply_update` function that matches on `StateUpdate` variants and updates `AppState` fields. 6. On `'r'` keypress (refresh), cancel and restart the fetcher. **watch.rs** — no changes needed (already passes `api_url` to `run()`). **Verify:** `SQLX_OFFLINE=true cargo check -p fxt` **Commit:** `feat(fxt): wire DataFetcher into TUI event loop` --- ## Task 10: Wire Overview + Training cockpits **Files:** - Modify: `bin/fxt/src/tui/cockpits/overview.rs` - Modify: `bin/fxt/src/tui/cockpits/training.rs` **What:** Handle empty/connecting state in rendering. When data hasn't arrived yet, show "Connecting..." instead of empty tables. **Overview cockpit changes:** - `render_services`: If `state.services.is_empty()`, show centered "Connecting..." - `render_resources`: If all zero, show "Waiting for data..." - `render_training_summary`: If `state.training_sessions.is_empty()`, show "No active training" - `render_portfolio`: If account equity is 0.0, show "Connecting..." **Training cockpit changes:** - `render_sessions_table`: If `state.training_sessions.is_empty()`, show "No active training sessions" - `render_gpu_panel`: If `state.gpu.name == "N/A"`, show "No GPU data" - `render_epoch_metrics`: Similar empty-state handling **Verify:** `SQLX_OFFLINE=true cargo check -p fxt` **Commit:** `feat(fxt): wire Overview + Training cockpits to live data` --- ## Task 11: Wire Trading cockpit **Files:** - Modify: `bin/fxt/src/tui/cockpits/trading.rs` **What:** Handle empty state for all 4 panels (positions, executions, account, broker health). - `render_positions`: Empty → "No open positions" - `render_executions`: Empty → "No recent executions" - `render_account`: All zeros → "Connecting..." - `render_broker_health`: Currently hardcoded. Wire to `state.session_status` or similar field. If no data → "Connecting..." **Verify:** `SQLX_OFFLINE=true cargo check -p fxt` **Commit:** `feat(fxt): wire Trading cockpit to live data` --- ## Task 12: Wire Services + Risk cockpits **Files:** - Modify: `bin/fxt/src/tui/cockpits/services.rs` - Modify: `bin/fxt/src/tui/cockpits/risk.rs` **Services cockpit:** - `render_service_grid`: Empty → "Connecting..." - `render_cluster_events`: Empty → "No events" - `render_cluster_resources`: All zeros → "Waiting for metrics..." **Risk cockpit:** - `render_kill_switches`: Handle default false values (already works) - `render_drawdown`: All zeros → "No drawdown data" - `render_circuit_breakers`: Empty → "No circuit breakers" - `render_position_limits`: Empty → "No position limits" **Verify:** `SQLX_OFFLINE=true cargo check -p fxt` **Commit:** `feat(fxt): wire Services + Risk cockpits to live data` --- ## Task 13: Wire Data cockpit **Files:** - Modify: `bin/fxt/src/tui/cockpits/data.rs` **What:** - `render_feeds`: If `state.data_feeds.is_empty()`, show "No data feeds" (market data stream may not be active if no symbols subscribed) - `render_cache_stats`: All zeros → "No cache data" - `render_pipeline`: Wire to download job status from `DataFetcher`. Show actual job states (PENDING, DOWNLOADING, etc.) instead of hardcoded "IDLE"/"RUNNING" **Verify:** `SQLX_OFFLINE=true cargo check -p fxt` **Commit:** `feat(fxt): wire Data cockpit to live data` --- ## Task 14: Add connection status to status bar **Files:** - Modify: `bin/fxt/src/tui/event_loop.rs` **What:** Update `render_status_bar` to show connection status from `state.connection_status`: - `Connecting` → yellow "Connecting..." - `Connected` → green "Connected" - `Reconnecting { attempt }` → yellow "Reconnecting (attempt N)..." - `Error(msg)` → red "Error: msg" Place between the keymap hints and the "Updated: X ago" text. **Verify:** `SQLX_OFFLINE=true cargo check -p fxt` **Commit:** `feat(fxt): show connection status in TUI status bar` --- ## Task 15: Full verification — tests + clippy **Verify:** ```bash SQLX_OFFLINE=true cargo test -p fxt --lib SQLX_OFFLINE=true cargo test -p api_gateway --lib SQLX_OFFLINE=true cargo clippy -p fxt -p api_gateway -- -D warnings ``` Fix any issues. All must pass with 0 errors, 0 warnings. **Commit:** `fix(fxt): test and clippy fixes for TUI live data` --- ## Task 16: Build + deploy api_gateway **Steps:** 1. `SQLX_OFFLINE=true cargo build --release -p api_gateway` 2. Deploy using pod-writer-deploy skill (see `/.claude/skills/pod-writer-deploy/`) 3. Verify with: `fxt service health` **Commit:** none (deployment only) --- ## Task 17: Build fxt + test in tmux **Steps:** 1. `SQLX_OFFLINE=true cargo build --release -p fxt` 2. `cp target/release/fxt ~/.cargo/bin/fxt` 3. `fxt auth login --username admin` (or use FXT_PASSWORD env var) 4. Launch: `fxt watch --api-url https://api.fxhnt.ai` 5. Test all 6 views (keys 1-6): - Overview: Should show real service health, real portfolio if authenticated - Training: Real training sessions if any running, real GPU data - Trading: Real positions from IBKR/broker-gateway, real executions - Services: Real health check results from all backends - Risk: Real circuit breaker status, VaR data if risk service running - Data: Real download job status, market data feed status 6. Test reconnection: temporarily disconnect, verify TUI shows "Reconnecting..." 7. Test help overlay (?), quit (q) **Verification checklist:** - [ ] All 6 views render without panic - [ ] Connected status shows in status bar - [ ] Data updates in real-time (positions change, metrics refresh) - [ ] Empty/connecting state shown gracefully when backend data unavailable - [ ] 'r' key forces reconnection - [ ] Quit works cleanly (no terminal corruption)