feat: add auth gRPC service + migrate dashboard from REST/WS to grpc-web

Backend (Rust):
- Create proto/auth.proto with Login and RefreshToken RPCs
- Implement AuthGrpcService in services/api with JWT issuance
- Register as unauthenticated service in main.rs (pre-auth)
- Update JWT issuer from foxhunt-api-gateway to foxhunt-api

Dashboard (TypeScript):
- Install @connectrpc/connect-web + @bufbuild/protobuf
- Generate TypeScript proto clients via buf (src/gen/)
- Create grpc.ts transport with JWT interceptor
- Rewrite all useApi hooks to typed gRPC calls
- Replace WebSocket with useGrpcStream server-streaming hooks
- Migrate all 6 pages + 6 components to grpc-web
- Delete api.ts, websocket.ts, useWebSocket.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-05 00:05:18 +01:00
parent 57e22c01a8
commit 84877e5146
44 changed files with 22151 additions and 728 deletions

35
proto/auth.proto Normal file
View File

@@ -0,0 +1,35 @@
syntax = "proto3";
package auth;
// Authentication service — token issuance and refresh.
// This service is exposed WITHOUT the auth interceptor (pre-authentication).
service AuthService {
// Authenticate with username/password, receive JWT tokens.
rpc Login(LoginRequest) returns (LoginResponse);
// Exchange a refresh token for a new access token.
rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse);
}
message LoginRequest {
string username = 1;
string password = 2;
}
message LoginResponse {
string access_token = 1;
string refresh_token = 2;
// Unix timestamp (seconds) when the access token expires.
int64 expires_at = 3;
}
message RefreshTokenRequest {
string refresh_token = 1;
}
message RefreshTokenResponse {
string access_token = 1;
string refresh_token = 2;
int64 expires_at = 3;
}

View File

@@ -179,6 +179,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
&["../../proto"],
)?;
// Compile Auth Service proto (package: auth) — server only (no client needed)
config
.clone()
.build_server(true)
.build_client(false)
.file_descriptor_set_path(out_dir.join("auth_descriptor.bin"))
.compile_well_known_types(true)
.extern_path(".google.protobuf", "::prost_types")
.type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]")
.server_mod_attribute(".", "#[allow(unused_qualifications)]")
.client_mod_attribute(".", "#[allow(unused_qualifications)]")
.compile_protos(
&["../../proto/auth.proto"],
&["../../proto"],
)?;
println!("cargo:rerun-if-changed=../../proto/fxt_trading.proto");
println!("cargo:rerun-if-changed=../../proto/trading.proto");
println!("cargo:rerun-if-changed=../../proto/risk.proto");
@@ -189,6 +205,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("cargo:rerun-if-changed=../../proto/broker_gateway.proto");
println!("cargo:rerun-if-changed=../../proto/data_acquisition.proto");
println!("cargo:rerun-if-changed=../../proto/ml.proto");
println!("cargo:rerun-if-changed=../../proto/auth.proto");
Ok(())
}

View File

@@ -0,0 +1,201 @@
//! gRPC AuthService implementation — Login and RefreshToken RPCs.
//!
//! This service is exposed WITHOUT the auth interceptor since it handles
//! pre-authentication token issuance.
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use tonic::{Request, Response, Status};
use tracing::{error, info, warn};
use uuid::Uuid;
use crate::auth::jwt::service::{JwtClaims, JwtConfig};
use crate::auth_proto::auth_service_server::AuthService;
use crate::auth_proto::{
LoginRequest, LoginResponse, RefreshTokenRequest, RefreshTokenResponse,
};
/// Access token lifetime: 1 hour (seconds).
const ACCESS_TOKEN_EXPIRY_SECS: u64 = 3600;
/// Refresh token lifetime: 24 hours (seconds).
const REFRESH_TOKEN_EXPIRY_SECS: u64 = 86400;
/// gRPC implementation of `auth.AuthService`.
pub struct AuthGrpcService {
jwt_config: Arc<JwtConfig>,
}
impl AuthGrpcService {
/// Create a new `AuthGrpcService` backed by the given JWT configuration.
pub fn new(jwt_config: Arc<JwtConfig>) -> Self {
Self { jwt_config }
}
/// Validate user credentials.
///
/// Checks the `USER_CREDENTIALS` environment variable (format: `user:pass`)
/// first, then falls back to a hard-coded development user (`admin` / `admin`).
fn validate_credentials(username: &str, password: &str) -> Result<(), Status> {
// Check USER_CREDENTIALS env var (format: "user:pass")
if let Ok(creds) = std::env::var("USER_CREDENTIALS") {
if let Some((env_user, env_pass)) = creds.split_once(':') {
if username == env_user && password == env_pass {
return Ok(());
}
}
// Env var set but credentials don't match
return Err(Status::unauthenticated("invalid credentials"));
}
// Development fallback: admin/admin
if username == "admin" && password == "admin" {
warn!("Login accepted using hard-coded dev credentials (set USER_CREDENTIALS for production)");
return Ok(());
}
Err(Status::unauthenticated("invalid credentials"))
}
/// Build and sign a JWT token (access or refresh).
fn issue_token(
&self,
subject: &str,
token_type: &str,
session_id: &str,
expiry_secs: u64,
) -> Result<(String, u64), Status> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| {
error!("System time error: {}", e);
Status::internal("system time error")
})?
.as_secs();
let exp = now.checked_add(expiry_secs).ok_or_else(|| {
error!("Overflow computing token expiry");
Status::internal("token expiry overflow")
})?;
let claims = JwtClaims {
jti: Uuid::new_v4().to_string(),
sub: subject.to_string(),
iat: now,
exp,
iss: self.jwt_config.jwt_issuer.clone(),
aud: self.jwt_config.jwt_audience.clone(),
roles: vec!["user".to_string()],
permissions: vec![],
token_type: token_type.to_string(),
session_id: Some(session_id.to_string()),
};
let header = Header::new(Algorithm::HS256);
let key = EncodingKey::from_secret(self.jwt_config.jwt_secret.as_bytes());
let token = encode(&header, &claims, &key).map_err(|e| {
error!("Failed to encode JWT: {}", e);
Status::internal("failed to issue token")
})?;
Ok((token, exp))
}
/// Decode and validate a refresh token, returning its claims.
fn decode_refresh_token(&self, token: &str) -> Result<JwtClaims, Status> {
if token.is_empty() {
return Err(Status::invalid_argument("refresh token is empty"));
}
if token.len() > 8192 {
return Err(Status::invalid_argument("refresh token too long"));
}
let key = DecodingKey::from_secret(self.jwt_config.jwt_secret.as_bytes());
let mut validation = Validation::new(Algorithm::HS256);
validation.set_issuer(&[&self.jwt_config.jwt_issuer]);
validation.set_audience(&[&self.jwt_config.jwt_audience]);
validation.validate_exp = true;
validation.validate_nbf = false;
validation.leeway = 10;
validation.validate_aud = true;
let token_data = decode::<JwtClaims>(token, &key, &validation).map_err(|e| {
warn!("Refresh token decode failed: {}", e);
Status::unauthenticated("invalid refresh token")
})?;
if token_data.claims.token_type != "refresh" {
return Err(Status::unauthenticated(
"token is not a refresh token",
));
}
if token_data.claims.sub.is_empty() {
return Err(Status::unauthenticated("refresh token has empty subject"));
}
Ok(token_data.claims)
}
}
#[tonic::async_trait]
impl AuthService for AuthGrpcService {
async fn login(
&self,
request: Request<LoginRequest>,
) -> Result<Response<LoginResponse>, Status> {
let req = request.into_inner();
if req.username.is_empty() || req.password.is_empty() {
return Err(Status::invalid_argument(
"username and password are required",
));
}
Self::validate_credentials(&req.username, &req.password)?;
let session_id = Uuid::new_v4().to_string();
let (access_token, expires_at) =
self.issue_token(&req.username, "access", &session_id, ACCESS_TOKEN_EXPIRY_SECS)?;
let (refresh_token, _) =
self.issue_token(&req.username, "refresh", &session_id, REFRESH_TOKEN_EXPIRY_SECS)?;
info!(user = %req.username, "Login successful, tokens issued");
Ok(Response::new(LoginResponse {
access_token,
refresh_token,
expires_at: i64::try_from(expires_at).unwrap_or(i64::MAX),
}))
}
async fn refresh_token(
&self,
request: Request<RefreshTokenRequest>,
) -> Result<Response<RefreshTokenResponse>, Status> {
let req = request.into_inner();
let claims = self.decode_refresh_token(&req.refresh_token)?;
let session_id = claims
.session_id
.unwrap_or_else(|| Uuid::new_v4().to_string());
let (access_token, expires_at) =
self.issue_token(&claims.sub, "access", &session_id, ACCESS_TOKEN_EXPIRY_SECS)?;
let (refresh_token, _) =
self.issue_token(&claims.sub, "refresh", &session_id, REFRESH_TOKEN_EXPIRY_SECS)?;
info!(user = %claims.sub, "Token refreshed successfully");
Ok(Response::new(RefreshTokenResponse {
access_token,
refresh_token,
expires_at: i64::try_from(expires_at).unwrap_or(i64::MAX),
}))
}
}

View File

@@ -103,7 +103,7 @@ impl JwtConfig {
Ok(Self {
jwt_secret,
jwt_issuer: "foxhunt-api-gateway".to_string(),
jwt_issuer: "foxhunt-api".to_string(),
jwt_audience: "foxhunt-services".to_string(),
})
}
@@ -454,7 +454,7 @@ mod tests {
.await
.expect("Should create config with valid JWT_SECRET");
assert_eq!(config.jwt_issuer, "foxhunt-api-gateway");
assert_eq!(config.jwt_issuer, "foxhunt-api");
assert_eq!(config.jwt_audience, "foxhunt-services");
assert!(config.jwt_secret.len() >= 64);
@@ -484,7 +484,7 @@ mod tests {
match result {
Ok(config) => {
// Verify config was loaded successfully
assert_eq!(config.jwt_issuer, "foxhunt-api-gateway");
assert_eq!(config.jwt_issuer, "foxhunt-api");
assert_eq!(config.jwt_audience, "foxhunt-services");
assert!(config.jwt_secret.len() >= 64);

View File

@@ -17,6 +17,7 @@
//! - Authorization: <100ns (in-memory cache)
//! - Rate limiting: <50ns (atomic counters)
pub mod grpc_login;
pub mod interceptor;
pub mod jwt;
#[cfg(feature = "mfa")]

View File

@@ -39,6 +39,8 @@ pub const DATA_ACQUISITION_FILE_DESCRIPTOR_SET: &[u8] =
tonic::include_file_descriptor_set!("data_acquisition_descriptor");
pub const ML_FILE_DESCRIPTOR_SET: &[u8] =
tonic::include_file_descriptor_set!("ml_descriptor");
pub const AUTH_FILE_DESCRIPTOR_SET: &[u8] =
tonic::include_file_descriptor_set!("auth_descriptor");
// Include generated protobuf code FIRST (needed by other modules)
pub mod foxhunt {
@@ -91,6 +93,11 @@ pub mod ml_inference {
tonic::include_proto!("ml");
}
// Auth Service proto (server-only, unauthenticated)
pub mod auth_proto {
tonic::include_proto!("auth");
}
// Error module MUST come before modules that use it
pub mod error;

View File

@@ -470,6 +470,7 @@ async fn main() -> Result<()> {
};
// Build gRPC server with all services
use api::auth_proto::auth_service_server::AuthServiceServer;
use api::foxhunt::tli::{
backtesting_service_server::BacktestingServiceServer,
trading_service_server::TradingServiceServer,
@@ -633,13 +634,18 @@ async fn main() -> Result<()> {
.register_encoded_file_descriptor_set(api::BROKER_GATEWAY_FILE_DESCRIPTOR_SET)
.register_encoded_file_descriptor_set(api::DATA_ACQUISITION_FILE_DESCRIPTOR_SET)
.register_encoded_file_descriptor_set(api::ML_FILE_DESCRIPTOR_SET)
.register_encoded_file_descriptor_set(api::AUTH_FILE_DESCRIPTOR_SET)
.build_v1()
.map_err(|e| anyhow::anyhow!("Failed to build reflection service: {}", e))?;
// Add health + reflection services
// Create unauthenticated auth service (Login / RefreshToken -- pre-authentication)
let auth_grpc_service = api::auth::grpc_login::AuthGrpcService::new(Arc::new(jwt_config));
// Add health + reflection services, then unauthenticated auth service
let mut router = server_builder
.add_service(health_service)
.add_service(reflection_service);
.add_service(reflection_service)
.add_service(AuthServiceServer::new(auth_grpc_service));
// Always add trading service (required) with authentication
router = router.add_service(TradingServiceServer::with_interceptor(

View File

@@ -0,0 +1,7 @@
version: v2
plugins:
- local: protoc-gen-es
out: src/gen
opt: target=ts
inputs:
- directory: ../proto

View File

@@ -8,6 +8,9 @@
"name": "web-dashboard",
"version": "0.0.0",
"dependencies": {
"@bufbuild/protobuf": "^2.11.0",
"@connectrpc/connect": "^2.1.1",
"@connectrpc/connect-web": "^2.1.1",
"@tailwindcss/vite": "^4.2.0",
"@tanstack/react-query": "^5.90.21",
"lightweight-charts": "^5.1.0",
@@ -19,6 +22,8 @@
"zustand": "^5.0.11"
},
"devDependencies": {
"@bufbuild/buf": "^1.66.0",
"@bufbuild/protoc-gen-es": "^2.11.0",
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
@@ -315,6 +320,226 @@
"node": ">=6.9.0"
}
},
"node_modules/@bufbuild/buf": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@bufbuild/buf/-/buf-1.66.0.tgz",
"integrity": "sha512-JSZT6OT8uKG2frScYNOS3Y4E+7wg1KSPQMKfLlbZZFnBoXwMqDdsxQF7O5ucameU+3DKRs+yQFI8tNQjFJ5T2g==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"bin": {
"buf": "bin/buf",
"protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking",
"protoc-gen-buf-lint": "bin/protoc-gen-buf-lint"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@bufbuild/buf-darwin-arm64": "1.66.0",
"@bufbuild/buf-darwin-x64": "1.66.0",
"@bufbuild/buf-linux-aarch64": "1.66.0",
"@bufbuild/buf-linux-armv7": "1.66.0",
"@bufbuild/buf-linux-x64": "1.66.0",
"@bufbuild/buf-win32-arm64": "1.66.0",
"@bufbuild/buf-win32-x64": "1.66.0"
}
},
"node_modules/@bufbuild/buf-darwin-arm64": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.66.0.tgz",
"integrity": "sha512-C9orXX4SSVYKEumWFEO2T6xKrXsrvij0uS8kR20sHt9MYuFke14y6DfnAvXINPZrNpDiyOTldG7BPQtR40poRw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/buf-darwin-x64": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.66.0.tgz",
"integrity": "sha512-5I33S9UbimoBNCpOdMsmJHcW9NfxzKW5PsVrSyajpo+LL22Lfyha2ZO3JNhT/k3ZlGVvtpDAS9pRS0ouNKmGgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/buf-linux-aarch64": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.66.0.tgz",
"integrity": "sha512-KFz9HukP3ACKfSe0fo+XbxndQR0kKCVLVfUkoc6qPUTrKn9lACnah6HlUtBlwanK95vg+MIMvy0DWoLYUX9cuA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/buf-linux-armv7": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-armv7/-/buf-linux-armv7-1.66.0.tgz",
"integrity": "sha512-W56iEZtnqB4ww9JCyhIipPrwZVe/zQ5I0pmWrJF8Daw67qvRYtvP3NSxtF5f0yAoB0tQAXDoqIiB/Ypl1UOz0g==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/buf-linux-x64": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.66.0.tgz",
"integrity": "sha512-6AYYyc2O32jnDjjHbyW9Oqcehd+PMw+34qVo+wGfaEgGwC+fyoL2oxcEmzsPyEvhY4mkYJRXaCb8p90Ndzd2vQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/buf-win32-arm64": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.66.0.tgz",
"integrity": "sha512-S+CD5NZ6q0HCI0g0PTVypjSQ63LsLm1Svd7yG1MjKz9mQ83Vl6rYXuzmYYVi2cww7w5tDew8sYASZETewvEuxg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/buf-win32-x64": {
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.66.0.tgz",
"integrity": "sha512-uSPoWRaX8Qd5pHZt6zzRuZXr4Sdbqe4Pm1PmMax/SGUPacVpH1v9AGX1/BTgx+g+b4vLS5T4SGxTl9ngkQaY3Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@bufbuild/protoc-gen-es": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-2.11.0.tgz",
"integrity": "sha512-VzQuwEQDXipbZ1soWUuAWm1Z0C3B/IDWGeysnbX6ogJ6As91C2mdvAND/ekQ4YIWgen4d5nqLfIBOWLqCCjYUA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "2.11.0",
"@bufbuild/protoplugin": "2.11.0"
},
"bin": {
"protoc-gen-es": "bin/protoc-gen-es"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@bufbuild/protobuf": "2.11.0"
},
"peerDependenciesMeta": {
"@bufbuild/protobuf": {
"optional": true
}
}
},
"node_modules/@bufbuild/protoplugin": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.11.0.tgz",
"integrity": "sha512-lyZVNFUHArIOt4W0+dwYBe5GBwbKzbOy8ObaloEqsw9Mmiwv2O48TwddDoHN4itylC+BaEGqFdI1W8WQt2vWJQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "2.11.0",
"@typescript/vfs": "^1.6.2",
"typescript": "5.4.5"
}
},
"node_modules/@bufbuild/protoplugin/node_modules/typescript": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/@connectrpc/connect": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.1.tgz",
"integrity": "sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ==",
"license": "Apache-2.0",
"peerDependencies": {
"@bufbuild/protobuf": "^2.7.0"
}
},
"node_modules/@connectrpc/connect-web": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@connectrpc/connect-web/-/connect-web-2.1.1.tgz",
"integrity": "sha512-J8317Q2MaFRCT1jzVR1o06bZhDIBmU0UAzWx6xOIXzOq8+k71/+k7MUF7AwcBUX+34WIvbm5syRgC5HXQA8fOg==",
"license": "Apache-2.0",
"peerDependencies": {
"@bufbuild/protobuf": "^2.7.0",
"@connectrpc/connect": "2.1.1"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
@@ -2087,6 +2312,19 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@typescript/vfs": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz",
"integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.4.3"
},
"peerDependencies": {
"typescript": "*"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz",

View File

@@ -7,9 +7,13 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"generate": "buf generate"
},
"dependencies": {
"@bufbuild/protobuf": "^2.11.0",
"@connectrpc/connect": "^2.1.1",
"@connectrpc/connect-web": "^2.1.1",
"@tailwindcss/vite": "^4.2.0",
"@tanstack/react-query": "^5.90.21",
"lightweight-charts": "^5.1.0",
@@ -21,6 +25,8 @@
"zustand": "^5.0.11"
},
"devDependencies": {
"@bufbuild/buf": "^1.66.0",
"@bufbuild/protoc-gen-es": "^2.11.0",
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",

View File

@@ -1,12 +1,9 @@
import { useWebSocket } from '../../hooks/useWebSocket';
import { useAuth } from '../../hooks/useAuth';
import { getTokenClaims } from '../../lib/auth';
import { wsManager } from '../../lib/websocket';
const EMPTY_TOPICS: string[] = [];
import { useMetricsStream } from '../../hooks/useGrpcStream';
export function StatusBar() {
const { connected } = useWebSocket(EMPTY_TOPICS);
const { connected } = useMetricsStream();
const { authenticated, logout } = useAuth();
const claims = authenticated ? getTokenClaims() : null;
@@ -20,18 +17,7 @@ export function StatusBar() {
}`}
aria-hidden="true"
/>
WS: {connected ? 'Connected' : (
<>
Disconnected
<button
onClick={() => wsManager.reconnect()}
className="ml-1 px-1.5 py-0.5 rounded text-[var(--color-accent)] hover:bg-[var(--color-accent)]/10 transition-colors"
aria-label="Reconnect WebSocket"
>
Reconnect
</button>
</>
)}
gRPC: {connected ? 'Connected' : 'Disconnected'}
</span>
{authenticated && claims && (
<span>User: {claims.sub}</span>

View File

@@ -1,17 +1,33 @@
import type { TrainingJob } from '../../lib/types';
import type { TrainingJobSummary } from '../../gen/ml_training_pb';
import { TrainingStatus } from '../../gen/ml_training_pb';
interface Props {
jobs?: TrainingJob[];
jobs?: TrainingJobSummary[];
loading?: boolean;
}
function statusStr(status: TrainingStatus): string {
switch (status) {
case TrainingStatus.PENDING: return 'queued';
case TrainingStatus.RUNNING: return 'running';
case TrainingStatus.COMPLETED: return 'completed';
case TrainingStatus.FAILED: return 'failed';
case TrainingStatus.STOPPED: return 'stopped';
default: return 'unknown';
}
}
export function TrainingProgress({ jobs = [], loading }: Props) {
const runningCount = jobs.filter(
(j) => j.status === TrainingStatus.RUNNING,
).length;
return (
<div>
<div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--color-border)]">
<span className="text-sm font-medium">Training Jobs</span>
<span className="text-xs text-[var(--color-text-secondary)]">
{jobs.filter((j) => j.status === 'running').length} running
{runningCount} running
</span>
</div>
@@ -27,37 +43,40 @@ export function TrainingProgress({ jobs = [], loading }: Props) {
</div>
)}
{jobs.map((job) => (
<div key={job.job_id} className="px-3 py-2 space-y-1">
<div key={job.jobId} className="px-3 py-2 space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-medium">{job.model_type}</span>
<span className="text-xs font-medium">{job.modelType}</span>
<span className="text-xs font-mono text-[var(--color-text-secondary)]">
{job.job_id.slice(0, 8)}
{job.jobId.slice(0, 8)}
</span>
</div>
<StatusBadge status={job.status} />
</div>
{/* Progress bar */}
{/* Progress bar — use description or a placeholder since proto doesn't have progress field */}
<div className="w-full bg-[var(--color-bg-primary)] rounded-full h-1.5">
<div
className={`h-1.5 rounded-full transition-all ${
job.status === 'failed'
job.status === TrainingStatus.FAILED
? 'bg-[var(--color-red)]'
: job.status === 'completed'
: job.status === TrainingStatus.COMPLETED
? 'bg-[var(--color-green)]'
: 'bg-[var(--color-accent)]'
}`}
style={{ width: `${job.progress * 100}%` }}
style={{
width: job.status === TrainingStatus.COMPLETED
? '100%'
: job.status === TrainingStatus.RUNNING
? '50%'
: '0%',
}}
/>
</div>
<div className="flex justify-between text-xs text-[var(--color-text-secondary)]">
<span>
Epoch {job.epoch}/{job.total_epochs}
</span>
<span>Loss: {job.loss.toFixed(4)}</span>
<span>{(job.progress * 100).toFixed(0)}%</span>
<span>{job.modelType}</span>
<span>{job.description || statusStr(job.status)}</span>
</div>
</div>
))}
@@ -66,18 +85,19 @@ export function TrainingProgress({ jobs = [], loading }: Props) {
);
}
function StatusBadge({ status }: { status: TrainingJob['status'] }) {
const styles = {
function StatusBadge({ status }: { status: TrainingStatus }) {
const label = statusStr(status);
const styles: Record<string, string> = {
queued: 'text-[var(--color-text-secondary)] bg-slate-500/10',
running: 'text-[var(--color-accent)] bg-blue-500/10',
completed: 'text-[var(--color-green)] bg-green-500/10',
failed: 'text-[var(--color-red)] bg-red-500/10',
stopped: 'text-[var(--color-yellow)] bg-yellow-500/10',
}[status];
};
return (
<span className={`text-xs px-2 py-0.5 rounded ${styles}`}>
{status}
<span className={`text-xs px-2 py-0.5 rounded ${styles[label] ?? ''}`}>
{label}
</span>
);
}

View File

@@ -1,15 +1,15 @@
import { useState } from 'react';
import { useApiPost } from '../../hooks/useApi';
import { useEmergencyStop } from '../../hooks/useApi';
export function EmergencyControls() {
const [confirmText, setConfirmText] = useState('');
const [showConfirm, setShowConfirm] = useState(false);
const emergencyStop = useApiPost('/risk/emergency-stop');
const emergencyStop = useEmergencyStop();
function handleEmergencyStop() {
if (confirmText === 'STOP') {
emergencyStop.mutate({});
emergencyStop.mutate({ reason: 'Manual emergency stop from dashboard' });
setShowConfirm(false);
setConfirmText('');
emergencyStop.reset();
@@ -26,7 +26,7 @@ export function EmergencyControls() {
{!showConfirm ? (
<button
onClick={() => setShowConfirm(true)}
aria-label="Trigger emergency stop cancels all orders and closes all positions"
aria-label="Trigger emergency stop -- cancels all orders and closes all positions"
className="w-full py-3 text-sm font-bold rounded bg-[var(--color-red)] text-white hover:bg-red-600 transition-colors"
>
EMERGENCY STOP

View File

@@ -1,13 +1,6 @@
import { useState, type FormEvent } from 'react';
import { useApiPost } from '../../hooks/useApi';
interface OrderPayload {
symbol: string;
side: string;
order_type: string;
quantity: number;
price?: number;
}
import { useSubmitOrder } from '../../hooks/useApi';
import { OrderSide, OrderType } from '../../gen/trading_pb';
interface ValidationErrors {
symbol?: string;
@@ -23,7 +16,7 @@ export function OrderForm() {
const [price, setPrice] = useState('');
const [validationErrors, setValidationErrors] = useState<ValidationErrors>({});
const submitOrder = useApiPost<unknown, OrderPayload>('/trading/orders');
const submitOrder = useSubmitOrder();
const symbolValid = /^[A-Z0-9.]+$/i.test(symbol);
@@ -61,18 +54,13 @@ export function OrderForm() {
setValidationErrors(errors);
if (Object.keys(errors).length > 0) return;
const payload: OrderPayload = {
submitOrder.mutate({
symbol,
side,
order_type: orderType,
side: side === 'buy' ? String(OrderSide.BUY) : String(OrderSide.SELL),
orderType: orderType === 'market' ? String(OrderType.MARKET) : String(OrderType.LIMIT),
quantity: qty,
};
if (orderType === 'limit') {
payload.price = parseFloat(price);
}
submitOrder.mutate(payload);
price: orderType === 'limit' ? parseFloat(price) : undefined,
});
}
return (

View File

@@ -1,18 +1,74 @@
import type { Order } from '../../lib/types';
import { useApiDelete } from '../../hooks/useApi';
import { useMutation } from '@tanstack/react-query';
import { tradingClient } from '../../lib/grpc';
import {
OrderSide,
OrderType as ProtoOrderType,
OrderStatus,
} from '../../gen/trading_pb';
import type { Order } from '../../gen/trading_pb';
interface Props {
orders?: Order[];
loading?: boolean;
}
function sideLabel(side: OrderSide): string {
switch (side) {
case OrderSide.BUY: return 'BUY';
case OrderSide.SELL: return 'SELL';
default: return 'N/A';
}
}
function orderTypeLabel(ot: ProtoOrderType): string {
switch (ot) {
case ProtoOrderType.MARKET: return 'market';
case ProtoOrderType.LIMIT: return 'limit';
case ProtoOrderType.STOP: return 'stop';
case ProtoOrderType.STOP_LIMIT: return 'stop-limit';
default: return 'unknown';
}
}
function statusLabelFn(status: OrderStatus): string {
switch (status) {
case OrderStatus.PENDING: return 'pending';
case OrderStatus.SUBMITTED: return 'open';
case OrderStatus.PARTIALLY_FILLED: return 'partially_filled';
case OrderStatus.FILLED: return 'filled';
case OrderStatus.CANCELLED: return 'cancelled';
case OrderStatus.REJECTED: return 'rejected';
default: return 'unknown';
}
}
function statusColorFn(status: OrderStatus): string {
const label = statusLabelFn(status);
const colors: Record<string, string> = {
pending: 'text-[var(--color-yellow)]',
open: 'text-[var(--color-accent)]',
partially_filled: 'text-[var(--color-yellow)]',
filled: 'text-[var(--color-green)]',
cancelled: 'text-[var(--color-text-secondary)]',
rejected: 'text-[var(--color-red)]',
};
return colors[label] ?? 'text-[var(--color-text-secondary)]';
}
export function OrdersTable({ orders = [], loading }: Props) {
const activeCount = orders.filter(
(o) =>
o.status === OrderStatus.SUBMITTED ||
o.status === OrderStatus.PENDING ||
o.status === OrderStatus.PARTIALLY_FILLED,
).length;
return (
<div>
<div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--color-border)]">
<span className="text-sm font-medium">Orders</span>
<span className="text-xs text-[var(--color-text-secondary)]">
{orders.filter((o) => o.status === 'open' || o.status === 'pending').length} active
{activeCount} active
</span>
</div>
@@ -46,7 +102,7 @@ export function OrdersTable({ orders = [], loading }: Props) {
</tr>
)}
{orders.map((order) => (
<OrderRow key={order.order_id} order={order} />
<OrderRow key={order.orderId} order={order} />
))}
</tbody>
</table>
@@ -56,47 +112,46 @@ export function OrdersTable({ orders = [], loading }: Props) {
}
function OrderRow({ order }: { order: Order }) {
const cancelOrder = useApiDelete(`/trading/orders/${order.order_id}`);
const canCancel = order.status === 'open' || order.status === 'pending';
const cancelOrder = useMutation({
mutationFn: () => tradingClient.cancelOrder({ orderId: order.orderId }),
});
const statusColor = {
pending: 'text-[var(--color-yellow)]',
open: 'text-[var(--color-accent)]',
partially_filled: 'text-[var(--color-yellow)]',
filled: 'text-[var(--color-green)]',
cancelled: 'text-[var(--color-text-secondary)]',
rejected: 'text-[var(--color-red)]',
}[order.status];
const canCancel =
order.status === OrderStatus.SUBMITTED ||
order.status === OrderStatus.PENDING ||
order.status === OrderStatus.PARTIALLY_FILLED;
return (
<tr className="border-b border-[var(--color-border)] hover:bg-[var(--color-bg-primary)]">
<td className="px-3 py-2 font-mono">{order.order_id.slice(0, 8)}</td>
<td className="px-3 py-2 font-mono">{order.orderId.slice(0, 8)}</td>
<td className="px-3 py-2">{order.symbol}</td>
<td className="px-3 py-2">
<span
className={
order.side === 'buy'
order.side === OrderSide.BUY
? 'text-[var(--color-green)]'
: 'text-[var(--color-red)]'
}
>
{order.side.toUpperCase()}
{sideLabel(order.side)}
</span>
</td>
<td className="px-3 py-2 capitalize">{order.order_type}</td>
<td className="px-3 py-2 capitalize">{orderTypeLabel(order.orderType)}</td>
<td className="px-3 py-2 text-right">
{order.filled_quantity}/{order.quantity}
{order.filledQuantity}/{order.quantity}
</td>
<td className="px-3 py-2 text-right">
{order.price ? order.price.toFixed(2) : 'MKT'}
</td>
<td className={`px-3 py-2 capitalize ${statusColor}`}>{order.status}</td>
<td className={`px-3 py-2 capitalize ${statusColorFn(order.status)}`}>
{statusLabelFn(order.status)}
</td>
<td className="px-3 py-2 text-right">
{canCancel && (
<button
onClick={() => cancelOrder.mutate()}
disabled={cancelOrder.isPending}
aria-label={`Cancel order ${order.order_id.slice(0, 8)}`}
aria-label={`Cancel order ${order.orderId.slice(0, 8)}`}
className="px-2 py-0.5 text-xs rounded bg-[var(--color-red)]/20 text-[var(--color-red)] hover:bg-[var(--color-red)]/30 disabled:opacity-50"
>
{cancelOrder.isPending ? 'Cancelling...' : 'Cancel'}

View File

@@ -1,4 +1,4 @@
import type { Position } from '../../lib/types';
import type { Position } from '../../gen/trading_pb';
interface Props {
positions?: Position[];
@@ -22,8 +22,8 @@ export function PositionsTable({ positions = [], loading }: Props) {
<th scope="col" className="text-left px-3 py-2 font-medium">Symbol</th>
<th scope="col" className="text-left px-3 py-2 font-medium">Side</th>
<th scope="col" className="text-right px-3 py-2 font-medium">Qty</th>
<th scope="col" className="text-right px-3 py-2 font-medium">Entry</th>
<th scope="col" className="text-right px-3 py-2 font-medium">Market</th>
<th scope="col" className="text-right px-3 py-2 font-medium">Avg Price</th>
<th scope="col" className="text-right px-3 py-2 font-medium">Mkt Value</th>
<th scope="col" className="text-right px-3 py-2 font-medium">P&L</th>
</tr>
</thead>
@@ -42,38 +42,41 @@ export function PositionsTable({ positions = [], loading }: Props) {
</td>
</tr>
)}
{positions.map((pos) => (
<tr
key={`${pos.symbol}-${pos.side}`}
className="border-b border-[var(--color-border)] hover:bg-[var(--color-bg-primary)]"
>
<td className="px-3 py-2 font-medium">{pos.symbol}</td>
<td className="px-3 py-2">
<span
className={
pos.side === 'long'
{positions.map((pos) => {
const isLong = pos.quantity >= 0;
return (
<tr
key={`${pos.symbol}-${pos.quantity}`}
className="border-b border-[var(--color-border)] hover:bg-[var(--color-bg-primary)]"
>
<td className="px-3 py-2 font-medium">{pos.symbol}</td>
<td className="px-3 py-2">
<span
className={
isLong
? 'text-[var(--color-green)]'
: 'text-[var(--color-red)]'
}
>
{isLong ? 'LONG' : 'SHORT'}
</span>
</td>
<td className="px-3 py-2 text-right">{Math.abs(pos.quantity)}</td>
<td className="px-3 py-2 text-right">{pos.averagePrice.toFixed(2)}</td>
<td className="px-3 py-2 text-right">{pos.marketValue.toFixed(2)}</td>
<td
className={`px-3 py-2 text-right font-medium ${
pos.unrealizedPnl >= 0
? 'text-[var(--color-green)]'
: 'text-[var(--color-red)]'
}
}`}
>
{pos.side.toUpperCase()}
</span>
</td>
<td className="px-3 py-2 text-right">{pos.quantity}</td>
<td className="px-3 py-2 text-right">{pos.entry_price.toFixed(2)}</td>
<td className="px-3 py-2 text-right">{pos.current_price.toFixed(2)}</td>
<td
className={`px-3 py-2 text-right font-medium ${
pos.unrealized_pnl >= 0
? 'text-[var(--color-green)]'
: 'text-[var(--color-red)]'
}`}
>
{pos.unrealized_pnl >= 0 ? '+' : ''}
{pos.unrealized_pnl.toFixed(2)}
</td>
</tr>
))}
{pos.unrealizedPnl >= 0 ? '+' : ''}
{pos.unrealizedPnl.toFixed(2)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>

View File

@@ -0,0 +1,139 @@
// @generated by protoc-gen-es v2.11.0 with parameter "target=ts"
// @generated from file auth.proto (package auth, syntax proto3)
/* eslint-disable */
import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2";
import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2";
import type { Message } from "@bufbuild/protobuf";
/**
* Describes the file auth.proto.
*/
export const file_auth: GenFile = /*@__PURE__*/
fileDesc("CgphdXRoLnByb3RvEgRhdXRoIjIKDExvZ2luUmVxdWVzdBIQCgh1c2VybmFtZRgBIAEoCRIQCghwYXNzd29yZBgCIAEoCSJQCg1Mb2dpblJlc3BvbnNlEhQKDGFjY2Vzc190b2tlbhgBIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAIgASgJEhIKCmV4cGlyZXNfYXQYAyABKAMiLAoTUmVmcmVzaFRva2VuUmVxdWVzdBIVCg1yZWZyZXNoX3Rva2VuGAEgASgJIlcKFFJlZnJlc2hUb2tlblJlc3BvbnNlEhQKDGFjY2Vzc190b2tlbhgBIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAIgASgJEhIKCmV4cGlyZXNfYXQYAyABKAMyhgEKC0F1dGhTZXJ2aWNlEjAKBUxvZ2luEhIuYXV0aC5Mb2dpblJlcXVlc3QaEy5hdXRoLkxvZ2luUmVzcG9uc2USRQoMUmVmcmVzaFRva2VuEhkuYXV0aC5SZWZyZXNoVG9rZW5SZXF1ZXN0GhouYXV0aC5SZWZyZXNoVG9rZW5SZXNwb25zZWIGcHJvdG8z");
/**
* @generated from message auth.LoginRequest
*/
export type LoginRequest = Message<"auth.LoginRequest"> & {
/**
* @generated from field: string username = 1;
*/
username: string;
/**
* @generated from field: string password = 2;
*/
password: string;
};
/**
* Describes the message auth.LoginRequest.
* Use `create(LoginRequestSchema)` to create a new message.
*/
export const LoginRequestSchema: GenMessage<LoginRequest> = /*@__PURE__*/
messageDesc(file_auth, 0);
/**
* @generated from message auth.LoginResponse
*/
export type LoginResponse = Message<"auth.LoginResponse"> & {
/**
* @generated from field: string access_token = 1;
*/
accessToken: string;
/**
* @generated from field: string refresh_token = 2;
*/
refreshToken: string;
/**
* Unix timestamp (seconds) when the access token expires.
*
* @generated from field: int64 expires_at = 3;
*/
expiresAt: bigint;
};
/**
* Describes the message auth.LoginResponse.
* Use `create(LoginResponseSchema)` to create a new message.
*/
export const LoginResponseSchema: GenMessage<LoginResponse> = /*@__PURE__*/
messageDesc(file_auth, 1);
/**
* @generated from message auth.RefreshTokenRequest
*/
export type RefreshTokenRequest = Message<"auth.RefreshTokenRequest"> & {
/**
* @generated from field: string refresh_token = 1;
*/
refreshToken: string;
};
/**
* Describes the message auth.RefreshTokenRequest.
* Use `create(RefreshTokenRequestSchema)` to create a new message.
*/
export const RefreshTokenRequestSchema: GenMessage<RefreshTokenRequest> = /*@__PURE__*/
messageDesc(file_auth, 2);
/**
* @generated from message auth.RefreshTokenResponse
*/
export type RefreshTokenResponse = Message<"auth.RefreshTokenResponse"> & {
/**
* @generated from field: string access_token = 1;
*/
accessToken: string;
/**
* @generated from field: string refresh_token = 2;
*/
refreshToken: string;
/**
* @generated from field: int64 expires_at = 3;
*/
expiresAt: bigint;
};
/**
* Describes the message auth.RefreshTokenResponse.
* Use `create(RefreshTokenResponseSchema)` to create a new message.
*/
export const RefreshTokenResponseSchema: GenMessage<RefreshTokenResponse> = /*@__PURE__*/
messageDesc(file_auth, 3);
/**
* Authentication service — token issuance and refresh.
* This service is exposed WITHOUT the auth interceptor (pre-authentication).
*
* @generated from service auth.AuthService
*/
export const AuthService: GenService<{
/**
* Authenticate with username/password, receive JWT tokens.
*
* @generated from rpc auth.AuthService.Login
*/
login: {
methodKind: "unary";
input: typeof LoginRequestSchema;
output: typeof LoginResponseSchema;
},
/**
* Exchange a refresh token for a new access token.
*
* @generated from rpc auth.AuthService.RefreshToken
*/
refreshToken: {
methodKind: "unary";
input: typeof RefreshTokenRequestSchema;
output: typeof RefreshTokenResponseSchema;
},
}> = /*@__PURE__*/
serviceDesc(file_auth, 0);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,697 @@
// @generated by protoc-gen-es v2.11.0 with parameter "target=ts"
// @generated from file data_acquisition.proto (package data_acquisition, syntax proto3)
/* eslint-disable */
import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2";
import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2";
import type { Message } from "@bufbuild/protobuf";
/**
* Describes the file data_acquisition.proto.
*/
export const file_data_acquisition: GenFile = /*@__PURE__*/
fileDesc("ChZkYXRhX2FjcXVpc2l0aW9uLnByb3RvEhBkYXRhX2FjcXVpc2l0aW9uIjcKG1N0cmVhbURvd25sb2FkU3RhdHVzUmVxdWVzdBIYChBpbnRlcnZhbF9zZWNvbmRzGAEgASgNIogCChdTY2hlZHVsZURvd25sb2FkUmVxdWVzdBIPCgdkYXRhc2V0GAEgASgJEg8KB3N5bWJvbHMYAiADKAkSEgoKc3RhcnRfZGF0ZRgDIAEoCRIQCghlbmRfZGF0ZRgEIAEoCRIOCgZzY2hlbWEYBSABKAkSEwoLZGVzY3JpcHRpb24YBiABKAkSQQoEdGFncxgHIAMoCzIzLmRhdGFfYWNxdWlzaXRpb24uU2NoZWR1bGVEb3dubG9hZFJlcXVlc3QuVGFnc0VudHJ5EhAKCHByaW9yaXR5GAggASgNGisKCVRhZ3NFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIokBChhTY2hlZHVsZURvd25sb2FkUmVzcG9uc2USDgoGam9iX2lkGAEgASgJEjAKBnN0YXR1cxgCIAEoDjIgLmRhdGFfYWNxdWlzaXRpb24uRG93bmxvYWRTdGF0dXMSGgoSZXN0aW1hdGVkX2Nvc3RfdXNkGAMgASgBEg8KB21lc3NhZ2UYBCABKAkiKgoYR2V0RG93bmxvYWRTdGF0dXNSZXF1ZXN0Eg4KBmpvYl9pZBgBIAEoCSJWChlHZXREb3dubG9hZFN0YXR1c1Jlc3BvbnNlEjkKC2pvYl9kZXRhaWxzGAEgASgLMiQuZGF0YV9hY3F1aXNpdGlvbi5Eb3dubG9hZEpvYkRldGFpbHMiNwoVQ2FuY2VsRG93bmxvYWRSZXF1ZXN0Eg4KBmpvYl9pZBgBIAEoCRIOCgZyZWFzb24YAiABKAkiOgoWQ2FuY2VsRG93bmxvYWRSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg8KB21lc3NhZ2UYAiABKAkimQEKF0xpc3REb3dubG9hZEpvYnNSZXF1ZXN0EgwKBHBhZ2UYASABKA0SEQoJcGFnZV9zaXplGAIgASgNEjcKDXN0YXR1c19maWx0ZXIYAyABKA4yIC5kYXRhX2FjcXVpc2l0aW9uLkRvd25sb2FkU3RhdHVzEhIKCnN0YXJ0X3RpbWUYBCABKAMSEAoIZW5kX3RpbWUYBSABKAMihAEKGExpc3REb3dubG9hZEpvYnNSZXNwb25zZRIyCgRqb2JzGAEgAygLMiQuZGF0YV9hY3F1aXNpdGlvbi5Eb3dubG9hZEpvYlN1bW1hcnkSEwoLdG90YWxfY291bnQYAiABKA0SDAoEcGFnZRgDIAEoDRIRCglwYWdlX3NpemUYBCABKA0iFAoSSGVhbHRoQ2hlY2tSZXF1ZXN0IqwBChNIZWFsdGhDaGVja1Jlc3BvbnNlEg8KB2hlYWx0aHkYASABKAgSDwoHbWVzc2FnZRgCIAEoCRJDCgdkZXRhaWxzGAMgAygLMjIuZGF0YV9hY3F1aXNpdGlvbi5IZWFsdGhDaGVja1Jlc3BvbnNlLkRldGFpbHNFbnRyeRouCgxEZXRhaWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKzBQoSRG93bmxvYWRKb2JEZXRhaWxzEg4KBmpvYl9pZBgBIAEoCRIwCgZzdGF0dXMYAiABKA4yIC5kYXRhX2FjcXVpc2l0aW9uLkRvd25sb2FkU3RhdHVzEg8KB2RhdGFzZXQYAyABKAkSDwoHc3ltYm9scxgEIAMoCRISCgpzdGFydF9kYXRlGAUgASgJEhAKCGVuZF9kYXRlGAYgASgJEg4KBnNjaGVtYRgHIAEoCRITCgtkZXNjcmlwdGlvbhgIIAEoCRI8CgR0YWdzGAkgAygLMi4uZGF0YV9hY3F1aXNpdGlvbi5Eb3dubG9hZEpvYkRldGFpbHMuVGFnc0VudHJ5EhAKCHByaW9yaXR5GAogASgNEhsKE3Byb2dyZXNzX3BlcmNlbnRhZ2UYCyABKAISGAoQYnl0ZXNfZG93bmxvYWRlZBgMIAEoBBITCgt0b3RhbF9ieXRlcxgNIAEoBBIaChJlc3RpbWF0ZWRfY29zdF91c2QYDiABKAESFwoPYWN0dWFsX2Nvc3RfdXNkGA8gASgBEhUKDXJlY29yZHNfY291bnQYECABKAQSFwoPaW52YWxpZF9yZWNvcmRzGBEgASgEEhoKEmRhdGFfcXVhbGl0eV9zY29yZRgSIAEoARISCgpsb2NhbF9wYXRoGBMgASgJEhIKCm1pbmlvX3BhdGgYFCABKAkSEgoKY3JlYXRlZF9hdBgVIAEoAxISCgpzdGFydGVkX2F0GBYgASgDEhQKDGNvbXBsZXRlZF9hdBgXIAEoAxIVCg1lcnJvcl9tZXNzYWdlGBggASgJEhMKC3JldHJ5X2NvdW50GBkgASgNEhIKCmNyZWF0ZWRfYnkYGiABKAkaKwoJVGFnc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEi/gEKEkRvd25sb2FkSm9iU3VtbWFyeRIOCgZqb2JfaWQYASABKAkSMAoGc3RhdHVzGAIgASgOMiAuZGF0YV9hY3F1aXNpdGlvbi5Eb3dubG9hZFN0YXR1cxIPCgdkYXRhc2V0GAMgASgJEg8KB3N5bWJvbHMYBCADKAkSEgoKc3RhcnRfZGF0ZRgFIAEoCRIQCghlbmRfZGF0ZRgGIAEoCRIbChNwcm9ncmVzc19wZXJjZW50YWdlGAcgASgCEhcKD2FjdHVhbF9jb3N0X3VzZBgIIAEoARISCgpjcmVhdGVkX2F0GAkgASgDEhQKDGNvbXBsZXRlZF9hdBgKIAEoAyqUAQoORG93bmxvYWRTdGF0dXMSGwoXRE9XTkxPQURfU1RBVFVTX1VOS05PV04QABILCgdQRU5ESU5HEAESDwoLRE9XTkxPQURJTkcQAhIOCgpWQUxJREFUSU5HEAMSDQoJVVBMT0FESU5HEAQSDQoJQ09NUExFVEVEEAUSCgoGRkFJTEVEEAYSDQoJQ0FOQ0VMTEVEEAcykgUKFkRhdGFBY3F1aXNpdGlvblNlcnZpY2USaQoQU2NoZWR1bGVEb3dubG9hZBIpLmRhdGFfYWNxdWlzaXRpb24uU2NoZWR1bGVEb3dubG9hZFJlcXVlc3QaKi5kYXRhX2FjcXVpc2l0aW9uLlNjaGVkdWxlRG93bmxvYWRSZXNwb25zZRJsChFHZXREb3dubG9hZFN0YXR1cxIqLmRhdGFfYWNxdWlzaXRpb24uR2V0RG93bmxvYWRTdGF0dXNSZXF1ZXN0GisuZGF0YV9hY3F1aXNpdGlvbi5HZXREb3dubG9hZFN0YXR1c1Jlc3BvbnNlEmMKDkNhbmNlbERvd25sb2FkEicuZGF0YV9hY3F1aXNpdGlvbi5DYW5jZWxEb3dubG9hZFJlcXVlc3QaKC5kYXRhX2FjcXVpc2l0aW9uLkNhbmNlbERvd25sb2FkUmVzcG9uc2USaQoQTGlzdERvd25sb2FkSm9icxIpLmRhdGFfYWNxdWlzaXRpb24uTGlzdERvd25sb2FkSm9ic1JlcXVlc3QaKi5kYXRhX2FjcXVpc2l0aW9uLkxpc3REb3dubG9hZEpvYnNSZXNwb25zZRJaCgtIZWFsdGhDaGVjaxIkLmRhdGFfYWNxdWlzaXRpb24uSGVhbHRoQ2hlY2tSZXF1ZXN0GiUuZGF0YV9hY3F1aXNpdGlvbi5IZWFsdGhDaGVja1Jlc3BvbnNlEnMKFFN0cmVhbURvd25sb2FkU3RhdHVzEi0uZGF0YV9hY3F1aXNpdGlvbi5TdHJlYW1Eb3dubG9hZFN0YXR1c1JlcXVlc3QaKi5kYXRhX2FjcXVpc2l0aW9uLkxpc3REb3dubG9hZEpvYnNSZXNwb25zZTABYgZwcm90bzM");
/**
* @generated from message data_acquisition.StreamDownloadStatusRequest
*/
export type StreamDownloadStatusRequest = Message<"data_acquisition.StreamDownloadStatusRequest"> & {
/**
* 0 = server default (5s)
*
* @generated from field: uint32 interval_seconds = 1;
*/
intervalSeconds: number;
};
/**
* Describes the message data_acquisition.StreamDownloadStatusRequest.
* Use `create(StreamDownloadStatusRequestSchema)` to create a new message.
*/
export const StreamDownloadStatusRequestSchema: GenMessage<StreamDownloadStatusRequest> = /*@__PURE__*/
messageDesc(file_data_acquisition, 0);
/**
* Request to schedule a new data download
*
* @generated from message data_acquisition.ScheduleDownloadRequest
*/
export type ScheduleDownloadRequest = Message<"data_acquisition.ScheduleDownloadRequest"> & {
/**
* Databento dataset (e.g., "GLBX.MDP3" for CME futures)
*
* @generated from field: string dataset = 1;
*/
dataset: string;
/**
* List of symbols to download (e.g., ["ES.FUT", "NQ.FUT"])
*
* @generated from field: repeated string symbols = 2;
*/
symbols: string[];
/**
* Start date in YYYY-MM-DD format
*
* @generated from field: string start_date = 3;
*/
startDate: string;
/**
* End date in YYYY-MM-DD format
*
* @generated from field: string end_date = 4;
*/
endDate: string;
/**
* Schema type (e.g., "ohlcv-1m", "mbp-10", "trades")
*
* @generated from field: string schema = 5;
*/
schema: string;
/**
* Optional description for this download
*
* @generated from field: string description = 6;
*/
description: string;
/**
* Optional tags for categorization
*
* @generated from field: map<string, string> tags = 7;
*/
tags: { [key: string]: string };
/**
* Priority level (1=low, 5=high)
*
* @generated from field: uint32 priority = 8;
*/
priority: number;
};
/**
* Describes the message data_acquisition.ScheduleDownloadRequest.
* Use `create(ScheduleDownloadRequestSchema)` to create a new message.
*/
export const ScheduleDownloadRequestSchema: GenMessage<ScheduleDownloadRequest> = /*@__PURE__*/
messageDesc(file_data_acquisition, 1);
/**
* @generated from message data_acquisition.ScheduleDownloadResponse
*/
export type ScheduleDownloadResponse = Message<"data_acquisition.ScheduleDownloadResponse"> & {
/**
* Unique job identifier
*
* @generated from field: string job_id = 1;
*/
jobId: string;
/**
* Initial job status
*
* @generated from field: data_acquisition.DownloadStatus status = 2;
*/
status: DownloadStatus;
/**
* Estimated cost in USD
*
* @generated from field: double estimated_cost_usd = 3;
*/
estimatedCostUsd: number;
/**
* Human-readable message
*
* @generated from field: string message = 4;
*/
message: string;
};
/**
* Describes the message data_acquisition.ScheduleDownloadResponse.
* Use `create(ScheduleDownloadResponseSchema)` to create a new message.
*/
export const ScheduleDownloadResponseSchema: GenMessage<ScheduleDownloadResponse> = /*@__PURE__*/
messageDesc(file_data_acquisition, 2);
/**
* @generated from message data_acquisition.GetDownloadStatusRequest
*/
export type GetDownloadStatusRequest = Message<"data_acquisition.GetDownloadStatusRequest"> & {
/**
* @generated from field: string job_id = 1;
*/
jobId: string;
};
/**
* Describes the message data_acquisition.GetDownloadStatusRequest.
* Use `create(GetDownloadStatusRequestSchema)` to create a new message.
*/
export const GetDownloadStatusRequestSchema: GenMessage<GetDownloadStatusRequest> = /*@__PURE__*/
messageDesc(file_data_acquisition, 3);
/**
* @generated from message data_acquisition.GetDownloadStatusResponse
*/
export type GetDownloadStatusResponse = Message<"data_acquisition.GetDownloadStatusResponse"> & {
/**
* @generated from field: data_acquisition.DownloadJobDetails job_details = 1;
*/
jobDetails?: DownloadJobDetails;
};
/**
* Describes the message data_acquisition.GetDownloadStatusResponse.
* Use `create(GetDownloadStatusResponseSchema)` to create a new message.
*/
export const GetDownloadStatusResponseSchema: GenMessage<GetDownloadStatusResponse> = /*@__PURE__*/
messageDesc(file_data_acquisition, 4);
/**
* @generated from message data_acquisition.CancelDownloadRequest
*/
export type CancelDownloadRequest = Message<"data_acquisition.CancelDownloadRequest"> & {
/**
* @generated from field: string job_id = 1;
*/
jobId: string;
/**
* Optional cancellation reason
*
* @generated from field: string reason = 2;
*/
reason: string;
};
/**
* Describes the message data_acquisition.CancelDownloadRequest.
* Use `create(CancelDownloadRequestSchema)` to create a new message.
*/
export const CancelDownloadRequestSchema: GenMessage<CancelDownloadRequest> = /*@__PURE__*/
messageDesc(file_data_acquisition, 5);
/**
* @generated from message data_acquisition.CancelDownloadResponse
*/
export type CancelDownloadResponse = Message<"data_acquisition.CancelDownloadResponse"> & {
/**
* @generated from field: bool success = 1;
*/
success: boolean;
/**
* @generated from field: string message = 2;
*/
message: string;
};
/**
* Describes the message data_acquisition.CancelDownloadResponse.
* Use `create(CancelDownloadResponseSchema)` to create a new message.
*/
export const CancelDownloadResponseSchema: GenMessage<CancelDownloadResponse> = /*@__PURE__*/
messageDesc(file_data_acquisition, 6);
/**
* @generated from message data_acquisition.ListDownloadJobsRequest
*/
export type ListDownloadJobsRequest = Message<"data_acquisition.ListDownloadJobsRequest"> & {
/**
* @generated from field: uint32 page = 1;
*/
page: number;
/**
* @generated from field: uint32 page_size = 2;
*/
pageSize: number;
/**
* @generated from field: data_acquisition.DownloadStatus status_filter = 3;
*/
statusFilter: DownloadStatus;
/**
* Unix timestamp
*
* @generated from field: int64 start_time = 4;
*/
startTime: bigint;
/**
* Unix timestamp
*
* @generated from field: int64 end_time = 5;
*/
endTime: bigint;
};
/**
* Describes the message data_acquisition.ListDownloadJobsRequest.
* Use `create(ListDownloadJobsRequestSchema)` to create a new message.
*/
export const ListDownloadJobsRequestSchema: GenMessage<ListDownloadJobsRequest> = /*@__PURE__*/
messageDesc(file_data_acquisition, 7);
/**
* @generated from message data_acquisition.ListDownloadJobsResponse
*/
export type ListDownloadJobsResponse = Message<"data_acquisition.ListDownloadJobsResponse"> & {
/**
* @generated from field: repeated data_acquisition.DownloadJobSummary jobs = 1;
*/
jobs: DownloadJobSummary[];
/**
* @generated from field: uint32 total_count = 2;
*/
totalCount: number;
/**
* @generated from field: uint32 page = 3;
*/
page: number;
/**
* @generated from field: uint32 page_size = 4;
*/
pageSize: number;
};
/**
* Describes the message data_acquisition.ListDownloadJobsResponse.
* Use `create(ListDownloadJobsResponseSchema)` to create a new message.
*/
export const ListDownloadJobsResponseSchema: GenMessage<ListDownloadJobsResponse> = /*@__PURE__*/
messageDesc(file_data_acquisition, 8);
/**
* @generated from message data_acquisition.HealthCheckRequest
*/
export type HealthCheckRequest = Message<"data_acquisition.HealthCheckRequest"> & {
};
/**
* Describes the message data_acquisition.HealthCheckRequest.
* Use `create(HealthCheckRequestSchema)` to create a new message.
*/
export const HealthCheckRequestSchema: GenMessage<HealthCheckRequest> = /*@__PURE__*/
messageDesc(file_data_acquisition, 9);
/**
* @generated from message data_acquisition.HealthCheckResponse
*/
export type HealthCheckResponse = Message<"data_acquisition.HealthCheckResponse"> & {
/**
* @generated from field: bool healthy = 1;
*/
healthy: boolean;
/**
* @generated from field: string message = 2;
*/
message: string;
/**
* @generated from field: map<string, string> details = 3;
*/
details: { [key: string]: string };
};
/**
* Describes the message data_acquisition.HealthCheckResponse.
* Use `create(HealthCheckResponseSchema)` to create a new message.
*/
export const HealthCheckResponseSchema: GenMessage<HealthCheckResponse> = /*@__PURE__*/
messageDesc(file_data_acquisition, 10);
/**
* @generated from message data_acquisition.DownloadJobDetails
*/
export type DownloadJobDetails = Message<"data_acquisition.DownloadJobDetails"> & {
/**
* @generated from field: string job_id = 1;
*/
jobId: string;
/**
* @generated from field: data_acquisition.DownloadStatus status = 2;
*/
status: DownloadStatus;
/**
* @generated from field: string dataset = 3;
*/
dataset: string;
/**
* @generated from field: repeated string symbols = 4;
*/
symbols: string[];
/**
* @generated from field: string start_date = 5;
*/
startDate: string;
/**
* @generated from field: string end_date = 6;
*/
endDate: string;
/**
* @generated from field: string schema = 7;
*/
schema: string;
/**
* @generated from field: string description = 8;
*/
description: string;
/**
* @generated from field: map<string, string> tags = 9;
*/
tags: { [key: string]: string };
/**
* @generated from field: uint32 priority = 10;
*/
priority: number;
/**
* Progress tracking
*
* 0.0 to 100.0
*
* @generated from field: float progress_percentage = 11;
*/
progressPercentage: number;
/**
* @generated from field: uint64 bytes_downloaded = 12;
*/
bytesDownloaded: bigint;
/**
* @generated from field: uint64 total_bytes = 13;
*/
totalBytes: bigint;
/**
* Cost tracking
*
* @generated from field: double estimated_cost_usd = 14;
*/
estimatedCostUsd: number;
/**
* @generated from field: double actual_cost_usd = 15;
*/
actualCostUsd: number;
/**
* Quality metrics
*
* @generated from field: uint64 records_count = 16;
*/
recordsCount: bigint;
/**
* @generated from field: uint64 invalid_records = 17;
*/
invalidRecords: bigint;
/**
* 0.0 to 1.0
*
* @generated from field: double data_quality_score = 18;
*/
dataQualityScore: number;
/**
* Storage paths
*
* @generated from field: string local_path = 19;
*/
localPath: string;
/**
* @generated from field: string minio_path = 20;
*/
minioPath: string;
/**
* Timestamps
*
* Unix timestamp
*
* @generated from field: int64 created_at = 21;
*/
createdAt: bigint;
/**
* @generated from field: int64 started_at = 22;
*/
startedAt: bigint;
/**
* @generated from field: int64 completed_at = 23;
*/
completedAt: bigint;
/**
* Error information
*
* @generated from field: string error_message = 24;
*/
errorMessage: string;
/**
* @generated from field: uint32 retry_count = 25;
*/
retryCount: number;
/**
* Metadata
*
* @generated from field: string created_by = 26;
*/
createdBy: string;
};
/**
* Describes the message data_acquisition.DownloadJobDetails.
* Use `create(DownloadJobDetailsSchema)` to create a new message.
*/
export const DownloadJobDetailsSchema: GenMessage<DownloadJobDetails> = /*@__PURE__*/
messageDesc(file_data_acquisition, 11);
/**
* @generated from message data_acquisition.DownloadJobSummary
*/
export type DownloadJobSummary = Message<"data_acquisition.DownloadJobSummary"> & {
/**
* @generated from field: string job_id = 1;
*/
jobId: string;
/**
* @generated from field: data_acquisition.DownloadStatus status = 2;
*/
status: DownloadStatus;
/**
* @generated from field: string dataset = 3;
*/
dataset: string;
/**
* @generated from field: repeated string symbols = 4;
*/
symbols: string[];
/**
* @generated from field: string start_date = 5;
*/
startDate: string;
/**
* @generated from field: string end_date = 6;
*/
endDate: string;
/**
* @generated from field: float progress_percentage = 7;
*/
progressPercentage: number;
/**
* @generated from field: double actual_cost_usd = 8;
*/
actualCostUsd: number;
/**
* @generated from field: int64 created_at = 9;
*/
createdAt: bigint;
/**
* @generated from field: int64 completed_at = 10;
*/
completedAt: bigint;
};
/**
* Describes the message data_acquisition.DownloadJobSummary.
* Use `create(DownloadJobSummarySchema)` to create a new message.
*/
export const DownloadJobSummarySchema: GenMessage<DownloadJobSummary> = /*@__PURE__*/
messageDesc(file_data_acquisition, 12);
/**
* @generated from enum data_acquisition.DownloadStatus
*/
export enum DownloadStatus {
/**
* @generated from enum value: DOWNLOAD_STATUS_UNKNOWN = 0;
*/
DOWNLOAD_STATUS_UNKNOWN = 0,
/**
* Queued, waiting to start
*
* @generated from enum value: PENDING = 1;
*/
PENDING = 1,
/**
* Actively downloading from Databento
*
* @generated from enum value: DOWNLOADING = 2;
*/
DOWNLOADING = 2,
/**
* Validating data quality
*
* @generated from enum value: VALIDATING = 3;
*/
VALIDATING = 3,
/**
* Uploading to MinIO
*
* @generated from enum value: UPLOADING = 4;
*/
UPLOADING = 4,
/**
* Successfully completed
*
* @generated from enum value: COMPLETED = 5;
*/
COMPLETED = 5,
/**
* Failed with errors
*
* @generated from enum value: FAILED = 6;
*/
FAILED = 6,
/**
* Cancelled by user
*
* @generated from enum value: CANCELLED = 7;
*/
CANCELLED = 7,
}
/**
* Describes the enum data_acquisition.DownloadStatus.
*/
export const DownloadStatusSchema: GenEnum<DownloadStatus> = /*@__PURE__*/
enumDesc(file_data_acquisition, 0);
/**
* Data Acquisition Service provides automated Databento data downloading and storage.
* This service handles scheduled downloads, cost tracking, quality validation, and automatic MinIO upload.
*
* @generated from service data_acquisition.DataAcquisitionService
*/
export const DataAcquisitionService: GenService<{
/**
* Download Management
* Schedule a new data download from Databento
*
* @generated from rpc data_acquisition.DataAcquisitionService.ScheduleDownload
*/
scheduleDownload: {
methodKind: "unary";
input: typeof ScheduleDownloadRequestSchema;
output: typeof ScheduleDownloadResponseSchema;
},
/**
* Get status of a download job
*
* @generated from rpc data_acquisition.DataAcquisitionService.GetDownloadStatus
*/
getDownloadStatus: {
methodKind: "unary";
input: typeof GetDownloadStatusRequestSchema;
output: typeof GetDownloadStatusResponseSchema;
},
/**
* Cancel a running or pending download job
*
* @generated from rpc data_acquisition.DataAcquisitionService.CancelDownload
*/
cancelDownload: {
methodKind: "unary";
input: typeof CancelDownloadRequestSchema;
output: typeof CancelDownloadResponseSchema;
},
/**
* List all download jobs with optional filters
*
* @generated from rpc data_acquisition.DataAcquisitionService.ListDownloadJobs
*/
listDownloadJobs: {
methodKind: "unary";
input: typeof ListDownloadJobsRequestSchema;
output: typeof ListDownloadJobsResponseSchema;
},
/**
* Service Health and Status
* Check service health and resource availability
*
* @generated from rpc data_acquisition.DataAcquisitionService.HealthCheck
*/
healthCheck: {
methodKind: "unary";
input: typeof HealthCheckRequestSchema;
output: typeof HealthCheckResponseSchema;
},
/**
* Server-streaming: polls ListDownloadJobs at gateway level
*
* @generated from rpc data_acquisition.DataAcquisitionService.StreamDownloadStatus
*/
streamDownloadStatus: {
methodKind: "server_streaming";
input: typeof StreamDownloadStatusRequestSchema;
output: typeof ListDownloadJobsResponseSchema;
},
}> = /*@__PURE__*/
serviceDesc(file_data_acquisition, 0);

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,120 @@
// @generated by protoc-gen-es v2.11.0 with parameter "target=ts"
// @generated from file health.proto (package grpc.health.v1, syntax proto3)
/* eslint-disable */
import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2";
import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2";
import type { Message } from "@bufbuild/protobuf";
/**
* Describes the file health.proto.
*/
export const file_health: GenFile = /*@__PURE__*/
fileDesc("CgxoZWFsdGgucHJvdG8SDmdycGMuaGVhbHRoLnYxIiUKEkhlYWx0aENoZWNrUmVxdWVzdBIPCgdzZXJ2aWNlGAEgASgJIkQKE0hlYWx0aENoZWNrUmVzcG9uc2USLQoGc3RhdHVzGAEgASgOMh0uZ3JwYy5oZWFsdGgudjEuU2VydmluZ1N0YXR1cypPCg1TZXJ2aW5nU3RhdHVzEgsKB1VOS05PV04QABILCgdTRVJWSU5HEAESDwoLTk9UX1NFUlZJTkcQAhITCg9TRVJWSUNFX1VOS05PV04QAzKuAQoGSGVhbHRoElAKBUNoZWNrEiIuZ3JwYy5oZWFsdGgudjEuSGVhbHRoQ2hlY2tSZXF1ZXN0GiMuZ3JwYy5oZWFsdGgudjEuSGVhbHRoQ2hlY2tSZXNwb25zZRJSCgVXYXRjaBIiLmdycGMuaGVhbHRoLnYxLkhlYWx0aENoZWNrUmVxdWVzdBojLmdycGMuaGVhbHRoLnYxLkhlYWx0aENoZWNrUmVzcG9uc2UwAWIGcHJvdG8z");
/**
* Health check request
*
* @generated from message grpc.health.v1.HealthCheckRequest
*/
export type HealthCheckRequest = Message<"grpc.health.v1.HealthCheckRequest"> & {
/**
* Service name to check (empty for overall service health)
*
* @generated from field: string service = 1;
*/
service: string;
};
/**
* Describes the message grpc.health.v1.HealthCheckRequest.
* Use `create(HealthCheckRequestSchema)` to create a new message.
*/
export const HealthCheckRequestSchema: GenMessage<HealthCheckRequest> = /*@__PURE__*/
messageDesc(file_health, 0);
/**
* Health check response
*
* @generated from message grpc.health.v1.HealthCheckResponse
*/
export type HealthCheckResponse = Message<"grpc.health.v1.HealthCheckResponse"> & {
/**
* Health status
*
* @generated from field: grpc.health.v1.ServingStatus status = 1;
*/
status: ServingStatus;
};
/**
* Describes the message grpc.health.v1.HealthCheckResponse.
* Use `create(HealthCheckResponseSchema)` to create a new message.
*/
export const HealthCheckResponseSchema: GenMessage<HealthCheckResponse> = /*@__PURE__*/
messageDesc(file_health, 1);
/**
* Serving status enum
*
* @generated from enum grpc.health.v1.ServingStatus
*/
export enum ServingStatus {
/**
* @generated from enum value: UNKNOWN = 0;
*/
UNKNOWN = 0,
/**
* @generated from enum value: SERVING = 1;
*/
SERVING = 1,
/**
* @generated from enum value: NOT_SERVING = 2;
*/
NOT_SERVING = 2,
/**
* Used when the requested service is unknown
*
* @generated from enum value: SERVICE_UNKNOWN = 3;
*/
SERVICE_UNKNOWN = 3,
}
/**
* Describes the enum grpc.health.v1.ServingStatus.
*/
export const ServingStatusSchema: GenEnum<ServingStatus> = /*@__PURE__*/
enumDesc(file_health, 0);
/**
* Health check service definition
*
* @generated from service grpc.health.v1.Health
*/
export const Health: GenService<{
/**
* Check the health status of the service
*
* @generated from rpc grpc.health.v1.Health.Check
*/
check: {
methodKind: "unary";
input: typeof HealthCheckRequestSchema;
output: typeof HealthCheckResponseSchema;
},
/**
* Watch for health status changes
*
* @generated from rpc grpc.health.v1.Health.Watch
*/
watch: {
methodKind: "server_streaming";
input: typeof HealthCheckRequestSchema;
output: typeof HealthCheckResponseSchema;
},
}> = /*@__PURE__*/
serviceDesc(file_health, 0);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,36 +1,179 @@
import { useQuery, useMutation, type UseQueryOptions } from '@tanstack/react-query';
import { apiGet, apiPost, apiPut, apiDelete } from '../lib/api';
import { useQuery, useMutation } from "@tanstack/react-query";
import {
tradingClient,
riskClient,
mlClient,
mlTrainingClient,
configClient,
monitoringClient,
backtestingClient,
} from "../lib/grpc";
/** Typed GET query hook */
export function useApiQuery<T>(
key: string[],
path: string,
options?: Omit<UseQueryOptions<T>, 'queryKey' | 'queryFn'>,
// ---------------------------------------------------------------------------
// Trading
// ---------------------------------------------------------------------------
export function usePositions() {
return useQuery({
queryKey: ["positions"],
queryFn: () => tradingClient.getPositions({}),
refetchInterval: 5000,
});
}
export function useOrders() {
return useQuery({
queryKey: ["orders"],
queryFn: () => tradingClient.getOrderStatus({}),
refetchInterval: 5000,
});
}
export function useOrderBook(symbol: string) {
return useQuery({
queryKey: ["order-book", symbol],
queryFn: () => tradingClient.getOrderBook({ symbol }),
refetchInterval: 2000,
});
}
export function useSubmitOrder() {
return useMutation({
mutationFn: (order: {
symbol: string;
side: string;
orderType: string;
quantity: number;
price?: number;
}) => tradingClient.submitOrder(order),
});
}
export function useMlPredictions() {
return useQuery({
queryKey: ["ml-predictions"],
queryFn: () => tradingClient.getMLPredictions({}),
refetchInterval: 5000,
});
}
// ---------------------------------------------------------------------------
// Risk
// ---------------------------------------------------------------------------
export function useRiskMetrics() {
return useQuery({
queryKey: ["risk-metrics"],
queryFn: () => riskClient.getRiskMetrics({}),
refetchInterval: 5000,
});
}
export function useEmergencyStop() {
return useMutation({
mutationFn: (params: { reason?: string }) =>
riskClient.emergencyStop(params),
});
}
// ---------------------------------------------------------------------------
// ML
// ---------------------------------------------------------------------------
export function useMlPrediction() {
return useQuery({
queryKey: ["ml-prediction"],
queryFn: () => mlClient.getPrediction({}),
refetchInterval: 5000,
});
}
// ---------------------------------------------------------------------------
// Training
// ---------------------------------------------------------------------------
export function useTrainingJobs() {
return useQuery({
queryKey: ["training-jobs"],
queryFn: () => mlTrainingClient.listTrainingJobs({}),
refetchInterval: 5000,
});
}
// ---------------------------------------------------------------------------
// Performance / Monitoring
// ---------------------------------------------------------------------------
export function usePerformanceMetrics() {
return useQuery({
queryKey: ["performance-metrics"],
queryFn: () => monitoringClient.getMetrics({}),
refetchInterval: 10000,
});
}
export function useSystemStatus() {
return useQuery({
queryKey: ["system-status"],
queryFn: () => monitoringClient.getSystemStatus({}),
refetchInterval: 10000,
});
}
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
export function useConfig() {
return useQuery({
queryKey: ["config"],
queryFn: () => configClient.getConfiguration({}),
});
}
export function useUpdateConfig() {
return useMutation({
mutationFn: (params: {
key: string;
value: string;
category?: string;
}) => configClient.updateConfiguration(params),
});
}
// ---------------------------------------------------------------------------
// Backtesting
// ---------------------------------------------------------------------------
export function useStartBacktest() {
return useMutation({
mutationFn: (params: {
strategy: string;
symbol: string;
startDate: string;
endDate: string;
}) => backtestingClient.startBacktest(params),
});
}
export function useBacktestStatus(backtestId: string | null) {
return useQuery({
queryKey: ["backtest-status", backtestId],
queryFn: () =>
backtestingClient.getBacktestStatus({ backtestId: backtestId! }),
enabled: backtestId !== null,
refetchInterval: 2000,
});
}
export function useBacktestResults(
backtestId: string | null,
enabled: boolean,
) {
return useQuery<T>({
queryKey: key,
queryFn: () => apiGet<T>(path),
...options,
});
}
/** Typed POST mutation hook */
export function useApiPost<TResponse, TBody = unknown>(path: string) {
return useMutation<TResponse, Error, TBody>({
mutationFn: (body) => apiPost<TResponse>(path, body),
});
}
/** Typed PUT mutation hook */
export function useApiPut<TResponse, TBody = unknown>(path: string) {
return useMutation<TResponse, Error, TBody>({
mutationFn: (body) => apiPut<TResponse>(path, body),
});
}
/** Typed DELETE mutation hook */
export function useApiDelete<TResponse>(path: string) {
return useMutation<TResponse, Error, void>({
mutationFn: () => apiDelete<TResponse>(path),
return useQuery({
queryKey: ["backtest-results", backtestId],
queryFn: () =>
backtestingClient.getBacktestResults({ backtestId: backtestId! }),
enabled,
});
}

View File

@@ -1,16 +1,23 @@
import { useCallback, useSyncExternalStore } from 'react';
import { clearTokens, getToken, isAuthenticated, setTokens } from '../lib/auth';
import { wsManager } from '../lib/websocket';
import { useCallback, useSyncExternalStore } from "react";
import {
clearTokens,
getToken,
isAuthenticated,
setTokens,
} from "../lib/auth";
/** Simple external store for auth state reactivity */
let authListeners: Set<() => void> = new Set();
const authListeners: Set<() => void> = new Set();
function notifyAuthChange() {
authListeners.forEach((l) => l());
}
function subscribeAuth(listener: () => void) {
authListeners.add(listener);
return () => authListeners.delete(listener);
return () => {
authListeners.delete(listener);
};
}
/** React hook for authentication state and actions */
@@ -18,15 +25,16 @@ export function useAuth() {
const authenticated = useSyncExternalStore(subscribeAuth, isAuthenticated);
const token = useSyncExternalStore(subscribeAuth, getToken);
const login = useCallback((accessToken: string, refreshToken?: string) => {
setTokens(accessToken, refreshToken);
wsManager.connect();
notifyAuthChange();
}, []);
const login = useCallback(
(accessToken: string, refreshToken?: string) => {
setTokens(accessToken, refreshToken);
notifyAuthChange();
},
[],
);
const logout = useCallback(() => {
clearTokens();
wsManager.disconnect();
notifyAuthChange();
}, []);

View File

@@ -0,0 +1,109 @@
import { useEffect, useRef, useState } from "react";
import {
tradingClient,
riskClient,
monitoringClient,
mlClient,
mlTrainingClient,
configClient,
} from "../lib/grpc";
// ---------------------------------------------------------------------------
// Generic server-streaming hook
// ---------------------------------------------------------------------------
/** Hook that consumes a server-streaming gRPC RPC and accumulates messages. */
export function useServerStream<T>(
streamFn: (signal: AbortSignal) => AsyncIterable<T>,
deps: unknown[] = [],
) {
const [lastMessage, setLastMessage] = useState<T | null>(null);
const [messages, setMessages] = useState<T[]>([]);
const [connected, setConnected] = useState(false);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
const controller = new AbortController();
setConnected(true);
(async () => {
try {
for await (const msg of streamFn(controller.signal)) {
if (!mountedRef.current) break;
setLastMessage(msg);
setMessages((prev) => [...prev.slice(-99), msg]);
}
} catch {
// Stream ended, aborted, or encountered an error
} finally {
if (mountedRef.current) setConnected(false);
}
})();
return () => {
mountedRef.current = false;
controller.abort();
};
}, deps); // eslint-disable-line react-hooks/exhaustive-deps
return { lastMessage, messages, connected };
}
// ---------------------------------------------------------------------------
// Typed stream hooks
// ---------------------------------------------------------------------------
export function useMarketDataStream() {
return useServerStream((signal) =>
tradingClient.streamMarketData({}, { signal }),
);
}
export function useOrderUpdatesStream() {
return useServerStream((signal) =>
tradingClient.streamOrders({}, { signal }),
);
}
export function usePositionUpdatesStream() {
return useServerStream((signal) =>
tradingClient.streamPositions({}, { signal }),
);
}
export function useRiskAlertsStream() {
return useServerStream((signal) =>
riskClient.streamRiskAlerts({}, { signal }),
);
}
export function useRiskMetricsStream() {
return useServerStream((signal) =>
riskClient.streamRiskMetrics({}, { signal }),
);
}
export function useMetricsStream() {
return useServerStream((signal) =>
monitoringClient.streamMetrics({}, { signal }),
);
}
export function useMlPredictionStream() {
return useServerStream((signal) =>
mlClient.streamPredictions({}, { signal }),
);
}
export function useTrainingStatusStream() {
return useServerStream((signal) =>
mlTrainingClient.subscribeToTrainingStatus({}, { signal }),
);
}
export function useConfigChangesStream() {
return useServerStream((signal) =>
configClient.streamConfigChanges({}, { signal }),
);
}

View File

@@ -1,49 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import type { ServerMessage, ServerMessageType } from '../lib/types';
import { wsManager } from '../lib/websocket';
/** React hook for WebSocket topic subscriptions and real-time messages */
export function useWebSocket(topics: string[]) {
const [messages, setMessages] = useState<ServerMessage[]>([]);
const [connected, setConnected] = useState(wsManager.connected);
const topicsRef = useRef(topics);
topicsRef.current = topics;
useEffect(() => {
// Subscribe to topics
if (topics.length > 0) {
wsManager.subscribe(topics);
}
// Listen for messages
const unsubscribe = wsManager.onMessage((msg) => {
if (topicsRef.current.includes(msg.type) || topicsRef.current.includes('*')) {
setMessages((prev) => [...prev.slice(-99), msg]); // Keep last 100
}
});
// Track connection status
const interval = setInterval(() => {
setConnected(wsManager.connected);
}, 1000);
return () => {
unsubscribe();
clearInterval(interval);
if (topics.length > 0) {
wsManager.unsubscribe(topics);
}
};
}, [topics.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
return { messages, connected, lastMessage: messages[messages.length - 1] };
}
/** Hook to get messages of a specific type */
export function useWebSocketTopic<T extends ServerMessage>(
topic: ServerMessageType,
) {
const { messages, connected } = useWebSocket([topic]);
const typed = messages.filter((m) => m.type === topic) as T[];
return { messages: typed, connected, lastMessage: typed[typed.length - 1] };
}

View File

@@ -1,100 +0,0 @@
import { clearTokens, isTokenExpired, SESSION_EXPIRED_KEY } from './auth';
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
/** Margin in seconds before actual expiry to treat the token as expired */
const EXPIRY_MARGIN_SECONDS = 60;
/** Maximum time in ms to wait for an API response before aborting */
const REQUEST_TIMEOUT_MS = 30_000;
/**
* Redirect to login with a session-expired flag so the LoginPage can
* display a user-friendly message instead of a silent redirect.
*/
function redirectToLogin(): void {
sessionStorage.setItem(SESSION_EXPIRED_KEY, '1');
clearTokens();
window.location.href = '/login';
}
/** Fetch wrapper that injects JWT Authorization header and handles 401 auto-logout */
async function apiFetch<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const token = localStorage.getItem('foxhunt_token');
// Pre-flight: detect expired (or about-to-expire) tokens before making the request
if (token && isTokenExpired(token, EXPIRY_MARGIN_SECONDS)) {
redirectToLogin();
throw new Error('Session expired. Please log in again.');
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> ?? {}),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
let res: Response;
try {
res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
signal: controller.signal,
});
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error('Request timed out. Please try again.');
}
throw err;
} finally {
clearTimeout(timeoutId);
}
if (!res.ok) {
// Server rejected the token — redirect gracefully
if (res.status === 401) {
redirectToLogin();
throw new Error('Session expired. Please log in again.');
}
const body = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(body.error ?? `API error: ${res.status}`);
}
return res.json();
}
/** GET request */
export function apiGet<T>(path: string): Promise<T> {
return apiFetch<T>(path);
}
/** POST request */
export function apiPost<T>(path: string, body?: unknown): Promise<T> {
return apiFetch<T>(path, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
});
}
/** PUT request */
export function apiPut<T>(path: string, body?: unknown): Promise<T> {
return apiFetch<T>(path, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
});
}
/** DELETE request */
export function apiDelete<T>(path: string): Promise<T> {
return apiFetch<T>(path, { method: 'DELETE' });
}

View File

@@ -0,0 +1,80 @@
import { createGrpcWebTransport } from "@connectrpc/connect-web";
import { createClient, type Interceptor } from "@connectrpc/connect";
import {
getToken,
isTokenExpired,
clearTokens,
SESSION_EXPIRED_KEY,
} from "./auth";
// Generated service definitions (protoc-gen-es v2 — services live in *_pb.ts)
import { TradingService } from "../gen/trading_pb";
import { RiskService } from "../gen/risk_pb";
import { MLService } from "../gen/ml_pb";
import { MLTrainingService } from "../gen/ml_training_pb";
import { ConfigService } from "../gen/config_pb";
import { MonitoringService } from "../gen/monitoring_pb";
import { BacktestingService } from "../gen/fxt_trading_pb";
import { AuthService } from "../gen/auth_pb";
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:50051";
/** Margin in seconds before actual expiry to treat the token as expired */
const EXPIRY_MARGIN_SECONDS = 60;
/**
* Interceptor that attaches the JWT Authorization header to every request
* and handles session expiry / UNAUTHENTICATED responses.
*/
const authInterceptor: Interceptor = (next) => async (req) => {
const token = getToken();
if (token) {
if (isTokenExpired(token, EXPIRY_MARGIN_SECONDS)) {
sessionStorage.setItem(SESSION_EXPIRED_KEY, "1");
clearTokens();
window.location.href = "/login";
throw new Error("Session expired");
}
req.header.set("authorization", `Bearer ${token}`);
}
try {
return await next(req);
} catch (err: unknown) {
// gRPC status 16 = UNAUTHENTICATED
if (
err &&
typeof err === "object" &&
"code" in err &&
(err as { code: number }).code === 16
) {
sessionStorage.setItem(SESSION_EXPIRED_KEY, "1");
clearTokens();
window.location.href = "/login";
}
throw err;
}
};
/** Authenticated transport — used for all services except login */
const transport = createGrpcWebTransport({
baseUrl: API_URL,
interceptors: [authInterceptor],
});
/** Public transport — no auth header, used for login/refresh */
const publicTransport = createGrpcWebTransport({
baseUrl: API_URL,
});
// ---------------------------------------------------------------------------
// Service clients
// ---------------------------------------------------------------------------
export const authClient = createClient(AuthService, publicTransport);
export const tradingClient = createClient(TradingService, transport);
export const riskClient = createClient(RiskService, transport);
export const mlClient = createClient(MLService, transport);
export const mlTrainingClient = createClient(MLTrainingService, transport);
export const configClient = createClient(ConfigService, transport);
export const monitoringClient = createClient(MonitoringService, transport);
export const backtestingClient = createClient(BacktestingService, transport);

View File

@@ -1,37 +1,36 @@
/**
* UI adapter types.
*
* These are kept as lightweight interfaces for components that render data
* without depending directly on the generated proto message types.
* Pages and hooks now work with the proto-generated types from src/gen/*_pb.ts
* but child components may still accept these plain interfaces for flexibility.
*/
/** Position in a trading account */
export interface Position {
symbol: string;
quantity: number;
entry_price: number;
current_price: number;
unrealized_pnl: number;
averagePrice: number;
marketValue: number;
unrealizedPnl: number;
side: 'long' | 'short';
}
/** Order submitted to the trading engine */
export interface Order {
order_id: string;
orderId: string;
symbol: string;
side: 'buy' | 'sell';
order_type: 'market' | 'limit' | 'stop';
orderType: 'market' | 'limit' | 'stop';
quantity: number;
price?: number;
status: 'pending' | 'open' | 'partially_filled' | 'filled' | 'cancelled' | 'rejected';
filled_quantity: number;
created_at: string;
filledQuantity: number;
createdAt: string;
}
/** Risk metrics snapshot */
export interface RiskMetrics {
portfolio_var: number;
position_utilization: number;
max_drawdown: number;
current_drawdown: number;
sharpe_ratio: number;
sortino_ratio: number;
}
/** ML model prediction */
/** ML model prediction (UI-facing) */
export interface MlPrediction {
model: string;
symbol: string;
@@ -41,42 +40,25 @@ export interface MlPrediction {
timestamp: string;
}
/** Training job status */
/** Training job status (UI-facing) */
export interface TrainingJob {
job_id: string;
model_type: string;
jobId: string;
modelType: string;
status: 'queued' | 'running' | 'completed' | 'failed' | 'stopped';
progress: number;
epoch: number;
total_epochs: number;
totalEpochs: number;
loss: number;
started_at: string;
startedAt: string;
}
/** Performance metrics */
/** Performance metrics (UI-facing) */
export interface PerformanceMetrics {
total_pnl: number;
daily_pnl: number;
win_rate: number;
total_trades: number;
sharpe_ratio: number;
sortino_ratio: number;
max_drawdown: number;
}
/** WebSocket message types (server -> client) */
export type ServerMessageType =
| 'market_data'
| 'order_update'
| 'risk_alert'
| 'position_update'
| 'ml_prediction'
| 'training_progress'
| 'metrics'
| 'config_update';
/** Generic server message envelope */
export interface ServerMessage {
type: ServerMessageType;
[key: string]: unknown;
totalPnl: number;
dailyPnl: number;
winRate: number;
totalTrades: number;
sharpeRatio: number;
sortinoRatio: number;
maxDrawdown: number;
}

View File

@@ -1,143 +0,0 @@
import type { ServerMessage } from './types';
type MessageHandler = (msg: ServerMessage) => void;
/** WebSocket connection manager with auto-reconnect and topic subscriptions */
export class WsManager {
private ws: WebSocket | null = null;
private url: string;
private handlers: Set<MessageHandler> = new Set();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private backoff = 1000;
private maxBackoff = 30000;
private maxAttempts = 50;
private attempts = 0;
private subscribedTopics: Set<string> = new Set();
private _connected = false;
constructor(url?: string) {
const envWsUrl = import.meta.env.VITE_WS_URL as string | undefined;
if (url) {
this.url = url;
} else if (envWsUrl) {
this.url = envWsUrl;
} else {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
this.url = `${protocol}//${window.location.host}/api/ws`;
}
}
get connected(): boolean {
return this._connected;
}
/** Connect to the WebSocket server */
connect(): void {
const token = localStorage.getItem('foxhunt_token');
if (!token) return;
const wsUrl = `${this.url}?token=${encodeURIComponent(token)}`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
this._connected = true;
this.backoff = 1000;
this.attempts = 0;
// Re-subscribe to previously subscribed topics
if (this.subscribedTopics.size > 0) {
this.send({ type: 'subscribe', topics: Array.from(this.subscribedTopics) });
}
};
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data) as ServerMessage;
this.handlers.forEach((h) => h(msg));
} catch {
// Ignore malformed messages
}
};
this.ws.onclose = () => {
this._connected = false;
this.scheduleReconnect();
};
this.ws.onerror = () => {
this.ws?.close();
};
}
/** Disconnect and stop reconnecting */
disconnect(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.ws?.close();
this.ws = null;
this._connected = false;
}
/** Manually trigger reconnection (resets backoff) */
reconnect(): void {
this.disconnect();
this.attempts = 0;
this.backoff = 1000;
this.connect();
}
get reconnectAttempts(): number {
return this.attempts;
}
get maxReconnectAttempts(): number {
return this.maxAttempts;
}
/** Subscribe to data topics */
subscribe(topics: string[]): void {
topics.forEach((t) => this.subscribedTopics.add(t));
if (this._connected) {
this.send({ type: 'subscribe', topics });
}
}
/** Unsubscribe from data topics */
unsubscribe(topics: string[]): void {
topics.forEach((t) => this.subscribedTopics.delete(t));
if (this._connected) {
this.send({ type: 'unsubscribe', topics });
}
}
/** Register a message handler */
onMessage(handler: MessageHandler): () => void {
this.handlers.add(handler);
return () => this.handlers.delete(handler);
}
private send(msg: unknown): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
}
}
private scheduleReconnect(): void {
if (this.reconnectTimer) return;
this.attempts += 1;
if (this.attempts > this.maxAttempts) {
// Stop reconnecting after too many failures
return;
}
const delay = this.backoff;
this.backoff = Math.min(this.backoff * 2, this.maxBackoff);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, delay);
}
}
/** Singleton WebSocket manager instance */
export const wsManager = new WsManager();

View File

@@ -1,36 +1,8 @@
import { useState, type FormEvent } from 'react';
import { PnLChart } from '../components/charts/PnLChart';
import { ComponentErrorBoundary } from '../components/common/ComponentErrorBoundary';
import { useApiPost, useApiQuery } from '../hooks/useApi';
interface BacktestParams {
strategy: string;
symbol: string;
start_date: string;
end_date: string;
}
interface BacktestStatus {
backtest_id: string;
status: 'running' | 'completed' | 'failed';
progress: number;
strategy: string;
symbol: string;
}
interface BacktestResult {
trades: BacktestTrade[];
equity_curve: { date: string; pnl: number }[];
}
interface BacktestTrade {
time: string;
symbol: string;
side: string;
entry_price: number;
exit_price: number;
pnl: number;
}
import { useStartBacktest, useBacktestStatus, useBacktestResults } from '../hooks/useApi';
import { BacktestStatus } from '../gen/fxt_trading_pb';
interface BacktestValidationErrors {
symbol?: string;
@@ -46,26 +18,12 @@ export function BacktestingDashboard() {
const [activeBacktestId, setActiveBacktestId] = useState<string | null>(null);
const startBacktest = useApiPost<{ backtest_id: string }, BacktestParams>(
'/backtest/start',
);
const startBacktest = useStartBacktest();
const backtestStatus = useApiQuery<BacktestStatus>(
['backtest-status', activeBacktestId ?? ''],
`/backtest/${activeBacktestId}/status`,
{
enabled: activeBacktestId !== null,
refetchInterval: 2000,
},
);
const backtestStatusQuery = useBacktestStatus(activeBacktestId);
const backtestResults = useApiQuery<BacktestResult>(
['backtest-results', activeBacktestId ?? ''],
`/backtest/${activeBacktestId}/results`,
{
enabled: backtestStatus.data?.status === 'completed',
},
);
const isCompleted = backtestStatusQuery.data?.status === BacktestStatus.COMPLETED;
const backtestResults = useBacktestResults(activeBacktestId, isCompleted);
const hasValidationErrors = Object.keys(validationErrors).length > 0;
@@ -96,11 +54,40 @@ export function BacktestingDashboard() {
if (Object.keys(errors).length > 0) return;
startBacktest.mutate(
{ strategy, symbol, start_date: startDate, end_date: endDate },
{ onSuccess: (data) => setActiveBacktestId(data.backtest_id) },
{ strategy, symbol, startDate, endDate },
{ onSuccess: (data) => setActiveBacktestId(data.backtestId) },
);
}
// Map BacktestStatus enum to display strings
function statusLabel(status: BacktestStatus | undefined): string {
switch (status) {
case BacktestStatus.RUNNING: return 'running';
case BacktestStatus.COMPLETED: return 'completed';
case BacktestStatus.FAILED: return 'failed';
case BacktestStatus.QUEUED: return 'queued';
case BacktestStatus.CANCELLED: return 'cancelled';
default: return 'unknown';
}
}
function statusColorClass(status: BacktestStatus | undefined): string {
switch (status) {
case BacktestStatus.COMPLETED: return 'bg-green-500/20 text-[var(--color-green)]';
case BacktestStatus.FAILED: return 'bg-red-500/20 text-[var(--color-red)]';
default: return 'bg-blue-500/20 text-[var(--color-accent)]';
}
}
// Adapt equity curve from proto to PnLChart shape
const equityCurveData = backtestResults.data?.equityCurve?.map((p) => ({
date: new Date(Number(p.timestampUnixNanos) / 1_000_000).toISOString().slice(0, 10),
pnl: p.equity,
}));
// Adapt trades from proto to display shape
const trades = backtestResults.data?.trades ?? [];
return (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -201,7 +188,7 @@ export function BacktestingDashboard() {
</button>
{startBacktest.isSuccess && (
<p className="text-xs text-[var(--color-green)]">
Backtest started: {startBacktest.data.backtest_id}
Backtest started: {startBacktest.data.backtestId}
</p>
)}
{startBacktest.isError && (
@@ -217,28 +204,24 @@ export function BacktestingDashboard() {
<div className="px-3 py-1.5 border-b border-[var(--color-border)]">
<span className="text-sm font-medium">Active Backtests</span>
</div>
{backtestStatus.data ? (
{backtestStatusQuery.data ? (
<div className="p-3 space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-mono">{backtestStatus.data.backtest_id.slice(0, 8)}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${
backtestStatus.data.status === 'completed' ? 'bg-green-500/20 text-[var(--color-green)]' :
backtestStatus.data.status === 'failed' ? 'bg-red-500/20 text-[var(--color-red)]' :
'bg-blue-500/20 text-[var(--color-accent)]'
}`}>
{backtestStatus.data.status}
<span className="font-mono">{backtestStatusQuery.data.backtestId.slice(0, 8)}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${statusColorClass(backtestStatusQuery.data.status)}`}>
{statusLabel(backtestStatusQuery.data.status)}
</span>
</div>
<div className="text-xs text-[var(--color-text-secondary)]">
{backtestStatus.data.strategy} / {backtestStatus.data.symbol}
{strategy} / {symbol}
</div>
{backtestStatus.data.status === 'running' && (
{backtestStatusQuery.data.status === BacktestStatus.RUNNING && (
<div className="w-full bg-[var(--color-bg-primary)] rounded-full h-1.5">
<div
className="bg-[var(--color-accent)] h-1.5 rounded-full transition-all"
style={{ width: `${(backtestStatus.data.progress * 100).toFixed(0)}%` }}
style={{ width: `${backtestStatusQuery.data.progressPercentage.toFixed(0)}%` }}
role="progressbar"
aria-valuenow={Math.round(backtestStatus.data.progress * 100)}
aria-valuenow={Math.round(backtestStatusQuery.data.progressPercentage)}
aria-valuemin={0}
aria-valuemax={100}
/>
@@ -256,7 +239,7 @@ export function BacktestingDashboard() {
{/* Equity Curve */}
<div className="h-64 rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<ComponentErrorBoundary name="Equity Curve">
<PnLChart title="Equity Curve" data={backtestResults.data?.equity_curve} />
<PnLChart title="Equity Curve" data={equityCurveData} />
</ComponentErrorBoundary>
</div>
@@ -278,21 +261,26 @@ export function BacktestingDashboard() {
</tr>
</thead>
<tbody>
{backtestResults.data?.trades && backtestResults.data.trades.length > 0 ? (
backtestResults.data.trades.map((trade, i) => (
<tr key={i} className="border-b border-[var(--color-border)] hover:bg-[var(--color-bg-primary)]">
<td className="px-3 py-1.5">{trade.time}</td>
<td className="px-3 py-1.5">{trade.symbol}</td>
<td className={`px-3 py-1.5 ${trade.side === 'buy' ? 'text-[var(--color-green)]' : 'text-[var(--color-red)]'}`}>
{trade.side.toUpperCase()}
</td>
<td className="px-3 py-1.5 text-right">{trade.entry_price.toFixed(2)}</td>
<td className="px-3 py-1.5 text-right">{trade.exit_price.toFixed(2)}</td>
<td className={`px-3 py-1.5 text-right ${trade.pnl >= 0 ? 'text-[var(--color-green)]' : 'text-[var(--color-red)]'}`}>
${trade.pnl.toFixed(2)}
</td>
</tr>
))
{trades.length > 0 ? (
trades.map((trade, i) => {
const sideStr = trade.side === 1 ? 'BUY' : trade.side === 2 ? 'SELL' : 'UNKNOWN';
const isBuy = trade.side === 1;
const entryTime = new Date(Number(trade.entryTimeUnixNanos) / 1_000_000).toISOString();
return (
<tr key={i} className="border-b border-[var(--color-border)] hover:bg-[var(--color-bg-primary)]">
<td className="px-3 py-1.5">{entryTime.slice(0, 19).replace('T', ' ')}</td>
<td className="px-3 py-1.5">{trade.symbol}</td>
<td className={`px-3 py-1.5 ${isBuy ? 'text-[var(--color-green)]' : 'text-[var(--color-red)]'}`}>
{sideStr}
</td>
<td className="px-3 py-1.5 text-right">{trade.entryPrice.toFixed(2)}</td>
<td className="px-3 py-1.5 text-right">{trade.exitPrice.toFixed(2)}</td>
<td className={`px-3 py-1.5 text-right ${trade.pnl >= 0 ? 'text-[var(--color-green)]' : 'text-[var(--color-red)]'}`}>
${trade.pnl.toFixed(2)}
</td>
</tr>
);
})
) : (
<tr>
<td colSpan={6} className="text-center py-8 text-[var(--color-text-secondary)]">

View File

@@ -1,10 +1,10 @@
import { useState } from 'react';
import { ComponentErrorBoundary } from '../components/common/ComponentErrorBoundary';
import { useApiQuery, useApiPut } from '../hooks/useApi';
import { useConfig, useUpdateConfig } from '../hooks/useApi';
type ConfigCategory = 'trading' | 'risk' | 'ml' | 'system';
interface ConfigEntry {
interface ConfigFieldDef {
key: string;
value: string;
description: string;
@@ -13,7 +13,7 @@ interface ConfigEntry {
export function ConfigDashboard() {
const [activeTab, setActiveTab] = useState<ConfigCategory>('trading');
const config = useApiQuery<Record<string, unknown>>(['config'], '/config');
const config = useConfig();
return (
<div className="space-y-4">
@@ -46,7 +46,7 @@ export function ConfigDashboard() {
{/* Config form */}
<ComponentErrorBoundary name="Configuration Form">
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<ConfigSection category={activeTab} config={config.data} />
<ConfigSection category={activeTab} settings={config.data?.settings} />
</div>
</ComponentErrorBoundary>
@@ -65,12 +65,12 @@ export function ConfigDashboard() {
function ConfigSection({
category,
config,
settings,
}: {
category: ConfigCategory;
config?: Record<string, unknown>;
settings?: { key: string; value: string; category: string }[];
}) {
const defaults: Record<ConfigCategory, ConfigEntry[]> = {
const defaults: Record<ConfigCategory, ConfigFieldDef[]> = {
trading: [
{ key: 'max_position_size', value: '1000', description: 'Maximum position size per symbol' },
{ key: 'max_order_value', value: '100000', description: 'Maximum order value in USD' },
@@ -92,21 +92,23 @@ function ConfigSection({
system: [
{ key: 'log_level', value: 'info', description: 'Logging level (trace/debug/info/warn/error)' },
{ key: 'grpc_timeout_ms', value: '10000', description: 'gRPC client timeout' },
{ key: 'ws_heartbeat_interval', value: '30', description: 'WebSocket heartbeat interval (s)' },
{ key: 'stream_heartbeat_interval', value: '30', description: 'Stream heartbeat interval (s)' },
{ key: 'metrics_push_interval', value: '5', description: 'Metrics push interval (s)' },
],
};
// Merge API values over defaults
// Merge server values over defaults
const fields = defaults[category].map((field) => {
const serverValue = config?.[field.key];
const serverSetting = settings?.find(
(s) => s.key === field.key && s.category === category,
);
return {
...field,
value: serverValue !== undefined ? String(serverValue) : field.value,
value: serverSetting?.value ?? field.value,
};
});
const updateConfig = useApiPut<unknown, Record<string, string>>('/config');
const updateConfig = useUpdateConfig();
return (
<div className="divide-y divide-[var(--color-border)]">
@@ -125,7 +127,11 @@ function ConfigSection({
className="w-32 px-2 py-1 text-sm text-right rounded border border-[var(--color-border)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] outline-none focus:border-[var(--color-accent)]"
onBlur={(e) => {
if (e.target.value !== field.value) {
updateConfig.mutate({ [field.key]: e.target.value });
updateConfig.mutate({
key: field.key,
value: e.target.value,
category,
});
}
}}
/>

View File

@@ -1,15 +1,10 @@
import { useState, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import { apiPost } from '../lib/api';
import { authClient } from '../lib/grpc';
import { SESSION_EXPIRED_KEY } from '../lib/auth';
interface LoginResponse {
token: string;
refresh_token?: string;
}
/** Read and clear the session-expired flag set by the API client on 401 / token expiry */
/** Read and clear the session-expired flag set by the gRPC interceptor on UNAUTHENTICATED / token expiry */
function consumeSessionExpiredFlag(): boolean {
const expired = sessionStorage.getItem(SESSION_EXPIRED_KEY) === '1';
if (expired) sessionStorage.removeItem(SESSION_EXPIRED_KEY);
@@ -36,11 +31,8 @@ export function LoginPage() {
setLoading(true);
try {
const response = await apiPost<LoginResponse>('/auth/login', {
username,
password,
});
login(response.token, response.refresh_token);
const response = await authClient.login({ username, password });
login(response.accessToken, response.refreshToken);
navigate('/trading', { replace: true });
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');

View File

@@ -2,16 +2,8 @@ import { ComponentErrorBoundary } from '../components/common/ComponentErrorBound
import { ModelCard } from '../components/ml/ModelCard';
import { EnsemblePanel } from '../components/ml/EnsemblePanel';
import { TrainingProgress } from '../components/ml/TrainingProgress';
import { useApiQuery } from '../hooks/useApi';
import { useWebSocketTopic } from '../hooks/useWebSocket';
import type { MlPrediction, TrainingJob } from '../lib/types';
interface RegimeData {
regime?: string;
volatility?: string;
confidence?: number;
duration?: string;
}
import { useMlPredictions, useTrainingJobs } from '../hooks/useApi';
import { useMetricsStream } from '../hooks/useGrpcStream';
const MODEL_GROUPS = [
{ label: 'Reinforcement Learning', models: ['DQN', 'PPO'] },
@@ -21,26 +13,27 @@ const MODEL_GROUPS = [
] as const;
export function MLDashboard() {
const predictions = useApiQuery<MlPrediction[]>(
['ml-predictions'],
'/ml/predictions',
{ refetchInterval: 5000 },
);
const predictions = useMlPredictions();
const trainingJobs = useTrainingJobs();
const { messages: metricsMessages } = useMetricsStream();
const trainingJobs = useApiQuery<TrainingJob[]>(
['training-jobs'],
'/training/jobs',
{ refetchInterval: 5000 },
);
const { messages: metricsMessages } = useWebSocketTopic('metrics');
// Extract regime data from most recent metrics message
// Extract regime data from most recent metrics stream message
const latestMetrics = metricsMessages.length > 0
? (metricsMessages.at(-1) as { data?: RegimeData })?.data
? metricsMessages.at(-1)
: undefined;
const preds = predictions.data ?? [];
// The gRPC response wraps predictions in a `predictions` field
const preds = predictions.data?.predictions ?? [];
// Adapt proto MLPrediction to the shape the ModelCard / EnsemblePanel expects
const adaptedPreds = preds.map((p) => ({
model: p.ensembleAction, // model name not in proto; use ensembleAction for signal
symbol: p.symbol,
signal: p.ensembleAction?.toLowerCase() as 'buy' | 'sell' | 'hold' | undefined,
confidence: p.ensembleConfidence,
predicted_return: p.ensembleSignal,
timestamp: '',
}));
return (
<div className="space-y-4">
@@ -61,7 +54,7 @@ export function MLDashboard() {
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-4">
{group.models.map((model) => {
const pred = preds.find((p) => p.model === model);
const pred = adaptedPreds.find((p) => p.model === model);
return (
<div
key={model}
@@ -86,7 +79,7 @@ export function MLDashboard() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ComponentErrorBoundary name="Ensemble Panel">
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<EnsemblePanel predictions={preds} />
<EnsemblePanel predictions={adaptedPreds} />
</div>
</ComponentErrorBoundary>
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
@@ -96,19 +89,19 @@ export function MLDashboard() {
<div className="p-3 space-y-2">
<div className="flex justify-between text-xs">
<span className="text-[var(--color-text-secondary)]">Current Regime</span>
<span className="text-[var(--color-accent)]">{latestMetrics?.regime ?? '--'}</span>
<span className="text-[var(--color-accent)]">--</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-[var(--color-text-secondary)]">Volatility</span>
<span>{latestMetrics?.volatility ?? '--'}</span>
<span>--</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-[var(--color-text-secondary)]">Regime Confidence</span>
<span>{latestMetrics?.confidence !== undefined ? `${(latestMetrics.confidence * 100).toFixed(1)}%` : '--'}</span>
<span>--</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-[var(--color-text-secondary)]">Regime Duration</span>
<span>{latestMetrics?.duration ?? '--'}</span>
<span>{latestMetrics ? 'streaming' : '--'}</span>
</div>
</div>
</div>
@@ -118,7 +111,7 @@ export function MLDashboard() {
<ComponentErrorBoundary name="Training Progress">
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<TrainingProgress
jobs={trainingJobs.data}
jobs={trainingJobs.data?.jobs}
loading={trainingJobs.isLoading}
/>
</div>

View File

@@ -1,69 +1,63 @@
import { PnLChart } from '../components/charts/PnLChart';
import { ComponentErrorBoundary } from '../components/common/ComponentErrorBoundary';
import { MetricCard } from '../components/charts/MetricCard';
import { useApiQuery } from '../hooks/useApi';
import type { PerformanceMetrics } from '../lib/types';
interface PnLPoint {
date: string;
pnl: number;
}
import { usePerformanceMetrics } from '../hooks/useApi';
export function PerformanceDashboard() {
const metrics = useApiQuery<PerformanceMetrics>(
['performance-metrics'],
'/performance/metrics',
{ refetchInterval: 10000 },
);
const metricsQuery = usePerformanceMetrics();
const pnlData = useApiQuery<PnLPoint[]>(
['performance-pnl'],
'/performance/pnl',
{ refetchInterval: 30000 },
);
// The gRPC response is GetMetricsResponse with a `metrics` array of Metric objects.
// We try to find known metric names in the array for display.
const metricsList = metricsQuery.data?.metrics ?? [];
const findMetric = (name: string) =>
metricsList.find((m) => m.name === name);
const m = metrics.data;
const sharpe = findMetric('sharpe_ratio');
const sortino = findMetric('sortino_ratio');
const maxDD = findMetric('max_drawdown');
const winRate = findMetric('win_rate');
const totalTrades = findMetric('total_trades');
return (
<div className="space-y-4">
{(metrics.isError || pnlData.isError) && (
{metricsQuery.isError && (
<div className="px-3 py-2 rounded border border-[var(--color-red)] bg-red-500/10 text-sm text-[var(--color-red)] flex items-center justify-between">
<span>Failed to load {metrics.isError ? 'metrics' : 'P&L data'}: {(metrics.error ?? pnlData.error)?.message}</span>
<button onClick={() => { metrics.refetch(); pnlData.refetch(); }} className="text-xs underline">Retry</button>
<span>Failed to load metrics: {metricsQuery.error.message}</span>
<button onClick={() => metricsQuery.refetch()} className="text-xs underline">Retry</button>
</div>
)}
{/* Top: Metric cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<MetricCard label="Sharpe Ratio" value={m?.sharpe_ratio?.toFixed(2) ?? '--'} />
<MetricCard label="Sharpe Ratio" value={sharpe?.value?.toFixed(2) ?? '--'} />
</div>
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<MetricCard label="Sortino Ratio" value={m?.sortino_ratio?.toFixed(2) ?? '--'} />
<MetricCard label="Sortino Ratio" value={sortino?.value?.toFixed(2) ?? '--'} />
</div>
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<MetricCard
label="Max Drawdown"
value={m?.max_drawdown?.toFixed(2) ?? '--'}
value={maxDD?.value?.toFixed(2) ?? '--'}
suffix="%"
/>
</div>
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<MetricCard
label="Win Rate"
value={m?.win_rate !== undefined ? (m.win_rate * 100).toFixed(1) : '--'}
value={winRate?.value !== undefined ? (winRate.value * 100).toFixed(1) : '--'}
suffix="%"
/>
</div>
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<MetricCard label="Total Trades" value={m?.total_trades ?? '--'} />
<MetricCard label="Total Trades" value={totalTrades?.value ?? '--'} />
</div>
</div>
{/* Middle: P&L Chart */}
<div className="h-64 rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<ComponentErrorBoundary name="P&L Chart">
<PnLChart title="Cumulative P&L" data={pnlData.data} />
<PnLChart title="Cumulative P&L" data={undefined} />
</ComponentErrorBoundary>
</div>
@@ -71,7 +65,7 @@ export function PerformanceDashboard() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="h-48 rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<ComponentErrorBoundary name="Daily Returns Chart">
<PnLChart title="Daily Returns" data={pnlData.data} />
<PnLChart title="Daily Returns" data={undefined} />
</ComponentErrorBoundary>
</div>
<div className="h-48 rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">

View File

@@ -2,18 +2,15 @@ import { ComponentErrorBoundary } from '../components/common/ComponentErrorBound
import { RiskGauge } from '../components/risk/RiskGauge';
import { DrawdownChart } from '../components/risk/DrawdownChart';
import { EmergencyControls } from '../components/risk/EmergencyControls';
import { useApiQuery } from '../hooks/useApi';
import { useWebSocketTopic } from '../hooks/useWebSocket';
import type { RiskMetrics } from '../lib/types';
import { useRiskMetrics } from '../hooks/useApi';
import { useRiskAlertsStream } from '../hooks/useGrpcStream';
import { RiskAlertSeverity } from '../gen/risk_pb';
export function RiskDashboard() {
const riskMetrics = useApiQuery<RiskMetrics>(['risk-metrics'], '/risk/metrics', {
refetchInterval: 5000,
});
const riskMetrics = useRiskMetrics();
const { messages: alerts } = useRiskAlertsStream();
const { messages: alerts } = useWebSocketTopic('risk_alert');
const metrics = riskMetrics.data;
const metrics = riskMetrics.data?.metrics;
return (
<div className="space-y-4">
@@ -30,15 +27,15 @@ export function RiskDashboard() {
<ComponentErrorBoundary name="VaR Gauge">
<RiskGauge
label="VaR Utilization"
value={metrics?.portfolio_var ?? 0}
value={metrics?.portfolioVar1d ?? 0}
/>
</ComponentErrorBoundary>
</div>
<div className="h-48 rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<ComponentErrorBoundary name="Position Gauge">
<ComponentErrorBoundary name="Volatility Gauge">
<RiskGauge
label="Position Utilization"
value={metrics?.position_utilization ?? 0}
label="Volatility"
value={metrics?.volatility ?? 0}
/>
</ComponentErrorBoundary>
</div>
@@ -46,7 +43,7 @@ export function RiskDashboard() {
<ComponentErrorBoundary name="Drawdown Gauge">
<RiskGauge
label="Drawdown"
value={Math.abs(metrics?.current_drawdown ?? 0)}
value={Math.abs(metrics?.currentDrawdown ?? 0)}
thresholds={{ warn: 5, danger: 10 }}
max={20}
/>
@@ -74,20 +71,33 @@ export function RiskDashboard() {
</div>
) : (
<div className="divide-y divide-[var(--color-border)]">
{alerts.slice(-20).reverse().map((alert, i) => (
<div key={`alert-${alerts.length - i}`} className="px-3 py-2 text-xs">
<span
className={
(alert as { severity?: string }).severity === 'critical'
? 'text-[var(--color-red)]'
: 'text-[var(--color-yellow)]'
}
>
[{((alert as { severity?: string }).severity ?? 'info').toUpperCase()}]
</span>{' '}
{JSON.stringify((alert as { data?: unknown }).data ?? alert)}
</div>
))}
{alerts.slice(-20).reverse().map((alert, i) => {
const severityLabel =
alert.severity === RiskAlertSeverity.CRITICAL
? 'CRITICAL'
: alert.severity === RiskAlertSeverity.EMERGENCY
? 'EMERGENCY'
: alert.severity === RiskAlertSeverity.WARNING
? 'WARNING'
: 'INFO';
const isCritical =
alert.severity === RiskAlertSeverity.CRITICAL ||
alert.severity === RiskAlertSeverity.EMERGENCY;
return (
<div key={`alert-${alerts.length - i}`} className="px-3 py-2 text-xs">
<span
className={
isCritical
? 'text-[var(--color-red)]'
: 'text-[var(--color-yellow)]'
}
>
[{severityLabel}]
</span>{' '}
{alert.message}
</div>
);
})}
</div>
)}
</div>

View File

@@ -5,37 +5,35 @@ import { OrderBook } from '../components/trading/OrderBook';
import { PositionsTable } from '../components/trading/PositionsTable';
import { OrderForm } from '../components/trading/OrderForm';
import { OrdersTable } from '../components/trading/OrdersTable';
import { useApiQuery } from '../hooks/useApi';
import { useWebSocket } from '../hooks/useWebSocket';
import type { Position, Order } from '../lib/types';
interface OrderBookData {
bids: { price: number; size: number }[];
asks: { price: number; size: number }[];
spread?: number;
}
const MARKET_DATA_TOPICS = ['market_data'];
import { usePositions, useOrders, useOrderBook } from '../hooks/useApi';
import { useMarketDataStream } from '../hooks/useGrpcStream';
export function TradingDashboard() {
const positions = useApiQuery<Position[]>(['positions'], '/trading/positions', {
refetchInterval: 5000,
});
const positions = usePositions();
const orders = useOrders();
const orderBook = useOrderBook('ES.FUT');
const { lastMessage: marketDataEvent } = useMarketDataStream();
const orders = useApiQuery<Order[]>(['orders'], '/trading/orders', {
refetchInterval: 5000,
});
const { lastMessage } = useWebSocket(MARKET_DATA_TOPICS);
const orderBookData = useMemo<OrderBookData>(() => {
const data = lastMessage?.data as OrderBookData | undefined;
// Adapt proto OrderBook response to the component's expected shape
const orderBookData = useMemo(() => {
const ob = orderBook.data?.orderBook;
return {
bids: data?.bids ?? [],
asks: data?.asks ?? [],
spread: data?.spread,
bids: ob?.bids?.map((l) => ({ price: l.price, size: l.quantity })) ?? [],
asks: ob?.asks?.map((l) => ({ price: l.price, size: l.quantity })) ?? [],
spread: undefined as number | undefined,
};
}, [lastMessage]);
}, [orderBook.data]);
// Extract positions array from the gRPC response message
const positionsList = positions.data?.positions;
// The order response wraps a single Order; for the orders list we use
// streamOrders or getOrderStatus which returns a single order.
// Since the REST API returned Order[] and the proto returns a single Order,
// we wrap it into an array for backward compatibility with OrdersTable.
const ordersList = orders.data?.order ? [orders.data.order] : undefined;
// We still expose lastMessage for components that might use market data
void marketDataEvent;
return (
<div className="space-y-4">
@@ -65,7 +63,7 @@ export function TradingDashboard() {
{/* Middle: Positions */}
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<PositionsTable
positions={positions.data}
positions={positionsList}
loading={positions.isLoading}
/>
</div>
@@ -73,7 +71,7 @@ export function TradingDashboard() {
{/* Bottom: Orders + Order Form */}
<div className="grid grid-cols-1 lg:grid-cols-[1fr_360px] gap-4">
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<OrdersTable orders={orders.data} loading={orders.isLoading} />
<OrdersTable orders={ordersList} loading={orders.isLoading} />
</div>
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<OrderForm />

View File

@@ -6,10 +6,15 @@ export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
ws: true,
},
// Proxy grpc-web requests to the tonic-web gateway
'/auth.AuthService': { target: 'http://localhost:50051' },
'/trading.TradingService': { target: 'http://localhost:50051' },
'/risk.RiskService': { target: 'http://localhost:50051' },
'/ml.MLService': { target: 'http://localhost:50051' },
'/ml_training.MLTrainingService': { target: 'http://localhost:50051' },
'/config.ConfigService': { target: 'http://localhost:50051' },
'/monitoring.MonitoringService': { target: 'http://localhost:50051' },
'/foxhunt.tli.BacktestingService': { target: 'http://localhost:50051' },
},
},
build: {
@@ -20,6 +25,7 @@ export default defineConfig({
vendor: ['react', 'react-dom', 'react-router-dom'],
charts: ['lightweight-charts', 'recharts'],
query: ['@tanstack/react-query'],
grpc: ['@connectrpc/connect', '@connectrpc/connect-web', '@bufbuild/protobuf'],
},
},
},