qs-backtest-server 0.2.9

Transport-neutral retained-job backtest service and CLI
Documentation
use std::path::PathBuf;
use std::process::{Command, Output};
use std::time::{SystemTime, UNIX_EPOCH};

struct TempJsonl {
    path: PathBuf,
}

impl TempJsonl {
    fn new(label: &str, contents: &str) -> Self {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let path = std::env::temp_dir().join(format!(
            "tg_backtest_cli_{label}_{}_{}.jsonl",
            std::process::id(),
            unique
        ));
        std::fs::write(&path, contents).unwrap();
        Self { path }
    }

    fn as_str(&self) -> &str {
        self.path.to_str().unwrap()
    }
}

impl Drop for TempJsonl {
    fn drop(&mut self) {
        let _ = std::fs::remove_file(&self.path);
    }
}

fn run_tg_backtest(args: &[&str]) -> Output {
    Command::new(env!("CARGO_BIN_EXE_tg_backtest"))
        .args(args)
        .output()
        .expect("tg_backtest subprocess should start")
}

#[test]
fn empty_input_without_coverage_report_exits_nonzero() {
    let signals = TempJsonl::new("empty", "");
    let output = run_tg_backtest(&[
        "--input",
        signals.as_str(),
        "--exchange",
        "fixture",
        "--symbol",
        "EURUSD",
    ]);

    assert!(!output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("no signals were loaded"),
        "stderr: {stderr}"
    );
    assert!(
        stderr.contains("provider/coverage report"),
        "stderr: {stderr}"
    );
}

#[test]
fn all_filtered_input_without_coverage_report_exits_nonzero() {
    let signals = TempJsonl::new(
        "filtered",
        r#"{"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":null}
"#,
    );
    let output = run_tg_backtest(&[
        "--input",
        signals.as_str(),
        "--exchange",
        "fixture",
        "--symbol",
        "EURUSD",
        "--base-lot",
        "0.1",
        "--account-currency",
        "USD",
        "--from",
        "2027-01-01",
    ]);

    assert!(!output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("date filters removed all 1 loaded signals"),
        "stderr: {stderr}"
    );
}

#[test]
fn invalid_timestamp_outcome_allows_zero_trade_coverage_report() {
    let signals = TempJsonl::new("coverage_empty", "");
    let outcomes = TempJsonl::new(
        "invalid_timestamp_outcome",
        r#"{"status":"failed","source":{"chat_id":42,"msg_id":7,"ts":"not-a-timestamp","message":"bad timestamp","reply_to":null},"parser":null,"failure":{"kind":"invalid_timestamp","value":"not-a-timestamp","reason":"unsupported source timestamp"}}
"#,
    );
    let output = run_tg_backtest(&[
        "--input",
        signals.as_str(),
        "--outcomes-input",
        outcomes.as_str(),
        "--exchange",
        "fixture",
        "--symbol",
        "EURUSD",
        "--account-currency",
        "USD",
        "--report",
        "provider",
    ]);

    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        output.status.success(),
        "stdout: {stdout}\nstderr: {stderr}"
    );
    assert!(stdout.contains("raw=1, parsed=0, skipped=0, failed=1"));
    assert!(
        stdout.contains("\"provided_positions\": 0"),
        "stdout: {stdout}"
    );
    assert!(
        stdout.contains("\"selected_positions\": 0"),
        "stdout: {stdout}"
    );
    assert!(
        stdout.contains("\"failed_messages\": 1"),
        "stdout: {stdout}"
    );
    assert!(stdout.contains("\"raw_messages\": 1"), "stdout: {stdout}");
}

#[test]
fn entry_size_is_rejected_locally_with_its_jsonl_line_number() {
    let signals = TempJsonl::new(
        "obsolete_entry_size",
        r#"{"action":"CloseAll","ts":"2026-01-15T09:59:00"}
{"action":"Entry","ts":"2026-01-15T10:00:00","symbol":"EURUSD","side":"Buy","order_type":"Market","price":null,"size":0.1,"stoploss":null,"targets":[],"group":null,"trade_id":null}
"#,
    );
    let output = run_tg_backtest(&[
        "--input",
        signals.as_str(),
        "--exchange",
        "fixture",
        "--symbol",
        "EURUSD",
    ]);

    assert!(!output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(stderr.contains("line 2"), "stderr: {stderr}");
    assert!(
        stderr.contains("Entry field `size` is obsolete"),
        "stderr: {stderr}"
    );
}

#[test]
fn entry_without_risk_is_rejected_locally_with_its_jsonl_line_number() {
    let signals = TempJsonl::new(
        "missing_entry_risk",
        r#"{"action":"Entry","ts":"2026-01-15T10:00:00","symbol":"EURUSD","side":"Buy","order_type":"Market","price":null,"stoploss":null,"targets":[],"group":null,"trade_id":null}
"#,
    );
    let output = run_tg_backtest(&[
        "--input",
        signals.as_str(),
        "--exchange",
        "fixture",
        "--symbol",
        "EURUSD",
    ]);

    assert!(!output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("line 1: Entry requires field `risk`"),
        "stderr: {stderr}"
    );
}

#[test]
fn entry_requires_sizing_and_account_currency_before_connecting() {
    let signals = TempJsonl::new(
        "entry_contract",
        r#"{"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":null}
"#,
    );

    let no_sizing = run_tg_backtest(&[
        "--input",
        signals.as_str(),
        "--exchange",
        "fixture",
        "--symbol",
        "EURUSD",
        "--account-currency",
        "USD",
    ]);
    let no_sizing_stderr = String::from_utf8_lossy(&no_sizing.stderr);
    assert!(!no_sizing.status.success());
    assert!(
        no_sizing_stderr.contains("Entry signals require exactly one of --base-lot"),
        "stderr: {no_sizing_stderr}"
    );

    let no_currency = run_tg_backtest(&[
        "--input",
        signals.as_str(),
        "--exchange",
        "fixture",
        "--symbol",
        "EURUSD",
        "--risk-percent",
        "1",
    ]);
    let no_currency_stderr = String::from_utf8_lossy(&no_currency.stderr);
    assert!(!no_currency.status.success());
    assert!(
        no_currency_stderr.contains("Entry signals require --account-currency"),
        "stderr: {no_currency_stderr}"
    );
}

#[test]
fn sizing_options_are_mutually_exclusive_in_command_line_parsing() {
    let output = run_tg_backtest(&[
        "--input",
        "signals.jsonl",
        "--exchange",
        "fixture",
        "--symbol",
        "EURUSD",
        "--base-lot",
        "0.1",
        "--risk-per-trade",
        "100",
    ]);

    assert!(!output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(stderr.contains("cannot be used with"), "stderr: {stderr}");
    assert!(stderr.contains("--base-lot"), "stderr: {stderr}");
    assert!(stderr.contains("--risk-per-trade"), "stderr: {stderr}");
}

#[test]
fn scale_in_keeps_its_size_field() {
    let signals = TempJsonl::new(
        "scale_in_size",
        r#"{"action":"ScaleIn","ts":"2026-01-15T10:00:00","position":{"type":"ByTradeId","trade_id":"trade-1"},"price":null,"size":0.1}
"#,
    );
    let output = run_tg_backtest(&[
        "--input",
        signals.as_str(),
        "--exchange",
        "fixture",
        "--symbol",
        "EURUSD",
        "--from",
        "2027-01-01",
    ]);

    assert!(!output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("date filters removed all 1 loaded signals"),
        "stderr: {stderr}"
    );
    assert!(
        !stderr.contains("failed to parse raw signal"),
        "stderr: {stderr}"
    );
}

#[test]
fn help_documents_current_sizing_currency_and_future_defaults() {
    let output = run_tg_backtest(&["--help"]);
    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);

    for option in [
        "--base-lot",
        "--risk-per-trade",
        "--risk-percent",
        "--account-currency",
        "--conversion-stale-after-ms",
        "--mtm-output",
        "--mtm-max-points",
        "--result-delivery",
        "--execution-mode",
        "--cancel-on-interrupt",
    ] {
        assert!(stdout.contains(option), "help omitted {option}: {stdout}");
    }
    assert!(
        !stdout.contains("--execution-convention"),
        "stdout: {stdout}"
    );
    assert!(stdout.contains("[default: standard]"), "stdout: {stdout}");
    assert!(stdout.contains("[default: bounded]"), "stdout: {stdout}");
    assert!(stdout.contains("[default: stream]"), "stdout: {stdout}");
    for value in ["- stream:", "- poll:", "- sync:"] {
        assert!(stdout.contains(value), "help omitted {value}: {stdout}");
    }
    assert!(stdout.contains("Default: 4096"), "stdout: {stdout}");
    assert!(
        stdout.contains("--risk-per-trade 100 --account-currency USD"),
        "stdout: {stdout}"
    );
}

#[test]
fn invalid_mtm_output_combinations_fail_before_loading_input() {
    let unrelated_max = run_tg_backtest(&[
        "--input",
        "missing-signals.jsonl",
        "--exchange",
        "fixture",
        "--mtm-output",
        "full",
        "--mtm-max-points",
        "64",
    ]);
    let unrelated_max_stderr = String::from_utf8_lossy(&unrelated_max.stderr);
    assert!(!unrelated_max.status.success());
    assert!(
        unrelated_max_stderr.contains("--mtm-max-points requires --mtm-output bounded"),
        "stderr: {unrelated_max_stderr}"
    );

    let out_of_range = run_tg_backtest(&[
        "--input",
        "missing-signals.jsonl",
        "--exchange",
        "fixture",
        "--mtm-max-points",
        "7",
    ]);
    let out_of_range_stderr = String::from_utf8_lossy(&out_of_range.stderr);
    assert!(!out_of_range.status.success());
    assert!(
        out_of_range_stderr.contains("--mtm-max-points must be between 8 and 16384"),
        "stderr: {out_of_range_stderr}"
    );

    let full_inline = run_tg_backtest(&[
        "--input",
        "missing-signals.jsonl",
        "--exchange",
        "fixture",
        "--mtm-output",
        "full",
        "--result-delivery",
        "inline",
    ]);
    let full_inline_stderr = String::from_utf8_lossy(&full_inline.stderr);
    assert!(!full_inline.status.success());
    assert!(
        full_inline_stderr
            .contains("--mtm-output full cannot be used with --result-delivery inline"),
        "stderr: {full_inline_stderr}"
    );
    assert!(
        !full_inline_stderr.contains("No such file"),
        "validation should run before loading input: {full_inline_stderr}"
    );
}