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:
35
proto/auth.proto
Normal file
35
proto/auth.proto
Normal 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;
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
201
services/api/src/auth/grpc_login.rs
Normal file
201
services/api/src/auth/grpc_login.rs
Normal 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),
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
7
web-dashboard/buf.gen.yaml
Normal file
7
web-dashboard/buf.gen.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
version: v2
|
||||
plugins:
|
||||
- local: protoc-gen-es
|
||||
out: src/gen
|
||||
opt: target=ts
|
||||
inputs:
|
||||
- directory: ../proto
|
||||
238
web-dashboard/package-lock.json
generated
238
web-dashboard/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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) => (
|
||||
{positions.map((pos) => {
|
||||
const isLong = pos.quantity >= 0;
|
||||
return (
|
||||
<tr
|
||||
key={`${pos.symbol}-${pos.side}`}
|
||||
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={
|
||||
pos.side === 'long'
|
||||
isLong
|
||||
? 'text-[var(--color-green)]'
|
||||
: 'text-[var(--color-red)]'
|
||||
}
|
||||
>
|
||||
{pos.side.toUpperCase()}
|
||||
{isLong ? 'LONG' : 'SHORT'}
|
||||
</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">{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.unrealized_pnl >= 0
|
||||
pos.unrealizedPnl >= 0
|
||||
? 'text-[var(--color-green)]'
|
||||
: 'text-[var(--color-red)]'
|
||||
}`}
|
||||
>
|
||||
{pos.unrealized_pnl >= 0 ? '+' : ''}
|
||||
{pos.unrealized_pnl.toFixed(2)}
|
||||
{pos.unrealizedPnl >= 0 ? '+' : ''}
|
||||
{pos.unrealizedPnl.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
139
web-dashboard/src/gen/auth_pb.ts
Normal file
139
web-dashboard/src/gen/auth_pb.ts
Normal 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);
|
||||
|
||||
948
web-dashboard/src/gen/broker_gateway_pb.ts
Normal file
948
web-dashboard/src/gen/broker_gateway_pb.ts
Normal file
File diff suppressed because one or more lines are too long
1432
web-dashboard/src/gen/config_pb.ts
Normal file
1432
web-dashboard/src/gen/config_pb.ts
Normal file
File diff suppressed because one or more lines are too long
697
web-dashboard/src/gen/data_acquisition_pb.ts
Normal file
697
web-dashboard/src/gen/data_acquisition_pb.ts
Normal 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);
|
||||
|
||||
3900
web-dashboard/src/gen/fxt_trading_pb.ts
Normal file
3900
web-dashboard/src/gen/fxt_trading_pb.ts
Normal file
File diff suppressed because one or more lines are too long
120
web-dashboard/src/gen/health_pb.ts
Normal file
120
web-dashboard/src/gen/health_pb.ts
Normal 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);
|
||||
|
||||
1597
web-dashboard/src/gen/ml_pb.ts
Normal file
1597
web-dashboard/src/gen/ml_pb.ts
Normal file
File diff suppressed because one or more lines are too long
2929
web-dashboard/src/gen/ml_training_pb.ts
Normal file
2929
web-dashboard/src/gen/ml_training_pb.ts
Normal file
File diff suppressed because one or more lines are too long
2342
web-dashboard/src/gen/monitoring_pb.ts
Normal file
2342
web-dashboard/src/gen/monitoring_pb.ts
Normal file
File diff suppressed because one or more lines are too long
1435
web-dashboard/src/gen/risk_pb.ts
Normal file
1435
web-dashboard/src/gen/risk_pb.ts
Normal file
File diff suppressed because one or more lines are too long
2922
web-dashboard/src/gen/trading_agent_pb.ts
Normal file
2922
web-dashboard/src/gen/trading_agent_pb.ts
Normal file
File diff suppressed because one or more lines are too long
2375
web-dashboard/src/gen/trading_pb.ts
Normal file
2375
web-dashboard/src/gen/trading_pb.ts
Normal file
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
const login = useCallback(
|
||||
(accessToken: string, refreshToken?: string) => {
|
||||
setTokens(accessToken, refreshToken);
|
||||
wsManager.connect();
|
||||
notifyAuthChange();
|
||||
}, []);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearTokens();
|
||||
wsManager.disconnect();
|
||||
notifyAuthChange();
|
||||
}, []);
|
||||
|
||||
|
||||
109
web-dashboard/src/hooks/useGrpcStream.ts
Normal file
109
web-dashboard/src/hooks/useGrpcStream.ts
Normal 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 }),
|
||||
);
|
||||
}
|
||||
@@ -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] };
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
80
web-dashboard/src/lib/grpc.ts
Normal file
80
web-dashboard/src/lib/grpc.ts
Normal 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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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) => (
|
||||
{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">{trade.time}</td>
|
||||
<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 ${trade.side === 'buy' ? 'text-[var(--color-green)]' : 'text-[var(--color-red)]'}`}>
|
||||
{trade.side.toUpperCase()}
|
||||
<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.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.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)]">
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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) => (
|
||||
{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={
|
||||
(alert as { severity?: string }).severity === 'critical'
|
||||
isCritical
|
||||
? 'text-[var(--color-red)]'
|
||||
: 'text-[var(--color-yellow)]'
|
||||
}
|
||||
>
|
||||
[{((alert as { severity?: string }).severity ?? 'info').toUpperCase()}]
|
||||
[{severityLabel}]
|
||||
</span>{' '}
|
||||
{JSON.stringify((alert as { data?: unknown }).data ?? alert)}
|
||||
{alert.message}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user