fix: parse flat result_summary structure per updated API doc

The API result_summary is a flat object with top-level fields
(total_positions, win_rate, profit_factor, net_pnl, sharpe_ratio, etc.)
not a nested backtest_metadata/instruments map. This was causing all
metrics to parse as None/zero for every completed run.

- Rewrite BacktestResult::from_response() to read flat fields directly
- Replace parse_ratio_value/parse_decimal_str with a single parse_number()
  that accepts both JSON numbers and decimal strings
- Populate winning_positions, losing_positions, total_fees, avg_bars_in_trade
  (previously always None)
- Simplify from_response signature — exchange/base/quote no longer needed
- Add expected_count and coverage_pct to CandleCoverage struct
- Update all example sell rules to use position_quantity instead of "0.01"
- Note that "9999" is a valid sell-all alias (auto-capped by the API)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 09:37:55 +02:00
parent 3f8d4de7fb
commit ee260ea4d5
3 changed files with 25 additions and 53 deletions

View File

@@ -570,13 +570,7 @@ async fn run_single_backtest(
.await .await
.context("poll")?; .context("poll")?;
Ok(BacktestResult::from_response( Ok(BacktestResult::from_response(&final_resp, &inst.symbol))
&final_resp,
&inst.symbol,
&inst.exchange,
&inst.base(),
&inst.quote(),
))
} }
fn save_validated_strategy( fn save_validated_strategy(

View File

@@ -107,6 +107,8 @@ Buy a fixed number of base units (semantic alias for a decimal string):
```json ```json
{{"kind":"position_quantity"}} {{"kind":"position_quantity"}}
``` ```
Alternatively, `"9999"` works for exits: sell quantities are automatically capped to the open
position size, so a large fixed number is equivalent to `position_quantity`.
NEVER use placeholder strings like `"ATR_SIZED"`, `"FULL_BALANCE"`, `"all"`, `"dynamic"` — NEVER use placeholder strings like `"ATR_SIZED"`, `"FULL_BALANCE"`, `"all"`, `"dynamic"` —
these are rejected immediately. these are rejected immediately.
@@ -251,7 +253,7 @@ Common mistakes to NEVER make:
}} }}
] ]
}}, }},
"then": {{"side": "sell", "quantity": "0.01"}} "then": {{"side": "sell", "quantity": {{"kind": "position_quantity"}}}}
}} }}
] ]
}} }}
@@ -302,7 +304,7 @@ Common mistakes to NEVER make:
}} }}
] ]
}}, }},
"then": {{"side": "sell", "quantity": "0.01"}} "then": {{"side": "sell", "quantity": {{"kind": "position_quantity"}}}}
}} }}
] ]
}} }}
@@ -364,7 +366,7 @@ Common mistakes to NEVER make:
}} }}
] ]
}}, }},
"then": {{"side": "sell", "quantity": "0.01"}} "then": {{"side": "sell", "quantity": {{"kind": "position_quantity"}}}}
}} }}
] ]
}} }}
@@ -449,7 +451,7 @@ The MACD line is `EMA(12) - EMA(26)`; the signal line is `EMA(9)` of the MACD li
}} }}
] ]
}}, }},
"then": {{"side": "sell", "quantity": "0.01"}} "then": {{"side": "sell", "quantity": {{"kind": "position_quantity"}}}}
}} }}
] ]
}} }}

View File

@@ -44,6 +44,8 @@ pub struct CandleCoverage {
pub first_open: String, pub first_open: String,
pub last_close: String, pub last_close: String,
pub count: u64, pub count: u64,
pub expected_count: Option<u64>,
pub coverage_pct: Option<f64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -66,17 +68,8 @@ pub struct BacktestResult {
} }
impl BacktestResult { impl BacktestResult {
/// Parse a backtest response. /// Parse a backtest response from the flat `result_summary` structure.
/// pub fn from_response(resp: &PaperRunResponse, instrument: &str) -> Self {
/// `exchange`, `base`, `quote` are needed to derive the instrument key used
/// in the `result_summary.instruments` map (e.g. `binancespot-eth_usdc`).
pub fn from_response(
resp: &PaperRunResponse,
instrument: &str,
exchange: &str,
base: &str,
quote: &str,
) -> Self {
let summary = resp.result_summary.as_ref(); let summary = resp.result_summary.as_ref();
if let Some(s) = summary { if let Some(s) = summary {
tracing::debug!("[{instrument}] result_summary: {}", serde_json::to_string(s).unwrap_or_default()); tracing::debug!("[{instrument}] result_summary: {}", serde_json::to_string(s).unwrap_or_default());
@@ -84,29 +77,20 @@ impl BacktestResult {
tracing::debug!("[{instrument}] result_summary: null"); tracing::debug!("[{instrument}] result_summary: null");
} }
// The API key for per-instrument stats: "binance_spot" + "eth" + "usdc" → "binancespot-eth_usdc"
let inst_key = format!("{}-{}_{}", exchange.replace('_', ""), base, quote);
let total_positions = summary.and_then(|s| {
s["backtest_metadata"]["position_count"].as_u64().map(|v| v as u32)
});
let inst_stats = summary.and_then(|s| s["instruments"].get(&inst_key));
Self { Self {
run_id: resp.id, run_id: resp.id,
instrument: instrument.to_string(), instrument: instrument.to_string(),
status: resp.status.clone(), status: resp.status.clone(),
total_positions, total_positions: summary.and_then(|s| s["total_positions"].as_u64().map(|v| v as u32)),
winning_positions: None, winning_positions: summary.and_then(|s| s["winning_positions"].as_u64().map(|v| v as u32)),
losing_positions: None, losing_positions: summary.and_then(|s| s["losing_positions"].as_u64().map(|v| v as u32)),
win_rate: inst_stats.and_then(|s| parse_ratio_value(&s["win_rate"])), win_rate: summary.and_then(|s| parse_number(&s["win_rate"])),
profit_factor: inst_stats.and_then(|s| parse_ratio_value(&s["profit_factor"])), profit_factor: summary.and_then(|s| parse_number(&s["profit_factor"])),
total_pnl: inst_stats.and_then(|s| parse_decimal_str(&s["pnl"])), total_pnl: summary.and_then(|s| parse_number(&s["total_pnl"])),
net_pnl: inst_stats.and_then(|s| parse_decimal_str(&s["pnl"])), net_pnl: summary.and_then(|s| parse_number(&s["net_pnl"])),
sharpe_ratio: inst_stats.and_then(|s| parse_ratio_value(&s["sharpe_ratio"])), sharpe_ratio: summary.and_then(|s| parse_number(&s["sharpe_ratio"])),
total_fees: None, total_fees: summary.and_then(|s| parse_number(&s["total_fees"])),
avg_bars_in_trade: None, avg_bars_in_trade: summary.and_then(|s| parse_number(&s["avg_bars_in_trade"])),
error_message: resp.error_message.clone(), error_message: resp.error_message.clone(),
condition_audit_summary: summary.and_then(|s| s.get("condition_audit_summary").cloned()), condition_audit_summary: summary.and_then(|s| s.get("condition_audit_summary").cloned()),
} }
@@ -151,18 +135,10 @@ impl BacktestResult {
} }
} }
/// Parse a `{"interval": null, "value": "123.45"}` ratio wrapper. /// Parse a numeric JSON value — accepts either a plain JSON number or a decimal string.
/// Returns `None` for null, missing, or sentinel values (Decimal::MAX ≈ 7.9e28). /// Returns `None` for null, missing, or sentinel values (>1e20 in magnitude).
fn parse_ratio_value(v: &Value) -> Option<f64> { fn parse_number(v: &Value) -> Option<f64> {
let s = v.get("value")?.as_str()?; let f = v.as_f64().or_else(|| v.as_str()?.parse().ok())?;
let f: f64 = s.parse().ok()?;
if f.abs() > 1e20 { None } else { Some(f) }
}
/// Parse a plain decimal string JSON value.
/// Returns `None` for null, missing, or sentinel values.
fn parse_decimal_str(v: &Value) -> Option<f64> {
let f: f64 = v.as_str()?.parse().ok()?;
if f.abs() > 1e20 { None } else { Some(f) } if f.abs() > 1e20 { None } else { Some(f) }
} }