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:
@@ -570,13 +570,7 @@ async fn run_single_backtest(
|
||||
.await
|
||||
.context("poll")?;
|
||||
|
||||
Ok(BacktestResult::from_response(
|
||||
&final_resp,
|
||||
&inst.symbol,
|
||||
&inst.exchange,
|
||||
&inst.base(),
|
||||
&inst.quote(),
|
||||
))
|
||||
Ok(BacktestResult::from_response(&final_resp, &inst.symbol))
|
||||
}
|
||||
|
||||
fn save_validated_strategy(
|
||||
|
||||
@@ -107,6 +107,8 @@ Buy a fixed number of base units (semantic alias for a decimal string):
|
||||
```json
|
||||
{{"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"` —
|
||||
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"}}}}
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
60
src/swym.rs
60
src/swym.rs
@@ -44,6 +44,8 @@ pub struct CandleCoverage {
|
||||
pub first_open: String,
|
||||
pub last_close: String,
|
||||
pub count: u64,
|
||||
pub expected_count: Option<u64>,
|
||||
pub coverage_pct: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -66,17 +68,8 @@ pub struct BacktestResult {
|
||||
}
|
||||
|
||||
impl BacktestResult {
|
||||
/// Parse a backtest response.
|
||||
///
|
||||
/// `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 {
|
||||
/// Parse a backtest response from the flat `result_summary` structure.
|
||||
pub fn from_response(resp: &PaperRunResponse, instrument: &str) -> Self {
|
||||
let summary = resp.result_summary.as_ref();
|
||||
if let Some(s) = summary {
|
||||
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");
|
||||
}
|
||||
|
||||
// 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 {
|
||||
run_id: resp.id,
|
||||
instrument: instrument.to_string(),
|
||||
status: resp.status.clone(),
|
||||
total_positions,
|
||||
winning_positions: None,
|
||||
losing_positions: None,
|
||||
win_rate: inst_stats.and_then(|s| parse_ratio_value(&s["win_rate"])),
|
||||
profit_factor: inst_stats.and_then(|s| parse_ratio_value(&s["profit_factor"])),
|
||||
total_pnl: inst_stats.and_then(|s| parse_decimal_str(&s["pnl"])),
|
||||
net_pnl: inst_stats.and_then(|s| parse_decimal_str(&s["pnl"])),
|
||||
sharpe_ratio: inst_stats.and_then(|s| parse_ratio_value(&s["sharpe_ratio"])),
|
||||
total_fees: None,
|
||||
avg_bars_in_trade: None,
|
||||
total_positions: summary.and_then(|s| s["total_positions"].as_u64().map(|v| v as u32)),
|
||||
winning_positions: summary.and_then(|s| s["winning_positions"].as_u64().map(|v| v as u32)),
|
||||
losing_positions: summary.and_then(|s| s["losing_positions"].as_u64().map(|v| v as u32)),
|
||||
win_rate: summary.and_then(|s| parse_number(&s["win_rate"])),
|
||||
profit_factor: summary.and_then(|s| parse_number(&s["profit_factor"])),
|
||||
total_pnl: summary.and_then(|s| parse_number(&s["total_pnl"])),
|
||||
net_pnl: summary.and_then(|s| parse_number(&s["net_pnl"])),
|
||||
sharpe_ratio: summary.and_then(|s| parse_number(&s["sharpe_ratio"])),
|
||||
total_fees: summary.and_then(|s| parse_number(&s["total_fees"])),
|
||||
avg_bars_in_trade: summary.and_then(|s| parse_number(&s["avg_bars_in_trade"])),
|
||||
error_message: resp.error_message.clone(),
|
||||
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.
|
||||
/// Returns `None` for null, missing, or sentinel values (Decimal::MAX ≈ 7.9e28).
|
||||
fn parse_ratio_value(v: &Value) -> Option<f64> {
|
||||
let s = v.get("value")?.as_str()?;
|
||||
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()?;
|
||||
/// Parse a numeric JSON value — accepts either a plain JSON number or a decimal string.
|
||||
/// Returns `None` for null, missing, or sentinel values (>1e20 in magnitude).
|
||||
fn parse_number(v: &Value) -> Option<f64> {
|
||||
let f = v.as_f64().or_else(|| v.as_str()?.parse().ok())?;
|
||||
if f.abs() > 1e20 { None } else { Some(f) }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user