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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
9
bin/fxt/src/mcp/mod.rs
Normal 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
132
bin/fxt/src/mcp/protocol.rs
Normal 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
543
bin/fxt/src/mcp/server.rs
Normal 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
361
bin/fxt/src/mcp/tools.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
15
bin/fxt/src/tui/cockpit.rs
Normal file
15
bin/fxt/src/tui/cockpit.rs
Normal 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);
|
||||
}
|
||||
164
bin/fxt/src/tui/cockpits/data.rs
Normal file
164
bin/fxt/src/tui/cockpits/data.rs
Normal 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
|
||||
}
|
||||
8
bin/fxt/src/tui/cockpits/mod.rs
Normal file
8
bin/fxt/src/tui/cockpits/mod.rs
Normal 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;
|
||||
201
bin/fxt/src/tui/cockpits/overview.rs
Normal file
201
bin/fxt/src/tui/cockpits/overview.rs
Normal 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]);
|
||||
}
|
||||
218
bin/fxt/src/tui/cockpits/risk.rs
Normal file
218
bin/fxt/src/tui/cockpits/risk.rs
Normal 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);
|
||||
}
|
||||
145
bin/fxt/src/tui/cockpits/services.rs
Normal file
145
bin/fxt/src/tui/cockpits/services.rs
Normal 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]);
|
||||
}
|
||||
211
bin/fxt/src/tui/cockpits/trading.rs
Normal file
211
bin/fxt/src/tui/cockpits/trading.rs
Normal 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);
|
||||
}
|
||||
206
bin/fxt/src/tui/cockpits/training.rs
Normal file
206
bin/fxt/src/tui/cockpits/training.rs
Normal 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);
|
||||
}
|
||||
279
bin/fxt/src/tui/event_loop.rs
Normal file
279
bin/fxt/src/tui/event_loop.rs
Normal 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
10
bin/fxt/src/tui/mod.rs
Normal 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
480
bin/fxt/src/tui/state.rs
Normal 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
140
bin/fxt/src/tui/theme.rs
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user