feat(0082): cost-graph composition — VolSlippageCost + CostSum net-R aggregate
Cycle 2 of the "Cost-model graph (in R)" milestone (#148): the milestone's real architectural claim — the cost graph composes. Two cost nodes now sum into one net-R curve while summarize_r and the net_r_equity tap stay structurally unchanged. - VolSlippageCost (aura-std): a second, STATE-DEPENDENT cost node; per-trade charge = slip_vol_mult * volatility / |entry-stop|, in R. The vol is an independent short-horizon realized range (SLIP_VOL_LENGTH=5, distinct from the stop's EWMA-3) — scaling slippage by the stop's own vol would collapse cost-in-R to a constant (indistinguishable from ConstantCost). - CostSum (aura-std): the cost-graph OUTPUT node — sums N cost nodes' 3-field cost-in-R records per-field into one aggregate. summarize_r and net_r_equity read the aggregate (one home for cost; n=1 is the identity, so the cost path is uniform). A future CostNode trait (deferred) will unify the cost-triple the two producer nodes currently restate by-convention. - Run path: --cost-per-trade and --slip-vol-mult combine, their costs summing into the net-R curve; a hoisted vol proxy (RollingMax-RollingMin) keeps the single feed. Run-path-scoped; sweep/walkforward/mc pass None. Co-temporality contract (the load-bearing design decision; corrected from the signed spec after the implement-loop correctly BLOCKED Task 3 on it). summarize_r positional-joins cost[i] <-> record[i], so the cost stream must be co-temporal 1:1 with the PM record. A cost node is therefore gated ONLY by the PM geometry (closed/open/entry/stop); a not-yet-warm state input (the vol proxy warms later than PM) contributes 0 cost that cycle rather than withholding and desyncing the stream. This makes co-temporality structural + warmup-independent, preserves the C18 golden, and generalizes to any future cost factor. The rejected alternative (a key-join in summarize_r) would have moved the golden and pushed cost-graph logic into the post-run fold. Recorded on #148. Tests: VolSlippageCost + CostSum unit sets (incl. the co-temporal-zero-during- warmup case); the CLI composition run (both flags -> net_both < net_flat, net_r_equity persisted); node-level EXACT additive composition (net_both == net_flat + net_vol - gross over the real nodes), the aggregate net_r_equity == post-run net total, and CostSum(1) identity. Full workspace suite green; clippy -D warnings clean; the no-cost C18 golden byte-identical (the regression floor). Deferred (later cycles of this milestone): the general CostNode trait + cost-graph composite-builder (now justified by two concrete nodes), the conviction-weighting R-aggregation axis, and cost on the reduce-mode sweep path. Spec + plan amended to the corrected contract; both are cycle ephemera (git rm at cycle close). refs #148
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
//! `CostSum` — the output node of a C10 cost-model graph: it sums `n_costs`
|
||||
//! cost-in-R records per-field into one aggregate record, so any number of cost
|
||||
//! nodes collapses to the single 3-field cost stream the net-R seam already
|
||||
//! consumes (`summarize_r` + the `net_r_equity` tap stay unchanged). Each cost
|
||||
//! node contributes the 3-field `{cost_in_r, cum_cost_in_r, open_cost_in_r}`
|
||||
//! record; the aggregate is the per-field sum. `n_costs = 1` is the identity, so
|
||||
//! the cost path is uniform whether one or several cost nodes are wired.
|
||||
|
||||
use aura_core::{
|
||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind,
|
||||
};
|
||||
|
||||
/// The cost-record field triple every cost node emits, in slot order, and its
|
||||
/// width. Interned once (mirroring `position_management::FIELD_NAMES`/`WIDTH`) so
|
||||
/// the input-name loop, the output schema, the lookback vector, and the eval
|
||||
/// accumulator all read one source of truth instead of restating the triple. The
|
||||
/// producer nodes (`ConstantCost`, `VolSlippageCost`) emit this same triple; that
|
||||
/// cross-node match remains a by-convention lockstep contract.
|
||||
const COST_FIELDS: [&str; 3] = ["cost_in_r", "cum_cost_in_r", "open_cost_in_r"];
|
||||
const COST_WIDTH: usize = COST_FIELDS.len();
|
||||
|
||||
/// Per-field sum of `n_costs` cost-in-R records. Inputs are
|
||||
/// `cost[k].{cost_in_r,cum_cost_in_r,open_cost_in_r}` for `k in 0..n_costs`, in
|
||||
/// slot order (3 per cost node); the 3-field output mirrors a single cost record.
|
||||
/// Emits `None` until every input leg is present (mode-A as-of join, like LinComb).
|
||||
pub struct CostSum {
|
||||
n_costs: usize,
|
||||
out: [Cell; COST_WIDTH],
|
||||
}
|
||||
|
||||
impl CostSum {
|
||||
pub fn new(n_costs: usize) -> Self {
|
||||
assert!(n_costs >= 1, "CostSum needs at least one cost input");
|
||||
Self { n_costs, out: [Cell::from_f64(0.0); COST_WIDTH] }
|
||||
}
|
||||
|
||||
/// The param-generic recipe. `n_costs` is topology (fixed per blueprint, C19),
|
||||
/// captured by the build closure (no per-build params). The input names are a
|
||||
/// lockstep contract with the connect side (`cost[k].<field>`).
|
||||
pub fn builder(n_costs: usize) -> PrimitiveBuilder {
|
||||
let mut inputs = Vec::with_capacity(n_costs * COST_WIDTH);
|
||||
for k in 0..n_costs {
|
||||
for field in COST_FIELDS {
|
||||
inputs.push(PortSpec {
|
||||
kind: ScalarKind::F64,
|
||||
firing: Firing::Any,
|
||||
name: format!("cost[{k}].{field}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
PrimitiveBuilder::new(
|
||||
"CostSum",
|
||||
NodeSchema {
|
||||
inputs,
|
||||
output: COST_FIELDS
|
||||
.iter()
|
||||
.map(|n| FieldSpec { name: (*n).into(), kind: ScalarKind::F64 })
|
||||
.collect(),
|
||||
params: vec![],
|
||||
},
|
||||
move |_| Box::new(CostSum::new(n_costs)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Node for CostSum {
|
||||
fn lookbacks(&self) -> Vec<usize> {
|
||||
vec![1; self.n_costs * COST_WIDTH]
|
||||
}
|
||||
|
||||
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
|
||||
let mut acc = [0.0_f64; COST_WIDTH]; // per-field accumulator, COST_FIELDS order
|
||||
for k in 0..self.n_costs {
|
||||
for (f, slot) in acc.iter_mut().enumerate() {
|
||||
let w = ctx.f64_in(k * COST_WIDTH + f);
|
||||
if w.is_empty() {
|
||||
return None;
|
||||
}
|
||||
*slot += w[0];
|
||||
}
|
||||
}
|
||||
self.out = [Cell::from_f64(acc[0]), Cell::from_f64(acc[1]), Cell::from_f64(acc[2])];
|
||||
Some(&self.out)
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
format!("CostSum({})", self.n_costs)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aura_core::{AnyColumn, Scalar, Timestamp};
|
||||
|
||||
fn f64_cols(n: usize) -> Vec<AnyColumn> {
|
||||
(0..n).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_records_sum_per_field() {
|
||||
let mut s = CostSum::new(2);
|
||||
let mut inputs = f64_cols(6);
|
||||
// cost[0] = [0.5, 0.5, 0.0]; cost[1] = [0.375, 1.0, 0.2]
|
||||
for (i, v) in [0.5, 0.5, 0.0, 0.375, 1.0, 0.2].into_iter().enumerate() {
|
||||
inputs[i].push(Scalar::f64(v)).unwrap();
|
||||
}
|
||||
// per-field sum: [0.875, 1.5, 0.2]
|
||||
assert_eq!(
|
||||
s.eval(Ctx::new(&inputs, Timestamp(0))),
|
||||
Some([Cell::from_f64(0.875), Cell::from_f64(1.5), Cell::from_f64(0.2)].as_slice())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn n_one_is_identity() {
|
||||
let mut s = CostSum::new(1);
|
||||
let mut inputs = f64_cols(3);
|
||||
for (i, v) in [0.5, 1.25, 0.3].into_iter().enumerate() {
|
||||
inputs[i].push(Scalar::f64(v)).unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
s.eval(Ctx::new(&inputs, Timestamp(0))),
|
||||
Some([Cell::from_f64(0.5), Cell::from_f64(1.25), Cell::from_f64(0.3)].as_slice())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn withholds_until_every_leg_present() {
|
||||
let mut s = CostSum::new(2);
|
||||
let mut inputs = f64_cols(6);
|
||||
// only the first cost node's three fields present -> withhold
|
||||
for col in inputs.iter_mut().take(3) {
|
||||
col.push(Scalar::f64(1.0)).unwrap();
|
||||
}
|
||||
assert_eq!(s.eval(Ctx::new(&inputs, Timestamp(0))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_slots_are_named_cost_index_field() {
|
||||
let s = CostSum::builder(2);
|
||||
let names: Vec<String> = s.schema().inputs.iter().map(|p| p.name.clone()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"cost[0].cost_in_r", "cost[0].cum_cost_in_r", "cost[0].open_cost_in_r",
|
||||
"cost[1].cost_in_r", "cost[1].cum_cost_in_r", "cost[1].open_cost_in_r",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_carries_the_arity() {
|
||||
assert_eq!(CostSum::new(2).label(), "CostSum(2)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "CostSum needs at least one cost input")]
|
||||
fn new_panics_on_zero() {
|
||||
let _ = CostSum::new(0);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ mod add;
|
||||
mod and;
|
||||
mod bias;
|
||||
mod constant_cost;
|
||||
mod cost_sum;
|
||||
mod delay;
|
||||
mod ema;
|
||||
mod eqconst;
|
||||
@@ -41,10 +42,12 @@ mod sma;
|
||||
mod sqrt;
|
||||
mod stop_rule;
|
||||
mod sub;
|
||||
mod vol_slippage_cost;
|
||||
pub use add::Add;
|
||||
pub use and::And;
|
||||
pub use bias::Bias;
|
||||
pub use constant_cost::ConstantCost;
|
||||
pub use cost_sum::CostSum;
|
||||
pub use delay::Delay;
|
||||
pub use ema::Ema;
|
||||
pub use eqconst::EqConst;
|
||||
@@ -70,3 +73,4 @@ pub use sma::Sma;
|
||||
pub use sqrt::Sqrt;
|
||||
pub use stop_rule::FixedStop;
|
||||
pub use sub::Sub;
|
||||
pub use vol_slippage_cost::VolSlippageCost;
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
//! `VolSlippageCost` — a slippage cost that scales with a measured volatility
|
||||
//! input, charged once per closed trade, in R. The second cost node of the C10
|
||||
//! cost-model graph and the first *state-dependent* one: identical in shape to
|
||||
//! [`crate::ConstantCost`] but its per-trade charge numerator is
|
||||
//! `slip_vol_mult · volatility` instead of a flat constant, so the cost-in-R
|
||||
//! varies trade-to-trade. R-pure: `slip_vol_mult · vol / |entry - stop|`;
|
||||
//! notional cancels (C10). The vol is supplied as an input (an upstream
|
||||
//! realized-range estimator), kept independent of the stop's own vol — scaling
|
||||
//! by the stop's vol would collapse cost-in-R to a constant.
|
||||
//!
|
||||
//! Co-temporality contract: a cost node's stream must stay 1:1 with the
|
||||
//! executor's PM record (the positional join `summarize_r` relies on). So the
|
||||
//! node is gated ONLY by the PM geometry; a not-yet-warm `volatility` input (the
|
||||
//! realized-range proxy warms later than PM) contributes 0 cost that cycle rather
|
||||
//! than withholding and desyncing the stream — honest, since there is no slippage
|
||||
//! estimate yet. This generalizes to any state-dependent cost factor.
|
||||
|
||||
use aura_core::{
|
||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
|
||||
ScalarKind,
|
||||
};
|
||||
|
||||
/// A volatility-scaled per-trade slippage, emitted in R. Inputs are the four
|
||||
/// executor-exposed geometry fields `closed`/`open`/`entry_price`/`stop_price`
|
||||
/// plus a `volatility` stream (price units). Withholds (`None`) only until the
|
||||
/// four geometry inputs are present; a not-yet-warm `volatility` contributes 0
|
||||
/// cost, so the stream stays co-temporal with the PM record.
|
||||
pub struct VolSlippageCost {
|
||||
slip_vol_mult: f64,
|
||||
cum: f64,
|
||||
out: [Cell; 3],
|
||||
}
|
||||
|
||||
impl VolSlippageCost {
|
||||
pub fn new(slip_vol_mult: f64) -> Self {
|
||||
assert!(slip_vol_mult >= 0.0, "VolSlippageCost slip_vol_mult must be >= 0");
|
||||
Self { slip_vol_mult, cum: 0.0, out: [Cell::from_f64(0.0); 3] }
|
||||
}
|
||||
|
||||
/// The param-generic recipe: one `slip_vol_mult` F64 knob; five inputs.
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::new(
|
||||
"VolSlippageCost",
|
||||
NodeSchema {
|
||||
inputs: vec![
|
||||
PortSpec { kind: ScalarKind::Bool, firing: Firing::Any, name: "closed".into() },
|
||||
PortSpec { kind: ScalarKind::Bool, firing: Firing::Any, name: "open".into() },
|
||||
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "entry_price".into() },
|
||||
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "stop_price".into() },
|
||||
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "volatility".into() },
|
||||
],
|
||||
output: vec![
|
||||
FieldSpec { name: "cost_in_r".into(), kind: ScalarKind::F64 },
|
||||
FieldSpec { name: "cum_cost_in_r".into(), kind: ScalarKind::F64 },
|
||||
FieldSpec { name: "open_cost_in_r".into(), kind: ScalarKind::F64 },
|
||||
],
|
||||
params: vec![ParamSpec { name: "slip_vol_mult".into(), kind: ScalarKind::F64 }],
|
||||
},
|
||||
|p| Box::new(VolSlippageCost::new(p[0].f64())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Node for VolSlippageCost {
|
||||
fn lookbacks(&self) -> Vec<usize> {
|
||||
vec![1, 1, 1, 1, 1]
|
||||
}
|
||||
|
||||
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
|
||||
let closed_w = ctx.bool_in(0);
|
||||
let open_w = ctx.bool_in(1);
|
||||
let entry_w = ctx.f64_in(2);
|
||||
let stop_w = ctx.f64_in(3);
|
||||
let vol_w = ctx.f64_in(4);
|
||||
// Gate only on the PM geometry (co-temporality contract); a not-yet-warm
|
||||
// volatility proxy contributes 0 cost rather than withholding the row.
|
||||
if closed_w.is_empty() || open_w.is_empty() || entry_w.is_empty() || stop_w.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let closed = closed_w[0];
|
||||
let open = open_w[0];
|
||||
let latched = (entry_w[0] - stop_w[0]).abs();
|
||||
let vol = if vol_w.is_empty() { 0.0 } else { vol_w[0] }; // 0 during proxy warm-up
|
||||
// Same zero-latched guard as ConstantCost: no valid 1R denominator -> no cost.
|
||||
let per = if latched > 0.0 { self.slip_vol_mult * vol / latched } else { 0.0 };
|
||||
let cost_in_r = if closed { per } else { 0.0 };
|
||||
let open_cost_in_r = if open { per } else { 0.0 };
|
||||
self.cum += cost_in_r;
|
||||
self.out = [
|
||||
Cell::from_f64(cost_in_r),
|
||||
Cell::from_f64(self.cum),
|
||||
Cell::from_f64(open_cost_in_r),
|
||||
];
|
||||
Some(&self.out)
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
format!("VolSlippageCost({})", self.slip_vol_mult)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aura_core::{AnyColumn, Scalar, Timestamp};
|
||||
|
||||
fn cols() -> Vec<AnyColumn> {
|
||||
vec![
|
||||
AnyColumn::with_capacity(ScalarKind::Bool, 1), // closed
|
||||
AnyColumn::with_capacity(ScalarKind::Bool, 1), // open
|
||||
AnyColumn::with_capacity(ScalarKind::F64, 1), // entry
|
||||
AnyColumn::with_capacity(ScalarKind::F64, 1), // stop
|
||||
AnyColumn::with_capacity(ScalarKind::F64, 1), // volatility
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_geometry_yet_withholds() {
|
||||
let mut c = VolSlippageCost::new(0.5);
|
||||
let inputs = cols(); // all columns empty
|
||||
assert_eq!(c.eval(Ctx::new(&inputs, Timestamp(0))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vol_not_yet_warm_emits_zero_cost_co_temporally() {
|
||||
// The realized-range proxy warms after the PM geometry; during warm-up the
|
||||
// node must still EMIT (a 0-cost row) so the cost stream stays 1:1 with the
|
||||
// PM record — withholding here would desync the positional join.
|
||||
let mut c = VolSlippageCost::new(0.5);
|
||||
let mut inputs = cols();
|
||||
inputs[0].push(Scalar::bool(true)).unwrap(); // closed
|
||||
inputs[1].push(Scalar::bool(false)).unwrap();
|
||||
inputs[2].push(Scalar::f64(100.0)).unwrap();
|
||||
inputs[3].push(Scalar::f64(96.0)).unwrap(); // latched 4.0
|
||||
// volatility column empty (proxy not warm) -> vol = 0 -> 0 cost, but a row IS emitted
|
||||
assert_eq!(
|
||||
c.eval(Ctx::new(&inputs, Timestamp(0))),
|
||||
Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.0)].as_slice())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_charges_mult_times_vol_over_latched() {
|
||||
let mut c = VolSlippageCost::new(0.5);
|
||||
let mut inputs = cols();
|
||||
inputs[0].push(Scalar::bool(true)).unwrap(); // closed
|
||||
inputs[1].push(Scalar::bool(false)).unwrap();
|
||||
inputs[2].push(Scalar::f64(100.0)).unwrap();
|
||||
inputs[3].push(Scalar::f64(96.0)).unwrap(); // latched 4.0
|
||||
inputs[4].push(Scalar::f64(3.0)).unwrap(); // vol 3.0
|
||||
// per = 0.5 * 3.0 / 4.0 = 0.375; cum = 0.375; open = 0.0
|
||||
assert_eq!(
|
||||
c.eval(Ctx::new(&inputs, Timestamp(0))),
|
||||
Some([Cell::from_f64(0.375), Cell::from_f64(0.375), Cell::from_f64(0.0)].as_slice())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_emits_would_be_cost_not_charged_to_cum() {
|
||||
let mut c = VolSlippageCost::new(0.5);
|
||||
let mut inputs = cols();
|
||||
inputs[0].push(Scalar::bool(false)).unwrap();
|
||||
inputs[1].push(Scalar::bool(true)).unwrap(); // open
|
||||
inputs[2].push(Scalar::f64(100.0)).unwrap();
|
||||
inputs[3].push(Scalar::f64(96.0)).unwrap(); // latched 4.0
|
||||
inputs[4].push(Scalar::f64(3.0)).unwrap(); // vol 3.0
|
||||
// cost_in_r = 0; cum 0; open_cost_in_r = 0.375
|
||||
assert_eq!(
|
||||
c.eval(Ctx::new(&inputs, Timestamp(0))),
|
||||
Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.375)].as_slice())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_latched_contributes_no_cost() {
|
||||
let mut c = VolSlippageCost::new(0.5);
|
||||
let mut inputs = cols();
|
||||
inputs[0].push(Scalar::bool(true)).unwrap();
|
||||
inputs[1].push(Scalar::bool(false)).unwrap();
|
||||
inputs[2].push(Scalar::f64(100.0)).unwrap();
|
||||
inputs[3].push(Scalar::f64(100.0)).unwrap(); // latched 0 -> no divide
|
||||
inputs[4].push(Scalar::f64(3.0)).unwrap();
|
||||
assert_eq!(
|
||||
c.eval(Ctx::new(&inputs, Timestamp(0))),
|
||||
Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.0)].as_slice())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cum_accumulates_across_closes() {
|
||||
let mut c = VolSlippageCost::new(0.5);
|
||||
let mut a = cols();
|
||||
a[0].push(Scalar::bool(true)).unwrap();
|
||||
a[1].push(Scalar::bool(false)).unwrap();
|
||||
a[2].push(Scalar::f64(100.0)).unwrap();
|
||||
a[3].push(Scalar::f64(96.0)).unwrap(); // latched 4
|
||||
a[4].push(Scalar::f64(3.0)).unwrap(); // 0.5*3/4 = 0.375
|
||||
let _ = c.eval(Ctx::new(&a, Timestamp(0)));
|
||||
let mut b = cols();
|
||||
b[0].push(Scalar::bool(true)).unwrap();
|
||||
b[1].push(Scalar::bool(false)).unwrap();
|
||||
b[2].push(Scalar::f64(100.0)).unwrap();
|
||||
b[3].push(Scalar::f64(98.0)).unwrap(); // latched 2
|
||||
b[4].push(Scalar::f64(4.0)).unwrap(); // 0.5*4/2 = 1.0; cum 1.375
|
||||
assert_eq!(
|
||||
c.eval(Ctx::new(&b, Timestamp(1))),
|
||||
Some([Cell::from_f64(1.0), Cell::from_f64(1.375), Cell::from_f64(0.0)].as_slice())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_carries_the_mult() {
|
||||
assert_eq!(VolSlippageCost::new(0.5).label(), "VolSlippageCost(0.5)");
|
||||
assert_eq!(VolSlippageCost::new(2.0).label(), "VolSlippageCost(2)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "slip_vol_mult must be >= 0")]
|
||||
fn new_panics_on_negative_mult() {
|
||||
let _ = VolSlippageCost::new(-1.0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user