Streaming-first architecture: 11 new poll-to-stream gateway adapters, channel-based DataFetcher in the TUI, auto-reconnect with backoff. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
6.5 KiB
6.5 KiB
TUI Live Data Wiring — Design
Date: 2026-03-04
Goal: Replace all hardcoded mock data in fxt watch with real gRPC streaming data from the API Gateway.
Architecture
Data Flow
gRPC Streams (19 total)
│
▼
DataFetcher (tokio tasks, one per stream)
│
▼ mpsc::UnboundedSender<StateUpdate>
│
event_loop (drains channel each tick, applies to AppState, renders)
DataFetcherspawns one tokio task per gRPC stream- Each task sends
StateUpdateenum variants through an unbounded mpsc channel - Event loop owns
AppStateexclusively — no shared mutable state, no locks - On stream disconnect, tasks auto-reconnect with exponential backoff (1s → 2s → 4s → max 30s)
StateUpdate Enum
enum StateUpdate {
Positions(Vec<PositionData>),
Executions(Vec<ExecutionData>),
Orders(Vec<OrderData>),
AccountState(AccountData),
PortfolioSummary(PortfolioData),
OrderBook(OrderBookData),
Services(Vec<ServiceData>),
SystemStatus(SystemStatusData),
Metrics(MetricsData),
RiskMetrics(RiskData),
CircuitBreakers(Vec<CircuitBreakerData>),
VaRUpdate(VaRData),
RiskAlerts(Vec<RiskAlertData>),
TrainingSessions(Vec<TrainingSessionData>),
TrainingJobStatus(TrainingJobData),
ModelStatus(ModelStatusData),
ModelMetrics(ModelMetricsData),
AgentStatus(AgentStatusData),
AgentActivity(AgentActivityData),
DataFeeds(Vec<DataFeedData>),
DownloadStatus(Vec<DownloadJobData>),
SessionStatus(SessionStatusData),
SignalStrength(SignalStrengthData),
ConnectionStatus(ConnectionStatus),
}
Proto Changes — 11 New Streaming RPCs
All new RPCs are gateway-level poll-to-stream adapters. The backend services remain unchanged.
| # | Proto | New RPC | Wrapped Unary | Poll Interval |
|---|---|---|---|---|
| 1 | broker_gateway.proto |
StreamAccountState |
GetAccountState |
3s |
| 2 | broker_gateway.proto |
StreamSessionStatus |
GetSessionStatus |
5s |
| 3 | risk.proto |
StreamCircuitBreakerStatus |
GetCircuitBreakerStatus |
2s |
| 4 | risk.proto |
StreamRiskMetrics |
GetRiskMetrics |
3s |
| 5 | data_acquisition.proto |
StreamDownloadStatus |
ListDownloadJobs |
5s |
| 6 | ml.proto |
StreamModelStatus |
GetModelStatus |
5s |
| 7 | trading_agent.proto |
StreamAgentStatus |
GetAgentStatus |
3s |
| 8 | monitoring.proto |
StreamServiceHealth |
GetHealthCheck |
5s |
| 9 | trading.proto |
StreamPortfolioSummary |
GetPortfolioSummary |
3s |
| 10 | ml_training.proto |
StreamTrainingJobStatus |
GetTrainingJobDetails |
3s |
| 11 | trading.proto |
StreamOrderBook |
GetOrderBook |
1s |
Existing Streaming RPCs (8 — already proxied, just need TUI clients)
| Proto | Existing Stream | TUI 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 |
StreamSystemStatus |
Services/Overview |
monitoring.proto |
StreamMetrics |
Services/Overview |
monitoring.proto |
StreamTrainingMetrics |
Training |
monitoring.proto |
StreamAlerts |
Services |
ml.proto |
StreamPredictions |
Overview |
ml.proto |
StreamModelMetrics |
Training/Overview |
ml.proto |
StreamSignalStrength |
Overview |
trading_agent.proto |
StreamAgentActivity |
Overview |
API Gateway — Poll-to-Stream Adapter Pattern
Each new streaming RPC in the gateway follows this pattern:
async fn stream_foo(&self, _request: Request<StreamFooRequest>)
-> Result<Response<Self::StreamFooStream>, Status>
{
let mut client = self.client.clone();
let (tx, rx) = tokio::sync::mpsc::channel(16);
tokio::spawn(async move {
loop {
match client.get_foo(Request::new(GetFooRequest {})).await {
Ok(resp) => {
if tx.send(Ok(resp.into_inner())).await.is_err() {
break; // client disconnected
}
}
Err(e) => {
error!("Backend GetFoo failed: {}", e);
let _ = tx.send(Err(e)).await;
break;
}
}
tokio::time::sleep(Duration::from_secs(N)).await;
}
});
Ok(Response::new(ReceiverStream::new(rx)))
}
TUI Changes
New File: bin/fxt/src/tui/data_fetcher.rs
DataFetcher::new(client: FoxhuntClient, tx: UnboundedSender<StateUpdate>)DataFetcher::start()— spawns all stream tasks- Each task: open stream → loop { recv → convert to StateUpdate → send on channel }
- Reconnect on error: exponential backoff 1s → 2s → 4s → max 30s
- Graceful shutdown via
CancellationToken
Modified Files
event_loop.rs: AcceptFoxhuntClient, createDataFetcher, drain mpsc channel each tickstate.rs: AddConnectionStatusenum, makeDefaultstart with empty vecswatch.rs: CreateFoxhuntClientwith auth, pass to event loop- 6 cockpit files: Handle empty state (show "Connecting..." or "No data")
ConnectionStatus
enum ConnectionStatus {
Connecting,
Connected,
Reconnecting { attempt: u32 },
Error(String),
}
Displayed in the status bar: "Connected to api.fxhnt.ai" / "Reconnecting (attempt 3)..." / etc.
Cockpit → Stream Mapping
| Cockpit | Streams Used |
|---|---|
| Overview | StreamSystemStatus, StreamPortfolioSummary, StreamAgentStatus, StreamModelMetrics |
| Training | StreamTrainingMetrics, StreamTrainingJobStatus, StreamModelStatus |
| Trading | StreamPositions, StreamExecutions, StreamOrders, StreamAccountState, StreamOrderBook, StreamPortfolioSummary, StreamSessionStatus |
| Services | StreamServiceHealth, StreamSystemStatus, StreamMetrics, StreamAlerts |
| Risk | StreamVaRUpdates, StreamRiskAlerts, StreamCircuitBreakerStatus, StreamRiskMetrics |
| Data | StreamMarketData, StreamDownloadStatus |
File Impact Summary
| Layer | Files | Type |
|---|---|---|
| Proto | 7 .proto files |
Modify (additive) |
| Gateway build | build.rs |
Modify (regenerate) |
| Gateway proxies | 7 proxy files | Modify (add stream methods) |
| FXT TUI | data_fetcher.rs |
New |
| FXT TUI | event_loop.rs, state.rs, watch.rs |
Modify |
| FXT TUI | 6 cockpit files | Modify (handle empty state) |
| FXT build | bin/fxt/build.rs |
May need regeneration |