safety(web-gateway,web-dashboard): server-side validation for backtesting and training routes

- Add validate_backtest(): strategy_name, symbols format, date range,
  initial_capital checks with 6 unit tests
- Add model_type validation against known types (dqn, ppo, tft, mamba2)
  with 1 integration test
- Add error banner to ConfigDashboard for failed config fetch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-02-22 09:30:13 +01:00
parent 71d988e5f1
commit d42a0da105
3 changed files with 162 additions and 0 deletions

View File

@@ -16,6 +16,13 @@ export function ConfigDashboard() {
return (
<div className="space-y-4">
{config.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 configuration: {config.error.message}</span>
<button onClick={() => config.refetch()} className="text-xs underline">Retry</button>
</div>
)}
{/* Tabs */}
<div className="flex gap-1 border-b border-[var(--color-border)] pb-0">
{(['trading', 'risk', 'ml', 'system'] as const).map((tab) => (

View File

@@ -34,6 +34,8 @@ async fn start_backtest(
Extension(_claims): Extension<Claims>,
Json(body): Json<StartBacktestBody>,
) -> Result<Json<serde_json::Value>, AppError> {
validate_backtest(&body)?;
let channel = state
.backtesting_channel
.as_ref()
@@ -56,6 +58,38 @@ async fn start_backtest(
))
}
/// Validate backtest request fields before forwarding to gRPC.
fn validate_backtest(body: &StartBacktestBody) -> Result<(), AppError> {
if body.strategy_name.trim().is_empty() {
return Err(AppError::BadRequest(
"Strategy name is required".to_owned(),
));
}
if body.symbols.is_empty() {
return Err(AppError::BadRequest(
"At least one symbol is required".to_owned(),
));
}
for sym in &body.symbols {
if !sym.chars().all(|c| c.is_ascii_alphanumeric() || c == '.') {
return Err(AppError::BadRequest(format!(
"Invalid symbol: {sym}"
)));
}
}
if body.start_date_unix_nanos >= body.end_date_unix_nanos {
return Err(AppError::BadRequest(
"Start date must be before end date".to_owned(),
));
}
if !body.initial_capital.is_finite() || body.initial_capital <= 0.0 {
return Err(AppError::BadRequest(
"Initial capital must be a positive number".to_owned(),
));
}
Ok(())
}
async fn get_status(
State(state): State<AppState>,
Extension(_claims): Extension<Claims>,
@@ -265,4 +299,94 @@ mod tests {
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn test_validate_backtest_valid() {
let body = StartBacktestBody {
strategy_name: "momentum".to_string(),
symbols: vec!["ES.FUT".to_string()],
start_date_unix_nanos: 1_000,
end_date_unix_nanos: 2_000,
initial_capital: 100_000.0,
parameters: Default::default(),
save_results: false,
description: String::new(),
};
assert!(validate_backtest(&body).is_ok());
}
#[test]
fn test_validate_backtest_empty_strategy() {
let body = StartBacktestBody {
strategy_name: String::new(),
symbols: vec!["ES.FUT".to_string()],
start_date_unix_nanos: 1_000,
end_date_unix_nanos: 2_000,
initial_capital: 100_000.0,
parameters: Default::default(),
save_results: false,
description: String::new(),
};
assert!(validate_backtest(&body).is_err());
}
#[test]
fn test_validate_backtest_no_symbols() {
let body = StartBacktestBody {
strategy_name: "momentum".to_string(),
symbols: vec![],
start_date_unix_nanos: 1_000,
end_date_unix_nanos: 2_000,
initial_capital: 100_000.0,
parameters: Default::default(),
save_results: false,
description: String::new(),
};
assert!(validate_backtest(&body).is_err());
}
#[test]
fn test_validate_backtest_bad_date_range() {
let body = StartBacktestBody {
strategy_name: "momentum".to_string(),
symbols: vec!["ES.FUT".to_string()],
start_date_unix_nanos: 2_000,
end_date_unix_nanos: 1_000,
initial_capital: 100_000.0,
parameters: Default::default(),
save_results: false,
description: String::new(),
};
assert!(validate_backtest(&body).is_err());
}
#[test]
fn test_validate_backtest_negative_capital() {
let body = StartBacktestBody {
strategy_name: "momentum".to_string(),
symbols: vec!["ES.FUT".to_string()],
start_date_unix_nanos: 1_000,
end_date_unix_nanos: 2_000,
initial_capital: -1.0,
parameters: Default::default(),
save_results: false,
description: String::new(),
};
assert!(validate_backtest(&body).is_err());
}
#[test]
fn test_validate_backtest_bad_symbol() {
let body = StartBacktestBody {
strategy_name: "momentum".to_string(),
symbols: vec!["ES;DROP".to_string()],
start_date_unix_nanos: 1_000,
end_date_unix_nanos: 2_000,
initial_capital: 100_000.0,
parameters: Default::default(),
save_results: false,
description: String::new(),
};
assert!(validate_backtest(&body).is_err());
}
}

View File

@@ -52,11 +52,25 @@ struct StartJobBody {
use_gpu: bool,
}
/// Known model types that the ML training service supports.
const VALID_MODEL_TYPES: &[&str] = &["dqn", "ppo", "tft", "mamba2"];
async fn start_job(
State(state): State<AppState>,
Extension(_claims): Extension<Claims>,
Json(body): Json<StartJobBody>,
) -> Result<Json<serde_json::Value>, AppError> {
if body.model_type.is_empty() {
return Err(AppError::BadRequest("Model type is required".to_owned()));
}
if !VALID_MODEL_TYPES.contains(&body.model_type.to_lowercase().as_str()) {
return Err(AppError::BadRequest(format!(
"Unknown model type '{}'. Valid types: {}",
body.model_type,
VALID_MODEL_TYPES.join(", ")
)));
}
let channel = state
.ml_training_channel
.as_ref()
@@ -258,4 +272,21 @@ mod tests {
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn test_start_job_unknown_model_type_returns_400() {
let state = test_state();
let app = test_app(state);
let token = make_token();
let body = serde_json::json!({"model_type": "unknown_model"});
let req = Request::builder()
.method("POST")
.uri("/training/jobs")
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_string(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
}