fix: preserve unknown numeric conditions through boolean expressions
This commit is contained in:
@@ -11,6 +11,7 @@ pub(crate) enum ValueType {
|
||||
pub(crate) enum Value {
|
||||
Number(f64),
|
||||
Boolean(bool),
|
||||
Missing(ValueType),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
@@ -18,20 +19,28 @@ impl Value {
|
||||
match self {
|
||||
Self::Number(_) => ValueType::Number,
|
||||
Self::Boolean(_) => ValueType::Boolean,
|
||||
Self::Missing(value_type) => value_type,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn as_number(self) -> Option<f64> {
|
||||
match self {
|
||||
Self::Number(value) => Some(value),
|
||||
Self::Boolean(_) => None,
|
||||
Self::Boolean(_) | Self::Missing(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn as_bool(self) -> Option<bool> {
|
||||
match self {
|
||||
Self::Boolean(value) => Some(value),
|
||||
Self::Number(_) => None,
|
||||
Self::Number(_) | Self::Missing(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized(self) -> Self {
|
||||
match self {
|
||||
Self::Number(value) if !value.is_finite() => Self::Missing(ValueType::Number),
|
||||
value => value,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,6 +112,8 @@ enum BinaryOp {
|
||||
LessEqual,
|
||||
Greater,
|
||||
GreaterEqual,
|
||||
And,
|
||||
Or,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -134,7 +145,7 @@ enum Instruction {
|
||||
Binary(BinaryOp),
|
||||
Call { builtin: Builtin, argc: u8 },
|
||||
JumpIfFalse(usize),
|
||||
JumpIfTrue(usize),
|
||||
ShortCircuit { on: bool, target: usize },
|
||||
Jump(usize),
|
||||
Return,
|
||||
}
|
||||
@@ -165,7 +176,7 @@ impl Program {
|
||||
let mut pc = 0usize;
|
||||
while let Some(instruction) = self.instructions.get(pc) {
|
||||
match *instruction {
|
||||
Instruction::Push(value) => scratch.stack.push(value),
|
||||
Instruction::Push(value) => scratch.stack.push(value.normalized()),
|
||||
Instruction::LoadVariable(index) => {
|
||||
let index = usize::from(index);
|
||||
let cached = scratch.variables[index];
|
||||
@@ -173,7 +184,8 @@ impl Program {
|
||||
Some(value) => value,
|
||||
None => {
|
||||
let expected_type = self.variable_types[index];
|
||||
let value = resolve(index, &self.variables[index], expected_type)?;
|
||||
let value =
|
||||
resolve(index, &self.variables[index], expected_type)?.normalized();
|
||||
if value.value_type() != expected_type {
|
||||
return Err(EvalError::new(format!(
|
||||
"variable {} expected {:?}, got {:?}",
|
||||
@@ -219,15 +231,23 @@ impl Program {
|
||||
scratch.stack.push(value);
|
||||
}
|
||||
Instruction::JumpIfFalse(target) => {
|
||||
let condition = pop_bool(&mut scratch.stack)?;
|
||||
// Like CASE WHEN, only a known true condition takes this branch.
|
||||
let condition = match pop(&mut scratch.stack)? {
|
||||
Value::Boolean(value) => value,
|
||||
Value::Missing(ValueType::Boolean) => false,
|
||||
_ => return Err(EvalError::new("boolean operand required")),
|
||||
};
|
||||
if !condition {
|
||||
pc = target;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Instruction::JumpIfTrue(target) => {
|
||||
let condition = pop_bool(&mut scratch.stack)?;
|
||||
if condition {
|
||||
Instruction::ShortCircuit { on, target } => {
|
||||
let condition =
|
||||
scratch.stack.last().copied().ok_or_else(|| {
|
||||
EvalError::new("stack underflow during short circuit")
|
||||
})?;
|
||||
if condition.as_bool() == Some(on) {
|
||||
pc = target;
|
||||
continue;
|
||||
}
|
||||
@@ -284,31 +304,56 @@ fn pop(stack: &mut Vec<Value>) -> Result<Value, EvalError> {
|
||||
stack.pop().ok_or_else(|| EvalError::new("stack underflow"))
|
||||
}
|
||||
|
||||
fn pop_bool(stack: &mut Vec<Value>) -> Result<bool, EvalError> {
|
||||
pop(stack)?
|
||||
.as_bool()
|
||||
.ok_or_else(|| EvalError::new("boolean operand required"))
|
||||
}
|
||||
|
||||
fn number(value: Value) -> Result<f64, EvalError> {
|
||||
if value == Value::Missing(ValueType::Number) {
|
||||
return Ok(f64::NAN);
|
||||
}
|
||||
value
|
||||
.as_number()
|
||||
.ok_or_else(|| EvalError::new("numeric operand required"))
|
||||
}
|
||||
|
||||
fn eval_unary(operator: UnaryOp, value: Value) -> Result<Value, EvalError> {
|
||||
match operator {
|
||||
if matches!(value, Value::Missing(_)) {
|
||||
return Ok(value);
|
||||
}
|
||||
let result: Result<Value, EvalError> = match operator {
|
||||
UnaryOp::Negate => Ok(Value::Number(-number(value)?)),
|
||||
UnaryOp::Not => {
|
||||
Ok(Value::Boolean(!value.as_bool().ok_or_else(|| {
|
||||
EvalError::new("boolean operand required for !")
|
||||
})?))
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(result?.normalized())
|
||||
}
|
||||
|
||||
fn eval_binary(operator: BinaryOp, lhs: Value, rhs: Value) -> Result<Value, EvalError> {
|
||||
match operator {
|
||||
if matches!(operator, BinaryOp::And | BinaryOp::Or) {
|
||||
let (lhs, rhs) = (lhs.as_bool(), rhs.as_bool());
|
||||
let result = match operator {
|
||||
BinaryOp::And if lhs == Some(false) || rhs == Some(false) => Some(false),
|
||||
BinaryOp::And if lhs == Some(true) && rhs == Some(true) => Some(true),
|
||||
BinaryOp::Or if lhs == Some(true) || rhs == Some(true) => Some(true),
|
||||
BinaryOp::Or if lhs == Some(false) && rhs == Some(false) => Some(false),
|
||||
_ => None,
|
||||
};
|
||||
return Ok(result
|
||||
.map(Value::Boolean)
|
||||
.unwrap_or(Value::Missing(ValueType::Boolean)));
|
||||
}
|
||||
if matches!(lhs, Value::Missing(_)) || matches!(rhs, Value::Missing(_)) {
|
||||
let value_type = match operator {
|
||||
BinaryOp::Add
|
||||
| BinaryOp::Subtract
|
||||
| BinaryOp::Multiply
|
||||
| BinaryOp::Divide
|
||||
| BinaryOp::Remainder => ValueType::Number,
|
||||
_ => ValueType::Boolean,
|
||||
};
|
||||
return Ok(Value::Missing(value_type));
|
||||
}
|
||||
let result: Result<Value, EvalError> = match operator {
|
||||
BinaryOp::Add => Ok(Value::Number(number(lhs)? + number(rhs)?)),
|
||||
BinaryOp::Subtract => Ok(Value::Number(number(lhs)? - number(rhs)?)),
|
||||
BinaryOp::Multiply => Ok(Value::Number(number(lhs)? * number(rhs)?)),
|
||||
@@ -356,7 +401,27 @@ fn eval_binary(operator: BinaryOp, lhs: Value, rhs: Value) -> Result<Value, Eval
|
||||
(lhs - rhs) / float_comparison_scale(lhs, rhs) > -f64::EPSILON,
|
||||
))
|
||||
}
|
||||
BinaryOp::And | BinaryOp::Or => unreachable!(),
|
||||
};
|
||||
Ok(result?.normalized())
|
||||
}
|
||||
|
||||
pub(crate) fn finite_comparison(operator: &str, lhs: f64, rhs: f64) -> Option<bool> {
|
||||
if !lhs.is_finite() || !rhs.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let operator = match operator {
|
||||
"==" => BinaryOp::Equal,
|
||||
"!=" => BinaryOp::NotEqual,
|
||||
"<" => BinaryOp::Less,
|
||||
"<=" => BinaryOp::LessEqual,
|
||||
">" => BinaryOp::Greater,
|
||||
">=" => BinaryOp::GreaterEqual,
|
||||
_ => return None,
|
||||
};
|
||||
eval_binary(operator, Value::Number(lhs), Value::Number(rhs))
|
||||
.ok()?
|
||||
.as_bool()
|
||||
}
|
||||
|
||||
fn float_comparison_scale(lhs: f64, rhs: f64) -> f64 {
|
||||
@@ -382,7 +447,16 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result<Value, EvalError> {
|
||||
.ok_or_else(|| EvalError::new("missing builtin argument"))
|
||||
.and_then(number)
|
||||
};
|
||||
Ok(match builtin {
|
||||
if !matches!(builtin, Builtin::Nz | Builtin::SafeDiv | Builtin::Iff)
|
||||
&& args.iter().any(|value| matches!(value, Value::Missing(_)))
|
||||
{
|
||||
return Ok(Value::Missing(if builtin == Builtin::Between {
|
||||
ValueType::Boolean
|
||||
} else {
|
||||
ValueType::Number
|
||||
}));
|
||||
}
|
||||
let result = match builtin {
|
||||
Builtin::Round => Value::Number(numeric(0)?.round()),
|
||||
Builtin::Floor => Value::Number(numeric(0)?.floor()),
|
||||
Builtin::Ceil => Value::Number(numeric(0)?.ceil()),
|
||||
@@ -393,7 +467,13 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result<Value, EvalError> {
|
||||
Builtin::Pow => Value::Number(numeric(0)?.powf(numeric(1)?)),
|
||||
Builtin::Log => Value::Number(numeric(0)?.ln()),
|
||||
Builtin::Exp => Value::Number(numeric(0)?.exp()),
|
||||
Builtin::Clamp => Value::Number(numeric(0)?.clamp(numeric(1)?, numeric(2)?)),
|
||||
Builtin::Clamp => {
|
||||
let (value, low, high) = (numeric(0)?, numeric(1)?, numeric(2)?);
|
||||
if low > high {
|
||||
return Err(EvalError::new("clamp lower bound exceeds upper bound"));
|
||||
}
|
||||
Value::Number(value.clamp(low, high))
|
||||
}
|
||||
Builtin::Between => {
|
||||
let value = numeric(0)?;
|
||||
Value::Boolean(value >= numeric(1)? && value <= numeric(2)?)
|
||||
@@ -417,13 +497,15 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result<Value, EvalError> {
|
||||
})
|
||||
}
|
||||
Builtin::Iff => {
|
||||
let condition = args
|
||||
.first()
|
||||
.and_then(|value| value.as_bool())
|
||||
.ok_or_else(|| EvalError::new("iff condition must be boolean"))?;
|
||||
let condition = match args.first().copied() {
|
||||
Some(Value::Boolean(value)) => value,
|
||||
Some(Value::Missing(ValueType::Boolean)) => false,
|
||||
_ => return Err(EvalError::new("iff condition must be boolean")),
|
||||
};
|
||||
if condition { args[1] } else { args[2] }
|
||||
}
|
||||
})
|
||||
};
|
||||
Ok(result.normalized())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -1119,24 +1201,20 @@ where
|
||||
let lhs_type = self.expression(lhs)?;
|
||||
require_type(lhs_type, ValueType::Boolean, position)?;
|
||||
let branch = self.instructions.len();
|
||||
self.instructions.push(match operator {
|
||||
ParsedBinaryOp::And => Instruction::JumpIfFalse(usize::MAX),
|
||||
ParsedBinaryOp::Or => Instruction::JumpIfTrue(usize::MAX),
|
||||
_ => unreachable!(),
|
||||
self.instructions.push(Instruction::ShortCircuit {
|
||||
on: operator == ParsedBinaryOp::Or,
|
||||
target: usize::MAX,
|
||||
});
|
||||
let rhs_type = self.expression(rhs)?;
|
||||
require_type(rhs_type, ValueType::Boolean, rhs.position())?;
|
||||
let end_jump = self.instructions.len();
|
||||
self.instructions.push(Instruction::Jump(usize::MAX));
|
||||
let short_target = self.instructions.len();
|
||||
self.instructions
|
||||
.push(Instruction::Push(Value::Boolean(matches!(
|
||||
operator,
|
||||
ParsedBinaryOp::Or
|
||||
))));
|
||||
.push(Instruction::Binary(if operator == ParsedBinaryOp::And {
|
||||
BinaryOp::And
|
||||
} else {
|
||||
BinaryOp::Or
|
||||
}));
|
||||
let end_target = self.instructions.len();
|
||||
patch_jump(&mut self.instructions, branch, short_target)?;
|
||||
patch_jump(&mut self.instructions, end_jump, end_target)?;
|
||||
patch_jump(&mut self.instructions, branch, end_target)?;
|
||||
return Ok(ValueType::Boolean);
|
||||
}
|
||||
|
||||
@@ -1285,7 +1363,7 @@ fn patch_jump(
|
||||
};
|
||||
match instruction {
|
||||
Instruction::JumpIfFalse(value)
|
||||
| Instruction::JumpIfTrue(value)
|
||||
| Instruction::ShortCircuit { target: value, .. }
|
||||
| Instruction::Jump(value) => {
|
||||
*value = target;
|
||||
Ok(())
|
||||
@@ -1394,6 +1472,93 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nullable_boolean_truth_table_preserves_unknown_under_negation() {
|
||||
let unknown = Value::Missing(ValueType::Boolean);
|
||||
let states = [Value::Boolean(false), Value::Boolean(true), unknown];
|
||||
let and = [
|
||||
[states[0], states[0], states[0]],
|
||||
[states[0], states[1], unknown],
|
||||
[states[0], unknown, unknown],
|
||||
];
|
||||
let or = [
|
||||
[states[0], states[1], unknown],
|
||||
[states[1], states[1], states[1]],
|
||||
[unknown, states[1], unknown],
|
||||
];
|
||||
for (i, lhs) in states.iter().enumerate() {
|
||||
for (j, rhs) in states.iter().enumerate() {
|
||||
let values = [("lhs", *lhs), ("rhs", *rhs)];
|
||||
assert_eq!(evaluate("lhs && rhs", &values), and[i][j]);
|
||||
assert_eq!(evaluate("lhs || rhs", &values), or[i][j]);
|
||||
assert_eq!(evaluate("!!(lhs && rhs)", &values), and[i][j]);
|
||||
assert_eq!(evaluate("!!(lhs || rhs)", &values), or[i][j]);
|
||||
}
|
||||
}
|
||||
assert_eq!(evaluate("!value", &[("value", unknown)]), unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_numeric_operands_do_not_become_boolean_false_or_zero() {
|
||||
let unknown = Value::Missing(ValueType::Boolean);
|
||||
for missing in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
for operator in ["==", "!=", "<", "<=", ">", ">="] {
|
||||
let values = [("value", Value::Number(missing))];
|
||||
assert_eq!(evaluate(&format!("value {operator} 0.0"), &values), unknown);
|
||||
assert_eq!(
|
||||
evaluate(&format!("!(0.0 {operator} value)"), &values),
|
||||
unknown
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate(&format!("!((value + 1.0) {operator} 0.0)"), &values),
|
||||
unknown
|
||||
);
|
||||
}
|
||||
}
|
||||
let missing = [("value", Value::Number(f64::NAN))];
|
||||
assert_eq!(evaluate("!(min(value, 1.0) > 0.0)", &missing), unknown);
|
||||
assert_eq!(evaluate("!between(value, 0.0, 1.0)", &missing), unknown);
|
||||
assert_eq!(evaluate("!(1.0 / 0.0 > 0.0)", &[]), unknown);
|
||||
assert_eq!(evaluate("!(sqrt(-1.0) > 0.0)", &[]), unknown);
|
||||
assert_eq!(evaluate("nz(value, 7.0)", &missing), Value::Number(7.0));
|
||||
assert_eq!(
|
||||
evaluate("nz(value, 0.0) == 0.0", &missing),
|
||||
Value::Boolean(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nullable_short_circuit_and_explicit_conditional_branches_are_lazy() {
|
||||
for source in [
|
||||
"false && missing",
|
||||
"true || missing",
|
||||
"if true { true } else { missing }",
|
||||
] {
|
||||
let program = compile(source, |_| Some(ValueType::Boolean)).unwrap();
|
||||
program
|
||||
.evaluate(&mut Scratch::default(), |_, _, _| {
|
||||
Err(EvalError::new("unused input must not be resolved"))
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let unknown = Value::Missing(ValueType::Boolean);
|
||||
assert_eq!(
|
||||
evaluate("if value { 1.0 } else { 2.0 }", &[("value", unknown)]),
|
||||
Value::Number(2.0)
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate("iff(value, 1.0, 2.0)", &[("value", unknown)]),
|
||||
Value::Number(2.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_clamp_returns_error_without_panicking() {
|
||||
let program = compile("clamp(1.0, 2.0, 0.0)", |_| None).unwrap();
|
||||
let result = program.evaluate(&mut Scratch::default(), |_, _, _| unreachable!());
|
||||
assert!(result.unwrap_err().to_string().contains("lower bound"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_circuit_does_not_resolve_unused_variable() {
|
||||
let program = compile("false && missing", |name| {
|
||||
|
||||
@@ -1388,6 +1388,15 @@ pub struct PlatformSelectionQuotePlan {
|
||||
pub diagnostics: Vec<String>,
|
||||
}
|
||||
|
||||
fn checked_rhai_comparison(
|
||||
operator: &str,
|
||||
lhs: f64,
|
||||
rhs: f64,
|
||||
) -> Result<bool, Box<rhai::EvalAltResult>> {
|
||||
numeric_expr_vm::finite_comparison(operator, lhs, rhs)
|
||||
.ok_or_else(|| format!("missing_numeric_operand: operator={operator}").into())
|
||||
}
|
||||
|
||||
fn platform_safe_div(lhs: f64, rhs: f64, fallback: f64) -> f64 {
|
||||
if rhs.abs() <= f64::EPSILON {
|
||||
fallback
|
||||
@@ -1565,21 +1574,48 @@ impl PlatformExprStrategy {
|
||||
|
||||
pub fn new(config: PlatformExprStrategyConfig) -> Self {
|
||||
let mut engine = Engine::new();
|
||||
// Dynamic scripts cannot carry a nullable boolean through Rhai's
|
||||
// logical operators. Reject an unknown comparison instead of letting
|
||||
// native NaN comparisons turn missing data into a buy/sell signal.
|
||||
for operator in ["==", "!=", "<", "<=", ">", ">="] {
|
||||
engine.register_fn(operator, move |lhs: f64, rhs: f64| {
|
||||
checked_rhai_comparison(operator, lhs, rhs)
|
||||
});
|
||||
engine.register_fn(operator, move |lhs: f64, rhs: i64| {
|
||||
checked_rhai_comparison(operator, lhs, rhs as f64)
|
||||
});
|
||||
engine.register_fn(operator, move |lhs: i64, rhs: f64| {
|
||||
checked_rhai_comparison(operator, lhs as f64, rhs)
|
||||
});
|
||||
}
|
||||
engine.register_fn("round", |value: f64| value.round());
|
||||
engine.register_fn("floor", |value: f64| value.floor());
|
||||
engine.register_fn("ceil", |value: f64| value.ceil());
|
||||
engine.register_fn("abs", |value: f64| value.abs());
|
||||
engine.register_fn("min", |lhs: f64, rhs: f64| lhs.min(rhs));
|
||||
engine.register_fn("max", |lhs: f64, rhs: f64| lhs.max(rhs));
|
||||
engine.register_fn("min", |lhs: f64, rhs: f64| {
|
||||
if lhs.is_finite() && rhs.is_finite() { lhs.min(rhs) } else { f64::NAN }
|
||||
});
|
||||
engine.register_fn("max", |lhs: f64, rhs: f64| {
|
||||
if lhs.is_finite() && rhs.is_finite() { lhs.max(rhs) } else { f64::NAN }
|
||||
});
|
||||
engine.register_fn("sqrt", |value: f64| value.sqrt());
|
||||
engine.register_fn("pow", |lhs: f64, rhs: f64| lhs.powf(rhs));
|
||||
engine.register_fn("log", |value: f64| value.ln());
|
||||
engine.register_fn("exp", |value: f64| value.exp());
|
||||
engine.register_fn("clamp", |value: f64, low: f64, high: f64| {
|
||||
value.clamp(low, high)
|
||||
engine.register_fn("clamp", |value: f64, low: f64, high: f64| -> Result<f64, Box<rhai::EvalAltResult>> {
|
||||
if !value.is_finite() || !low.is_finite() || !high.is_finite() {
|
||||
return Ok(f64::NAN);
|
||||
}
|
||||
if low > high {
|
||||
return Err("clamp lower bound exceeds upper bound".into());
|
||||
}
|
||||
Ok(value.clamp(low, high))
|
||||
});
|
||||
engine.register_fn("between", |value: f64, low: f64, high: f64| {
|
||||
value >= low && value <= high
|
||||
engine.register_fn("between", |value: f64, low: f64, high: f64| -> Result<bool, Box<rhai::EvalAltResult>> {
|
||||
if !value.is_finite() || !low.is_finite() || !high.is_finite() {
|
||||
return Err("missing_numeric_operand: function=between".into());
|
||||
}
|
||||
Ok(value >= low && value <= high)
|
||||
});
|
||||
engine.register_fn(
|
||||
"nz",
|
||||
@@ -8330,6 +8366,7 @@ impl PlatformExprStrategy {
|
||||
return match value {
|
||||
NumericVmValue::Number(number) => Ok(number),
|
||||
NumericVmValue::Boolean(boolean) => Ok(if boolean { 1.0 } else { 0.0 }),
|
||||
NumericVmValue::Missing(_) => Ok(f64::NAN),
|
||||
};
|
||||
}
|
||||
let value = self.eval_dynamic(ctx, expr, day, stock, position)?;
|
||||
@@ -8360,6 +8397,7 @@ impl PlatformExprStrategy {
|
||||
return match value {
|
||||
NumericVmValue::Boolean(boolean) => Ok(boolean),
|
||||
NumericVmValue::Number(number) => Ok(number.is_finite() && number != 0.0),
|
||||
NumericVmValue::Missing(_) => Ok(false),
|
||||
};
|
||||
}
|
||||
let value = self.eval_dynamic(ctx, expr, day, stock, position)?;
|
||||
@@ -16604,6 +16642,33 @@ mod tests {
|
||||
assert!(missing_stock.turnover_ratio.is_nan());
|
||||
assert!(missing_stock.effective_turnover_ratio.is_nan());
|
||||
assert_eq!(present_stock.turnover_ratio, 0.0);
|
||||
for predicate in [
|
||||
"!(model_score > 0.0)",
|
||||
"!(model_score != 0.0)",
|
||||
"!between(model_score, 0.0, 1.0)",
|
||||
"!(min(model_score, 1.0) > 0.0)",
|
||||
"!(model_score > 0.0) || false",
|
||||
] {
|
||||
assert!(!strategy.eval_bool(&ctx, predicate, &day, Some(&missing_stock), None).unwrap(), "{predicate}");
|
||||
}
|
||||
for predicate in [
|
||||
"!(model_score > 0.0) || true",
|
||||
"!(model_score > 0.0 && false)",
|
||||
] {
|
||||
assert!(strategy.eval_bool(&ctx, predicate, &day, Some(&missing_stock), None).unwrap(), "{predicate}");
|
||||
}
|
||||
for predicate in [
|
||||
"symbol == \"000001.SZ\" && !(model_score > 0.0)",
|
||||
"symbol == \"000001.SZ\" && !(model_score > 0)",
|
||||
"symbol == \"000001.SZ\" && !(0 < model_score)",
|
||||
] {
|
||||
let error = strategy.eval_bool(&ctx, predicate, &day, Some(&missing_stock), None).unwrap_err();
|
||||
assert!(error.to_string().contains("missing_numeric_operand"), "{error}");
|
||||
}
|
||||
assert!(!strategy.eval_bool(
|
||||
&ctx, "symbol == \"OTHER\" && !(model_score > 0.0)",
|
||||
&day, Some(&missing_stock), None,
|
||||
).unwrap());
|
||||
assert!(!strategy.eval_bool(&ctx, "model_score", &day, Some(&missing_stock), None).unwrap());
|
||||
assert!(strategy.eval_bool(&ctx, "model_score", &day, Some(&present_stock), None).unwrap());
|
||||
for field in ["turnover_ratio", "effective_turnover_ratio"] {
|
||||
|
||||
Reference in New Issue
Block a user