合并主线数值校验与买入阶段约束

This commit is contained in:
boris
2026-09-09 05:49:45 +08:00
18 changed files with 2772 additions and 85 deletions
+156
View File
@@ -380,6 +380,7 @@ pub struct BrokerSimulator<C, R> {
runtime_intraday_start_time: Cell<Option<NaiveTime>>,
runtime_intraday_end_time: Cell<Option<NaiveTime>>,
runtime_decision_date: Cell<Option<NaiveDate>>,
runtime_buy_denials: RefCell<BTreeMap<String, String>>,
runtime_order_created_date: Cell<Option<NaiveDate>>,
runtime_decision_total_equity: Cell<Option<f64>>,
runtime_target_position_limit: Cell<Option<usize>>,
@@ -412,6 +413,7 @@ impl<C, R> BrokerSimulator<C, R> {
runtime_intraday_start_time: Cell::new(None),
runtime_intraday_end_time: Cell::new(None),
runtime_decision_date: Cell::new(None),
runtime_buy_denials: RefCell::new(BTreeMap::new()),
runtime_order_created_date: Cell::new(None),
runtime_decision_total_equity: Cell::new(None),
runtime_target_position_limit: Cell::new(None),
@@ -448,6 +450,7 @@ impl<C, R> BrokerSimulator<C, R> {
runtime_intraday_start_time: Cell::new(None),
runtime_intraday_end_time: Cell::new(None),
runtime_decision_date: Cell::new(None),
runtime_buy_denials: RefCell::new(BTreeMap::new()),
runtime_order_created_date: Cell::new(None),
runtime_decision_total_equity: Cell::new(None),
runtime_target_position_limit: Cell::new(None),
@@ -1385,6 +1388,7 @@ where
decision: &StrategyDecision,
) -> Result<BrokerExecutionReport, BacktestError> {
let previous_decision_date = self.runtime_decision_date.get();
let previous_buy_denials = self.runtime_buy_denials.replace(decision.buy_denials.clone());
let previous_order_created_date = self.runtime_order_created_date.get();
let previous_decision_total_equity = self.runtime_decision_total_equity.get();
self.runtime_decision_date.set(Some(decision_date));
@@ -1393,6 +1397,7 @@ where
self.runtime_decision_total_equity
.set(decision_total_equity.filter(|equity| equity.is_finite() && *equity >= 0.0));
let result = self.execute_with_runtime_dates(date, portfolio, data, decision);
self.runtime_buy_denials.replace(previous_buy_denials);
self.runtime_decision_date.set(previous_decision_date);
self.runtime_order_created_date
.set(previous_order_created_date);
@@ -2833,6 +2838,18 @@ where
}
}
if existing.side == OrderSide::Buy
&& (target_total_quantity > existing.requested_quantity
|| target_limit_price > existing.limit_price)
&& let Some(denial) = self.runtime_buy_denials.borrow().get(&existing.symbol)
{
Self::emit_open_order_update_rejected(
report, date, order_id, Some(&existing.symbol), Some(existing.side),
reason, denial,
);
return;
}
let resets_queue_priority = target_limit_price.to_bits() != existing.limit_price.to_bits()
|| target_total_quantity > existing.requested_quantity;
{
@@ -4189,6 +4206,9 @@ where
if !rule.allowed {
return rule.reason;
}
if let Some(reason) = self.runtime_buy_denials.borrow().get(symbol) {
return Some(reason.clone());
}
match self.market_fillable_quantity(
snapshot,
OrderSide::Buy,
@@ -6140,6 +6160,12 @@ where
data.instrument(symbol),
algo_request,
);
let rule = if rule.allowed && emit_creation_events {
self.runtime_buy_denials.borrow().get(symbol)
.map_or(rule, |reason| RuleCheck::reject(reason.clone()))
} else {
rule
};
if !rule.allowed {
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
let status = match rule.reason.as_deref() {
@@ -8213,6 +8239,136 @@ mod tests {
}
}
#[test]
fn decision_buy_denial_blocks_topup_but_allows_sell_and_does_not_leak() {
let first = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let second = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let data = DataSet::from_components(
vec![limit_test_instrument()],
vec![dated_limit_test_snapshot(first), dated_limit_test_snapshot(second)],
Vec::new(),
vec![dated_limit_test_candidate(first, false, false, true, true),
dated_limit_test_candidate(second, false, false, true, true)],
vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)],
).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::CurrentBarClose);
let mut portfolio = PortfolioState::new(100_000.0);
broker.execute(first, &mut portfolio, &data, &next_open_buy_decision()).unwrap();
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 100);
let mut blocked = StrategyDecision::default();
blocked.buy_denials.insert("000001.SZ".to_string(), "strategy_buy_condition_false".to_string());
blocked.order_intents.push(OrderIntent::TargetValue {
symbol: "000001.SZ".to_string(), target_value: 3_000.0, reason: "topup".to_string(),
});
let report = broker.execute(second, &mut portfolio, &data, &blocked).unwrap();
assert!(report.fill_events.is_empty());
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 100);
assert!(broker.runtime_buy_denials.borrow().is_empty());
blocked.order_intents = next_open_sell_decision().order_intents;
let report = broker.execute(second, &mut portfolio, &data, &blocked).unwrap();
assert_eq!(report.fill_events.len(), 1);
assert_eq!(report.fill_events[0].side, OrderSide::Sell);
assert!(broker.runtime_buy_denials.borrow().is_empty());
}
#[test]
fn decision_buy_denial_does_not_rewrite_existing_pending_order() {
let date = limit_test_snapshot().date;
let data = DataSet::from_components(vec![limit_test_instrument()], vec![limit_test_snapshot()],
Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::CurrentBarClose);
broker.upsert_open_order(test_open_order(99));
let mut decision = StrategyDecision::default();
decision.buy_denials.insert("000001.SZ".to_string(), "strategy_buy_condition_false".to_string());
let mut portfolio = PortfolioState::new(100_000.0);
let report = broker.execute(date, &mut portfolio, &data, &decision).unwrap();
assert!(!report.fill_events.is_empty());
assert!(broker.runtime_buy_denials.borrow().is_empty());
}
#[test]
fn decision_buy_denial_uses_actual_next_open_target_delta() {
let first = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let second = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let mut next = dated_limit_test_snapshot(second);
next.day_open = 9.5;
next.open = 9.5;
next.close = 9.5;
next.last_price = 9.5;
next.bid1 = 9.5;
next.ask1 = 9.5;
let data = DataSet::from_components(vec![limit_test_instrument()],
vec![dated_limit_test_snapshot(first), next], Vec::new(),
vec![dated_limit_test_candidate(first, false, false, true, true),
dated_limit_test_candidate(second, false, false, true, true)],
vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)]).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::NextBarOpen);
let mut portfolio = PortfolioState::new(1_000_000.0);
let mut initial = StrategyDecision::default();
initial.order_intents.push(OrderIntent::Shares {
symbol: "000001.SZ".to_string(), quantity: 10_000, reason: "initial".to_string(),
});
broker.execute(first, &mut portfolio, &data, &initial).unwrap();
let mut decision = StrategyDecision::default();
decision.buy_denials.insert("000001.SZ".to_string(), "strategy_buy_condition_false".to_string());
// Below the signal-day holding value, but above next-open value.
decision.order_intents.push(OrderIntent::TargetValue {
symbol: "000001.SZ".to_string(), target_value: 97_500.0, reason: "target".to_string(),
});
let report = broker.execute_with_event_dates_and_decision_equity(
second, first, first, None, &mut portfolio, &data, &decision).unwrap();
assert!(report.fill_events.is_empty());
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 10_000);
assert!(report.order_events.iter().any(|event| event.side == OrderSide::Buy));
assert!(broker.runtime_buy_denials.borrow().is_empty());
}
#[test]
fn decision_buy_denial_rejects_increasing_amendments_without_mutation() {
let date = limit_test_snapshot().date;
let data = DataSet::from_components(vec![limit_test_instrument()], vec![limit_test_snapshot()],
Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap();
for (quantity, price) in [(Some(300), None), (None, Some(10.5)),
(Some(100), Some(10.5)), (Some(300), Some(9.5))] {
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks);
broker.upsert_open_order(test_open_order(1));
broker.upsert_open_order(test_open_order(2));
broker.runtime_buy_denials.borrow_mut().insert("000001.SZ".to_string(), "strategy_buy_condition_false".to_string());
let portfolio = PortfolioState::new(100_000.0);
let mut report = BrokerExecutionReport::default();
broker.modify_open_order(date, &portfolio, &data, 1, quantity, price, "amend", &mut report);
let orders = broker.open_orders.borrow();
assert_eq!(orders.iter().map(|order| order.order_id).collect::<Vec<_>>(), vec![1, 2]);
assert_eq!(orders[0].requested_quantity, 200);
assert_eq!(orders[0].remaining_quantity, 200);
assert_eq!(orders[0].limit_price, 10.0);
assert!(report.order_events.is_empty());
let event = report.process_events.last().unwrap();
assert_eq!(event.kind, crate::events::ProcessEventKind::OrderUpdateReject);
assert!(event.detail.contains("strategy_buy_condition_false"));
}
}
#[test]
fn decision_buy_denial_allows_reducing_an_existing_buy() {
let date = limit_test_snapshot().date;
let data = DataSet::from_components(vec![limit_test_instrument()], vec![limit_test_snapshot()],
Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks);
broker.upsert_open_order(test_open_order(1));
broker.runtime_buy_denials.borrow_mut().insert("000001.SZ".to_string(), "strategy_buy_condition_false".to_string());
let portfolio = PortfolioState::new(100_000.0);
let mut report = BrokerExecutionReport::default();
broker.modify_open_order(date, &portfolio, &data, 1, Some(100), Some(9.5), "reduce", &mut report);
let orders = broker.open_orders.borrow();
assert_eq!(orders[0].requested_quantity, 100);
assert_eq!(orders[0].limit_price, 9.5);
assert!(!report.order_events.last().unwrap().reason.contains("strategy_buy_condition_false"));
}
fn next_open_sell_decision() -> StrategyDecision {
StrategyDecision {
order_intents: vec![OrderIntent::Shares {
+24 -1
View File
@@ -1,6 +1,17 @@
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
pub fn listed_sector_is_kcb(value: &str) -> Option<bool> {
match value.trim().to_ascii_uppercase().as_str() {
"科创板" | "KSH" | "STAR" | "STAR_MARKET" => Some(true),
"主板" | "沪市主板" | "深市主板" | "中小板" | "中小企业板" | "创业板"
| "北交所" | "北证" | "新三板" | "基础层" | "创新层" | "精选层"
| "MAIN" | "MAIN_BOARD" | "CHINEXT" | "GEM" | "BJ" | "BJS" | "BJSE"
| "BSE" => Some(false),
_ => None,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Instrument {
pub symbol: String,
@@ -70,7 +81,19 @@ fn default_status() -> String {
#[cfg(test)]
mod tests {
use super::Instrument;
use super::{Instrument, listed_sector_is_kcb};
#[test]
fn listing_sector_is_explicit_and_unknown_stays_unknown() {
assert_eq!(listed_sector_is_kcb("科创板"), Some(true));
assert_eq!(listed_sector_is_kcb(" star "), Some(true));
assert_eq!(listed_sector_is_kcb("主板"), Some(false));
assert_eq!(listed_sector_is_kcb("创业板"), Some(false));
assert_eq!(listed_sector_is_kcb("北证"), Some(false));
for value in ["", "-", "SH", "688001.SH", "半导体"] {
assert_eq!(listed_sector_is_kcb(value), None);
}
}
fn instrument(board: &str, round_lot: u32) -> Instrument {
Instrument {
+204 -39
View File
@@ -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| {
+387 -20
View File
@@ -605,6 +605,7 @@ pub struct PlatformExprStrategyConfig {
pub selection_limit_expr: String,
pub selection_candidate_limit_expr: String,
pub stock_filter_expr: String,
pub buy_filter_expr: String,
pub buy_scale_expr: String,
pub exposure_expr: String,
pub position_exposure_schedule: BTreeMap<NaiveDate, f64>,
@@ -649,6 +650,7 @@ pub struct PlatformExprStrategyConfig {
pub matching_type: MatchingType,
pub quote_quantity_limit: bool,
pub current_day_precomputed_factors: bool,
pub completed_session_factor_fields: BTreeSet<String>,
pub candidate_symbols_by_date: BTreeMap<NaiveDate, BTreeSet<String>>,
pub intraday_execution_time: Option<NaiveTime>,
pub explicit_action_times: Vec<NaiveTime>,
@@ -683,6 +685,7 @@ impl PlatformExprStrategyConfig {
selection_limit_expr: "1".to_string(),
selection_candidate_limit_expr: String::new(),
stock_filter_expr: String::new(),
buy_filter_expr: String::new(),
buy_scale_expr: "1.0".to_string(),
exposure_expr: "1.0".to_string(),
position_exposure_schedule: BTreeMap::new(),
@@ -727,6 +730,7 @@ impl PlatformExprStrategyConfig {
matching_type: MatchingType::CurrentBarClose,
quote_quantity_limit: true,
current_day_precomputed_factors: false,
completed_session_factor_fields: BTreeSet::new(),
candidate_symbols_by_date: BTreeMap::new(),
intraday_execution_time: None,
explicit_action_times: Vec::new(),
@@ -1384,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
@@ -1396,6 +1409,26 @@ fn platform_safe_div_default(lhs: f64, rhs: f64) -> f64 {
platform_safe_div(lhs, rhs, 0.0)
}
fn completed_session_factor_date(
ctx: &StrategyContext<'_>,
date: NaiveDate,
factor_date: NaiveDate,
execution_time: Option<NaiveTime>,
) -> Option<NaiveDate> {
let factor_date = factor_date.min(ctx.decision_date);
if factor_date < date || factor_date < ctx.decision_date {
return Some(factor_date);
}
let time = execution_time.or_else(|| ctx.active_datetime.map(|value| value.time()));
// Native CN stock daily indicator rows become usable only after the
// session closes. Absence of an intraday clock denotes a daily close bar.
if time.is_none_or(|time| time >= NaiveTime::from_hms_opt(15, 0, 0).unwrap()) {
Some(factor_date)
} else {
ctx.data.previous_trading_date(factor_date, 1)
}
}
impl PlatformExprStrategy {
fn market_cap_storage_to_strategy_unit(value: f64) -> f64 {
value
@@ -1541,21 +1574,50 @@ impl PlatformExprStrategy {
pub fn new(config: PlatformExprStrategyConfig) -> Self {
let mut engine = Engine::new();
engine.set_fast_operators(false);
engine.set_fail_on_invalid_map_property(true);
// 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",
@@ -1778,6 +1840,7 @@ impl PlatformExprStrategy {
"stock_filter_expr".to_string(),
self.config.stock_filter_expr.as_str(),
),
("buy_filter_expr".to_string(), self.config.buy_filter_expr.as_str()),
(
"buy_scale_expr".to_string(),
self.config.buy_scale_expr.as_str(),
@@ -4659,7 +4722,7 @@ impl PlatformExprStrategy {
} else if intraday_same_day_factor {
f64::NAN
} else {
factor.extra_factors.get("amount").copied().unwrap_or(0.0)
factor.extra_factors.get("amount").copied().unwrap_or(f64::NAN)
};
let market_cap_bn = decision_market_cap_bn(factor);
let free_float_cap_bn = decision_free_float_cap_bn(factor);
@@ -4702,6 +4765,23 @@ impl PlatformExprStrategy {
} else {
BTreeMap::new()
};
if !self.config.completed_session_factor_fields.is_empty() {
let visible_date = completed_session_factor_date(
ctx, date, factor_date,
execution_time.or(self.config.intraday_execution_time),
);
let visible_factor = visible_date
.and_then(|visible_date| ctx.data.factor_by_symbol_id(visible_date, symbol_id));
for field in &self.config.completed_session_factor_fields {
if self.stock_extra_factor_map_required || self.stock_extra_factor_identifiers.contains(field) {
let value = visible_factor
.and_then(|row| row.extra_factors.get(field.as_str()))
.copied()
.unwrap_or(f64::NAN);
extra_factors.insert(field.clone(), value);
}
}
}
if self.stock_extra_factors_required
&& (self.stock_extra_factor_map_required
|| self
@@ -4724,8 +4804,8 @@ impl PlatformExprStrategy {
minute_volume: market.minute_volume as i64,
bid1_volume: market.bid1_volume as i64,
ask1_volume: market.ask1_volume as i64,
turnover_ratio: factor.turnover_ratio.unwrap_or(0.0),
effective_turnover_ratio: factor.effective_turnover_ratio.unwrap_or(0.0),
turnover_ratio: factor.turnover_ratio.unwrap_or(f64::NAN),
effective_turnover_ratio: factor.effective_turnover_ratio.unwrap_or(f64::NAN),
open: feature_market.day_open,
high: expression_high,
low: expression_low,
@@ -8280,11 +8360,31 @@ impl PlatformExprStrategy {
day: &DayExpressionState,
stock: Option<&StockExpressionState>,
position: Option<&PositionExpressionState>,
) -> Result<f64, BacktestError> {
let value = self.eval_float_or_missing(ctx, expr, day, stock, position)?;
if !value.is_finite() {
return Err(BacktestError::Execution(format!(
"missing_numeric_result: expression={expr:?}, symbol={}, decision_date={}, execution_date={}",
stock.map(|item| item.symbol.as_ref()).unwrap_or("portfolio"),
ctx.decision_date, ctx.execution_date,
)));
}
Ok(value)
}
fn eval_float_or_missing(
&self,
ctx: &StrategyContext<'_>,
expr: &str,
day: &DayExpressionState,
stock: Option<&StockExpressionState>,
position: Option<&PositionExpressionState>,
) -> Result<f64, BacktestError> {
if let Some(value) = self.eval_numeric_vm(ctx, expr, day, stock, position)? {
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)?;
@@ -8314,7 +8414,8 @@ impl PlatformExprStrategy {
if let Some(value) = self.eval_numeric_vm(ctx, expr, day, stock, position)? {
return match value {
NumericVmValue::Boolean(boolean) => Ok(boolean),
NumericVmValue::Number(number) => Ok(number != 0.0),
NumericVmValue::Number(number) => Ok(number.is_finite() && number != 0.0),
NumericVmValue::Missing(_) => Ok(false),
};
}
let value = self.eval_dynamic(ctx, expr, day, stock, position)?;
@@ -8322,7 +8423,7 @@ impl PlatformExprStrategy {
return Ok(boolean);
}
if let Some(number) = value.clone().try_cast::<f64>() {
return Ok(number != 0.0);
return Ok(number.is_finite() && number != 0.0);
}
if let Some(number) = value.try_cast::<i64>() {
return Ok(number != 0);
@@ -9871,6 +9972,7 @@ impl PlatformExprStrategy {
)];
diagnostics.extend(action_diagnostics);
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -10355,7 +10457,7 @@ impl PlatformExprStrategy {
stock: &StockExpressionState,
) -> Result<f64, BacktestError> {
if self.rank_expr_present {
return match self.eval_float(ctx, &self.config.rank_expr, day, Some(stock), None) {
return match self.eval_float_or_missing(ctx, &self.config.rank_expr, day, Some(stock), None) {
Ok(value) => Ok(value),
Err(error) if Self::is_missing_rolling_mean_error(&error) => Ok(f64::NAN),
Err(error) if Self::is_non_numeric_expr_error(&error) => Ok(f64::NAN),
@@ -10913,6 +11015,7 @@ impl PlatformExprStrategy {
let expressions = [
config.prelude.as_str(),
config.stock_filter_expr.as_str(),
config.buy_filter_expr.as_str(),
config.buy_scale_expr.as_str(),
config.stop_loss_expr.as_str(),
config.take_profit_expr.as_str(),
@@ -10945,6 +11048,7 @@ impl PlatformExprStrategy {
for expr in [
config.prelude.as_str(),
config.stock_filter_expr.as_str(),
config.buy_filter_expr.as_str(),
config.buy_scale_expr.as_str(),
config.stop_loss_expr.as_str(),
config.take_profit_expr.as_str(),
@@ -10992,6 +11096,7 @@ impl PlatformExprStrategy {
for expr in [
config.prelude.as_str(),
config.stock_filter_expr.as_str(),
config.buy_filter_expr.as_str(),
config.buy_scale_expr.as_str(),
config.stop_loss_expr.as_str(),
config.take_profit_expr.as_str(),
@@ -11032,6 +11137,9 @@ impl PlatformExprStrategy {
if Self::expr_requires_stock_extra_factors(
&config.stock_filter_expr,
prelude_declared_identifiers,
) || Self::expr_requires_stock_extra_factors(
&config.buy_filter_expr,
prelude_declared_identifiers,
) {
return true;
}
@@ -11061,6 +11169,7 @@ impl PlatformExprStrategy {
[
config.prelude.as_str(),
config.stock_filter_expr.as_str(),
config.buy_filter_expr.as_str(),
config.buy_scale_expr.as_str(),
config.stop_loss_expr.as_str(),
config.take_profit_expr.as_str(),
@@ -11095,6 +11204,11 @@ impl PlatformExprStrategy {
&config.stock_filter_expr,
prelude_declared_identifiers,
);
Self::collect_stock_extra_factor_identifiers(
&mut identifiers,
&config.buy_filter_expr,
prelude_declared_identifiers,
);
for expr in [
config.buy_scale_expr.as_str(),
config.stop_loss_expr.as_str(),
@@ -12069,10 +12183,11 @@ impl Strategy for PlatformExprStrategy {
.is_some();
if scheduled_rotation {
self.executing_scheduled_rotation = true;
let rotation = self.on_day(ctx);
let rotation = self.compute_day_decision(ctx);
self.executing_scheduled_rotation = false;
decision.merge_from(rotation?);
}
self.attach_buy_denials(ctx, &mut decision)?;
Ok(decision)
}
@@ -12121,12 +12236,55 @@ impl Strategy for PlatformExprStrategy {
&& self.config.explicit_action_schedule.is_none()
&& self.unscheduled_explicit_actions_are_due(ctx.decision_date)
{
return self.explicit_action_decision(ctx);
let mut decision = self.explicit_action_decision(ctx)?;
self.attach_buy_denials(ctx, &mut decision)?;
return Ok(decision);
}
Ok(StrategyDecision::default())
}
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
let mut decision = self.compute_day_decision(ctx)?;
self.attach_buy_denials(ctx, &mut decision)?;
Ok(decision)
}
}
impl PlatformExprStrategy {
fn attach_buy_denials(&self, ctx: &StrategyContext<'_>, decision: &mut StrategyDecision) -> Result<(), BacktestError> {
if self.config.buy_filter_expr.trim().is_empty() {
return Ok(());
}
let symbols = decision.potential_buy_symbols(ctx.open_orders);
if symbols.is_empty() {
return Ok(());
}
let day = self.day_state(ctx, ctx.decision_date)?;
let (market_date, _, factor_date) = self.selection_dates(ctx);
let execution_time = ctx.active_datetime.filter(|value| value.date() == market_date)
.map(|value| value.time()).or(self.config.intraday_execution_time);
let needs_quote = Self::stock_filter_quote_usage_for_expr(&Self::normalize_expr(&self.config.buy_filter_expr))
!= StockFilterQuoteUsage::DailyOnly;
for symbol in symbols {
if needs_quote && self.uses_intraday_execution_quotes() && !ctx.is_lagged_execution()
&& self.scheduled_quote_at_time(ctx, market_date, &symbol, execution_time).is_none()
{
return Err(BacktestError::Execution(format!(
"buy condition quote unavailable: symbol={symbol} decision_date={}", ctx.decision_date,
)));
}
let stock = self.stock_state_with_factor_date_and_time(ctx, market_date, factor_date, &symbol, execution_time, true)?;
if !self.eval_bool(ctx, &self.config.buy_filter_expr, &day, Some(&stock), None)? {
decision.buy_denials.insert(symbol, format!(
"strategy_buy_condition_false decision_date={} expression={}",
ctx.decision_date, self.config.buy_filter_expr,
));
}
}
Ok(())
}
fn compute_day_decision(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
if self.config.rotation_enabled
&& self
.config
@@ -12529,6 +12687,7 @@ impl Strategy for PlatformExprStrategy {
));
}
return Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols,
@@ -13805,6 +13964,7 @@ impl Strategy for PlatformExprStrategy {
];
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols,
@@ -13903,6 +14063,164 @@ mod tests {
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
}
#[test]
fn buy_filter_attaches_denials_without_rewriting_selection() {
let prev = d(2025, 1, 2);
let curr = d(2025, 1, 3);
let symbol = "000001.SZ";
let mut parts = single_symbol_platform_data(&[prev, curr], symbol).snapshot_components();
for row in &mut parts.factors { row.extra_factors.insert("entry_gate".into(), 0.0); }
let data = DataSet::from_components(parts.instruments, parts.market, parts.factors, parts.candidates, parts.benchmarks).unwrap();
let portfolio = PortfolioState::new(30_000.0);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: curr, decision_date: curr, decision_index: 1, data: &data,
portfolio: &portfolio, futures_account: None, open_orders: &[], dynamic_universe: None,
subscriptions: &subscriptions, process_events: &[], active_process_event: None,
active_datetime: None, order_events: &[], fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = symbol.to_string();
cfg.max_positions = 1;
cfg.refresh_rate = 1;
cfg.benchmark_short_ma_days = 1;
cfg.benchmark_long_ma_days = 1;
cfg.market_cap_lower_expr = "0".to_string();
cfg.market_cap_upper_expr = "100".to_string();
cfg.selection_limit_expr = "1".to_string();
cfg.stock_filter_expr = "close > 0".to_string();
cfg.buy_filter_expr = "entry_gate > 0".to_string();
cfg.current_day_precomputed_factors = true;
let mut strategy = PlatformExprStrategy::new(cfg);
let decision = strategy.on_day(&ctx).unwrap();
assert!(!decision.order_intents.is_empty());
assert!(decision.buy_denials.contains_key(symbol));
assert!(strategy.stock_extra_factor_identifiers.contains("entry_gate"));
assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly);
}
#[test]
fn buy_quote_filter_rejects_missing_intraday_quote_not_daily_close() {
let date = d(2025, 1, 2);
let symbol = "000001.SZ";
let data = single_symbol_platform_data(&[date], symbol);
let portfolio = PortfolioState::new(30_000.0);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date, decision_date: date, decision_index: 0, data: &data,
portfolio: &portfolio, futures_account: None, open_orders: &[], dynamic_universe: None,
subscriptions: &subscriptions, process_events: &[], active_process_event: None,
active_datetime: None, order_events: &[], fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::generic();
cfg.signal_symbol = symbol.to_string();
cfg.buy_filter_expr = "last > 0".to_string();
cfg.intraday_execution_time = NaiveTime::from_hms_opt(10, 18, 0);
let strategy = PlatformExprStrategy::new(cfg);
let mut decision = crate::StrategyDecision::default();
decision.order_intents.push(OrderIntent::TargetValue { symbol: symbol.to_string(), target_value: 10_000.0, reason: "buy".to_string() });
let error = strategy.attach_buy_denials(&ctx, &mut decision).unwrap_err();
assert!(error.to_string().contains("buy condition quote unavailable"), "{error}");
assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly);
}
#[test]
fn buy_filter_uses_active_schedule_time_instead_of_first_configured_time() {
let date = d(2025, 1, 2);
let symbol = "000001.SZ";
let parts = single_symbol_platform_data(&[date], symbol).snapshot_components();
let quotes = [(10, 18, 9.5), (14, 59, 10.5)].into_iter().map(|(hour, minute, price)| IntradayExecutionQuote {
date, symbol: symbol.to_string(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(),
last_price: price, bid1: price, ask1: price, bid1_volume: 1000, ask1_volume: 1000,
volume_delta: 1000, amount_delta: price * 1000.0, trading_phase: Some("continuous".to_string()),
}).collect();
let data = DataSet::from_components_with_actions_and_quotes(parts.instruments, parts.market,
parts.factors, parts.candidates, parts.benchmarks, Vec::new(), quotes).unwrap();
let portfolio = PortfolioState::new(30_000.0);
let subscriptions = BTreeSet::new();
let mut ctx = StrategyContext {
execution_date: date, decision_date: date, decision_index: 0, data: &data,
portfolio: &portfolio, futures_account: None, open_orders: &[], dynamic_universe: None,
subscriptions: &subscriptions, process_events: &[], active_process_event: None,
active_datetime: None, order_events: &[], fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::generic();
cfg.signal_symbol = symbol.to_string();
cfg.buy_filter_expr = "last > 10".to_string();
cfg.intraday_execution_time = NaiveTime::from_hms_opt(10, 18, 0);
let strategy = PlatformExprStrategy::new(cfg);
for (hour, minute, denied) in [(10, 18, true), (14, 59, false)] {
ctx.active_datetime = Some(date.and_hms_opt(hour, minute, 0).unwrap());
let mut decision = crate::StrategyDecision::default();
decision.order_intents.push(OrderIntent::TargetValue { symbol: symbol.to_string(), target_value: 10_000.0, reason: "target".to_string() });
strategy.attach_buy_denials(&ctx, &mut decision).unwrap();
assert_eq!(decision.buy_denials.contains_key(symbol), denied);
}
}
#[test]
fn completed_session_factor_dates_exclude_intraday_and_preserve_next_open() {
let prev = d(2025, 1, 2);
let curr = d(2025, 1, 3);
let data = single_symbol_platform_data(&[prev, curr], "000001.SZ");
let portfolio = PortfolioState::new(10_000.0);
let subscriptions = BTreeSet::new();
let mut ctx = StrategyContext {
execution_date: curr, decision_date: curr, decision_index: 1,
data: &data, portfolio: &portfolio, futures_account: None,
open_orders: &[], dynamic_universe: None, subscriptions: &subscriptions,
process_events: &[], active_process_event: None, active_datetime: None,
order_events: &[], fills: &[],
};
for hour in [9, 10, 14] {
assert_eq!(super::completed_session_factor_date(&ctx, curr, curr,
NaiveTime::from_hms_opt(hour, 30, 0)), Some(prev));
}
assert_eq!(super::completed_session_factor_date(&ctx, curr, curr,
NaiveTime::from_hms_opt(15, 0, 0)), Some(curr));
assert_eq!(super::completed_session_factor_date(&ctx, curr, curr, None), Some(curr));
ctx.active_datetime = Some(curr.and_hms_opt(10, 0, 0).unwrap());
assert_eq!(super::completed_session_factor_date(&ctx, curr, curr, None), Some(prev));
ctx.decision_date = prev;
assert_eq!(super::completed_session_factor_date(&ctx, curr, curr, None), Some(prev));
ctx.execution_date = prev;
assert_eq!(super::completed_session_factor_date(&ctx, prev, prev, None), None);
}
#[test]
fn completed_session_bound_value_does_not_read_same_day_future_value() {
let prev = d(2025, 1, 2);
let curr = d(2025, 1, 3);
let symbol = "000001.SZ";
let mut parts = single_symbol_platform_data(&[prev, curr], symbol).snapshot_components();
for row in &mut parts.factors {
row.extra_factors.insert("native_daily".into(), if row.date == prev { 2.0 } else { 999.0 });
row.extra_factors.insert("published_today".into(), 7.0);
}
let data = DataSet::from_components(parts.instruments, parts.market, parts.factors,
parts.candidates, parts.benchmarks).unwrap();
let portfolio = PortfolioState::new(10_000.0);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: curr, decision_date: curr, decision_index: 1,
data: &data, portfolio: &portfolio, futures_account: None,
open_orders: &[], dynamic_universe: None, subscriptions: &subscriptions,
process_events: &[], active_process_event: None, active_datetime: None,
order_events: &[], fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::generic();
cfg.stock_filter_expr = "native_daily > 0 && published_today > 0".to_string();
cfg.completed_session_factor_fields.insert("native_daily".to_string());
let strategy = PlatformExprStrategy::new(cfg);
let morning = strategy.stock_state_with_factor_date_and_time(&ctx, curr, curr, symbol,
NaiveTime::from_hms_opt(10, 0, 0), false).unwrap();
assert_eq!(morning.extra_factors["native_daily"], 2.0);
assert_eq!(morning.extra_factors["published_today"], 7.0);
let close = strategy.stock_state_with_factor_date_and_time(&ctx, curr, curr, symbol,
NaiveTime::from_hms_opt(15, 0, 0), false).unwrap();
assert_eq!(close.extra_factors["native_daily"], 999.0);
}
#[test]
fn target_scale_replenishment_preserves_strategy_cash_allocation() {
let scale = 30.0 / 31.0;
@@ -16260,8 +16578,8 @@ mod tests {
market_cap_bn: 12.0,
free_float_cap_bn: 10.0,
pe_ttm: 8.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
@@ -16271,8 +16589,8 @@ mod tests {
market_cap_bn: 12.0,
free_float_cap_bn: 10.0,
pe_ttm: 8.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
turnover_ratio: Some(0.0),
effective_turnover_ratio: Some(0.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::from([
("model_score".into(), 2.0),
@@ -16337,6 +16655,51 @@ mod tests {
.expect("present stock state");
assert!(day.available_factor_names.contains("model_score"));
assert!(missing_stock.turnover_ratio.is_nan());
assert!(missing_stock.effective_turnover_ratio.is_nan());
assert_eq!(present_stock.turnover_ratio, 0.0);
for expression in ["model_score", "min(model_score, 1.0)", "model_score / 100.0"] {
let error = strategy.eval_float(&ctx, expression, &day, Some(&missing_stock), None).unwrap_err();
assert!(error.to_string().contains("missing_numeric_result"), "{error}");
}
assert!(strategy.eval_float_or_missing(&ctx, "model_score", &day, Some(&missing_stock), None).unwrap().is_nan());
assert_eq!(strategy.eval_float(&ctx, "nz(model_score, 0.0)", &day, Some(&missing_stock), None).unwrap(), 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 result = strategy.eval_bool(&ctx, predicate, &day, Some(&missing_stock), None);
assert!(result.is_err(), "{predicate}: {result:?}");
let error = result.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"] {
let predicate = format!("{field} < 1.0");
assert!(!strategy.eval_bool(&ctx, &predicate, &day, Some(&missing_stock), None).unwrap());
assert!(strategy.eval_bool(&ctx, &predicate, &day, Some(&present_stock), None).unwrap());
}
assert!(
!strategy
.stock_passes_expr(&ctx, &day, &missing_stock)
@@ -23702,7 +24065,7 @@ mod tests {
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: BTreeMap::from([("amount".into(), 20_000_000.0)]),
},
DailyFactorSnapshot {
date: factor_date,
@@ -23713,7 +24076,7 @@ mod tests {
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: BTreeMap::from([("amount".into(), 10_000_000.0)]),
},
DailyFactorSnapshot {
date: decision_date,
@@ -23864,6 +24227,10 @@ mod tests {
.stock_state_with_factor_date(&ctx, decision_date, decision_date, limit_symbol)
.expect("next-open decision state");
assert_eq!(decision_day_state.amount, 30_000_000.0);
let prior_factor_state = strategy
.stock_state_with_factor_date(&ctx, decision_date, factor_date, limit_symbol)
.expect("previous factor-day state");
assert_eq!(prior_factor_state.amount, 20_000_000.0);
let decision = strategy.on_day(&ctx).expect("platform decision");
+55 -18
View File
@@ -977,6 +977,8 @@ pub struct StrategyExpressionOrderingConfig {
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StrategyExpressionTradingConfig {
#[serde(default, alias = "buy_filter_expr")]
pub buy_filter_expr: Option<String>,
#[serde(default)]
pub stage: Option<String>,
#[serde(default)]
@@ -1785,6 +1787,23 @@ pub fn platform_expr_config_from_spec(
let Some(spec) = strategy_spec else {
return Ok(cfg);
};
if let Some(conditions) = spec.stock_pool_factor_contract.as_ref()
.and_then(|contract| contract.get("conditions"))
.and_then(Value::as_array)
{
for condition in conditions {
let Some(binding) = condition.pointer("/semantic/backtestBinding") else { continue };
let field = binding.get("field").and_then(Value::as_str).unwrap_or("");
let dataset = binding.get("sourceDataset").and_then(Value::as_str).unwrap_or("");
if !dataset.starts_with("indicators_") || field.is_empty()
|| !field.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|| field.as_bytes()[0].is_ascii_digit()
{
return Err("invalid native factor backtest binding".to_string());
}
cfg.completed_session_factor_fields.insert(field.to_string());
}
}
let mut benchmark_short_explicit = false;
let mut benchmark_long_explicit = false;
let mut stock_short_explicit = false;
@@ -2272,6 +2291,9 @@ pub fn platform_expr_config_from_spec(
}
}
if let Some(trading) = runtime_expr.trading.as_ref() {
if let Some(expr) = trading.buy_filter_expr.as_ref() {
cfg.buy_filter_expr = expr.clone();
}
if let Some(expr) = trading
.refresh_rate_expr
.as_ref()
@@ -3063,8 +3085,7 @@ fn instrument_query_id(symbol: &str, board: &str) -> String {
}
fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String {
let has_suffix = symbol.trim().rsplit_once('.').is_some();
if has_suffix && symbol_is_kcb(symbol) {
if raw_board.and_then(crate::instrument::listed_sector_is_kcb) == Some(true) {
return "KSH".to_string();
}
let normalized = raw_board
@@ -3079,9 +3100,6 @@ fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String {
if let Some((_, suffix)) = symbol.rsplit_once('.') {
return suffix.to_ascii_uppercase();
}
if symbol_is_kcb(symbol) {
return "KSH".to_string();
}
if symbol.starts_with('8') || symbol.starts_with('4') {
return "BJ".to_string();
}
@@ -3098,27 +3116,46 @@ fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String {
"UNK".to_string()
}
fn symbol_is_kcb(symbol: &str) -> bool {
let normalized = symbol.trim().to_ascii_uppercase();
let Some((code, suffix)) = normalized.rsplit_once('.') else {
return normalized.starts_with("688") || normalized.starts_with("689");
};
suffix == "SH" && (code.starts_with("688") || code.starts_with("689"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_board_classifies_kcb_by_688_689_sh_suffix_only() {
assert_eq!(normalize_board("688001.SH", None), "KSH");
assert_eq!(normalize_board("689001.SH", None), "KSH");
fn parses_buy_filter_as_a_separate_trading_condition() {
let cfg = platform_expr_config_from_value("buy-guard", "000001.SZ", &serde_json::json!({
"runtimeExpressions": {
"selection": {"stockFilterExpr": "close > 0"},
"trading": {"buyFilterExpr": "gate > 0"}
}
})).unwrap();
assert_eq!(cfg.stock_filter_expr, "close > 0");
assert_eq!(cfg.buy_filter_expr, "gate > 0");
}
#[test]
fn native_factor_bindings_declare_completed_session_fields() {
let spec = serde_json::json!({"stockPoolFactorContract": {"conditions": [
{"factorRef": "up_days_stock", "semantic": {"backtestBinding": {
"field": "ths_up_days_stock", "sourceDataset": "indicators_up_days_stock"
}}}
]}});
let cfg = platform_expr_config_from_value("test", "000852.SH", &spec).unwrap();
assert_eq!(cfg.completed_session_factor_fields,
BTreeSet::from(["ths_up_days_stock".to_string()]));
let empty = platform_expr_config_from_value("test", "000852.SH", &serde_json::json!({})).unwrap();
assert!(empty.completed_session_factor_fields.is_empty());
}
#[test]
fn normalize_board_does_not_infer_kcb_from_security_code() {
assert_eq!(normalize_board("688001.SH", None), "SH");
assert_eq!(normalize_board("689001.SH", None), "SH");
assert_eq!(normalize_board("688001.BJ", None), "BJ");
assert_eq!(normalize_board("689001.SZ", None), "SZ");
assert_eq!(normalize_board("688001", None), "KSH");
assert_eq!(normalize_board("688001", None), "SH");
assert_eq!(normalize_board("688001", Some("SZ")), "SZ");
assert_eq!(normalize_board("688001.SH", Some("SH")), "KSH");
assert_eq!(normalize_board("688001.SH", Some("SH")), "SH");
assert_eq!(normalize_board("000001.SZ", Some("KSH")), "KSH");
}
#[test]
+20 -6
View File
@@ -397,7 +397,7 @@ impl ChinaAShareRiskControl {
RiskCheckScope::Buy => config.static_rules.reject_kcb_buy,
RiskCheckScope::Sell => false,
};
if reject_kcb && (candidate.is_kcb || symbol_is_kcb(&candidate.symbol)) {
if reject_kcb && candidate.is_kcb {
return Some("kcb");
}
let reject_bjse = match scope {
@@ -600,11 +600,6 @@ impl ChinaAShareRiskControl {
}
}
fn symbol_is_kcb(symbol: &str) -> bool {
let normalized = symbol.trim().to_ascii_uppercase();
(normalized.starts_with("688") || normalized.starts_with("689")) && normalized.ends_with(".SH")
}
fn symbol_is_bjse(symbol: &str) -> bool {
let normalized = symbol.trim().to_ascii_uppercase();
normalized.ends_with(".BJ") || normalized.ends_with(".BSE") || normalized.ends_with(".BE")
@@ -1009,6 +1004,24 @@ mod tests {
assert_eq!(configured_reason, None);
}
#[test]
fn kcb_filter_uses_classification_instead_of_security_code() {
let date = d(2025, 1, 2);
let market = market(date, 6.27, 5.63);
let mut candidate = candidate(date);
let config = FidcRiskControlConfig::default();
for symbol in ["688001.SH", "689001.SH", "000001.SZ"] {
candidate.symbol = symbol.to_string();
for is_kcb in [false, true] {
candidate.is_kcb = is_kcb;
let reason = ChinaAShareRiskControl::buy_rejection_reason_with_config(
date, &candidate, &market, None, 6.27, &config,
);
assert_eq!(reason, is_kcb.then_some("kcb"), "{symbol}");
}
}
}
#[test]
fn st_and_star_st_filters_are_independent() {
let date = d(2025, 1, 2);
@@ -1139,6 +1152,7 @@ mod tests {
let date = d(2025, 1, 2);
let mut candidate = candidate(date);
candidate.symbol = "688506.SH".to_string();
candidate.is_kcb = true;
candidate.risk_level_code = Some("missing_risk_state".to_string());
let market = market(date, 6.27, 5.63);
let mut config = FidcRiskControlConfig::default();
+58 -1
View File
@@ -977,6 +977,7 @@ fn safe_ratio(numerator: f64, denominator: f64) -> f64 {
#[derive(Debug, Clone, Default)]
pub struct StrategyDecision {
pub buy_denials: BTreeMap<String, String>,
pub rebalance: bool,
pub target_weights: BTreeMap<String, f64>,
pub exit_symbols: BTreeSet<String>,
@@ -987,7 +988,20 @@ pub struct StrategyDecision {
}
impl StrategyDecision {
pub fn potential_buy_symbols(&self, open_orders: &[OpenOrderView]) -> BTreeSet<String> {
let mut symbols = BTreeSet::new();
if self.rebalance {
symbols.extend(self.target_weights.iter().filter(|(_, weight)| **weight > 0.0).map(|(symbol, _)| symbol.clone()));
}
for intent in &self.order_intents {
intent.collect_potential_buy_symbols(open_orders, &mut symbols);
}
symbols.retain(|symbol| !symbol.trim().is_empty());
symbols
}
pub fn merge_from(&mut self, mut other: StrategyDecision) {
self.buy_denials.append(&mut other.buy_denials);
self.rebalance |= other.rebalance;
self.target_weights.append(&mut other.target_weights);
self.exit_symbols.append(&mut other.exit_symbols);
@@ -998,7 +1012,8 @@ impl StrategyDecision {
}
pub fn is_empty(&self) -> bool {
!self.rebalance
self.buy_denials.is_empty()
&& !self.rebalance
&& self.target_weights.is_empty()
&& self.exit_symbols.is_empty()
&& self.order_intents.is_empty()
@@ -1214,6 +1229,42 @@ pub enum OrderIntent {
}
impl OrderIntent {
fn collect_potential_buy_symbols(&self, open_orders: &[OpenOrderView], symbols: &mut BTreeSet<String>) {
match self.unwrapped() {
Self::Shares { symbol, quantity, .. } | Self::LimitShares { symbol, quantity, .. } if *quantity > 0 => { symbols.insert(symbol.clone()); }
Self::Lots { symbol, lots, .. } | Self::LimitLots { symbol, lots, .. } if *lots > 0 => { symbols.insert(symbol.clone()); }
Self::TargetShares { symbol, target_quantity, .. } | Self::LimitTargetShares { symbol, target_quantity, .. } if *target_quantity > 0 => { symbols.insert(symbol.clone()); }
Self::Value { symbol, value, .. } | Self::LimitValue { symbol, value, .. } | Self::AlgoValue { symbol, value, .. } if *value > 0.0 => { symbols.insert(symbol.clone()); }
Self::Percent { symbol, percent, .. } | Self::LimitPercent { symbol, percent, .. } | Self::AlgoPercent { symbol, percent, .. } if *percent > 0.0 => { symbols.insert(symbol.clone()); }
Self::TargetValue { symbol, target_value, .. } | Self::LimitTargetValue { symbol, target_value, .. } | Self::TimedTargetValue { symbol, target_value, .. } if *target_value > 0.0 => { symbols.insert(symbol.clone()); }
Self::TargetPercent { symbol, target_percent, .. } | Self::LimitTargetPercent { symbol, target_percent, .. } if *target_percent > 0.0 => { symbols.insert(symbol.clone()); }
Self::TargetPortfolioSmart { target_weights, .. } => {
symbols.extend(target_weights.iter().filter(|(_, weight)| **weight > 0.0).map(|(symbol, _)| symbol.clone()));
}
Self::ModifyOrder { order_id, new_total_quantity, new_limit_price, .. } => {
if let Some(order) = open_orders.iter().find(|order| order.order_id == *order_id)
&& order.side == OrderSide::Buy
&& (new_total_quantity.is_some_and(|value| value > order.requested_quantity)
|| new_limit_price.is_some_and(|value| value > order.limit_price))
{
symbols.insert(order.symbol.clone());
}
}
Self::Shares { .. } | Self::LimitShares { .. }
| Self::Lots { .. } | Self::LimitLots { .. }
| Self::TargetShares { .. } | Self::LimitTargetShares { .. }
| Self::Value { .. } | Self::LimitValue { .. } | Self::AlgoValue { .. }
| Self::Percent { .. } | Self::LimitPercent { .. } | Self::AlgoPercent { .. }
| Self::TargetValue { .. } | Self::LimitTargetValue { .. } | Self::TimedTargetValue { .. }
| Self::TargetPercent { .. } | Self::LimitTargetPercent { .. }
| Self::CancelOrder { .. } | Self::CancelSymbol { .. } | Self::CancelAll { .. }
| Self::UpdateUniverse { .. } | Self::Subscribe { .. } | Self::Unsubscribe { .. }
| Self::DepositWithdraw { .. } | Self::FinanceRepay { .. } | Self::SetManagementFeeRate { .. }
| Self::Futures { .. } => {}
Self::WithTimeInForce { .. } => unreachable!("intent is unwrapped"),
}
}
pub fn with_time_in_force(self, time_in_force: OrderTimeInForce) -> Self {
match self {
Self::WithTimeInForce { intent, .. } => Self::WithTimeInForce {
@@ -1569,6 +1620,7 @@ impl Strategy for CnSmallCapRotationStrategy {
if self.config.in_skip_window(ctx.decision_date) {
self.last_gross_exposure = Some(0.0);
return Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: true,
target_weights: BTreeMap::new(),
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
@@ -1590,6 +1642,7 @@ impl Strategy for CnSmallCapRotationStrategy {
if message.contains("signal series insufficient") =>
{
return Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1765,6 +1818,7 @@ impl Strategy for CnSmallCapRotationStrategy {
self.last_gross_exposure = Some(gross_exposure);
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance,
target_weights,
exit_symbols,
@@ -2773,6 +2827,7 @@ impl Strategy for OmniMicroCapStrategy {
let lagged_execution = ctx.is_lagged_execution();
if self.config.in_skip_window(signal_date) {
return Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
@@ -2803,6 +2858,7 @@ impl Strategy for OmniMicroCapStrategy {
if message.contains("insufficient benchmark") =>
{
return Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -3013,6 +3069,7 @@ impl Strategy for OmniMicroCapStrategy {
];
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols,