feat: MCP server mode + TUI cockpit framework

MCP server (bin/fxt/src/mcp/):
- JSON-RPC 2.0 protocol over stdin/stdout
- 32 tool definitions across 11 domains
- McpServer with initialize/tools_list/tools_call handlers
- 21 unit tests

TUI cockpits (bin/fxt/src/tui/):
- Purple/cyan/dark navy theme from design spec
- 6 cockpit views: overview, training, trading, services, risk, data
- Crossterm event loop with key handling (1-6 switch, q quit, ? help)
- CockpitView trait for pluggable cockpit rendering

All 75 tests pass, 0 clippy warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-03 21:48:59 +01:00
parent d1b9de3363
commit ab101f1110
19 changed files with 3158 additions and 8 deletions

View File

@@ -1,18 +1,30 @@
//! `fxt mcp` -- MCP (Model Context Protocol) server mode.
//!
//! Starts a JSON-RPC 2.0 server on stdin/stdout, exposing all Foxhunt
//! operations as MCP tools for LLM agents (Claude, Cursor, etc.).
use anyhow::Result;
use clap::Parser;
use crate::mcp::server::McpServer;
/// Start MCP server (JSON-RPC 2.0 on stdin/stdout)
#[derive(Parser, Debug)]
pub struct McpCommand;
pub struct McpCommand {
/// API endpoint URL for backend gRPC calls
#[arg(
long = "api-url",
env = "FXT_API_URL",
default_value = "https://api.fxhnt.ai"
)]
api_url: String,
}
impl McpCommand {
/// Start an MCP server that exposes Foxhunt operations as tools
/// Start the MCP server that exposes Foxhunt operations as tools
/// for LLM agents (e.g. Claude, Cursor).
///
/// This command does not need a pre-built gRPC client --
/// the server manages its own connections.
pub async fn execute(&self) -> Result<()> {
anyhow::bail!("mcp server not yet implemented")
let server = McpServer::new(self.api_url.clone());
server.run().await
}
}

View File

@@ -3,8 +3,20 @@
use anyhow::Result;
use clap::Parser;
/// Launch the full-screen TUI cockpit dashboard.
///
/// Displays six purpose-built views: Overview, Training, Trading,
/// Services, Risk, and Data. Navigate with keys 1-6, quit with q.
#[derive(Parser, Debug)]
pub struct WatchCommand;
pub struct WatchCommand {
/// API Gateway endpoint (used for future gRPC streaming).
#[arg(
long = "api-url",
env = "FXT_API_URL",
default_value = "https://api.fxhnt.ai"
)]
api_url: String,
}
impl WatchCommand {
/// Launch the full-screen TUI dashboard.
@@ -12,6 +24,6 @@ impl WatchCommand {
/// This command does not need a gRPC client at construction time --
/// the TUI event loop manages its own connections.
pub async fn execute(&self) -> Result<()> {
anyhow::bail!("watch (TUI) not yet implemented")
crate::tui::event_loop::run(&self.api_url).await
}
}

View File

@@ -42,6 +42,8 @@ use toml as _;
use tonic as _;
use tracing_subscriber as _;
use uuid as _;
use crossterm as _;
use ratatui as _;
// Core modules
pub mod auth;
@@ -49,7 +51,9 @@ pub mod commands;
pub mod config;
pub mod error;
pub mod grpc;
pub mod mcp;
pub mod output;
pub mod tui;
/// FXT version information.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");

9
bin/fxt/src/mcp/mod.rs Normal file
View File

@@ -0,0 +1,9 @@
//! MCP (Model Context Protocol) server for Foxhunt.
//!
//! Implements JSON-RPC 2.0 over stdin/stdout, allowing LLM agents
//! (Claude, Cursor, etc.) to interact with the Foxhunt trading system
//! programmatically.
pub mod protocol;
pub mod server;
pub mod tools;

132
bin/fxt/src/mcp/protocol.rs Normal file
View File

@@ -0,0 +1,132 @@
//! JSON-RPC 2.0 message types for the MCP protocol.
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// A JSON-RPC 2.0 request (or notification, when `id` is `None`).
#[derive(Debug, Deserialize)]
pub struct JsonRpcRequest {
/// Must be `"2.0"`.
pub jsonrpc: String,
/// Request identifier. `None` for notifications (no response expected).
pub id: Option<Value>,
/// The method to invoke (e.g. `"initialize"`, `"tools/list"`, `"tools/call"`).
pub method: String,
/// Method parameters. Defaults to `null` when absent.
#[serde(default)]
pub params: Value,
}
/// A JSON-RPC 2.0 response.
#[derive(Debug, Serialize)]
pub struct JsonRpcResponse {
/// Always `"2.0"`.
pub jsonrpc: String,
/// Mirrors the `id` from the request.
pub id: Value,
/// The result on success.
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
/// The error on failure.
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<JsonRpcError>,
}
/// A JSON-RPC 2.0 error object.
#[derive(Debug, Serialize)]
pub struct JsonRpcError {
/// Numeric error code (see JSON-RPC 2.0 spec).
pub code: i64,
/// Human-readable error message.
pub message: String,
/// Optional structured error data.
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
// ── Standard JSON-RPC 2.0 error codes ───────────────────────────────
/// The JSON sent is not a valid JSON-RPC request.
pub const INVALID_REQUEST: i64 = -32600;
/// The method does not exist or is not available.
pub const METHOD_NOT_FOUND: i64 = -32601;
/// Invalid method parameters.
pub const INVALID_PARAMS: i64 = -32602;
/// Internal server error.
pub const INTERNAL_ERROR: i64 = -32603;
// ── Helpers ─────────────────────────────────────────────────────────
/// Build a successful response.
pub fn success_response(id: Value, result: Value) -> JsonRpcResponse {
JsonRpcResponse {
jsonrpc: "2.0".into(),
id,
result: Some(result),
error: None,
}
}
/// Build an error response.
pub fn error_response(id: Value, code: i64, message: &str) -> JsonRpcResponse {
JsonRpcResponse {
jsonrpc: "2.0".into(),
id,
result: None,
error: Some(JsonRpcError {
code,
message: message.to_owned(),
data: None,
}),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn deserialize_request_with_id() {
let raw = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#;
let req: JsonRpcRequest = serde_json::from_str(raw).unwrap();
assert_eq!(req.jsonrpc, "2.0");
assert_eq!(req.method, "initialize");
assert_eq!(req.id, Some(json!(1)));
}
#[test]
fn deserialize_notification_no_id() {
let raw = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#;
let req: JsonRpcRequest = serde_json::from_str(raw).unwrap();
assert!(req.id.is_none());
assert_eq!(req.params, Value::Null);
}
#[test]
fn serialize_success_response() {
let resp = success_response(json!(1), json!({"ok": true}));
let s = serde_json::to_string(&resp).unwrap();
assert!(s.contains(r#""result":"#));
assert!(!s.contains(r#""error""#));
}
#[test]
fn serialize_error_response() {
let resp = error_response(json!(2), METHOD_NOT_FOUND, "Method not found");
let s = serde_json::to_string(&resp).unwrap();
assert!(s.contains(r#""error""#));
assert!(!s.contains(r#""result""#));
}
#[test]
fn deserialize_request_string_id() {
let raw = r#"{"jsonrpc":"2.0","id":"abc","method":"tools/list"}"#;
let req: JsonRpcRequest = serde_json::from_str(raw).unwrap();
assert_eq!(req.id, Some(json!("abc")));
}
}

543
bin/fxt/src/mcp/server.rs Normal file
View File

@@ -0,0 +1,543 @@
//! MCP server -- JSON-RPC 2.0 over stdin/stdout.
//!
//! Reads one JSON object per line from stdin, dispatches to the
//! appropriate handler, and writes the response to stdout.
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use super::protocol::{
error_response, success_response, JsonRpcRequest, JsonRpcResponse, INVALID_PARAMS,
INVALID_REQUEST, METHOD_NOT_FOUND,
};
use super::tools;
/// MCP server that exposes Foxhunt operations as tools.
pub struct McpServer {
/// API Gateway URL used for future gRPC calls.
api_url: String,
}
impl McpServer {
/// Create a new MCP server targeting the given API endpoint.
pub fn new(api_url: String) -> Self {
Self { api_url }
}
/// Run the server loop: read JSON lines from stdin, write responses to stdout.
///
/// Terminates when stdin reaches EOF.
pub async fn run(&self) -> anyhow::Result<()> {
let stdin = tokio::io::stdin();
let mut stdout = tokio::io::stdout();
let reader = BufReader::new(stdin);
let mut lines = reader.lines();
// Stderr for server diagnostics (never write diagnostics to stdout).
eprintln!("[fxt-mcp] server started, api_url={}", self.api_url);
loop {
let Some(line) = lines.next_line().await? else {
break; // EOF
};
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
// Parse the incoming JSON-RPC request.
let request: JsonRpcRequest = match serde_json::from_str(trimmed) {
Ok(r) => r,
Err(e) => {
let resp = error_response(
Value::Null,
INVALID_REQUEST,
&format!("invalid JSON: {e}"),
);
write_response(&mut stdout, &resp).await?;
continue;
}
};
// Notifications (no id) do not get a response.
let is_notification = request.id.is_none();
let response = self.handle_request(&request).await;
if !is_notification {
if let Some(resp) = response {
write_response(&mut stdout, &resp).await?;
}
}
}
eprintln!("[fxt-mcp] stdin closed, shutting down");
Ok(())
}
/// Dispatch a request to the appropriate handler.
///
/// Returns `None` for notifications that need no response.
async fn handle_request(&self, req: &JsonRpcRequest) -> Option<JsonRpcResponse> {
let id = req.id.clone().unwrap_or(Value::Null);
match req.method.as_str() {
"initialize" => Some(self.handle_initialize(id)),
"notifications/initialized" | "notifications/cancelled" => None,
"tools/list" => Some(self.handle_tools_list(id)),
"tools/call" => Some(self.handle_tools_call(id, &req.params).await),
_ => Some(error_response(
id,
METHOD_NOT_FOUND,
&format!("unknown method: {}", req.method),
)),
}
}
/// Handle `initialize` -- return server capabilities.
fn handle_initialize(&self, id: Value) -> JsonRpcResponse {
success_response(
id,
json!({
"protocolVersion": "2024-11-05",
"serverInfo": {
"name": "fxt",
"version": env!("CARGO_PKG_VERSION")
},
"capabilities": {
"tools": {}
}
}),
)
}
/// Handle `tools/list` -- enumerate all available tools.
fn handle_tools_list(&self, id: Value) -> JsonRpcResponse {
let tool_defs = tools::all_tools();
// Convert to the MCP wire format.
let tools_json: Vec<Value> = tool_defs
.iter()
.map(|t| {
json!({
"name": t.name,
"description": t.description,
"inputSchema": t.input_schema
})
})
.collect();
success_response(id, json!({ "tools": tools_json }))
}
/// Handle `tools/call` -- execute a tool and return the result.
///
/// Currently returns stub responses; real implementations will use
/// the same gRPC calls as the CLI commands.
async fn handle_tools_call(&self, id: Value, params: &Value) -> JsonRpcResponse {
let Some(tool_name) = params.get("name").and_then(Value::as_str) else {
return error_response(id, INVALID_PARAMS, "missing 'name' in tools/call params");
};
let arguments = params
.get("arguments")
.cloned()
.unwrap_or(Value::Object(serde_json::Map::new()));
// Verify the tool exists.
let known = tools::all_tools();
let tool_exists = known.iter().any(|t| t.name == tool_name);
if !tool_exists {
return error_response(
id,
INVALID_PARAMS,
&format!("unknown tool: {tool_name}"),
);
}
// Execute the tool (stub for now).
let result = self.execute_tool(tool_name, &arguments).await;
match result {
Ok(content) => success_response(
id,
json!({
"content": [{
"type": "text",
"text": content
}]
}),
),
Err(e) => {
// Tool execution failed -- return as tool error, not protocol error.
success_response(
id,
json!({
"content": [{
"type": "text",
"text": format!("error: {e}")
}],
"isError": true
}),
)
}
}
}
/// Execute a tool by name with the given arguments.
///
/// Stub implementation: returns a placeholder message for each tool.
/// Real implementations will use gRPC calls to the API Gateway,
/// reusing the same client logic as the CLI commands.
async fn execute_tool(
&self,
tool_name: &str,
arguments: &Value,
) -> Result<String, anyhow::Error> {
// Dispatch by tool name. For now, return informative stubs that
// indicate what will eventually happen.
let response = match tool_name {
// ── Service ─────────────────────────────────────────────
"fxt_service_list" => "stub: would list all services via gRPC ServiceDiscovery".into(),
"fxt_service_status" => {
let svc = arg_str(arguments, "service").unwrap_or_default();
format!("stub: would get status of service '{svc}' via gRPC")
}
"fxt_service_health" => {
"stub: would health-check all services via gRPC Health/Check".into()
}
// ── Training ────────────────────────────────────────────
"fxt_train_start" => {
let model = arg_str(arguments, "model").unwrap_or_default();
let symbol = arg_str(arguments, "symbol")
.unwrap_or_else(|| "ES.FUT".into());
format!("stub: would start training model={model} symbol={symbol} via gRPC MLTraining/StartTraining")
}
"fxt_train_stop" => {
let job_id = arg_str(arguments, "job_id").unwrap_or_default();
format!("stub: would stop training job={job_id} via gRPC MLTraining/StopTraining")
}
"fxt_train_status" => {
let job_id = arg_str(arguments, "job_id")
.unwrap_or_else(|| "latest".into());
format!(
"stub: would get training status job={job_id} via gRPC MLTraining/GetTrainingStatus"
)
}
"fxt_train_list" => {
"stub: would list training jobs via gRPC MLTraining/ListTrainingJobs".into()
}
// ── Trading ─────────────────────────────────────────────
"fxt_trade_positions" => {
"stub: would get positions via gRPC TradingService/GetPositions".into()
}
"fxt_trade_orders" => {
"stub: would list orders via gRPC TradingService/GetOrders".into()
}
"fxt_trade_account" => {
"stub: would get account state via gRPC TradingService/GetAccountState".into()
}
"fxt_trade_submit" => {
let symbol = arg_str(arguments, "symbol").unwrap_or_default();
let side = arg_str(arguments, "side").unwrap_or_default();
let qty = arguments
.get("quantity")
.and_then(Value::as_f64)
.unwrap_or(0.0);
let otype = arg_str(arguments, "order_type").unwrap_or_default();
format!(
"stub: would submit order symbol={symbol} side={side} qty={qty} type={otype} via gRPC TradingService/SubmitOrder"
)
}
// ── Models ──────────────────────────────────────────────
"fxt_model_list" => "stub: would list models via gRPC MLService/ListModels".into(),
"fxt_model_status" => {
let model = arg_str(arguments, "model").unwrap_or_default();
format!("stub: would get model status model={model} via gRPC MLService/GetModelStatus")
}
"fxt_model_predict" => {
let model = arg_str(arguments, "model").unwrap_or_default();
let symbol = arg_str(arguments, "symbol").unwrap_or_default();
format!(
"stub: would get prediction model={model} symbol={symbol} via gRPC MLService/Predict"
)
}
"fxt_model_ensemble" => {
let symbol = arg_str(arguments, "symbol").unwrap_or_default();
format!(
"stub: would get ensemble vote symbol={symbol} via gRPC MLService/GetEnsembleVote"
)
}
// ── Broker ──────────────────────────────────────────────
"fxt_broker_status" => {
"stub: would get broker status via gRPC BrokerGateway/GetSessionStatus".into()
}
"fxt_broker_connect" => {
"stub: would connect broker via gRPC BrokerGateway/Connect".into()
}
// ── Risk ────────────────────────────────────────────────
"fxt_risk_status" => {
"stub: would get risk status via gRPC RiskService/GetRiskStatus".into()
}
"fxt_risk_limits" => {
"stub: would get risk limits via gRPC RiskService/GetLimits".into()
}
"fxt_risk_drawdown" => {
"stub: would get drawdown stats via gRPC RiskService/GetDrawdown".into()
}
"fxt_risk_emergency" => {
let action = arg_str(arguments, "action").unwrap_or_default();
format!(
"stub: would {action} trading via gRPC RiskService/EmergencyAction"
)
}
// ── Data ────────────────────────────────────────────────
"fxt_data_status" => {
"stub: would get data status via gRPC DataAcquisition/GetStatus".into()
}
"fxt_data_feeds" => {
"stub: would list data feeds via gRPC DataAcquisition/ListFeeds".into()
}
// ── Cluster ─────────────────────────────────────────────
"fxt_cluster_status" => {
"stub: would get cluster status via gRPC Monitoring/GetClusterStatus".into()
}
"fxt_cluster_resources" => {
"stub: would get resource utilization via gRPC Monitoring/GetResources".into()
}
// ── Agent ───────────────────────────────────────────────
"fxt_agent_start" => {
"stub: would start agent via gRPC TradingAgent/StartAgent".into()
}
"fxt_agent_stop" => {
"stub: would stop agent via gRPC TradingAgent/StopAgent".into()
}
"fxt_agent_status" => {
"stub: would get agent status via gRPC TradingAgent/GetAgentStatus".into()
}
// ── Config ──────────────────────────────────────────────
"fxt_config_get" => {
let key = arg_str(arguments, "key").unwrap_or_default();
format!("stub: would get config key={key} via gRPC ConfigService/GetConfig")
}
"fxt_config_set" => {
let key = arg_str(arguments, "key").unwrap_or_default();
let val = arg_str(arguments, "value").unwrap_or_default();
format!(
"stub: would set config key={key} value={val} via gRPC ConfigService/SetConfig"
)
}
// ── Tune ────────────────────────────────────────────────
"fxt_tune_start" => {
let model = arg_str(arguments, "model").unwrap_or_default();
format!(
"stub: would start tuning model={model} via gRPC MLTraining/StartHyperopt"
)
}
"fxt_tune_status" => {
"stub: would get tuning progress via gRPC MLTraining/GetHyperoptStatus".into()
}
_ => format!("unknown tool: {tool_name}"),
};
Ok(response)
}
}
/// Extract a string argument from the arguments object.
fn arg_str(args: &Value, key: &str) -> Option<String> {
args.get(key).and_then(Value::as_str).map(String::from)
}
/// Write a JSON-RPC response as a single line to stdout.
async fn write_response(
writer: &mut tokio::io::Stdout,
response: &JsonRpcResponse,
) -> anyhow::Result<()> {
let serialized = serde_json::to_string(response)?;
writer.write_all(serialized.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn make_server() -> McpServer {
McpServer::new("http://localhost:9090".into())
}
fn make_request(method: &str, id: Option<Value>, params: Value) -> JsonRpcRequest {
JsonRpcRequest {
jsonrpc: "2.0".into(),
id,
method: method.into(),
params,
}
}
#[tokio::test]
async fn initialize_returns_capabilities() {
let server = make_server();
let req = make_request("initialize", Some(json!(1)), json!({}));
let resp = server.handle_request(&req).await.unwrap();
let result = resp.result.unwrap();
assert_eq!(result["protocolVersion"], "2024-11-05");
assert_eq!(result["serverInfo"]["name"], "fxt");
assert!(result["capabilities"]["tools"].is_object());
}
#[tokio::test]
async fn notification_returns_none() {
let server = make_server();
let req = make_request("notifications/initialized", None, json!({}));
let resp = server.handle_request(&req).await;
assert!(resp.is_none());
}
#[tokio::test]
async fn tools_list_returns_all_tools() {
let server = make_server();
let req = make_request("tools/list", Some(json!(2)), json!({}));
let resp = server.handle_request(&req).await.unwrap();
let result = resp.result.unwrap();
let tools_array = result["tools"].as_array().unwrap();
assert_eq!(tools_array.len(), 32);
// Each tool must have name, description, inputSchema.
for tool in tools_array {
assert!(tool["name"].is_string());
assert!(tool["description"].is_string());
assert!(tool["inputSchema"].is_object());
}
}
#[tokio::test]
async fn tools_call_known_tool() {
let server = make_server();
let req = make_request(
"tools/call",
Some(json!(3)),
json!({"name": "fxt_service_list", "arguments": {}}),
);
let resp = server.handle_request(&req).await.unwrap();
assert!(resp.error.is_none());
let result = resp.result.unwrap();
let text = result["content"][0]["text"].as_str().unwrap();
assert!(text.contains("stub"));
}
#[tokio::test]
async fn tools_call_unknown_tool() {
let server = make_server();
let req = make_request(
"tools/call",
Some(json!(4)),
json!({"name": "fxt_nonexistent", "arguments": {}}),
);
let resp = server.handle_request(&req).await.unwrap();
assert!(resp.error.is_some());
assert_eq!(resp.error.unwrap().code, INVALID_PARAMS);
}
#[tokio::test]
async fn tools_call_missing_name() {
let server = make_server();
let req = make_request("tools/call", Some(json!(5)), json!({"arguments": {}}));
let resp = server.handle_request(&req).await.unwrap();
assert!(resp.error.is_some());
assert_eq!(resp.error.unwrap().code, INVALID_PARAMS);
}
#[tokio::test]
async fn unknown_method_returns_error() {
let server = make_server();
let req = make_request("bogus/method", Some(json!(6)), json!({}));
let resp = server.handle_request(&req).await.unwrap();
assert!(resp.error.is_some());
assert_eq!(resp.error.unwrap().code, METHOD_NOT_FOUND);
}
#[tokio::test]
async fn tools_call_with_arguments() {
let server = make_server();
let req = make_request(
"tools/call",
Some(json!(7)),
json!({
"name": "fxt_train_start",
"arguments": {"model": "dqn", "symbol": "NQ.FUT"}
}),
);
let resp = server.handle_request(&req).await.unwrap();
let result = resp.result.unwrap();
let text = result["content"][0]["text"].as_str().unwrap();
assert!(text.contains("dqn"));
assert!(text.contains("NQ.FUT"));
}
#[tokio::test]
async fn tools_call_trade_submit() {
let server = make_server();
let req = make_request(
"tools/call",
Some(json!(8)),
json!({
"name": "fxt_trade_submit",
"arguments": {
"symbol": "ES.FUT",
"side": "buy",
"quantity": 1,
"order_type": "market"
}
}),
);
let resp = server.handle_request(&req).await.unwrap();
let result = resp.result.unwrap();
let text = result["content"][0]["text"].as_str().unwrap();
assert!(text.contains("ES.FUT"));
assert!(text.contains("buy"));
}
#[tokio::test]
async fn initialize_response_has_version() {
let server = make_server();
let req = make_request("initialize", Some(json!(9)), json!({}));
let resp = server.handle_request(&req).await.unwrap();
let result = resp.result.unwrap();
let version = result["serverInfo"]["version"].as_str().unwrap();
assert!(!version.is_empty());
}
#[tokio::test]
async fn tool_execution_risk_emergency() {
let server = make_server();
let req = make_request(
"tools/call",
Some(json!(10)),
json!({
"name": "fxt_risk_emergency",
"arguments": {"action": "halt"}
}),
);
let resp = server.handle_request(&req).await.unwrap();
let result = resp.result.unwrap();
let text = result["content"][0]["text"].as_str().unwrap();
assert!(text.contains("halt"));
}
}

361
bin/fxt/src/mcp/tools.rs Normal file
View File

@@ -0,0 +1,361 @@
//! MCP tool definitions -- maps CLI commands to tool descriptors.
use serde::Serialize;
use serde_json::{json, Value};
/// A single MCP tool descriptor.
#[derive(Debug, Clone, Serialize)]
pub struct ToolDefinition {
/// Unique tool name (e.g. `"fxt_service_list"`).
pub name: String,
/// Human-readable description of what the tool does.
pub description: String,
/// JSON Schema for the tool's input arguments.
#[serde(rename = "inputSchema")]
pub input_schema: Value,
}
/// Returns all available tool definitions.
///
/// Organised by domain: services, training, trading, models,
/// broker, risk, data, cluster, agent, config, tune.
pub fn all_tools() -> Vec<ToolDefinition> {
vec![
// ── Service operations ──────────────────────────────────────
tool(
"fxt_service_list",
"List all services with health status",
json!({"type": "object", "properties": {}}),
),
tool(
"fxt_service_status",
"Get detailed status of a specific service",
json!({
"type": "object",
"properties": {
"service": {"type": "string", "description": "Service name"}
},
"required": ["service"]
}),
),
tool(
"fxt_service_health",
"Health check all services",
json!({"type": "object", "properties": {}}),
),
// ── Training ────────────────────────────────────────────────
tool(
"fxt_train_start",
"Start ML training job",
json!({
"type": "object",
"properties": {
"model": {
"type": "string",
"description": "Model type (dqn, ppo, tft, mamba2, tggn, tlob, liquid, kan, xlstm, diffusion)"
},
"symbol": {
"type": "string",
"description": "Trading symbol (e.g. ES.FUT)"
}
},
"required": ["model"]
}),
),
tool(
"fxt_train_stop",
"Stop a training job",
json!({
"type": "object",
"properties": {
"job_id": {"type": "string", "description": "Training job identifier"}
},
"required": ["job_id"]
}),
),
tool(
"fxt_train_status",
"Get training job status and metrics",
json!({
"type": "object",
"properties": {
"job_id": {"type": "string", "description": "Training job identifier (omit for latest)"}
}
}),
),
tool(
"fxt_train_list",
"List all training jobs",
json!({"type": "object", "properties": {}}),
),
// ── Trading ─────────────────────────────────────────────────
tool(
"fxt_trade_positions",
"Get current positions",
json!({"type": "object", "properties": {}}),
),
tool(
"fxt_trade_orders",
"List active orders",
json!({"type": "object", "properties": {}}),
),
tool(
"fxt_trade_account",
"Get account state (balance, margin, P&L)",
json!({"type": "object", "properties": {}}),
),
tool(
"fxt_trade_submit",
"Place an order",
json!({
"type": "object",
"properties": {
"symbol": {"type": "string", "description": "Trading symbol (e.g. ES.FUT)"},
"side": {"type": "string", "enum": ["buy", "sell"], "description": "Order side"},
"quantity": {"type": "number", "description": "Order quantity"},
"order_type": {"type": "string", "enum": ["market", "limit"], "description": "Order type"},
"price": {"type": "number", "description": "Limit price (required for limit orders)"}
},
"required": ["symbol", "side", "quantity", "order_type"]
}),
),
// ── Model management ────────────────────────────────────────
tool(
"fxt_model_list",
"List available models",
json!({"type": "object", "properties": {}}),
),
tool(
"fxt_model_status",
"Get model health and performance metrics",
json!({
"type": "object",
"properties": {
"model": {"type": "string", "description": "Model name or identifier"}
},
"required": ["model"]
}),
),
tool(
"fxt_model_predict",
"Get model prediction for a symbol",
json!({
"type": "object",
"properties": {
"model": {"type": "string", "description": "Model name"},
"symbol": {"type": "string", "description": "Trading symbol"}
},
"required": ["model", "symbol"]
}),
),
tool(
"fxt_model_ensemble",
"Get ensemble vote (aggregated prediction from all models)",
json!({
"type": "object",
"properties": {
"symbol": {"type": "string", "description": "Trading symbol"}
},
"required": ["symbol"]
}),
),
// ── Broker ──────────────────────────────────────────────────
tool(
"fxt_broker_status",
"Broker session status (FIX, heartbeat RTT)",
json!({"type": "object", "properties": {}}),
),
tool(
"fxt_broker_connect",
"Establish broker session",
json!({"type": "object", "properties": {}}),
),
// ── Risk ────────────────────────────────────────────────────
tool(
"fxt_risk_status",
"Risk system state (kill switches, circuit breakers)",
json!({"type": "object", "properties": {}}),
),
tool(
"fxt_risk_limits",
"Current risk limits",
json!({"type": "object", "properties": {}}),
),
tool(
"fxt_risk_drawdown",
"Drawdown statistics",
json!({"type": "object", "properties": {}}),
),
tool(
"fxt_risk_emergency",
"Emergency controls (halt all trading or resume)",
json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["halt", "resume"],
"description": "Emergency action"
}
},
"required": ["action"]
}),
),
// ── Data ────────────────────────────────────────────────────
tool(
"fxt_data_status",
"Data feed status and freshness",
json!({"type": "object", "properties": {}}),
),
tool(
"fxt_data_feeds",
"Active feed list",
json!({"type": "object", "properties": {}}),
),
// ── Cluster ─────────────────────────────────────────────────
tool(
"fxt_cluster_status",
"Node/pod status",
json!({"type": "object", "properties": {}}),
),
tool(
"fxt_cluster_resources",
"CPU/RAM/GPU utilization",
json!({"type": "object", "properties": {}}),
),
// ── Agent ───────────────────────────────────────────────────
tool(
"fxt_agent_start",
"Start trading agent",
json!({"type": "object", "properties": {}}),
),
tool(
"fxt_agent_stop",
"Stop trading agent",
json!({"type": "object", "properties": {}}),
),
tool(
"fxt_agent_status",
"Trading agent state",
json!({"type": "object", "properties": {}}),
),
// ── Config ──────────────────────────────────────────────────
tool(
"fxt_config_get",
"Get configuration value",
json!({
"type": "object",
"properties": {
"key": {"type": "string", "description": "Configuration key"}
},
"required": ["key"]
}),
),
tool(
"fxt_config_set",
"Set configuration value",
json!({
"type": "object",
"properties": {
"key": {"type": "string", "description": "Configuration key"},
"value": {"type": "string", "description": "New value"}
},
"required": ["key", "value"]
}),
),
// ── Tune ────────────────────────────────────────────────────
tool(
"fxt_tune_start",
"Start hyperparameter tuning (PSO/Argmin)",
json!({
"type": "object",
"properties": {
"model": {
"type": "string",
"description": "Model type to tune"
}
},
"required": ["model"]
}),
),
tool(
"fxt_tune_status",
"Tuning progress and best parameters so far",
json!({"type": "object", "properties": {}}),
),
]
}
/// Helper to build a [`ToolDefinition`].
fn tool(name: &str, description: &str, input_schema: Value) -> ToolDefinition {
ToolDefinition {
name: name.to_owned(),
description: description.to_owned(),
input_schema,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn all_tools_non_empty() {
let tools = all_tools();
assert!(!tools.is_empty());
}
#[test]
fn all_tool_names_unique() {
let tools = all_tools();
let mut names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
let count = names.len();
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), count, "duplicate tool names found");
}
#[test]
fn tool_names_follow_convention() {
let tools = all_tools();
for t in &tools {
assert!(
t.name.starts_with("fxt_"),
"tool name '{}' must start with 'fxt_'",
t.name
);
}
}
#[test]
fn tool_schemas_are_objects() {
let tools = all_tools();
for t in &tools {
let ty = t.input_schema.get("type");
assert_eq!(
ty,
Some(&json!("object")),
"tool '{}' input_schema.type must be 'object'",
t.name
);
}
}
#[test]
fn tool_definitions_serialize() {
let tools = all_tools();
let first = tools.first().unwrap();
let json_str = serde_json::to_string(first).unwrap();
assert!(json_str.contains("inputSchema"));
assert!(json_str.contains(&first.name));
}
#[test]
fn expected_tool_count() {
// 3 service + 4 training + 4 trading + 4 model + 2 broker
// + 4 risk + 2 data + 2 cluster + 3 agent + 2 config + 2 tune = 32
let tools = all_tools();
assert_eq!(tools.len(), 32);
}
}

View File

@@ -0,0 +1,15 @@
//! Cockpit trait -- interface that every TUI view must implement.
use ratatui::Frame;
use ratatui::layout::Rect;
use super::state::AppState;
/// A single TUI view that can render itself into a terminal frame region.
pub trait Cockpit {
/// Human-readable name shown in the tab bar.
fn name(&self) -> &str;
/// Draw this cockpit into `area` using the current `state`.
fn render(&self, frame: &mut Frame, area: Rect, state: &AppState);
}

View File

@@ -0,0 +1,164 @@
#![allow(clippy::indexing_slicing)] // ratatui Layout::split indices match constraints
//! Data cockpit -- live feeds, cache stats, pipeline status.
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, Row, Table};
use crate::tui::cockpit::Cockpit;
use crate::tui::state::AppState;
use crate::tui::theme;
pub struct DataCockpit;
impl Cockpit for DataCockpit {
fn name(&self) -> &str {
"Data"
}
fn render(&self, frame: &mut Frame, area: Rect, state: &AppState) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(area);
render_feeds(frame, rows[0], state);
let bottom = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(rows[1]);
render_cache_stats(frame, bottom[0], state);
render_pipeline(frame, bottom[1], state);
}
}
fn render_feeds(frame: &mut Frame, area: Rect, state: &AppState) {
let header = Row::new(vec!["Symbol", "Records/s", "Latency", "Status"])
.style(theme::header_style());
let rows: Vec<Row> = state
.data_feeds
.iter()
.map(|f| {
let latency_style = if f.latency_us > 100 {
Style::default().fg(theme::ERROR)
} else if f.latency_us > 50 {
Style::default().fg(theme::WARNING)
} else {
Style::default().fg(theme::SUCCESS)
};
let status_style = if f.status == "LIVE" {
Style::default().fg(theme::SUCCESS)
} else {
Style::default().fg(theme::ERROR)
};
Row::new(vec![
Span::styled(f.symbol.clone(), Style::default().fg(theme::TEXT)),
Span::styled(
format!("{:>8}", format_number(f.records_per_sec)),
Style::default().fg(theme::HIGHLIGHT),
),
Span::styled(format!("{:>4}us", f.latency_us), latency_style),
Span::styled(f.status.clone(), status_style),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(10),
Constraint::Length(12),
Constraint::Length(10),
Constraint::Length(10),
],
)
.header(header)
.block(theme::block(" Active Data Feeds "));
frame.render_widget(table, area);
}
fn render_cache_stats(frame: &mut Frame, area: Rect, state: &AppState) {
let items = vec![
ListItem::new(Line::from(vec![
Span::styled("Total Records ", theme::muted_style()),
Span::styled(
format_number(state.data_cache.total_records),
Style::default().fg(theme::TEXT),
),
])),
ListItem::new(Line::from(vec![
Span::styled("Hit Rate ", theme::muted_style()),
Span::styled(
format!("{:.1}%", state.data_cache.cache_hit_rate),
Style::default().fg(theme::HIGHLIGHT),
),
])),
ListItem::new(Line::from(vec![
Span::styled("Disk Usage ", theme::muted_style()),
Span::styled(
format!("{:.1} GB", state.data_cache.disk_usage_gb),
Style::default().fg(theme::TEXT),
),
])),
ListItem::new(Line::from(vec![
Span::styled("Date Range ", theme::muted_style()),
Span::styled(
format!("{} -- {}", state.data_cache.oldest_date, state.data_cache.newest_date),
theme::muted_style(),
),
])),
];
let list = List::new(items).block(theme::block(" Data Cache "));
frame.render_widget(list, area);
}
fn render_pipeline(frame: &mut Frame, area: Rect, _state: &AppState) {
// Mock pipeline stages -- will be driven by gRPC later.
let items = vec![
ListItem::new(Line::from(vec![
Span::styled("Download ", theme::muted_style()),
Span::styled("IDLE", Style::default().fg(theme::MUTED)),
])),
ListItem::new(Line::from(vec![
Span::styled("Normalize ", theme::muted_style()),
Span::styled("IDLE", Style::default().fg(theme::MUTED)),
])),
ListItem::new(Line::from(vec![
Span::styled("Feature Gen ", theme::muted_style()),
Span::styled("RUNNING", Style::default().fg(theme::SUCCESS)),
])),
ListItem::new(Line::from(vec![
Span::styled("Validation ", theme::muted_style()),
Span::styled("IDLE", Style::default().fg(theme::MUTED)),
])),
];
let list = List::new(items).block(theme::block(" Pipeline Status "));
frame.render_widget(list, area);
}
/// Format a large number with thousand separators (e.g. 12_480 -> "12,480").
fn format_number(n: u64) -> String {
let s = n.to_string();
let bytes = s.as_bytes();
let len = bytes.len();
#[allow(clippy::integer_division)]
let cap = len + len / 3;
let mut result = String::with_capacity(cap);
for (i, &b) in bytes.iter().enumerate() {
if i > 0 && (len - i) % 3 == 0 {
result.push(',');
}
result.push(b as char);
}
result
}

View File

@@ -0,0 +1,8 @@
//! Individual cockpit view implementations.
pub mod data;
pub mod overview;
pub mod risk;
pub mod services;
pub mod trading;
pub mod training;

View File

@@ -0,0 +1,201 @@
#![allow(clippy::indexing_slicing)] // ratatui Layout::split indices match constraints
//! Overview cockpit -- four-quadrant summary dashboard.
//!
//! Top-left: Service health list
//! Top-right: CPU / RAM / GPU gauges
//! Bottom-left: Training summary table
//! Bottom-right: Portfolio summary
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Gauge, List, ListItem, Row, Table};
use crate::tui::cockpit::Cockpit;
use crate::tui::state::AppState;
use crate::tui::theme;
pub struct OverviewCockpit;
impl Cockpit for OverviewCockpit {
fn name(&self) -> &str {
"Overview"
}
fn render(&self, frame: &mut Frame, area: Rect, state: &AppState) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
let top = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(rows[0]);
let bottom = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(rows[1]);
render_services(frame, top[0], state);
render_resources(frame, top[1], state);
render_training_summary(frame, bottom[0], state);
render_portfolio(frame, bottom[1], state);
}
}
// ---------------------------------------------------------------------------
// Quadrant renderers
// ---------------------------------------------------------------------------
fn render_services(frame: &mut Frame, area: Rect, state: &AppState) {
let items: Vec<ListItem> = state
.services
.iter()
.map(|s| {
let dot = if s.healthy { "\u{25CF}" } else { "\u{25CB}" };
let style = theme::status_style(s.healthy);
ListItem::new(Line::from(vec![
Span::styled(format!("{dot} "), style),
Span::styled(&s.name, Style::default().fg(theme::TEXT)),
Span::styled(format!(" {}", s.uptime), theme::muted_style()),
]))
})
.collect();
let list = List::new(items).block(theme::block(" Services "));
frame.render_widget(list, area);
}
fn render_resources(frame: &mut Frame, area: Rect, state: &AppState) {
let block = theme::block(" Resources ");
let inner = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(2),
Constraint::Min(0),
])
.split(inner);
// CPU gauge
let cpu_pct = state.system_resources.cpu_usage_pct / 100.0;
let cpu_label = format!("CPU {:.1}%", state.system_resources.cpu_usage_pct);
let cpu_gauge = Gauge::default()
.gauge_style(Style::default().fg(theme::SECONDARY))
.label(cpu_label)
.ratio(cpu_pct.clamp(0.0, 1.0));
frame.render_widget(cpu_gauge, chunks[0]);
// RAM gauge
let ram_pct = if state.system_resources.ram_total_gb > 0.0 {
state.system_resources.ram_used_gb / state.system_resources.ram_total_gb
} else {
0.0
};
let ram_label = format!(
"RAM {:.1}/{:.0} GB",
state.system_resources.ram_used_gb, state.system_resources.ram_total_gb
);
let ram_gauge = Gauge::default()
.gauge_style(Style::default().fg(theme::PRIMARY))
.label(ram_label)
.ratio(ram_pct.clamp(0.0, 1.0));
frame.render_widget(ram_gauge, chunks[1]);
// GPU gauge
let gpu_pct = state.gpu.utilization / 100.0;
let gpu_label = format!("GPU {:.1}% {}", state.gpu.utilization, state.gpu.name);
let gpu_gauge = Gauge::default()
.gauge_style(Style::default().fg(theme::HIGHLIGHT))
.label(gpu_label)
.ratio(gpu_pct.clamp(0.0, 1.0));
frame.render_widget(gpu_gauge, chunks[2]);
}
fn render_training_summary(frame: &mut Frame, area: Rect, state: &AppState) {
let header = Row::new(vec!["Model", "Epoch", "Loss", "Sharpe"])
.style(theme::header_style());
let rows: Vec<Row> = state
.training_sessions
.iter()
.map(|s| {
Row::new(vec![
s.model.clone(),
format!("{}/{}", s.epoch, s.max_epochs),
format!("{:.4}", s.loss),
format!("{:.2}", s.sharpe),
])
.style(Style::default().fg(theme::TEXT))
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(10),
],
)
.header(header)
.block(theme::block(" Training "));
frame.render_widget(table, area);
}
fn render_portfolio(frame: &mut Frame, area: Rect, state: &AppState) {
let block = theme::block(" Portfolio ");
let inner = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(5), Constraint::Min(0)])
.split(inner);
// Account summary lines
let pnl_style = if state.account.daily_pnl >= 0.0 {
theme::status_style(true)
} else {
theme::status_style(false)
};
let summary_items = vec![
ListItem::new(Line::from(vec![
Span::styled("Equity ", theme::muted_style()),
Span::styled(
theme::format_usd(state.account.equity),
Style::default().fg(theme::TEXT),
),
])),
ListItem::new(Line::from(vec![
Span::styled("Cash ", theme::muted_style()),
Span::styled(
theme::format_usd(state.account.cash),
Style::default().fg(theme::TEXT),
),
])),
ListItem::new(Line::from(vec![
Span::styled("Daily P&L ", theme::muted_style()),
Span::styled(theme::format_usd_signed(state.account.daily_pnl), pnl_style),
])),
ListItem::new(Line::from(vec![
Span::styled("Positions ", theme::muted_style()),
Span::styled(
format!("{}", state.positions.len()),
Style::default().fg(theme::TEXT),
),
])),
];
let summary = List::new(summary_items);
frame.render_widget(summary, chunks[0]);
}

View File

@@ -0,0 +1,218 @@
#![allow(clippy::indexing_slicing)] // ratatui Layout::split indices match constraints
//! Risk cockpit -- kill switches, drawdown, circuit breakers, position limits.
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Gauge, List, ListItem, Row, Table};
use crate::tui::cockpit::Cockpit;
use crate::tui::state::AppState;
use crate::tui::theme;
pub struct RiskCockpit;
impl Cockpit for RiskCockpit {
fn name(&self) -> &str {
"Risk"
}
fn render(&self, frame: &mut Frame, area: Rect, state: &AppState) {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
let left = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(cols[0]);
let right = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(cols[1]);
render_kill_switches(frame, left[0], state);
render_drawdown(frame, left[1], state);
render_circuit_breakers(frame, right[0], state);
render_position_limits(frame, right[1], state);
}
}
fn render_kill_switches(frame: &mut Frame, area: Rect, state: &AppState) {
let switches = [
("Global ", state.risk.global_kill_switch),
("Portfolio ", state.risk.portfolio_kill_switch),
("Strategy ", state.risk.strategy_kill_switch),
("Instrument ", state.risk.instrument_kill_switch),
];
let items: Vec<ListItem> = switches
.iter()
.map(|(name, active)| {
let (label, style) = if *active {
("TRIPPED", Style::default().fg(theme::ERROR).add_modifier(Modifier::BOLD))
} else {
("SAFE", Style::default().fg(theme::SUCCESS))
};
ListItem::new(Line::from(vec![
Span::styled(*name, theme::muted_style()),
Span::styled(label, style),
]))
})
.collect();
let list = List::new(items).block(theme::block(" Kill Switches "));
frame.render_widget(list, area);
}
fn render_drawdown(frame: &mut Frame, area: Rect, state: &AppState) {
let block = theme::block(" Drawdown Monitor ");
let inner = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(2),
Constraint::Min(0),
])
.split(inner);
// Current drawdown gauge
let dd_pct = state.risk.current_drawdown_pct / 100.0;
let dd_color = if state.risk.current_drawdown_pct > 5.0 {
theme::ERROR
} else if state.risk.current_drawdown_pct > 3.0 {
theme::WARNING
} else {
theme::SUCCESS
};
let dd_gauge = Gauge::default()
.gauge_style(Style::default().fg(dd_color))
.label(format!(
"Current DD {:.1}%",
state.risk.current_drawdown_pct,
))
.ratio(dd_pct.clamp(0.0, 1.0));
frame.render_widget(dd_gauge, chunks[0]);
// Max drawdown
let max_dd_pct = state.risk.max_drawdown_pct / 100.0;
let max_dd_gauge = Gauge::default()
.gauge_style(Style::default().fg(theme::WARNING))
.label(format!("Max DD {:.1}%", state.risk.max_drawdown_pct))
.ratio(max_dd_pct.clamp(0.0, 1.0));
frame.render_widget(max_dd_gauge, chunks[1]);
// High water mark
let hwm_items = vec![ListItem::new(Line::from(vec![
Span::styled("High Water ", theme::muted_style()),
Span::styled(
theme::format_usd(state.risk.high_water_mark),
Style::default().fg(theme::HIGHLIGHT),
),
]))];
let hwm = List::new(hwm_items);
frame.render_widget(hwm, chunks[2]);
}
fn render_circuit_breakers(frame: &mut Frame, area: Rect, state: &AppState) {
let header = Row::new(vec!["Breaker", "Threshold", "Current", "Status"])
.style(theme::header_style());
let rows: Vec<Row> = state
.risk
.circuit_breakers
.iter()
.map(|cb| {
let status_text = if cb.tripped { "TRIPPED" } else { "OK" };
let status_style = if cb.tripped {
Style::default()
.fg(theme::ERROR)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme::SUCCESS)
};
Row::new(vec![
Span::styled(cb.name.clone(), Style::default().fg(theme::TEXT)),
Span::styled(format!("{:.1}", cb.threshold), theme::muted_style()),
Span::styled(
format!("{:.1}", cb.current),
Style::default().fg(theme::TEXT),
),
Span::styled((*status_text).to_owned(), status_style),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(18),
Constraint::Length(12),
Constraint::Length(10),
Constraint::Length(10),
],
)
.header(header)
.block(theme::block(" Circuit Breakers "));
frame.render_widget(table, area);
}
fn render_position_limits(frame: &mut Frame, area: Rect, state: &AppState) {
let header = Row::new(vec!["Symbol", "Max", "Current", "Usage"])
.style(theme::header_style());
let rows: Vec<Row> = state
.risk
.position_limits
.iter()
.map(|pl| {
let usage_pct = if pl.max_qty > 0 {
(pl.current_qty as f64 / pl.max_qty as f64) * 100.0
} else {
0.0
};
let usage_style = if usage_pct > 80.0 {
Style::default().fg(theme::ERROR)
} else if usage_pct > 50.0 {
Style::default().fg(theme::WARNING)
} else {
Style::default().fg(theme::SUCCESS)
};
Row::new(vec![
Span::styled(pl.symbol.clone(), Style::default().fg(theme::TEXT)),
Span::styled(format!("{}", pl.max_qty), theme::muted_style()),
Span::styled(
format!("{}", pl.current_qty),
Style::default().fg(theme::TEXT),
),
Span::styled(format!("{:.0}%", usage_pct), usage_style),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(10),
Constraint::Length(8),
Constraint::Length(10),
Constraint::Length(10),
],
)
.header(header)
.block(theme::block(" Position Limits "));
frame.render_widget(table, area);
}

View File

@@ -0,0 +1,145 @@
#![allow(clippy::indexing_slicing)] // ratatui Layout::split indices match constraints
//! Services cockpit -- service health grid, cluster events, resources.
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Gauge, List, ListItem, Row, Table};
use crate::tui::cockpit::Cockpit;
use crate::tui::state::AppState;
use crate::tui::theme;
pub struct ServicesCockpit;
impl Cockpit for ServicesCockpit {
fn name(&self) -> &str {
"Services"
}
fn render(&self, frame: &mut Frame, area: Rect, state: &AppState) {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(area);
render_service_grid(frame, cols[0], state);
let right = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(cols[1]);
render_cluster_events(frame, right[0], state);
render_cluster_resources(frame, right[1], state);
}
}
fn render_service_grid(frame: &mut Frame, area: Rect, state: &AppState) {
let header = Row::new(vec!["Service", "Status", "Uptime", "Version"])
.style(theme::header_style());
let rows: Vec<Row> = state
.services
.iter()
.map(|s| {
let status_text = if s.healthy { "HEALTHY" } else { "DOWN" };
let status_style = theme::status_style(s.healthy);
Row::new(vec![
Span::styled(s.name.clone(), Style::default().fg(theme::TEXT)),
Span::styled((*status_text).to_owned(), status_style),
Span::styled(s.uptime.clone(), theme::muted_style()),
Span::styled(s.version.clone(), theme::muted_style()),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(20),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(10),
],
)
.header(header)
.block(theme::block(" Service Health "));
frame.render_widget(table, area);
}
fn render_cluster_events(frame: &mut Frame, area: Rect, state: &AppState) {
let items: Vec<ListItem> = state
.cluster_events
.iter()
.map(|e| {
let kind_style = if e.kind == "Warning" {
theme::warning_style()
} else {
Style::default().fg(theme::SUCCESS)
};
ListItem::new(Line::from(vec![
Span::styled(format!("{} ", e.time), theme::muted_style()),
Span::styled(format!("[{}] ", e.kind), kind_style),
Span::styled(&e.message, Style::default().fg(theme::TEXT)),
]))
})
.collect();
let list = List::new(items).block(theme::block(" Cluster Events "));
frame.render_widget(list, area);
}
fn render_cluster_resources(frame: &mut Frame, area: Rect, state: &AppState) {
let block = theme::block(" Cluster Resources ");
let inner = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(2),
Constraint::Min(0),
])
.split(inner);
// CPU
let cpu_pct = state.system_resources.cpu_usage_pct / 100.0;
let cpu_gauge = Gauge::default()
.gauge_style(Style::default().fg(theme::SECONDARY))
.label(format!("CPU {:.1}%", state.system_resources.cpu_usage_pct))
.ratio(cpu_pct.clamp(0.0, 1.0));
frame.render_widget(cpu_gauge, chunks[0]);
// RAM
let ram_pct = if state.system_resources.ram_total_gb > 0.0 {
state.system_resources.ram_used_gb / state.system_resources.ram_total_gb
} else {
0.0
};
let ram_gauge = Gauge::default()
.gauge_style(Style::default().fg(theme::PRIMARY))
.label(format!(
"RAM {:.1}/{:.0} GB",
state.system_resources.ram_used_gb, state.system_resources.ram_total_gb,
))
.ratio(ram_pct.clamp(0.0, 1.0));
frame.render_widget(ram_gauge, chunks[1]);
// GPU cluster
let gpu_pct = state.system_resources.gpu_cluster_utilization / 100.0;
let gpu_gauge = Gauge::default()
.gauge_style(Style::default().fg(theme::HIGHLIGHT))
.label(format!(
"GPU {:.1}%",
state.system_resources.gpu_cluster_utilization,
))
.ratio(gpu_pct.clamp(0.0, 1.0));
frame.render_widget(gpu_gauge, chunks[2]);
}

View File

@@ -0,0 +1,211 @@
#![allow(clippy::indexing_slicing)] // ratatui Layout::split indices match constraints
//! Trading cockpit -- positions, executions, account, broker health.
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, Row, Table};
use crate::tui::cockpit::Cockpit;
use crate::tui::state::AppState;
use crate::tui::theme;
pub struct TradingCockpit;
impl Cockpit for TradingCockpit {
fn name(&self) -> &str {
"Trading"
}
fn render(&self, frame: &mut Frame, area: Rect, state: &AppState) {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(area);
let left = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(cols[0]);
let right = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(cols[1]);
render_positions(frame, left[0], state);
render_executions(frame, left[1], state);
render_account(frame, right[0], state);
render_broker_health(frame, right[1], state);
}
}
fn render_positions(frame: &mut Frame, area: Rect, state: &AppState) {
let header = Row::new(vec!["Symbol", "Qty", "Entry", "Market", "Unrl P&L"])
.style(theme::header_style());
let rows: Vec<Row> = state
.positions
.iter()
.map(|p| {
let pnl_style = if p.unrealized_pnl >= 0.0 {
theme::status_style(true)
} else {
theme::status_style(false)
};
Row::new(vec![
Span::styled(p.symbol.clone(), Style::default().fg(theme::TEXT)),
Span::styled(
format!("{}", p.qty),
Style::default().fg(if p.qty >= 0 {
theme::SECONDARY
} else {
theme::WARNING
}),
),
Span::styled(format!("{:.2}", p.entry_price), theme::muted_style()),
Span::styled(
format!("{:.2}", p.market_price),
Style::default().fg(theme::TEXT),
),
Span::styled(theme::format_usd_signed(p.unrealized_pnl), pnl_style),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(10),
Constraint::Length(6),
Constraint::Length(12),
Constraint::Length(12),
Constraint::Length(14),
],
)
.header(header)
.block(theme::block(" Positions "));
frame.render_widget(table, area);
}
fn render_executions(frame: &mut Frame, area: Rect, state: &AppState) {
let header = Row::new(vec!["Time", "Symbol", "Side", "Qty", "Price", "Status"])
.style(theme::header_style());
let rows: Vec<Row> = state
.executions
.iter()
.map(|e| {
let side_style = if e.side == "BUY" {
Style::default().fg(theme::SUCCESS)
} else {
Style::default().fg(theme::ERROR)
};
Row::new(vec![
Span::styled(e.time.clone(), theme::muted_style()),
Span::styled(e.symbol.clone(), Style::default().fg(theme::TEXT)),
Span::styled(e.side.clone(), side_style),
Span::styled(format!("{}", e.qty), Style::default().fg(theme::TEXT)),
Span::styled(format!("{:.2}", e.price), Style::default().fg(theme::TEXT)),
Span::styled(e.status.clone(), Style::default().fg(theme::SUCCESS)),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(6),
Constraint::Length(6),
Constraint::Length(12),
Constraint::Length(8),
],
)
.header(header)
.block(theme::block(" Recent Executions "));
frame.render_widget(table, area);
}
fn render_account(frame: &mut Frame, area: Rect, state: &AppState) {
let pnl_style = if state.account.daily_pnl >= 0.0 {
theme::status_style(true)
} else {
theme::status_style(false)
};
let total_pnl_style = if state.account.total_pnl >= 0.0 {
theme::status_style(true)
} else {
theme::status_style(false)
};
let items = vec![
ListItem::new(Line::from(vec![
Span::styled("Equity ", theme::muted_style()),
Span::styled(
theme::format_usd(state.account.equity),
Style::default().fg(theme::TEXT),
),
])),
ListItem::new(Line::from(vec![
Span::styled("Cash ", theme::muted_style()),
Span::styled(
theme::format_usd(state.account.cash),
Style::default().fg(theme::TEXT),
),
])),
ListItem::new(Line::from(vec![
Span::styled("Margin Used ", theme::muted_style()),
Span::styled(
theme::format_usd(state.account.margin_used),
Style::default().fg(theme::WARNING),
),
])),
ListItem::new(Line::from(vec![
Span::styled("Daily P&L ", theme::muted_style()),
Span::styled(theme::format_usd_signed(state.account.daily_pnl), pnl_style),
])),
ListItem::new(Line::from(vec![
Span::styled("Total P&L ", theme::muted_style()),
Span::styled(
theme::format_usd_signed(state.account.total_pnl),
total_pnl_style,
),
])),
];
let list = List::new(items).block(theme::block(" Account "));
frame.render_widget(list, area);
}
fn render_broker_health(frame: &mut Frame, area: Rect, _state: &AppState) {
// Mock broker health -- will be driven by gRPC later.
let items = vec![
ListItem::new(Line::from(vec![
Span::styled("FIX Session ", theme::muted_style()),
Span::styled("CONNECTED", Style::default().fg(theme::SUCCESS)),
])),
ListItem::new(Line::from(vec![
Span::styled("Heartbeat ", theme::muted_style()),
Span::styled("12ms", Style::default().fg(theme::HIGHLIGHT)),
])),
ListItem::new(Line::from(vec![
Span::styled("IB Gateway ", theme::muted_style()),
Span::styled("READY", Style::default().fg(theme::SUCCESS)),
])),
ListItem::new(Line::from(vec![
Span::styled("Order Router ", theme::muted_style()),
Span::styled("ACTIVE", Style::default().fg(theme::SUCCESS)),
])),
];
let list = List::new(items).block(theme::block(" Broker Health "));
frame.render_widget(list, area);
}

View File

@@ -0,0 +1,206 @@
#![allow(clippy::indexing_slicing)] // ratatui Layout::split indices match constraints
//! Training cockpit -- ML training session dashboard.
//!
//! Active sessions table, GPU panel, epoch financial metrics.
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Gauge, List, ListItem, Row, Table};
use crate::tui::cockpit::Cockpit;
use crate::tui::state::AppState;
use crate::tui::theme;
pub struct TrainingCockpit;
impl Cockpit for TrainingCockpit {
fn name(&self) -> &str {
"Training"
}
fn render(&self, frame: &mut Frame, area: Rect, state: &AppState) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(area);
render_sessions_table(frame, rows[0], state);
let bottom = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(rows[1]);
render_gpu_panel(frame, bottom[0], state);
render_epoch_metrics(frame, bottom[1], state);
}
}
fn render_sessions_table(frame: &mut Frame, area: Rect, state: &AppState) {
let header = Row::new(vec![
"Model", "Fold", "Epoch", "Loss", "Val Loss", "Batch/s", "Sharpe",
])
.style(theme::header_style());
let rows: Vec<Row> = state
.training_sessions
.iter()
.map(|s| {
Row::new(vec![
s.model.clone(),
format!("{}", s.fold),
format!("{}/{}", s.epoch, s.max_epochs),
format!("{:.4}", s.loss),
format!("{:.4}", s.val_loss),
format!("{:.1}", s.batches_per_sec),
format!("{:.2}", s.sharpe),
])
.style(Style::default().fg(theme::TEXT))
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(10),
Constraint::Length(6),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(10),
],
)
.header(header)
.block(theme::block(" Active Training Sessions "));
frame.render_widget(table, area);
}
fn render_gpu_panel(frame: &mut Frame, area: Rect, state: &AppState) {
let block = theme::block(" GPU ");
let inner = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(2),
Constraint::Min(0),
])
.split(inner);
// GPU name
let name_line = Line::from(vec![Span::styled(
&state.gpu.name,
Style::default()
.fg(theme::HIGHLIGHT)
.add_modifier(Modifier::BOLD),
)]);
frame.render_widget(
ratatui::widgets::Paragraph::new(name_line),
chunks[0],
);
// Utilization gauge
let util_pct = state.gpu.utilization / 100.0;
let util_gauge = Gauge::default()
.gauge_style(Style::default().fg(theme::SECONDARY))
.label(format!("Util {:.1}%", state.gpu.utilization))
.ratio(util_pct.clamp(0.0, 1.0));
frame.render_widget(util_gauge, chunks[1]);
// Memory gauge
let mem_pct = if state.gpu.memory_total_gb > 0.0 {
state.gpu.memory_used_gb / state.gpu.memory_total_gb
} else {
0.0
};
let mem_gauge = Gauge::default()
.gauge_style(Style::default().fg(theme::PRIMARY))
.label(format!(
"VRAM {:.1}/{:.0} GB",
state.gpu.memory_used_gb, state.gpu.memory_total_gb
))
.ratio(mem_pct.clamp(0.0, 1.0));
frame.render_widget(mem_gauge, chunks[2]);
// Temp + power
let temp_color = if state.gpu.temperature_c > 85 {
theme::ERROR
} else if state.gpu.temperature_c > 75 {
theme::WARNING
} else {
theme::SUCCESS
};
let info_items = vec![ListItem::new(Line::from(vec![
Span::styled(
format!("{}C", state.gpu.temperature_c),
Style::default().fg(temp_color),
),
Span::styled(" ", theme::muted_style()),
Span::styled(
format!("{}W / {}W", state.gpu.power_watts, state.gpu.power_limit_watts),
theme::muted_style(),
),
]))];
let info = List::new(info_items);
frame.render_widget(info, chunks[3]);
}
fn render_epoch_metrics(frame: &mut Frame, area: Rect, state: &AppState) {
let header = Row::new(vec!["Model", "Sharpe", "Sortino", "Win%", "MaxDD"])
.style(theme::header_style());
let rows: Vec<Row> = state
.training_sessions
.iter()
.map(|s| {
let dd_style = if s.max_dd > 0.10 {
Style::default().fg(theme::ERROR)
} else if s.max_dd > 0.05 {
Style::default().fg(theme::WARNING)
} else {
Style::default().fg(theme::SUCCESS)
};
Row::new(vec![
Span::styled(s.model.clone(), Style::default().fg(theme::TEXT)),
Span::styled(
format!("{:.2}", s.sharpe),
Style::default().fg(theme::HIGHLIGHT),
),
Span::styled(
format!("{:.2}", s.sortino),
Style::default().fg(theme::SECONDARY),
),
Span::styled(
format!("{:.1}%", s.win_rate * 100.0),
Style::default().fg(theme::TEXT),
),
Span::styled(format!("{:.1}%", s.max_dd * 100.0), dd_style),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(10),
],
)
.header(header)
.block(theme::block(" Epoch Financial Metrics "));
frame.render_widget(table, area);
}

View File

@@ -0,0 +1,279 @@
#![allow(clippy::indexing_slicing)] // ratatui Layout::split indices match constraints
//! TUI event loop -- terminal setup, input handling, rendering.
use std::io::{self, Stdout};
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use crossterm::ExecutableCommand;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use super::cockpit::Cockpit;
use super::cockpits::data::DataCockpit;
use super::cockpits::overview::OverviewCockpit;
use super::cockpits::risk::RiskCockpit;
use super::cockpits::services::ServicesCockpit;
use super::cockpits::trading::TradingCockpit;
use super::cockpits::training::TrainingCockpit;
use super::state::AppState;
use super::theme;
/// Number of cockpit views.
const COCKPIT_COUNT: usize = 6;
/// Tick interval for the event loop (1 second).
const TICK_RATE: Duration = Duration::from_secs(1);
/// Run the TUI event loop.
///
/// `_api_url` is accepted for future gRPC streaming but is unused today
/// (mock data only).
pub async fn run(_api_url: &str) -> anyhow::Result<()> {
// Setup terminal
let mut terminal = setup_terminal()?;
let result = event_loop(&mut terminal).await;
// Always restore terminal, even on error
teardown_terminal(&mut terminal)?;
result
}
// ---------------------------------------------------------------------------
// Terminal lifecycle
// ---------------------------------------------------------------------------
fn setup_terminal() -> anyhow::Result<Terminal<CrosstermBackend<Stdout>>> {
enable_raw_mode()?;
io::stdout().execute(EnterAlternateScreen)?;
let backend = CrosstermBackend::new(io::stdout());
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
fn teardown_terminal(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
) -> anyhow::Result<()> {
disable_raw_mode()?;
terminal.backend_mut().execute(LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
// ---------------------------------------------------------------------------
// Event loop
// ---------------------------------------------------------------------------
async fn event_loop(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
) -> anyhow::Result<()> {
let mut state = AppState::default();
let cockpits: Vec<Box<dyn Cockpit>> = vec![
Box::new(OverviewCockpit),
Box::new(TrainingCockpit),
Box::new(TradingCockpit),
Box::new(ServicesCockpit),
Box::new(RiskCockpit),
Box::new(DataCockpit),
];
loop {
// Render
terminal.draw(|frame| {
let size = frame.area();
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // tab bar
Constraint::Min(0), // cockpit body
Constraint::Length(1), // status bar
])
.split(size);
render_tab_bar(frame, layout[0], &cockpits, state.current_cockpit);
if let Some(cockpit) = cockpits.get(state.current_cockpit) {
cockpit.render(frame, layout[1], &state);
}
render_status_bar(frame, layout[2], &state);
if state.show_help {
render_help_overlay(frame, size);
}
})?;
// Poll for events with timeout
if crossterm::event::poll(TICK_RATE)? {
if let Event::Key(key) = event::read()? {
#[allow(clippy::wildcard_enum_match_arm)]
match key.code {
// Quit
KeyCode::Char('q') | KeyCode::Esc => break,
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
break;
}
// Cockpit switching (1-6)
KeyCode::Char(c @ '1'..='6') => {
let idx = (c as usize).saturating_sub('1' as usize);
if idx < COCKPIT_COUNT {
state.current_cockpit = idx;
}
}
// Help toggle
KeyCode::Char('?') => {
state.show_help = !state.show_help;
}
// Refresh
KeyCode::Char('r') => {
state.last_update = std::time::Instant::now();
}
_ => {}
}
}
}
// Tick -- update timestamp (data refresh will happen here later)
state.last_update = std::time::Instant::now();
}
Ok(())
}
// ---------------------------------------------------------------------------
// Chrome renderers
// ---------------------------------------------------------------------------
fn render_tab_bar(
frame: &mut ratatui::Frame,
area: Rect,
cockpits: &[Box<dyn Cockpit>],
active: usize,
) {
let mut spans = Vec::new();
spans.push(Span::styled(" FXT ", Style::default().fg(theme::PRIMARY).add_modifier(Modifier::BOLD)));
spans.push(Span::styled(" | ", theme::muted_style()));
for (i, cockpit) in cockpits.iter().enumerate() {
let num = format!("{}", i + 1);
if i == active {
spans.push(Span::styled(
format!("[{num}] {}", cockpit.name()),
Style::default()
.fg(theme::HIGHLIGHT)
.add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::styled(
format!(" {num} {}", cockpit.name()),
theme::muted_style(),
));
}
if i + 1 < cockpits.len() {
spans.push(Span::styled(" ", theme::muted_style()));
}
}
let tab_line = Line::from(spans);
let paragraph = Paragraph::new(tab_line);
frame.render_widget(paragraph, area);
}
fn render_status_bar(frame: &mut ratatui::Frame, area: Rect, state: &AppState) {
let elapsed = state.last_update.elapsed();
let ago = if elapsed.as_secs() < 2 {
"just now".to_owned()
} else {
format!("{}s ago", elapsed.as_secs())
};
let line = Line::from(vec![
Span::styled(" q", Style::default().fg(theme::SECONDARY)),
Span::styled(" quit ", theme::muted_style()),
Span::styled("?", Style::default().fg(theme::SECONDARY)),
Span::styled(" help ", theme::muted_style()),
Span::styled("r", Style::default().fg(theme::SECONDARY)),
Span::styled(" refresh ", theme::muted_style()),
Span::styled("1-6", Style::default().fg(theme::SECONDARY)),
Span::styled(" switch cockpit ", theme::muted_style()),
Span::styled(
format!("Updated: {ago}"),
theme::muted_style(),
),
]);
let paragraph = Paragraph::new(line);
frame.render_widget(paragraph, area);
}
fn render_help_overlay(frame: &mut ratatui::Frame, area: Rect) {
// Center a help box in the terminal
let width = 50_u16.min(area.width.saturating_sub(4));
let height = 14_u16.min(area.height.saturating_sub(4));
#[allow(clippy::integer_division)]
let x = area.x + (area.width.saturating_sub(width)) / 2;
#[allow(clippy::integer_division)]
let y = area.y + (area.height.saturating_sub(height)) / 2;
let help_area = Rect::new(x, y, width, height);
// Clear behind the overlay
frame.render_widget(Clear, help_area);
let help_text = vec![
Line::from(""),
Line::from(vec![
Span::styled(" 1-6 ", Style::default().fg(theme::SECONDARY)),
Span::styled("Switch cockpit view", Style::default().fg(theme::TEXT)),
]),
Line::from(vec![
Span::styled(" q ", Style::default().fg(theme::SECONDARY)),
Span::styled("Quit", Style::default().fg(theme::TEXT)),
]),
Line::from(vec![
Span::styled(" Esc ", Style::default().fg(theme::SECONDARY)),
Span::styled("Quit", Style::default().fg(theme::TEXT)),
]),
Line::from(vec![
Span::styled(" ? ", Style::default().fg(theme::SECONDARY)),
Span::styled("Toggle this help", Style::default().fg(theme::TEXT)),
]),
Line::from(vec![
Span::styled(" r ", Style::default().fg(theme::SECONDARY)),
Span::styled("Force refresh", Style::default().fg(theme::TEXT)),
]),
Line::from(vec![
Span::styled(" C-c ", Style::default().fg(theme::SECONDARY)),
Span::styled("Quit (Ctrl+C)", Style::default().fg(theme::TEXT)),
]),
Line::from(""),
Line::from(vec![Span::styled(
" Cockpits: Overview | Training | Trading",
theme::muted_style(),
)]),
Line::from(vec![Span::styled(
" Services | Risk | Data",
theme::muted_style(),
)]),
];
let help_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::PRIMARY))
.title(" Keyboard Shortcuts ")
.title_style(theme::title_style());
let help = Paragraph::new(help_text).block(help_block).wrap(Wrap { trim: false });
frame.render_widget(help, help_area);
}

10
bin/fxt/src/tui/mod.rs Normal file
View File

@@ -0,0 +1,10 @@
//! TUI cockpit dashboard system.
//!
//! Six purpose-built cockpit views for monitoring the Foxhunt trading
//! platform: Overview, Training, Trading, Services, Risk, and Data.
pub mod cockpit;
pub mod cockpits;
pub mod event_loop;
pub mod state;
pub mod theme;

480
bin/fxt/src/tui/state.rs Normal file
View File

@@ -0,0 +1,480 @@
//! Application state -- view-model structs consumed by every cockpit.
//!
//! These are intentionally decoupled from protobuf types so the TUI layer
//! has zero coupling to the gRPC schema.
use std::time::Instant;
// ---------------------------------------------------------------------------
// Top-level state
// ---------------------------------------------------------------------------
/// Root state shared across all six cockpit views.
pub struct AppState {
/// Active cockpit index (0..=5).
pub current_cockpit: usize,
/// Whether the help overlay is visible.
pub show_help: bool,
/// ML training sessions.
pub training_sessions: Vec<TrainingSessionData>,
/// GPU telemetry.
pub gpu: GpuData,
/// Service health statuses.
pub services: Vec<ServiceData>,
/// Open trading positions.
pub positions: Vec<PositionData>,
/// Recent trade executions.
pub executions: Vec<ExecutionData>,
/// Account-level summary.
pub account: AccountData,
/// Aggregated risk metrics.
pub risk: RiskData,
/// Host / cluster resource usage.
pub system_resources: SystemResources,
/// Live data-feed information.
pub data_feeds: Vec<DataFeedData>,
/// Data cache statistics.
pub data_cache: DataCacheData,
/// K8s cluster events.
pub cluster_events: Vec<ClusterEventData>,
/// Timestamp of the last data refresh.
pub last_update: Instant,
}
impl Default for AppState {
fn default() -> Self {
Self {
current_cockpit: 0,
show_help: false,
training_sessions: vec![
TrainingSessionData {
model: "TFT".into(),
fold: 1,
epoch: 12,
max_epochs: 50,
loss: 0.0342,
val_loss: 0.0387,
batches_per_sec: 148.2,
sharpe: 1.87,
sortino: 2.41,
win_rate: 0.584,
max_dd: 0.062,
},
TrainingSessionData {
model: "Mamba2".into(),
fold: 3,
epoch: 28,
max_epochs: 50,
loss: 0.0218,
val_loss: 0.0241,
batches_per_sec: 312.5,
sharpe: 2.14,
sortino: 3.02,
win_rate: 0.612,
max_dd: 0.048,
},
TrainingSessionData {
model: "DQN".into(),
fold: 2,
epoch: 45,
max_epochs: 100,
loss: 0.0891,
val_loss: 0.0923,
batches_per_sec: 524.0,
sharpe: 1.42,
sortino: 1.89,
win_rate: 0.551,
max_dd: 0.074,
},
],
gpu: GpuData {
name: "NVIDIA L40S".into(),
utilization: 87.3,
memory_used_gb: 38.2,
memory_total_gb: 48.0,
temperature_c: 72,
power_watts: 285,
power_limit_watts: 350,
},
services: vec![
ServiceData::new("api-gateway", true),
ServiceData::new("trading-service", true),
ServiceData::new("ml-training", true),
ServiceData::new("trading-agent", true),
ServiceData::new("broker-gateway", false),
ServiceData::new("data-acquisition", true),
ServiceData::new("risk-service", true),
ServiceData::new("web-gateway", true),
],
positions: vec![
PositionData {
symbol: "ES.FUT".into(),
qty: 2,
entry_price: 5842.50,
market_price: 5856.25,
unrealized_pnl: 687.50,
},
PositionData {
symbol: "NQ.FUT".into(),
qty: -1,
entry_price: 21_340.00,
market_price: 21_285.00,
unrealized_pnl: 1100.00,
},
PositionData {
symbol: "6E.FUT".into(),
qty: 3,
entry_price: 1.0842,
market_price: 1.0831,
unrealized_pnl: -412.50,
},
],
executions: vec![
ExecutionData {
time: "14:32:18".into(),
symbol: "ES.FUT".into(),
side: "BUY".into(),
qty: 1,
price: 5842.50,
status: "FILLED".into(),
},
ExecutionData {
time: "14:28:04".into(),
symbol: "NQ.FUT".into(),
side: "SELL".into(),
qty: 1,
price: 21_340.00,
status: "FILLED".into(),
},
ExecutionData {
time: "14:15:22".into(),
symbol: "6E.FUT".into(),
side: "BUY".into(),
qty: 3,
price: 1.0842,
status: "FILLED".into(),
},
],
account: AccountData {
equity: 1_250_000.0,
cash: 980_000.0,
margin_used: 270_000.0,
daily_pnl: 4_375.0,
total_pnl: 52_180.0,
},
risk: RiskData {
global_kill_switch: false,
portfolio_kill_switch: false,
strategy_kill_switch: false,
instrument_kill_switch: false,
current_drawdown_pct: 2.1,
max_drawdown_pct: 7.4,
high_water_mark: 1_275_000.0,
circuit_breakers: vec![
CircuitBreakerData {
name: "Daily Loss Limit".into(),
threshold: 25_000.0,
current: 4_375.0,
tripped: false,
},
CircuitBreakerData {
name: "Max Position Size".into(),
threshold: 10.0,
current: 6.0,
tripped: false,
},
CircuitBreakerData {
name: "Volatility Guard".into(),
threshold: 3.0,
current: 1.8,
tripped: false,
},
],
position_limits: vec![
PositionLimitData {
symbol: "ES.FUT".into(),
max_qty: 10,
current_qty: 2,
},
PositionLimitData {
symbol: "NQ.FUT".into(),
max_qty: 5,
current_qty: 1,
},
PositionLimitData {
symbol: "6E.FUT".into(),
max_qty: 8,
current_qty: 3,
},
],
},
system_resources: SystemResources {
cpu_usage_pct: 34.2,
ram_used_gb: 48.6,
ram_total_gb: 128.0,
gpu_cluster_utilization: 87.3,
},
data_feeds: vec![
DataFeedData {
symbol: "ES.FUT".into(),
records_per_sec: 12_480,
latency_us: 42,
status: "LIVE".into(),
},
DataFeedData {
symbol: "NQ.FUT".into(),
records_per_sec: 9_870,
latency_us: 38,
status: "LIVE".into(),
},
DataFeedData {
symbol: "6E.FUT".into(),
records_per_sec: 4_210,
latency_us: 51,
status: "LIVE".into(),
},
DataFeedData {
symbol: "ZN.FUT".into(),
records_per_sec: 6_340,
latency_us: 45,
status: "LIVE".into(),
},
],
data_cache: DataCacheData {
total_records: 42_800_000,
cache_hit_rate: 94.7,
disk_usage_gb: 12.4,
oldest_date: "2024-01-02".into(),
newest_date: "2026-03-03".into(),
},
cluster_events: vec![
ClusterEventData {
time: "14:30:00".into(),
kind: "Normal".into(),
reason: "Scheduled".into(),
message: "Successfully assigned pod ml-training-0".into(),
},
ClusterEventData {
time: "14:25:12".into(),
kind: "Normal".into(),
reason: "Pulled".into(),
message: "Container image already present on machine".into(),
},
ClusterEventData {
time: "14:20:45".into(),
kind: "Warning".into(),
reason: "BackOff".into(),
message: "Back-off restarting failed container broker-gw".into(),
},
],
last_update: Instant::now(),
}
}
}
// ---------------------------------------------------------------------------
// Data sub-structs
// ---------------------------------------------------------------------------
/// A single ML training session.
pub struct TrainingSessionData {
pub model: String,
pub fold: u32,
pub epoch: u32,
pub max_epochs: u32,
pub loss: f64,
pub val_loss: f64,
pub batches_per_sec: f64,
pub sharpe: f64,
pub sortino: f64,
pub win_rate: f64,
pub max_dd: f64,
}
/// GPU telemetry snapshot.
pub struct GpuData {
pub name: String,
pub utilization: f64,
pub memory_used_gb: f64,
pub memory_total_gb: f64,
pub temperature_c: u32,
pub power_watts: u32,
pub power_limit_watts: u32,
}
impl Default for GpuData {
fn default() -> Self {
Self {
name: "N/A".into(),
utilization: 0.0,
memory_used_gb: 0.0,
memory_total_gb: 0.0,
temperature_c: 0,
power_watts: 0,
power_limit_watts: 0,
}
}
}
/// A backend microservice.
pub struct ServiceData {
pub name: String,
pub healthy: bool,
pub uptime: String,
pub version: String,
}
impl ServiceData {
pub fn new(name: &str, healthy: bool) -> Self {
Self {
name: name.into(),
healthy,
uptime: if healthy {
"3d 14h".into()
} else {
"DOWN".into()
},
version: "0.1.0".into(),
}
}
}
/// An open trading position.
pub struct PositionData {
pub symbol: String,
pub qty: i64,
pub entry_price: f64,
pub market_price: f64,
pub unrealized_pnl: f64,
}
/// A recent trade execution.
pub struct ExecutionData {
pub time: String,
pub symbol: String,
pub side: String,
pub qty: i64,
pub price: f64,
pub status: String,
}
/// Account-level financials.
pub struct AccountData {
pub equity: f64,
pub cash: f64,
pub margin_used: f64,
pub daily_pnl: f64,
pub total_pnl: f64,
}
impl Default for AccountData {
fn default() -> Self {
Self {
equity: 0.0,
cash: 0.0,
margin_used: 0.0,
daily_pnl: 0.0,
total_pnl: 0.0,
}
}
}
/// Kill-switch, drawdown, circuit-breaker, and position-limit data.
pub struct RiskData {
pub global_kill_switch: bool,
pub portfolio_kill_switch: bool,
pub strategy_kill_switch: bool,
pub instrument_kill_switch: bool,
pub current_drawdown_pct: f64,
pub max_drawdown_pct: f64,
pub high_water_mark: f64,
pub circuit_breakers: Vec<CircuitBreakerData>,
pub position_limits: Vec<PositionLimitData>,
}
impl Default for RiskData {
fn default() -> Self {
Self {
global_kill_switch: false,
portfolio_kill_switch: false,
strategy_kill_switch: false,
instrument_kill_switch: false,
current_drawdown_pct: 0.0,
max_drawdown_pct: 0.0,
high_water_mark: 0.0,
circuit_breakers: Vec::new(),
position_limits: Vec::new(),
}
}
}
/// A single circuit breaker.
pub struct CircuitBreakerData {
pub name: String,
pub threshold: f64,
pub current: f64,
pub tripped: bool,
}
/// Position-limit on a single instrument.
pub struct PositionLimitData {
pub symbol: String,
pub max_qty: i64,
pub current_qty: i64,
}
/// System resource metrics.
pub struct SystemResources {
pub cpu_usage_pct: f64,
pub ram_used_gb: f64,
pub ram_total_gb: f64,
pub gpu_cluster_utilization: f64,
}
impl Default for SystemResources {
fn default() -> Self {
Self {
cpu_usage_pct: 0.0,
ram_used_gb: 0.0,
ram_total_gb: 0.0,
gpu_cluster_utilization: 0.0,
}
}
}
/// A single live data feed.
pub struct DataFeedData {
pub symbol: String,
pub records_per_sec: u64,
pub latency_us: u64,
pub status: String,
}
/// Aggregate data cache statistics.
pub struct DataCacheData {
pub total_records: u64,
pub cache_hit_rate: f64,
pub disk_usage_gb: f64,
pub oldest_date: String,
pub newest_date: String,
}
impl Default for DataCacheData {
fn default() -> Self {
Self {
total_records: 0,
cache_hit_rate: 0.0,
disk_usage_gb: 0.0,
oldest_date: String::new(),
newest_date: String::new(),
}
}
}
/// A K8s cluster event.
pub struct ClusterEventData {
pub time: String,
pub kind: String,
pub reason: String,
pub message: String,
}

140
bin/fxt/src/tui/theme.rs Normal file
View File

@@ -0,0 +1,140 @@
//! Color theme and style helpers for the Foxhunt TUI.
//!
//! All colors are derived from the Foxhunt brand: purple + cyan on dark navy.
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders};
// ---------------------------------------------------------------------------
// Brand palette
// ---------------------------------------------------------------------------
/// Dark navy background (#0F0F23).
pub const BG: Color = Color::Rgb(15, 15, 35);
/// Slightly lighter surface (#1A1A2E).
pub const SURFACE: Color = Color::Rgb(26, 26, 46);
/// Primary text -- light gray (#E0E0E0).
pub const TEXT: Color = Color::Rgb(224, 224, 224);
/// Primary accent -- purple (#8B5CF6).
pub const PRIMARY: Color = Color::Rgb(139, 92, 246);
/// Secondary accent -- cyan (#06B6D4).
pub const SECONDARY: Color = Color::Rgb(6, 182, 212);
/// Success -- green (#10B981).
pub const SUCCESS: Color = Color::Rgb(16, 185, 129);
/// Warning -- amber (#F59E0B).
pub const WARNING: Color = Color::Rgb(245, 158, 11);
/// Error -- red (#EF4444).
pub const ERROR: Color = Color::Rgb(239, 68, 68);
/// Muted text -- gray (#6B7280).
pub const MUTED: Color = Color::Rgb(107, 114, 128);
/// Border -- dim purple (#4C1D95).
pub const BORDER: Color = Color::Rgb(76, 29, 149);
/// Highlight -- bright cyan (#22D3EE).
pub const HIGHLIGHT: Color = Color::Rgb(34, 211, 238);
// ---------------------------------------------------------------------------
// Style helpers
// ---------------------------------------------------------------------------
/// Bold primary-colored text for titles.
pub fn title_style() -> Style {
Style::default().fg(PRIMARY).add_modifier(Modifier::BOLD)
}
/// Dim purple border style.
pub fn border_style() -> Style {
Style::default().fg(BORDER)
}
/// Green for healthy, red for unhealthy.
pub fn status_style(healthy: bool) -> Style {
if healthy {
Style::default().fg(SUCCESS)
} else {
Style::default().fg(ERROR)
}
}
/// Header row style for tables -- secondary accent, bold.
pub fn header_style() -> Style {
Style::default()
.fg(SECONDARY)
.add_modifier(Modifier::BOLD)
}
/// Standard bordered block with a purple title.
pub fn block(title: &str) -> Block<'_> {
Block::default()
.borders(Borders::ALL)
.border_style(border_style())
.title(title.to_owned())
.title_style(title_style())
}
/// Muted text style.
pub fn muted_style() -> Style {
Style::default().fg(MUTED)
}
/// Accent style for highlighted values.
pub fn accent_style() -> Style {
Style::default().fg(HIGHLIGHT)
}
/// Warning style.
pub fn warning_style() -> Style {
Style::default().fg(WARNING)
}
// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------
/// Format a float as a dollar string with thousand separators and 2 decimal places.
/// Example: `format_usd(1_250_000.0)` => `"$1,250,000.00"`.
pub fn format_usd(value: f64) -> String {
let abs = value.abs();
let integer_part = abs as u64;
let frac = abs - integer_part as f64;
let int_str = format_u64_with_commas(integer_part);
let sign = if value < 0.0 { "-" } else { "" };
format!("{sign}${int_str}.{:02}", (frac * 100.0).round() as u64)
}
/// Format a float as a signed dollar string with thousand separators.
/// Example: `format_usd_signed(-4375.0)` => `"-$4,375.00"`.
pub fn format_usd_signed(value: f64) -> String {
let abs = value.abs();
let integer_part = abs as u64;
let frac = abs - integer_part as f64;
let int_str = format_u64_with_commas(integer_part);
let sign = if value < 0.0 { "-" } else { "+" };
format!("{sign}${int_str}.{:02}", (frac * 100.0).round() as u64)
}
/// Format a u64 with thousand separators.
fn format_u64_with_commas(n: u64) -> String {
let s = n.to_string();
let bytes = s.as_bytes();
let len = bytes.len();
#[allow(clippy::integer_division)]
let cap = len + len / 3;
let mut result = String::with_capacity(cap);
for (i, &b) in bytes.iter().enumerate() {
if i > 0 && (len - i) % 3 == 0 {
result.push(',');
}
result.push(b as char);
}
result
}