qs-backtest-api 0.4.1

Provider-neutral request, response, event, and client contracts for the quant-system backtest service
Documentation
use qs_backtest_api::{
    BacktestConfigMsg, BacktestRunSpec, CommissionModelMsg, EntryProfileRouteMsg,
    FutureQuoteConfigMsg, PositionRefMsg, ProfileRef, ProviderEvaluationOptionsMsg, RawSignalMsg,
    ResultDeliveryMsg, RunBacktestRequest,
};
use serde_json::{Value, json};

fn entry() -> Value {
    json!({
        "action": "Entry",
        "ts": "2026-01-15T10:00:00",
        "symbol": "EURUSD",
        "side": "Buy",
        "order_type": "Market",
        "price": null,
        "risk": 1.0,
        "stoploss": null,
        "targets": [],
        "group": null,
        "trade_id": "trade-1"
    })
}

fn close() -> Value {
    json!({
        "action": "Close",
        "ts": "2026-01-15T10:01:00",
        "position": {
            "type": "ByTradeId",
            "trade_id": "trade-1"
        }
    })
}

fn add_rule() -> Value {
    json!({
        "action": "AddRule",
        "ts": "2026-01-15T10:02:00",
        "position": {
            "type": "ByTradeId",
            "trade_id": "trade-1"
        },
        "rule": {
            "type": "TrailingStop",
            "distance": 0.001
        }
    })
}

fn request_with_signal(signal: Value) -> Value {
    json!({
        "request": {
            "symbol": "EURUSD",
            "exchange": "fixture",
            "data_type": "tick",
            "raw_signals": [signal],
            "profile": null,
            "config": {
                "initial_balance": null,
                "close_on_finish": null,
                "fill_model": null
            }
        }
    })
}

#[test]
fn standalone_raw_signal_messages_keep_the_compatibility_boundary() {
    let mut unknown_action_field = close();
    unknown_action_field["unexpected"] = json!(true);
    assert!(serde_json::from_value::<RawSignalMsg>(unknown_action_field).is_err());

    let mut obsolete_entry_size = entry();
    obsolete_entry_size["size"] = json!(0.1);
    assert!(serde_json::from_value::<RawSignalMsg>(obsolete_entry_size).is_err());

    let mut nested_position_field = close();
    nested_position_field["position"]["future_position_field"] = json!(true);
    let close: RawSignalMsg = serde_json::from_value(nested_position_field).unwrap();
    assert!(matches!(close, RawSignalMsg::Close { .. }));

    let mut nested_rule_field = add_rule();
    nested_rule_field["rule"]["future_rule_field"] = json!(true);
    let add_rule: RawSignalMsg = serde_json::from_value(nested_rule_field).unwrap();
    assert!(matches!(add_rule, RawSignalMsg::AddRule { .. }));
}

#[test]
fn wrapped_run_requests_reject_unknown_fields_recursively() {
    let mut cases = Vec::new();

    let mut root = request_with_signal(entry());
    root["unexpected"] = json!(true);
    cases.push(("request wrapper", root));

    let mut run_spec = request_with_signal(entry());
    run_spec["request"]["unexpected"] = json!(true);
    cases.push(("run spec", run_spec));

    let mut config = request_with_signal(entry());
    config["request"]["config"]["unexpected"] = json!(true);
    cases.push(("config", config));

    let mut sizing = request_with_signal(entry());
    sizing["request"]["config"]["sizing"] = json!({
        "type": "FixedLot",
        "lots": 0.1,
        "unexpected": true
    });
    cases.push(("sizing", sizing));

    let mut action = request_with_signal(entry());
    action["request"]["raw_signals"][0]["unexpected"] = json!(true);
    cases.push(("raw signal", action));

    let mut position = request_with_signal(close());
    position["request"]["raw_signals"][0]["position"]["unexpected"] = json!(true);
    cases.push(("position reference", position));

    let mut rule = request_with_signal(add_rule());
    rule["request"]["raw_signals"][0]["rule"]["unexpected"] = json!(true);
    cases.push(("rule definition", rule));

    let mut costs = request_with_signal(entry());
    costs["request"]["config"]["costs"] = json!({
        "EURUSD": {
            "commission": {"type": "PerLotPerSide", "amount": 3.5, "currency": "USD"},
            "unexpected": true
        }
    });
    cases.push(("instrument costs", costs));

    let mut commission = request_with_signal(entry());
    commission["request"]["config"]["costs"] = json!({
        "EURUSD": {
            "commission": {
                "type": "NotionalRatePerSide",
                "buy_rate": 0.005,
                "sell_rate": 0.005,
                "unexpected": true
            }
        }
    });
    cases.push(("commission model", commission));

    let mut swap = request_with_signal(entry());
    swap["request"]["config"]["costs"] = json!({
        "EURUSD": {
            "swap": {
                "amount": {"unit": "Points", "long": -6.1, "short": 1.9},
                "rollover": "22:00:00",
                "triple_weekday": "Wed",
                "unexpected": true
            }
        }
    });
    cases.push(("swap schedule", swap));

    let mut swap_amount = request_with_signal(entry());
    swap_amount["request"]["config"]["costs"] = json!({
        "EURUSD": {
            "swap": {
                "amount": {"unit": "Points", "long": -6.1, "short": 1.9, "unexpected": true},
                "rollover": "22:00:00",
                "triple_weekday": "Wed"
            }
        }
    });
    cases.push(("swap amount", swap_amount));

    let mut future = request_with_signal(entry());
    future["future"] = json!({"unexpected": true});
    cases.push(("future config", future));

    let mut evaluation = request_with_signal(entry());
    evaluation["evaluation"] = json!({
        "context": {
            "provider_id": null,
            "source_id": null,
            "unexpected": true
        }
    });
    cases.push(("evaluation context", evaluation));

    for (boundary, value) in cases {
        assert!(
            serde_json::from_value::<RunBacktestRequest>(value).is_err(),
            "unknown field was accepted in {boundary}"
        );
    }
}

#[test]
fn run_requests_support_parser_free_direct_construction() {
    let request = RunBacktestRequest {
        request: BacktestRunSpec {
            symbol: "EURUSD".into(),
            symbols: Vec::new(),
            all_symbols: false,
            on_unavailable: Default::default(),
            exchange: "fixture".into(),
            data_type: "tick".into(),
            timeframe: None,
            from: None,
            to: None,
            raw_signals: vec![
                RawSignalMsg::Entry {
                    ts: "2026-01-15T10:00:00".into(),
                    symbol: "EURUSD".into(),
                    side: "Buy".into(),
                    order_type: "Market".into(),
                    price: None,
                    risk: 1.0,
                    stoploss: None,
                    targets: Vec::new(),
                    group: None,
                    trade_id: Some("trade-1".into()),
                    entry_class: Some("expanded".into()),
                },
                RawSignalMsg::ScaleIn {
                    ts: "2026-01-15T10:01:00".into(),
                    position: PositionRefMsg::ByTradeId {
                        trade_id: "trade-1".into(),
                    },
                    price: None,
                    size: 0.25,
                },
            ],
            profile: None,
            profile_def: None,
            entry_profile_routes: vec![EntryProfileRouteMsg {
                entry_class: "expanded".into(),
                profile: ProfileRef::Named("demo".into()),
            }],
            config: BacktestConfigMsg {
                initial_balance: Some(10_000.0),
                close_on_finish: Some(true),
                fill_model: None,
                sizing: None,
                costs: Default::default(),
            },
        },
        future: FutureQuoteConfigMsg::default(),
        evaluation: ProviderEvaluationOptionsMsg::default(),
        result_delivery: ResultDeliveryMsg::Auto,
    };

    let decoded: RunBacktestRequest =
        serde_json::from_value(serde_json::to_value(request).unwrap()).unwrap();
    assert_eq!(decoded.request.raw_signals.len(), 2);
    assert_eq!(decoded.request.entry_profile_routes.len(), 1);
    assert!(matches!(
        decoded.request.raw_signals[1],
        RawSignalMsg::ScaleIn { size: 0.25, .. }
    ));
}

#[test]
fn run_requests_accept_the_stored_metadata_tag_spelling() {
    // A run records its applied cost specification in `qs-core` spelling; feeding that back must not be rejected.
    let mut value = request_with_signal(entry());
    value["request"]["config"]["costs"] = json!({
        "EURUSD": {
            "commission": {"type": "per_lot_per_side", "amount": 3.5, "currency": "USD"},
            "swap": {
                "amount": {"unit": "points", "long": -6.1, "short": 1.9},
                "rollover": "22:00:00",
                "triple_weekday": "Wed"
            }
        }
    });

    let decoded: RunBacktestRequest = serde_json::from_value(value).unwrap();
    assert!(matches!(
        decoded.request.config.costs["EURUSD"].commission,
        Some(CommissionModelMsg::PerLotPerSide { amount, .. }) if amount == 3.5
    ));
}

#[test]
fn run_requests_carry_per_symbol_costs() {
    let mut value = request_with_signal(entry());
    value["request"]["config"]["costs"] = json!({
        "EURUSD": {
            "commission": {"type": "PerLotPerSide", "amount": 3.5, "currency": "USD"},
            "swap": {
                "amount": {"unit": "Points", "long": -6.1, "short": 1.9},
                "rollover": "22:00:00",
                "triple_weekday": "Wed"
            }
        },
        "BTCUSD": {
            "commission": {"type": "NotionalRatePerSide", "buy_rate": 0.005, "sell_rate": 0.005}
        }
    });

    let decoded: RunBacktestRequest = serde_json::from_value(value).unwrap();
    let costs = &decoded.request.config.costs;
    assert_eq!(costs.len(), 2);
    assert!(matches!(
        costs["EURUSD"].commission,
        Some(CommissionModelMsg::PerLotPerSide { amount, .. }) if amount == 3.5
    ));
    let swap = costs["EURUSD"].swap.as_ref().unwrap();
    assert_eq!(swap.rollover, "22:00:00");
    assert_eq!(swap.triple_weekday, "Wed");
    assert!(swap.skipped_weekdays.is_empty());
    assert!(matches!(
        costs["BTCUSD"].commission,
        Some(CommissionModelMsg::NotionalRatePerSide { buy_rate, sell_rate })
            if buy_rate == 0.005 && sell_rate == 0.005
    ));
    assert!(costs["BTCUSD"].swap.is_none());
}

#[test]
fn a_request_without_costs_stays_absent_on_the_wire() {
    let value = request_with_signal(entry());
    let decoded: RunBacktestRequest = serde_json::from_value(value).unwrap();
    assert!(decoded.request.config.costs.is_empty());
    let encoded = serde_json::to_value(&decoded).unwrap();
    assert!(encoded["request"]["config"].get("costs").is_none());
}