17-task plan for replacing hardcoded mock data in fxt watch with real gRPC streaming data from the API Gateway. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
24 KiB
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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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):
use std::time::Duration;
// In the impl block, add stream type alias:
type StreamAccountStateStream =
Pin<Box<dyn Stream<Item = Result<GetAccountStateResponse, Status>> + Send>>;
// Then the method:
async fn stream_account_state(
&self,
request: Request<StreamAccountStateRequest>,
) -> Result<Response<Self::StreamAccountStateStream>, 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, pollsget_account_state)stream_session_status(default interval 5s, pollsget_session_status)- Add
use std::time::Duration;at top
data_acquisition_proxy.rs — add 1 method:
stream_download_status(default interval 5s, pollslist_download_jobswith 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, pollsget_circuit_breaker_status)stream_risk_metrics(default interval 3s, pollsget_risk_metrics)- Import:
use std::time::Duration;
ml_proxy.rs — add 1 method:
stream_model_status(default interval 5s, pollsget_model_status)- Forward
model_namefrom the stream request to eachGetModelStatusRequest - 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, pollsget_portfolio_summary)stream_order_book(default interval 1s, pollsget_order_book, forwardssymbol+depth)- Import:
use std::time::Duration;
trading_agent_proxy.rs — add 1 method:
stream_agent_status(default interval 3s, pollsget_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), yieldSystemStatusEventwrapping theGetSystemStatusResponsefields - 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
MetricsEventwrapping 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
AlertEventeach 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:
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:
- Add
ConnectionStatusenum - Add missing data types needed by streams (
OrderData,VaRData,AlertData, etc.) - Change
AppState::default()to start with empty vecs (not mock data) - Add
connection_status: ConnectionStatusfield toAppState
Add to top of state.rs:
/// 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(addpub 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:
pub enum StateUpdate {
// Trading cockpit
Positions(Vec<PositionData>),
Executions(Vec<ExecutionData>),
AccountState(AccountData),
PortfolioSummary(/* fields */),
SessionStatus(/* fields */),
// Training cockpit
TrainingSessions(Vec<TrainingSessionData>),
Gpu(GpuData),
// Services cockpit
Services(Vec<ServiceData>),
SystemResources(SystemResources),
ClusterEvents(Vec<ClusterEventData>),
// Risk cockpit
CircuitBreakers(Vec<CircuitBreakerData>),
PositionLimits(Vec<PositionLimitData>),
RiskMetrics(/* drawdown, VaR */),
// Data cockpit
DataFeeds(Vec<DataFeedData>),
DownloadJobs(Vec</* download job data */>),
// Meta
Connection(ConnectionStatus),
}
DataFetcher struct:
pub struct DataFetcher {
tx: mpsc::UnboundedSender<StateUpdate>,
cancel: tokio_util::sync::CancellationToken,
}
impl DataFetcher {
pub fn new(
client: &FoxhuntClient,
tx: mpsc::UnboundedSender<StateUpdate>,
) -> 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:
fn spawn_positions_stream(client: &FoxhuntClient, tx: UnboundedSender<StateUpdate>, 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:
- Change
runsignature:
pub async fn run(api_url: &str) -> anyhow::Result<()> {
(remove the _ prefix from api_url)
- Inside
run(), beforeevent_loop():
// 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);
-
Pass
rxtoevent_loop(). -
Inside
event_loop(), at the start of each loop iteration, drain the channel:
// Drain all pending state updates
while let Ok(update) = rx.try_recv() {
apply_update(&mut state, update);
}
-
Add
apply_updatefunction that matches onStateUpdatevariants and updatesAppStatefields. -
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: Ifstate.services.is_empty(), show centered "Connecting..."render_resources: If all zero, show "Waiting for data..."render_training_summary: Ifstate.training_sessions.is_empty(), show "No active training"render_portfolio: If account equity is 0.0, show "Connecting..."
Training cockpit changes:
render_sessions_table: Ifstate.training_sessions.is_empty(), show "No active training sessions"render_gpu_panel: Ifstate.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 tostate.session_statusor 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: Ifstate.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 fromDataFetcher. 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:
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:
SQLX_OFFLINE=true cargo build --release -p api_gateway- Deploy using pod-writer-deploy skill (see
/.claude/skills/pod-writer-deploy/) - Verify with:
fxt service health
Commit: none (deployment only)
Task 17: Build fxt + test in tmux
Steps:
SQLX_OFFLINE=true cargo build --release -p fxtcp target/release/fxt ~/.cargo/bin/fxtfxt auth login --username admin(or use FXT_PASSWORD env var)- Launch:
fxt watch --api-url https://api.fxhnt.ai - 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
- Test reconnection: temporarily disconnect, verify TUI shows "Reconnecting..."
- 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)