diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 11c9633..52d4d96 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -380,6 +380,7 @@ pub struct BrokerSimulator { runtime_intraday_start_time: Cell>, runtime_intraday_end_time: Cell>, runtime_decision_date: Cell>, + runtime_buy_denials: RefCell>, runtime_order_created_date: Cell>, runtime_decision_total_equity: Cell>, runtime_target_position_limit: Cell>, @@ -412,6 +413,7 @@ impl BrokerSimulator { 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 BrokerSimulator { 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 { 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![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 { diff --git a/crates/fidc-core/src/instrument.rs b/crates/fidc-core/src/instrument.rs index 454c597..381639b 100644 --- a/crates/fidc-core/src/instrument.rs +++ b/crates/fidc-core/src/instrument.rs @@ -1,6 +1,17 @@ use chrono::NaiveDate; use serde::{Deserialize, Serialize}; +pub fn listed_sector_is_kcb(value: &str) -> Option { + 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 { diff --git a/crates/fidc-core/src/numeric_expr_vm.rs b/crates/fidc-core/src/numeric_expr_vm.rs index e8d4a38..171576d 100644 --- a/crates/fidc-core/src/numeric_expr_vm.rs +++ b/crates/fidc-core/src/numeric_expr_vm.rs @@ -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 { match self { Self::Number(value) => Some(value), - Self::Boolean(_) => None, + Self::Boolean(_) | Self::Missing(_) => None, } } pub(crate) fn as_bool(self) -> Option { 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) -> Result { stack.pop().ok_or_else(|| EvalError::new("stack underflow")) } -fn pop_bool(stack: &mut Vec) -> Result { - pop(stack)? - .as_bool() - .ok_or_else(|| EvalError::new("boolean operand required")) -} - fn number(value: Value) -> Result { + 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 { - match operator { + if matches!(value, Value::Missing(_)) { + return Ok(value); + } + let result: Result = 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 { - 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 = 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 -f64::EPSILON, )) } + BinaryOp::And | BinaryOp::Or => unreachable!(), + }; + Ok(result?.normalized()) +} + +pub(crate) fn finite_comparison(operator: &str, lhs: f64, rhs: f64) -> Option { + 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 { .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 { 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 { }) } 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| { diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index e803d5b..56f3a22 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -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, @@ -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, pub candidate_symbols_by_date: BTreeMap>, pub intraday_execution_time: Option, pub explicit_action_times: Vec, @@ -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, } +fn checked_rhai_comparison( + operator: &str, + lhs: f64, + rhs: f64, +) -> Result> { + 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, +) -> Option { + 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> { + 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> { + 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 { + 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 { 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::() { - return Ok(number != 0.0); + return Ok(number.is_finite() && number != 0.0); } if let Some(number) = value.try_cast::() { 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 { 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 { + 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 { 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"); diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index c39dfc0..26970e7 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -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, #[serde(default)] pub stage: Option, #[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] diff --git a/crates/fidc-core/src/risk_control.rs b/crates/fidc-core/src/risk_control.rs index e701f96..cec4d24 100644 --- a/crates/fidc-core/src/risk_control.rs +++ b/crates/fidc-core/src/risk_control.rs @@ -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(); diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 6744815..fb76b14 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -977,6 +977,7 @@ fn safe_ratio(numerator: f64, denominator: f64) -> f64 { #[derive(Debug, Clone, Default)] pub struct StrategyDecision { + pub buy_denials: BTreeMap, pub rebalance: bool, pub target_weights: BTreeMap, pub exit_symbols: BTreeSet, @@ -987,7 +988,20 @@ pub struct StrategyDecision { } impl StrategyDecision { + pub fn potential_buy_symbols(&self, open_orders: &[OpenOrderView]) -> BTreeSet { + 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) { + 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, diff --git a/crates/fidc-core/tests/corporate_actions.rs b/crates/fidc-core/tests/corporate_actions.rs index f72351d..27ed34a 100644 --- a/crates/fidc-core/tests/corporate_actions.rs +++ b/crates/fidc-core/tests/corporate_actions.rs @@ -90,6 +90,7 @@ impl Strategy for BuyAndHoldStrategy { ctx: &StrategyContext<'_>, ) -> Result { Ok(StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), diff --git a/crates/fidc-core/tests/delisting.rs b/crates/fidc-core/tests/delisting.rs index 48e3f4c..96d9bc1 100644 --- a/crates/fidc-core/tests/delisting.rs +++ b/crates/fidc-core/tests/delisting.rs @@ -24,6 +24,7 @@ impl Strategy for BuyThenHoldStrategy { ) -> Result { 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(), diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index 49cb815..2faf10a 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -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 { 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 { 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 { 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 { 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(), diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index e97244e..f2fb863 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -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(), diff --git a/docs/evidence/decision-buy-denial-foundation-20260908.json b/docs/evidence/decision-buy-denial-foundation-20260908.json new file mode 100644 index 0000000..2d885a0 --- /dev/null +++ b/docs/evidence/decision-buy-denial-foundation-20260908.json @@ -0,0 +1,18 @@ +{ + "date": "2026-09-08", + "host": "192.168.31.177", + "candidateCommit": "fa6f189", + "unitTestsPassed": 453, + "integrationTestsPassed": 122, + "ignoredManualBenchmarks": 8, + "failed": 0, + "backtestRunnerCargoCheck": "passed", + "covered": ["top-up rejection", "sell permission preserved", "decision context restored", "existing pending order not rewritten", "next-open target direction determined by actual price"], + "deployed": false, + "factorCompilerConnected": false, + "paperLivePlanConnected": false, + "orderAmendmentAcceptanceComplete": false, + "brokerAmendmentTestsPassed": true, + "amendmentPolicy": "Deny buy quantity increases or limit-price increases; allow validated reductions; preserve original state and queue order on rejection.", + "realStrategyAcceptanceComplete": false +} diff --git a/docs/evidence/native-daily-factor-replays-20260907/native-pit-intraday-20260907.json b/docs/evidence/native-daily-factor-replays-20260907/native-pit-intraday-20260907.json new file mode 100644 index 0000000..c868aeb --- /dev/null +++ b/docs/evidence/native-daily-factor-replays-20260907/native-pit-intraday-20260907.json @@ -0,0 +1,467 @@ +{ + "schemaVersion": "fidc-backtest-benchmark/v5", + "label": "native-pit-intraday", + "generatedAtUnixSeconds": 1788790043, + "requestSha256": "9dd420ae606878b90ff660fcd4efe520e04a237ec758ed974b87b6d9a72cab73", + "requestOrigin": { + "kind": "request_file" + }, + "baseline": null, + "baselineComparable": true, + "baselineComparison": null, + "bundleRefresh": null, + "baseUrl": "http://127.0.0.1:8081", + "runs": [ + { + "slot": 0, + "cacheClass": "restart_or_cold", + "runId": "btr_1788790021780_1150210_0", + "status": "succeeded", + "queueWaitSeconds": 0.0, + "executionSeconds": 8.994, + "totalSeconds": 8.994, + "runnerSeconds": 7.904, + "bundleValidationSeconds": 0.166, + "sourceEpochSeconds": 0.0, + "strategyContractSeconds": 0.001, + "terminalCalendarSeconds": 0.0, + "riskFreeRateSeconds": 0.031, + "riskFreeRateCacheHit": false, + "engineSeconds": 0.012, + "dataSeconds": 7.662, + "resultSeconds": 0.03, + "unattributedSeconds": 0.0, + "canonicalSeconds": 0.001, + "resultFinalization": { + "canonicalSeconds": 0.001, + "processEventStoreSeconds": 0.001, + "storePublishSeconds": 0.027, + "summarySeconds": 0.0 + }, + "dataPreparationTimings": { + "adjustmentValidationSeconds": 0.0, + "availableStockStDates": 0, + "benchmarkFetchSeconds": 0.344, + "candidatePlanBasePanelSeconds": 0.006, + "candidatePlanSelectionSeconds": 0.004, + "corporateActionSeconds": 0.605, + "datasetConstructSeconds": 0.0, + "externalFetchSeconds": 0.344, + "historyDateSeconds": 0.0, + "instrumentIndexBuildSeconds": 0.0, + "instrumentMetadataSeconds": 0.016, + "loopSeconds": 0.0, + "marketIndexBuildSeconds": 0.001, + "quotePlanSeconds": 0.935, + "riskSnapshotSeconds": 0.0, + "sourceQuerySeconds": 5.752, + "stockStLookupSeconds": 0.0, + "supplementalExecutionFetchSeconds": 0.0, + "totalSeconds": 7.659 + }, + "finalizationSeconds": 1.089, + "finalizationTimings": { + "artifactVerificationSeconds": 0.0, + "cacheRegistrationSeconds": 0.0, + "postgresClientAcquireSeconds": 0.216, + "postgresPersistSeconds": 0.872, + "preTerminalUpdateSeconds": 1.089, + "schemaCheckSeconds": 0.0, + "timingSource": "fidc-backtest-service-monotonic" + }, + "totalReturn": 0.011211658838299776, + "tradeCount": 104, + "riskDecisionCount": 11, + "resultStoreDigest": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4", + "canonicalResultDigest": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a", + "terminalAudit": { + "cashReceivableCount": 0, + "cashReceivableTotalAmount": 0.0, + "earliestDeferredCashDate": null, + "futuresOpenOrderCount": 0, + "lastExecutionDate": "2025-09-12", + "omittedOpenOrderCount": 0, + "openOrderSamples": [], + "pendingCashFlowCount": 0, + "pendingCashFlowNetAmount": 0.0, + "status": "clean", + "stockOpenOrderCount": 0 + }, + "terminalAuditVerified": true, + "terminalAuditSha256": "5836794e260aa2cfd1a06d4568ae6577a37c9cbe09ec0f00995121ea5b2522d9", + "implementation": { + "buildManifestSha256": "fb4f7ac191c8ab8d8d39fc48014f84ce01d4c0731ffe986e7d5cb0107a3e5539", + "builtAt": "2026-09-07T22:06:18+08:00", + "engineCommit": "1b78186c4e37273c64c1f9e208c2e47021ee4fff", + "identitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33", + "runnerBinarySha256": "7cddebf9ab5cbbd5d6f9bd5c7bf006c3895ee26967d23d80dc86b47c50fb761d", + "schemaVersion": "fidc-backtest-implementation/v1", + "serviceBinarySha256": "f09d121d73c34ad3cd5e2e0c24b0bdaf1974309c9ec3b93229cfb824b1e9f11a", + "serviceCommit": "c80a38b91a398fd713f1f1faaa19a581ea85efad", + "status": "verified" + }, + "implementationVerified": true, + "implementationIdentitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33", + "artifactManifest": { + "canonicalResult": { + "algorithm": "sha256", + "ordering": "engine_fact_order_v2", + "schemaVersion": "fidc-canonical-backtest-result/v2", + "sections": { + "accountEvents": { + "rowCount": 104, + "sha256": "ea6bbbd55df3da0692dfd712f88ef6f8af510ada318641ede71884f5c5d14e7e" + }, + "equityFacts": { + "rowCount": 5, + "sha256": "96004e235e4603c0a7d1a5629a2f0d2b364258d562e1d397cdd6925718a7d060" + }, + "fillEvents": { + "rowCount": 104, + "sha256": "2c37f626b7de014f5638dc8edf33f3ca8d23126ab2e62f26f01fbcb33e8c4e48" + }, + "holdingSnapshots": { + "rowCount": 112, + "sha256": "50abab8a304d2e2a7de344538aff604c8bf96ef00283330d107916cc2e234f67" + }, + "orderEvents": { + "rowCount": 108, + "sha256": "f327f21151a5d7dbe53d2dcde9202be699f9672f758373eeb9c67985ca3826f5" + }, + "riskAudits": { + "rowCount": 11, + "sha256": "428a8d7395b54ec5a9523eb745d5007c64b9d9867615e870d95ea9ea176f536e" + } + }, + "sha256": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a", + "totalRows": 444 + }, + "processEventStore": { + "bytes": 44302, + "identitySha256": "1444075abea626e8d7d1b8c9c9cda984a2a687ff2b9465db5648e0f4525130e7", + "manifestBytes": 461, + "manifestSha256": "b43a818096d09648b809a46d12ad9eae39638d57ea52f7696906359036a80c52", + "schemaVersion": "fidc-backtest-process-event-manifest/v1", + "sections": { + "processEvents": { + "blockCount": 1, + "bytes": 43841, + "rowCount": 370, + "sha256": "8edb6dad95777e3eef5946acbbdfc4b9125df34a79ac716d8c3ff7835b552bf6" + } + }, + "sha256": "661d84b7b430ac25fb5c959df8e7fb309133784d85a7524375cd803e29e52094", + "totalEvents": 370 + }, + "resultStore": { + "bytes": 148006, + "identitySha256": "484b7a4a8d5cc77c6222ce01f2fe452c890995a476579f209d1d4ac43885d60a", + "manifestBytes": 2999, + "manifestSha256": "a0a7072f176db79d781846cc124d2e4794c5084c48c525876863751f709c4e7f", + "schemaVersion": "fidc-backtest-fact-manifest/v1", + "sections": { + "accountEvents": { + "blockCount": 1, + "bytes": 13313, + "rowCount": 104, + "sha256": "ab2f936c9072f80d58487b62a6f64553d279582ddfe6af0299afe20447e83424" + }, + "equityFacts": { + "blockCount": 1, + "bytes": 11009, + "rowCount": 5, + "sha256": "c40650f5b27cfff873194572ffe28a5412fe77730fe75492c7b5a575f8f5caae" + }, + "fillEvents": { + "blockCount": 1, + "bytes": 40541, + "rowCount": 104, + "sha256": "80a3f1d3a9c23db7dec2dc70f029280c0844370be6d87703cd396a234068f708" + }, + "holdingSnapshots": { + "blockCount": 1, + "bytes": 51338, + "rowCount": 112, + "sha256": "7684244dc8e7788f777309169be61b14fcb532abdd89fe93f0a7f2980a615ba9" + }, + "orderEvents": { + "blockCount": 1, + "bytes": 25768, + "rowCount": 108, + "sha256": "31ff15d2cf79b1eb9c333d19d8fb400c9c5a52c36b0f662330ec39aa9d0f805c" + }, + "riskAudits": { + "blockCount": 1, + "bytes": 3038, + "rowCount": 11, + "sha256": "5a7269219a21cf14c23d788a6a96ddf1d1ddf0ef1e3b57a8db4ae4b1da789aff" + } + }, + "sha256": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4", + "totalEvents": 444 + }, + "schemaVersion": "fidc-backtest-artifacts/v4" + } + }, + { + "slot": 1, + "cacheClass": "process_hot", + "runId": "btr_1788790036494_1150210_1", + "status": "succeeded", + "queueWaitSeconds": 0.0, + "executionSeconds": 0.596, + "totalSeconds": 0.596, + "runnerSeconds": 0.026, + "bundleValidationSeconds": 0.0, + "sourceEpochSeconds": 0.0, + "strategyContractSeconds": 0.003, + "terminalCalendarSeconds": 0.0, + "riskFreeRateSeconds": 0.0, + "riskFreeRateCacheHit": true, + "engineSeconds": 0.012, + "dataSeconds": 0.007, + "resultSeconds": 0.002, + "unattributedSeconds": 0.0, + "canonicalSeconds": 0.001, + "resultFinalization": { + "canonicalSeconds": 0.001, + "processEventStoreSeconds": 0.001, + "storePublishSeconds": 0.0, + "summarySeconds": 0.0 + }, + "dataPreparationTimings": { + "adjustmentValidationSeconds": 0.0, + "availableStockStDates": 0, + "benchmarkFetchSeconds": 0.0, + "candidatePlanBasePanelSeconds": 0.0, + "candidatePlanSelectionSeconds": 0.0, + "corporateActionSeconds": 0.0, + "datasetConstructSeconds": 0.0, + "externalFetchSeconds": 0.0, + "historyDateSeconds": 0.0, + "instrumentIndexBuildSeconds": 0.0, + "instrumentMetadataSeconds": 0.0, + "loopSeconds": 0.0, + "marketIndexBuildSeconds": 0.0, + "quotePlanSeconds": 0.0, + "riskSnapshotSeconds": 0.0, + "sourceQuerySeconds": 0.0, + "stockStLookupSeconds": 0.0, + "supplementalExecutionFetchSeconds": 0.0, + "totalSeconds": 0.006 + }, + "finalizationSeconds": 0.569, + "finalizationTimings": { + "artifactVerificationSeconds": 0.001, + "cacheRegistrationSeconds": 0.0, + "postgresClientAcquireSeconds": 0.27, + "postgresPersistSeconds": 0.298, + "preTerminalUpdateSeconds": 0.569, + "schemaCheckSeconds": 0.0, + "timingSource": "fidc-backtest-service-monotonic" + }, + "totalReturn": 0.011211658838299776, + "tradeCount": 104, + "riskDecisionCount": 11, + "resultStoreDigest": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4", + "canonicalResultDigest": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a", + "terminalAudit": { + "cashReceivableCount": 0, + "cashReceivableTotalAmount": 0.0, + "earliestDeferredCashDate": null, + "futuresOpenOrderCount": 0, + "lastExecutionDate": "2025-09-12", + "omittedOpenOrderCount": 0, + "openOrderSamples": [], + "pendingCashFlowCount": 0, + "pendingCashFlowNetAmount": 0.0, + "status": "clean", + "stockOpenOrderCount": 0 + }, + "terminalAuditVerified": true, + "terminalAuditSha256": "5836794e260aa2cfd1a06d4568ae6577a37c9cbe09ec0f00995121ea5b2522d9", + "implementation": { + "buildManifestSha256": "fb4f7ac191c8ab8d8d39fc48014f84ce01d4c0731ffe986e7d5cb0107a3e5539", + "builtAt": "2026-09-07T22:06:18+08:00", + "engineCommit": "1b78186c4e37273c64c1f9e208c2e47021ee4fff", + "identitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33", + "runnerBinarySha256": "7cddebf9ab5cbbd5d6f9bd5c7bf006c3895ee26967d23d80dc86b47c50fb761d", + "schemaVersion": "fidc-backtest-implementation/v1", + "serviceBinarySha256": "f09d121d73c34ad3cd5e2e0c24b0bdaf1974309c9ec3b93229cfb824b1e9f11a", + "serviceCommit": "c80a38b91a398fd713f1f1faaa19a581ea85efad", + "status": "verified" + }, + "implementationVerified": true, + "implementationIdentitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33", + "artifactManifest": { + "canonicalResult": { + "algorithm": "sha256", + "ordering": "engine_fact_order_v2", + "schemaVersion": "fidc-canonical-backtest-result/v2", + "sections": { + "accountEvents": { + "rowCount": 104, + "sha256": "ea6bbbd55df3da0692dfd712f88ef6f8af510ada318641ede71884f5c5d14e7e" + }, + "equityFacts": { + "rowCount": 5, + "sha256": "96004e235e4603c0a7d1a5629a2f0d2b364258d562e1d397cdd6925718a7d060" + }, + "fillEvents": { + "rowCount": 104, + "sha256": "2c37f626b7de014f5638dc8edf33f3ca8d23126ab2e62f26f01fbcb33e8c4e48" + }, + "holdingSnapshots": { + "rowCount": 112, + "sha256": "50abab8a304d2e2a7de344538aff604c8bf96ef00283330d107916cc2e234f67" + }, + "orderEvents": { + "rowCount": 108, + "sha256": "f327f21151a5d7dbe53d2dcde9202be699f9672f758373eeb9c67985ca3826f5" + }, + "riskAudits": { + "rowCount": 11, + "sha256": "428a8d7395b54ec5a9523eb745d5007c64b9d9867615e870d95ea9ea176f536e" + } + }, + "sha256": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a", + "totalRows": 444 + }, + "processEventStore": { + "bytes": 44302, + "identitySha256": "1444075abea626e8d7d1b8c9c9cda984a2a687ff2b9465db5648e0f4525130e7", + "manifestBytes": 461, + "manifestSha256": "b43a818096d09648b809a46d12ad9eae39638d57ea52f7696906359036a80c52", + "schemaVersion": "fidc-backtest-process-event-manifest/v1", + "sections": { + "processEvents": { + "blockCount": 1, + "bytes": 43841, + "rowCount": 370, + "sha256": "8edb6dad95777e3eef5946acbbdfc4b9125df34a79ac716d8c3ff7835b552bf6" + } + }, + "sha256": "661d84b7b430ac25fb5c959df8e7fb309133784d85a7524375cd803e29e52094", + "totalEvents": 370 + }, + "resultStore": { + "bytes": 148006, + "identitySha256": "484b7a4a8d5cc77c6222ce01f2fe452c890995a476579f209d1d4ac43885d60a", + "manifestBytes": 2999, + "manifestSha256": "a0a7072f176db79d781846cc124d2e4794c5084c48c525876863751f709c4e7f", + "schemaVersion": "fidc-backtest-fact-manifest/v1", + "sections": { + "accountEvents": { + "blockCount": 1, + "bytes": 13313, + "rowCount": 104, + "sha256": "ab2f936c9072f80d58487b62a6f64553d279582ddfe6af0299afe20447e83424" + }, + "equityFacts": { + "blockCount": 1, + "bytes": 11009, + "rowCount": 5, + "sha256": "c40650f5b27cfff873194572ffe28a5412fe77730fe75492c7b5a575f8f5caae" + }, + "fillEvents": { + "blockCount": 1, + "bytes": 40541, + "rowCount": 104, + "sha256": "80a3f1d3a9c23db7dec2dc70f029280c0844370be6d87703cd396a234068f708" + }, + "holdingSnapshots": { + "blockCount": 1, + "bytes": 51338, + "rowCount": 112, + "sha256": "7684244dc8e7788f777309169be61b14fcb532abdd89fe93f0a7f2980a615ba9" + }, + "orderEvents": { + "blockCount": 1, + "bytes": 25768, + "rowCount": 108, + "sha256": "31ff15d2cf79b1eb9c333d19d8fb400c9c5a52c36b0f662330ec39aa9d0f805c" + }, + "riskAudits": { + "blockCount": 1, + "bytes": 3038, + "rowCount": 11, + "sha256": "5a7269219a21cf14c23d788a6a96ddf1d1ddf0ef1e3b57a8db4ae4b1da789aff" + } + }, + "sha256": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4", + "totalEvents": 444 + }, + "schemaVersion": "fidc-backtest-artifacts/v4" + } + } + ], + "summary": { + "resultConsistent": true, + "resultStoreDigest": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4", + "canonicalResultDigest": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a", + "totalReturn": 0.011211658838299776, + "tradeCount": 104, + "terminalAudit": { + "cashReceivableCount": 0, + "cashReceivableTotalAmount": 0.0, + "earliestDeferredCashDate": null, + "futuresOpenOrderCount": 0, + "lastExecutionDate": "2025-09-12", + "omittedOpenOrderCount": 0, + "openOrderSamples": [], + "pendingCashFlowCount": 0, + "pendingCashFlowNetAmount": 0.0, + "status": "clean", + "stockOpenOrderCount": 0 + }, + "terminalAuditSha256": "5836794e260aa2cfd1a06d4568ae6577a37c9cbe09ec0f00995121ea5b2522d9", + "implementation": { + "buildManifestSha256": "fb4f7ac191c8ab8d8d39fc48014f84ce01d4c0731ffe986e7d5cb0107a3e5539", + "builtAt": "2026-09-07T22:06:18+08:00", + "engineCommit": "1b78186c4e37273c64c1f9e208c2e47021ee4fff", + "identitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33", + "runnerBinarySha256": "7cddebf9ab5cbbd5d6f9bd5c7bf006c3895ee26967d23d80dc86b47c50fb761d", + "schemaVersion": "fidc-backtest-implementation/v1", + "serviceBinarySha256": "f09d121d73c34ad3cd5e2e0c24b0bdaf1974309c9ec3b93229cfb824b1e9f11a", + "serviceCommit": "c80a38b91a398fd713f1f1faaa19a581ea85efad", + "status": "verified" + }, + "implementationIdentitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33", + "restartOrCold": { + "queueWaitSeconds": 0.0, + "executionSeconds": 8.994, + "totalSeconds": 8.994, + "runnerSeconds": 7.904, + "bundleValidationSeconds": 0.166, + "sourceEpochSeconds": 0.0, + "strategyContractSeconds": 0.001, + "terminalCalendarSeconds": 0.0, + "riskFreeRateSeconds": 0.031, + "riskFreeRateCacheHit": false, + "engineSeconds": 0.012, + "dataSeconds": 7.662, + "resultSeconds": 0.03, + "unattributedSeconds": 0.0, + "canonicalSeconds": 0.001, + "finalizationSeconds": 1.089 + }, + "processHotMedian": { + "queueWaitSeconds": 0.0, + "executionSeconds": 0.596, + "totalSeconds": 0.596, + "runnerSeconds": 0.026, + "bundleValidationSeconds": 0.0, + "sourceEpochSeconds": 0.0, + "strategyContractSeconds": 0.003, + "terminalCalendarSeconds": 0.0, + "riskFreeRateSeconds": 0.0, + "riskFreeRateCacheHit": true, + "engineSeconds": 0.012, + "dataSeconds": 0.007, + "resultSeconds": 0.002, + "unattributedSeconds": 0.0, + "canonicalSeconds": 0.001, + "finalizationSeconds": 0.569 + } + } +} diff --git a/docs/evidence/native-daily-factor-replays-20260907/native-pit-intraday-request-20260907.json b/docs/evidence/native-daily-factor-replays-20260907/native-pit-intraday-request-20260907.json new file mode 100644 index 0000000..6bcf4de --- /dev/null +++ b/docs/evidence/native-daily-factor-replays-20260907/native-pit-intraday-request-20260907.json @@ -0,0 +1,531 @@ +{ + "strategy_id": "benchmark-native-factor-overlay", + "strategy_version_id": "1.0.0", + "user_id": "2d84120b-567a-417e-b6a4-6ffedceb132f", + "runtime": { + "start_date": "2025-09-08", + "end_date": "2025-09-12", + "frequency": "1d", + "source_table": "strategy_factory_source_lake.daily_source_rows_v1", + "signal_symbol": "000300.SH", + "benchmark_symbol": "000300.SH", + "initial_cash": 10000000.0, + "backtestDataBundleId": "bt_bundle_85825809f940ce489825", + "backtestDataBundleHash": "c83203674aa4ba23eed0bbc043c2a53f13bcbc37f45c395b3fb8d560a4d14991" + }, + "execution": { + "matchingType": "current_bar_close", + "rebalanceCashMode": "sell_then_buy", + "sellThenBuyDelaySlippageRate": 0, + "slippageModel": "price_ratio", + "slippageValue": 0.002, + "commissionRate": 0.0001, + "minimumCommission": 0.0, + "stampTaxRateBeforeChange": 0.001, + "stampTaxRateAfterChange": 0.0005, + "stampTaxChangeDate": "2023-08-28", + "volumeLimit": true, + "liquidityLimit": false, + "volumePercent": 0.25, + "riskPolicy": { + "allowMarketOrders": true, + "blacklistEnabled": true, + "blacklistedSymbols": [], + "commissionRate": 0.0001, + "forbidSameDayRebuyAfterSell": false, + "liquidityLimitEnabled": false, + "liveTradingEnabled": false, + "maxOrderNotional": 100000000, + "maxOrderQuantity": 1000000, + "maxSymbolPosition": 10000000, + "minimumCommission": 0, + "rejectBjseBuy": true, + "rejectBjseSelection": false, + "rejectInactiveBuy": true, + "rejectInactiveSelection": false, + "rejectInactiveSell": true, + "rejectKcbBuy": false, + "rejectKcbSelection": false, + "rejectLowerLimitSelection": false, + "rejectLowerLimitSell": true, + "rejectNewListingBuy": true, + "rejectNewListingSelection": false, + "rejectOneYuanBuy": true, + "rejectOneYuanSelection": false, + "rejectPausedBuy": true, + "rejectPausedSelection": false, + "rejectPausedSell": true, + "rejectStBuy": true, + "rejectStSelection": false, + "rejectStarStBuy": true, + "rejectStarStSelection": false, + "rejectUpperLimitBuy": true, + "rejectUpperLimitSelection": false, + "respectAllowBuySell": true, + "stampTaxChangeDate": "2023-08-28", + "stampTaxRateAfterChange": 0.0005, + "stampTaxRateBeforeChange": 0.001, + "volumeLimitEnabled": true, + "volumePercent": 0.25 + } + }, + "strategy_source": { + "source_type": "platform-strategy", + "language": "engine-script", + "parser": "omniquant-engine-script-v2", + "source_code": "strategy(\"许总_组合_backtest\") {\n mode(\"rotation\")\n market(\"CN_A\")\n benchmark(\"000300.SH\")\n signal(\"000300.SH\")\n rebalance.every_days(1).at([\"09:30\"])\n universe.include([\"688116.SH\", \"603200.SH\", \"300395.SZ\", \"600732.SH\", \"000063.SZ\", \"002625.SZ\", \"300811.SZ\", \"002851.SZ\", \"000988.SZ\", \"002281.SZ\", \"300205.SZ\", \"002239.SZ\", \"600079.SH\", \"600276.SH\", \"600909.SH\", \"002169.SZ\", \"603156.SH\", \"002654.SZ\", \"000999.SZ\", \"601066.SH\", \"603345.SH\", \"000333.SZ\", \"603726.SH\", \"688012.SH\"])\n selection.limit(24)\n selection.candidate_limit(24)\n selection.market_cap_band(field=\"close\", lower=0, upper=1000000000000)\n filter.stock_expr((close > 0)) && (ths_up_days_stock >= 1)\n ordering.rank_expr((((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") || ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") || symbol == \"002625.SZ\")) || (((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") || symbol == \"000988.SZ\") || ((symbol == \"002281.SZ\" || symbol == \"300205.SZ\") || symbol == \"002239.SZ\"))) || ((((symbol == \"600079.SH\" || symbol == \"600276.SH\") || symbol == \"600909.SH\") || ((symbol == \"002169.SZ\" || symbol == \"603156.SH\") || symbol == \"002654.SZ\")) || (((symbol == \"000999.SZ\" || symbol == \"601066.SH\") || symbol == \"603345.SH\") || ((symbol == \"000333.SZ\" || symbol == \"603726.SH\") || symbol == \"688012.SH\")))) ? (((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") || ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") || symbol == \"002625.SZ\")) || (((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") || symbol == \"000988.SZ\") || ((symbol == \"002281.SZ\" || symbol == \"300205.SZ\") || symbol == \"002239.SZ\"))) ? ((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") || ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") || symbol == \"002625.SZ\")) ? (((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") ? ((symbol == \"688116.SH\" || symbol == \"603200.SH\") ? (symbol == \"688116.SH\" ? (0) : (1)) : (2)) : ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") ? (symbol == \"600732.SH\" ? (3) : (4)) : (5))) : (((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") || symbol == \"000988.SZ\") ? ((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") ? (symbol == \"300811.SZ\" ? (6) : (7)) : (8)) : ((symbol == \"002281.SZ\" || symbol == \"300205.SZ\") ? (symbol == \"002281.SZ\" ? (9) : (10)) : (11)))) : ((((symbol == \"600079.SH\" || symbol == \"600276.SH\") || symbol == \"600909.SH\") || ((symbol == \"002169.SZ\" || symbol == \"603156.SH\") || symbol == \"002654.SZ\")) ? (((symbol == \"600079.SH\" || symbol == \"600276.SH\") || symbol == \"600909.SH\") ? ((symbol == \"600079.SH\" || symbol == \"600276.SH\") ? (symbol == \"600079.SH\" ? (12) : (13)) : (14)) : ((symbol == \"002169.SZ\" || symbol == \"603156.SH\") ? (symbol == \"002169.SZ\" ? (15) : (16)) : (17))) : (((symbol == \"000999.SZ\" || symbol == \"601066.SH\") || symbol == \"603345.SH\") ? ((symbol == \"000999.SZ\" || symbol == \"601066.SH\") ? (symbol == \"000999.SZ\" ? (18) : (19)) : (20)) : ((symbol == \"000333.SZ\" || symbol == \"603726.SH\") ? (symbol == \"000333.SZ\" ? (21) : (22)) : (23))))) : 24, \"asc\")\n risk.index_exposure(max(0.0, 0.9000000000 - 0.0000 / max(total_equity, 1.0)))\n allocation.buy_scale((((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") || ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") || symbol == \"002625.SZ\")) || (((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") || symbol == \"000988.SZ\") || ((symbol == \"002281.SZ\" || symbol == \"300205.SZ\") || symbol == \"002239.SZ\"))) || ((((symbol == \"600079.SH\" || symbol == \"600276.SH\") || symbol == \"600909.SH\") || ((symbol == \"002169.SZ\" || symbol == \"603156.SH\") || symbol == \"002654.SZ\")) || (((symbol == \"000999.SZ\" || symbol == \"601066.SH\") || symbol == \"603345.SH\") || ((symbol == \"000333.SZ\" || symbol == \"603726.SH\") || symbol == \"688012.SH\")))) ? (((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || (symbol == \"300395.SZ\" || symbol == \"600732.SH\")) || ((symbol == \"000063.SZ\" || symbol == \"002625.SZ\") || (symbol == \"300811.SZ\" || symbol == \"002851.SZ\"))) || (((symbol == \"000988.SZ\" || symbol == \"002281.SZ\") || (symbol == \"300205.SZ\" || symbol == \"002239.SZ\")) || ((symbol == \"600079.SH\" || symbol == \"600276.SH\") || (symbol == \"600909.SH\" || symbol == \"002169.SZ\")))) ? (1.0008000000) : (0.9984000000)) : 0.0)\n risk.stop_loss(holding_return <= -0.0800000000)\n risk.take_profit(holding_return >= 0.1600000000)\n risk.reference_price_mode(\"position_average_entry_price\")\n trading.daily_top_up(true)\n trading.daily_position_target_adjust(true)\n trading.target_portfolio_daily(true)\n trading.rebalance_existing_positions(true)\n trading.hold_until_exit(true)\n trading.retry_empty_rebalance(true)\n trading.release_slot_on_exit_signal(true)\n trading.redistribute_target_weights_after_exit(true)\n trading.reenter_exited_targets(true)\n execution.matching_type(\"current_bar_close\")\n}" + }, + "strategy_spec": { + "benchmark": { + "fallbackInstrumentId": "000300.SH", + "instrumentId": "000300.SH", + "note": "必须使用真实指数链路;若 000852.SH 不可用,应直接报错而不是退化到其他标的。" + }, + "engineConfig": { + "benchmarkSymbol": "000300.SH", + "commissionRate": 0.0001, + "dividendReinvestment": false, + "dynamicRange": { + "baseCapFloor": 7, + "baseIndexLevel": 2000, + "capSpan": 1000000000000, + "xs": 0.008 + }, + "frequency": "1d", + "indexThrottle": { + "defensiveExposure": 0.5, + "fullExposure": 1, + "longDays": 10, + "rsiRate": 1.0001, + "shortDays": 5 + }, + "liquidityLimit": false, + "matchingType": "current_bar_close", + "minimumCommission": 0.0, + "rankLimit": 24, + "rebalanceCashMode": "sell_then_buy", + "rebalanceSchedule": { + "frequency": "daily", + "time": "09:30" + }, + "refreshRate": 1, + "riskPolicy": { + "allowMarketOrders": true, + "blacklistEnabled": true, + "blacklistedSymbols": [], + "commissionRate": 0.0001, + "forbidSameDayRebuyAfterSell": false, + "liquidityLimitEnabled": false, + "liveTradingEnabled": false, + "maxOrderNotional": 100000000, + "maxOrderQuantity": 1000000, + "maxSymbolPosition": 10000000, + "minimumCommission": 0, + "rejectBjseBuy": true, + "rejectBjseSelection": false, + "rejectInactiveBuy": true, + "rejectInactiveSelection": false, + "rejectInactiveSell": true, + "rejectKcbBuy": false, + "rejectKcbSelection": false, + "rejectLowerLimitSelection": false, + "rejectLowerLimitSell": true, + "rejectNewListingBuy": true, + "rejectNewListingSelection": false, + "rejectOneYuanBuy": true, + "rejectOneYuanSelection": false, + "rejectPausedBuy": true, + "rejectPausedSelection": false, + "rejectPausedSell": true, + "rejectStBuy": true, + "rejectStSelection": false, + "rejectStarStBuy": true, + "rejectStarStSelection": false, + "rejectUpperLimitBuy": true, + "rejectUpperLimitSelection": false, + "respectAllowBuySell": true, + "stampTaxChangeDate": "2023-08-28", + "stampTaxRateAfterChange": 0.0005, + "stampTaxRateBeforeChange": 0.001, + "volumeLimitEnabled": true, + "volumePercent": 0.25 + }, + "rsiRate": 1.0001, + "sellThenBuyDelaySlippageRate": 0, + "signalSymbol": "000300.SH", + "skipWindows": [], + "slippageModel": "price_ratio", + "slippageValue": 0.002, + "stampTaxChangeDate": "2023-08-28", + "stampTaxRateAfterChange": 0.0005, + "stampTaxRateBeforeChange": 0.001, + "stockMaFilter": { + "longDays": 20, + "midDays": 10, + "rsiRate": 1.0001, + "shortDays": 5, + "volumeLongDays": 60, + "volumeShortDays": 5 + }, + "stopLossMultiplier": 0.93, + "strictValueBudget": true, + "takeProfitMultiplier": 1.07, + "templateId": "许总_组合_backtest", + "volumeLimit": true, + "volumePercent": 0.25 + }, + "execution": { + "commissionRate": 0.0001, + "executionGranularity": "daily_or_minute_bar", + "extractor": "omniquant-engine-script-v2", + "frequency": "1d", + "liquidityLimit": false, + "matchingType": "current_bar_close", + "minimumCommission": 0.0, + "priceSource": "current_bar_close_or_next_bar_open_or_minute_bar", + "rebalanceCashMode": "sell_then_buy", + "riskPolicy": { + "allowMarketOrders": true, + "blacklistEnabled": true, + "blacklistedSymbols": [], + "commissionRate": 0.0001, + "forbidSameDayRebuyAfterSell": false, + "liquidityLimitEnabled": false, + "liveTradingEnabled": false, + "maxOrderNotional": 100000000, + "maxOrderQuantity": 1000000, + "maxSymbolPosition": 10000000, + "minimumCommission": 0, + "rejectBjseBuy": true, + "rejectBjseSelection": false, + "rejectInactiveBuy": true, + "rejectInactiveSelection": false, + "rejectInactiveSell": true, + "rejectKcbBuy": false, + "rejectKcbSelection": false, + "rejectLowerLimitSelection": false, + "rejectLowerLimitSell": true, + "rejectNewListingBuy": true, + "rejectNewListingSelection": false, + "rejectOneYuanBuy": true, + "rejectOneYuanSelection": false, + "rejectPausedBuy": true, + "rejectPausedSelection": false, + "rejectPausedSell": true, + "rejectStBuy": true, + "rejectStSelection": false, + "rejectStarStBuy": true, + "rejectStarStSelection": false, + "rejectUpperLimitBuy": true, + "rejectUpperLimitSelection": false, + "respectAllowBuySell": true, + "stampTaxChangeDate": "2023-08-28", + "stampTaxRateAfterChange": 0.0005, + "stampTaxRateBeforeChange": 0.001, + "volumeLimitEnabled": true, + "volumePercent": 0.25 + }, + "selectionGranularity": "strategy_factory_source_lake.daily_source_rows_v1", + "sellThenBuyDelaySlippageRate": 0, + "slippageModel": "price_ratio", + "slippageValue": 0.002, + "sourceKind": "platform-strategy", + "sourceLanguage": "engine-script", + "stampTaxChangeDate": "2023-08-28", + "stampTaxRateAfterChange": 0.0005, + "stampTaxRateBeforeChange": 0.001, + "strictValueBudget": true, + "volumeLimit": true, + "volumePercent": 0.25 + }, + "factorRefs": [ + "close", + "ths_up_days_stock" + ], + "factorValueBindings": [], + "market": "CN_A", + "metadata": { + "backtestDataBundle": { + "sourceTable": "strategy_factory_source_lake.daily_source_rows_v1", + "backtestDataBundleId": "bt_bundle_85825809f940ce489825", + "backtestDataBundleHash": "c83203674aa4ba23eed0bbc043c2a53f13bcbc37f45c395b3fb8d560a4d14991" + }, + "backtestDataBundleHash": "c83203674aa4ba23eed0bbc043c2a53f13bcbc37f45c395b3fb8d560a4d14991", + "backtestDataBundleId": "bt_bundle_85825809f940ce489825", + "sourceTable": "strategy_factory_source_lake.daily_source_rows_v1", + "strategyExecutionContract": { + "commissionRate": "0.0001", + "dynamicSlippageImpact": "0", + "dynamicSlippageMax": "0", + "dynamicSlippageVolatility": "0", + "frequency": "1d", + "hash": "fnv1a-66a4a68e", + "liquidityLimitEnabled": false, + "matchingType": "current_bar_close", + "minimumCommission": "0", + "rebalanceCashMode": "sell_then_buy", + "riskPolicy": { + "allowMarketOrders": true, + "blacklistEnabled": true, + "blacklistedSymbols": [], + "commissionRate": 0.0001, + "forbidSameDayRebuyAfterSell": false, + "liquidityLimitEnabled": false, + "liveTradingEnabled": false, + "maxOrderNotional": 100000000, + "maxOrderQuantity": 1000000, + "maxSymbolPosition": 10000000, + "minimumCommission": 0, + "rejectBjseBuy": true, + "rejectBjseSelection": false, + "rejectInactiveBuy": true, + "rejectInactiveSelection": false, + "rejectInactiveSell": true, + "rejectKcbBuy": false, + "rejectKcbSelection": false, + "rejectLowerLimitSelection": false, + "rejectLowerLimitSell": true, + "rejectNewListingBuy": true, + "rejectNewListingSelection": false, + "rejectOneYuanBuy": true, + "rejectOneYuanSelection": false, + "rejectPausedBuy": true, + "rejectPausedSelection": false, + "rejectPausedSell": true, + "rejectStBuy": true, + "rejectStSelection": false, + "rejectStarStBuy": true, + "rejectStarStSelection": false, + "rejectUpperLimitBuy": true, + "rejectUpperLimitSelection": false, + "respectAllowBuySell": true, + "stampTaxChangeDate": "2023-08-28", + "stampTaxRateAfterChange": 0.0005, + "stampTaxRateBeforeChange": 0.001, + "volumeLimitEnabled": true, + "volumePercent": 0.25 + }, + "sellThenBuyDelaySlippageRate": "0", + "slippageMode": "fixed", + "slippageRate": "0.002", + "stampTaxChangeDate": "2023-08-28", + "stampTaxRateAfterChange": "0.0005", + "stampTaxRateBeforeChange": "0.001", + "version": "fidc-strategy-execution-v1", + "volumeLimitEnabled": true, + "volumePercent": "0.25" + }, + "strategyExecutionContractHash": "fnv1a-66a4a68e", + "strategyExecutionContractVersion": "fidc-strategy-execution-v1", + "strategyRiskPolicyVersion": "fidc-risk-policy-v2" + }, + "mode": "rotation", + "rebalance": { + "dailyApproximation": "日线回测按 matching_type 撮合;分钟线回测按交易时刻分钟价格撮合", + "frequencyDays": 1, + "schedule": { + "frequency": "daily", + "time": "09:30" + }, + "tradeTimes": [ + "09:30" + ] + }, + "risk": { + "indexThrottleExpr": "max(0.0, 0.9000000000 - 0.0000 / max(total_equity, 1.0))", + "stopLossExpr": "holding_return <= -0.0800000000", + "stopTakeReferencePriceMode": "position_average_entry_price", + "takeProfitExpr": "holding_return >= 0.1600000000" + }, + "runtimeExpressions": { + "allocation": { + "buyScaleExpr": "(((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") || ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") || symbol == \"002625.SZ\")) || (((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") || symbol == \"000988.SZ\") || ((symbol == \"002281.SZ\" || symbol == \"300205.SZ\") || symbol == \"002239.SZ\"))) || ((((symbol == \"600079.SH\" || symbol == \"600276.SH\") || symbol == \"600909.SH\") || ((symbol == \"002169.SZ\" || symbol == \"603156.SH\") || symbol == \"002654.SZ\")) || (((symbol == \"000999.SZ\" || symbol == \"601066.SH\") || symbol == \"603345.SH\") || ((symbol == \"000333.SZ\" || symbol == \"603726.SH\") || symbol == \"688012.SH\")))) ? (((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || (symbol == \"300395.SZ\" || symbol == \"600732.SH\")) || ((symbol == \"000063.SZ\" || symbol == \"002625.SZ\") || (symbol == \"300811.SZ\" || symbol == \"002851.SZ\"))) || (((symbol == \"000988.SZ\" || symbol == \"002281.SZ\") || (symbol == \"300205.SZ\" || symbol == \"002239.SZ\")) || ((symbol == \"600079.SH\" || symbol == \"600276.SH\") || (symbol == \"600909.SH\" || symbol == \"002169.SZ\")))) ? (1.0008000000) : (0.9984000000)) : 0.0" + }, + "ordering": { + "rankBy": "market_cap", + "rankExpr": "(((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") || ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") || symbol == \"002625.SZ\")) || (((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") || symbol == \"000988.SZ\") || ((symbol == \"002281.SZ\" || symbol == \"300205.SZ\") || symbol == \"002239.SZ\"))) || ((((symbol == \"600079.SH\" || symbol == \"600276.SH\") || symbol == \"600909.SH\") || ((symbol == \"002169.SZ\" || symbol == \"603156.SH\") || symbol == \"002654.SZ\")) || (((symbol == \"000999.SZ\" || symbol == \"601066.SH\") || symbol == \"603345.SH\") || ((symbol == \"000333.SZ\" || symbol == \"603726.SH\") || symbol == \"688012.SH\")))) ? (((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") || ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") || symbol == \"002625.SZ\")) || (((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") || symbol == \"000988.SZ\") || ((symbol == \"002281.SZ\" || symbol == \"300205.SZ\") || symbol == \"002239.SZ\"))) ? ((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") || ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") || symbol == \"002625.SZ\")) ? (((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") ? ((symbol == \"688116.SH\" || symbol == \"603200.SH\") ? (symbol == \"688116.SH\" ? (0) : (1)) : (2)) : ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") ? (symbol == \"600732.SH\" ? (3) : (4)) : (5))) : (((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") || symbol == \"000988.SZ\") ? ((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") ? (symbol == \"300811.SZ\" ? (6) : (7)) : (8)) : ((symbol == \"002281.SZ\" || symbol == \"300205.SZ\") ? (symbol == \"002281.SZ\" ? (9) : (10)) : (11)))) : ((((symbol == \"600079.SH\" || symbol == \"600276.SH\") || symbol == \"600909.SH\") || ((symbol == \"002169.SZ\" || symbol == \"603156.SH\") || symbol == \"002654.SZ\")) ? (((symbol == \"600079.SH\" || symbol == \"600276.SH\") || symbol == \"600909.SH\") ? ((symbol == \"600079.SH\" || symbol == \"600276.SH\") ? (symbol == \"600079.SH\" ? (12) : (13)) : (14)) : ((symbol == \"002169.SZ\" || symbol == \"603156.SH\") ? (symbol == \"002169.SZ\" ? (15) : (16)) : (17))) : (((symbol == \"000999.SZ\" || symbol == \"601066.SH\") || symbol == \"603345.SH\") ? ((symbol == \"000999.SZ\" || symbol == \"601066.SH\") ? (symbol == \"000999.SZ\" ? (18) : (19)) : (20)) : ((symbol == \"000333.SZ\" || symbol == \"603726.SH\") ? (symbol == \"000333.SZ\" ? (21) : (22)) : (23))))) : 24", + "rankOrder": "asc" + }, + "prelude": "", + "risk": { + "exposureExpr": "max(0.0, 0.9000000000 - 0.0000 / max(total_equity, 1.0))", + "stopLossExpr": "holding_return <= -0.0800000000", + "stopTakeReferencePriceMode": "position_average_entry_price", + "takeProfitExpr": "holding_return >= 0.1600000000" + }, + "schedule": { + "frequency": "daily", + "time": "09:30" + }, + "selection": { + "candidateLimitExpr": "24", + "limitExpr": "24", + "marketCapField": "close", + "marketCapLowerExpr": "0", + "marketCapUpperExpr": "1000000000000", + "stockFilterExpr": "((close > 0)) && (ths_up_days_stock >= 1)" + }, + "trading": { + "actions": [], + "dailyPositionTargetAdjust": true, + "dailyTopUp": true, + "holdUntilExit": true, + "rebalanceExistingPositions": true, + "redistributeTargetWeightsAfterExit": true, + "reenterExitedTargets": true, + "releaseSlotOnExitSignal": true, + "retryEmptyRebalance": true, + "rotationEnabled": true, + "stage": "on_day", + "subscriptionGuardRequired": false, + "targetPortfolioDaily": true + } + }, + "seasonality": { + "skipWindows": [] + }, + "selectors": [ + { + "field": "market_cap", + "lowerExpr": "0", + "mapping": "close -> strategy_factory_source_lake.runtime_fields.close", + "type": "dynamicRange", + "upperExpr": "1000000000000" + }, + { + "expr": "((close > 0)) && (ths_up_days_stock >= 1)", + "type": "filter" + }, + { + "limitExpr": "24", + "orderBy": [ + "market_cap asc" + ], + "type": "rank" + } + ], + "signalSymbol": "000300.SH", + "sourceCode": "strategy(\"许总_组合_backtest\") {\n mode(\"rotation\")\n market(\"CN_A\")\n benchmark(\"000300.SH\")\n signal(\"000300.SH\")\n rebalance.every_days(1).at([\"09:30\"])\n universe.include([\"688116.SH\", \"603200.SH\", \"300395.SZ\", \"600732.SH\", \"000063.SZ\", \"002625.SZ\", \"300811.SZ\", \"002851.SZ\", \"000988.SZ\", \"002281.SZ\", \"300205.SZ\", \"002239.SZ\", \"600079.SH\", \"600276.SH\", \"600909.SH\", \"002169.SZ\", \"603156.SH\", \"002654.SZ\", \"000999.SZ\", \"601066.SH\", \"603345.SH\", \"000333.SZ\", \"603726.SH\", \"688012.SH\"])\n selection.limit(24)\n selection.candidate_limit(24)\n selection.market_cap_band(field=\"close\", lower=0, upper=1000000000000)\n filter.stock_expr((close > 0)) && (ths_up_days_stock >= 1)\n ordering.rank_expr((((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") || ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") || symbol == \"002625.SZ\")) || (((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") || symbol == \"000988.SZ\") || ((symbol == \"002281.SZ\" || symbol == \"300205.SZ\") || symbol == \"002239.SZ\"))) || ((((symbol == \"600079.SH\" || symbol == \"600276.SH\") || symbol == \"600909.SH\") || ((symbol == \"002169.SZ\" || symbol == \"603156.SH\") || symbol == \"002654.SZ\")) || (((symbol == \"000999.SZ\" || symbol == \"601066.SH\") || symbol == \"603345.SH\") || ((symbol == \"000333.SZ\" || symbol == \"603726.SH\") || symbol == \"688012.SH\")))) ? (((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") || ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") || symbol == \"002625.SZ\")) || (((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") || symbol == \"000988.SZ\") || ((symbol == \"002281.SZ\" || symbol == \"300205.SZ\") || symbol == \"002239.SZ\"))) ? ((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") || ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") || symbol == \"002625.SZ\")) ? (((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") ? ((symbol == \"688116.SH\" || symbol == \"603200.SH\") ? (symbol == \"688116.SH\" ? (0) : (1)) : (2)) : ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") ? (symbol == \"600732.SH\" ? (3) : (4)) : (5))) : (((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") || symbol == \"000988.SZ\") ? ((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") ? (symbol == \"300811.SZ\" ? (6) : (7)) : (8)) : ((symbol == \"002281.SZ\" || symbol == \"300205.SZ\") ? (symbol == \"002281.SZ\" ? (9) : (10)) : (11)))) : ((((symbol == \"600079.SH\" || symbol == \"600276.SH\") || symbol == \"600909.SH\") || ((symbol == \"002169.SZ\" || symbol == \"603156.SH\") || symbol == \"002654.SZ\")) ? (((symbol == \"600079.SH\" || symbol == \"600276.SH\") || symbol == \"600909.SH\") ? ((symbol == \"600079.SH\" || symbol == \"600276.SH\") ? (symbol == \"600079.SH\" ? (12) : (13)) : (14)) : ((symbol == \"002169.SZ\" || symbol == \"603156.SH\") ? (symbol == \"002169.SZ\" ? (15) : (16)) : (17))) : (((symbol == \"000999.SZ\" || symbol == \"601066.SH\") || symbol == \"603345.SH\") ? ((symbol == \"000999.SZ\" || symbol == \"601066.SH\") ? (symbol == \"000999.SZ\" ? (18) : (19)) : (20)) : ((symbol == \"000333.SZ\" || symbol == \"603726.SH\") ? (symbol == \"000333.SZ\" ? (21) : (22)) : (23))))) : 24, \"asc\")\n risk.index_exposure(max(0.0, 0.9000000000 - 0.0000 / max(total_equity, 1.0)))\n allocation.buy_scale((((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") || ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") || symbol == \"002625.SZ\")) || (((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") || symbol == \"000988.SZ\") || ((symbol == \"002281.SZ\" || symbol == \"300205.SZ\") || symbol == \"002239.SZ\"))) || ((((symbol == \"600079.SH\" || symbol == \"600276.SH\") || symbol == \"600909.SH\") || ((symbol == \"002169.SZ\" || symbol == \"603156.SH\") || symbol == \"002654.SZ\")) || (((symbol == \"000999.SZ\" || symbol == \"601066.SH\") || symbol == \"603345.SH\") || ((symbol == \"000333.SZ\" || symbol == \"603726.SH\") || symbol == \"688012.SH\")))) ? (((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || (symbol == \"300395.SZ\" || symbol == \"600732.SH\")) || ((symbol == \"000063.SZ\" || symbol == \"002625.SZ\") || (symbol == \"300811.SZ\" || symbol == \"002851.SZ\"))) || (((symbol == \"000988.SZ\" || symbol == \"002281.SZ\") || (symbol == \"300205.SZ\" || symbol == \"002239.SZ\")) || ((symbol == \"600079.SH\" || symbol == \"600276.SH\") || (symbol == \"600909.SH\" || symbol == \"002169.SZ\")))) ? (1.0008000000) : (0.9984000000)) : 0.0)\n risk.stop_loss(holding_return <= -0.0800000000)\n risk.take_profit(holding_return >= 0.1600000000)\n risk.reference_price_mode(\"position_average_entry_price\")\n trading.daily_top_up(true)\n trading.daily_position_target_adjust(true)\n trading.target_portfolio_daily(true)\n trading.rebalance_existing_positions(true)\n trading.hold_until_exit(true)\n trading.retry_empty_rebalance(true)\n trading.release_slot_on_exit_signal(true)\n trading.redistribute_target_weights_after_exit(true)\n trading.reenter_exited_targets(true)\n execution.matching_type(\"current_bar_close\")\n}", + "strategyId": "许总_组合_backtest", + "universe": { + "exclude": [], + "implementationNotes": [ + "ST、停牌、退市、新股、科创、一元、涨跌停、同日卖出禁买、成交量和费用由 riskPolicy / RiskLimits 统一执行", + "上市日期与退市日期取自 instrument 结构化字段,不再使用股票名称做 ST/退市判断", + "盘中 current_price / last_price 由策略交易时刻批量 tick 查询驱动" + ], + "include": [ + "688116.SH", + "603200.SH", + "300395.SZ", + "600732.SH", + "000063.SZ", + "002625.SZ", + "300811.SZ", + "002851.SZ", + "000988.SZ", + "002281.SZ", + "300205.SZ", + "002239.SZ", + "600079.SH", + "600276.SH", + "600909.SH", + "002169.SZ", + "603156.SH", + "002654.SZ", + "000999.SZ", + "601066.SH", + "603345.SH", + "000333.SZ", + "603726.SH", + "688012.SH" + ] + }, + "version": "1.0.0", + "stockPoolFactorContract": { + "schemaVersion": 1, + "entryLogic": "all", + "exitLogic": "any", + "conditions": [ + { + "factorRef": "up_days_stock", + "label": "连涨天数", + "role": "selection", + "registryRole": "selection_feature", + "roleRegistrySha256": "1d0b307c168feda08d5fbe20f0e88964230553f8ceb2017b66aec48dbd5a5b57", + "roleEvidence": { + "role": "selection_feature", + "polarity": "trend_persistence_positive", + "signalShape": "state", + "holdingStates": [ + "flat" + ], + "requiredConfirmations": [], + "cooldownTradingDays": 0, + "windowTradingDays": 1, + "recommendedParameters": { + "inputUnit": "days", + "minimum": 0 + } + }, + "operator": ">=", + "threshold": 1, + "semantic": { + "ref": "up_days_stock", + "label": "连涨天数", + "status": "available", + "queryable": true, + "source": "strategy-factory-source-lake:indicator", + "schema": "strategy-factory.value-semantics/v1", + "valueType": "integer", + "semanticType": "count", + "comparisonGroup": "count", + "storageUnit": "days", + "inputUnit": "days", + "inputScale": 1.0, + "allowedOperators": [ + ">", + ">=", + "<", + "<=", + "==", + "!=", + "between", + "in" + ], + "nullable": true, + "declared": true, + "metadataStatus": "declared", + "semanticProvenance": "explicit_manifest", + "businessSemanticDeclared": true, + "minimum": 0, + "backtestBinding": { + "field": "ths_up_days_stock", + "sourceDataset": "indicators_up_days_stock" + }, + "tradingRoles": [ + { + "role": "selection_feature", + "polarity": "trend_persistence_positive", + "signalShape": "state", + "holdingStates": [ + "flat" + ], + "requiredConfirmations": [], + "cooldownTradingDays": 0, + "windowTradingDays": 1, + "recommendedParameters": { + "inputUnit": "days", + "minimum": 0 + } + } + ], + "tradingRoleTradable": true, + "tradingRoleEvidenceStatus": "source_lake_registered_indicator", + "tradingRoleRegistrySha256": "1d0b307c168feda08d5fbe20f0e88964230553f8ceb2017b66aec48dbd5a5b57" + } + } + ] + } + } +} diff --git a/docs/evidence/native-daily-factor-replays-20260907/native-pit-nextopen-20260907.json b/docs/evidence/native-daily-factor-replays-20260907/native-pit-nextopen-20260907.json new file mode 100644 index 0000000..5fbd2d8 --- /dev/null +++ b/docs/evidence/native-daily-factor-replays-20260907/native-pit-nextopen-20260907.json @@ -0,0 +1,276 @@ +{ + "schemaVersion": "fidc-backtest-benchmark/v5", + "label": "native-pit-nextopen", + "generatedAtUnixSeconds": 1788790370, + "requestSha256": "7948497bfa966eb118e56438e7c261869a26ab70a62186b92f761719f85e91c7", + "requestOrigin": { + "kind": "request_file" + }, + "baseline": null, + "baselineComparable": true, + "baselineComparison": null, + "bundleRefresh": null, + "baseUrl": "http://127.0.0.1:8081", + "runs": [ + { + "slot": 0, + "cacheClass": "restart_or_cold", + "runId": "btr_1788790344805_1150210_2", + "status": "succeeded", + "queueWaitSeconds": 0.0, + "executionSeconds": 21.61, + "totalSeconds": 21.61, + "runnerSeconds": 21.294, + "bundleValidationSeconds": 0.004, + "sourceEpochSeconds": 0.0, + "strategyContractSeconds": 0.003, + "terminalCalendarSeconds": 0.0, + "riskFreeRateSeconds": 0.017, + "riskFreeRateCacheHit": false, + "engineSeconds": 5.566, + "dataSeconds": 15.305, + "resultSeconds": 0.395, + "unattributedSeconds": 0.0, + "canonicalSeconds": 0.206, + "resultFinalization": { + "canonicalSeconds": 0.206, + "processEventStoreSeconds": 0.168, + "storePublishSeconds": 0.005, + "summarySeconds": 0.003 + }, + "dataPreparationTimings": { + "adjustmentValidationSeconds": 0.0, + "availableStockStDates": 0, + "benchmarkFetchSeconds": 0.023, + "candidatePlanBasePanelSeconds": 0.0, + "candidatePlanSelectionSeconds": 0.0, + "corporateActionSeconds": 0.017, + "datasetConstructSeconds": 2.943, + "externalFetchSeconds": 0.025, + "historyDateSeconds": 0.0, + "instrumentIndexBuildSeconds": 0.269, + "instrumentMetadataSeconds": 0.006, + "loopSeconds": 0.925, + "marketIndexBuildSeconds": 1.846, + "quotePlanSeconds": 0.0, + "riskSnapshotSeconds": 0.0, + "sourceQuerySeconds": 9.0, + "stockStLookupSeconds": 0.0, + "supplementalExecutionFetchSeconds": 0.0, + "totalSeconds": 15.3 + }, + "finalizationSeconds": 0.307, + "finalizationTimings": { + "artifactVerificationSeconds": 0.008, + "cacheRegistrationSeconds": 0.0, + "postgresClientAcquireSeconds": 0.0, + "postgresPersistSeconds": 0.299, + "preTerminalUpdateSeconds": 0.307, + "schemaCheckSeconds": 0.0, + "timingSource": "fidc-backtest-service-monotonic" + }, + "totalReturn": 0.9857987122635, + "tradeCount": 25408, + "riskDecisionCount": 113, + "resultStoreDigest": "1eab5a5ffe59efeaba6b2048a33760aab31d42efa0fc10e35a4a7048db853235", + "canonicalResultDigest": "b29b085d43bcc0f8f1712767421781c70570a24112933623d4bbbef46508d710", + "terminalAudit": { + "cashReceivableCount": 0, + "cashReceivableTotalAmount": 0.0, + "earliestDeferredCashDate": null, + "futuresOpenOrderCount": 0, + "lastExecutionDate": "2026-08-28", + "omittedOpenOrderCount": 0, + "openOrderSamples": [], + "pendingCashFlowCount": 0, + "pendingCashFlowNetAmount": 0.0, + "status": "clean", + "stockOpenOrderCount": 0 + }, + "terminalAuditVerified": true, + "terminalAuditSha256": "0e8503c2bfe9ecfd93bab3a95f79461830886eb693cfe6f477279908c0d95bd5", + "implementation": { + "buildManifestSha256": "fb4f7ac191c8ab8d8d39fc48014f84ce01d4c0731ffe986e7d5cb0107a3e5539", + "builtAt": "2026-09-07T22:06:18+08:00", + "engineCommit": "1b78186c4e37273c64c1f9e208c2e47021ee4fff", + "identitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33", + "runnerBinarySha256": "7cddebf9ab5cbbd5d6f9bd5c7bf006c3895ee26967d23d80dc86b47c50fb761d", + "schemaVersion": "fidc-backtest-implementation/v1", + "serviceBinarySha256": "f09d121d73c34ad3cd5e2e0c24b0bdaf1974309c9ec3b93229cfb824b1e9f11a", + "serviceCommit": "c80a38b91a398fd713f1f1faaa19a581ea85efad", + "status": "verified" + }, + "implementationVerified": true, + "implementationIdentitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33", + "artifactManifest": { + "canonicalResult": { + "algorithm": "sha256", + "ordering": "engine_fact_order_v2", + "schemaVersion": "fidc-canonical-backtest-result/v2", + "sections": { + "accountEvents": { + "rowCount": 25572, + "sha256": "da7576c1cbade9b48f1a154d07e870f1472924f4e0054cba0ef06739585ce380" + }, + "equityFacts": { + "rowCount": 1216, + "sha256": "13db0a6d484cdf871e4f1dc1a4bc8db1e809d2808b21cf4fcab3961e6ad9e6fe" + }, + "fillEvents": { + "rowCount": 25408, + "sha256": "92e0d103bbb487af45d27b720cdc92ef398d16e11655a6d0bcff8046013335db" + }, + "holdingSnapshots": { + "rowCount": 33164, + "sha256": "97e3d60230a167a960bd2f615adfa33a9171527b62f1a181e2d5af694090e492" + }, + "orderEvents": { + "rowCount": 25541, + "sha256": "635fe79eab9b1db0c116da2860ded66c350c051f44751d36306d158777039744" + }, + "riskAudits": { + "rowCount": 113, + "sha256": "17bc940bcac18b050235ae3f17195f12586d1e29cd7bc64b6189edf2e07d1406" + } + }, + "sha256": "b29b085d43bcc0f8f1712767421781c70570a24112933623d4bbbef46508d710", + "totalRows": 111014 + }, + "processEventStore": { + "bytes": 10115947, + "identitySha256": "e89d00ea1c2525c2ab1b57a29f2447047f2523083a953869ea2e25a488986565", + "manifestBytes": 9757, + "manifestSha256": "9bff5b97488c8804275ee65a717f01dca9ac68443b9766d4aab8d36189b1d8a1", + "schemaVersion": "fidc-backtest-process-event-manifest/v1", + "sections": { + "processEvents": { + "blockCount": 61, + "bytes": 10106190, + "rowCount": 83859, + "sha256": "f436a32403aaa74b3068a0d8c74bfb7c429523d3f78617234eec82a009ab3da2" + } + }, + "sha256": "aa7488614a85f1527713332039b0fa212d1807f11b03e0e701480bf5d8ede1a4", + "totalEvents": 83859 + }, + "resultStore": { + "bytes": 35831197, + "identitySha256": "a238986d82354e802d2c73526d035021b38e1eed0a2b0b0f1eb6ce41ed4f321b", + "manifestBytes": 54237, + "manifestSha256": "0991f096db161fd16da00a7529ece54a8cf68643d88aeec8f4d3fedc6c740d03", + "schemaVersion": "fidc-backtest-fact-manifest/v1", + "sections": { + "accountEvents": { + "blockCount": 61, + "bytes": 3303444, + "rowCount": 25572, + "sha256": "276fd3edd7e60afbb05fa32c111db3e1df2025637887509b6353b24c7f127a48" + }, + "equityFacts": { + "blockCount": 61, + "bytes": 3761920, + "rowCount": 1216, + "sha256": "42c22c70bad16b01d8f70fa80977028b2789381772e4eff7bb231701e18293a1" + }, + "fillEvents": { + "blockCount": 61, + "bytes": 7731746, + "rowCount": 25408, + "sha256": "c59a4a1e70195bd8a2421941e11b7fbb138d658796ef4d42466c44505b8353d8" + }, + "holdingSnapshots": { + "blockCount": 61, + "bytes": 15176388, + "rowCount": 33164, + "sha256": "6e40d3f1d43005dc4f0c492662c4fdf600ca96e9abe874db4b56b9bd51e89cd4" + }, + "orderEvents": { + "blockCount": 61, + "bytes": 5775840, + "rowCount": 25541, + "sha256": "14fa27db9328c4c6337157af4fc1efc1c4bc9f239a4605bf92439b0f3f426200" + }, + "riskAudits": { + "blockCount": 36, + "bytes": 27622, + "rowCount": 113, + "sha256": "2f6d9a01ec568ed13943b9590e21d73bf614ce2c777302764533d2da0168b2d5" + } + }, + "sha256": "1eab5a5ffe59efeaba6b2048a33760aab31d42efa0fc10e35a4a7048db853235", + "totalEvents": 111014 + }, + "schemaVersion": "fidc-backtest-artifacts/v4" + } + } + ], + "summary": { + "resultConsistent": true, + "resultStoreDigest": "1eab5a5ffe59efeaba6b2048a33760aab31d42efa0fc10e35a4a7048db853235", + "canonicalResultDigest": "b29b085d43bcc0f8f1712767421781c70570a24112933623d4bbbef46508d710", + "totalReturn": 0.9857987122635, + "tradeCount": 25408, + "terminalAudit": { + "cashReceivableCount": 0, + "cashReceivableTotalAmount": 0.0, + "earliestDeferredCashDate": null, + "futuresOpenOrderCount": 0, + "lastExecutionDate": "2026-08-28", + "omittedOpenOrderCount": 0, + "openOrderSamples": [], + "pendingCashFlowCount": 0, + "pendingCashFlowNetAmount": 0.0, + "status": "clean", + "stockOpenOrderCount": 0 + }, + "terminalAuditSha256": "0e8503c2bfe9ecfd93bab3a95f79461830886eb693cfe6f477279908c0d95bd5", + "implementation": { + "buildManifestSha256": "fb4f7ac191c8ab8d8d39fc48014f84ce01d4c0731ffe986e7d5cb0107a3e5539", + "builtAt": "2026-09-07T22:06:18+08:00", + "engineCommit": "1b78186c4e37273c64c1f9e208c2e47021ee4fff", + "identitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33", + "runnerBinarySha256": "7cddebf9ab5cbbd5d6f9bd5c7bf006c3895ee26967d23d80dc86b47c50fb761d", + "schemaVersion": "fidc-backtest-implementation/v1", + "serviceBinarySha256": "f09d121d73c34ad3cd5e2e0c24b0bdaf1974309c9ec3b93229cfb824b1e9f11a", + "serviceCommit": "c80a38b91a398fd713f1f1faaa19a581ea85efad", + "status": "verified" + }, + "implementationIdentitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33", + "restartOrCold": { + "queueWaitSeconds": 0.0, + "executionSeconds": 21.61, + "totalSeconds": 21.61, + "runnerSeconds": 21.294, + "bundleValidationSeconds": 0.004, + "sourceEpochSeconds": 0.0, + "strategyContractSeconds": 0.003, + "terminalCalendarSeconds": 0.0, + "riskFreeRateSeconds": 0.017, + "riskFreeRateCacheHit": false, + "engineSeconds": 5.566, + "dataSeconds": 15.305, + "resultSeconds": 0.395, + "unattributedSeconds": 0.0, + "canonicalSeconds": 0.206, + "finalizationSeconds": 0.307 + }, + "processHotMedian": { + "queueWaitSeconds": 0.0, + "executionSeconds": 21.61, + "totalSeconds": 21.61, + "runnerSeconds": 21.294, + "bundleValidationSeconds": 0.004, + "sourceEpochSeconds": 0.0, + "strategyContractSeconds": 0.003, + "terminalCalendarSeconds": 0.0, + "riskFreeRateSeconds": 0.017, + "riskFreeRateCacheHit": false, + "engineSeconds": 5.566, + "dataSeconds": 15.305, + "resultSeconds": 0.395, + "unattributedSeconds": 0.0, + "canonicalSeconds": 0.206, + "finalizationSeconds": 0.307 + } + } +} diff --git a/docs/evidence/native-daily-factor-replays-20260907/native-pit-nextopen-request-20260907.json b/docs/evidence/native-daily-factor-replays-20260907/native-pit-nextopen-request-20260907.json new file mode 100644 index 0000000..5034e40 --- /dev/null +++ b/docs/evidence/native-daily-factor-replays-20260907/native-pit-nextopen-request-20260907.json @@ -0,0 +1,424 @@ +{ + "strategy_id": "benchmark-native-factor-overlay", + "strategy_version_id": "goal-five-year-semantics-v1", + "user_id": "boris", + "runtime": { + "start_date": "2021-08-23", + "end_date": "2026-08-28", + "frequency": "1d", + "source_table": "strategy_factory_source_lake.daily_source_rows_v1", + "signal_symbol": "000852.SH", + "benchmark_symbol": "000852.SH", + "initial_cash": 10000000.0, + "backtestDataBundleId": "bt_bundle_b44e03990c76064f54a9", + "backtestDataBundleHash": "d7c1461131edaecb5981e207852782d92e636dbfee9fd7c44063605d96eb2b4f" + }, + "execution": { + "matchingType": "next_bar_open", + "rebalanceCashMode": "same_point_net", + "slippageModel": "price_ratio", + "slippageValue": 0.0001, + "commissionRate": 0.0001, + "minimumCommission": 5.0, + "stampTaxRateBeforeChange": 0.001, + "stampTaxRateAfterChange": 0.0005, + "stampTaxChangeDate": "2023-08-28", + "volumeLimit": true, + "liquidityLimit": false, + "volumePercent": 0.25, + "riskPolicy": { + "allowMarketOrders": true, + "blacklistEnabled": false, + "blacklistedSymbols": [], + "commissionRate": 0.0001, + "forbidSameDayRebuyAfterSell": true, + "liquidityLimitEnabled": false, + "liveTradingEnabled": false, + "minimumCommission": 5.0, + "rejectBjseBuy": false, + "rejectBjseSelection": false, + "rejectInactiveBuy": true, + "rejectInactiveSelection": false, + "rejectInactiveSell": true, + "rejectKcbBuy": true, + "rejectKcbSelection": false, + "rejectLowerLimitSelection": false, + "rejectLowerLimitSell": true, + "rejectNewListingBuy": true, + "rejectNewListingSelection": false, + "rejectOneYuanBuy": true, + "rejectOneYuanSelection": false, + "rejectPausedBuy": true, + "rejectPausedSelection": false, + "rejectPausedSell": true, + "rejectStBuy": true, + "rejectStSelection": false, + "rejectStarStBuy": true, + "rejectStarStSelection": false, + "rejectUpperLimitBuy": true, + "rejectUpperLimitSelection": false, + "respectAllowBuySell": true, + "stampTaxChangeDate": "2023-08-28", + "stampTaxRateAfterChange": 0.0005, + "stampTaxRateBeforeChange": 0.001, + "volumeLimitEnabled": true, + "volumePercent": 0.25 + } + }, + "strategy_source": { + "source_type": "platform-strategy", + "language": "engine-script", + "parser": "omniquant-engine-script-v2", + "source_code": "strategy(\"xiaoshizhi_1_06_dynamic_small_cap_csi2000_signal_day_exposure\") {\n market(\"CN_A\");\n benchmark(\"000300.SH\");\n signal(\"932000.CSI\");\n\n let stocknum = 30;\n let candidate_pool_size = 50;\n let position_denominator_extra = 1;\n let signal_close_t = rolling_mean_current(\"signal_close\", 1);\n let signal_ma10_t = rolling_mean_current(\"signal_close\", 10);\n let signal_ma30_t = rolling_mean_current(\"signal_close\", 30);\n let signal_vol20_t = rolling_return_stddev_current(\"signal_close\", 20);\n let signal_high60_t = rolling_max_current(\"signal_close\", 60);\n let signal_drawdown60_t = 1.0 - safe_div(signal_close_t, signal_high60_t);\n let signal_range_t = safe_div(clamp(signal_close_t, 2000.0, 3000.0) - 2000.0, 1000.0);\n let market_cap_lower_t = 12.0 + signal_range_t * 5.0;\n let market_cap_upper_t = 40.0 + signal_range_t * 5.0;\n let base_exposure_t = signal_ma10_t > signal_ma30_t ? 1.0 : 0.3;\n let volatility_exposure_t = signal_vol20_t >= 0.025 ? 0.3 : 1.0;\n let drawdown_exposure_t = signal_drawdown60_t >= 0.08 ? 0.2 : 1.0;\n let final_exposure_t =\n signal_close_t > 0.0 &&\n signal_ma10_t > 0.0 &&\n signal_ma30_t > 0.0 &&\n signal_high60_t > 0.0\n ? min(min(base_exposure_t, volatility_exposure_t), drawdown_exposure_t)\n : 0.0;\n\n rebalance.every_days(1).at([\"15:00\"]);\n\n selection.market_cap_band(\n field=\"market_cap\",\n lower=market_cap_lower_t,\n upper=market_cap_upper_t\n );\n\n filter.stock_expr(((!is_star_st && !is_kcb && !is_bjse && rolling_mean_current(\"close\", 5) > rolling_mean_current(\"close\", 10) && rolling_mean_current(\"close\", 10) > rolling_mean_current(\"close\", 30) && rolling_mean_current(\"volume\", 5) < rolling_mean_current(\"volume\", 100)) && (!is_st))) && (ths_up_days_stock >= 1);\n\n ordering.rank_by(\"market_cap\", \"asc\");\n selection.candidate_limit(50);\n selection.limit(stocknum);\n\n allocation.buy_scale(30.0 / 31.0);\n execution.strict_value_budget(true)\n\n trading.hold_until_exit(true);\n trading.max_holding_days(90);\n trading.daily_top_up(true);\n trading.daily_position_target_adjust(true);\n trading.target_portfolio_daily(true);\n trading.rebalance_existing_positions(true);\n trading.retry_empty_rebalance(true);\n trading.release_slot_on_exit_signal(true);\n\n risk.stop_loss(0.08);\n risk.take_profit(0.16);\n risk.reference_price_mode(\"signal_day_post_adjusted_close\");\n risk.index_exposure(final_exposure_t);\n\n risk.policy(reject_st_selection=false, reject_st_buy=true, reject_star_st_selection=false, reject_star_st_buy=true, reject_paused_selection=false, reject_paused_buy=true, reject_paused_sell=true, reject_inactive_selection=false, reject_inactive_buy=true, reject_inactive_sell=true, reject_new_listing_selection=false, reject_new_listing_buy=true, reject_kcb_selection=false, reject_kcb_buy=true, reject_bjse_selection=false, reject_bjse_buy=false, reject_one_yuan_selection=false, reject_one_yuan_buy=true, respect_allow_buy_sell=true, reject_upper_limit_selection=false, reject_lower_limit_selection=false, reject_upper_limit_buy=true, reject_lower_limit_sell=true, forbid_same_day_rebuy_after_sell=true, blacklist_enabled=false, blacklisted_symbols=[], allow_market_orders=true, live_trading_enabled=false, volume_limit_enabled=true, liquidity_limit_enabled=false, volume_percent=0.25, commission_rate=0.0001, minimum_commission=5.0, stamp_tax_rate_before_change=0.001, stamp_tax_rate_after_change=0.0005, stamp_tax_change_date=\"2023-08-28\");\n\n execution.matching_type(\"next_bar_open\");\n execution.slippage(\"price_ratio\", 0.0001);\n execution.rebalance_cash_mode(\"same_point_net\");\n}" + }, + "strategy_spec": { + "benchmark": { + "fallbackInstrumentId": "000852.SH", + "instrumentId": "000852.SH" + }, + "engineConfig": { + "benchmarkSymbol": "000852.SH", + "commissionRate": 0.0001, + "dividendReinvestment": false, + "dynamicRange": { + "baseCapFloor": 7, + "baseIndexLevel": 2000, + "capSpan": 10, + "xs": 0.008 + }, + "frequency": "1d", + "indexThrottle": { + "defensiveExposure": 0.5, + "fullExposure": 1, + "longDays": 130, + "rsiRate": 1.0001, + "shortDays": 1 + }, + "liquidityLimit": false, + "matchingType": "next_bar_open", + "minimumCommission": 5.0, + "rankLimit": 30, + "rebalanceCashMode": "same_point_net", + "rebalanceSchedule": { + "frequency": "daily", + "time": "15:00" + }, + "refreshRate": 1, + "riskPolicy": { + "allowMarketOrders": true, + "blacklistEnabled": false, + "blacklistedSymbols": [], + "commissionRate": 0.0001, + "forbidSameDayRebuyAfterSell": true, + "liquidityLimitEnabled": false, + "liveTradingEnabled": false, + "minimumCommission": 5.0, + "rejectBjseBuy": false, + "rejectBjseSelection": false, + "rejectInactiveBuy": true, + "rejectInactiveSelection": false, + "rejectInactiveSell": true, + "rejectKcbBuy": true, + "rejectKcbSelection": false, + "rejectLowerLimitSelection": false, + "rejectLowerLimitSell": true, + "rejectNewListingBuy": true, + "rejectNewListingSelection": false, + "rejectOneYuanBuy": true, + "rejectOneYuanSelection": false, + "rejectPausedBuy": true, + "rejectPausedSelection": false, + "rejectPausedSell": true, + "rejectStBuy": true, + "rejectStSelection": false, + "rejectStarStBuy": true, + "rejectStarStSelection": false, + "rejectUpperLimitBuy": true, + "rejectUpperLimitSelection": false, + "respectAllowBuySell": true, + "stampTaxChangeDate": "2023-08-28", + "stampTaxRateAfterChange": 0.0005, + "stampTaxRateBeforeChange": 0.001, + "volumeLimitEnabled": true, + "volumePercent": 0.25 + }, + "rsiRate": 1.0001, + "signalSymbol": "000852.SH", + "skipWindows": [], + "slippageModel": "price_ratio", + "slippageValue": 0.0001, + "stampTaxChangeDate": "2023-08-28", + "stampTaxRateAfterChange": 0.0005, + "stampTaxRateBeforeChange": 0.001, + "stockMaFilter": { + "longDays": 30, + "midDays": 10, + "rsiRate": 1.0001, + "shortDays": 5, + "volumeLongDays": 100, + "volumeShortDays": 5 + }, + "stopLossMultiplier": 0.08, + "strictValueBudget": true, + "takeProfitMultiplier": 0.16, + "templateId": "xiaoshizhi_1_06_dynamic_small_cap_csi2000_signal_day_exposure", + "volumeLimit": true, + "volumePercent": 0.25 + }, + "execution": { + "commissionRate": 0.0001, + "executionGranularity": "daily_or_minute_bar", + "extractor": "omniquant-engine-script-v2", + "frequency": "1d", + "liquidityLimit": false, + "matchingType": "next_bar_open", + "minimumCommission": 5.0, + "priceSource": "current_bar_close_or_next_bar_open_or_minute_bar", + "rebalanceCashMode": "same_point_net", + "riskPolicy": { + "allowMarketOrders": true, + "blacklistEnabled": false, + "blacklistedSymbols": [], + "commissionRate": 0.0001, + "forbidSameDayRebuyAfterSell": true, + "liquidityLimitEnabled": false, + "liveTradingEnabled": false, + "minimumCommission": 5.0, + "rejectBjseBuy": false, + "rejectBjseSelection": false, + "rejectInactiveBuy": true, + "rejectInactiveSelection": false, + "rejectInactiveSell": true, + "rejectKcbBuy": true, + "rejectKcbSelection": false, + "rejectLowerLimitSelection": false, + "rejectLowerLimitSell": true, + "rejectNewListingBuy": true, + "rejectNewListingSelection": false, + "rejectOneYuanBuy": true, + "rejectOneYuanSelection": false, + "rejectPausedBuy": true, + "rejectPausedSelection": false, + "rejectPausedSell": true, + "rejectStBuy": true, + "rejectStSelection": false, + "rejectStarStBuy": true, + "rejectStarStSelection": false, + "rejectUpperLimitBuy": true, + "rejectUpperLimitSelection": false, + "respectAllowBuySell": true, + "stampTaxChangeDate": "2023-08-28", + "stampTaxRateAfterChange": 0.0005, + "stampTaxRateBeforeChange": 0.001, + "volumeLimitEnabled": true, + "volumePercent": 0.25 + }, + "selectionGranularity": "strategy_factory_source_lake.daily_source_rows_v1", + "slippageModel": "price_ratio", + "slippageValue": 0.0001, + "sourceKind": "platform-strategy", + "sourceLanguage": "engine-script", + "stampTaxChangeDate": "2023-08-28", + "stampTaxRateAfterChange": 0.0005, + "stampTaxRateBeforeChange": 0.001, + "strictValueBudget": true, + "volumeLimit": true, + "volumePercent": 0.25 + }, + "factorRefs": [ + "market_cap", + "ths_up_days_stock" + ], + "market": "CN_A", + "metadata": { + "backtestDataBundle": { + "sourceTable": "strategy_factory_source_lake.daily_source_rows_v1", + "backtestDataBundleId": "bt_bundle_b44e03990c76064f54a9", + "backtestDataBundleHash": "d7c1461131edaecb5981e207852782d92e636dbfee9fd7c44063605d96eb2b4f" + }, + "backtestDataBundleHash": "d7c1461131edaecb5981e207852782d92e636dbfee9fd7c44063605d96eb2b4f", + "backtestDataBundleId": "bt_bundle_b44e03990c76064f54a9", + "sourceTable": "strategy_factory_source_lake.daily_source_rows_v1" + }, + "rebalance": { + "dailyApproximation": "日线回测按 matching_type 撮合;分钟线回测按交易时刻分钟价格撮合", + "frequencyDays": 1, + "schedule": { + "frequency": "daily", + "time": "15:00" + }, + "tradeTimes": [ + "15:00" + ] + }, + "risk": { + "indexThrottleExpr": "final_exposure_t", + "stopLossExpr": "0.08", + "stopTakeReferencePriceMode": "signal_day_post_adjusted_close", + "takeProfitExpr": "0.16" + }, + "runtimeExpressions": { + "allocation": { + "buyScaleExpr": "30.0 / 31.0" + }, + "ordering": { + "rankBy": "market_cap", + "rankExpr": "", + "rankOrder": "asc" + }, + "prelude": "let stocknum = 30;\nlet candidate_pool_size = 50;\nlet position_denominator_extra = 1;\nlet signal_close_t = rolling_mean_current(\"signal_close\", 1);\nlet signal_ma10_t = rolling_mean_current(\"signal_close\", 10);\nlet signal_ma30_t = rolling_mean_current(\"signal_close\", 30);\nlet signal_vol20_t = rolling_return_stddev_current(\"signal_close\", 20);\nlet signal_high60_t = rolling_max_current(\"signal_close\", 60);\nlet signal_drawdown60_t = 1.0 - safe_div(signal_close_t, signal_high60_t);\nlet signal_range_t = safe_div(clamp(signal_close_t, 2000.0, 3000.0) - 2000.0, 1000.0);\nlet market_cap_lower_t = 12.0 + signal_range_t * 5.0;\nlet market_cap_upper_t = 40.0 + signal_range_t * 5.0;\nlet base_exposure_t = signal_ma10_t > signal_ma30_t ? 1.0 : 0.3;\nlet volatility_exposure_t = signal_vol20_t >= 0.025 ? 0.3 : 1.0;\nlet drawdown_exposure_t = signal_drawdown60_t >= 0.08 ? 0.2 : 1.0;\nlet final_exposure_t = signal_close_t > 0.0 && signal_ma10_t > 0.0 && signal_ma30_t > 0.0 && signal_high60_t > 0.0 ? min(min(base_exposure_t, volatility_exposure_t), drawdown_exposure_t) : 0.0;\nlet warmup_probe = rolling_sum(\"amount\", 125);", + "risk": { + "exposureExpr": "final_exposure_t", + "stopLossExpr": "0.08", + "stopTakeReferencePriceMode": "signal_day_post_adjusted_close", + "takeProfitExpr": "0.16" + }, + "schedule": { + "frequency": "daily", + "time": "15:00" + }, + "selection": { + "candidateLimitExpr": "50", + "limitExpr": "stocknum", + "marketCapField": "market_cap", + "marketCapLowerExpr": "market_cap_lower_t", + "marketCapUpperExpr": "market_cap_upper_t", + "stockFilterExpr": "(((!is_star_st && !is_kcb && !is_bjse && rolling_mean_current(\"close\", 5) > rolling_mean_current(\"close\", 10) && rolling_mean_current(\"close\", 10) > rolling_mean_current(\"close\", 30) && rolling_mean_current(\"volume\", 5) < rolling_mean_current(\"volume\", 100)) && (!is_st))) && (ths_up_days_stock >= 1)" + }, + "trading": { + "actions": [], + "dailyPositionTargetAdjust": true, + "dailyTopUp": true, + "holdUntilExit": true, + "maxHoldingDays": 90, + "rebalanceExistingPositions": true, + "releaseSlotOnExitSignal": true, + "retryEmptyRebalance": true, + "rotationEnabled": true, + "stage": "on_day", + "subscriptionGuardRequired": false, + "targetPortfolioDaily": true + } + }, + "seasonality": { + "skipWindows": [] + }, + "selectors": [ + { + "field": "market_cap", + "lowerExpr": "market_cap_lower_t", + "mapping": "market_cap -> strategy_factory_source_lake.runtime_fields.market_cap", + "type": "dynamicRange", + "upperExpr": "market_cap_upper_t" + }, + { + "expr": "(((!is_star_st && !is_kcb && !is_bjse && rolling_mean_current(\"close\", 5) > rolling_mean_current(\"close\", 10) && rolling_mean_current(\"close\", 10) > rolling_mean_current(\"close\", 30) && rolling_mean_current(\"volume\", 5) < rolling_mean_current(\"volume\", 100)) && (!is_st))) && (ths_up_days_stock >= 1)", + "type": "filter" + }, + { + "limitExpr": "stocknum", + "orderBy": [ + "market_cap asc" + ], + "type": "rank" + } + ], + "signalSymbol": "000852.SH", + "sourceCode": "strategy(\"xiaoshizhi_1_06_dynamic_small_cap_csi2000_signal_day_exposure\") {\n market(\"CN_A\");\n benchmark(\"000300.SH\");\n signal(\"932000.CSI\");\n\n let stocknum = 30;\n let candidate_pool_size = 50;\n let position_denominator_extra = 1;\n let signal_close_t = rolling_mean_current(\"signal_close\", 1);\n let signal_ma10_t = rolling_mean_current(\"signal_close\", 10);\n let signal_ma30_t = rolling_mean_current(\"signal_close\", 30);\n let signal_vol20_t = rolling_return_stddev_current(\"signal_close\", 20);\n let signal_high60_t = rolling_max_current(\"signal_close\", 60);\n let signal_drawdown60_t = 1.0 - safe_div(signal_close_t, signal_high60_t);\n let signal_range_t = safe_div(clamp(signal_close_t, 2000.0, 3000.0) - 2000.0, 1000.0);\n let market_cap_lower_t = 12.0 + signal_range_t * 5.0;\n let market_cap_upper_t = 40.0 + signal_range_t * 5.0;\n let base_exposure_t = signal_ma10_t > signal_ma30_t ? 1.0 : 0.3;\n let volatility_exposure_t = signal_vol20_t >= 0.025 ? 0.3 : 1.0;\n let drawdown_exposure_t = signal_drawdown60_t >= 0.08 ? 0.2 : 1.0;\n let final_exposure_t =\n signal_close_t > 0.0 &&\n signal_ma10_t > 0.0 &&\n signal_ma30_t > 0.0 &&\n signal_high60_t > 0.0\n ? min(min(base_exposure_t, volatility_exposure_t), drawdown_exposure_t)\n : 0.0;\n\n rebalance.every_days(1).at([\"15:00\"]);\n\n selection.market_cap_band(\n field=\"market_cap\",\n lower=market_cap_lower_t,\n upper=market_cap_upper_t\n );\n\n filter.stock_expr(((!is_star_st && !is_kcb && !is_bjse && rolling_mean_current(\"close\", 5) > rolling_mean_current(\"close\", 10) && rolling_mean_current(\"close\", 10) > rolling_mean_current(\"close\", 30) && rolling_mean_current(\"volume\", 5) < rolling_mean_current(\"volume\", 100)) && (!is_st))) && (ths_up_days_stock >= 1);\n\n ordering.rank_by(\"market_cap\", \"asc\");\n selection.candidate_limit(50);\n selection.limit(stocknum);\n\n allocation.buy_scale(30.0 / 31.0);\n execution.strict_value_budget(true)\n\n trading.hold_until_exit(true);\n trading.max_holding_days(90);\n trading.daily_top_up(true);\n trading.daily_position_target_adjust(true);\n trading.target_portfolio_daily(true);\n trading.rebalance_existing_positions(true);\n trading.retry_empty_rebalance(true);\n trading.release_slot_on_exit_signal(true);\n\n risk.stop_loss(0.08);\n risk.take_profit(0.16);\n risk.reference_price_mode(\"signal_day_post_adjusted_close\");\n risk.index_exposure(final_exposure_t);\n\n risk.policy(reject_st_selection=false, reject_st_buy=true, reject_star_st_selection=false, reject_star_st_buy=true, reject_paused_selection=false, reject_paused_buy=true, reject_paused_sell=true, reject_inactive_selection=false, reject_inactive_buy=true, reject_inactive_sell=true, reject_new_listing_selection=false, reject_new_listing_buy=true, reject_kcb_selection=false, reject_kcb_buy=true, reject_bjse_selection=false, reject_bjse_buy=false, reject_one_yuan_selection=false, reject_one_yuan_buy=true, respect_allow_buy_sell=true, reject_upper_limit_selection=false, reject_lower_limit_selection=false, reject_upper_limit_buy=true, reject_lower_limit_sell=true, forbid_same_day_rebuy_after_sell=true, blacklist_enabled=false, blacklisted_symbols=[], allow_market_orders=true, live_trading_enabled=false, volume_limit_enabled=true, liquidity_limit_enabled=false, volume_percent=0.25, commission_rate=0.0001, minimum_commission=5.0, stamp_tax_rate_before_change=0.001, stamp_tax_rate_after_change=0.0005, stamp_tax_change_date=\"2023-08-28\");\n\n execution.matching_type(\"next_bar_open\");\n execution.slippage(\"price_ratio\", 0.0001);\n execution.rebalance_cash_mode(\"same_point_net\");\n}", + "strategyId": "warmup-expression-contract-acceptance", + "universe": { + "exclude": [], + "implementationNotes": [ + "ST、停牌、退市、新股、科创、一元、涨跌停、同日卖出禁买、成交量和费用由 riskPolicy / RiskLimits 统一执行", + "上市日期与退市日期取自 instrument 结构化字段,不再使用股票名称做 ST/退市判断", + "盘中 current_price / last_price 由策略交易时刻批量 tick 查询驱动" + ] + }, + "version": "1.0.0", + "stockPoolFactorContract": { + "schemaVersion": 1, + "entryLogic": "all", + "exitLogic": "any", + "conditions": [ + { + "factorRef": "up_days_stock", + "label": "连涨天数", + "role": "selection", + "registryRole": "selection_feature", + "roleRegistrySha256": "1d0b307c168feda08d5fbe20f0e88964230553f8ceb2017b66aec48dbd5a5b57", + "roleEvidence": { + "role": "selection_feature", + "polarity": "trend_persistence_positive", + "signalShape": "state", + "holdingStates": [ + "flat" + ], + "requiredConfirmations": [], + "cooldownTradingDays": 0, + "windowTradingDays": 1, + "recommendedParameters": { + "inputUnit": "days", + "minimum": 0 + } + }, + "operator": ">=", + "threshold": 1, + "semantic": { + "ref": "up_days_stock", + "label": "连涨天数", + "status": "available", + "queryable": true, + "source": "strategy-factory-source-lake:indicator", + "schema": "strategy-factory.value-semantics/v1", + "valueType": "integer", + "semanticType": "count", + "comparisonGroup": "count", + "storageUnit": "days", + "inputUnit": "days", + "inputScale": 1.0, + "allowedOperators": [ + ">", + ">=", + "<", + "<=", + "==", + "!=", + "between", + "in" + ], + "nullable": true, + "declared": true, + "metadataStatus": "declared", + "semanticProvenance": "explicit_manifest", + "businessSemanticDeclared": true, + "minimum": 0, + "backtestBinding": { + "field": "ths_up_days_stock", + "sourceDataset": "indicators_up_days_stock" + }, + "tradingRoles": [ + { + "role": "selection_feature", + "polarity": "trend_persistence_positive", + "signalShape": "state", + "holdingStates": [ + "flat" + ], + "requiredConfirmations": [], + "cooldownTradingDays": 0, + "windowTradingDays": 1, + "recommendedParameters": { + "inputUnit": "days", + "minimum": 0 + } + } + ], + "tradingRoleTradable": true, + "tradingRoleEvidenceStatus": "source_lake_registered_indicator", + "tradingRoleRegistrySha256": "1d0b307c168feda08d5fbe20f0e88964230553f8ceb2017b66aec48dbd5a5b57" + } + } + ] + } + } +} diff --git a/docs/evidence/native-daily-factor-visibility-20260907.json b/docs/evidence/native-daily-factor-visibility-20260907.json new file mode 100644 index 0000000..5b068ad --- /dev/null +++ b/docs/evidence/native-daily-factor-visibility-20260907.json @@ -0,0 +1,58 @@ +{ + "date": "2026-09-07", + "host": "192.168.31.177", + "identity": "boris", + "implementationCommit": "a02ac6e", + "valueRegressionCommit": "cb97aa1", + "scope": "Native daily indicator fields explicitly bound in stockPoolFactorContract; other factor fields and pricing are unchanged.", + "targetedTests": {"passed": 3, "failed": 0}, + "fullLibraryTestsBeforeAdditionalValueCase": {"passed": 447, "ignored": 6, "failed": 0}, + "provenCases": [ + "09:30, 10:30 and 14:30 resolve to the preceding trading date", + "15:00 resolves to the completed decision day", + "active intraday datetime applies when no explicit execution time exists", + "next-open retains the completed decision day", + "no previous trading date does not fall back to the current day", + "stock state with prior value 2 and current value 999 reads 2 intraday and 999 at close", + "unbound factor value remains unchanged" + ], + "backtestServiceDeployed": true, + "paperLiveRuntimeDeployed": true, + "paperLiveDeploymentEvidence": "/Users/boris/WorkSpace/fidc-trading-platform/docs/evidence/trading-engine-revision-deployment-20260907.json", + "realBacktestAcceptanceComplete": false, + "scopedBacktestEvidence": { + "intraday": { + "range": "2025-09-08..2025-09-12", + "time": "09:30", + "runIds": ["btr_1788790021780_1150210_0", "btr_1788790036494_1150210_1"], + "seconds": [8.994, 0.596], + "tradeCount": 104, + "riskDecisionCount": 11, + "canonical": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a", + "identical": true, + "persistedFactorBindingVerified": true, + "rawParquetAudit": { + "buyFills": 60, + "priorPassCurrentFailExamples": 21, + "existingPositionTopUpsBelowCurrentSelectionThreshold": 23, + "retainedTargetReentryBelowCurrentSelectionThreshold": {"symbol": "600276.SH", "date": "2025-09-12", "priorExit": "2025-09-11 stop_loss_exit", "configuration": "reenterExitedTargets=true", "reason": "model_target_portfolio_daily"}, + "note": "Selection-only conditions are not an execution-time buy veto. Position adjustment and explicit retained-target reentry must be audited separately from fresh candidate selection." + } + }, + "nextOpen": { + "range": "2021-08-23..2026-08-28", + "runId": "btr_1788790344805_1150210_2", + "seconds": 21.610, + "tradeCount": 25408, + "canonical": "b29b085d43bcc0f8f1712767421781c70570a24112933623d4bbbef46508d710", + "matchesPreFixBaseline": true + }, + "terminalAudits": "clean", + "rawEvidenceDirectory": "native-daily-factor-replays-20260907" + }, + "limitations": [ + "This is not a generic per-field publication-timestamp model for all factor datasets.", + "Raw dynamic fields used without a stock-pool native binding need separate availability-contract review.", + "Broader factor/PIT and actual trading acceptance remain required; these replays use isolated API research fixtures. Browser draft handoff is separately recorded in OmniQuant documentation." + ] +} diff --git a/docs/factor-decision-phase-contract.md b/docs/factor-decision-phase-contract.md new file mode 100644 index 0000000..2160bff --- /dev/null +++ b/docs/factor-decision-phase-contract.md @@ -0,0 +1,36 @@ +# Factor Decision Phases + +Status: broker foundation implemented; factor compiler, evaluator and runtime-plan integration are not complete. Do not advertise this as a fully working stock-pool buy-condition feature. + +## Separate Contracts + +| Phase | Meaning | Must Not Do | +|---|---|---| +| Selection | Build and rank the candidate universe at the strategy decision clock | Pretend this also guards every later top-up | +| Buy permission | Decide whether this decision may create new buy exposure for a symbol | Convert a denied buy into a sell or silently drop a holding from a full target snapshot | +| Exit/reduction | Produce the explicitly configured exit or partial target | Normalize remaining targets upward without an explicit strategy rule | +| Execution risk | Apply actual execution-date price, ST, suspension, lifecycle, liquidity and cost constraints | Substitute decision-date risk facts for next-open execution facts | +| Existing orders | Continue the already submitted order under its execution risk and lifetime contract | Implicitly cancel or rewrite it merely because a later decision has a new buy denial | + +## Broker Primitive + +`StrategyDecision.buy_denials` is a symbol-to-reason map sampled by the strategy layer, not a factor evaluator. Merged decisions retain denials. The broker installs it only while processing that decision and restores the prior context afterward; it is never shared through DataSet caches. + +New positive buy quantities and target-buy planning respect the map after standard market/risk checks. Sells remain permitted. The actual execution price determines whether a value/portfolio target requires buying: a target below the signal-day holding value can become a buy after a lower next open, so signal-day direction alone is insufficient. + +Existing resting orders are not automatically canceled by this primitive. A buy amendment is denied if it increases total quantity or raises the limit price, even if the other dimension decreases. Reductions in both dimensions remain allowed after normal validation. A rejected amendment emits an update-rejection process event without replacing the original order state or queue priority. Full runtime-plan integration still requires testing. + +## Required Integration + +1. Split selection and buy-role output in the stock-pool compiler instead of folding both into `stock_filter`. +2. Evaluate buy expressions at the declared decision clock using typed field availability, units and frozen data identity. Missing data must retain its own diagnostic, not silently become a false trading signal. +3. Populate denials for every symbol a decision can buy, including portfolio targets, retained-target reentry and top-ups. Do not infer execution direction from signal-day value. +4. Preserve/consume constraints in Paper/Live strategy-plan conversion. No consumer may silently discard a nonempty denial map. +5. Carry the tested amendment policy through runtime-plan conversion; validate source-date and execution-date risk independently. +6. Verify same-bundle baseline parity when no buy constraint is configured, then test explicit buy failures across share, value, target and algorithmic orders. + +## Current Evidence + +On 177, broker tests verify blocked target top-ups, permitted sells, context restoration, existing pending-order preservation, a next-open target direction flip, and risk-increasing/reducing amendments with unchanged state on rejection. Full `fidc-core` tests passed: 453 unit tests and 122 integration tests, with 8 manual benchmarks ignored. The backtest runner previously compiled against the changed API. + +The candidate is not deployed. The current OmniQuant compiler still needs the above integration, and no production readiness claim follows from these low-level tests.