合并主线数值校验与买入阶段约束
This commit is contained in:
@@ -380,6 +380,7 @@ pub struct BrokerSimulator<C, R> {
|
|||||||
runtime_intraday_start_time: Cell<Option<NaiveTime>>,
|
runtime_intraday_start_time: Cell<Option<NaiveTime>>,
|
||||||
runtime_intraday_end_time: Cell<Option<NaiveTime>>,
|
runtime_intraday_end_time: Cell<Option<NaiveTime>>,
|
||||||
runtime_decision_date: Cell<Option<NaiveDate>>,
|
runtime_decision_date: Cell<Option<NaiveDate>>,
|
||||||
|
runtime_buy_denials: RefCell<BTreeMap<String, String>>,
|
||||||
runtime_order_created_date: Cell<Option<NaiveDate>>,
|
runtime_order_created_date: Cell<Option<NaiveDate>>,
|
||||||
runtime_decision_total_equity: Cell<Option<f64>>,
|
runtime_decision_total_equity: Cell<Option<f64>>,
|
||||||
runtime_target_position_limit: Cell<Option<usize>>,
|
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_start_time: Cell::new(None),
|
||||||
runtime_intraday_end_time: Cell::new(None),
|
runtime_intraday_end_time: Cell::new(None),
|
||||||
runtime_decision_date: Cell::new(None),
|
runtime_decision_date: Cell::new(None),
|
||||||
|
runtime_buy_denials: RefCell::new(BTreeMap::new()),
|
||||||
runtime_order_created_date: Cell::new(None),
|
runtime_order_created_date: Cell::new(None),
|
||||||
runtime_decision_total_equity: Cell::new(None),
|
runtime_decision_total_equity: Cell::new(None),
|
||||||
runtime_target_position_limit: 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_start_time: Cell::new(None),
|
||||||
runtime_intraday_end_time: Cell::new(None),
|
runtime_intraday_end_time: Cell::new(None),
|
||||||
runtime_decision_date: Cell::new(None),
|
runtime_decision_date: Cell::new(None),
|
||||||
|
runtime_buy_denials: RefCell::new(BTreeMap::new()),
|
||||||
runtime_order_created_date: Cell::new(None),
|
runtime_order_created_date: Cell::new(None),
|
||||||
runtime_decision_total_equity: Cell::new(None),
|
runtime_decision_total_equity: Cell::new(None),
|
||||||
runtime_target_position_limit: Cell::new(None),
|
runtime_target_position_limit: Cell::new(None),
|
||||||
@@ -1385,6 +1388,7 @@ where
|
|||||||
decision: &StrategyDecision,
|
decision: &StrategyDecision,
|
||||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||||
let previous_decision_date = self.runtime_decision_date.get();
|
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_order_created_date = self.runtime_order_created_date.get();
|
||||||
let previous_decision_total_equity = self.runtime_decision_total_equity.get();
|
let previous_decision_total_equity = self.runtime_decision_total_equity.get();
|
||||||
self.runtime_decision_date.set(Some(decision_date));
|
self.runtime_decision_date.set(Some(decision_date));
|
||||||
@@ -1393,6 +1397,7 @@ where
|
|||||||
self.runtime_decision_total_equity
|
self.runtime_decision_total_equity
|
||||||
.set(decision_total_equity.filter(|equity| equity.is_finite() && *equity >= 0.0));
|
.set(decision_total_equity.filter(|equity| equity.is_finite() && *equity >= 0.0));
|
||||||
let result = self.execute_with_runtime_dates(date, portfolio, data, decision);
|
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_decision_date.set(previous_decision_date);
|
||||||
self.runtime_order_created_date
|
self.runtime_order_created_date
|
||||||
.set(previous_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()
|
let resets_queue_priority = target_limit_price.to_bits() != existing.limit_price.to_bits()
|
||||||
|| target_total_quantity > existing.requested_quantity;
|
|| target_total_quantity > existing.requested_quantity;
|
||||||
{
|
{
|
||||||
@@ -4189,6 +4206,9 @@ where
|
|||||||
if !rule.allowed {
|
if !rule.allowed {
|
||||||
return rule.reason;
|
return rule.reason;
|
||||||
}
|
}
|
||||||
|
if let Some(reason) = self.runtime_buy_denials.borrow().get(symbol) {
|
||||||
|
return Some(reason.clone());
|
||||||
|
}
|
||||||
match self.market_fillable_quantity(
|
match self.market_fillable_quantity(
|
||||||
snapshot,
|
snapshot,
|
||||||
OrderSide::Buy,
|
OrderSide::Buy,
|
||||||
@@ -6140,6 +6160,12 @@ where
|
|||||||
data.instrument(symbol),
|
data.instrument(symbol),
|
||||||
algo_request,
|
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 {
|
if !rule.allowed {
|
||||||
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
|
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
|
||||||
let status = match rule.reason.as_deref() {
|
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 {
|
fn next_open_sell_decision() -> StrategyDecision {
|
||||||
StrategyDecision {
|
StrategyDecision {
|
||||||
order_intents: vec![OrderIntent::Shares {
|
order_intents: vec![OrderIntent::Shares {
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use serde::{Deserialize, Serialize};
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Instrument {
|
pub struct Instrument {
|
||||||
pub symbol: String,
|
pub symbol: String,
|
||||||
@@ -70,7 +81,19 @@ fn default_status() -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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 {
|
fn instrument(board: &str, round_lot: u32) -> Instrument {
|
||||||
Instrument {
|
Instrument {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub(crate) enum ValueType {
|
|||||||
pub(crate) enum Value {
|
pub(crate) enum Value {
|
||||||
Number(f64),
|
Number(f64),
|
||||||
Boolean(bool),
|
Boolean(bool),
|
||||||
|
Missing(ValueType),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Value {
|
impl Value {
|
||||||
@@ -18,20 +19,28 @@ impl Value {
|
|||||||
match self {
|
match self {
|
||||||
Self::Number(_) => ValueType::Number,
|
Self::Number(_) => ValueType::Number,
|
||||||
Self::Boolean(_) => ValueType::Boolean,
|
Self::Boolean(_) => ValueType::Boolean,
|
||||||
|
Self::Missing(value_type) => value_type,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn as_number(self) -> Option<f64> {
|
pub(crate) fn as_number(self) -> Option<f64> {
|
||||||
match self {
|
match self {
|
||||||
Self::Number(value) => Some(value),
|
Self::Number(value) => Some(value),
|
||||||
Self::Boolean(_) => None,
|
Self::Boolean(_) | Self::Missing(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn as_bool(self) -> Option<bool> {
|
pub(crate) fn as_bool(self) -> Option<bool> {
|
||||||
match self {
|
match self {
|
||||||
Self::Boolean(value) => Some(value),
|
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,
|
LessEqual,
|
||||||
Greater,
|
Greater,
|
||||||
GreaterEqual,
|
GreaterEqual,
|
||||||
|
And,
|
||||||
|
Or,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -134,7 +145,7 @@ enum Instruction {
|
|||||||
Binary(BinaryOp),
|
Binary(BinaryOp),
|
||||||
Call { builtin: Builtin, argc: u8 },
|
Call { builtin: Builtin, argc: u8 },
|
||||||
JumpIfFalse(usize),
|
JumpIfFalse(usize),
|
||||||
JumpIfTrue(usize),
|
ShortCircuit { on: bool, target: usize },
|
||||||
Jump(usize),
|
Jump(usize),
|
||||||
Return,
|
Return,
|
||||||
}
|
}
|
||||||
@@ -165,7 +176,7 @@ impl Program {
|
|||||||
let mut pc = 0usize;
|
let mut pc = 0usize;
|
||||||
while let Some(instruction) = self.instructions.get(pc) {
|
while let Some(instruction) = self.instructions.get(pc) {
|
||||||
match *instruction {
|
match *instruction {
|
||||||
Instruction::Push(value) => scratch.stack.push(value),
|
Instruction::Push(value) => scratch.stack.push(value.normalized()),
|
||||||
Instruction::LoadVariable(index) => {
|
Instruction::LoadVariable(index) => {
|
||||||
let index = usize::from(index);
|
let index = usize::from(index);
|
||||||
let cached = scratch.variables[index];
|
let cached = scratch.variables[index];
|
||||||
@@ -173,7 +184,8 @@ impl Program {
|
|||||||
Some(value) => value,
|
Some(value) => value,
|
||||||
None => {
|
None => {
|
||||||
let expected_type = self.variable_types[index];
|
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 {
|
if value.value_type() != expected_type {
|
||||||
return Err(EvalError::new(format!(
|
return Err(EvalError::new(format!(
|
||||||
"variable {} expected {:?}, got {:?}",
|
"variable {} expected {:?}, got {:?}",
|
||||||
@@ -219,15 +231,23 @@ impl Program {
|
|||||||
scratch.stack.push(value);
|
scratch.stack.push(value);
|
||||||
}
|
}
|
||||||
Instruction::JumpIfFalse(target) => {
|
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 {
|
if !condition {
|
||||||
pc = target;
|
pc = target;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Instruction::JumpIfTrue(target) => {
|
Instruction::ShortCircuit { on, target } => {
|
||||||
let condition = pop_bool(&mut scratch.stack)?;
|
let condition =
|
||||||
if condition {
|
scratch.stack.last().copied().ok_or_else(|| {
|
||||||
|
EvalError::new("stack underflow during short circuit")
|
||||||
|
})?;
|
||||||
|
if condition.as_bool() == Some(on) {
|
||||||
pc = target;
|
pc = target;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -284,31 +304,56 @@ fn pop(stack: &mut Vec<Value>) -> Result<Value, EvalError> {
|
|||||||
stack.pop().ok_or_else(|| EvalError::new("stack underflow"))
|
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> {
|
fn number(value: Value) -> Result<f64, EvalError> {
|
||||||
|
if value == Value::Missing(ValueType::Number) {
|
||||||
|
return Ok(f64::NAN);
|
||||||
|
}
|
||||||
value
|
value
|
||||||
.as_number()
|
.as_number()
|
||||||
.ok_or_else(|| EvalError::new("numeric operand required"))
|
.ok_or_else(|| EvalError::new("numeric operand required"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn eval_unary(operator: UnaryOp, value: Value) -> Result<Value, EvalError> {
|
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::Negate => Ok(Value::Number(-number(value)?)),
|
||||||
UnaryOp::Not => {
|
UnaryOp::Not => {
|
||||||
Ok(Value::Boolean(!value.as_bool().ok_or_else(|| {
|
Ok(Value::Boolean(!value.as_bool().ok_or_else(|| {
|
||||||
EvalError::new("boolean operand required for !")
|
EvalError::new("boolean operand required for !")
|
||||||
})?))
|
})?))
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
Ok(result?.normalized())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn eval_binary(operator: BinaryOp, lhs: Value, rhs: Value) -> Result<Value, EvalError> {
|
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::Add => Ok(Value::Number(number(lhs)? + number(rhs)?)),
|
||||||
BinaryOp::Subtract => 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)?)),
|
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,
|
(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 {
|
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"))
|
.ok_or_else(|| EvalError::new("missing builtin argument"))
|
||||||
.and_then(number)
|
.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::Round => Value::Number(numeric(0)?.round()),
|
||||||
Builtin::Floor => Value::Number(numeric(0)?.floor()),
|
Builtin::Floor => Value::Number(numeric(0)?.floor()),
|
||||||
Builtin::Ceil => Value::Number(numeric(0)?.ceil()),
|
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::Pow => Value::Number(numeric(0)?.powf(numeric(1)?)),
|
||||||
Builtin::Log => Value::Number(numeric(0)?.ln()),
|
Builtin::Log => Value::Number(numeric(0)?.ln()),
|
||||||
Builtin::Exp => Value::Number(numeric(0)?.exp()),
|
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 => {
|
Builtin::Between => {
|
||||||
let value = numeric(0)?;
|
let value = numeric(0)?;
|
||||||
Value::Boolean(value >= numeric(1)? && value <= numeric(2)?)
|
Value::Boolean(value >= numeric(1)? && value <= numeric(2)?)
|
||||||
@@ -417,13 +497,15 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result<Value, EvalError> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
Builtin::Iff => {
|
Builtin::Iff => {
|
||||||
let condition = args
|
let condition = match args.first().copied() {
|
||||||
.first()
|
Some(Value::Boolean(value)) => value,
|
||||||
.and_then(|value| value.as_bool())
|
Some(Value::Missing(ValueType::Boolean)) => false,
|
||||||
.ok_or_else(|| EvalError::new("iff condition must be boolean"))?;
|
_ => return Err(EvalError::new("iff condition must be boolean")),
|
||||||
|
};
|
||||||
if condition { args[1] } else { args[2] }
|
if condition { args[1] } else { args[2] }
|
||||||
}
|
}
|
||||||
})
|
};
|
||||||
|
Ok(result.normalized())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
@@ -1119,24 +1201,20 @@ where
|
|||||||
let lhs_type = self.expression(lhs)?;
|
let lhs_type = self.expression(lhs)?;
|
||||||
require_type(lhs_type, ValueType::Boolean, position)?;
|
require_type(lhs_type, ValueType::Boolean, position)?;
|
||||||
let branch = self.instructions.len();
|
let branch = self.instructions.len();
|
||||||
self.instructions.push(match operator {
|
self.instructions.push(Instruction::ShortCircuit {
|
||||||
ParsedBinaryOp::And => Instruction::JumpIfFalse(usize::MAX),
|
on: operator == ParsedBinaryOp::Or,
|
||||||
ParsedBinaryOp::Or => Instruction::JumpIfTrue(usize::MAX),
|
target: usize::MAX,
|
||||||
_ => unreachable!(),
|
|
||||||
});
|
});
|
||||||
let rhs_type = self.expression(rhs)?;
|
let rhs_type = self.expression(rhs)?;
|
||||||
require_type(rhs_type, ValueType::Boolean, rhs.position())?;
|
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
|
self.instructions
|
||||||
.push(Instruction::Push(Value::Boolean(matches!(
|
.push(Instruction::Binary(if operator == ParsedBinaryOp::And {
|
||||||
operator,
|
BinaryOp::And
|
||||||
ParsedBinaryOp::Or
|
} else {
|
||||||
))));
|
BinaryOp::Or
|
||||||
|
}));
|
||||||
let end_target = self.instructions.len();
|
let end_target = self.instructions.len();
|
||||||
patch_jump(&mut self.instructions, branch, short_target)?;
|
patch_jump(&mut self.instructions, branch, end_target)?;
|
||||||
patch_jump(&mut self.instructions, end_jump, end_target)?;
|
|
||||||
return Ok(ValueType::Boolean);
|
return Ok(ValueType::Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1285,7 +1363,7 @@ fn patch_jump(
|
|||||||
};
|
};
|
||||||
match instruction {
|
match instruction {
|
||||||
Instruction::JumpIfFalse(value)
|
Instruction::JumpIfFalse(value)
|
||||||
| Instruction::JumpIfTrue(value)
|
| Instruction::ShortCircuit { target: value, .. }
|
||||||
| Instruction::Jump(value) => {
|
| Instruction::Jump(value) => {
|
||||||
*value = target;
|
*value = target;
|
||||||
Ok(())
|
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]
|
#[test]
|
||||||
fn short_circuit_does_not_resolve_unused_variable() {
|
fn short_circuit_does_not_resolve_unused_variable() {
|
||||||
let program = compile("false && missing", |name| {
|
let program = compile("false && missing", |name| {
|
||||||
|
|||||||
@@ -605,6 +605,7 @@ pub struct PlatformExprStrategyConfig {
|
|||||||
pub selection_limit_expr: String,
|
pub selection_limit_expr: String,
|
||||||
pub selection_candidate_limit_expr: String,
|
pub selection_candidate_limit_expr: String,
|
||||||
pub stock_filter_expr: String,
|
pub stock_filter_expr: String,
|
||||||
|
pub buy_filter_expr: String,
|
||||||
pub buy_scale_expr: String,
|
pub buy_scale_expr: String,
|
||||||
pub exposure_expr: String,
|
pub exposure_expr: String,
|
||||||
pub position_exposure_schedule: BTreeMap<NaiveDate, f64>,
|
pub position_exposure_schedule: BTreeMap<NaiveDate, f64>,
|
||||||
@@ -649,6 +650,7 @@ pub struct PlatformExprStrategyConfig {
|
|||||||
pub matching_type: MatchingType,
|
pub matching_type: MatchingType,
|
||||||
pub quote_quantity_limit: bool,
|
pub quote_quantity_limit: bool,
|
||||||
pub current_day_precomputed_factors: bool,
|
pub current_day_precomputed_factors: bool,
|
||||||
|
pub completed_session_factor_fields: BTreeSet<String>,
|
||||||
pub candidate_symbols_by_date: BTreeMap<NaiveDate, BTreeSet<String>>,
|
pub candidate_symbols_by_date: BTreeMap<NaiveDate, BTreeSet<String>>,
|
||||||
pub intraday_execution_time: Option<NaiveTime>,
|
pub intraday_execution_time: Option<NaiveTime>,
|
||||||
pub explicit_action_times: Vec<NaiveTime>,
|
pub explicit_action_times: Vec<NaiveTime>,
|
||||||
@@ -683,6 +685,7 @@ impl PlatformExprStrategyConfig {
|
|||||||
selection_limit_expr: "1".to_string(),
|
selection_limit_expr: "1".to_string(),
|
||||||
selection_candidate_limit_expr: String::new(),
|
selection_candidate_limit_expr: String::new(),
|
||||||
stock_filter_expr: String::new(),
|
stock_filter_expr: String::new(),
|
||||||
|
buy_filter_expr: String::new(),
|
||||||
buy_scale_expr: "1.0".to_string(),
|
buy_scale_expr: "1.0".to_string(),
|
||||||
exposure_expr: "1.0".to_string(),
|
exposure_expr: "1.0".to_string(),
|
||||||
position_exposure_schedule: BTreeMap::new(),
|
position_exposure_schedule: BTreeMap::new(),
|
||||||
@@ -727,6 +730,7 @@ impl PlatformExprStrategyConfig {
|
|||||||
matching_type: MatchingType::CurrentBarClose,
|
matching_type: MatchingType::CurrentBarClose,
|
||||||
quote_quantity_limit: true,
|
quote_quantity_limit: true,
|
||||||
current_day_precomputed_factors: false,
|
current_day_precomputed_factors: false,
|
||||||
|
completed_session_factor_fields: BTreeSet::new(),
|
||||||
candidate_symbols_by_date: BTreeMap::new(),
|
candidate_symbols_by_date: BTreeMap::new(),
|
||||||
intraday_execution_time: None,
|
intraday_execution_time: None,
|
||||||
explicit_action_times: Vec::new(),
|
explicit_action_times: Vec::new(),
|
||||||
@@ -1384,6 +1388,15 @@ pub struct PlatformSelectionQuotePlan {
|
|||||||
pub diagnostics: Vec<String>,
|
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 {
|
fn platform_safe_div(lhs: f64, rhs: f64, fallback: f64) -> f64 {
|
||||||
if rhs.abs() <= f64::EPSILON {
|
if rhs.abs() <= f64::EPSILON {
|
||||||
fallback
|
fallback
|
||||||
@@ -1396,6 +1409,26 @@ fn platform_safe_div_default(lhs: f64, rhs: f64) -> f64 {
|
|||||||
platform_safe_div(lhs, rhs, 0.0)
|
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 {
|
impl PlatformExprStrategy {
|
||||||
fn market_cap_storage_to_strategy_unit(value: f64) -> f64 {
|
fn market_cap_storage_to_strategy_unit(value: f64) -> f64 {
|
||||||
value
|
value
|
||||||
@@ -1541,21 +1574,50 @@ impl PlatformExprStrategy {
|
|||||||
|
|
||||||
pub fn new(config: PlatformExprStrategyConfig) -> Self {
|
pub fn new(config: PlatformExprStrategyConfig) -> Self {
|
||||||
let mut engine = Engine::new();
|
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("round", |value: f64| value.round());
|
||||||
engine.register_fn("floor", |value: f64| value.floor());
|
engine.register_fn("floor", |value: f64| value.floor());
|
||||||
engine.register_fn("ceil", |value: f64| value.ceil());
|
engine.register_fn("ceil", |value: f64| value.ceil());
|
||||||
engine.register_fn("abs", |value: f64| value.abs());
|
engine.register_fn("abs", |value: f64| value.abs());
|
||||||
engine.register_fn("min", |lhs: f64, rhs: f64| lhs.min(rhs));
|
engine.register_fn("min", |lhs: f64, rhs: f64| {
|
||||||
engine.register_fn("max", |lhs: f64, rhs: f64| lhs.max(rhs));
|
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("sqrt", |value: f64| value.sqrt());
|
||||||
engine.register_fn("pow", |lhs: f64, rhs: f64| lhs.powf(rhs));
|
engine.register_fn("pow", |lhs: f64, rhs: f64| lhs.powf(rhs));
|
||||||
engine.register_fn("log", |value: f64| value.ln());
|
engine.register_fn("log", |value: f64| value.ln());
|
||||||
engine.register_fn("exp", |value: f64| value.exp());
|
engine.register_fn("exp", |value: f64| value.exp());
|
||||||
engine.register_fn("clamp", |value: f64, low: f64, high: f64| {
|
engine.register_fn("clamp", |value: f64, low: f64, high: f64| -> Result<f64, Box<rhai::EvalAltResult>> {
|
||||||
value.clamp(low, high)
|
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| {
|
engine.register_fn("between", |value: f64, low: f64, high: f64| -> Result<bool, Box<rhai::EvalAltResult>> {
|
||||||
value >= low && value <= high
|
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(
|
engine.register_fn(
|
||||||
"nz",
|
"nz",
|
||||||
@@ -1778,6 +1840,7 @@ impl PlatformExprStrategy {
|
|||||||
"stock_filter_expr".to_string(),
|
"stock_filter_expr".to_string(),
|
||||||
self.config.stock_filter_expr.as_str(),
|
self.config.stock_filter_expr.as_str(),
|
||||||
),
|
),
|
||||||
|
("buy_filter_expr".to_string(), self.config.buy_filter_expr.as_str()),
|
||||||
(
|
(
|
||||||
"buy_scale_expr".to_string(),
|
"buy_scale_expr".to_string(),
|
||||||
self.config.buy_scale_expr.as_str(),
|
self.config.buy_scale_expr.as_str(),
|
||||||
@@ -4659,7 +4722,7 @@ impl PlatformExprStrategy {
|
|||||||
} else if intraday_same_day_factor {
|
} else if intraday_same_day_factor {
|
||||||
f64::NAN
|
f64::NAN
|
||||||
} else {
|
} 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 market_cap_bn = decision_market_cap_bn(factor);
|
||||||
let free_float_cap_bn = decision_free_float_cap_bn(factor);
|
let free_float_cap_bn = decision_free_float_cap_bn(factor);
|
||||||
@@ -4702,6 +4765,23 @@ impl PlatformExprStrategy {
|
|||||||
} else {
|
} else {
|
||||||
BTreeMap::new()
|
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
|
if self.stock_extra_factors_required
|
||||||
&& (self.stock_extra_factor_map_required
|
&& (self.stock_extra_factor_map_required
|
||||||
|| self
|
|| self
|
||||||
@@ -4724,8 +4804,8 @@ impl PlatformExprStrategy {
|
|||||||
minute_volume: market.minute_volume as i64,
|
minute_volume: market.minute_volume as i64,
|
||||||
bid1_volume: market.bid1_volume as i64,
|
bid1_volume: market.bid1_volume as i64,
|
||||||
ask1_volume: market.ask1_volume as i64,
|
ask1_volume: market.ask1_volume as i64,
|
||||||
turnover_ratio: factor.turnover_ratio.unwrap_or(0.0),
|
turnover_ratio: factor.turnover_ratio.unwrap_or(f64::NAN),
|
||||||
effective_turnover_ratio: factor.effective_turnover_ratio.unwrap_or(0.0),
|
effective_turnover_ratio: factor.effective_turnover_ratio.unwrap_or(f64::NAN),
|
||||||
open: feature_market.day_open,
|
open: feature_market.day_open,
|
||||||
high: expression_high,
|
high: expression_high,
|
||||||
low: expression_low,
|
low: expression_low,
|
||||||
@@ -8280,11 +8360,31 @@ impl PlatformExprStrategy {
|
|||||||
day: &DayExpressionState,
|
day: &DayExpressionState,
|
||||||
stock: Option<&StockExpressionState>,
|
stock: Option<&StockExpressionState>,
|
||||||
position: Option<&PositionExpressionState>,
|
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> {
|
) -> Result<f64, BacktestError> {
|
||||||
if let Some(value) = self.eval_numeric_vm(ctx, expr, day, stock, position)? {
|
if let Some(value) = self.eval_numeric_vm(ctx, expr, day, stock, position)? {
|
||||||
return match value {
|
return match value {
|
||||||
NumericVmValue::Number(number) => Ok(number),
|
NumericVmValue::Number(number) => Ok(number),
|
||||||
NumericVmValue::Boolean(boolean) => Ok(if boolean { 1.0 } else { 0.0 }),
|
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)?;
|
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)? {
|
if let Some(value) = self.eval_numeric_vm(ctx, expr, day, stock, position)? {
|
||||||
return match value {
|
return match value {
|
||||||
NumericVmValue::Boolean(boolean) => Ok(boolean),
|
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)?;
|
let value = self.eval_dynamic(ctx, expr, day, stock, position)?;
|
||||||
@@ -8322,7 +8423,7 @@ impl PlatformExprStrategy {
|
|||||||
return Ok(boolean);
|
return Ok(boolean);
|
||||||
}
|
}
|
||||||
if let Some(number) = value.clone().try_cast::<f64>() {
|
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>() {
|
if let Some(number) = value.try_cast::<i64>() {
|
||||||
return Ok(number != 0);
|
return Ok(number != 0);
|
||||||
@@ -9871,6 +9972,7 @@ impl PlatformExprStrategy {
|
|||||||
)];
|
)];
|
||||||
diagnostics.extend(action_diagnostics);
|
diagnostics.extend(action_diagnostics);
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -10355,7 +10457,7 @@ impl PlatformExprStrategy {
|
|||||||
stock: &StockExpressionState,
|
stock: &StockExpressionState,
|
||||||
) -> Result<f64, BacktestError> {
|
) -> Result<f64, BacktestError> {
|
||||||
if self.rank_expr_present {
|
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),
|
Ok(value) => Ok(value),
|
||||||
Err(error) if Self::is_missing_rolling_mean_error(&error) => Ok(f64::NAN),
|
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),
|
Err(error) if Self::is_non_numeric_expr_error(&error) => Ok(f64::NAN),
|
||||||
@@ -10913,6 +11015,7 @@ impl PlatformExprStrategy {
|
|||||||
let expressions = [
|
let expressions = [
|
||||||
config.prelude.as_str(),
|
config.prelude.as_str(),
|
||||||
config.stock_filter_expr.as_str(),
|
config.stock_filter_expr.as_str(),
|
||||||
|
config.buy_filter_expr.as_str(),
|
||||||
config.buy_scale_expr.as_str(),
|
config.buy_scale_expr.as_str(),
|
||||||
config.stop_loss_expr.as_str(),
|
config.stop_loss_expr.as_str(),
|
||||||
config.take_profit_expr.as_str(),
|
config.take_profit_expr.as_str(),
|
||||||
@@ -10945,6 +11048,7 @@ impl PlatformExprStrategy {
|
|||||||
for expr in [
|
for expr in [
|
||||||
config.prelude.as_str(),
|
config.prelude.as_str(),
|
||||||
config.stock_filter_expr.as_str(),
|
config.stock_filter_expr.as_str(),
|
||||||
|
config.buy_filter_expr.as_str(),
|
||||||
config.buy_scale_expr.as_str(),
|
config.buy_scale_expr.as_str(),
|
||||||
config.stop_loss_expr.as_str(),
|
config.stop_loss_expr.as_str(),
|
||||||
config.take_profit_expr.as_str(),
|
config.take_profit_expr.as_str(),
|
||||||
@@ -10992,6 +11096,7 @@ impl PlatformExprStrategy {
|
|||||||
for expr in [
|
for expr in [
|
||||||
config.prelude.as_str(),
|
config.prelude.as_str(),
|
||||||
config.stock_filter_expr.as_str(),
|
config.stock_filter_expr.as_str(),
|
||||||
|
config.buy_filter_expr.as_str(),
|
||||||
config.buy_scale_expr.as_str(),
|
config.buy_scale_expr.as_str(),
|
||||||
config.stop_loss_expr.as_str(),
|
config.stop_loss_expr.as_str(),
|
||||||
config.take_profit_expr.as_str(),
|
config.take_profit_expr.as_str(),
|
||||||
@@ -11032,6 +11137,9 @@ impl PlatformExprStrategy {
|
|||||||
if Self::expr_requires_stock_extra_factors(
|
if Self::expr_requires_stock_extra_factors(
|
||||||
&config.stock_filter_expr,
|
&config.stock_filter_expr,
|
||||||
prelude_declared_identifiers,
|
prelude_declared_identifiers,
|
||||||
|
) || Self::expr_requires_stock_extra_factors(
|
||||||
|
&config.buy_filter_expr,
|
||||||
|
prelude_declared_identifiers,
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -11061,6 +11169,7 @@ impl PlatformExprStrategy {
|
|||||||
[
|
[
|
||||||
config.prelude.as_str(),
|
config.prelude.as_str(),
|
||||||
config.stock_filter_expr.as_str(),
|
config.stock_filter_expr.as_str(),
|
||||||
|
config.buy_filter_expr.as_str(),
|
||||||
config.buy_scale_expr.as_str(),
|
config.buy_scale_expr.as_str(),
|
||||||
config.stop_loss_expr.as_str(),
|
config.stop_loss_expr.as_str(),
|
||||||
config.take_profit_expr.as_str(),
|
config.take_profit_expr.as_str(),
|
||||||
@@ -11095,6 +11204,11 @@ impl PlatformExprStrategy {
|
|||||||
&config.stock_filter_expr,
|
&config.stock_filter_expr,
|
||||||
prelude_declared_identifiers,
|
prelude_declared_identifiers,
|
||||||
);
|
);
|
||||||
|
Self::collect_stock_extra_factor_identifiers(
|
||||||
|
&mut identifiers,
|
||||||
|
&config.buy_filter_expr,
|
||||||
|
prelude_declared_identifiers,
|
||||||
|
);
|
||||||
for expr in [
|
for expr in [
|
||||||
config.buy_scale_expr.as_str(),
|
config.buy_scale_expr.as_str(),
|
||||||
config.stop_loss_expr.as_str(),
|
config.stop_loss_expr.as_str(),
|
||||||
@@ -12069,10 +12183,11 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
.is_some();
|
.is_some();
|
||||||
if scheduled_rotation {
|
if scheduled_rotation {
|
||||||
self.executing_scheduled_rotation = true;
|
self.executing_scheduled_rotation = true;
|
||||||
let rotation = self.on_day(ctx);
|
let rotation = self.compute_day_decision(ctx);
|
||||||
self.executing_scheduled_rotation = false;
|
self.executing_scheduled_rotation = false;
|
||||||
decision.merge_from(rotation?);
|
decision.merge_from(rotation?);
|
||||||
}
|
}
|
||||||
|
self.attach_buy_denials(ctx, &mut decision)?;
|
||||||
Ok(decision)
|
Ok(decision)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12121,12 +12236,55 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
&& self.config.explicit_action_schedule.is_none()
|
&& self.config.explicit_action_schedule.is_none()
|
||||||
&& self.unscheduled_explicit_actions_are_due(ctx.decision_date)
|
&& 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())
|
Ok(StrategyDecision::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
|
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
|
if self.config.rotation_enabled
|
||||||
&& self
|
&& self
|
||||||
.config
|
.config
|
||||||
@@ -12529,6 +12687,7 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
return Ok(StrategyDecision {
|
return Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols,
|
exit_symbols,
|
||||||
@@ -13805,6 +13964,7 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
];
|
];
|
||||||
|
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols,
|
exit_symbols,
|
||||||
@@ -13903,6 +14063,164 @@ mod tests {
|
|||||||
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
|
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]
|
#[test]
|
||||||
fn target_scale_replenishment_preserves_strategy_cash_allocation() {
|
fn target_scale_replenishment_preserves_strategy_cash_allocation() {
|
||||||
let scale = 30.0 / 31.0;
|
let scale = 30.0 / 31.0;
|
||||||
@@ -16260,8 +16578,8 @@ mod tests {
|
|||||||
market_cap_bn: 12.0,
|
market_cap_bn: 12.0,
|
||||||
free_float_cap_bn: 10.0,
|
free_float_cap_bn: 10.0,
|
||||||
pe_ttm: 8.0,
|
pe_ttm: 8.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: None,
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: None,
|
||||||
adjustment_factor_backward1: None,
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
@@ -16271,8 +16589,8 @@ mod tests {
|
|||||||
market_cap_bn: 12.0,
|
market_cap_bn: 12.0,
|
||||||
free_float_cap_bn: 10.0,
|
free_float_cap_bn: 10.0,
|
||||||
pe_ttm: 8.0,
|
pe_ttm: 8.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(0.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(0.0),
|
||||||
adjustment_factor_backward1: None,
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::from([
|
extra_factors: BTreeMap::from([
|
||||||
("model_score".into(), 2.0),
|
("model_score".into(), 2.0),
|
||||||
@@ -16337,6 +16655,51 @@ mod tests {
|
|||||||
.expect("present stock state");
|
.expect("present stock state");
|
||||||
|
|
||||||
assert!(day.available_factor_names.contains("model_score"));
|
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!(
|
assert!(
|
||||||
!strategy
|
!strategy
|
||||||
.stock_passes_expr(&ctx, &day, &missing_stock)
|
.stock_passes_expr(&ctx, &day, &missing_stock)
|
||||||
@@ -23702,7 +24065,7 @@ mod tests {
|
|||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
adjustment_factor_backward1: None,
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::from([("amount".into(), 20_000_000.0)]),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
date: factor_date,
|
date: factor_date,
|
||||||
@@ -23713,7 +24076,7 @@ mod tests {
|
|||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
adjustment_factor_backward1: None,
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::from([("amount".into(), 10_000_000.0)]),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
date: decision_date,
|
date: decision_date,
|
||||||
@@ -23864,6 +24227,10 @@ mod tests {
|
|||||||
.stock_state_with_factor_date(&ctx, decision_date, decision_date, limit_symbol)
|
.stock_state_with_factor_date(&ctx, decision_date, decision_date, limit_symbol)
|
||||||
.expect("next-open decision state");
|
.expect("next-open decision state");
|
||||||
assert_eq!(decision_day_state.amount, 30_000_000.0);
|
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");
|
let decision = strategy.on_day(&ctx).expect("platform decision");
|
||||||
|
|
||||||
|
|||||||
@@ -977,6 +977,8 @@ pub struct StrategyExpressionOrderingConfig {
|
|||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct StrategyExpressionTradingConfig {
|
pub struct StrategyExpressionTradingConfig {
|
||||||
|
#[serde(default, alias = "buy_filter_expr")]
|
||||||
|
pub buy_filter_expr: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stage: Option<String>,
|
pub stage: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -1785,6 +1787,23 @@ pub fn platform_expr_config_from_spec(
|
|||||||
let Some(spec) = strategy_spec else {
|
let Some(spec) = strategy_spec else {
|
||||||
return Ok(cfg);
|
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_short_explicit = false;
|
||||||
let mut benchmark_long_explicit = false;
|
let mut benchmark_long_explicit = false;
|
||||||
let mut stock_short_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(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
|
if let Some(expr) = trading
|
||||||
.refresh_rate_expr
|
.refresh_rate_expr
|
||||||
.as_ref()
|
.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 {
|
fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String {
|
||||||
let has_suffix = symbol.trim().rsplit_once('.').is_some();
|
if raw_board.and_then(crate::instrument::listed_sector_is_kcb) == Some(true) {
|
||||||
if has_suffix && symbol_is_kcb(symbol) {
|
|
||||||
return "KSH".to_string();
|
return "KSH".to_string();
|
||||||
}
|
}
|
||||||
let normalized = raw_board
|
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('.') {
|
if let Some((_, suffix)) = symbol.rsplit_once('.') {
|
||||||
return suffix.to_ascii_uppercase();
|
return suffix.to_ascii_uppercase();
|
||||||
}
|
}
|
||||||
if symbol_is_kcb(symbol) {
|
|
||||||
return "KSH".to_string();
|
|
||||||
}
|
|
||||||
if symbol.starts_with('8') || symbol.starts_with('4') {
|
if symbol.starts_with('8') || symbol.starts_with('4') {
|
||||||
return "BJ".to_string();
|
return "BJ".to_string();
|
||||||
}
|
}
|
||||||
@@ -3098,27 +3116,46 @@ fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String {
|
|||||||
"UNK".to_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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn normalize_board_classifies_kcb_by_688_689_sh_suffix_only() {
|
fn parses_buy_filter_as_a_separate_trading_condition() {
|
||||||
assert_eq!(normalize_board("688001.SH", None), "KSH");
|
let cfg = platform_expr_config_from_value("buy-guard", "000001.SZ", &serde_json::json!({
|
||||||
assert_eq!(normalize_board("689001.SH", None), "KSH");
|
"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("688001.BJ", None), "BJ");
|
||||||
assert_eq!(normalize_board("689001.SZ", None), "SZ");
|
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", 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]
|
#[test]
|
||||||
|
|||||||
@@ -397,7 +397,7 @@ impl ChinaAShareRiskControl {
|
|||||||
RiskCheckScope::Buy => config.static_rules.reject_kcb_buy,
|
RiskCheckScope::Buy => config.static_rules.reject_kcb_buy,
|
||||||
RiskCheckScope::Sell => false,
|
RiskCheckScope::Sell => false,
|
||||||
};
|
};
|
||||||
if reject_kcb && (candidate.is_kcb || symbol_is_kcb(&candidate.symbol)) {
|
if reject_kcb && candidate.is_kcb {
|
||||||
return Some("kcb");
|
return Some("kcb");
|
||||||
}
|
}
|
||||||
let reject_bjse = match scope {
|
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 {
|
fn symbol_is_bjse(symbol: &str) -> bool {
|
||||||
let normalized = symbol.trim().to_ascii_uppercase();
|
let normalized = symbol.trim().to_ascii_uppercase();
|
||||||
normalized.ends_with(".BJ") || normalized.ends_with(".BSE") || normalized.ends_with(".BE")
|
normalized.ends_with(".BJ") || normalized.ends_with(".BSE") || normalized.ends_with(".BE")
|
||||||
@@ -1009,6 +1004,24 @@ mod tests {
|
|||||||
assert_eq!(configured_reason, None);
|
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]
|
#[test]
|
||||||
fn st_and_star_st_filters_are_independent() {
|
fn st_and_star_st_filters_are_independent() {
|
||||||
let date = d(2025, 1, 2);
|
let date = d(2025, 1, 2);
|
||||||
@@ -1139,6 +1152,7 @@ mod tests {
|
|||||||
let date = d(2025, 1, 2);
|
let date = d(2025, 1, 2);
|
||||||
let mut candidate = candidate(date);
|
let mut candidate = candidate(date);
|
||||||
candidate.symbol = "688506.SH".to_string();
|
candidate.symbol = "688506.SH".to_string();
|
||||||
|
candidate.is_kcb = true;
|
||||||
candidate.risk_level_code = Some("missing_risk_state".to_string());
|
candidate.risk_level_code = Some("missing_risk_state".to_string());
|
||||||
let market = market(date, 6.27, 5.63);
|
let market = market(date, 6.27, 5.63);
|
||||||
let mut config = FidcRiskControlConfig::default();
|
let mut config = FidcRiskControlConfig::default();
|
||||||
|
|||||||
@@ -977,6 +977,7 @@ fn safe_ratio(numerator: f64, denominator: f64) -> f64 {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct StrategyDecision {
|
pub struct StrategyDecision {
|
||||||
|
pub buy_denials: BTreeMap<String, String>,
|
||||||
pub rebalance: bool,
|
pub rebalance: bool,
|
||||||
pub target_weights: BTreeMap<String, f64>,
|
pub target_weights: BTreeMap<String, f64>,
|
||||||
pub exit_symbols: BTreeSet<String>,
|
pub exit_symbols: BTreeSet<String>,
|
||||||
@@ -987,7 +988,20 @@ pub struct StrategyDecision {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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) {
|
pub fn merge_from(&mut self, mut other: StrategyDecision) {
|
||||||
|
self.buy_denials.append(&mut other.buy_denials);
|
||||||
self.rebalance |= other.rebalance;
|
self.rebalance |= other.rebalance;
|
||||||
self.target_weights.append(&mut other.target_weights);
|
self.target_weights.append(&mut other.target_weights);
|
||||||
self.exit_symbols.append(&mut other.exit_symbols);
|
self.exit_symbols.append(&mut other.exit_symbols);
|
||||||
@@ -998,7 +1012,8 @@ impl StrategyDecision {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
!self.rebalance
|
self.buy_denials.is_empty()
|
||||||
|
&& !self.rebalance
|
||||||
&& self.target_weights.is_empty()
|
&& self.target_weights.is_empty()
|
||||||
&& self.exit_symbols.is_empty()
|
&& self.exit_symbols.is_empty()
|
||||||
&& self.order_intents.is_empty()
|
&& self.order_intents.is_empty()
|
||||||
@@ -1214,6 +1229,42 @@ pub enum OrderIntent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
pub fn with_time_in_force(self, time_in_force: OrderTimeInForce) -> Self {
|
||||||
match self {
|
match self {
|
||||||
Self::WithTimeInForce { intent, .. } => Self::WithTimeInForce {
|
Self::WithTimeInForce { intent, .. } => Self::WithTimeInForce {
|
||||||
@@ -1569,6 +1620,7 @@ impl Strategy for CnSmallCapRotationStrategy {
|
|||||||
if self.config.in_skip_window(ctx.decision_date) {
|
if self.config.in_skip_window(ctx.decision_date) {
|
||||||
self.last_gross_exposure = Some(0.0);
|
self.last_gross_exposure = Some(0.0);
|
||||||
return Ok(StrategyDecision {
|
return Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: true,
|
rebalance: true,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
|
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
|
||||||
@@ -1590,6 +1642,7 @@ impl Strategy for CnSmallCapRotationStrategy {
|
|||||||
if message.contains("signal series insufficient") =>
|
if message.contains("signal series insufficient") =>
|
||||||
{
|
{
|
||||||
return Ok(StrategyDecision {
|
return Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -1765,6 +1818,7 @@ impl Strategy for CnSmallCapRotationStrategy {
|
|||||||
self.last_gross_exposure = Some(gross_exposure);
|
self.last_gross_exposure = Some(gross_exposure);
|
||||||
|
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance,
|
rebalance,
|
||||||
target_weights,
|
target_weights,
|
||||||
exit_symbols,
|
exit_symbols,
|
||||||
@@ -2773,6 +2827,7 @@ impl Strategy for OmniMicroCapStrategy {
|
|||||||
let lagged_execution = ctx.is_lagged_execution();
|
let lagged_execution = ctx.is_lagged_execution();
|
||||||
if self.config.in_skip_window(signal_date) {
|
if self.config.in_skip_window(signal_date) {
|
||||||
return Ok(StrategyDecision {
|
return Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
|
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
|
||||||
@@ -2803,6 +2858,7 @@ impl Strategy for OmniMicroCapStrategy {
|
|||||||
if message.contains("insufficient benchmark") =>
|
if message.contains("insufficient benchmark") =>
|
||||||
{
|
{
|
||||||
return Ok(StrategyDecision {
|
return Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -3013,6 +3069,7 @@ impl Strategy for OmniMicroCapStrategy {
|
|||||||
];
|
];
|
||||||
|
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols,
|
exit_symbols,
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ impl Strategy for BuyAndHoldStrategy {
|
|||||||
ctx: &StrategyContext<'_>,
|
ctx: &StrategyContext<'_>,
|
||||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ impl Strategy for BuyThenHoldStrategy {
|
|||||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||||
if ctx.decision_date == d(2025, 1, 2) && ctx.portfolio.position("000001.SZ").is_none() {
|
if ctx.decision_date == d(2025, 1, 2) && ctx.portfolio.position("000001.SZ").is_none() {
|
||||||
return Ok(StrategyDecision {
|
return Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ impl Strategy for HookProbeStrategy {
|
|||||||
.borrow_mut()
|
.borrow_mut()
|
||||||
.push(format!("on_day:{}", ctx.execution_date));
|
.push(format!("on_day:{}", ctx.execution_date));
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -334,6 +335,7 @@ impl Strategy for AuctionOrderStrategy {
|
|||||||
_ctx: &StrategyContext<'_>,
|
_ctx: &StrategyContext<'_>,
|
||||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -377,6 +379,7 @@ impl Strategy for FuturesOrderStrategy {
|
|||||||
return Ok(StrategyDecision::default());
|
return Ok(StrategyDecision::default());
|
||||||
}
|
}
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -720,6 +723,7 @@ impl Strategy for LimitCarryStrategy {
|
|||||||
}
|
}
|
||||||
self.issued = true;
|
self.issued = true;
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -803,6 +807,7 @@ impl Strategy for UniverseDirectiveStrategy {
|
|||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
};
|
};
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -844,6 +849,7 @@ impl Strategy for MinuteProbeStrategy {
|
|||||||
_ctx: &StrategyContext<'_>,
|
_ctx: &StrategyContext<'_>,
|
||||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -885,6 +891,7 @@ impl Strategy for MinuteProbeStrategy {
|
|||||||
}
|
}
|
||||||
self.ordered = true;
|
self.ordered = true;
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -987,6 +994,7 @@ impl Strategy for OrderInspectionStrategy {
|
|||||||
_ctx: &StrategyContext<'_>,
|
_ctx: &StrategyContext<'_>,
|
||||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -1030,6 +1038,7 @@ impl Strategy for AccountFlowStrategy {
|
|||||||
return Ok(StrategyDecision::default());
|
return Ok(StrategyDecision::default());
|
||||||
}
|
}
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -4134,6 +4143,7 @@ impl Strategy for BuyMissingRowThenHoldStrategy {
|
|||||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||||
if ctx.execution_date == d(2025, 5, 26) {
|
if ctx.execution_date == d(2025, 5, 26) {
|
||||||
return Ok(StrategyDecision {
|
return Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ fn execute_single_value_order(
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
data,
|
data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -399,6 +400,7 @@ fn broker_executes_explicit_order_value_buy() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -557,6 +559,7 @@ fn broker_delayed_limit_open_sell_uses_minute_price() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -687,6 +690,7 @@ fn broker_executes_order_shares_and_order_lots() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -806,6 +810,7 @@ fn broker_executes_target_shares_like_order_to() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -981,6 +986,7 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -1139,6 +1145,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -1254,6 +1261,7 @@ fn broker_executes_order_percent_and_target_percent() {
|
|||||||
&mut percent_portfolio,
|
&mut percent_portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -1278,6 +1286,7 @@ fn broker_executes_order_percent_and_target_percent() {
|
|||||||
&mut target_percent_portfolio,
|
&mut target_percent_portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -1380,6 +1389,7 @@ fn broker_uses_day_open_price_for_open_auction_matching() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -1487,6 +1497,7 @@ fn broker_open_auction_uses_auction_volume_without_quote_liquidity() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -1590,6 +1601,7 @@ fn broker_cancels_buy_when_open_hits_upper_limit() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -1707,6 +1719,7 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -1812,6 +1825,7 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -1933,6 +1947,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -2039,6 +2054,7 @@ fn broker_rejects_intraday_last_order_without_execution_quotes() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -2163,6 +2179,7 @@ fn broker_executes_intraday_last_on_start_quote_with_trade_delta() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -2282,6 +2299,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -2398,6 +2416,7 @@ fn broker_cancels_market_buy_when_minute_has_no_volume() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -2534,6 +2553,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -2707,6 +2727,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -2888,6 +2909,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -3036,6 +3058,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -3172,6 +3195,7 @@ fn broker_uses_best_own_price_for_intraday_matching() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -3290,6 +3314,7 @@ fn broker_uses_best_counterparty_price_for_intraday_matching() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -3461,6 +3486,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: true,
|
rebalance: true,
|
||||||
target_weights: BTreeMap::from([("000002.SZ".to_string(), 1.0)]),
|
target_weights: BTreeMap::from([("000002.SZ".to_string(), 1.0)]),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -3657,6 +3683,7 @@ fn rebalance_uses_day_open_for_open_auction_valuation() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: true,
|
rebalance: true,
|
||||||
target_weights: BTreeMap::from([
|
target_weights: BTreeMap::from([
|
||||||
("000001.SZ".to_string(), 0.5),
|
("000001.SZ".to_string(), 0.5),
|
||||||
@@ -3841,6 +3868,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: true,
|
rebalance: true,
|
||||||
target_weights: BTreeMap::from([
|
target_weights: BTreeMap::from([
|
||||||
("000001.SZ".to_string(), 0.2),
|
("000001.SZ".to_string(), 0.2),
|
||||||
@@ -4025,6 +4053,7 @@ fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: true,
|
rebalance: true,
|
||||||
target_weights: BTreeMap::from([
|
target_weights: BTreeMap::from([
|
||||||
("000001.SZ".to_string(), 0.48),
|
("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,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -4244,6 +4274,7 @@ fn broker_allows_bjse_quantities_above_minimum_without_round_lot_step() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -4350,6 +4381,7 @@ fn broker_allows_full_odd_lot_sell_when_liquidating_position() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -4483,6 +4515,7 @@ fn same_day_sell_then_rebuy_is_rejected_by_default() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -4627,6 +4660,7 @@ fn same_day_sell_then_rebuy_can_be_allowed_by_policy() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -4680,6 +4714,7 @@ fn broker_configured_policy_can_allow_upper_limit_buy() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -4726,6 +4761,7 @@ fn broker_configured_policy_can_allow_lower_limit_sell() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -4901,6 +4937,7 @@ fn broker_expires_day_limit_buy_at_market_close() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -4941,6 +4978,7 @@ fn broker_expires_day_limit_buy_at_market_close() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -5867,6 +5905,7 @@ fn broker_uses_limit_price_slippage_for_limit_orders() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -5905,6 +5944,7 @@ fn broker_rejects_limit_buy_when_final_execution_price_reaches_upper_limit() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -5949,6 +5989,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() {
|
|||||||
&mut value_portfolio,
|
&mut value_portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -5974,6 +6015,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() {
|
|||||||
&mut percent_portfolio,
|
&mut percent_portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -6010,6 +6052,7 @@ fn broker_cancels_open_order_by_order_id() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -6033,6 +6076,7 @@ fn broker_cancels_open_order_by_order_id() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -6080,6 +6124,7 @@ fn broker_emits_cancellation_reject_for_unknown_order() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -6188,6 +6233,7 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+531
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+424
@@ -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."
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user