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

This commit is contained in:
boris
2026-09-09 05:49:45 +08:00
18 changed files with 2772 additions and 85 deletions
+156
View File
@@ -380,6 +380,7 @@ pub struct BrokerSimulator<C, R> {
runtime_intraday_start_time: Cell<Option<NaiveTime>>,
runtime_intraday_end_time: Cell<Option<NaiveTime>>,
runtime_decision_date: Cell<Option<NaiveDate>>,
runtime_buy_denials: RefCell<BTreeMap<String, String>>,
runtime_order_created_date: Cell<Option<NaiveDate>>,
runtime_decision_total_equity: Cell<Option<f64>>,
runtime_target_position_limit: Cell<Option<usize>>,
@@ -412,6 +413,7 @@ impl<C, R> BrokerSimulator<C, R> {
runtime_intraday_start_time: Cell::new(None),
runtime_intraday_end_time: Cell::new(None),
runtime_decision_date: Cell::new(None),
runtime_buy_denials: RefCell::new(BTreeMap::new()),
runtime_order_created_date: Cell::new(None),
runtime_decision_total_equity: Cell::new(None),
runtime_target_position_limit: Cell::new(None),
@@ -448,6 +450,7 @@ impl<C, R> BrokerSimulator<C, R> {
runtime_intraday_start_time: Cell::new(None),
runtime_intraday_end_time: Cell::new(None),
runtime_decision_date: Cell::new(None),
runtime_buy_denials: RefCell::new(BTreeMap::new()),
runtime_order_created_date: Cell::new(None),
runtime_decision_total_equity: Cell::new(None),
runtime_target_position_limit: Cell::new(None),
@@ -1385,6 +1388,7 @@ where
decision: &StrategyDecision,
) -> Result<BrokerExecutionReport, BacktestError> {
let previous_decision_date = self.runtime_decision_date.get();
let previous_buy_denials = self.runtime_buy_denials.replace(decision.buy_denials.clone());
let previous_order_created_date = self.runtime_order_created_date.get();
let previous_decision_total_equity = self.runtime_decision_total_equity.get();
self.runtime_decision_date.set(Some(decision_date));
@@ -1393,6 +1397,7 @@ where
self.runtime_decision_total_equity
.set(decision_total_equity.filter(|equity| equity.is_finite() && *equity >= 0.0));
let result = self.execute_with_runtime_dates(date, portfolio, data, decision);
self.runtime_buy_denials.replace(previous_buy_denials);
self.runtime_decision_date.set(previous_decision_date);
self.runtime_order_created_date
.set(previous_order_created_date);
@@ -2833,6 +2838,18 @@ where
}
}
if existing.side == OrderSide::Buy
&& (target_total_quantity > existing.requested_quantity
|| target_limit_price > existing.limit_price)
&& let Some(denial) = self.runtime_buy_denials.borrow().get(&existing.symbol)
{
Self::emit_open_order_update_rejected(
report, date, order_id, Some(&existing.symbol), Some(existing.side),
reason, denial,
);
return;
}
let resets_queue_priority = target_limit_price.to_bits() != existing.limit_price.to_bits()
|| target_total_quantity > existing.requested_quantity;
{
@@ -4189,6 +4206,9 @@ where
if !rule.allowed {
return rule.reason;
}
if let Some(reason) = self.runtime_buy_denials.borrow().get(symbol) {
return Some(reason.clone());
}
match self.market_fillable_quantity(
snapshot,
OrderSide::Buy,
@@ -6140,6 +6160,12 @@ where
data.instrument(symbol),
algo_request,
);
let rule = if rule.allowed && emit_creation_events {
self.runtime_buy_denials.borrow().get(symbol)
.map_or(rule, |reason| RuleCheck::reject(reason.clone()))
} else {
rule
};
if !rule.allowed {
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
let status = match rule.reason.as_deref() {
@@ -8213,6 +8239,136 @@ mod tests {
}
}
#[test]
fn decision_buy_denial_blocks_topup_but_allows_sell_and_does_not_leak() {
let first = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let second = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let data = DataSet::from_components(
vec![limit_test_instrument()],
vec![dated_limit_test_snapshot(first), dated_limit_test_snapshot(second)],
Vec::new(),
vec![dated_limit_test_candidate(first, false, false, true, true),
dated_limit_test_candidate(second, false, false, true, true)],
vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)],
).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::CurrentBarClose);
let mut portfolio = PortfolioState::new(100_000.0);
broker.execute(first, &mut portfolio, &data, &next_open_buy_decision()).unwrap();
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 100);
let mut blocked = StrategyDecision::default();
blocked.buy_denials.insert("000001.SZ".to_string(), "strategy_buy_condition_false".to_string());
blocked.order_intents.push(OrderIntent::TargetValue {
symbol: "000001.SZ".to_string(), target_value: 3_000.0, reason: "topup".to_string(),
});
let report = broker.execute(second, &mut portfolio, &data, &blocked).unwrap();
assert!(report.fill_events.is_empty());
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 100);
assert!(broker.runtime_buy_denials.borrow().is_empty());
blocked.order_intents = next_open_sell_decision().order_intents;
let report = broker.execute(second, &mut portfolio, &data, &blocked).unwrap();
assert_eq!(report.fill_events.len(), 1);
assert_eq!(report.fill_events[0].side, OrderSide::Sell);
assert!(broker.runtime_buy_denials.borrow().is_empty());
}
#[test]
fn decision_buy_denial_does_not_rewrite_existing_pending_order() {
let date = limit_test_snapshot().date;
let data = DataSet::from_components(vec![limit_test_instrument()], vec![limit_test_snapshot()],
Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::CurrentBarClose);
broker.upsert_open_order(test_open_order(99));
let mut decision = StrategyDecision::default();
decision.buy_denials.insert("000001.SZ".to_string(), "strategy_buy_condition_false".to_string());
let mut portfolio = PortfolioState::new(100_000.0);
let report = broker.execute(date, &mut portfolio, &data, &decision).unwrap();
assert!(!report.fill_events.is_empty());
assert!(broker.runtime_buy_denials.borrow().is_empty());
}
#[test]
fn decision_buy_denial_uses_actual_next_open_target_delta() {
let first = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let second = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let mut next = dated_limit_test_snapshot(second);
next.day_open = 9.5;
next.open = 9.5;
next.close = 9.5;
next.last_price = 9.5;
next.bid1 = 9.5;
next.ask1 = 9.5;
let data = DataSet::from_components(vec![limit_test_instrument()],
vec![dated_limit_test_snapshot(first), next], Vec::new(),
vec![dated_limit_test_candidate(first, false, false, true, true),
dated_limit_test_candidate(second, false, false, true, true)],
vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)]).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::NextBarOpen);
let mut portfolio = PortfolioState::new(1_000_000.0);
let mut initial = StrategyDecision::default();
initial.order_intents.push(OrderIntent::Shares {
symbol: "000001.SZ".to_string(), quantity: 10_000, reason: "initial".to_string(),
});
broker.execute(first, &mut portfolio, &data, &initial).unwrap();
let mut decision = StrategyDecision::default();
decision.buy_denials.insert("000001.SZ".to_string(), "strategy_buy_condition_false".to_string());
// Below the signal-day holding value, but above next-open value.
decision.order_intents.push(OrderIntent::TargetValue {
symbol: "000001.SZ".to_string(), target_value: 97_500.0, reason: "target".to_string(),
});
let report = broker.execute_with_event_dates_and_decision_equity(
second, first, first, None, &mut portfolio, &data, &decision).unwrap();
assert!(report.fill_events.is_empty());
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 10_000);
assert!(report.order_events.iter().any(|event| event.side == OrderSide::Buy));
assert!(broker.runtime_buy_denials.borrow().is_empty());
}
#[test]
fn decision_buy_denial_rejects_increasing_amendments_without_mutation() {
let date = limit_test_snapshot().date;
let data = DataSet::from_components(vec![limit_test_instrument()], vec![limit_test_snapshot()],
Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap();
for (quantity, price) in [(Some(300), None), (None, Some(10.5)),
(Some(100), Some(10.5)), (Some(300), Some(9.5))] {
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks);
broker.upsert_open_order(test_open_order(1));
broker.upsert_open_order(test_open_order(2));
broker.runtime_buy_denials.borrow_mut().insert("000001.SZ".to_string(), "strategy_buy_condition_false".to_string());
let portfolio = PortfolioState::new(100_000.0);
let mut report = BrokerExecutionReport::default();
broker.modify_open_order(date, &portfolio, &data, 1, quantity, price, "amend", &mut report);
let orders = broker.open_orders.borrow();
assert_eq!(orders.iter().map(|order| order.order_id).collect::<Vec<_>>(), vec![1, 2]);
assert_eq!(orders[0].requested_quantity, 200);
assert_eq!(orders[0].remaining_quantity, 200);
assert_eq!(orders[0].limit_price, 10.0);
assert!(report.order_events.is_empty());
let event = report.process_events.last().unwrap();
assert_eq!(event.kind, crate::events::ProcessEventKind::OrderUpdateReject);
assert!(event.detail.contains("strategy_buy_condition_false"));
}
}
#[test]
fn decision_buy_denial_allows_reducing_an_existing_buy() {
let date = limit_test_snapshot().date;
let data = DataSet::from_components(vec![limit_test_instrument()], vec![limit_test_snapshot()],
Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks);
broker.upsert_open_order(test_open_order(1));
broker.runtime_buy_denials.borrow_mut().insert("000001.SZ".to_string(), "strategy_buy_condition_false".to_string());
let portfolio = PortfolioState::new(100_000.0);
let mut report = BrokerExecutionReport::default();
broker.modify_open_order(date, &portfolio, &data, 1, Some(100), Some(9.5), "reduce", &mut report);
let orders = broker.open_orders.borrow();
assert_eq!(orders[0].requested_quantity, 100);
assert_eq!(orders[0].limit_price, 9.5);
assert!(!report.order_events.last().unwrap().reason.contains("strategy_buy_condition_false"));
}
fn next_open_sell_decision() -> StrategyDecision {
StrategyDecision {
order_intents: vec![OrderIntent::Shares {
+24 -1
View File
@@ -1,6 +1,17 @@
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
pub fn listed_sector_is_kcb(value: &str) -> Option<bool> {
match value.trim().to_ascii_uppercase().as_str() {
"科创板" | "KSH" | "STAR" | "STAR_MARKET" => Some(true),
"主板" | "沪市主板" | "深市主板" | "中小板" | "中小企业板" | "创业板"
| "北交所" | "北证" | "新三板" | "基础层" | "创新层" | "精选层"
| "MAIN" | "MAIN_BOARD" | "CHINEXT" | "GEM" | "BJ" | "BJS" | "BJSE"
| "BSE" => Some(false),
_ => None,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Instrument {
pub symbol: String,
@@ -70,7 +81,19 @@ fn default_status() -> String {
#[cfg(test)]
mod tests {
use super::Instrument;
use super::{Instrument, listed_sector_is_kcb};
#[test]
fn listing_sector_is_explicit_and_unknown_stays_unknown() {
assert_eq!(listed_sector_is_kcb("科创板"), Some(true));
assert_eq!(listed_sector_is_kcb(" star "), Some(true));
assert_eq!(listed_sector_is_kcb("主板"), Some(false));
assert_eq!(listed_sector_is_kcb("创业板"), Some(false));
assert_eq!(listed_sector_is_kcb("北证"), Some(false));
for value in ["", "-", "SH", "688001.SH", "半导体"] {
assert_eq!(listed_sector_is_kcb(value), None);
}
}
fn instrument(board: &str, round_lot: u32) -> Instrument {
Instrument {
+204 -39
View File
@@ -11,6 +11,7 @@ pub(crate) enum ValueType {
pub(crate) enum Value {
Number(f64),
Boolean(bool),
Missing(ValueType),
}
impl Value {
@@ -18,20 +19,28 @@ impl Value {
match self {
Self::Number(_) => ValueType::Number,
Self::Boolean(_) => ValueType::Boolean,
Self::Missing(value_type) => value_type,
}
}
pub(crate) fn as_number(self) -> Option<f64> {
match self {
Self::Number(value) => Some(value),
Self::Boolean(_) => None,
Self::Boolean(_) | Self::Missing(_) => None,
}
}
pub(crate) fn as_bool(self) -> Option<bool> {
match self {
Self::Boolean(value) => Some(value),
Self::Number(_) => None,
Self::Number(_) | Self::Missing(_) => None,
}
}
fn normalized(self) -> Self {
match self {
Self::Number(value) if !value.is_finite() => Self::Missing(ValueType::Number),
value => value,
}
}
}
@@ -103,6 +112,8 @@ enum BinaryOp {
LessEqual,
Greater,
GreaterEqual,
And,
Or,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -134,7 +145,7 @@ enum Instruction {
Binary(BinaryOp),
Call { builtin: Builtin, argc: u8 },
JumpIfFalse(usize),
JumpIfTrue(usize),
ShortCircuit { on: bool, target: usize },
Jump(usize),
Return,
}
@@ -165,7 +176,7 @@ impl Program {
let mut pc = 0usize;
while let Some(instruction) = self.instructions.get(pc) {
match *instruction {
Instruction::Push(value) => scratch.stack.push(value),
Instruction::Push(value) => scratch.stack.push(value.normalized()),
Instruction::LoadVariable(index) => {
let index = usize::from(index);
let cached = scratch.variables[index];
@@ -173,7 +184,8 @@ impl Program {
Some(value) => value,
None => {
let expected_type = self.variable_types[index];
let value = resolve(index, &self.variables[index], expected_type)?;
let value =
resolve(index, &self.variables[index], expected_type)?.normalized();
if value.value_type() != expected_type {
return Err(EvalError::new(format!(
"variable {} expected {:?}, got {:?}",
@@ -219,15 +231,23 @@ impl Program {
scratch.stack.push(value);
}
Instruction::JumpIfFalse(target) => {
let condition = pop_bool(&mut scratch.stack)?;
// Like CASE WHEN, only a known true condition takes this branch.
let condition = match pop(&mut scratch.stack)? {
Value::Boolean(value) => value,
Value::Missing(ValueType::Boolean) => false,
_ => return Err(EvalError::new("boolean operand required")),
};
if !condition {
pc = target;
continue;
}
}
Instruction::JumpIfTrue(target) => {
let condition = pop_bool(&mut scratch.stack)?;
if condition {
Instruction::ShortCircuit { on, target } => {
let condition =
scratch.stack.last().copied().ok_or_else(|| {
EvalError::new("stack underflow during short circuit")
})?;
if condition.as_bool() == Some(on) {
pc = target;
continue;
}
@@ -284,31 +304,56 @@ fn pop(stack: &mut Vec<Value>) -> Result<Value, EvalError> {
stack.pop().ok_or_else(|| EvalError::new("stack underflow"))
}
fn pop_bool(stack: &mut Vec<Value>) -> Result<bool, EvalError> {
pop(stack)?
.as_bool()
.ok_or_else(|| EvalError::new("boolean operand required"))
}
fn number(value: Value) -> Result<f64, EvalError> {
if value == Value::Missing(ValueType::Number) {
return Ok(f64::NAN);
}
value
.as_number()
.ok_or_else(|| EvalError::new("numeric operand required"))
}
fn eval_unary(operator: UnaryOp, value: Value) -> Result<Value, EvalError> {
match operator {
if matches!(value, Value::Missing(_)) {
return Ok(value);
}
let result: Result<Value, EvalError> = match operator {
UnaryOp::Negate => Ok(Value::Number(-number(value)?)),
UnaryOp::Not => {
Ok(Value::Boolean(!value.as_bool().ok_or_else(|| {
EvalError::new("boolean operand required for !")
})?))
}
}
};
Ok(result?.normalized())
}
fn eval_binary(operator: BinaryOp, lhs: Value, rhs: Value) -> Result<Value, EvalError> {
match operator {
if matches!(operator, BinaryOp::And | BinaryOp::Or) {
let (lhs, rhs) = (lhs.as_bool(), rhs.as_bool());
let result = match operator {
BinaryOp::And if lhs == Some(false) || rhs == Some(false) => Some(false),
BinaryOp::And if lhs == Some(true) && rhs == Some(true) => Some(true),
BinaryOp::Or if lhs == Some(true) || rhs == Some(true) => Some(true),
BinaryOp::Or if lhs == Some(false) && rhs == Some(false) => Some(false),
_ => None,
};
return Ok(result
.map(Value::Boolean)
.unwrap_or(Value::Missing(ValueType::Boolean)));
}
if matches!(lhs, Value::Missing(_)) || matches!(rhs, Value::Missing(_)) {
let value_type = match operator {
BinaryOp::Add
| BinaryOp::Subtract
| BinaryOp::Multiply
| BinaryOp::Divide
| BinaryOp::Remainder => ValueType::Number,
_ => ValueType::Boolean,
};
return Ok(Value::Missing(value_type));
}
let result: Result<Value, EvalError> = match operator {
BinaryOp::Add => Ok(Value::Number(number(lhs)? + number(rhs)?)),
BinaryOp::Subtract => Ok(Value::Number(number(lhs)? - number(rhs)?)),
BinaryOp::Multiply => Ok(Value::Number(number(lhs)? * number(rhs)?)),
@@ -356,7 +401,27 @@ fn eval_binary(operator: BinaryOp, lhs: Value, rhs: Value) -> Result<Value, Eval
(lhs - rhs) / float_comparison_scale(lhs, rhs) > -f64::EPSILON,
))
}
BinaryOp::And | BinaryOp::Or => unreachable!(),
};
Ok(result?.normalized())
}
pub(crate) fn finite_comparison(operator: &str, lhs: f64, rhs: f64) -> Option<bool> {
if !lhs.is_finite() || !rhs.is_finite() {
return None;
}
let operator = match operator {
"==" => BinaryOp::Equal,
"!=" => BinaryOp::NotEqual,
"<" => BinaryOp::Less,
"<=" => BinaryOp::LessEqual,
">" => BinaryOp::Greater,
">=" => BinaryOp::GreaterEqual,
_ => return None,
};
eval_binary(operator, Value::Number(lhs), Value::Number(rhs))
.ok()?
.as_bool()
}
fn float_comparison_scale(lhs: f64, rhs: f64) -> f64 {
@@ -382,7 +447,16 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result<Value, EvalError> {
.ok_or_else(|| EvalError::new("missing builtin argument"))
.and_then(number)
};
Ok(match builtin {
if !matches!(builtin, Builtin::Nz | Builtin::SafeDiv | Builtin::Iff)
&& args.iter().any(|value| matches!(value, Value::Missing(_)))
{
return Ok(Value::Missing(if builtin == Builtin::Between {
ValueType::Boolean
} else {
ValueType::Number
}));
}
let result = match builtin {
Builtin::Round => Value::Number(numeric(0)?.round()),
Builtin::Floor => Value::Number(numeric(0)?.floor()),
Builtin::Ceil => Value::Number(numeric(0)?.ceil()),
@@ -393,7 +467,13 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result<Value, EvalError> {
Builtin::Pow => Value::Number(numeric(0)?.powf(numeric(1)?)),
Builtin::Log => Value::Number(numeric(0)?.ln()),
Builtin::Exp => Value::Number(numeric(0)?.exp()),
Builtin::Clamp => Value::Number(numeric(0)?.clamp(numeric(1)?, numeric(2)?)),
Builtin::Clamp => {
let (value, low, high) = (numeric(0)?, numeric(1)?, numeric(2)?);
if low > high {
return Err(EvalError::new("clamp lower bound exceeds upper bound"));
}
Value::Number(value.clamp(low, high))
}
Builtin::Between => {
let value = numeric(0)?;
Value::Boolean(value >= numeric(1)? && value <= numeric(2)?)
@@ -417,13 +497,15 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result<Value, EvalError> {
})
}
Builtin::Iff => {
let condition = args
.first()
.and_then(|value| value.as_bool())
.ok_or_else(|| EvalError::new("iff condition must be boolean"))?;
let condition = match args.first().copied() {
Some(Value::Boolean(value)) => value,
Some(Value::Missing(ValueType::Boolean)) => false,
_ => return Err(EvalError::new("iff condition must be boolean")),
};
if condition { args[1] } else { args[2] }
}
})
};
Ok(result.normalized())
}
#[derive(Debug, Clone, PartialEq)]
@@ -1119,24 +1201,20 @@ where
let lhs_type = self.expression(lhs)?;
require_type(lhs_type, ValueType::Boolean, position)?;
let branch = self.instructions.len();
self.instructions.push(match operator {
ParsedBinaryOp::And => Instruction::JumpIfFalse(usize::MAX),
ParsedBinaryOp::Or => Instruction::JumpIfTrue(usize::MAX),
_ => unreachable!(),
self.instructions.push(Instruction::ShortCircuit {
on: operator == ParsedBinaryOp::Or,
target: usize::MAX,
});
let rhs_type = self.expression(rhs)?;
require_type(rhs_type, ValueType::Boolean, rhs.position())?;
let end_jump = self.instructions.len();
self.instructions.push(Instruction::Jump(usize::MAX));
let short_target = self.instructions.len();
self.instructions
.push(Instruction::Push(Value::Boolean(matches!(
operator,
ParsedBinaryOp::Or
))));
.push(Instruction::Binary(if operator == ParsedBinaryOp::And {
BinaryOp::And
} else {
BinaryOp::Or
}));
let end_target = self.instructions.len();
patch_jump(&mut self.instructions, branch, short_target)?;
patch_jump(&mut self.instructions, end_jump, end_target)?;
patch_jump(&mut self.instructions, branch, end_target)?;
return Ok(ValueType::Boolean);
}
@@ -1285,7 +1363,7 @@ fn patch_jump(
};
match instruction {
Instruction::JumpIfFalse(value)
| Instruction::JumpIfTrue(value)
| Instruction::ShortCircuit { target: value, .. }
| Instruction::Jump(value) => {
*value = target;
Ok(())
@@ -1394,6 +1472,93 @@ mod tests {
);
}
#[test]
fn nullable_boolean_truth_table_preserves_unknown_under_negation() {
let unknown = Value::Missing(ValueType::Boolean);
let states = [Value::Boolean(false), Value::Boolean(true), unknown];
let and = [
[states[0], states[0], states[0]],
[states[0], states[1], unknown],
[states[0], unknown, unknown],
];
let or = [
[states[0], states[1], unknown],
[states[1], states[1], states[1]],
[unknown, states[1], unknown],
];
for (i, lhs) in states.iter().enumerate() {
for (j, rhs) in states.iter().enumerate() {
let values = [("lhs", *lhs), ("rhs", *rhs)];
assert_eq!(evaluate("lhs && rhs", &values), and[i][j]);
assert_eq!(evaluate("lhs || rhs", &values), or[i][j]);
assert_eq!(evaluate("!!(lhs && rhs)", &values), and[i][j]);
assert_eq!(evaluate("!!(lhs || rhs)", &values), or[i][j]);
}
}
assert_eq!(evaluate("!value", &[("value", unknown)]), unknown);
}
#[test]
fn missing_numeric_operands_do_not_become_boolean_false_or_zero() {
let unknown = Value::Missing(ValueType::Boolean);
for missing in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
for operator in ["==", "!=", "<", "<=", ">", ">="] {
let values = [("value", Value::Number(missing))];
assert_eq!(evaluate(&format!("value {operator} 0.0"), &values), unknown);
assert_eq!(
evaluate(&format!("!(0.0 {operator} value)"), &values),
unknown
);
assert_eq!(
evaluate(&format!("!((value + 1.0) {operator} 0.0)"), &values),
unknown
);
}
}
let missing = [("value", Value::Number(f64::NAN))];
assert_eq!(evaluate("!(min(value, 1.0) > 0.0)", &missing), unknown);
assert_eq!(evaluate("!between(value, 0.0, 1.0)", &missing), unknown);
assert_eq!(evaluate("!(1.0 / 0.0 > 0.0)", &[]), unknown);
assert_eq!(evaluate("!(sqrt(-1.0) > 0.0)", &[]), unknown);
assert_eq!(evaluate("nz(value, 7.0)", &missing), Value::Number(7.0));
assert_eq!(
evaluate("nz(value, 0.0) == 0.0", &missing),
Value::Boolean(true)
);
}
#[test]
fn nullable_short_circuit_and_explicit_conditional_branches_are_lazy() {
for source in [
"false && missing",
"true || missing",
"if true { true } else { missing }",
] {
let program = compile(source, |_| Some(ValueType::Boolean)).unwrap();
program
.evaluate(&mut Scratch::default(), |_, _, _| {
Err(EvalError::new("unused input must not be resolved"))
})
.unwrap();
}
let unknown = Value::Missing(ValueType::Boolean);
assert_eq!(
evaluate("if value { 1.0 } else { 2.0 }", &[("value", unknown)]),
Value::Number(2.0)
);
assert_eq!(
evaluate("iff(value, 1.0, 2.0)", &[("value", unknown)]),
Value::Number(2.0)
);
}
#[test]
fn invalid_clamp_returns_error_without_panicking() {
let program = compile("clamp(1.0, 2.0, 0.0)", |_| None).unwrap();
let result = program.evaluate(&mut Scratch::default(), |_, _, _| unreachable!());
assert!(result.unwrap_err().to_string().contains("lower bound"));
}
#[test]
fn short_circuit_does_not_resolve_unused_variable() {
let program = compile("false && missing", |name| {
+387 -20
View File
@@ -605,6 +605,7 @@ pub struct PlatformExprStrategyConfig {
pub selection_limit_expr: String,
pub selection_candidate_limit_expr: String,
pub stock_filter_expr: String,
pub buy_filter_expr: String,
pub buy_scale_expr: String,
pub exposure_expr: String,
pub position_exposure_schedule: BTreeMap<NaiveDate, f64>,
@@ -649,6 +650,7 @@ pub struct PlatformExprStrategyConfig {
pub matching_type: MatchingType,
pub quote_quantity_limit: bool,
pub current_day_precomputed_factors: bool,
pub completed_session_factor_fields: BTreeSet<String>,
pub candidate_symbols_by_date: BTreeMap<NaiveDate, BTreeSet<String>>,
pub intraday_execution_time: Option<NaiveTime>,
pub explicit_action_times: Vec<NaiveTime>,
@@ -683,6 +685,7 @@ impl PlatformExprStrategyConfig {
selection_limit_expr: "1".to_string(),
selection_candidate_limit_expr: String::new(),
stock_filter_expr: String::new(),
buy_filter_expr: String::new(),
buy_scale_expr: "1.0".to_string(),
exposure_expr: "1.0".to_string(),
position_exposure_schedule: BTreeMap::new(),
@@ -727,6 +730,7 @@ impl PlatformExprStrategyConfig {
matching_type: MatchingType::CurrentBarClose,
quote_quantity_limit: true,
current_day_precomputed_factors: false,
completed_session_factor_fields: BTreeSet::new(),
candidate_symbols_by_date: BTreeMap::new(),
intraday_execution_time: None,
explicit_action_times: Vec::new(),
@@ -1384,6 +1388,15 @@ pub struct PlatformSelectionQuotePlan {
pub diagnostics: Vec<String>,
}
fn checked_rhai_comparison(
operator: &str,
lhs: f64,
rhs: f64,
) -> Result<bool, Box<rhai::EvalAltResult>> {
numeric_expr_vm::finite_comparison(operator, lhs, rhs)
.ok_or_else(|| format!("missing_numeric_operand: operator={operator}").into())
}
fn platform_safe_div(lhs: f64, rhs: f64, fallback: f64) -> f64 {
if rhs.abs() <= f64::EPSILON {
fallback
@@ -1396,6 +1409,26 @@ fn platform_safe_div_default(lhs: f64, rhs: f64) -> f64 {
platform_safe_div(lhs, rhs, 0.0)
}
fn completed_session_factor_date(
ctx: &StrategyContext<'_>,
date: NaiveDate,
factor_date: NaiveDate,
execution_time: Option<NaiveTime>,
) -> Option<NaiveDate> {
let factor_date = factor_date.min(ctx.decision_date);
if factor_date < date || factor_date < ctx.decision_date {
return Some(factor_date);
}
let time = execution_time.or_else(|| ctx.active_datetime.map(|value| value.time()));
// Native CN stock daily indicator rows become usable only after the
// session closes. Absence of an intraday clock denotes a daily close bar.
if time.is_none_or(|time| time >= NaiveTime::from_hms_opt(15, 0, 0).unwrap()) {
Some(factor_date)
} else {
ctx.data.previous_trading_date(factor_date, 1)
}
}
impl PlatformExprStrategy {
fn market_cap_storage_to_strategy_unit(value: f64) -> f64 {
value
@@ -1541,21 +1574,50 @@ impl PlatformExprStrategy {
pub fn new(config: PlatformExprStrategyConfig) -> Self {
let mut engine = Engine::new();
engine.set_fast_operators(false);
engine.set_fail_on_invalid_map_property(true);
// Dynamic scripts cannot carry a nullable boolean through Rhai's
// logical operators. Reject an unknown comparison instead of letting
// native NaN comparisons turn missing data into a buy/sell signal.
for operator in ["==", "!=", "<", "<=", ">", ">="] {
engine.register_fn(operator, move |lhs: f64, rhs: f64| {
checked_rhai_comparison(operator, lhs, rhs)
});
engine.register_fn(operator, move |lhs: f64, rhs: i64| {
checked_rhai_comparison(operator, lhs, rhs as f64)
});
engine.register_fn(operator, move |lhs: i64, rhs: f64| {
checked_rhai_comparison(operator, lhs as f64, rhs)
});
}
engine.register_fn("round", |value: f64| value.round());
engine.register_fn("floor", |value: f64| value.floor());
engine.register_fn("ceil", |value: f64| value.ceil());
engine.register_fn("abs", |value: f64| value.abs());
engine.register_fn("min", |lhs: f64, rhs: f64| lhs.min(rhs));
engine.register_fn("max", |lhs: f64, rhs: f64| lhs.max(rhs));
engine.register_fn("min", |lhs: f64, rhs: f64| {
if lhs.is_finite() && rhs.is_finite() { lhs.min(rhs) } else { f64::NAN }
});
engine.register_fn("max", |lhs: f64, rhs: f64| {
if lhs.is_finite() && rhs.is_finite() { lhs.max(rhs) } else { f64::NAN }
});
engine.register_fn("sqrt", |value: f64| value.sqrt());
engine.register_fn("pow", |lhs: f64, rhs: f64| lhs.powf(rhs));
engine.register_fn("log", |value: f64| value.ln());
engine.register_fn("exp", |value: f64| value.exp());
engine.register_fn("clamp", |value: f64, low: f64, high: f64| {
value.clamp(low, high)
engine.register_fn("clamp", |value: f64, low: f64, high: f64| -> Result<f64, Box<rhai::EvalAltResult>> {
if !value.is_finite() || !low.is_finite() || !high.is_finite() {
return Ok(f64::NAN);
}
if low > high {
return Err("clamp lower bound exceeds upper bound".into());
}
Ok(value.clamp(low, high))
});
engine.register_fn("between", |value: f64, low: f64, high: f64| {
value >= low && value <= high
engine.register_fn("between", |value: f64, low: f64, high: f64| -> Result<bool, Box<rhai::EvalAltResult>> {
if !value.is_finite() || !low.is_finite() || !high.is_finite() {
return Err("missing_numeric_operand: function=between".into());
}
Ok(value >= low && value <= high)
});
engine.register_fn(
"nz",
@@ -1778,6 +1840,7 @@ impl PlatformExprStrategy {
"stock_filter_expr".to_string(),
self.config.stock_filter_expr.as_str(),
),
("buy_filter_expr".to_string(), self.config.buy_filter_expr.as_str()),
(
"buy_scale_expr".to_string(),
self.config.buy_scale_expr.as_str(),
@@ -4659,7 +4722,7 @@ impl PlatformExprStrategy {
} else if intraday_same_day_factor {
f64::NAN
} else {
factor.extra_factors.get("amount").copied().unwrap_or(0.0)
factor.extra_factors.get("amount").copied().unwrap_or(f64::NAN)
};
let market_cap_bn = decision_market_cap_bn(factor);
let free_float_cap_bn = decision_free_float_cap_bn(factor);
@@ -4702,6 +4765,23 @@ impl PlatformExprStrategy {
} else {
BTreeMap::new()
};
if !self.config.completed_session_factor_fields.is_empty() {
let visible_date = completed_session_factor_date(
ctx, date, factor_date,
execution_time.or(self.config.intraday_execution_time),
);
let visible_factor = visible_date
.and_then(|visible_date| ctx.data.factor_by_symbol_id(visible_date, symbol_id));
for field in &self.config.completed_session_factor_fields {
if self.stock_extra_factor_map_required || self.stock_extra_factor_identifiers.contains(field) {
let value = visible_factor
.and_then(|row| row.extra_factors.get(field.as_str()))
.copied()
.unwrap_or(f64::NAN);
extra_factors.insert(field.clone(), value);
}
}
}
if self.stock_extra_factors_required
&& (self.stock_extra_factor_map_required
|| self
@@ -4724,8 +4804,8 @@ impl PlatformExprStrategy {
minute_volume: market.minute_volume as i64,
bid1_volume: market.bid1_volume as i64,
ask1_volume: market.ask1_volume as i64,
turnover_ratio: factor.turnover_ratio.unwrap_or(0.0),
effective_turnover_ratio: factor.effective_turnover_ratio.unwrap_or(0.0),
turnover_ratio: factor.turnover_ratio.unwrap_or(f64::NAN),
effective_turnover_ratio: factor.effective_turnover_ratio.unwrap_or(f64::NAN),
open: feature_market.day_open,
high: expression_high,
low: expression_low,
@@ -8280,11 +8360,31 @@ impl PlatformExprStrategy {
day: &DayExpressionState,
stock: Option<&StockExpressionState>,
position: Option<&PositionExpressionState>,
) -> Result<f64, BacktestError> {
let value = self.eval_float_or_missing(ctx, expr, day, stock, position)?;
if !value.is_finite() {
return Err(BacktestError::Execution(format!(
"missing_numeric_result: expression={expr:?}, symbol={}, decision_date={}, execution_date={}",
stock.map(|item| item.symbol.as_ref()).unwrap_or("portfolio"),
ctx.decision_date, ctx.execution_date,
)));
}
Ok(value)
}
fn eval_float_or_missing(
&self,
ctx: &StrategyContext<'_>,
expr: &str,
day: &DayExpressionState,
stock: Option<&StockExpressionState>,
position: Option<&PositionExpressionState>,
) -> Result<f64, BacktestError> {
if let Some(value) = self.eval_numeric_vm(ctx, expr, day, stock, position)? {
return match value {
NumericVmValue::Number(number) => Ok(number),
NumericVmValue::Boolean(boolean) => Ok(if boolean { 1.0 } else { 0.0 }),
NumericVmValue::Missing(_) => Ok(f64::NAN),
};
}
let value = self.eval_dynamic(ctx, expr, day, stock, position)?;
@@ -8314,7 +8414,8 @@ impl PlatformExprStrategy {
if let Some(value) = self.eval_numeric_vm(ctx, expr, day, stock, position)? {
return match value {
NumericVmValue::Boolean(boolean) => Ok(boolean),
NumericVmValue::Number(number) => Ok(number != 0.0),
NumericVmValue::Number(number) => Ok(number.is_finite() && number != 0.0),
NumericVmValue::Missing(_) => Ok(false),
};
}
let value = self.eval_dynamic(ctx, expr, day, stock, position)?;
@@ -8322,7 +8423,7 @@ impl PlatformExprStrategy {
return Ok(boolean);
}
if let Some(number) = value.clone().try_cast::<f64>() {
return Ok(number != 0.0);
return Ok(number.is_finite() && number != 0.0);
}
if let Some(number) = value.try_cast::<i64>() {
return Ok(number != 0);
@@ -9871,6 +9972,7 @@ impl PlatformExprStrategy {
)];
diagnostics.extend(action_diagnostics);
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -10355,7 +10457,7 @@ impl PlatformExprStrategy {
stock: &StockExpressionState,
) -> Result<f64, BacktestError> {
if self.rank_expr_present {
return match self.eval_float(ctx, &self.config.rank_expr, day, Some(stock), None) {
return match self.eval_float_or_missing(ctx, &self.config.rank_expr, day, Some(stock), None) {
Ok(value) => Ok(value),
Err(error) if Self::is_missing_rolling_mean_error(&error) => Ok(f64::NAN),
Err(error) if Self::is_non_numeric_expr_error(&error) => Ok(f64::NAN),
@@ -10913,6 +11015,7 @@ impl PlatformExprStrategy {
let expressions = [
config.prelude.as_str(),
config.stock_filter_expr.as_str(),
config.buy_filter_expr.as_str(),
config.buy_scale_expr.as_str(),
config.stop_loss_expr.as_str(),
config.take_profit_expr.as_str(),
@@ -10945,6 +11048,7 @@ impl PlatformExprStrategy {
for expr in [
config.prelude.as_str(),
config.stock_filter_expr.as_str(),
config.buy_filter_expr.as_str(),
config.buy_scale_expr.as_str(),
config.stop_loss_expr.as_str(),
config.take_profit_expr.as_str(),
@@ -10992,6 +11096,7 @@ impl PlatformExprStrategy {
for expr in [
config.prelude.as_str(),
config.stock_filter_expr.as_str(),
config.buy_filter_expr.as_str(),
config.buy_scale_expr.as_str(),
config.stop_loss_expr.as_str(),
config.take_profit_expr.as_str(),
@@ -11032,6 +11137,9 @@ impl PlatformExprStrategy {
if Self::expr_requires_stock_extra_factors(
&config.stock_filter_expr,
prelude_declared_identifiers,
) || Self::expr_requires_stock_extra_factors(
&config.buy_filter_expr,
prelude_declared_identifiers,
) {
return true;
}
@@ -11061,6 +11169,7 @@ impl PlatformExprStrategy {
[
config.prelude.as_str(),
config.stock_filter_expr.as_str(),
config.buy_filter_expr.as_str(),
config.buy_scale_expr.as_str(),
config.stop_loss_expr.as_str(),
config.take_profit_expr.as_str(),
@@ -11095,6 +11204,11 @@ impl PlatformExprStrategy {
&config.stock_filter_expr,
prelude_declared_identifiers,
);
Self::collect_stock_extra_factor_identifiers(
&mut identifiers,
&config.buy_filter_expr,
prelude_declared_identifiers,
);
for expr in [
config.buy_scale_expr.as_str(),
config.stop_loss_expr.as_str(),
@@ -12069,10 +12183,11 @@ impl Strategy for PlatformExprStrategy {
.is_some();
if scheduled_rotation {
self.executing_scheduled_rotation = true;
let rotation = self.on_day(ctx);
let rotation = self.compute_day_decision(ctx);
self.executing_scheduled_rotation = false;
decision.merge_from(rotation?);
}
self.attach_buy_denials(ctx, &mut decision)?;
Ok(decision)
}
@@ -12121,12 +12236,55 @@ impl Strategy for PlatformExprStrategy {
&& self.config.explicit_action_schedule.is_none()
&& self.unscheduled_explicit_actions_are_due(ctx.decision_date)
{
return self.explicit_action_decision(ctx);
let mut decision = self.explicit_action_decision(ctx)?;
self.attach_buy_denials(ctx, &mut decision)?;
return Ok(decision);
}
Ok(StrategyDecision::default())
}
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
let mut decision = self.compute_day_decision(ctx)?;
self.attach_buy_denials(ctx, &mut decision)?;
Ok(decision)
}
}
impl PlatformExprStrategy {
fn attach_buy_denials(&self, ctx: &StrategyContext<'_>, decision: &mut StrategyDecision) -> Result<(), BacktestError> {
if self.config.buy_filter_expr.trim().is_empty() {
return Ok(());
}
let symbols = decision.potential_buy_symbols(ctx.open_orders);
if symbols.is_empty() {
return Ok(());
}
let day = self.day_state(ctx, ctx.decision_date)?;
let (market_date, _, factor_date) = self.selection_dates(ctx);
let execution_time = ctx.active_datetime.filter(|value| value.date() == market_date)
.map(|value| value.time()).or(self.config.intraday_execution_time);
let needs_quote = Self::stock_filter_quote_usage_for_expr(&Self::normalize_expr(&self.config.buy_filter_expr))
!= StockFilterQuoteUsage::DailyOnly;
for symbol in symbols {
if needs_quote && self.uses_intraday_execution_quotes() && !ctx.is_lagged_execution()
&& self.scheduled_quote_at_time(ctx, market_date, &symbol, execution_time).is_none()
{
return Err(BacktestError::Execution(format!(
"buy condition quote unavailable: symbol={symbol} decision_date={}", ctx.decision_date,
)));
}
let stock = self.stock_state_with_factor_date_and_time(ctx, market_date, factor_date, &symbol, execution_time, true)?;
if !self.eval_bool(ctx, &self.config.buy_filter_expr, &day, Some(&stock), None)? {
decision.buy_denials.insert(symbol, format!(
"strategy_buy_condition_false decision_date={} expression={}",
ctx.decision_date, self.config.buy_filter_expr,
));
}
}
Ok(())
}
fn compute_day_decision(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
if self.config.rotation_enabled
&& self
.config
@@ -12529,6 +12687,7 @@ impl Strategy for PlatformExprStrategy {
));
}
return Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols,
@@ -13805,6 +13964,7 @@ impl Strategy for PlatformExprStrategy {
];
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols,
@@ -13903,6 +14063,164 @@ mod tests {
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
}
#[test]
fn buy_filter_attaches_denials_without_rewriting_selection() {
let prev = d(2025, 1, 2);
let curr = d(2025, 1, 3);
let symbol = "000001.SZ";
let mut parts = single_symbol_platform_data(&[prev, curr], symbol).snapshot_components();
for row in &mut parts.factors { row.extra_factors.insert("entry_gate".into(), 0.0); }
let data = DataSet::from_components(parts.instruments, parts.market, parts.factors, parts.candidates, parts.benchmarks).unwrap();
let portfolio = PortfolioState::new(30_000.0);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: curr, decision_date: curr, decision_index: 1, data: &data,
portfolio: &portfolio, futures_account: None, open_orders: &[], dynamic_universe: None,
subscriptions: &subscriptions, process_events: &[], active_process_event: None,
active_datetime: None, order_events: &[], fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = symbol.to_string();
cfg.max_positions = 1;
cfg.refresh_rate = 1;
cfg.benchmark_short_ma_days = 1;
cfg.benchmark_long_ma_days = 1;
cfg.market_cap_lower_expr = "0".to_string();
cfg.market_cap_upper_expr = "100".to_string();
cfg.selection_limit_expr = "1".to_string();
cfg.stock_filter_expr = "close > 0".to_string();
cfg.buy_filter_expr = "entry_gate > 0".to_string();
cfg.current_day_precomputed_factors = true;
let mut strategy = PlatformExprStrategy::new(cfg);
let decision = strategy.on_day(&ctx).unwrap();
assert!(!decision.order_intents.is_empty());
assert!(decision.buy_denials.contains_key(symbol));
assert!(strategy.stock_extra_factor_identifiers.contains("entry_gate"));
assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly);
}
#[test]
fn buy_quote_filter_rejects_missing_intraday_quote_not_daily_close() {
let date = d(2025, 1, 2);
let symbol = "000001.SZ";
let data = single_symbol_platform_data(&[date], symbol);
let portfolio = PortfolioState::new(30_000.0);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date, decision_date: date, decision_index: 0, data: &data,
portfolio: &portfolio, futures_account: None, open_orders: &[], dynamic_universe: None,
subscriptions: &subscriptions, process_events: &[], active_process_event: None,
active_datetime: None, order_events: &[], fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::generic();
cfg.signal_symbol = symbol.to_string();
cfg.buy_filter_expr = "last > 0".to_string();
cfg.intraday_execution_time = NaiveTime::from_hms_opt(10, 18, 0);
let strategy = PlatformExprStrategy::new(cfg);
let mut decision = crate::StrategyDecision::default();
decision.order_intents.push(OrderIntent::TargetValue { symbol: symbol.to_string(), target_value: 10_000.0, reason: "buy".to_string() });
let error = strategy.attach_buy_denials(&ctx, &mut decision).unwrap_err();
assert!(error.to_string().contains("buy condition quote unavailable"), "{error}");
assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly);
}
#[test]
fn buy_filter_uses_active_schedule_time_instead_of_first_configured_time() {
let date = d(2025, 1, 2);
let symbol = "000001.SZ";
let parts = single_symbol_platform_data(&[date], symbol).snapshot_components();
let quotes = [(10, 18, 9.5), (14, 59, 10.5)].into_iter().map(|(hour, minute, price)| IntradayExecutionQuote {
date, symbol: symbol.to_string(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(),
last_price: price, bid1: price, ask1: price, bid1_volume: 1000, ask1_volume: 1000,
volume_delta: 1000, amount_delta: price * 1000.0, trading_phase: Some("continuous".to_string()),
}).collect();
let data = DataSet::from_components_with_actions_and_quotes(parts.instruments, parts.market,
parts.factors, parts.candidates, parts.benchmarks, Vec::new(), quotes).unwrap();
let portfolio = PortfolioState::new(30_000.0);
let subscriptions = BTreeSet::new();
let mut ctx = StrategyContext {
execution_date: date, decision_date: date, decision_index: 0, data: &data,
portfolio: &portfolio, futures_account: None, open_orders: &[], dynamic_universe: None,
subscriptions: &subscriptions, process_events: &[], active_process_event: None,
active_datetime: None, order_events: &[], fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::generic();
cfg.signal_symbol = symbol.to_string();
cfg.buy_filter_expr = "last > 10".to_string();
cfg.intraday_execution_time = NaiveTime::from_hms_opt(10, 18, 0);
let strategy = PlatformExprStrategy::new(cfg);
for (hour, minute, denied) in [(10, 18, true), (14, 59, false)] {
ctx.active_datetime = Some(date.and_hms_opt(hour, minute, 0).unwrap());
let mut decision = crate::StrategyDecision::default();
decision.order_intents.push(OrderIntent::TargetValue { symbol: symbol.to_string(), target_value: 10_000.0, reason: "target".to_string() });
strategy.attach_buy_denials(&ctx, &mut decision).unwrap();
assert_eq!(decision.buy_denials.contains_key(symbol), denied);
}
}
#[test]
fn completed_session_factor_dates_exclude_intraday_and_preserve_next_open() {
let prev = d(2025, 1, 2);
let curr = d(2025, 1, 3);
let data = single_symbol_platform_data(&[prev, curr], "000001.SZ");
let portfolio = PortfolioState::new(10_000.0);
let subscriptions = BTreeSet::new();
let mut ctx = StrategyContext {
execution_date: curr, decision_date: curr, decision_index: 1,
data: &data, portfolio: &portfolio, futures_account: None,
open_orders: &[], dynamic_universe: None, subscriptions: &subscriptions,
process_events: &[], active_process_event: None, active_datetime: None,
order_events: &[], fills: &[],
};
for hour in [9, 10, 14] {
assert_eq!(super::completed_session_factor_date(&ctx, curr, curr,
NaiveTime::from_hms_opt(hour, 30, 0)), Some(prev));
}
assert_eq!(super::completed_session_factor_date(&ctx, curr, curr,
NaiveTime::from_hms_opt(15, 0, 0)), Some(curr));
assert_eq!(super::completed_session_factor_date(&ctx, curr, curr, None), Some(curr));
ctx.active_datetime = Some(curr.and_hms_opt(10, 0, 0).unwrap());
assert_eq!(super::completed_session_factor_date(&ctx, curr, curr, None), Some(prev));
ctx.decision_date = prev;
assert_eq!(super::completed_session_factor_date(&ctx, curr, curr, None), Some(prev));
ctx.execution_date = prev;
assert_eq!(super::completed_session_factor_date(&ctx, prev, prev, None), None);
}
#[test]
fn completed_session_bound_value_does_not_read_same_day_future_value() {
let prev = d(2025, 1, 2);
let curr = d(2025, 1, 3);
let symbol = "000001.SZ";
let mut parts = single_symbol_platform_data(&[prev, curr], symbol).snapshot_components();
for row in &mut parts.factors {
row.extra_factors.insert("native_daily".into(), if row.date == prev { 2.0 } else { 999.0 });
row.extra_factors.insert("published_today".into(), 7.0);
}
let data = DataSet::from_components(parts.instruments, parts.market, parts.factors,
parts.candidates, parts.benchmarks).unwrap();
let portfolio = PortfolioState::new(10_000.0);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: curr, decision_date: curr, decision_index: 1,
data: &data, portfolio: &portfolio, futures_account: None,
open_orders: &[], dynamic_universe: None, subscriptions: &subscriptions,
process_events: &[], active_process_event: None, active_datetime: None,
order_events: &[], fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::generic();
cfg.stock_filter_expr = "native_daily > 0 && published_today > 0".to_string();
cfg.completed_session_factor_fields.insert("native_daily".to_string());
let strategy = PlatformExprStrategy::new(cfg);
let morning = strategy.stock_state_with_factor_date_and_time(&ctx, curr, curr, symbol,
NaiveTime::from_hms_opt(10, 0, 0), false).unwrap();
assert_eq!(morning.extra_factors["native_daily"], 2.0);
assert_eq!(morning.extra_factors["published_today"], 7.0);
let close = strategy.stock_state_with_factor_date_and_time(&ctx, curr, curr, symbol,
NaiveTime::from_hms_opt(15, 0, 0), false).unwrap();
assert_eq!(close.extra_factors["native_daily"], 999.0);
}
#[test]
fn target_scale_replenishment_preserves_strategy_cash_allocation() {
let scale = 30.0 / 31.0;
@@ -16260,8 +16578,8 @@ mod tests {
market_cap_bn: 12.0,
free_float_cap_bn: 10.0,
pe_ttm: 8.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
@@ -16271,8 +16589,8 @@ mod tests {
market_cap_bn: 12.0,
free_float_cap_bn: 10.0,
pe_ttm: 8.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
turnover_ratio: Some(0.0),
effective_turnover_ratio: Some(0.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::from([
("model_score".into(), 2.0),
@@ -16337,6 +16655,51 @@ mod tests {
.expect("present stock state");
assert!(day.available_factor_names.contains("model_score"));
assert!(missing_stock.turnover_ratio.is_nan());
assert!(missing_stock.effective_turnover_ratio.is_nan());
assert_eq!(present_stock.turnover_ratio, 0.0);
for expression in ["model_score", "min(model_score, 1.0)", "model_score / 100.0"] {
let error = strategy.eval_float(&ctx, expression, &day, Some(&missing_stock), None).unwrap_err();
assert!(error.to_string().contains("missing_numeric_result"), "{error}");
}
assert!(strategy.eval_float_or_missing(&ctx, "model_score", &day, Some(&missing_stock), None).unwrap().is_nan());
assert_eq!(strategy.eval_float(&ctx, "nz(model_score, 0.0)", &day, Some(&missing_stock), None).unwrap(), 0.0);
for predicate in [
"!(model_score > 0.0)",
"!(model_score != 0.0)",
"!between(model_score, 0.0, 1.0)",
"!(min(model_score, 1.0) > 0.0)",
"!(model_score > 0.0) || false",
] {
assert!(!strategy.eval_bool(&ctx, predicate, &day, Some(&missing_stock), None).unwrap(), "{predicate}");
}
for predicate in [
"!(model_score > 0.0) || true",
"!(model_score > 0.0 && false)",
] {
assert!(strategy.eval_bool(&ctx, predicate, &day, Some(&missing_stock), None).unwrap(), "{predicate}");
}
for predicate in [
"symbol == \"000001.SZ\" && !(model_score > 0.0)",
"symbol == \"000001.SZ\" && !(model_score > 0)",
"symbol == \"000001.SZ\" && !(0 < model_score)",
] {
let result = strategy.eval_bool(&ctx, predicate, &day, Some(&missing_stock), None);
assert!(result.is_err(), "{predicate}: {result:?}");
let error = result.unwrap_err();
assert!(error.to_string().contains("missing_numeric_operand"), "{error}");
}
assert!(!strategy.eval_bool(
&ctx, "symbol == \"OTHER\" && !(model_score > 0.0)",
&day, Some(&missing_stock), None,
).unwrap());
assert!(!strategy.eval_bool(&ctx, "model_score", &day, Some(&missing_stock), None).unwrap());
assert!(strategy.eval_bool(&ctx, "model_score", &day, Some(&present_stock), None).unwrap());
for field in ["turnover_ratio", "effective_turnover_ratio"] {
let predicate = format!("{field} < 1.0");
assert!(!strategy.eval_bool(&ctx, &predicate, &day, Some(&missing_stock), None).unwrap());
assert!(strategy.eval_bool(&ctx, &predicate, &day, Some(&present_stock), None).unwrap());
}
assert!(
!strategy
.stock_passes_expr(&ctx, &day, &missing_stock)
@@ -23702,7 +24065,7 @@ mod tests {
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: BTreeMap::from([("amount".into(), 20_000_000.0)]),
},
DailyFactorSnapshot {
date: factor_date,
@@ -23713,7 +24076,7 @@ mod tests {
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: BTreeMap::from([("amount".into(), 10_000_000.0)]),
},
DailyFactorSnapshot {
date: decision_date,
@@ -23864,6 +24227,10 @@ mod tests {
.stock_state_with_factor_date(&ctx, decision_date, decision_date, limit_symbol)
.expect("next-open decision state");
assert_eq!(decision_day_state.amount, 30_000_000.0);
let prior_factor_state = strategy
.stock_state_with_factor_date(&ctx, decision_date, factor_date, limit_symbol)
.expect("previous factor-day state");
assert_eq!(prior_factor_state.amount, 20_000_000.0);
let decision = strategy.on_day(&ctx).expect("platform decision");
+55 -18
View File
@@ -977,6 +977,8 @@ pub struct StrategyExpressionOrderingConfig {
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StrategyExpressionTradingConfig {
#[serde(default, alias = "buy_filter_expr")]
pub buy_filter_expr: Option<String>,
#[serde(default)]
pub stage: Option<String>,
#[serde(default)]
@@ -1785,6 +1787,23 @@ pub fn platform_expr_config_from_spec(
let Some(spec) = strategy_spec else {
return Ok(cfg);
};
if let Some(conditions) = spec.stock_pool_factor_contract.as_ref()
.and_then(|contract| contract.get("conditions"))
.and_then(Value::as_array)
{
for condition in conditions {
let Some(binding) = condition.pointer("/semantic/backtestBinding") else { continue };
let field = binding.get("field").and_then(Value::as_str).unwrap_or("");
let dataset = binding.get("sourceDataset").and_then(Value::as_str).unwrap_or("");
if !dataset.starts_with("indicators_") || field.is_empty()
|| !field.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|| field.as_bytes()[0].is_ascii_digit()
{
return Err("invalid native factor backtest binding".to_string());
}
cfg.completed_session_factor_fields.insert(field.to_string());
}
}
let mut benchmark_short_explicit = false;
let mut benchmark_long_explicit = false;
let mut stock_short_explicit = false;
@@ -2272,6 +2291,9 @@ pub fn platform_expr_config_from_spec(
}
}
if let Some(trading) = runtime_expr.trading.as_ref() {
if let Some(expr) = trading.buy_filter_expr.as_ref() {
cfg.buy_filter_expr = expr.clone();
}
if let Some(expr) = trading
.refresh_rate_expr
.as_ref()
@@ -3063,8 +3085,7 @@ fn instrument_query_id(symbol: &str, board: &str) -> String {
}
fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String {
let has_suffix = symbol.trim().rsplit_once('.').is_some();
if has_suffix && symbol_is_kcb(symbol) {
if raw_board.and_then(crate::instrument::listed_sector_is_kcb) == Some(true) {
return "KSH".to_string();
}
let normalized = raw_board
@@ -3079,9 +3100,6 @@ fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String {
if let Some((_, suffix)) = symbol.rsplit_once('.') {
return suffix.to_ascii_uppercase();
}
if symbol_is_kcb(symbol) {
return "KSH".to_string();
}
if symbol.starts_with('8') || symbol.starts_with('4') {
return "BJ".to_string();
}
@@ -3098,27 +3116,46 @@ fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String {
"UNK".to_string()
}
fn symbol_is_kcb(symbol: &str) -> bool {
let normalized = symbol.trim().to_ascii_uppercase();
let Some((code, suffix)) = normalized.rsplit_once('.') else {
return normalized.starts_with("688") || normalized.starts_with("689");
};
suffix == "SH" && (code.starts_with("688") || code.starts_with("689"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_board_classifies_kcb_by_688_689_sh_suffix_only() {
assert_eq!(normalize_board("688001.SH", None), "KSH");
assert_eq!(normalize_board("689001.SH", None), "KSH");
fn parses_buy_filter_as_a_separate_trading_condition() {
let cfg = platform_expr_config_from_value("buy-guard", "000001.SZ", &serde_json::json!({
"runtimeExpressions": {
"selection": {"stockFilterExpr": "close > 0"},
"trading": {"buyFilterExpr": "gate > 0"}
}
})).unwrap();
assert_eq!(cfg.stock_filter_expr, "close > 0");
assert_eq!(cfg.buy_filter_expr, "gate > 0");
}
#[test]
fn native_factor_bindings_declare_completed_session_fields() {
let spec = serde_json::json!({"stockPoolFactorContract": {"conditions": [
{"factorRef": "up_days_stock", "semantic": {"backtestBinding": {
"field": "ths_up_days_stock", "sourceDataset": "indicators_up_days_stock"
}}}
]}});
let cfg = platform_expr_config_from_value("test", "000852.SH", &spec).unwrap();
assert_eq!(cfg.completed_session_factor_fields,
BTreeSet::from(["ths_up_days_stock".to_string()]));
let empty = platform_expr_config_from_value("test", "000852.SH", &serde_json::json!({})).unwrap();
assert!(empty.completed_session_factor_fields.is_empty());
}
#[test]
fn normalize_board_does_not_infer_kcb_from_security_code() {
assert_eq!(normalize_board("688001.SH", None), "SH");
assert_eq!(normalize_board("689001.SH", None), "SH");
assert_eq!(normalize_board("688001.BJ", None), "BJ");
assert_eq!(normalize_board("689001.SZ", None), "SZ");
assert_eq!(normalize_board("688001", None), "KSH");
assert_eq!(normalize_board("688001", None), "SH");
assert_eq!(normalize_board("688001", Some("SZ")), "SZ");
assert_eq!(normalize_board("688001.SH", Some("SH")), "KSH");
assert_eq!(normalize_board("688001.SH", Some("SH")), "SH");
assert_eq!(normalize_board("000001.SZ", Some("KSH")), "KSH");
}
#[test]
+20 -6
View File
@@ -397,7 +397,7 @@ impl ChinaAShareRiskControl {
RiskCheckScope::Buy => config.static_rules.reject_kcb_buy,
RiskCheckScope::Sell => false,
};
if reject_kcb && (candidate.is_kcb || symbol_is_kcb(&candidate.symbol)) {
if reject_kcb && candidate.is_kcb {
return Some("kcb");
}
let reject_bjse = match scope {
@@ -600,11 +600,6 @@ impl ChinaAShareRiskControl {
}
}
fn symbol_is_kcb(symbol: &str) -> bool {
let normalized = symbol.trim().to_ascii_uppercase();
(normalized.starts_with("688") || normalized.starts_with("689")) && normalized.ends_with(".SH")
}
fn symbol_is_bjse(symbol: &str) -> bool {
let normalized = symbol.trim().to_ascii_uppercase();
normalized.ends_with(".BJ") || normalized.ends_with(".BSE") || normalized.ends_with(".BE")
@@ -1009,6 +1004,24 @@ mod tests {
assert_eq!(configured_reason, None);
}
#[test]
fn kcb_filter_uses_classification_instead_of_security_code() {
let date = d(2025, 1, 2);
let market = market(date, 6.27, 5.63);
let mut candidate = candidate(date);
let config = FidcRiskControlConfig::default();
for symbol in ["688001.SH", "689001.SH", "000001.SZ"] {
candidate.symbol = symbol.to_string();
for is_kcb in [false, true] {
candidate.is_kcb = is_kcb;
let reason = ChinaAShareRiskControl::buy_rejection_reason_with_config(
date, &candidate, &market, None, 6.27, &config,
);
assert_eq!(reason, is_kcb.then_some("kcb"), "{symbol}");
}
}
}
#[test]
fn st_and_star_st_filters_are_independent() {
let date = d(2025, 1, 2);
@@ -1139,6 +1152,7 @@ mod tests {
let date = d(2025, 1, 2);
let mut candidate = candidate(date);
candidate.symbol = "688506.SH".to_string();
candidate.is_kcb = true;
candidate.risk_level_code = Some("missing_risk_state".to_string());
let market = market(date, 6.27, 5.63);
let mut config = FidcRiskControlConfig::default();
+58 -1
View File
@@ -977,6 +977,7 @@ fn safe_ratio(numerator: f64, denominator: f64) -> f64 {
#[derive(Debug, Clone, Default)]
pub struct StrategyDecision {
pub buy_denials: BTreeMap<String, String>,
pub rebalance: bool,
pub target_weights: BTreeMap<String, f64>,
pub exit_symbols: BTreeSet<String>,
@@ -987,7 +988,20 @@ pub struct StrategyDecision {
}
impl StrategyDecision {
pub fn potential_buy_symbols(&self, open_orders: &[OpenOrderView]) -> BTreeSet<String> {
let mut symbols = BTreeSet::new();
if self.rebalance {
symbols.extend(self.target_weights.iter().filter(|(_, weight)| **weight > 0.0).map(|(symbol, _)| symbol.clone()));
}
for intent in &self.order_intents {
intent.collect_potential_buy_symbols(open_orders, &mut symbols);
}
symbols.retain(|symbol| !symbol.trim().is_empty());
symbols
}
pub fn merge_from(&mut self, mut other: StrategyDecision) {
self.buy_denials.append(&mut other.buy_denials);
self.rebalance |= other.rebalance;
self.target_weights.append(&mut other.target_weights);
self.exit_symbols.append(&mut other.exit_symbols);
@@ -998,7 +1012,8 @@ impl StrategyDecision {
}
pub fn is_empty(&self) -> bool {
!self.rebalance
self.buy_denials.is_empty()
&& !self.rebalance
&& self.target_weights.is_empty()
&& self.exit_symbols.is_empty()
&& self.order_intents.is_empty()
@@ -1214,6 +1229,42 @@ pub enum OrderIntent {
}
impl OrderIntent {
fn collect_potential_buy_symbols(&self, open_orders: &[OpenOrderView], symbols: &mut BTreeSet<String>) {
match self.unwrapped() {
Self::Shares { symbol, quantity, .. } | Self::LimitShares { symbol, quantity, .. } if *quantity > 0 => { symbols.insert(symbol.clone()); }
Self::Lots { symbol, lots, .. } | Self::LimitLots { symbol, lots, .. } if *lots > 0 => { symbols.insert(symbol.clone()); }
Self::TargetShares { symbol, target_quantity, .. } | Self::LimitTargetShares { symbol, target_quantity, .. } if *target_quantity > 0 => { symbols.insert(symbol.clone()); }
Self::Value { symbol, value, .. } | Self::LimitValue { symbol, value, .. } | Self::AlgoValue { symbol, value, .. } if *value > 0.0 => { symbols.insert(symbol.clone()); }
Self::Percent { symbol, percent, .. } | Self::LimitPercent { symbol, percent, .. } | Self::AlgoPercent { symbol, percent, .. } if *percent > 0.0 => { symbols.insert(symbol.clone()); }
Self::TargetValue { symbol, target_value, .. } | Self::LimitTargetValue { symbol, target_value, .. } | Self::TimedTargetValue { symbol, target_value, .. } if *target_value > 0.0 => { symbols.insert(symbol.clone()); }
Self::TargetPercent { symbol, target_percent, .. } | Self::LimitTargetPercent { symbol, target_percent, .. } if *target_percent > 0.0 => { symbols.insert(symbol.clone()); }
Self::TargetPortfolioSmart { target_weights, .. } => {
symbols.extend(target_weights.iter().filter(|(_, weight)| **weight > 0.0).map(|(symbol, _)| symbol.clone()));
}
Self::ModifyOrder { order_id, new_total_quantity, new_limit_price, .. } => {
if let Some(order) = open_orders.iter().find(|order| order.order_id == *order_id)
&& order.side == OrderSide::Buy
&& (new_total_quantity.is_some_and(|value| value > order.requested_quantity)
|| new_limit_price.is_some_and(|value| value > order.limit_price))
{
symbols.insert(order.symbol.clone());
}
}
Self::Shares { .. } | Self::LimitShares { .. }
| Self::Lots { .. } | Self::LimitLots { .. }
| Self::TargetShares { .. } | Self::LimitTargetShares { .. }
| Self::Value { .. } | Self::LimitValue { .. } | Self::AlgoValue { .. }
| Self::Percent { .. } | Self::LimitPercent { .. } | Self::AlgoPercent { .. }
| Self::TargetValue { .. } | Self::LimitTargetValue { .. } | Self::TimedTargetValue { .. }
| Self::TargetPercent { .. } | Self::LimitTargetPercent { .. }
| Self::CancelOrder { .. } | Self::CancelSymbol { .. } | Self::CancelAll { .. }
| Self::UpdateUniverse { .. } | Self::Subscribe { .. } | Self::Unsubscribe { .. }
| Self::DepositWithdraw { .. } | Self::FinanceRepay { .. } | Self::SetManagementFeeRate { .. }
| Self::Futures { .. } => {}
Self::WithTimeInForce { .. } => unreachable!("intent is unwrapped"),
}
}
pub fn with_time_in_force(self, time_in_force: OrderTimeInForce) -> Self {
match self {
Self::WithTimeInForce { intent, .. } => Self::WithTimeInForce {
@@ -1569,6 +1620,7 @@ impl Strategy for CnSmallCapRotationStrategy {
if self.config.in_skip_window(ctx.decision_date) {
self.last_gross_exposure = Some(0.0);
return Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: true,
target_weights: BTreeMap::new(),
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
@@ -1590,6 +1642,7 @@ impl Strategy for CnSmallCapRotationStrategy {
if message.contains("signal series insufficient") =>
{
return Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1765,6 +1818,7 @@ impl Strategy for CnSmallCapRotationStrategy {
self.last_gross_exposure = Some(gross_exposure);
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance,
target_weights,
exit_symbols,
@@ -2773,6 +2827,7 @@ impl Strategy for OmniMicroCapStrategy {
let lagged_execution = ctx.is_lagged_execution();
if self.config.in_skip_window(signal_date) {
return Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
@@ -2803,6 +2858,7 @@ impl Strategy for OmniMicroCapStrategy {
if message.contains("insufficient benchmark") =>
{
return Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -3013,6 +3069,7 @@ impl Strategy for OmniMicroCapStrategy {
];
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols,
@@ -90,6 +90,7 @@ impl Strategy for BuyAndHoldStrategy {
ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
+1
View File
@@ -24,6 +24,7 @@ impl Strategy for BuyThenHoldStrategy {
) -> Result<StrategyDecision, fidc_core::BacktestError> {
if ctx.decision_date == d(2025, 1, 2) && ctx.portfolio.position("000001.SZ").is_none() {
return Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
+10
View File
@@ -295,6 +295,7 @@ impl Strategy for HookProbeStrategy {
.borrow_mut()
.push(format!("on_day:{}", ctx.execution_date));
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -334,6 +335,7 @@ impl Strategy for AuctionOrderStrategy {
_ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -377,6 +379,7 @@ impl Strategy for FuturesOrderStrategy {
return Ok(StrategyDecision::default());
}
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -720,6 +723,7 @@ impl Strategy for LimitCarryStrategy {
}
self.issued = true;
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -803,6 +807,7 @@ impl Strategy for UniverseDirectiveStrategy {
_ => Vec::new(),
};
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -844,6 +849,7 @@ impl Strategy for MinuteProbeStrategy {
_ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -885,6 +891,7 @@ impl Strategy for MinuteProbeStrategy {
}
self.ordered = true;
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -987,6 +994,7 @@ impl Strategy for OrderInspectionStrategy {
_ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1030,6 +1038,7 @@ impl Strategy for AccountFlowStrategy {
return Ok(StrategyDecision::default());
}
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -4134,6 +4143,7 @@ impl Strategy for BuyMissingRowThenHoldStrategy {
) -> Result<StrategyDecision, fidc_core::BacktestError> {
if ctx.execution_date == d(2025, 5, 26) {
return Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -209,6 +209,7 @@ fn execute_single_value_order(
&mut portfolio,
data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -399,6 +400,7 @@ fn broker_executes_explicit_order_value_buy() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -557,6 +559,7 @@ fn broker_delayed_limit_open_sell_uses_minute_price() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -687,6 +690,7 @@ fn broker_executes_order_shares_and_order_lots() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -806,6 +810,7 @@ fn broker_executes_target_shares_like_order_to() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -981,6 +986,7 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1139,6 +1145,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1254,6 +1261,7 @@ fn broker_executes_order_percent_and_target_percent() {
&mut percent_portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1278,6 +1286,7 @@ fn broker_executes_order_percent_and_target_percent() {
&mut target_percent_portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1380,6 +1389,7 @@ fn broker_uses_day_open_price_for_open_auction_matching() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1487,6 +1497,7 @@ fn broker_open_auction_uses_auction_volume_without_quote_liquidity() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1590,6 +1601,7 @@ fn broker_cancels_buy_when_open_hits_upper_limit() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1707,6 +1719,7 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1812,6 +1825,7 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1933,6 +1947,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -2039,6 +2054,7 @@ fn broker_rejects_intraday_last_order_without_execution_quotes() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -2163,6 +2179,7 @@ fn broker_executes_intraday_last_on_start_quote_with_trade_delta() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -2282,6 +2299,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -2398,6 +2416,7 @@ fn broker_cancels_market_buy_when_minute_has_no_volume() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -2534,6 +2553,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -2707,6 +2727,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -2888,6 +2909,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -3036,6 +3058,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -3172,6 +3195,7 @@ fn broker_uses_best_own_price_for_intraday_matching() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -3290,6 +3314,7 @@ fn broker_uses_best_counterparty_price_for_intraday_matching() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -3461,6 +3486,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: true,
target_weights: BTreeMap::from([("000002.SZ".to_string(), 1.0)]),
exit_symbols: BTreeSet::new(),
@@ -3657,6 +3683,7 @@ fn rebalance_uses_day_open_for_open_auction_valuation() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: true,
target_weights: BTreeMap::from([
("000001.SZ".to_string(), 0.5),
@@ -3841,6 +3868,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: true,
target_weights: BTreeMap::from([
("000001.SZ".to_string(), 0.2),
@@ -4025,6 +4053,7 @@ fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: true,
target_weights: BTreeMap::from([
("000001.SZ".to_string(), 0.48),
@@ -4139,6 +4168,7 @@ fn broker_uses_board_specific_min_quantity_and_step_size_for_buy_sizing() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -4244,6 +4274,7 @@ fn broker_allows_bjse_quantities_above_minimum_without_round_lot_step() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -4350,6 +4381,7 @@ fn broker_allows_full_odd_lot_sell_when_liquidating_position() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -4483,6 +4515,7 @@ fn same_day_sell_then_rebuy_is_rejected_by_default() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -4627,6 +4660,7 @@ fn same_day_sell_then_rebuy_can_be_allowed_by_policy() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -4680,6 +4714,7 @@ fn broker_configured_policy_can_allow_upper_limit_buy() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -4726,6 +4761,7 @@ fn broker_configured_policy_can_allow_lower_limit_sell() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -4901,6 +4937,7 @@ fn broker_expires_day_limit_buy_at_market_close() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -4941,6 +4978,7 @@ fn broker_expires_day_limit_buy_at_market_close() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -5867,6 +5905,7 @@ fn broker_uses_limit_price_slippage_for_limit_orders() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -5905,6 +5944,7 @@ fn broker_rejects_limit_buy_when_final_execution_price_reaches_upper_limit() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -5949,6 +5989,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() {
&mut value_portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -5974,6 +6015,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() {
&mut percent_portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -6010,6 +6052,7 @@ fn broker_cancels_open_order_by_order_id() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -6033,6 +6076,7 @@ fn broker_cancels_open_order_by_order_id() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -6080,6 +6124,7 @@ fn broker_emits_cancellation_reject_for_unknown_order() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -6188,6 +6233,7 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -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
}
@@ -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
}
}
}
File diff suppressed because one or more lines are too long
@@ -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
}
}
}
@@ -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"
}
}
]
}
}
}
@@ -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."
]
}
+36
View File
@@ -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.