合并主线数值校验与买入阶段约束
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -90,6 +90,7 @@ impl Strategy for BuyAndHoldStrategy {
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
|
||||
@@ -24,6 +24,7 @@ impl Strategy for BuyThenHoldStrategy {
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
if ctx.decision_date == d(2025, 1, 2) && ctx.portfolio.position("000001.SZ").is_none() {
|
||||
return Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
|
||||
@@ -295,6 +295,7 @@ impl Strategy for HookProbeStrategy {
|
||||
.borrow_mut()
|
||||
.push(format!("on_day:{}", ctx.execution_date));
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -334,6 +335,7 @@ impl Strategy for AuctionOrderStrategy {
|
||||
_ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -377,6 +379,7 @@ impl Strategy for FuturesOrderStrategy {
|
||||
return Ok(StrategyDecision::default());
|
||||
}
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -720,6 +723,7 @@ impl Strategy for LimitCarryStrategy {
|
||||
}
|
||||
self.issued = true;
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -803,6 +807,7 @@ impl Strategy for UniverseDirectiveStrategy {
|
||||
_ => Vec::new(),
|
||||
};
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -844,6 +849,7 @@ impl Strategy for MinuteProbeStrategy {
|
||||
_ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -885,6 +891,7 @@ impl Strategy for MinuteProbeStrategy {
|
||||
}
|
||||
self.ordered = true;
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -987,6 +994,7 @@ impl Strategy for OrderInspectionStrategy {
|
||||
_ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -1030,6 +1038,7 @@ impl Strategy for AccountFlowStrategy {
|
||||
return Ok(StrategyDecision::default());
|
||||
}
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -4134,6 +4143,7 @@ impl Strategy for BuyMissingRowThenHoldStrategy {
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
if ctx.execution_date == d(2025, 5, 26) {
|
||||
return Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
|
||||
@@ -209,6 +209,7 @@ fn execute_single_value_order(
|
||||
&mut portfolio,
|
||||
data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -399,6 +400,7 @@ fn broker_executes_explicit_order_value_buy() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -557,6 +559,7 @@ fn broker_delayed_limit_open_sell_uses_minute_price() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -687,6 +690,7 @@ fn broker_executes_order_shares_and_order_lots() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -806,6 +810,7 @@ fn broker_executes_target_shares_like_order_to() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -981,6 +986,7 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -1139,6 +1145,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -1254,6 +1261,7 @@ fn broker_executes_order_percent_and_target_percent() {
|
||||
&mut percent_portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -1278,6 +1286,7 @@ fn broker_executes_order_percent_and_target_percent() {
|
||||
&mut target_percent_portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -1380,6 +1389,7 @@ fn broker_uses_day_open_price_for_open_auction_matching() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -1487,6 +1497,7 @@ fn broker_open_auction_uses_auction_volume_without_quote_liquidity() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -1590,6 +1601,7 @@ fn broker_cancels_buy_when_open_hits_upper_limit() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -1707,6 +1719,7 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -1812,6 +1825,7 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -1933,6 +1947,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -2039,6 +2054,7 @@ fn broker_rejects_intraday_last_order_without_execution_quotes() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -2163,6 +2179,7 @@ fn broker_executes_intraday_last_on_start_quote_with_trade_delta() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -2282,6 +2299,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -2398,6 +2416,7 @@ fn broker_cancels_market_buy_when_minute_has_no_volume() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -2534,6 +2553,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -2707,6 +2727,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -2888,6 +2909,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -3036,6 +3058,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -3172,6 +3195,7 @@ fn broker_uses_best_own_price_for_intraday_matching() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -3290,6 +3314,7 @@ fn broker_uses_best_counterparty_price_for_intraday_matching() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -3461,6 +3486,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: true,
|
||||
target_weights: BTreeMap::from([("000002.SZ".to_string(), 1.0)]),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -3657,6 +3683,7 @@ fn rebalance_uses_day_open_for_open_auction_valuation() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: true,
|
||||
target_weights: BTreeMap::from([
|
||||
("000001.SZ".to_string(), 0.5),
|
||||
@@ -3841,6 +3868,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: true,
|
||||
target_weights: BTreeMap::from([
|
||||
("000001.SZ".to_string(), 0.2),
|
||||
@@ -4025,6 +4053,7 @@ fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: true,
|
||||
target_weights: BTreeMap::from([
|
||||
("000001.SZ".to_string(), 0.48),
|
||||
@@ -4139,6 +4168,7 @@ fn broker_uses_board_specific_min_quantity_and_step_size_for_buy_sizing() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -4244,6 +4274,7 @@ fn broker_allows_bjse_quantities_above_minimum_without_round_lot_step() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -4350,6 +4381,7 @@ fn broker_allows_full_odd_lot_sell_when_liquidating_position() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -4483,6 +4515,7 @@ fn same_day_sell_then_rebuy_is_rejected_by_default() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -4627,6 +4660,7 @@ fn same_day_sell_then_rebuy_can_be_allowed_by_policy() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -4680,6 +4714,7 @@ fn broker_configured_policy_can_allow_upper_limit_buy() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -4726,6 +4761,7 @@ fn broker_configured_policy_can_allow_lower_limit_sell() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -4901,6 +4937,7 @@ fn broker_expires_day_limit_buy_at_market_close() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -4941,6 +4978,7 @@ fn broker_expires_day_limit_buy_at_market_close() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -5867,6 +5905,7 @@ fn broker_uses_limit_price_slippage_for_limit_orders() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -5905,6 +5944,7 @@ fn broker_rejects_limit_buy_when_final_execution_price_reaches_upper_limit() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -5949,6 +5989,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() {
|
||||
&mut value_portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -5974,6 +6015,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() {
|
||||
&mut percent_portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -6010,6 +6052,7 @@ fn broker_cancels_open_order_by_order_id() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -6033,6 +6076,7 @@ fn broker_cancels_open_order_by_order_id() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -6080,6 +6124,7 @@ fn broker_emits_cancellation_reject_for_unknown_order() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -6188,6 +6233,7 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
|
||||
Reference in New Issue
Block a user