Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87ad8d0014 | |||
| 4ceff5a3e8 | |||
| 14270c34f3 | |||
| b46227b863 | |||
| 9b84dbabc1 | |||
| e1ff46c97a | |||
| d4bb180841 | |||
| ae4bbe9e92 | |||
| 9591a5d26f | |||
| e3f1028667 | |||
| ea58ab2166 | |||
| 3b1aa2ebcb | |||
| fda2e70456 | |||
| 3cea91467d | |||
| dd6b37be16 | |||
| 078839b0f3 | |||
| 326438aac9 | |||
| 30da6eaead | |||
| 3784246e6b | |||
| fa6f189cdd | |||
| daa9d8d341 | |||
| c85afb59ab | |||
| 7d293f092e | |||
| da27204a71 | |||
| bac721e593 | |||
| bc666c6433 | |||
| 4a71992752 | |||
| 1b78186c4e | |||
| cb97aa193d | |||
| a02ac6e853 | |||
| f3cc790659 | |||
| 5ffbf76565 | |||
| 04b45adf98 | |||
| 9714c051c5 | |||
| fbf5a6d61a | |||
| 94632f42d6 | |||
| 3f6017d98b | |||
| d5af51c02b | |||
| 1ec0bb65f7 | |||
| c934a948c6 | |||
| 950bcaa7aa | |||
| 728ed7998d | |||
| 757b5665ca | |||
| c280bbc1c3 | |||
| 68c186f649 | |||
| f2de8b281a | |||
| 19f725dbaa | |||
| df7a1ee382 | |||
| 4fe1f0d77c | |||
| f45b3a71fa | |||
| 1aa7c28616 | |||
| e542e52bdb | |||
| 0afbdc2210 | |||
| fc6dea12eb | |||
| c3f88ebf12 | |||
| 1d1c93f8e2 | |||
| 929b105173 | |||
| e00777ebc2 | |||
| e4f6cdd025 | |||
| 2a705a361a | |||
| b4ec5da808 | |||
| 4d8761cc3c | |||
| fdd0dd0525 | |||
| df29c8d3ec | |||
| ef24402747 | |||
| 78e872b609 |
Generated
+66
@@ -37,6 +37,15 @@ version = "2.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.2"
|
version = "3.20.2"
|
||||||
@@ -99,6 +108,15 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-deque"
|
name = "crossbeam-deque"
|
||||||
version = "0.8.7"
|
version = "0.8.7"
|
||||||
@@ -130,6 +148,26 @@ version = "0.2.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"crypto-common",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.17.0"
|
version = "1.17.0"
|
||||||
@@ -153,6 +191,7 @@ dependencies = [
|
|||||||
"rhai",
|
"rhai",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -162,6 +201,16 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -431,6 +480,17 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.10.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -512,6 +572,12 @@ dependencies = [
|
|||||||
"crunchy",
|
"crunchy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
|
|||||||
+2
-1
@@ -11,6 +11,7 @@ version = "0.1.0"
|
|||||||
authors = ["OpenAI Codex"]
|
authors = ["OpenAI Codex"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
sha2 = "=0.10.9"
|
||||||
ahash = "=0.8.12"
|
ahash = "=0.8.12"
|
||||||
chrono = { version = "=0.4.44", features = ["serde"] }
|
chrono = { version = "=0.4.44", features = ["serde"] }
|
||||||
indexmap = { version = "=2.11.4", features = ["serde"] }
|
indexmap = { version = "=2.11.4", features = ["serde"] }
|
||||||
@@ -18,5 +19,5 @@ reqwest = { version = "=0.12.24", default-features = false, features = ["json",
|
|||||||
rayon = "=1.12.0"
|
rayon = "=1.12.0"
|
||||||
rhai = { version = "=1.23.6", features = ["sync"] }
|
rhai = { version = "=1.23.6", features = ["sync"] }
|
||||||
serde = { version = "=1.0.228", features = ["derive"] }
|
serde = { version = "=1.0.228", features = ["derive"] }
|
||||||
serde_json = "=1.0.145"
|
serde_json = { version = "=1.0.145", features = ["float_roundtrip"] }
|
||||||
thiserror = "=2.0.18"
|
thiserror = "=2.0.18"
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ Source Lake 日线成交量保留原始可用性合同:源 `volume=null` 与
|
|||||||
|
|
||||||
`holdUntilExit=true` 与 `stopTakeReferencePriceMode=signal_day_post_adjusted_close` 组合表示持久模型组合语义:股票进入模型目标后即记录信号日和后复权参考价,不以买单是否成交为前提。涨停、停牌或其他执行风控导致买单未成交时,模型成员仍占用目标槽位、每天累计模型持有日并继续生成目标仓位;达到止盈、止损或最大模型持有期后才从模型组合移除。实际订单仍由成交日风控独立决定,不得用实际持仓集合覆盖模型目标集合。
|
`holdUntilExit=true` 与 `stopTakeReferencePriceMode=signal_day_post_adjusted_close` 组合表示持久模型组合语义:股票进入模型目标后即记录信号日和后复权参考价,不以买单是否成交为前提。涨停、停牌或其他执行风控导致买单未成交时,模型成员仍占用目标槽位、每天累计模型持有日并继续生成目标仓位;达到止盈、止损或最大模型持有期后才从模型组合移除。实际订单仍由成交日风控独立决定,不得用实际持仓集合覆盖模型目标集合。
|
||||||
|
|
||||||
|
`targetPortfolioDaily=true` 时,每只股票的默认目标金额固定为 `target_budget / selection_limit * buy_scale`,候选不足、缺行情或风控拒绝产生的剩余资金保留为现金,不得自动归一到满仓。止盈、止损或最大持有期触发后,标的从活动目标顺序移除;卖出未完成时继续占用仓位槽且不得反向补买,成功释放的槽位只能由同一决策时点已排序且通过策略条件的后续候选补充。只有显式设置 `redistributeTargetWeightsAfterExit=true` 才在可用目标间重新分配权重,只有显式设置 `reenterExitedTargets=true` 才保留退出标的供后续重新入场;两个开关互相独立,默认都为 `false`。
|
||||||
|
|
||||||
## 内置微盘策略
|
## 内置微盘策略
|
||||||
|
|
||||||
`OmniMicroCapStrategy` 是平台内置的微盘轮动策略,用于 demo、性能验证和策略迁移基线:
|
`OmniMicroCapStrategy` 是平台内置的微盘轮动策略,用于 demo、性能验证和策略迁移基线:
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ rayon.workspace = true
|
|||||||
rhai.workspace = true
|
rhai.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
|||||||
@@ -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),
|
||||||
@@ -966,6 +969,31 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn complete_daily_target_position(intent: &OrderIntent) -> Option<(&str, bool)> {
|
||||||
|
match intent.unwrapped() {
|
||||||
|
OrderIntent::TargetValue {
|
||||||
|
symbol,
|
||||||
|
target_value,
|
||||||
|
reason,
|
||||||
|
}
|
||||||
|
| OrderIntent::TimedTargetValue {
|
||||||
|
symbol,
|
||||||
|
target_value,
|
||||||
|
reason,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
| OrderIntent::LimitTargetValue {
|
||||||
|
symbol,
|
||||||
|
target_value,
|
||||||
|
reason,
|
||||||
|
..
|
||||||
|
} if reason == "model_target_portfolio_daily" => {
|
||||||
|
Some((symbol, target_value.is_finite() && *target_value > 0.0))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn infer_target_position_limit(
|
fn infer_target_position_limit(
|
||||||
&self,
|
&self,
|
||||||
portfolio: &PortfolioState,
|
portfolio: &PortfolioState,
|
||||||
@@ -979,6 +1007,21 @@ where
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let complete_daily_targets = intents
|
||||||
|
.iter()
|
||||||
|
.filter_map(|intent| Self::complete_daily_target_position(intent))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if !complete_daily_targets.is_empty() {
|
||||||
|
return Some(
|
||||||
|
complete_daily_targets
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, positive)| *positive)
|
||||||
|
.map(|(symbol, _)| symbol)
|
||||||
|
.collect::<BTreeSet<_>>()
|
||||||
|
.len(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let held_symbols = portfolio
|
let held_symbols = portfolio
|
||||||
.positions()
|
.positions()
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1345,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));
|
||||||
@@ -1353,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);
|
||||||
@@ -2793,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;
|
||||||
{
|
{
|
||||||
@@ -4149,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,
|
||||||
@@ -6100,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() {
|
||||||
@@ -8173,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 {
|
||||||
@@ -10206,6 +10402,43 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn complete_daily_target_batch_uses_positive_target_count_for_slot_limit() {
|
||||||
|
let prev_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 1).expect("valid date");
|
||||||
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
|
ChinaAShareCostModel::default(),
|
||||||
|
ChinaEquityRuleHooks,
|
||||||
|
PriceField::Open,
|
||||||
|
);
|
||||||
|
let mut portfolio = PortfolioState::new(20_000.0);
|
||||||
|
for symbol in ["000001.SZ", "000002.SZ", "000003.SZ"] {
|
||||||
|
portfolio.position_mut(symbol).buy(prev_date, 1_000, 10.0);
|
||||||
|
}
|
||||||
|
let intents = vec![
|
||||||
|
OrderIntent::TargetValue {
|
||||||
|
symbol: "000001.SZ".to_string(),
|
||||||
|
target_value: 0.0,
|
||||||
|
reason: "stop_loss_exit".to_string(),
|
||||||
|
},
|
||||||
|
OrderIntent::TargetValue {
|
||||||
|
symbol: "000002.SZ".to_string(),
|
||||||
|
target_value: 10_000.0,
|
||||||
|
reason: "model_target_portfolio_daily".to_string(),
|
||||||
|
},
|
||||||
|
OrderIntent::TargetValue {
|
||||||
|
symbol: "000004.SZ".to_string(),
|
||||||
|
target_value: 10_000.0,
|
||||||
|
reason: "model_target_portfolio_daily".to_string(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let refs = intents.iter().collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
broker.infer_target_position_limit(&portfolio, &refs),
|
||||||
|
Some(2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn failed_target_exit_blocks_replacement_entry_when_no_position_slot_is_released() {
|
fn failed_target_exit_blocks_replacement_entry_when_no_position_slot_is_released() {
|
||||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
||||||
|
|||||||
+688
-119
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use chrono::{Datelike, Duration, NaiveDate, NaiveTime};
|
use chrono::{Datelike, Duration, NaiveDate, NaiveTime};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -453,6 +454,8 @@ pub struct BacktestEngine<S, C, R> {
|
|||||||
futures_cost_model: FuturesTransactionCostModel,
|
futures_cost_model: FuturesTransactionCostModel,
|
||||||
futures_validation_config: FuturesValidationConfig,
|
futures_validation_config: FuturesValidationConfig,
|
||||||
execution_quote_loader: Option<ExecutionQuoteLoader>,
|
execution_quote_loader: Option<ExecutionQuoteLoader>,
|
||||||
|
preplanned_decision_quote_symbols_by_date:
|
||||||
|
Option<Arc<BTreeMap<NaiveDate, BTreeSet<String>>>>,
|
||||||
execution_quote_request_cache:
|
execution_quote_request_cache:
|
||||||
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>,
|
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>,
|
||||||
risk_free_rate_contract: Option<RiskFreeRateContract>,
|
risk_free_rate_contract: Option<RiskFreeRateContract>,
|
||||||
@@ -539,6 +542,7 @@ impl<S, C, R> BacktestEngine<S, C, R> {
|
|||||||
futures_cost_model: FuturesTransactionCostModel::default(),
|
futures_cost_model: FuturesTransactionCostModel::default(),
|
||||||
futures_validation_config: FuturesValidationConfig::default(),
|
futures_validation_config: FuturesValidationConfig::default(),
|
||||||
execution_quote_loader: None,
|
execution_quote_loader: None,
|
||||||
|
preplanned_decision_quote_symbols_by_date: None,
|
||||||
execution_quote_request_cache: BTreeSet::new(),
|
execution_quote_request_cache: BTreeSet::new(),
|
||||||
risk_free_rate_contract: None,
|
risk_free_rate_contract: None,
|
||||||
}
|
}
|
||||||
@@ -563,6 +567,14 @@ impl<S, C, R> BacktestEngine<S, C, R> {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_preplanned_decision_quote_symbols_by_date(
|
||||||
|
mut self,
|
||||||
|
symbols_by_date: Arc<BTreeMap<NaiveDate, BTreeSet<String>>>,
|
||||||
|
) -> Self {
|
||||||
|
self.preplanned_decision_quote_symbols_by_date = Some(symbols_by_date);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_dividend_reinvestment(mut self, enabled: bool) -> Self {
|
pub fn with_dividend_reinvestment(mut self, enabled: bool) -> Self {
|
||||||
self.dividend_reinvestment = enabled;
|
self.dividend_reinvestment = enabled;
|
||||||
self
|
self
|
||||||
@@ -2519,7 +2531,22 @@ where
|
|||||||
)?;
|
)?;
|
||||||
let on_day_open_orders = self.open_order_views();
|
let on_day_open_orders = self.open_order_views();
|
||||||
let decision_quote_times = self.strategy.decision_quote_times();
|
let decision_quote_times = self.strategy.decision_quote_times();
|
||||||
if !decision_quote_times.is_empty() {
|
if self.execution_quote_loader.is_some() && !decision_quote_times.is_empty() {
|
||||||
|
if let Some(preplanned) = self
|
||||||
|
.preplanned_decision_quote_symbols_by_date
|
||||||
|
.as_ref()
|
||||||
|
.map(Arc::clone)
|
||||||
|
{
|
||||||
|
let empty_symbols = BTreeSet::new();
|
||||||
|
let decision_quote_symbols = preplanned
|
||||||
|
.get(&execution_date)
|
||||||
|
.unwrap_or(&empty_symbols);
|
||||||
|
self.ensure_execution_quotes_for_symbols_at_times(
|
||||||
|
execution_date,
|
||||||
|
decision_quote_symbols,
|
||||||
|
&decision_quote_times,
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
let decision_quote_symbols =
|
let decision_quote_symbols =
|
||||||
self.strategy.decision_quote_symbols(&StrategyContext {
|
self.strategy.decision_quote_symbols(&StrategyContext {
|
||||||
execution_date,
|
execution_date,
|
||||||
@@ -2546,6 +2573,7 @@ where
|
|||||||
&decision_quote_times,
|
&decision_quote_times,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
self.ensure_execution_quotes_for_portfolio_times(
|
self.ensure_execution_quotes_for_portfolio_times(
|
||||||
execution_date,
|
execution_date,
|
||||||
&portfolio,
|
&portfolio,
|
||||||
@@ -3025,7 +3053,7 @@ where
|
|||||||
drop(minute_group);
|
drop(minute_group);
|
||||||
drop(minute_quotes);
|
drop(minute_quotes);
|
||||||
drop(quote_data);
|
drop(quote_data);
|
||||||
self.data.remove_execution_quotes_on_date(execution_date);
|
self.data.release_execution_quotes_on_date(execution_date);
|
||||||
}
|
}
|
||||||
|
|
||||||
portfolio.update_prices_with_options(
|
portfolio.update_prices_with_options(
|
||||||
@@ -5326,6 +5354,7 @@ mod tests {
|
|||||||
pe_ttm: 12.0,
|
pe_ttm: 12.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub mod platform_expr_strategy;
|
|||||||
pub mod platform_runtime_schema;
|
pub mod platform_runtime_schema;
|
||||||
pub mod platform_strategy_spec;
|
pub mod platform_strategy_spec;
|
||||||
pub mod portfolio;
|
pub mod portfolio;
|
||||||
|
pub mod portfolio_loss;
|
||||||
pub mod risk_control;
|
pub mod risk_control;
|
||||||
pub mod rules;
|
pub mod rules;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
@@ -31,8 +32,8 @@ pub use data::{
|
|||||||
BenchmarkSnapshot, CandidateEligibility, CorporateAction, DailyFactorSnapshot,
|
BenchmarkSnapshot, CandidateEligibility, CorporateAction, DailyFactorSnapshot,
|
||||||
DailyMarketSnapshot, DailySnapshotBundle, DataSet, DataSetError, DividendRecord,
|
DailyMarketSnapshot, DailySnapshotBundle, DataSet, DataSetError, DividendRecord,
|
||||||
EligibleUniverseSnapshot, FactorTextValue, FactorValue, IntradayExecutionQuote,
|
EligibleUniverseSnapshot, FactorTextValue, FactorValue, IntradayExecutionQuote,
|
||||||
IntradayOrderBookDepthLevel, NumericFactorMap, PriceBar, PriceField, SecuritiesMarginRecord,
|
IntradayMarketSnapshotOverlay, IntradayOrderBookDepthLevel, NumericFactorMap, PriceBar,
|
||||||
SplitRecord, YieldCurvePoint,
|
PriceField, SecuritiesMarginRecord, SplitRecord, YieldCurvePoint,
|
||||||
};
|
};
|
||||||
pub use engine::{
|
pub use engine::{
|
||||||
AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary,
|
AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary,
|
||||||
@@ -83,6 +84,7 @@ pub use platform_strategy_spec::{
|
|||||||
platform_expr_config_from_value, validate_strategy_risk_policy_fields,
|
platform_expr_config_from_value, validate_strategy_risk_policy_fields,
|
||||||
};
|
};
|
||||||
pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position};
|
pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position};
|
||||||
|
pub use portfolio_loss::{ClosedPortfolioSession, PortfolioLossConfig, PortfolioLossDecision, PortfolioLossError, PortfolioLossState};
|
||||||
pub use risk_control::{
|
pub use risk_control::{
|
||||||
ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit, RiskCheckScope,
|
ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit, RiskCheckScope,
|
||||||
StaticRiskRuleConfig, TradingConstraintConfig,
|
StaticRiskRuleConfig, TradingConstraintConfig,
|
||||||
|
|||||||
@@ -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| {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet};
|
|||||||
use chrono::{NaiveDate, NaiveTime};
|
use chrono::{NaiveDate, NaiveTime};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use crate::portfolio_loss::PortfolioLossConfig;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage,
|
DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage,
|
||||||
@@ -62,6 +63,8 @@ pub struct StrategyBenchmarkSpec {
|
|||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct StrategyUniverseSpec {
|
pub struct StrategyUniverseSpec {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub include: Option<Vec<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub exclude: Vec<String>,
|
pub exclude: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -913,6 +916,8 @@ pub struct StrategyExpressionRiskConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>,
|
pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub portfolio_loss_control: Option<StrategyPortfolioLossControlConfig>,
|
||||||
|
#[serde(default)]
|
||||||
pub stop_loss_expr: Option<String>,
|
pub stop_loss_expr: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub take_profit_expr: Option<String>,
|
pub take_profit_expr: Option<String>,
|
||||||
@@ -961,6 +966,16 @@ pub struct StrategyPortfolioDrawdownControlConfig {
|
|||||||
pub cooldown_trading_days: Option<usize>,
|
pub cooldown_trading_days: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct StrategyPortfolioLossControlConfig {
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
pub lookback: Option<usize>,
|
||||||
|
pub loss_trigger: Option<f64>,
|
||||||
|
pub floor_exposure: Option<f64>,
|
||||||
|
pub cooldown_trading_days: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct StrategyExpressionOrderingConfig {
|
pub struct StrategyExpressionOrderingConfig {
|
||||||
@@ -975,6 +990,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)]
|
||||||
@@ -1020,6 +1037,10 @@ pub struct StrategyExpressionTradingConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub release_slot_on_exit_signal: Option<bool>,
|
pub release_slot_on_exit_signal: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub redistribute_target_weights_after_exit: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub reenter_exited_targets: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
pub subscription_guard_required: Option<bool>,
|
pub subscription_guard_required: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub subscriptions: Vec<String>,
|
pub subscriptions: Vec<String>,
|
||||||
@@ -1255,6 +1276,9 @@ fn parse_stop_take_reference_price_mode(
|
|||||||
"position_cost_basis" | "position_cost" | "execution_cost_basis" => {
|
"position_cost_basis" | "position_cost" | "execution_cost_basis" => {
|
||||||
Ok(PlatformStopTakeReferencePriceMode::PositionCostBasis)
|
Ok(PlatformStopTakeReferencePriceMode::PositionCostBasis)
|
||||||
}
|
}
|
||||||
|
"position_average_entry_price" | "position_avg_price" | "average_entry_price" => {
|
||||||
|
Ok(PlatformStopTakeReferencePriceMode::PositionAverageEntryPrice)
|
||||||
|
}
|
||||||
"signal_day_post_adjusted_close"
|
"signal_day_post_adjusted_close"
|
||||||
| "signal_post_adjusted_close"
|
| "signal_post_adjusted_close"
|
||||||
| "model_signal_post_adjusted_close" => {
|
| "model_signal_post_adjusted_close" => {
|
||||||
@@ -1776,6 +1800,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;
|
||||||
@@ -1937,6 +1978,27 @@ pub fn platform_expr_config_from_spec(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(universe) = spec.universe.as_ref() {
|
if let Some(universe) = spec.universe.as_ref() {
|
||||||
|
cfg.universe_include = universe
|
||||||
|
.include
|
||||||
|
.as_ref()
|
||||||
|
.map(|raw_symbols| {
|
||||||
|
let mut symbols = BTreeSet::new();
|
||||||
|
for raw_symbol in raw_symbols {
|
||||||
|
let symbol = normalize_symbol(raw_symbol, None);
|
||||||
|
if !is_static_cn_universe_symbol(&symbol) {
|
||||||
|
return Err(format!(
|
||||||
|
"universe.include contains invalid CN stock symbol: {raw_symbol}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !symbols.insert(symbol.clone()) {
|
||||||
|
return Err(format!(
|
||||||
|
"universe.include contains duplicate normalized symbol: {symbol}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(symbols)
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
cfg.universe_exclude = universe
|
cfg.universe_exclude = universe
|
||||||
.exclude
|
.exclude
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2127,6 +2189,18 @@ pub fn platform_expr_config_from_spec(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(control) = risk.portfolio_loss_control.as_ref()
|
||||||
|
&& control.enabled.unwrap_or(true)
|
||||||
|
{
|
||||||
|
let parsed = PortfolioLossConfig {
|
||||||
|
lookback: control.lookback.ok_or("portfolioLossControl.lookback is required")?,
|
||||||
|
loss_trigger: control.loss_trigger.ok_or("portfolioLossControl.lossTrigger is required")?,
|
||||||
|
floor_exposure: control.floor_exposure.ok_or("portfolioLossControl.floorExposure is required")?,
|
||||||
|
cooldown_trading_days: control.cooldown_trading_days.ok_or("portfolioLossControl.cooldownTradingDays is required")?,
|
||||||
|
};
|
||||||
|
parsed.validate().map_err(|error| error.to_string())?;
|
||||||
|
cfg.portfolio_loss_control = Some(parsed);
|
||||||
|
}
|
||||||
if let Some(control) = risk.portfolio_drawdown_control.as_ref()
|
if let Some(control) = risk.portfolio_drawdown_control.as_ref()
|
||||||
&& control.enabled.unwrap_or(true)
|
&& control.enabled.unwrap_or(true)
|
||||||
{
|
{
|
||||||
@@ -2242,6 +2316,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()
|
||||||
@@ -2294,6 +2371,12 @@ pub fn platform_expr_config_from_spec(
|
|||||||
if let Some(enabled) = trading.release_slot_on_exit_signal {
|
if let Some(enabled) = trading.release_slot_on_exit_signal {
|
||||||
cfg.release_slot_on_exit_signal = enabled;
|
cfg.release_slot_on_exit_signal = enabled;
|
||||||
}
|
}
|
||||||
|
if let Some(enabled) = trading.redistribute_target_weights_after_exit {
|
||||||
|
cfg.redistribute_target_weights_after_exit = enabled;
|
||||||
|
}
|
||||||
|
if let Some(enabled) = trading.reenter_exited_targets {
|
||||||
|
cfg.reenter_exited_targets = enabled;
|
||||||
|
}
|
||||||
if let Some(enabled) = trading.delayed_limit_open_exit {
|
if let Some(enabled) = trading.delayed_limit_open_exit {
|
||||||
cfg.delayed_limit_open_exit_enabled = enabled;
|
cfg.delayed_limit_open_exit_enabled = enabled;
|
||||||
if enabled {
|
if enabled {
|
||||||
@@ -2996,6 +3079,15 @@ fn normalize_symbol(symbol: &str, raw_board: Option<&str>) -> String {
|
|||||||
instrument_query_id(trimmed, &normalize_board(trimmed, raw_board))
|
instrument_query_id(trimmed, &normalize_board(trimmed, raw_board))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_static_cn_universe_symbol(symbol: &str) -> bool {
|
||||||
|
let Some((code, exchange)) = symbol.rsplit_once('.') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
code.len() == 6
|
||||||
|
&& code.bytes().all(|byte| byte.is_ascii_digit())
|
||||||
|
&& matches!(exchange, "SH" | "SZ" | "BJ")
|
||||||
|
}
|
||||||
|
|
||||||
fn instrument_query_id(symbol: &str, board: &str) -> String {
|
fn instrument_query_id(symbol: &str, board: &str) -> String {
|
||||||
if symbol.contains('.') {
|
if symbol.contains('.') {
|
||||||
return symbol.to_ascii_uppercase();
|
return symbol.to_ascii_uppercase();
|
||||||
@@ -3018,8 +3110,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
|
||||||
@@ -3034,9 +3125,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();
|
||||||
}
|
}
|
||||||
@@ -3053,27 +3141,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]
|
||||||
@@ -3082,7 +3189,10 @@ mod tests {
|
|||||||
"strategyId": "runtime_spec_test",
|
"strategyId": "runtime_spec_test",
|
||||||
"signalSymbol": "000852.SH",
|
"signalSymbol": "000852.SH",
|
||||||
"benchmark": { "instrumentId": "000852.SH" },
|
"benchmark": { "instrumentId": "000852.SH" },
|
||||||
"universe": { "exclude": ["paused", "st", "kcb", "one_yuan"] },
|
"universe": {
|
||||||
|
"include": ["600000.sh", "000001.SZ"],
|
||||||
|
"exclude": ["paused", "st", "kcb", "one_yuan"]
|
||||||
|
},
|
||||||
"runtimeExpressions": {
|
"runtimeExpressions": {
|
||||||
"prelude": "let stocknum = 8;",
|
"prelude": "let stocknum = 8;",
|
||||||
"selection": {
|
"selection": {
|
||||||
@@ -3121,6 +3231,13 @@ mod tests {
|
|||||||
assert_eq!(cfg.signal_symbol, "000852.SH");
|
assert_eq!(cfg.signal_symbol, "000852.SH");
|
||||||
assert_eq!(cfg.selection_limit_expr, "stocknum");
|
assert_eq!(cfg.selection_limit_expr, "stocknum");
|
||||||
assert_eq!(cfg.refresh_rate_expr, "year >= 2024 ? 5 : 20");
|
assert_eq!(cfg.refresh_rate_expr, "year >= 2024 ? 5 : 20");
|
||||||
|
assert_eq!(
|
||||||
|
cfg.universe_include,
|
||||||
|
Some(BTreeSet::from([
|
||||||
|
"000001.SZ".to_string(),
|
||||||
|
"600000.SH".to_string()
|
||||||
|
]))
|
||||||
|
);
|
||||||
assert_eq!(cfg.universe_exclude, ["paused", "st", "kcb", "one_yuan"]);
|
assert_eq!(cfg.universe_exclude, ["paused", "st", "kcb", "one_yuan"]);
|
||||||
assert!(!cfg.rotation_enabled);
|
assert!(!cfg.rotation_enabled);
|
||||||
assert!(cfg.daily_top_up_enabled);
|
assert!(cfg.daily_top_up_enabled);
|
||||||
@@ -3142,6 +3259,29 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_invalid_or_duplicate_static_universe_symbols() {
|
||||||
|
let invalid = serde_json::json!({
|
||||||
|
"universe": {"include": ["not-a-stock"]}
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
platform_expr_config_from_value("", "", &invalid)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("invalid CN stock symbol")
|
||||||
|
);
|
||||||
|
|
||||||
|
let duplicate = serde_json::json!({
|
||||||
|
"universe": {"include": ["600000.sh", "600000.SH"]}
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
platform_expr_config_from_value("", "", &duplicate)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("duplicate normalized symbol")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_and_rejects_invalid_position_target_rules() {
|
fn parses_and_rejects_invalid_position_target_rules() {
|
||||||
let spec = serde_json::json!({
|
let spec = serde_json::json!({
|
||||||
@@ -3484,7 +3624,9 @@ mod tests {
|
|||||||
"targetPortfolioDaily": true,
|
"targetPortfolioDaily": true,
|
||||||
"rebalanceExistingPositions": true,
|
"rebalanceExistingPositions": true,
|
||||||
"holdUntilExit": true,
|
"holdUntilExit": true,
|
||||||
"releaseSlotOnExitSignal": true
|
"releaseSlotOnExitSignal": true,
|
||||||
|
"redistributeTargetWeightsAfterExit": true,
|
||||||
|
"reenterExitedTargets": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -3498,6 +3640,8 @@ mod tests {
|
|||||||
assert!(cfg.rebalance_existing_positions);
|
assert!(cfg.rebalance_existing_positions);
|
||||||
assert!(cfg.hold_until_exit_enabled);
|
assert!(cfg.hold_until_exit_enabled);
|
||||||
assert!(cfg.release_slot_on_exit_signal);
|
assert!(cfg.release_slot_on_exit_signal);
|
||||||
|
assert!(cfg.redistribute_target_weights_after_exit);
|
||||||
|
assert!(cfg.reenter_exited_targets);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -4445,6 +4589,27 @@ mod tests {
|
|||||||
assert_eq!(control.cooldown_trading_days, 30);
|
assert_eq!(control.cooldown_trading_days, 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn portfolio_loss_contract_is_explicit_and_validated() {
|
||||||
|
let spec = serde_json::json!({"runtimeExpressions":{"risk":{"portfolioLossControl":{
|
||||||
|
"enabled":true,"lookback":20,"lossTrigger":0.05,"floorExposure":0.2,"cooldownTradingDays":10
|
||||||
|
}}}});
|
||||||
|
let cfg = platform_expr_config_from_value("", "", &spec).unwrap();
|
||||||
|
assert_eq!(cfg.portfolio_loss_control.unwrap(), PortfolioLossConfig {
|
||||||
|
lookback:20, loss_trigger:0.05, floor_exposure:0.2, cooldown_trading_days:10,
|
||||||
|
});
|
||||||
|
for (field, value) in [("lookback", serde_json::json!(0)),
|
||||||
|
("lossTrigger", serde_json::json!(0.01)), ("floorExposure", serde_json::json!(1.1)),
|
||||||
|
("cooldownTradingDays", serde_json::json!(0))] {
|
||||||
|
let mut invalid = spec.clone();
|
||||||
|
invalid["runtimeExpressions"]["risk"]["portfolioLossControl"][field] = value;
|
||||||
|
assert!(platform_expr_config_from_value("", "", &invalid).is_err());
|
||||||
|
}
|
||||||
|
let mut missing = spec.clone();
|
||||||
|
missing["runtimeExpressions"]["risk"]["portfolioLossControl"].as_object_mut().unwrap().remove("lossTrigger");
|
||||||
|
assert!(platform_expr_config_from_value("", "", &missing).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_invalid_portfolio_drawdown_control() {
|
fn rejects_invalid_portfolio_drawdown_control() {
|
||||||
let spec = serde_json::json!({
|
let spec = serde_json::json!({
|
||||||
@@ -4483,6 +4648,24 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_position_average_entry_stop_take_reference_price_mode() {
|
||||||
|
let spec = serde_json::json!({
|
||||||
|
"runtimeExpressions": {
|
||||||
|
"risk": {
|
||||||
|
"stopTakeReferencePriceMode": "position_average_entry_price"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
cfg.stop_take_reference_price_mode,
|
||||||
|
PlatformStopTakeReferencePriceMode::PositionAverageEntryPrice
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unknown_stop_take_reference_price_mode() {
|
fn rejects_unknown_stop_take_reference_price_mode() {
|
||||||
let spec = serde_json::json!({
|
let spec = serde_json::json!({
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ pub struct PositionLot {
|
|||||||
pub struct Position {
|
pub struct Position {
|
||||||
pub symbol: String,
|
pub symbol: String,
|
||||||
pub quantity: u32,
|
pub quantity: u32,
|
||||||
|
// ALV-compatible moving average execution price; partial sells do not rebase it.
|
||||||
|
pub average_price: f64,
|
||||||
// ALV-compatible moving average including buy costs; partial sells do not rebase it.
|
// ALV-compatible moving average including buy costs; partial sells do not rebase it.
|
||||||
pub average_cost: f64,
|
pub average_cost: f64,
|
||||||
pub last_price: f64,
|
pub last_price: f64,
|
||||||
@@ -86,6 +88,7 @@ impl Position {
|
|||||||
Self {
|
Self {
|
||||||
symbol: symbol.into(),
|
symbol: symbol.into(),
|
||||||
quantity: 0,
|
quantity: 0,
|
||||||
|
average_price: 0.0,
|
||||||
average_cost: 0.0,
|
average_cost: 0.0,
|
||||||
last_price: 0.0,
|
last_price: 0.0,
|
||||||
realized_pnl: FixedMoney::ZERO,
|
realized_pnl: FixedMoney::ZERO,
|
||||||
@@ -127,6 +130,7 @@ impl Position {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let previous_quantity = self.quantity;
|
let previous_quantity = self.quantity;
|
||||||
|
let previous_average_price = self.average_price;
|
||||||
let previous_average_cost = self.average_cost;
|
let previous_average_cost = self.average_cost;
|
||||||
let gross_amount = fixed_money_or_panic(
|
let gross_amount = fixed_money_or_panic(
|
||||||
execution_price * quantity as f64,
|
execution_price * quantity as f64,
|
||||||
@@ -146,6 +150,18 @@ impl Position {
|
|||||||
.day_buy_value
|
.day_buy_value
|
||||||
.checked_add(gross_amount)
|
.checked_add(gross_amount)
|
||||||
.expect("fixed-point day buy value overflow");
|
.expect("fixed-point day buy value overflow");
|
||||||
|
if previous_quantity > 0
|
||||||
|
&& previous_average_price.is_finite()
|
||||||
|
&& previous_average_price > 0.0
|
||||||
|
&& execution_price.is_finite()
|
||||||
|
&& execution_price > 0.0
|
||||||
|
{
|
||||||
|
self.average_price = (previous_average_price * previous_quantity as f64
|
||||||
|
+ execution_price * quantity as f64)
|
||||||
|
/ self.quantity as f64;
|
||||||
|
} else {
|
||||||
|
self.average_price = execution_price;
|
||||||
|
}
|
||||||
if previous_quantity > 0
|
if previous_quantity > 0
|
||||||
&& previous_average_cost.is_finite()
|
&& previous_average_cost.is_finite()
|
||||||
&& previous_average_cost > 0.0
|
&& previous_average_cost > 0.0
|
||||||
@@ -186,6 +202,7 @@ impl Position {
|
|||||||
let mut remaining_proceeds = total_proceeds;
|
let mut remaining_proceeds = total_proceeds;
|
||||||
let mut realized = FixedMoney::ZERO;
|
let mut realized = FixedMoney::ZERO;
|
||||||
let mut realized_entry = FixedMoney::ZERO;
|
let mut realized_entry = FixedMoney::ZERO;
|
||||||
|
let average_price_before_sell = self.average_price;
|
||||||
let average_cost_before_sell = self.average_cost;
|
let average_cost_before_sell = self.average_cost;
|
||||||
|
|
||||||
while remaining > 0 {
|
while remaining > 0 {
|
||||||
@@ -250,12 +267,20 @@ impl Position {
|
|||||||
.checked_add(total_proceeds)
|
.checked_add(total_proceeds)
|
||||||
.ok_or_else(|| "fixed-point day sell value overflow".to_string())?;
|
.ok_or_else(|| "fixed-point day sell value overflow".to_string())?;
|
||||||
if self.quantity == 0 {
|
if self.quantity == 0 {
|
||||||
|
self.average_price = 0.0;
|
||||||
self.recalculate_average_cost();
|
self.recalculate_average_cost();
|
||||||
} else if average_cost_before_sell.is_finite() && average_cost_before_sell > 0.0 {
|
} else {
|
||||||
|
if average_price_before_sell.is_finite() && average_price_before_sell > 0.0 {
|
||||||
|
self.average_price = average_price_before_sell;
|
||||||
|
} else {
|
||||||
|
self.average_price = self.average_entry_price().unwrap_or(0.0);
|
||||||
|
}
|
||||||
|
if average_cost_before_sell.is_finite() && average_cost_before_sell > 0.0 {
|
||||||
self.average_cost = average_cost_before_sell;
|
self.average_cost = average_cost_before_sell;
|
||||||
} else {
|
} else {
|
||||||
self.recalculate_average_cost();
|
self.recalculate_average_cost();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
self.refresh_day_pnl();
|
self.refresh_day_pnl();
|
||||||
Ok(realized.to_f64())
|
Ok(realized.to_f64())
|
||||||
}
|
}
|
||||||
@@ -298,6 +323,13 @@ impl Position {
|
|||||||
.to_f64()
|
.to_f64()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn unrealized_average_price_pnl(&self) -> f64 {
|
||||||
|
if self.quantity == 0 || !self.average_price.is_finite() || self.average_price <= 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
(self.last_price - self.average_price) * self.quantity as f64
|
||||||
|
}
|
||||||
|
|
||||||
pub fn pnl(&self) -> f64 {
|
pub fn pnl(&self) -> f64 {
|
||||||
self.realized_pnl.to_f64() + self.unrealized_pnl()
|
self.realized_pnl.to_f64() + self.unrealized_pnl()
|
||||||
}
|
}
|
||||||
@@ -421,9 +453,12 @@ impl Position {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn holding_return(&self, price: f64) -> Option<f64> {
|
pub fn holding_return(&self, price: f64) -> Option<f64> {
|
||||||
let Some(avg_price) = self.average_entry_price() else {
|
let avg_price = self
|
||||||
return None;
|
.average_price
|
||||||
};
|
.is_finite()
|
||||||
|
.then_some(self.average_price)
|
||||||
|
.filter(|value| *value > 0.0)
|
||||||
|
.or_else(|| self.average_entry_price())?;
|
||||||
if avg_price <= 0.0 {
|
if avg_price <= 0.0 {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -503,6 +538,7 @@ impl Position {
|
|||||||
if adjust_cost_basis {
|
if adjust_cost_basis {
|
||||||
self.average_cost -= dividend_per_share;
|
self.average_cost -= dividend_per_share;
|
||||||
}
|
}
|
||||||
|
self.average_price -= dividend_per_share;
|
||||||
self.last_price -= dividend_per_share;
|
self.last_price -= dividend_per_share;
|
||||||
self.day_dividend_cash = self
|
self.day_dividend_cash = self
|
||||||
.day_dividend_cash
|
.day_dividend_cash
|
||||||
@@ -545,6 +581,11 @@ impl Position {
|
|||||||
self.lots = scaled_lots;
|
self.lots = scaled_lots;
|
||||||
self.quantity = self.lots.iter().map(|lot| lot.quantity).sum();
|
self.quantity = self.lots.iter().map(|lot| lot.quantity).sum();
|
||||||
self.last_price /= ratio;
|
self.last_price /= ratio;
|
||||||
|
if self.average_price.is_finite() && self.average_price > 0.0 {
|
||||||
|
self.average_price /= ratio;
|
||||||
|
} else {
|
||||||
|
self.average_price = self.average_entry_price().unwrap_or(0.0);
|
||||||
|
}
|
||||||
if self.average_cost.is_finite() && self.average_cost > 0.0 {
|
if self.average_cost.is_finite() && self.average_cost > 0.0 {
|
||||||
self.average_cost /= ratio;
|
self.average_cost /= ratio;
|
||||||
} else {
|
} else {
|
||||||
@@ -1128,8 +1169,11 @@ impl PortfolioState {
|
|||||||
.map(|position| {
|
.map(|position| {
|
||||||
let market_value = position.market_value();
|
let market_value = position.market_value();
|
||||||
let entry_average_cost = position
|
let entry_average_cost = position
|
||||||
.average_entry_price()
|
.average_price
|
||||||
|
.is_finite()
|
||||||
|
.then_some(position.average_price)
|
||||||
.filter(|value| value.is_finite() && *value > 0.0)
|
.filter(|value| value.is_finite() && *value > 0.0)
|
||||||
|
.or_else(|| position.average_entry_price())
|
||||||
.unwrap_or(position.average_cost);
|
.unwrap_or(position.average_cost);
|
||||||
HoldingSummary {
|
HoldingSummary {
|
||||||
date,
|
date,
|
||||||
@@ -1143,7 +1187,7 @@ impl PortfolioState {
|
|||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
},
|
},
|
||||||
unrealized_pnl: position.unrealized_entry_pnl(),
|
unrealized_pnl: position.unrealized_average_price_pnl(),
|
||||||
realized_pnl: position.realized_entry_pnl(),
|
realized_pnl: position.realized_entry_pnl(),
|
||||||
pnl: position.entry_pnl(),
|
pnl: position.entry_pnl(),
|
||||||
trading_pnl: position.trading_pnl,
|
trading_pnl: position.trading_pnl,
|
||||||
@@ -1181,6 +1225,7 @@ impl PortfolioState {
|
|||||||
|
|
||||||
let old_quantity = old_position.quantity;
|
let old_quantity = old_position.quantity;
|
||||||
let last_price = old_position.last_price;
|
let last_price = old_position.last_price;
|
||||||
|
let old_average_price = old_position.average_price;
|
||||||
let old_average_cost = old_position.average_cost;
|
let old_average_cost = old_position.average_cost;
|
||||||
let realized_pnl = old_position.realized_pnl;
|
let realized_pnl = old_position.realized_pnl;
|
||||||
let realized_entry_pnl = old_position.realized_entry_pnl;
|
let realized_entry_pnl = old_position.realized_entry_pnl;
|
||||||
@@ -1218,6 +1263,7 @@ impl PortfolioState {
|
|||||||
.entry(new_symbol.to_string())
|
.entry(new_symbol.to_string())
|
||||||
.or_insert_with(|| Position::new(new_symbol));
|
.or_insert_with(|| Position::new(new_symbol));
|
||||||
let successor_quantity_before = successor.quantity;
|
let successor_quantity_before = successor.quantity;
|
||||||
|
let successor_average_price_before = successor.average_price;
|
||||||
let successor_average_cost_before = successor.average_cost;
|
let successor_average_cost_before = successor.average_cost;
|
||||||
successor.lots.extend(converted_lots);
|
successor.lots.extend(converted_lots);
|
||||||
successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum();
|
successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum();
|
||||||
@@ -1232,6 +1278,30 @@ impl PortfolioState {
|
|||||||
if converted_last_price > 0.0 {
|
if converted_last_price > 0.0 {
|
||||||
successor.last_price = converted_last_price;
|
successor.last_price = converted_last_price;
|
||||||
}
|
}
|
||||||
|
let converted_average_price = if old_average_price.is_finite()
|
||||||
|
&& old_average_price > 0.0
|
||||||
|
&& ratio.is_finite()
|
||||||
|
&& ratio > 0.0
|
||||||
|
{
|
||||||
|
Some(old_average_price / ratio)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(converted_average_price) = converted_average_price {
|
||||||
|
if successor_quantity_before > 0
|
||||||
|
&& successor_average_price_before.is_finite()
|
||||||
|
&& successor_average_price_before > 0.0
|
||||||
|
{
|
||||||
|
successor.average_price = (successor_average_price_before
|
||||||
|
* successor_quantity_before as f64
|
||||||
|
+ converted_average_price * converted_quantity as f64)
|
||||||
|
/ successor.quantity as f64;
|
||||||
|
} else {
|
||||||
|
successor.average_price = converted_average_price;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
successor.average_price = successor.average_entry_price().unwrap_or(0.0);
|
||||||
|
}
|
||||||
let converted_average_cost = if old_average_cost.is_finite()
|
let converted_average_cost = if old_average_cost.is_finite()
|
||||||
&& old_average_cost > 0.0
|
&& old_average_cost > 0.0
|
||||||
&& ratio.is_finite()
|
&& ratio.is_finite()
|
||||||
@@ -1375,8 +1445,10 @@ mod tests {
|
|||||||
let realized = position.sell(100, 6.0).expect("partial FIFO sell");
|
let realized = position.sell(100, 6.0).expect("partial FIFO sell");
|
||||||
|
|
||||||
assert_eq!(position.quantity, 100);
|
assert_eq!(position.quantity, 100);
|
||||||
|
assert!((position.average_price - 7.5).abs() < 1e-12);
|
||||||
assert!((position.average_cost - 7.55).abs() < 1e-12);
|
assert!((position.average_cost - 7.55).abs() < 1e-12);
|
||||||
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
|
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
|
||||||
|
assert!((position.unrealized_average_price_pnl() + 150.0).abs() < 1e-12);
|
||||||
assert!((realized + 405.0).abs() < 1e-12);
|
assert!((realized + 405.0).abs() < 1e-12);
|
||||||
assert!((position.unrealized_pnl() - 95.0).abs() < 1e-12);
|
assert!((position.unrealized_pnl() - 95.0).abs() < 1e-12);
|
||||||
assert!((position.pnl() + 310.0).abs() < 1e-12);
|
assert!((position.pnl() + 310.0).abs() < 1e-12);
|
||||||
@@ -1409,6 +1481,7 @@ mod tests {
|
|||||||
position.record_buy_trade_cost(22_200, 100.0);
|
position.record_buy_trade_cost(22_200, 100.0);
|
||||||
|
|
||||||
assert!(position.average_cost > 5.66);
|
assert!(position.average_cost > 5.66);
|
||||||
|
assert!((position.average_price - 5.66).abs() < 1e-12);
|
||||||
assert!((position.average_entry_price().unwrap() - 5.66).abs() < 1e-12);
|
assert!((position.average_entry_price().unwrap() - 5.66).abs() < 1e-12);
|
||||||
assert!((position.holding_return(6.06).unwrap() - (6.06 / 5.66 - 1.0)).abs() < 1e-12);
|
assert!((position.holding_return(6.06).unwrap() - (6.06 / 5.66 - 1.0)).abs() < 1e-12);
|
||||||
}
|
}
|
||||||
@@ -1430,6 +1503,7 @@ mod tests {
|
|||||||
position.sell(2700, 16.8331).expect("partial sell");
|
position.sell(2700, 16.8331).expect("partial sell");
|
||||||
|
|
||||||
assert_eq!(position.quantity, 100);
|
assert_eq!(position.quantity, 100);
|
||||||
|
assert!((position.average_price - 18.94711428571429).abs() < 1e-12);
|
||||||
assert!((position.average_cost - average_cost_before).abs() < 1e-12);
|
assert!((position.average_cost - average_cost_before).abs() < 1e-12);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1443,11 +1517,13 @@ mod tests {
|
|||||||
|
|
||||||
position.sell(100, 6.0).expect("partial sell");
|
position.sell(100, 6.0).expect("partial sell");
|
||||||
assert_eq!(position.quantity, 100);
|
assert_eq!(position.quantity, 100);
|
||||||
|
assert!((position.average_price - 7.5).abs() < 1e-12);
|
||||||
assert!((position.average_cost - 7.5).abs() < 1e-12);
|
assert!((position.average_cost - 7.5).abs() < 1e-12);
|
||||||
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
|
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
|
||||||
|
|
||||||
position.buy(date, 100, 5.0);
|
position.buy(date, 100, 5.0);
|
||||||
assert_eq!(position.quantity, 200);
|
assert_eq!(position.quantity, 200);
|
||||||
|
assert!((position.average_price - 6.25).abs() < 1e-12);
|
||||||
assert!((position.average_cost - 6.25).abs() < 1e-12);
|
assert!((position.average_cost - 6.25).abs() < 1e-12);
|
||||||
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
|
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
|
||||||
}
|
}
|
||||||
@@ -1489,6 +1565,7 @@ mod tests {
|
|||||||
let cash = position.apply_cash_dividend_preserve_cost_basis(0.6);
|
let cash = position.apply_cash_dividend_preserve_cost_basis(0.6);
|
||||||
|
|
||||||
assert!((cash - 600.0).abs() < 1e-12);
|
assert!((cash - 600.0).abs() < 1e-12);
|
||||||
|
assert!((position.average_price - 45.85).abs() < 1e-12);
|
||||||
assert!((position.average_cost - cost_before).abs() < 1e-12);
|
assert!((position.average_cost - cost_before).abs() < 1e-12);
|
||||||
assert!((position.average_entry_price().unwrap() - (entry_before - 0.6)).abs() < 1e-12);
|
assert!((position.average_entry_price().unwrap() - (entry_before - 0.6)).abs() < 1e-12);
|
||||||
assert!((position.last_price - 45.85).abs() < 1e-12);
|
assert!((position.last_price - 45.85).abs() < 1e-12);
|
||||||
@@ -1571,6 +1648,7 @@ mod tests {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -1659,6 +1737,7 @@ mod tests {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
|
|||||||
@@ -0,0 +1,517 @@
|
|||||||
|
//! Causal portfolio-loss state, independent of market-data and order adapters.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use chrono::{DateTime, FixedOffset, NaiveDate, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
const STATE_SCHEMA: &str = "fidc.portfolio-loss-state/v1";
|
||||||
|
const MAX_OBSERVATIONS: usize = 120;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct PortfolioLossConfig {
|
||||||
|
pub lookback: usize,
|
||||||
|
pub loss_trigger: f64,
|
||||||
|
pub floor_exposure: f64,
|
||||||
|
pub cooldown_trading_days: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PortfolioLossConfig {
|
||||||
|
pub fn validate(&self) -> Result<(), PortfolioLossError> {
|
||||||
|
if !matches!(self.lookback, 10 | 20 | 40 | 60)
|
||||||
|
|| !self.loss_trigger.is_finite()
|
||||||
|
|| !(0.02..=0.30).contains(&self.loss_trigger)
|
||||||
|
|| !self.floor_exposure.is_finite()
|
||||||
|
|| !(0.0..=1.0).contains(&self.floor_exposure)
|
||||||
|
|| !(1..=120).contains(&self.cooldown_trading_days)
|
||||||
|
{
|
||||||
|
return Err(PortfolioLossError::InvalidConfig);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finalized portfolio accounting, not a market close used as a proxy for NAV.
|
||||||
|
/// Unit NAV must already exclude external deposits and withdrawals.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct ClosedPortfolioSession {
|
||||||
|
pub date: NaiveDate,
|
||||||
|
pub previous_session_date: Option<NaiveDate>,
|
||||||
|
pub available_at: DateTime<Utc>,
|
||||||
|
pub start_unit_nav: f64,
|
||||||
|
pub end_unit_nav: f64,
|
||||||
|
pub start_gross_exposure: f64,
|
||||||
|
pub end_gross_exposure: f64,
|
||||||
|
pub source_sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClosedPortfolioSession {
|
||||||
|
fn validate(&self) -> Result<(), PortfolioLossError> {
|
||||||
|
let earliest = self.date.and_hms_opt(7, 30, 0).unwrap().and_utc();
|
||||||
|
if [self.start_unit_nav, self.end_unit_nav]
|
||||||
|
.iter()
|
||||||
|
.any(|value| !value.is_finite() || *value <= 0.0)
|
||||||
|
|| [self.start_gross_exposure, self.end_gross_exposure]
|
||||||
|
.iter()
|
||||||
|
.any(|value| !value.is_finite() || *value < 0.0)
|
||||||
|
|| self
|
||||||
|
.previous_session_date
|
||||||
|
.is_some_and(|date| date >= self.date)
|
||||||
|
|| self.available_at < earliest
|
||||||
|
|| self.source_sha256.len() != 64
|
||||||
|
|| !self
|
||||||
|
.source_sha256
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||||
|
{
|
||||||
|
return Err(PortfolioLossError::InvalidObservation);
|
||||||
|
}
|
||||||
|
self.unit_return()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unit_return(&self) -> Result<Option<f64>, PortfolioLossError> {
|
||||||
|
let gross = self.start_gross_exposure.max(self.end_gross_exposure);
|
||||||
|
if gross <= 1e-12 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let value = (self.end_unit_nav / self.start_unit_nav - 1.0) / gross;
|
||||||
|
if !value.is_finite() {
|
||||||
|
return Err(PortfolioLossError::InvalidObservation);
|
||||||
|
}
|
||||||
|
Ok(Some(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct PortfolioLossDecision {
|
||||||
|
pub execution_date: NaiveDate,
|
||||||
|
pub observed_through: Option<NaiveDate>,
|
||||||
|
pub observation_count: usize,
|
||||||
|
pub trailing_unit_return: Option<f64>,
|
||||||
|
pub threshold_breached: bool,
|
||||||
|
pub newly_triggered: bool,
|
||||||
|
pub risk_off: bool,
|
||||||
|
pub cooldown_before: usize,
|
||||||
|
pub cooldown_after: usize,
|
||||||
|
pub target_exposure: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct PortfolioLossState {
|
||||||
|
schema_version: String,
|
||||||
|
config: PortfolioLossConfig,
|
||||||
|
started_on: NaiveDate,
|
||||||
|
observations: VecDeque<ClosedPortfolioSession>,
|
||||||
|
last_session: Option<ClosedPortfolioSession>,
|
||||||
|
cooldown_remaining: usize,
|
||||||
|
trigger_count: usize,
|
||||||
|
last_decision: Option<PortfolioLossDecision>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error, PartialEq, Eq)]
|
||||||
|
pub enum PortfolioLossError {
|
||||||
|
#[error("invalid portfolio loss configuration")]
|
||||||
|
InvalidConfig,
|
||||||
|
#[error("invalid finalized portfolio session observation")]
|
||||||
|
InvalidObservation,
|
||||||
|
#[error("portfolio loss state does not match its frozen configuration")]
|
||||||
|
StateMismatch,
|
||||||
|
#[error("portfolio session history is missing, reordered or corrected")]
|
||||||
|
SessionDiscontinuity,
|
||||||
|
#[error("portfolio loss observation is not visible at the decision")]
|
||||||
|
ObservationNotVisible,
|
||||||
|
#[error("portfolio loss decisions must follow trading-session order")]
|
||||||
|
DecisionOrder,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PortfolioLossState {
|
||||||
|
pub fn new(
|
||||||
|
config: PortfolioLossConfig,
|
||||||
|
started_on: NaiveDate,
|
||||||
|
) -> Result<Self, PortfolioLossError> {
|
||||||
|
config.validate()?;
|
||||||
|
Ok(Self {
|
||||||
|
schema_version: STATE_SCHEMA.to_owned(),
|
||||||
|
config,
|
||||||
|
started_on,
|
||||||
|
observations: VecDeque::new(),
|
||||||
|
last_session: None,
|
||||||
|
cooldown_remaining: 0,
|
||||||
|
trigger_count: 0,
|
||||||
|
last_decision: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validation is required after deserialization; a JSON hash alone is not
|
||||||
|
/// account/generation authorization, which belongs to the state owner.
|
||||||
|
pub fn validate(&self, expected: &PortfolioLossConfig) -> Result<(), PortfolioLossError> {
|
||||||
|
expected.validate()?;
|
||||||
|
if self.schema_version != STATE_SCHEMA
|
||||||
|
|| &self.config != expected
|
||||||
|
|| self.observations.len() > MAX_OBSERVATIONS
|
||||||
|
|| self.cooldown_remaining >= expected.cooldown_trading_days
|
||||||
|
{
|
||||||
|
return Err(PortfolioLossError::StateMismatch);
|
||||||
|
}
|
||||||
|
let mut previous = None;
|
||||||
|
for item in &self.observations {
|
||||||
|
item.validate()?;
|
||||||
|
if item.date < self.started_on
|
||||||
|
|| previous.is_some_and(|date| item.date <= date)
|
||||||
|
|| item.unit_return()?.is_none()
|
||||||
|
{
|
||||||
|
return Err(PortfolioLossError::StateMismatch);
|
||||||
|
}
|
||||||
|
previous = Some(item.date);
|
||||||
|
}
|
||||||
|
if let Some(last) = &self.last_session {
|
||||||
|
last.validate()?;
|
||||||
|
if last.date < self.started_on
|
||||||
|
|| previous.is_some_and(|date| date > last.date)
|
||||||
|
|| (last.unit_return()?.is_some() && self.observations.back() != Some(last))
|
||||||
|
{
|
||||||
|
return Err(PortfolioLossError::StateMismatch);
|
||||||
|
}
|
||||||
|
} else if !self.observations.is_empty() {
|
||||||
|
return Err(PortfolioLossError::StateMismatch);
|
||||||
|
}
|
||||||
|
if let Some(decision) = &self.last_decision {
|
||||||
|
let breached = decision
|
||||||
|
.trailing_unit_return
|
||||||
|
.is_some_and(|value| value <= -expected.loss_trigger);
|
||||||
|
let triggered = decision.cooldown_before == 0 && breached;
|
||||||
|
let after = if decision.cooldown_before > 0 {
|
||||||
|
decision.cooldown_before - 1
|
||||||
|
} else if triggered {
|
||||||
|
expected.cooldown_trading_days - 1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
if decision.execution_date < self.started_on
|
||||||
|
|| decision
|
||||||
|
.observed_through
|
||||||
|
.is_some_and(|date| date >= decision.execution_date)
|
||||||
|
|| !decision.target_exposure.is_finite()
|
||||||
|
|| !(0.0..=1.0).contains(&decision.target_exposure)
|
||||||
|
|| decision
|
||||||
|
.trailing_unit_return
|
||||||
|
.is_some_and(|value| !value.is_finite())
|
||||||
|
|| decision.cooldown_after != self.cooldown_remaining
|
||||||
|
|| decision.observation_count > MAX_OBSERVATIONS
|
||||||
|
|| decision.cooldown_before >= expected.cooldown_trading_days
|
||||||
|
|| decision.threshold_breached != breached
|
||||||
|
|| decision.newly_triggered != triggered
|
||||||
|
|| decision.risk_off != (decision.cooldown_before > 0 || triggered)
|
||||||
|
|| decision.cooldown_after != after
|
||||||
|
|| decision.trailing_unit_return.is_some()
|
||||||
|
!= (decision.observation_count >= expected.lookback)
|
||||||
|
|| self.trigger_count
|
||||||
|
> (decision.execution_date - self.started_on).num_days() as usize + 1
|
||||||
|
{
|
||||||
|
return Err(PortfolioLossError::StateMismatch);
|
||||||
|
}
|
||||||
|
} else if self.cooldown_remaining != 0 || self.trigger_count != 0 {
|
||||||
|
return Err(PortfolioLossError::StateMismatch);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exact duplicate delivery is idempotent. Historical corrections require
|
||||||
|
/// explicit reconciliation instead of changing an already-used window.
|
||||||
|
pub fn observe(&mut self, session: ClosedPortfolioSession) -> Result<bool, PortfolioLossError> {
|
||||||
|
self.validate(&self.config)?;
|
||||||
|
session.validate()?;
|
||||||
|
if self.last_session.as_ref() == Some(&session) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let previous_date = self.last_session.as_ref().map(|value| value.date);
|
||||||
|
if session.date < self.started_on
|
||||||
|
|| session.previous_session_date != previous_date
|
||||||
|
|| previous_date.is_some_and(|date| session.date <= date)
|
||||||
|
|| (previous_date.is_none() && session.date != self.started_on)
|
||||||
|
|| self
|
||||||
|
.last_session
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|last| session.start_unit_nav != last.end_unit_nav)
|
||||||
|
{
|
||||||
|
return Err(PortfolioLossError::SessionDiscontinuity);
|
||||||
|
}
|
||||||
|
if session.unit_return()?.is_some() {
|
||||||
|
self.observations.push_back(session.clone());
|
||||||
|
if self.observations.len() > MAX_OBSERVATIONS {
|
||||||
|
self.observations.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.last_session = Some(session);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decide(
|
||||||
|
&mut self,
|
||||||
|
execution_date: NaiveDate,
|
||||||
|
previous_completed_session: Option<NaiveDate>,
|
||||||
|
decision_at: DateTime<Utc>,
|
||||||
|
risk_on_exposure: f64,
|
||||||
|
) -> Result<PortfolioLossDecision, PortfolioLossError> {
|
||||||
|
self.validate(&self.config)?;
|
||||||
|
if !risk_on_exposure.is_finite() || !(0.0..=1.0).contains(&risk_on_exposure) {
|
||||||
|
return Err(PortfolioLossError::InvalidConfig);
|
||||||
|
}
|
||||||
|
if execution_date < self.started_on
|
||||||
|
|| previous_completed_session.is_some_and(|date| date >= execution_date)
|
||||||
|
|| decision_at
|
||||||
|
.with_timezone(&FixedOffset::east_opt(8 * 3600).unwrap())
|
||||||
|
.date_naive()
|
||||||
|
!= execution_date
|
||||||
|
|| self
|
||||||
|
.last_decision
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|last| execution_date < last.execution_date)
|
||||||
|
{
|
||||||
|
return Err(PortfolioLossError::DecisionOrder);
|
||||||
|
}
|
||||||
|
if let Some(last) = &self.last_session {
|
||||||
|
if last.date >= execution_date || last.available_at > decision_at {
|
||||||
|
return Err(PortfolioLossError::ObservationNotVisible);
|
||||||
|
}
|
||||||
|
if Some(last.date) != previous_completed_session {
|
||||||
|
return Err(PortfolioLossError::SessionDiscontinuity);
|
||||||
|
}
|
||||||
|
} else if execution_date != self.started_on {
|
||||||
|
return Err(PortfolioLossError::SessionDiscontinuity);
|
||||||
|
}
|
||||||
|
if let Some(cached) = self
|
||||||
|
.last_decision
|
||||||
|
.as_mut()
|
||||||
|
.filter(|last| last.execution_date == execution_date)
|
||||||
|
{
|
||||||
|
cached.target_exposure = if cached.risk_off {
|
||||||
|
self.config.floor_exposure.min(risk_on_exposure)
|
||||||
|
} else {
|
||||||
|
risk_on_exposure
|
||||||
|
};
|
||||||
|
return Ok(cached.clone());
|
||||||
|
}
|
||||||
|
let trailing = if self.observations.len() >= self.config.lookback {
|
||||||
|
let start = self.observations.len() - self.config.lookback;
|
||||||
|
let mut growth = 1.0;
|
||||||
|
for item in self.observations.iter().skip(start) {
|
||||||
|
growth *=
|
||||||
|
(1.0 + item.unit_return()?.expect("nonzero exposure observation")).max(0.0);
|
||||||
|
}
|
||||||
|
let result = growth - 1.0;
|
||||||
|
if !result.is_finite() {
|
||||||
|
return Err(PortfolioLossError::InvalidObservation);
|
||||||
|
}
|
||||||
|
Some(result)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let breached = trailing.is_some_and(|value| value <= -self.config.loss_trigger);
|
||||||
|
let before = self.cooldown_remaining;
|
||||||
|
let triggered = before == 0 && breached;
|
||||||
|
let risk_off = before > 0 || triggered;
|
||||||
|
let after = if before > 0 {
|
||||||
|
before - 1
|
||||||
|
} else if triggered {
|
||||||
|
self.config.cooldown_trading_days - 1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let decision = PortfolioLossDecision {
|
||||||
|
execution_date,
|
||||||
|
observed_through: self.last_session.as_ref().map(|value| value.date),
|
||||||
|
observation_count: self.observations.len(),
|
||||||
|
trailing_unit_return: trailing,
|
||||||
|
threshold_breached: breached,
|
||||||
|
newly_triggered: triggered,
|
||||||
|
risk_off,
|
||||||
|
cooldown_before: before,
|
||||||
|
cooldown_after: after,
|
||||||
|
target_exposure: if risk_off {
|
||||||
|
self.config.floor_exposure.min(risk_on_exposure)
|
||||||
|
} else {
|
||||||
|
risk_on_exposure
|
||||||
|
},
|
||||||
|
};
|
||||||
|
self.cooldown_remaining = after;
|
||||||
|
self.trigger_count += usize::from(triggered);
|
||||||
|
self.last_decision = Some(decision.clone());
|
||||||
|
Ok(decision)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn last_session(&self) -> Option<&ClosedPortfolioSession> {
|
||||||
|
self.last_session.as_ref()
|
||||||
|
}
|
||||||
|
pub fn last_decision(&self) -> Option<&PortfolioLossDecision> {
|
||||||
|
self.last_decision.as_ref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::{Duration, TimeZone};
|
||||||
|
|
||||||
|
fn date(day: i64) -> NaiveDate {
|
||||||
|
NaiveDate::from_ymd_opt(2023, 1, 3).unwrap() + Duration::days(day)
|
||||||
|
}
|
||||||
|
fn time(day: i64, hour: u32) -> DateTime<Utc> {
|
||||||
|
Utc.from_utc_datetime(&date(day).and_hms_opt(hour, 0, 0).unwrap())
|
||||||
|
}
|
||||||
|
fn config() -> PortfolioLossConfig {
|
||||||
|
PortfolioLossConfig {
|
||||||
|
lookback: 10,
|
||||||
|
loss_trigger: 0.05,
|
||||||
|
floor_exposure: 0.2,
|
||||||
|
cooldown_trading_days: 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn session(day: i64, start: f64, end: f64, gross: f64) -> ClosedPortfolioSession {
|
||||||
|
ClosedPortfolioSession {
|
||||||
|
date: date(day),
|
||||||
|
previous_session_date: (day > 0).then(|| date(day - 1)),
|
||||||
|
available_at: time(day, 8),
|
||||||
|
start_unit_nav: start,
|
||||||
|
end_unit_nav: end,
|
||||||
|
start_gross_exposure: gross,
|
||||||
|
end_gross_exposure: gross,
|
||||||
|
source_sha256: "a".repeat(64),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restart_is_exact_and_duplicate_decisions_do_not_consume_cooldown() {
|
||||||
|
let mut state = PortfolioLossState::new(config(), date(0)).unwrap();
|
||||||
|
let mut nav = 1.0;
|
||||||
|
for day in 0..10 {
|
||||||
|
let end = nav * 0.994;
|
||||||
|
state.observe(session(day, nav, end, 1.0)).unwrap();
|
||||||
|
nav = end;
|
||||||
|
}
|
||||||
|
let first = state
|
||||||
|
.decide(date(10), Some(date(9)), time(10, 1), 0.9)
|
||||||
|
.unwrap();
|
||||||
|
assert!(first.newly_triggered);
|
||||||
|
assert_eq!(first.cooldown_after, 2);
|
||||||
|
let serialized = serde_json::to_string(&state).unwrap();
|
||||||
|
let mut restored: PortfolioLossState = serde_json::from_str(&serialized).unwrap();
|
||||||
|
restored.validate(&config()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
first,
|
||||||
|
restored
|
||||||
|
.decide(date(10), Some(date(9)), time(10, 1), 0.9)
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
let lowered = restored
|
||||||
|
.decide(date(10), Some(date(9)), time(10, 2), 0.1)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(lowered.target_exposure, 0.1);
|
||||||
|
assert_eq!(lowered.cooldown_after, 2);
|
||||||
|
for day in 10..15 {
|
||||||
|
let end = nav * 1.01;
|
||||||
|
let row = session(day, nav, end, 0.2);
|
||||||
|
state.observe(row.clone()).unwrap();
|
||||||
|
restored.observe(row).unwrap();
|
||||||
|
nav = end;
|
||||||
|
assert_eq!(
|
||||||
|
state
|
||||||
|
.decide(date(day + 1), Some(date(day)), time(day + 1, 1), 0.9)
|
||||||
|
.unwrap(),
|
||||||
|
restored
|
||||||
|
.decide(date(day + 1), Some(date(day)), time(day + 1, 1), 0.9)
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refuses_future_missing_corrected_and_incomplete_accounting() {
|
||||||
|
let mut state = PortfolioLossState::new(config(), date(0)).unwrap();
|
||||||
|
let first = session(0, 1.0, 0.99, 1.0);
|
||||||
|
assert!(state.observe(first.clone()).unwrap());
|
||||||
|
assert!(!state.observe(first.clone()).unwrap());
|
||||||
|
let original = state.clone();
|
||||||
|
let mut changed = first;
|
||||||
|
changed.end_unit_nav = 0.98;
|
||||||
|
assert_eq!(
|
||||||
|
state.observe(changed),
|
||||||
|
Err(PortfolioLossError::SessionDiscontinuity)
|
||||||
|
);
|
||||||
|
assert_eq!(state, original);
|
||||||
|
assert_eq!(
|
||||||
|
state.decide(date(0), None, time(0, 1), 0.9),
|
||||||
|
Err(PortfolioLossError::ObservationNotVisible)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.decide(date(2), Some(date(1)), time(2, 1), 0.9),
|
||||||
|
Err(PortfolioLossError::SessionDiscontinuity)
|
||||||
|
);
|
||||||
|
let mut late = PortfolioLossState::new(config(), date(0)).unwrap();
|
||||||
|
let mut delayed = session(0, 1.0, 0.99, 1.0);
|
||||||
|
delayed.available_at = time(2, 1);
|
||||||
|
late.observe(delayed).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
late.decide(date(1), Some(date(0)), time(1, 1), 0.9),
|
||||||
|
Err(PortfolioLossError::ObservationNotVisible)
|
||||||
|
);
|
||||||
|
let mut invalid = session(1, 0.99, 1.0, 1.0);
|
||||||
|
invalid.end_unit_nav = f64::NAN;
|
||||||
|
assert_eq!(
|
||||||
|
state.observe(invalid),
|
||||||
|
Err(PortfolioLossError::InvalidObservation)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cash_sessions_preserve_continuity_without_inventing_returns() {
|
||||||
|
let mut state = PortfolioLossState::new(config(), date(0)).unwrap();
|
||||||
|
for day in 0..20 {
|
||||||
|
state.observe(session(day, 1.0, 1.0, 0.0)).unwrap();
|
||||||
|
}
|
||||||
|
let decision = state
|
||||||
|
.decide(date(20), Some(date(19)), time(20, 1), 0.9)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(decision.observation_count, 0);
|
||||||
|
assert_eq!(decision.trailing_unit_return, None);
|
||||||
|
assert_eq!(decision.target_exposure, 0.9);
|
||||||
|
assert_eq!(state.last_session().unwrap().date, date(19));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restored_state_rejects_changed_policy_and_forged_cooldown() {
|
||||||
|
let state = PortfolioLossState::new(config(), date(0)).unwrap();
|
||||||
|
let mut changed = config();
|
||||||
|
changed.floor_exposure = 0.5;
|
||||||
|
assert_eq!(
|
||||||
|
state.validate(&changed),
|
||||||
|
Err(PortfolioLossError::StateMismatch)
|
||||||
|
);
|
||||||
|
let mut forged = state.clone();
|
||||||
|
forged.cooldown_remaining = 1;
|
||||||
|
assert_eq!(
|
||||||
|
forged.validate(&config()),
|
||||||
|
Err(PortfolioLossError::StateMismatch)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nav_serialization_preserves_float_bits() {
|
||||||
|
let mut seed = 0xabcddcba12345678_u64;
|
||||||
|
for _ in 0..2000 {
|
||||||
|
seed ^= seed << 13;
|
||||||
|
seed ^= seed >> 7;
|
||||||
|
seed ^= seed << 17;
|
||||||
|
let value = 0.01 + (seed as f64 / u64::MAX as f64) * 9.99;
|
||||||
|
let serialized = serde_json::to_string(&value).unwrap();
|
||||||
|
let restored: f64 = serde_json::from_str(&serialized).unwrap();
|
||||||
|
assert_eq!(value.to_bits(), restored.to_bits());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
@@ -3154,6 +3211,7 @@ mod tests {
|
|||||||
pe_ttm: 12.0,
|
pe_ttm: 12.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|||||||
@@ -339,6 +339,7 @@ mod tests {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(0.01),
|
turnover_ratio: Some(0.01),
|
||||||
effective_turnover_ratio: Some(0.01),
|
effective_turnover_ratio: Some(0.01),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: Default::default(),
|
extra_factors: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
@@ -144,6 +145,7 @@ fn stock_factor_snapshot(date: NaiveDate) -> DailyFactorSnapshot {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,6 +272,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -280,6 +283,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -290,6 +294,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use fidc_core::{
|
|||||||
IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
|
IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
|
||||||
StrategyDecision,
|
StrategyDecision,
|
||||||
};
|
};
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||||
@@ -62,6 +63,191 @@ impl Strategy for DecisionQuoteReader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct NoLoaderDecisionQuoteStrategy {
|
||||||
|
symbol_plan_calls: Arc<Mutex<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Strategy for NoLoaderDecisionQuoteStrategy {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"no_loader_decision_quote_strategy"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decision_quote_times(&self) -> Vec<NaiveTime> {
|
||||||
|
vec![t(10, 18, 0)]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decision_quote_symbols(
|
||||||
|
&mut self,
|
||||||
|
_ctx: &StrategyContext<'_>,
|
||||||
|
) -> Result<BTreeSet<String>, fidc_core::BacktestError> {
|
||||||
|
*self
|
||||||
|
.symbol_plan_calls
|
||||||
|
.lock()
|
||||||
|
.expect("symbol plan counter mutex") += 1;
|
||||||
|
Ok(BTreeSet::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
|
||||||
|
DataSet::from_components(
|
||||||
|
Vec::new(),
|
||||||
|
vec![DailyMarketSnapshot {
|
||||||
|
date,
|
||||||
|
symbol: "000001.SZ".to_string(),
|
||||||
|
timestamp: Some(format!("{date} 15:00:00")),
|
||||||
|
day_open: 10.0,
|
||||||
|
open: 10.0,
|
||||||
|
high: 10.2,
|
||||||
|
low: 9.9,
|
||||||
|
close: 10.0,
|
||||||
|
last_price: 10.0,
|
||||||
|
bid1: 10.0,
|
||||||
|
ask1: 10.0,
|
||||||
|
prev_close: 9.8,
|
||||||
|
volume: 10_000,
|
||||||
|
minute_volume: 1_000,
|
||||||
|
bid1_volume: 10_000,
|
||||||
|
ask1_volume: 10_000,
|
||||||
|
trading_phase: Some("continuous".to_string()),
|
||||||
|
paused: false,
|
||||||
|
upper_limit: 10.78,
|
||||||
|
lower_limit: 8.82,
|
||||||
|
price_tick: 0.01,
|
||||||
|
}],
|
||||||
|
vec![DailyFactorSnapshot {
|
||||||
|
date,
|
||||||
|
symbol: "000001.SZ".to_string(),
|
||||||
|
market_cap_bn: 10.0,
|
||||||
|
free_float_cap_bn: 10.0,
|
||||||
|
pe_ttm: 10.0,
|
||||||
|
turnover_ratio: None,
|
||||||
|
effective_turnover_ratio: None,
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
|
extra_factors: Default::default(),
|
||||||
|
}],
|
||||||
|
vec![CandidateEligibility {
|
||||||
|
date,
|
||||||
|
symbol: "000001.SZ".to_string(),
|
||||||
|
is_st: false,
|
||||||
|
is_star_st: false,
|
||||||
|
is_new_listing: false,
|
||||||
|
is_paused: false,
|
||||||
|
allow_buy: true,
|
||||||
|
allow_sell: true,
|
||||||
|
is_kcb: false,
|
||||||
|
is_one_yuan: false,
|
||||||
|
risk_level_code: None,
|
||||||
|
}],
|
||||||
|
vec![BenchmarkSnapshot {
|
||||||
|
date,
|
||||||
|
benchmark: "000852.SH".to_string(),
|
||||||
|
open: 1000.0,
|
||||||
|
close: 1001.0,
|
||||||
|
prev_close: 999.0,
|
||||||
|
volume: 1_000_000,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.expect("dataset")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
|
||||||
|
let date = d(2026, 1, 5);
|
||||||
|
let data = single_day_quote_plan_data(date);
|
||||||
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
|
ChinaAShareCostModel::default(),
|
||||||
|
ChinaEquityRuleHooks,
|
||||||
|
PriceField::Close,
|
||||||
|
)
|
||||||
|
.with_matching_type(MatchingType::CurrentBarClose);
|
||||||
|
let config = BacktestConfig {
|
||||||
|
initial_cash: 10_000.0,
|
||||||
|
benchmark_code: "000852.SH".to_string(),
|
||||||
|
start_date: Some(date),
|
||||||
|
end_date: Some(date),
|
||||||
|
decision_lag_trading_days: 0,
|
||||||
|
execution_price_field: PriceField::Close,
|
||||||
|
};
|
||||||
|
let symbol_plan_calls = Arc::new(Mutex::new(0usize));
|
||||||
|
let loader_calls = Arc::new(Mutex::new(0usize));
|
||||||
|
let strategy = NoLoaderDecisionQuoteStrategy {
|
||||||
|
symbol_plan_calls: Arc::clone(&symbol_plan_calls),
|
||||||
|
};
|
||||||
|
let captured_loader_calls = Arc::clone(&loader_calls);
|
||||||
|
let preplanned = Arc::new(BTreeMap::from([(
|
||||||
|
date,
|
||||||
|
BTreeSet::from(["000001.SZ".to_string()]),
|
||||||
|
)]));
|
||||||
|
let mut engine = BacktestEngine::new(data, strategy, broker, config)
|
||||||
|
.with_execution_quote_loader(move |request| {
|
||||||
|
*captured_loader_calls.lock().expect("loader counter mutex") += 1;
|
||||||
|
Ok(request
|
||||||
|
.symbols
|
||||||
|
.into_iter()
|
||||||
|
.map(|symbol| IntradayExecutionQuote {
|
||||||
|
date: request.date,
|
||||||
|
symbol,
|
||||||
|
timestamp: request.date.and_time(t(10, 17, 59)),
|
||||||
|
last_price: 10.0,
|
||||||
|
bid1: 10.0,
|
||||||
|
ask1: 10.0,
|
||||||
|
bid1_volume: 10_000,
|
||||||
|
ask1_volume: 10_000,
|
||||||
|
volume_delta: 10_000,
|
||||||
|
amount_delta: 100_000.0,
|
||||||
|
trading_phase: Some("continuous".to_string()),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
})
|
||||||
|
.with_preplanned_decision_quote_symbols_by_date(preplanned);
|
||||||
|
|
||||||
|
engine.run().expect("backtest should run");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*symbol_plan_calls.lock().expect("symbol plan counter mutex"),
|
||||||
|
0,
|
||||||
|
"the strategy plan must not be recomputed after a complete plan is supplied"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*loader_calls.lock().expect("loader counter mutex"),
|
||||||
|
1,
|
||||||
|
"the supplied symbols must still pass through the normal quote loader"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn engine_skips_decision_quote_symbol_plan_without_loader() {
|
||||||
|
let date = d(2026, 1, 5);
|
||||||
|
let data = single_day_quote_plan_data(date);
|
||||||
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
|
ChinaAShareCostModel::default(),
|
||||||
|
ChinaEquityRuleHooks,
|
||||||
|
PriceField::Close,
|
||||||
|
)
|
||||||
|
.with_matching_type(MatchingType::CurrentBarClose);
|
||||||
|
let config = BacktestConfig {
|
||||||
|
initial_cash: 10_000.0,
|
||||||
|
benchmark_code: "000852.SH".to_string(),
|
||||||
|
start_date: Some(date),
|
||||||
|
end_date: Some(date),
|
||||||
|
decision_lag_trading_days: 0,
|
||||||
|
execution_price_field: PriceField::Close,
|
||||||
|
};
|
||||||
|
let symbol_plan_calls = Arc::new(Mutex::new(0usize));
|
||||||
|
let strategy = NoLoaderDecisionQuoteStrategy {
|
||||||
|
symbol_plan_calls: Arc::clone(&symbol_plan_calls),
|
||||||
|
};
|
||||||
|
let mut engine = BacktestEngine::new(data, strategy, broker, config);
|
||||||
|
|
||||||
|
engine.run().expect("backtest should run");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*symbol_plan_calls.lock().expect("symbol plan counter mutex"),
|
||||||
|
0,
|
||||||
|
"a preloaded/no-loader engine cannot use a newly computed quote symbol plan"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||||
let first = d(2026, 1, 5);
|
let first = d(2026, 1, 5);
|
||||||
@@ -125,6 +311,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: None,
|
turnover_ratio: None,
|
||||||
effective_turnover_ratio: None,
|
effective_turnover_ratio: None,
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: Default::default(),
|
extra_factors: Default::default(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -135,6 +322,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: None,
|
turnover_ratio: None,
|
||||||
effective_turnover_ratio: None,
|
effective_turnover_ratio: None,
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: Default::default(),
|
extra_factors: Default::default(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -293,6 +481,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: None,
|
turnover_ratio: None,
|
||||||
effective_turnover_ratio: None,
|
effective_turnover_ratio: None,
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: Default::default(),
|
extra_factors: Default::default(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -303,6 +492,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: None,
|
turnover_ratio: None,
|
||||||
effective_turnover_ratio: None,
|
effective_turnover_ratio: None,
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: Default::default(),
|
extra_factors: Default::default(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -526,6 +716,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: None,
|
turnover_ratio: None,
|
||||||
effective_turnover_ratio: None,
|
effective_turnover_ratio: None,
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: Default::default(),
|
extra_factors: Default::default(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -536,6 +727,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: None,
|
turnover_ratio: None,
|
||||||
effective_turnover_ratio: None,
|
effective_turnover_ratio: None,
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: Default::default(),
|
extra_factors: Default::default(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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(),
|
||||||
@@ -170,6 +171,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -180,6 +182,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -190,6 +193,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -200,6 +204,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -436,6 +441,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -446,6 +452,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -456,6 +463,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ fn single_day_anchor_data(date: NaiveDate) -> DataSet {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -144,6 +145,7 @@ fn factor_row(
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors,
|
extra_factors,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,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(),
|
||||||
@@ -332,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(),
|
||||||
@@ -375,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(),
|
||||||
@@ -718,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(),
|
||||||
@@ -801,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(),
|
||||||
@@ -842,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(),
|
||||||
@@ -883,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(),
|
||||||
@@ -985,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(),
|
||||||
@@ -1028,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(),
|
||||||
@@ -1137,6 +1148,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -1147,6 +1159,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1353,6 +1366,7 @@ fn engine_executes_open_auction_decisions_before_on_day() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -1453,6 +1467,7 @@ fn engine_executes_futures_order_intents_against_future_account() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -2168,6 +2183,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -2463,6 +2479,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -2631,6 +2648,7 @@ fn strategy_context_exposes_final_order_runtime_view() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -2874,6 +2892,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -2884,6 +2903,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3069,6 +3089,7 @@ fn engine_expires_pending_day_limit_orders_at_market_close() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -3079,6 +3100,7 @@ fn engine_expires_pending_day_limit_orders_at_market_close() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3266,6 +3288,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -3276,6 +3299,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -3286,6 +3310,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3521,6 +3546,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -3531,6 +3557,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -3541,6 +3568,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3886,6 +3914,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -3896,6 +3925,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
|
|||||||
pe_ttm: 12.0,
|
pe_ttm: 12.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -4040,6 +4070,7 @@ fn engine_exposes_current_process_context_to_strategies() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -4112,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(),
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ fn order_value_rounding_data(date: NaiveDate, symbol: &str, price: f64) -> DataS
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -119,6 +120,7 @@ fn intraday_liquidity_data(date: NaiveDate, symbol: &str) -> DataSet {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -207,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(),
|
||||||
@@ -272,6 +275,7 @@ fn single_symbol_limit_price_data(
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -343,6 +347,7 @@ fn broker_executes_explicit_order_value_buy() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -395,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(),
|
||||||
@@ -495,6 +501,7 @@ fn broker_delayed_limit_open_sell_uses_minute_price() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -552,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(),
|
||||||
@@ -629,6 +637,7 @@ fn broker_executes_order_shares_and_order_lots() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -681,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(),
|
||||||
@@ -757,6 +767,7 @@ fn broker_executes_target_shares_like_order_to() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -799,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(),
|
||||||
@@ -904,6 +916,7 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -914,6 +927,7 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() {
|
|||||||
pe_ttm: 14.0,
|
pe_ttm: 14.0,
|
||||||
turnover_ratio: Some(2.2),
|
turnover_ratio: Some(2.2),
|
||||||
effective_turnover_ratio: Some(2.0),
|
effective_turnover_ratio: Some(2.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -972,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(),
|
||||||
@@ -1062,6 +1077,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
|
|||||||
pe_ttm: 14.0,
|
pe_ttm: 14.0,
|
||||||
turnover_ratio: Some(2.2),
|
turnover_ratio: Some(2.2),
|
||||||
effective_turnover_ratio: Some(2.0),
|
effective_turnover_ratio: Some(2.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -1129,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(),
|
||||||
@@ -1205,6 +1222,7 @@ fn broker_executes_order_percent_and_target_percent() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -1243,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(),
|
||||||
@@ -1267,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(),
|
||||||
@@ -1329,6 +1349,7 @@ fn broker_uses_day_open_price_for_open_auction_matching() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -1368,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(),
|
||||||
@@ -1435,6 +1457,7 @@ fn broker_open_auction_uses_auction_volume_without_quote_liquidity() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -1474,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(),
|
||||||
@@ -1538,6 +1562,7 @@ fn broker_cancels_buy_when_open_hits_upper_limit() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -1576,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(),
|
||||||
@@ -1653,6 +1679,7 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -1692,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(),
|
||||||
@@ -1755,6 +1783,7 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -1796,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(),
|
||||||
@@ -1862,6 +1892,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -1916,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(),
|
||||||
@@ -1982,6 +2014,7 @@ fn broker_rejects_intraday_last_order_without_execution_quotes() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -2021,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(),
|
||||||
@@ -2091,6 +2125,7 @@ fn broker_executes_intraday_last_on_start_quote_with_trade_delta() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -2144,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(),
|
||||||
@@ -2209,6 +2245,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -2262,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(),
|
||||||
@@ -2339,6 +2377,7 @@ fn broker_cancels_market_buy_when_minute_has_no_volume() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -2377,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(),
|
||||||
@@ -2445,6 +2485,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -2512,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(),
|
||||||
@@ -2616,6 +2658,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -2684,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(),
|
||||||
@@ -2771,6 +2815,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -2864,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(),
|
||||||
@@ -2931,6 +2977,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -3011,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(),
|
||||||
@@ -3093,6 +3141,7 @@ fn broker_uses_best_own_price_for_intraday_matching() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -3146,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(),
|
||||||
@@ -3210,6 +3260,7 @@ fn broker_uses_best_counterparty_price_for_intraday_matching() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -3263,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(),
|
||||||
@@ -3365,6 +3417,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -3375,6 +3428,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
|
|||||||
pe_ttm: 18.0,
|
pe_ttm: 18.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3432,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(),
|
||||||
@@ -3558,6 +3613,7 @@ fn rebalance_uses_day_open_for_open_auction_valuation() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -3568,6 +3624,7 @@ fn rebalance_uses_day_open_for_open_auction_valuation() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
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,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3626,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),
|
||||||
@@ -3744,6 +3802,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -3754,6 +3813,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
|
|||||||
pe_ttm: 18.0,
|
pe_ttm: 18.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3808,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),
|
||||||
@@ -3926,6 +3987,7 @@ fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -3936,6 +3998,7 @@ fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() {
|
|||||||
pe_ttm: 18.0,
|
pe_ttm: 18.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3990,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),
|
||||||
@@ -4062,6 +4126,7 @@ fn broker_uses_board_specific_min_quantity_and_step_size_for_buy_sizing() {
|
|||||||
pe_ttm: 20.0,
|
pe_ttm: 20.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -4103,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(),
|
||||||
@@ -4166,6 +4232,7 @@ fn broker_allows_bjse_quantities_above_minimum_without_round_lot_step() {
|
|||||||
pe_ttm: 20.0,
|
pe_ttm: 20.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -4207,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(),
|
||||||
@@ -4272,6 +4340,7 @@ fn broker_allows_full_odd_lot_sell_when_liquidating_position() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -4312,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(),
|
||||||
@@ -4386,6 +4456,7 @@ fn same_day_sell_then_rebuy_is_rejected_by_default() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -4444,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(),
|
||||||
@@ -4526,6 +4598,7 @@ fn same_day_sell_then_rebuy_can_be_allowed_by_policy() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -4587,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(),
|
||||||
@@ -4640,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(),
|
||||||
@@ -4686,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(),
|
||||||
@@ -4778,6 +4854,7 @@ fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -4788,6 +4865,7 @@ fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -4859,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(),
|
||||||
@@ -4899,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(),
|
||||||
@@ -5825,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(),
|
||||||
@@ -5863,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(),
|
||||||
@@ -5907,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(),
|
||||||
@@ -5932,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(),
|
||||||
@@ -5968,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(),
|
||||||
@@ -5991,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(),
|
||||||
@@ -6038,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(),
|
||||||
@@ -6104,6 +6191,7 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -6145,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(),
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ fn factor(
|
|||||||
pe_ttm: 18.0,
|
pe_ttm: 18.0,
|
||||||
turnover_ratio: None,
|
turnover_ratio: None,
|
||||||
effective_turnover_ratio: None,
|
effective_turnover_ratio: None,
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: Default::default(),
|
extra_factors: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,47 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "fidc-engine-optimization-rejection/v1",
|
||||||
|
"generatedDate": "2026-09-07",
|
||||||
|
"host": "192.168.31.177",
|
||||||
|
"candidate": {
|
||||||
|
"engineCommit": "757b5665ca01bceb2bf8afe1ae2fe2dfc9c52c5c",
|
||||||
|
"implementationIdentitySha256": "a60aed1a405c1dc4713fdf72a379dcd07bc493998ec92c0aa0573c21ee863fc8",
|
||||||
|
"description": "Keep the flat immutable quote base and merge a run-local lazy quote overlay through every DataSet quote API."
|
||||||
|
},
|
||||||
|
"revert": {
|
||||||
|
"engineCommit": "728ed795b0c3b9bac9b7e79cb90e469c106e3629",
|
||||||
|
"status": "deployed_and_healthy"
|
||||||
|
},
|
||||||
|
"measurements": {
|
||||||
|
"currentCloseOneYear": {
|
||||||
|
"acceptedHotMedianEngineSeconds": 0.722,
|
||||||
|
"candidateHotMedianEngineSeconds": 0.858,
|
||||||
|
"candidateHotMedianTotalSeconds": 1.56,
|
||||||
|
"engineRegressionPercent": 18.836565
|
||||||
|
},
|
||||||
|
"nextBarOpenFiveYear": {
|
||||||
|
"acceptedHotMedianEngineSeconds": 2.223,
|
||||||
|
"candidateHotMedianEngineSeconds": 3.319,
|
||||||
|
"candidateHotMedianTotalSeconds": 4.032,
|
||||||
|
"engineRegressionPercent": 49.302744
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"totalReturnEqual": true,
|
||||||
|
"tradeCountEqual": true,
|
||||||
|
"canonicalResultDigestEqual": true,
|
||||||
|
"resultStoreDigestEqual": true,
|
||||||
|
"terminalAuditEqual": true,
|
||||||
|
"candidateWorkspaceCoreTestsPassed": 442,
|
||||||
|
"revertWorkspaceCoreTestsPassed": 441,
|
||||||
|
"workspaceFailures": 0
|
||||||
|
},
|
||||||
|
"decision": {
|
||||||
|
"status": "rejected_and_removed",
|
||||||
|
"reason": "Public DataSet reads paid overlay branching and enlarged code paths even when no overlay existed; end-to-end regressions outweighed the avoided lazy-write clone.",
|
||||||
|
"replacementDirection": "Build immutable shared Base Panels before engine execution. Any supplemental intraday store must be private to the broker/loader and must not alter generic DataSet read APIs."
|
||||||
|
},
|
||||||
|
"supportingEvidence": [
|
||||||
|
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/current-close-run-overlay-rejection-20260907.json",
|
||||||
|
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/next-open-run-overlay-rejection-20260907.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "fidc-engine-optimization-rejection/v1",
|
||||||
|
"generatedDate": "2026-09-07",
|
||||||
|
"host": "192.168.31.177",
|
||||||
|
"candidate": {
|
||||||
|
"engineCommit": "f2de8b281a7f9537665d86895f8c51fb33386d9e",
|
||||||
|
"implementationIdentitySha256": "1ea0d2834901504e79d76d9df722f009a2ced3c2a02ddf247eae8722be135516",
|
||||||
|
"description": "Store execution quote dates, symbol maps and quote vectors as nested Arc values so a lazy write clones only one path."
|
||||||
|
},
|
||||||
|
"revert": {
|
||||||
|
"engineCommit": "68c186f649965118da63ff0f0dc2272005538a55",
|
||||||
|
"implementationIdentitySha256": "4f81eb707baeb76375a03eb8989cb043089b1a7c1d07a998570861eb7dc6fa95",
|
||||||
|
"description": "Restore the flat read-optimized execution quote index."
|
||||||
|
},
|
||||||
|
"measurements": {
|
||||||
|
"currentCloseOneYear": {
|
||||||
|
"candidateHotMedianTotalSeconds": 1.19,
|
||||||
|
"candidateHotMedianEngineSeconds": 0.652,
|
||||||
|
"revertHotMedianTotalSeconds": 1.183,
|
||||||
|
"revertHotMedianEngineSeconds": 0.722,
|
||||||
|
"candidateEngineImprovementPercent": 9.695291
|
||||||
|
},
|
||||||
|
"nextBarOpenFiveYear": {
|
||||||
|
"candidateHotMedianTotalSeconds": 2.896,
|
||||||
|
"candidateHotMedianEngineSeconds": 2.25,
|
||||||
|
"revertHotMedianTotalSeconds": 2.848,
|
||||||
|
"revertHotMedianEngineSeconds": 2.223,
|
||||||
|
"candidateEngineRegressionPercent": 1.214575
|
||||||
|
},
|
||||||
|
"staticCurrentClose": {
|
||||||
|
"candidateHotMedianEngineSeconds": 0.324,
|
||||||
|
"acceptedFlatIndexHotMedianEngineSeconds": 0.322
|
||||||
|
},
|
||||||
|
"fullMinute": {
|
||||||
|
"candidateHotMedianEngineSeconds": 0.225,
|
||||||
|
"acceptedFlatIndexHotMedianEngineSeconds": 0.226
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"totalReturnEqual": true,
|
||||||
|
"tradeCountEqual": true,
|
||||||
|
"canonicalResultDigestEqual": true,
|
||||||
|
"resultStoreDigestEqual": true,
|
||||||
|
"terminalAuditEqual": true,
|
||||||
|
"candidateWorkspaceCoreTestsPassed": 442,
|
||||||
|
"revertWorkspaceCoreTestsPassed": 441,
|
||||||
|
"workspaceFailures": 0
|
||||||
|
},
|
||||||
|
"decision": {
|
||||||
|
"status": "rejected_and_removed",
|
||||||
|
"reason": "Nested Arc indirection improves the lazy-write strategy but causes a repeatable next_bar_open regression and has no static/full-minute read benefit.",
|
||||||
|
"replacementDirection": "Keep the flat immutable base index and isolate lazy additions in a run-local overlay without changing the base iterator representation."
|
||||||
|
},
|
||||||
|
"supportingEvidence": [
|
||||||
|
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/current-close-layered-quote-index-rejection-20260907.json",
|
||||||
|
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/static-pool-layered-quote-index-rejection-20260907.json",
|
||||||
|
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/full-minute-layered-quote-index-rejection-20260907.json",
|
||||||
|
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/next-open-layered-quote-index-rejection-20260907.json",
|
||||||
|
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/current-close-after-layered-index-revert-20260907.json",
|
||||||
|
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/next-open-after-layered-index-revert-20260907.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "fidc-engine-optimization-ab/v1",
|
||||||
|
"generatedDate": "2026-09-07",
|
||||||
|
"host": "192.168.31.177",
|
||||||
|
"change": {
|
||||||
|
"engineCommit": "df7a1ee382843b22d145718576d24e78fdd6c804",
|
||||||
|
"description": "Use binary partition points on the shared ascending market-cap symbol index before constructing per-stock expression state.",
|
||||||
|
"activation": "Only ascending market-cap rank with a market-cap selection field and finite inclusive bounds.",
|
||||||
|
"fallback": "Free-float, custom field, custom expression, descending rank and non-finite bounds keep the original full candidate path.",
|
||||||
|
"cachedBusinessResults": false
|
||||||
|
},
|
||||||
|
"deployment": {
|
||||||
|
"serviceCommit": "e4ed9467b9a4ca3ed56c64724623f0ba0d005b91",
|
||||||
|
"implementationIdentitySha256": "b38dc2bb32d46197eb2c48460757b7e4e1c1a3ea80dee1447a169d35af4e759e",
|
||||||
|
"serviceBinarySha256": "31b97e2ff8d9dca00c685ab7e70c239cb038ba467ed3ec669a237de7b8d0ba27",
|
||||||
|
"runnerBinarySha256": "932d531a222df936c82ca38c3ddad1f940c386e315c906cd3da4f428348114d8",
|
||||||
|
"serviceUser": "boris",
|
||||||
|
"sourceLakeRestarted": false,
|
||||||
|
"health": "ok"
|
||||||
|
},
|
||||||
|
"currentCloseOneYear": {
|
||||||
|
"before": {
|
||||||
|
"processHotMedianTotalSeconds": 1.238,
|
||||||
|
"processHotMedianEngineSeconds": 0.773
|
||||||
|
},
|
||||||
|
"after": {
|
||||||
|
"runs": 5,
|
||||||
|
"processHotMedianTotalSeconds": 1.154,
|
||||||
|
"processHotMedianEngineSeconds": 0.714,
|
||||||
|
"totalReturn": 0.3201517861398,
|
||||||
|
"tradeCount": 5351,
|
||||||
|
"canonicalResultDigest": "7204c6f41b8e39fbf1af7fc55cd601b80f3427a7aa058394ccd8b0b14ca48eed",
|
||||||
|
"resultStoreDigest": "0b4d24ed5ec2b27cc4135707b4c51c78eb2c3e35a20da8108610778c30c72c73",
|
||||||
|
"terminalAuditStatus": "clean"
|
||||||
|
},
|
||||||
|
"improvementPercent": {
|
||||||
|
"total": 6.785137,
|
||||||
|
"engine": 7.6326
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"crossModeRegression": {
|
||||||
|
"staticCurrentClose": {
|
||||||
|
"runs": 6,
|
||||||
|
"processHotMedianTotalSeconds": 0.822,
|
||||||
|
"processHotMedianEngineSeconds": 0.322,
|
||||||
|
"totalReturn": 0.13228843240310018,
|
||||||
|
"tradeCount": 4445,
|
||||||
|
"canonicalResultDigest": "fdfa855295c0b55bdbe6f39952ead1515e844bf033ced974d3c3ddc037a5d0b1",
|
||||||
|
"resultStoreDigest": "697566645116c76ff837cd36f7f9bbd7ad3eb30510a5b95012fb730d5072d511"
|
||||||
|
},
|
||||||
|
"nextBarOpenFiveYear": {
|
||||||
|
"runs": 5,
|
||||||
|
"processHotMedianTotalSeconds": 2.811,
|
||||||
|
"processHotMedianEngineSeconds": 2.188,
|
||||||
|
"totalReturn": 0.9922618879291,
|
||||||
|
"tradeCount": 25827,
|
||||||
|
"canonicalResultDigest": "ac1d167cb1e1073e1d1ecb01e914f94d7560081c1d238e6b4418d86233250719",
|
||||||
|
"resultStoreDigest": "79570e0ae6b07badc1b693dc897dd1381647d259a4fe44ed3e50bc215e2fd088"
|
||||||
|
},
|
||||||
|
"fullMinute": {
|
||||||
|
"runs": 6,
|
||||||
|
"processHotMedianTotalSeconds": 0.598,
|
||||||
|
"processHotMedianEngineSeconds": 0.226,
|
||||||
|
"totalReturn": 0.03473222656500008,
|
||||||
|
"tradeCount": 156,
|
||||||
|
"canonicalResultDigest": "7dae3a618932b90d36e9968c027f08a94d69b8fc6a56b1ea0cc1a2cb771d85b8",
|
||||||
|
"resultStoreDigest": "bbbd7080b8fd7f6e8c3a8132499842bd0e4d644dbfdc3e8dbca7d2c0c1381e93"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"beforePath": "/srv/fidc/canonical/run/fidc-private/evidence/current-close-shared-quote-profile-20260907",
|
||||||
|
"afterPath": "/srv/fidc/canonical/run/fidc-private/evidence/current-close-market-cap-range-profile-20260907",
|
||||||
|
"beforeSamples": 408,
|
||||||
|
"afterSamples": 375,
|
||||||
|
"lostSamples": 0,
|
||||||
|
"stockStatePercentBefore": 6.8,
|
||||||
|
"stockStatePercentAfter": 3.02,
|
||||||
|
"marketCapSelectionPercentBefore": 2.91,
|
||||||
|
"marketCapSelectionPercentAfter": 1.7
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"workspaceCoreTestsPassed": 441,
|
||||||
|
"workspaceCoreTestsIgnored": 6,
|
||||||
|
"workspaceFailures": 0,
|
||||||
|
"allCanonicalDigestsMatched": true,
|
||||||
|
"allResultStoreDigestsMatched": true,
|
||||||
|
"allTerminalAuditsClean": true
|
||||||
|
},
|
||||||
|
"supportingEvidence": [
|
||||||
|
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/current-close-market-cap-range-20260907.json",
|
||||||
|
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/static-pool-market-cap-range-control-20260907.json",
|
||||||
|
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/next-open-market-cap-range-20260907.json",
|
||||||
|
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/full-minute-market-cap-range-control-20260907.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "fidc-backtest-benchmark/v5",
|
||||||
|
"label": "native-pit-intraday",
|
||||||
|
"generatedAtUnixSeconds": 1788790043,
|
||||||
|
"requestSha256": "9dd420ae606878b90ff660fcd4efe520e04a237ec758ed974b87b6d9a72cab73",
|
||||||
|
"requestOrigin": {
|
||||||
|
"kind": "request_file"
|
||||||
|
},
|
||||||
|
"baseline": null,
|
||||||
|
"baselineComparable": true,
|
||||||
|
"baselineComparison": null,
|
||||||
|
"bundleRefresh": null,
|
||||||
|
"baseUrl": "http://127.0.0.1:8081",
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"slot": 0,
|
||||||
|
"cacheClass": "restart_or_cold",
|
||||||
|
"runId": "btr_1788790021780_1150210_0",
|
||||||
|
"status": "succeeded",
|
||||||
|
"queueWaitSeconds": 0.0,
|
||||||
|
"executionSeconds": 8.994,
|
||||||
|
"totalSeconds": 8.994,
|
||||||
|
"runnerSeconds": 7.904,
|
||||||
|
"bundleValidationSeconds": 0.166,
|
||||||
|
"sourceEpochSeconds": 0.0,
|
||||||
|
"strategyContractSeconds": 0.001,
|
||||||
|
"terminalCalendarSeconds": 0.0,
|
||||||
|
"riskFreeRateSeconds": 0.031,
|
||||||
|
"riskFreeRateCacheHit": false,
|
||||||
|
"engineSeconds": 0.012,
|
||||||
|
"dataSeconds": 7.662,
|
||||||
|
"resultSeconds": 0.03,
|
||||||
|
"unattributedSeconds": 0.0,
|
||||||
|
"canonicalSeconds": 0.001,
|
||||||
|
"resultFinalization": {
|
||||||
|
"canonicalSeconds": 0.001,
|
||||||
|
"processEventStoreSeconds": 0.001,
|
||||||
|
"storePublishSeconds": 0.027,
|
||||||
|
"summarySeconds": 0.0
|
||||||
|
},
|
||||||
|
"dataPreparationTimings": {
|
||||||
|
"adjustmentValidationSeconds": 0.0,
|
||||||
|
"availableStockStDates": 0,
|
||||||
|
"benchmarkFetchSeconds": 0.344,
|
||||||
|
"candidatePlanBasePanelSeconds": 0.006,
|
||||||
|
"candidatePlanSelectionSeconds": 0.004,
|
||||||
|
"corporateActionSeconds": 0.605,
|
||||||
|
"datasetConstructSeconds": 0.0,
|
||||||
|
"externalFetchSeconds": 0.344,
|
||||||
|
"historyDateSeconds": 0.0,
|
||||||
|
"instrumentIndexBuildSeconds": 0.0,
|
||||||
|
"instrumentMetadataSeconds": 0.016,
|
||||||
|
"loopSeconds": 0.0,
|
||||||
|
"marketIndexBuildSeconds": 0.001,
|
||||||
|
"quotePlanSeconds": 0.935,
|
||||||
|
"riskSnapshotSeconds": 0.0,
|
||||||
|
"sourceQuerySeconds": 5.752,
|
||||||
|
"stockStLookupSeconds": 0.0,
|
||||||
|
"supplementalExecutionFetchSeconds": 0.0,
|
||||||
|
"totalSeconds": 7.659
|
||||||
|
},
|
||||||
|
"finalizationSeconds": 1.089,
|
||||||
|
"finalizationTimings": {
|
||||||
|
"artifactVerificationSeconds": 0.0,
|
||||||
|
"cacheRegistrationSeconds": 0.0,
|
||||||
|
"postgresClientAcquireSeconds": 0.216,
|
||||||
|
"postgresPersistSeconds": 0.872,
|
||||||
|
"preTerminalUpdateSeconds": 1.089,
|
||||||
|
"schemaCheckSeconds": 0.0,
|
||||||
|
"timingSource": "fidc-backtest-service-monotonic"
|
||||||
|
},
|
||||||
|
"totalReturn": 0.011211658838299776,
|
||||||
|
"tradeCount": 104,
|
||||||
|
"riskDecisionCount": 11,
|
||||||
|
"resultStoreDigest": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4",
|
||||||
|
"canonicalResultDigest": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a",
|
||||||
|
"terminalAudit": {
|
||||||
|
"cashReceivableCount": 0,
|
||||||
|
"cashReceivableTotalAmount": 0.0,
|
||||||
|
"earliestDeferredCashDate": null,
|
||||||
|
"futuresOpenOrderCount": 0,
|
||||||
|
"lastExecutionDate": "2025-09-12",
|
||||||
|
"omittedOpenOrderCount": 0,
|
||||||
|
"openOrderSamples": [],
|
||||||
|
"pendingCashFlowCount": 0,
|
||||||
|
"pendingCashFlowNetAmount": 0.0,
|
||||||
|
"status": "clean",
|
||||||
|
"stockOpenOrderCount": 0
|
||||||
|
},
|
||||||
|
"terminalAuditVerified": true,
|
||||||
|
"terminalAuditSha256": "5836794e260aa2cfd1a06d4568ae6577a37c9cbe09ec0f00995121ea5b2522d9",
|
||||||
|
"implementation": {
|
||||||
|
"buildManifestSha256": "fb4f7ac191c8ab8d8d39fc48014f84ce01d4c0731ffe986e7d5cb0107a3e5539",
|
||||||
|
"builtAt": "2026-09-07T22:06:18+08:00",
|
||||||
|
"engineCommit": "1b78186c4e37273c64c1f9e208c2e47021ee4fff",
|
||||||
|
"identitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
|
||||||
|
"runnerBinarySha256": "7cddebf9ab5cbbd5d6f9bd5c7bf006c3895ee26967d23d80dc86b47c50fb761d",
|
||||||
|
"schemaVersion": "fidc-backtest-implementation/v1",
|
||||||
|
"serviceBinarySha256": "f09d121d73c34ad3cd5e2e0c24b0bdaf1974309c9ec3b93229cfb824b1e9f11a",
|
||||||
|
"serviceCommit": "c80a38b91a398fd713f1f1faaa19a581ea85efad",
|
||||||
|
"status": "verified"
|
||||||
|
},
|
||||||
|
"implementationVerified": true,
|
||||||
|
"implementationIdentitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
|
||||||
|
"artifactManifest": {
|
||||||
|
"canonicalResult": {
|
||||||
|
"algorithm": "sha256",
|
||||||
|
"ordering": "engine_fact_order_v2",
|
||||||
|
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||||
|
"sections": {
|
||||||
|
"accountEvents": {
|
||||||
|
"rowCount": 104,
|
||||||
|
"sha256": "ea6bbbd55df3da0692dfd712f88ef6f8af510ada318641ede71884f5c5d14e7e"
|
||||||
|
},
|
||||||
|
"equityFacts": {
|
||||||
|
"rowCount": 5,
|
||||||
|
"sha256": "96004e235e4603c0a7d1a5629a2f0d2b364258d562e1d397cdd6925718a7d060"
|
||||||
|
},
|
||||||
|
"fillEvents": {
|
||||||
|
"rowCount": 104,
|
||||||
|
"sha256": "2c37f626b7de014f5638dc8edf33f3ca8d23126ab2e62f26f01fbcb33e8c4e48"
|
||||||
|
},
|
||||||
|
"holdingSnapshots": {
|
||||||
|
"rowCount": 112,
|
||||||
|
"sha256": "50abab8a304d2e2a7de344538aff604c8bf96ef00283330d107916cc2e234f67"
|
||||||
|
},
|
||||||
|
"orderEvents": {
|
||||||
|
"rowCount": 108,
|
||||||
|
"sha256": "f327f21151a5d7dbe53d2dcde9202be699f9672f758373eeb9c67985ca3826f5"
|
||||||
|
},
|
||||||
|
"riskAudits": {
|
||||||
|
"rowCount": 11,
|
||||||
|
"sha256": "428a8d7395b54ec5a9523eb745d5007c64b9d9867615e870d95ea9ea176f536e"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sha256": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a",
|
||||||
|
"totalRows": 444
|
||||||
|
},
|
||||||
|
"processEventStore": {
|
||||||
|
"bytes": 44302,
|
||||||
|
"identitySha256": "1444075abea626e8d7d1b8c9c9cda984a2a687ff2b9465db5648e0f4525130e7",
|
||||||
|
"manifestBytes": 461,
|
||||||
|
"manifestSha256": "b43a818096d09648b809a46d12ad9eae39638d57ea52f7696906359036a80c52",
|
||||||
|
"schemaVersion": "fidc-backtest-process-event-manifest/v1",
|
||||||
|
"sections": {
|
||||||
|
"processEvents": {
|
||||||
|
"blockCount": 1,
|
||||||
|
"bytes": 43841,
|
||||||
|
"rowCount": 370,
|
||||||
|
"sha256": "8edb6dad95777e3eef5946acbbdfc4b9125df34a79ac716d8c3ff7835b552bf6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sha256": "661d84b7b430ac25fb5c959df8e7fb309133784d85a7524375cd803e29e52094",
|
||||||
|
"totalEvents": 370
|
||||||
|
},
|
||||||
|
"resultStore": {
|
||||||
|
"bytes": 148006,
|
||||||
|
"identitySha256": "484b7a4a8d5cc77c6222ce01f2fe452c890995a476579f209d1d4ac43885d60a",
|
||||||
|
"manifestBytes": 2999,
|
||||||
|
"manifestSha256": "a0a7072f176db79d781846cc124d2e4794c5084c48c525876863751f709c4e7f",
|
||||||
|
"schemaVersion": "fidc-backtest-fact-manifest/v1",
|
||||||
|
"sections": {
|
||||||
|
"accountEvents": {
|
||||||
|
"blockCount": 1,
|
||||||
|
"bytes": 13313,
|
||||||
|
"rowCount": 104,
|
||||||
|
"sha256": "ab2f936c9072f80d58487b62a6f64553d279582ddfe6af0299afe20447e83424"
|
||||||
|
},
|
||||||
|
"equityFacts": {
|
||||||
|
"blockCount": 1,
|
||||||
|
"bytes": 11009,
|
||||||
|
"rowCount": 5,
|
||||||
|
"sha256": "c40650f5b27cfff873194572ffe28a5412fe77730fe75492c7b5a575f8f5caae"
|
||||||
|
},
|
||||||
|
"fillEvents": {
|
||||||
|
"blockCount": 1,
|
||||||
|
"bytes": 40541,
|
||||||
|
"rowCount": 104,
|
||||||
|
"sha256": "80a3f1d3a9c23db7dec2dc70f029280c0844370be6d87703cd396a234068f708"
|
||||||
|
},
|
||||||
|
"holdingSnapshots": {
|
||||||
|
"blockCount": 1,
|
||||||
|
"bytes": 51338,
|
||||||
|
"rowCount": 112,
|
||||||
|
"sha256": "7684244dc8e7788f777309169be61b14fcb532abdd89fe93f0a7f2980a615ba9"
|
||||||
|
},
|
||||||
|
"orderEvents": {
|
||||||
|
"blockCount": 1,
|
||||||
|
"bytes": 25768,
|
||||||
|
"rowCount": 108,
|
||||||
|
"sha256": "31ff15d2cf79b1eb9c333d19d8fb400c9c5a52c36b0f662330ec39aa9d0f805c"
|
||||||
|
},
|
||||||
|
"riskAudits": {
|
||||||
|
"blockCount": 1,
|
||||||
|
"bytes": 3038,
|
||||||
|
"rowCount": 11,
|
||||||
|
"sha256": "5a7269219a21cf14c23d788a6a96ddf1d1ddf0ef1e3b57a8db4ae4b1da789aff"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sha256": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4",
|
||||||
|
"totalEvents": 444
|
||||||
|
},
|
||||||
|
"schemaVersion": "fidc-backtest-artifacts/v4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slot": 1,
|
||||||
|
"cacheClass": "process_hot",
|
||||||
|
"runId": "btr_1788790036494_1150210_1",
|
||||||
|
"status": "succeeded",
|
||||||
|
"queueWaitSeconds": 0.0,
|
||||||
|
"executionSeconds": 0.596,
|
||||||
|
"totalSeconds": 0.596,
|
||||||
|
"runnerSeconds": 0.026,
|
||||||
|
"bundleValidationSeconds": 0.0,
|
||||||
|
"sourceEpochSeconds": 0.0,
|
||||||
|
"strategyContractSeconds": 0.003,
|
||||||
|
"terminalCalendarSeconds": 0.0,
|
||||||
|
"riskFreeRateSeconds": 0.0,
|
||||||
|
"riskFreeRateCacheHit": true,
|
||||||
|
"engineSeconds": 0.012,
|
||||||
|
"dataSeconds": 0.007,
|
||||||
|
"resultSeconds": 0.002,
|
||||||
|
"unattributedSeconds": 0.0,
|
||||||
|
"canonicalSeconds": 0.001,
|
||||||
|
"resultFinalization": {
|
||||||
|
"canonicalSeconds": 0.001,
|
||||||
|
"processEventStoreSeconds": 0.001,
|
||||||
|
"storePublishSeconds": 0.0,
|
||||||
|
"summarySeconds": 0.0
|
||||||
|
},
|
||||||
|
"dataPreparationTimings": {
|
||||||
|
"adjustmentValidationSeconds": 0.0,
|
||||||
|
"availableStockStDates": 0,
|
||||||
|
"benchmarkFetchSeconds": 0.0,
|
||||||
|
"candidatePlanBasePanelSeconds": 0.0,
|
||||||
|
"candidatePlanSelectionSeconds": 0.0,
|
||||||
|
"corporateActionSeconds": 0.0,
|
||||||
|
"datasetConstructSeconds": 0.0,
|
||||||
|
"externalFetchSeconds": 0.0,
|
||||||
|
"historyDateSeconds": 0.0,
|
||||||
|
"instrumentIndexBuildSeconds": 0.0,
|
||||||
|
"instrumentMetadataSeconds": 0.0,
|
||||||
|
"loopSeconds": 0.0,
|
||||||
|
"marketIndexBuildSeconds": 0.0,
|
||||||
|
"quotePlanSeconds": 0.0,
|
||||||
|
"riskSnapshotSeconds": 0.0,
|
||||||
|
"sourceQuerySeconds": 0.0,
|
||||||
|
"stockStLookupSeconds": 0.0,
|
||||||
|
"supplementalExecutionFetchSeconds": 0.0,
|
||||||
|
"totalSeconds": 0.006
|
||||||
|
},
|
||||||
|
"finalizationSeconds": 0.569,
|
||||||
|
"finalizationTimings": {
|
||||||
|
"artifactVerificationSeconds": 0.001,
|
||||||
|
"cacheRegistrationSeconds": 0.0,
|
||||||
|
"postgresClientAcquireSeconds": 0.27,
|
||||||
|
"postgresPersistSeconds": 0.298,
|
||||||
|
"preTerminalUpdateSeconds": 0.569,
|
||||||
|
"schemaCheckSeconds": 0.0,
|
||||||
|
"timingSource": "fidc-backtest-service-monotonic"
|
||||||
|
},
|
||||||
|
"totalReturn": 0.011211658838299776,
|
||||||
|
"tradeCount": 104,
|
||||||
|
"riskDecisionCount": 11,
|
||||||
|
"resultStoreDigest": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4",
|
||||||
|
"canonicalResultDigest": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a",
|
||||||
|
"terminalAudit": {
|
||||||
|
"cashReceivableCount": 0,
|
||||||
|
"cashReceivableTotalAmount": 0.0,
|
||||||
|
"earliestDeferredCashDate": null,
|
||||||
|
"futuresOpenOrderCount": 0,
|
||||||
|
"lastExecutionDate": "2025-09-12",
|
||||||
|
"omittedOpenOrderCount": 0,
|
||||||
|
"openOrderSamples": [],
|
||||||
|
"pendingCashFlowCount": 0,
|
||||||
|
"pendingCashFlowNetAmount": 0.0,
|
||||||
|
"status": "clean",
|
||||||
|
"stockOpenOrderCount": 0
|
||||||
|
},
|
||||||
|
"terminalAuditVerified": true,
|
||||||
|
"terminalAuditSha256": "5836794e260aa2cfd1a06d4568ae6577a37c9cbe09ec0f00995121ea5b2522d9",
|
||||||
|
"implementation": {
|
||||||
|
"buildManifestSha256": "fb4f7ac191c8ab8d8d39fc48014f84ce01d4c0731ffe986e7d5cb0107a3e5539",
|
||||||
|
"builtAt": "2026-09-07T22:06:18+08:00",
|
||||||
|
"engineCommit": "1b78186c4e37273c64c1f9e208c2e47021ee4fff",
|
||||||
|
"identitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
|
||||||
|
"runnerBinarySha256": "7cddebf9ab5cbbd5d6f9bd5c7bf006c3895ee26967d23d80dc86b47c50fb761d",
|
||||||
|
"schemaVersion": "fidc-backtest-implementation/v1",
|
||||||
|
"serviceBinarySha256": "f09d121d73c34ad3cd5e2e0c24b0bdaf1974309c9ec3b93229cfb824b1e9f11a",
|
||||||
|
"serviceCommit": "c80a38b91a398fd713f1f1faaa19a581ea85efad",
|
||||||
|
"status": "verified"
|
||||||
|
},
|
||||||
|
"implementationVerified": true,
|
||||||
|
"implementationIdentitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
|
||||||
|
"artifactManifest": {
|
||||||
|
"canonicalResult": {
|
||||||
|
"algorithm": "sha256",
|
||||||
|
"ordering": "engine_fact_order_v2",
|
||||||
|
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||||
|
"sections": {
|
||||||
|
"accountEvents": {
|
||||||
|
"rowCount": 104,
|
||||||
|
"sha256": "ea6bbbd55df3da0692dfd712f88ef6f8af510ada318641ede71884f5c5d14e7e"
|
||||||
|
},
|
||||||
|
"equityFacts": {
|
||||||
|
"rowCount": 5,
|
||||||
|
"sha256": "96004e235e4603c0a7d1a5629a2f0d2b364258d562e1d397cdd6925718a7d060"
|
||||||
|
},
|
||||||
|
"fillEvents": {
|
||||||
|
"rowCount": 104,
|
||||||
|
"sha256": "2c37f626b7de014f5638dc8edf33f3ca8d23126ab2e62f26f01fbcb33e8c4e48"
|
||||||
|
},
|
||||||
|
"holdingSnapshots": {
|
||||||
|
"rowCount": 112,
|
||||||
|
"sha256": "50abab8a304d2e2a7de344538aff604c8bf96ef00283330d107916cc2e234f67"
|
||||||
|
},
|
||||||
|
"orderEvents": {
|
||||||
|
"rowCount": 108,
|
||||||
|
"sha256": "f327f21151a5d7dbe53d2dcde9202be699f9672f758373eeb9c67985ca3826f5"
|
||||||
|
},
|
||||||
|
"riskAudits": {
|
||||||
|
"rowCount": 11,
|
||||||
|
"sha256": "428a8d7395b54ec5a9523eb745d5007c64b9d9867615e870d95ea9ea176f536e"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sha256": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a",
|
||||||
|
"totalRows": 444
|
||||||
|
},
|
||||||
|
"processEventStore": {
|
||||||
|
"bytes": 44302,
|
||||||
|
"identitySha256": "1444075abea626e8d7d1b8c9c9cda984a2a687ff2b9465db5648e0f4525130e7",
|
||||||
|
"manifestBytes": 461,
|
||||||
|
"manifestSha256": "b43a818096d09648b809a46d12ad9eae39638d57ea52f7696906359036a80c52",
|
||||||
|
"schemaVersion": "fidc-backtest-process-event-manifest/v1",
|
||||||
|
"sections": {
|
||||||
|
"processEvents": {
|
||||||
|
"blockCount": 1,
|
||||||
|
"bytes": 43841,
|
||||||
|
"rowCount": 370,
|
||||||
|
"sha256": "8edb6dad95777e3eef5946acbbdfc4b9125df34a79ac716d8c3ff7835b552bf6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sha256": "661d84b7b430ac25fb5c959df8e7fb309133784d85a7524375cd803e29e52094",
|
||||||
|
"totalEvents": 370
|
||||||
|
},
|
||||||
|
"resultStore": {
|
||||||
|
"bytes": 148006,
|
||||||
|
"identitySha256": "484b7a4a8d5cc77c6222ce01f2fe452c890995a476579f209d1d4ac43885d60a",
|
||||||
|
"manifestBytes": 2999,
|
||||||
|
"manifestSha256": "a0a7072f176db79d781846cc124d2e4794c5084c48c525876863751f709c4e7f",
|
||||||
|
"schemaVersion": "fidc-backtest-fact-manifest/v1",
|
||||||
|
"sections": {
|
||||||
|
"accountEvents": {
|
||||||
|
"blockCount": 1,
|
||||||
|
"bytes": 13313,
|
||||||
|
"rowCount": 104,
|
||||||
|
"sha256": "ab2f936c9072f80d58487b62a6f64553d279582ddfe6af0299afe20447e83424"
|
||||||
|
},
|
||||||
|
"equityFacts": {
|
||||||
|
"blockCount": 1,
|
||||||
|
"bytes": 11009,
|
||||||
|
"rowCount": 5,
|
||||||
|
"sha256": "c40650f5b27cfff873194572ffe28a5412fe77730fe75492c7b5a575f8f5caae"
|
||||||
|
},
|
||||||
|
"fillEvents": {
|
||||||
|
"blockCount": 1,
|
||||||
|
"bytes": 40541,
|
||||||
|
"rowCount": 104,
|
||||||
|
"sha256": "80a3f1d3a9c23db7dec2dc70f029280c0844370be6d87703cd396a234068f708"
|
||||||
|
},
|
||||||
|
"holdingSnapshots": {
|
||||||
|
"blockCount": 1,
|
||||||
|
"bytes": 51338,
|
||||||
|
"rowCount": 112,
|
||||||
|
"sha256": "7684244dc8e7788f777309169be61b14fcb532abdd89fe93f0a7f2980a615ba9"
|
||||||
|
},
|
||||||
|
"orderEvents": {
|
||||||
|
"blockCount": 1,
|
||||||
|
"bytes": 25768,
|
||||||
|
"rowCount": 108,
|
||||||
|
"sha256": "31ff15d2cf79b1eb9c333d19d8fb400c9c5a52c36b0f662330ec39aa9d0f805c"
|
||||||
|
},
|
||||||
|
"riskAudits": {
|
||||||
|
"blockCount": 1,
|
||||||
|
"bytes": 3038,
|
||||||
|
"rowCount": 11,
|
||||||
|
"sha256": "5a7269219a21cf14c23d788a6a96ddf1d1ddf0ef1e3b57a8db4ae4b1da789aff"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sha256": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4",
|
||||||
|
"totalEvents": 444
|
||||||
|
},
|
||||||
|
"schemaVersion": "fidc-backtest-artifacts/v4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": {
|
||||||
|
"resultConsistent": true,
|
||||||
|
"resultStoreDigest": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4",
|
||||||
|
"canonicalResultDigest": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a",
|
||||||
|
"totalReturn": 0.011211658838299776,
|
||||||
|
"tradeCount": 104,
|
||||||
|
"terminalAudit": {
|
||||||
|
"cashReceivableCount": 0,
|
||||||
|
"cashReceivableTotalAmount": 0.0,
|
||||||
|
"earliestDeferredCashDate": null,
|
||||||
|
"futuresOpenOrderCount": 0,
|
||||||
|
"lastExecutionDate": "2025-09-12",
|
||||||
|
"omittedOpenOrderCount": 0,
|
||||||
|
"openOrderSamples": [],
|
||||||
|
"pendingCashFlowCount": 0,
|
||||||
|
"pendingCashFlowNetAmount": 0.0,
|
||||||
|
"status": "clean",
|
||||||
|
"stockOpenOrderCount": 0
|
||||||
|
},
|
||||||
|
"terminalAuditSha256": "5836794e260aa2cfd1a06d4568ae6577a37c9cbe09ec0f00995121ea5b2522d9",
|
||||||
|
"implementation": {
|
||||||
|
"buildManifestSha256": "fb4f7ac191c8ab8d8d39fc48014f84ce01d4c0731ffe986e7d5cb0107a3e5539",
|
||||||
|
"builtAt": "2026-09-07T22:06:18+08:00",
|
||||||
|
"engineCommit": "1b78186c4e37273c64c1f9e208c2e47021ee4fff",
|
||||||
|
"identitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
|
||||||
|
"runnerBinarySha256": "7cddebf9ab5cbbd5d6f9bd5c7bf006c3895ee26967d23d80dc86b47c50fb761d",
|
||||||
|
"schemaVersion": "fidc-backtest-implementation/v1",
|
||||||
|
"serviceBinarySha256": "f09d121d73c34ad3cd5e2e0c24b0bdaf1974309c9ec3b93229cfb824b1e9f11a",
|
||||||
|
"serviceCommit": "c80a38b91a398fd713f1f1faaa19a581ea85efad",
|
||||||
|
"status": "verified"
|
||||||
|
},
|
||||||
|
"implementationIdentitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
|
||||||
|
"restartOrCold": {
|
||||||
|
"queueWaitSeconds": 0.0,
|
||||||
|
"executionSeconds": 8.994,
|
||||||
|
"totalSeconds": 8.994,
|
||||||
|
"runnerSeconds": 7.904,
|
||||||
|
"bundleValidationSeconds": 0.166,
|
||||||
|
"sourceEpochSeconds": 0.0,
|
||||||
|
"strategyContractSeconds": 0.001,
|
||||||
|
"terminalCalendarSeconds": 0.0,
|
||||||
|
"riskFreeRateSeconds": 0.031,
|
||||||
|
"riskFreeRateCacheHit": false,
|
||||||
|
"engineSeconds": 0.012,
|
||||||
|
"dataSeconds": 7.662,
|
||||||
|
"resultSeconds": 0.03,
|
||||||
|
"unattributedSeconds": 0.0,
|
||||||
|
"canonicalSeconds": 0.001,
|
||||||
|
"finalizationSeconds": 1.089
|
||||||
|
},
|
||||||
|
"processHotMedian": {
|
||||||
|
"queueWaitSeconds": 0.0,
|
||||||
|
"executionSeconds": 0.596,
|
||||||
|
"totalSeconds": 0.596,
|
||||||
|
"runnerSeconds": 0.026,
|
||||||
|
"bundleValidationSeconds": 0.0,
|
||||||
|
"sourceEpochSeconds": 0.0,
|
||||||
|
"strategyContractSeconds": 0.003,
|
||||||
|
"terminalCalendarSeconds": 0.0,
|
||||||
|
"riskFreeRateSeconds": 0.0,
|
||||||
|
"riskFreeRateCacheHit": true,
|
||||||
|
"engineSeconds": 0.012,
|
||||||
|
"dataSeconds": 0.007,
|
||||||
|
"resultSeconds": 0.002,
|
||||||
|
"unattributedSeconds": 0.0,
|
||||||
|
"canonicalSeconds": 0.001,
|
||||||
|
"finalizationSeconds": 0.569
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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,89 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "fidc-engine-optimization-ab/v1",
|
||||||
|
"generatedDate": "2026-09-07",
|
||||||
|
"host": "192.168.31.177",
|
||||||
|
"change": {
|
||||||
|
"engineCommit": "f45b3a71fa78ecb95b0fa80687130ae7385549e8",
|
||||||
|
"description": "Do not clone the shared multi-date execution quote map when a run reaches the end of one execution date.",
|
||||||
|
"dataContract": "The prepared-data cache remains immutable and shared. A uniquely owned run-local quote map still removes completed dates.",
|
||||||
|
"cachedBusinessResults": false
|
||||||
|
},
|
||||||
|
"deployment": {
|
||||||
|
"serviceCommit": "11cfe1b8d09c1da4807400f3d903199ea6f1e711",
|
||||||
|
"implementationIdentitySha256": "7dbc839acb26fed98fc84a0bc221c316bf370046780c6ae75a79171fd52e5535",
|
||||||
|
"serviceBinarySha256": "bc9385ef340b8e0b3d0d17a9251e159806107443865361271ee0ccd8dbe1a768",
|
||||||
|
"runnerBinarySha256": "55c321f39518b5821e35ee1ec5320e35f94590be3aeeeac0827df07670e623fc",
|
||||||
|
"serviceUser": "boris",
|
||||||
|
"sourceLakeRestarted": false,
|
||||||
|
"health": "ok"
|
||||||
|
},
|
||||||
|
"currentCloseOneYear": {
|
||||||
|
"before": {
|
||||||
|
"processHotMedianTotalSeconds": 1.548,
|
||||||
|
"processHotMedianEngineSeconds": 1.09
|
||||||
|
},
|
||||||
|
"after": {
|
||||||
|
"runs": 5,
|
||||||
|
"processHotMedianTotalSeconds": 1.238,
|
||||||
|
"processHotMedianEngineSeconds": 0.773,
|
||||||
|
"totalReturn": 0.3201517861398,
|
||||||
|
"tradeCount": 5351,
|
||||||
|
"canonicalResultDigest": "7204c6f41b8e39fbf1af7fc55cd601b80f3427a7aa058394ccd8b0b14ca48eed",
|
||||||
|
"resultStoreDigest": "0b4d24ed5ec2b27cc4135707b4c51c78eb2c3e35a20da8108610778c30c72c73",
|
||||||
|
"terminalAuditStatus": "clean"
|
||||||
|
},
|
||||||
|
"improvementPercent": {
|
||||||
|
"total": 20.02584,
|
||||||
|
"engine": 29.082569
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"crossModeRegression": {
|
||||||
|
"staticCurrentClose": {
|
||||||
|
"processHotTotalSeconds": 0.866,
|
||||||
|
"processHotEngineSeconds": 0.326,
|
||||||
|
"totalReturn": 0.13228843240310018,
|
||||||
|
"tradeCount": 4445,
|
||||||
|
"canonicalResultDigest": "fdfa855295c0b55bdbe6f39952ead1515e844bf033ced974d3c3ddc037a5d0b1",
|
||||||
|
"resultStoreDigest": "697566645116c76ff837cd36f7f9bbd7ad3eb30510a5b95012fb730d5072d511"
|
||||||
|
},
|
||||||
|
"nextBarOpenFiveYear": {
|
||||||
|
"processHotTotalSeconds": 3.212,
|
||||||
|
"processHotEngineSeconds": 2.597,
|
||||||
|
"totalReturn": 0.9922618879291,
|
||||||
|
"tradeCount": 25827,
|
||||||
|
"canonicalResultDigest": "ac1d167cb1e1073e1d1ecb01e914f94d7560081c1d238e6b4418d86233250719",
|
||||||
|
"resultStoreDigest": "79570e0ae6b07badc1b693dc897dd1381647d259a4fe44ed3e50bc215e2fd088"
|
||||||
|
},
|
||||||
|
"fullMinute": {
|
||||||
|
"processHotTotalSeconds": 0.973,
|
||||||
|
"processHotEngineSeconds": 0.231,
|
||||||
|
"totalReturn": 0.03473222656500008,
|
||||||
|
"tradeCount": 156,
|
||||||
|
"canonicalResultDigest": "7dae3a618932b90d36e9968c027f08a94d69b8fc6a56b1ea0cc1a2cb771d85b8",
|
||||||
|
"resultStoreDigest": "bbbd7080b8fd7f6e8c3a8132499842bd0e4d644dbfdc3e8dbca7d2c0c1381e93"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"beforePath": "/srv/fidc/canonical/run/fidc-private/evidence/current-close-post-preplan-profile-20260907",
|
||||||
|
"afterPath": "/srv/fidc/canonical/run/fidc-private/evidence/current-close-shared-quote-profile-20260907",
|
||||||
|
"beforeSamples": 564,
|
||||||
|
"afterSamples": 408,
|
||||||
|
"lostSamples": 0,
|
||||||
|
"beforeFinding": "Arc::make_mut was reached from remove_execution_quotes_on_date and cloned the shared quote map.",
|
||||||
|
"afterFinding": "The remove_execution_quotes_on_date call chain is absent; release_execution_quotes_on_date accounts for 0.27 percent of sampled cycles."
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"workspaceCoreTestsPassed": 440,
|
||||||
|
"workspaceCoreTestsIgnored": 6,
|
||||||
|
"workspaceFailures": 0,
|
||||||
|
"allCanonicalDigestsMatched": true,
|
||||||
|
"allResultStoreDigestsMatched": true,
|
||||||
|
"allTerminalAuditsClean": true
|
||||||
|
},
|
||||||
|
"supportingEvidence": [
|
||||||
|
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/current-close-shared-quote-release-20260907.json",
|
||||||
|
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/static-pool-shared-quote-release-20260907.json",
|
||||||
|
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/next-open-shared-quote-release-20260907.json",
|
||||||
|
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/full-minute-shared-quote-release-20260907.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "fidc-quote-plan-optimization-ab/v1",
|
||||||
|
"generatedDate": "2026-09-07",
|
||||||
|
"host": "192.168.31.177",
|
||||||
|
"implementation": {
|
||||||
|
"engineCommit": "c934a948c6b3adc0d38b99cf5826481a9a7c3ceb",
|
||||||
|
"serviceCommit": "178cd2a76961b7e687424cc47107c2c1c3f4f74e",
|
||||||
|
"implementationIdentitySha256": "192392bd7c6bbb7d9a8601aca51a55de4a518357d2c3014b4a09e644e7caccc6",
|
||||||
|
"engineChange": "Stream ascending market-cap quote candidates from the shared ordered symbol index without a candidate-state arena or redundant sort.",
|
||||||
|
"runnerChange": "Build the preliminary selection DataSet from daily bundles instead of flattening and regrouping component vectors."
|
||||||
|
},
|
||||||
|
"quotePlanSeconds": {
|
||||||
|
"baselineSamples": [
|
||||||
|
7.143,
|
||||||
|
6.848,
|
||||||
|
7.404
|
||||||
|
],
|
||||||
|
"baselineMedian": 7.143,
|
||||||
|
"candidateSamples": [
|
||||||
|
7.092,
|
||||||
|
6.115,
|
||||||
|
5.931,
|
||||||
|
6.179,
|
||||||
|
6.129,
|
||||||
|
6.096
|
||||||
|
],
|
||||||
|
"candidateMedian": 6.122,
|
||||||
|
"improvementPercent": 14.293714
|
||||||
|
},
|
||||||
|
"dynamicCurrentClose": {
|
||||||
|
"totalReturn": 0.3201517861398,
|
||||||
|
"tradeCount": 5351,
|
||||||
|
"riskDecisionCount": 2661,
|
||||||
|
"canonicalResultDigest": "7204c6f41b8e39fbf1af7fc55cd601b80f3427a7aa058394ccd8b0b14ca48eed",
|
||||||
|
"resultStoreDigest": "0b4d24ed5ec2b27cc4135707b4c51c78eb2c3e35a20da8108610778c30c72c73",
|
||||||
|
"terminalAuditStatus": "clean"
|
||||||
|
},
|
||||||
|
"crossModeRegression": {
|
||||||
|
"staticCurrentClose": {
|
||||||
|
"totalReturn": 0.13228843240310018,
|
||||||
|
"tradeCount": 4445,
|
||||||
|
"canonicalResultDigest": "fdfa855295c0b55bdbe6f39952ead1515e844bf033ced974d3c3ddc037a5d0b1",
|
||||||
|
"resultStoreDigest": "697566645116c76ff837cd36f7f9bbd7ad3eb30510a5b95012fb730d5072d511"
|
||||||
|
},
|
||||||
|
"fullMinute": {
|
||||||
|
"totalReturn": 0.03473222656500008,
|
||||||
|
"tradeCount": 156,
|
||||||
|
"canonicalResultDigest": "7dae3a618932b90d36e9968c027f08a94d69b8fc6a56b1ea0cc1a2cb771d85b8",
|
||||||
|
"resultStoreDigest": "bbbd7080b8fd7f6e8c3a8132499842bd0e4d644dbfdc3e8dbca7d2c0c1381e93"
|
||||||
|
},
|
||||||
|
"nextBarOpenFiveYear": {
|
||||||
|
"totalReturn": 0.9922618879291,
|
||||||
|
"tradeCount": 25827,
|
||||||
|
"canonicalResultDigest": "ac1d167cb1e1073e1d1ecb01e914f94d7560081c1d238e6b4418d86233250719",
|
||||||
|
"resultStoreDigest": "79570e0ae6b07badc1b693dc897dd1381647d259a4fe44ed3e50bc215e2fd088"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"testGate": {
|
||||||
|
"engineCorePassed": 441,
|
||||||
|
"engineIgnoredBenchmarks": 6,
|
||||||
|
"runnerPassed": 347,
|
||||||
|
"runnerIgnoredBenchmarks": 3,
|
||||||
|
"apiPassed": 80,
|
||||||
|
"failures": 0,
|
||||||
|
"fastPathGenericPathParityTest": true
|
||||||
|
},
|
||||||
|
"remainingBottleneck": {
|
||||||
|
"profilePath": "/srv/fidc/canonical/run/fidc-private/evidence/daily-bundle-quote-plan-cold-profile-20260907",
|
||||||
|
"dataSeconds": 11.215,
|
||||||
|
"quotePlanSeconds": 6.096,
|
||||||
|
"datasetConstructSeconds": 1.677,
|
||||||
|
"loopSeconds": 2.28,
|
||||||
|
"finding": "SymbolPriceSeries, adjusted-close series and DataSet indexes are still built once for quote planning and again for the final run DataSet.",
|
||||||
|
"next": "Replace SourceRowRecord and duplicate preliminary/final DataSet construction with one epoch-scoped typed Base Panel and lightweight run views."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "fidc-typed-adjustment-factor-snapshot/v1",
|
||||||
|
"generatedAt": "2026-09-07T18:12:00+08:00",
|
||||||
|
"scope": "FIDC engine, backtest runner and strategy runtime",
|
||||||
|
"changes": {
|
||||||
|
"engineCommit": "04b45adf98772f4ce3cd8b7a2c08d76480655fee",
|
||||||
|
"backtestServiceCommit": "76748bbc3d2cfc4d76ff8e35c5fcda1fcddd00ec",
|
||||||
|
"tradingPlatformCommit": "bec62d0f7f6126a36ec0a101f7b1a66b138537eb",
|
||||||
|
"datasetSchemaVersion": 58,
|
||||||
|
"sourceRowCacheSchemaVersion": 28,
|
||||||
|
"contract": "adjustment_factor_backward1 remains a nullable typed field from Arrow decode through DailyFactorSnapshot and AdjustedCloseSeries; dynamic NumericFactorMap values stay sparse",
|
||||||
|
"legacyInputPolicy": "extra_factors containing adjustment_factor_backward1 and non-positive or non-finite typed adjustment values are rejected"
|
||||||
|
},
|
||||||
|
"tests": {
|
||||||
|
"fidcCore": "445 passed, 6 ignored",
|
||||||
|
"backtestRunner": "351 passed, 3 ignored",
|
||||||
|
"backtestApi": "80 passed",
|
||||||
|
"strategyRuntime": "66 passed",
|
||||||
|
"runtimeRollingOrderRegression": "full 5/10/30/100-day framework rolling history produced a valid paper order"
|
||||||
|
},
|
||||||
|
"productionImplementation": {
|
||||||
|
"engineCommit": "04b45adf98772f4ce3cd8b7a2c08d76480655fee",
|
||||||
|
"serviceCommit": "76748bbc3d2cfc4d76ff8e35c5fcda1fcddd00ec",
|
||||||
|
"identitySha256": "2c0ab93663018f4fae8d61ba0c522c0f796837d952bbfb26a6bc229cc52bd722",
|
||||||
|
"runnerBinarySha256": "8591dfa763a52e06467dfe2ff2c8bef4d382ef875b356009fe5a7e8ea2013ea3",
|
||||||
|
"serviceBinarySha256": "4e467981f569e265c4ccc862c04be85eb99e9f4d107878785c5961a8b382d65f",
|
||||||
|
"status": "verified"
|
||||||
|
},
|
||||||
|
"fiveYearFreshProcess": {
|
||||||
|
"baseline": {
|
||||||
|
"runId": "btr_1788772852314_617886_0",
|
||||||
|
"dataSeconds": 14.121,
|
||||||
|
"sourceQuerySeconds": 4.304,
|
||||||
|
"datasetConstructSeconds": 3.672,
|
||||||
|
"riskFreeRateSeconds": 2.77,
|
||||||
|
"totalSeconds": 20.947,
|
||||||
|
"queryCompletedRssKb": 6654344,
|
||||||
|
"datasetConstructCompletedRssKb": 12775136
|
||||||
|
},
|
||||||
|
"candidate": {
|
||||||
|
"runId": "btr_1788775107699_632665_0",
|
||||||
|
"dataSeconds": 13.198,
|
||||||
|
"sourceQuerySeconds": 4.169,
|
||||||
|
"datasetConstructSeconds": 3.499,
|
||||||
|
"riskFreeRateSeconds": 0.01,
|
||||||
|
"totalSeconds": 17.381,
|
||||||
|
"queryCompletedRssKb": 6738520,
|
||||||
|
"datasetConstructCompletedRssKb": 12694000
|
||||||
|
},
|
||||||
|
"improvementPercent": {
|
||||||
|
"dataSeconds": 6.5364,
|
||||||
|
"sourceQuerySeconds": 3.1366,
|
||||||
|
"datasetConstructSeconds": 4.7113,
|
||||||
|
"datasetConstructCompletedRssKb": 0.6351,
|
||||||
|
"totalSecondsAfterRiskFreeNormalization": 4.4317
|
||||||
|
},
|
||||||
|
"comparisonNote": "total wall time is normalized only for the independently measured risk-free lookup difference; data and RSS values are compared directly"
|
||||||
|
},
|
||||||
|
"fiveYearMemoryCold": {
|
||||||
|
"baselineRunIds": [
|
||||||
|
"btr_1788772635840_613911_1",
|
||||||
|
"btr_1788772672944_613911_2",
|
||||||
|
"btr_1788772707181_613911_3"
|
||||||
|
],
|
||||||
|
"candidateRunIds": [
|
||||||
|
"btr_1788775166889_632665_1",
|
||||||
|
"btr_1788775202156_632665_2",
|
||||||
|
"btr_1788775353507_632665_5"
|
||||||
|
],
|
||||||
|
"baselineMedian": {
|
||||||
|
"totalSeconds": 16.809,
|
||||||
|
"dataSeconds": 12.8,
|
||||||
|
"sourceQuerySeconds": 4.411,
|
||||||
|
"dailyLoopSeconds": 1.077,
|
||||||
|
"datasetConstructSeconds": 3.701,
|
||||||
|
"engineSeconds": 3.278
|
||||||
|
},
|
||||||
|
"candidateMedian": {
|
||||||
|
"totalSeconds": 16.653,
|
||||||
|
"dataSeconds": 12.569,
|
||||||
|
"sourceQuerySeconds": 4.054,
|
||||||
|
"dailyLoopSeconds": 1.047,
|
||||||
|
"datasetConstructSeconds": 3.577,
|
||||||
|
"engineSeconds": 3.372
|
||||||
|
},
|
||||||
|
"improvementPercent": {
|
||||||
|
"totalSeconds": 0.9281,
|
||||||
|
"dataSeconds": 1.8047,
|
||||||
|
"sourceQuerySeconds": 8.0934,
|
||||||
|
"dailyLoopSeconds": 2.7855,
|
||||||
|
"datasetConstructSeconds": 3.3504,
|
||||||
|
"engineSeconds": -2.8676
|
||||||
|
},
|
||||||
|
"excludedQueueOutlier": {
|
||||||
|
"runId": "btr_1788775236942_632665_4",
|
||||||
|
"totalSeconds": 69.401,
|
||||||
|
"queueWaitSeconds": 48.625,
|
||||||
|
"blockingRunId": "btr_1788775223727_632665_3",
|
||||||
|
"reason": "another user backtest naturally occupied the single runner; it was not stopped or modified"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"controls": {
|
||||||
|
"currentClose": {
|
||||||
|
"runIds": [
|
||||||
|
"btr_1788775434926_632665_6",
|
||||||
|
"btr_1788775464828_632665_7",
|
||||||
|
"btr_1788775491657_632665_8"
|
||||||
|
],
|
||||||
|
"medianTotalSeconds": 10.044,
|
||||||
|
"totalReturn": 0.3201517861398,
|
||||||
|
"tradeCount": 5351,
|
||||||
|
"canonicalSha256": "7204c6f41b8e39fbf1af7fc55cd601b80f3427a7aa058394ccd8b0b14ca48eed",
|
||||||
|
"resultStoreSha256": "0b4d24ed5ec2b27cc4135707b4c51c78eb2c3e35a20da8108610778c30c72c73"
|
||||||
|
},
|
||||||
|
"staticDaily": {
|
||||||
|
"runId": "btr_1788775531308_632665_9",
|
||||||
|
"totalReturn": 0.13228843240310018,
|
||||||
|
"tradeCount": 4445,
|
||||||
|
"canonicalSha256": "fdfa855295c0b55bdbe6f39952ead1515e844bf033ced974d3c3ddc037a5d0b1",
|
||||||
|
"resultStoreSha256": "697566645116c76ff837cd36f7f9bbd7ad3eb30510a5b95012fb730d5072d511"
|
||||||
|
},
|
||||||
|
"fullMinute": {
|
||||||
|
"runId": "btr_1788775545217_632665_10",
|
||||||
|
"totalReturn": 0.03473222656500008,
|
||||||
|
"tradeCount": 156,
|
||||||
|
"canonicalSha256": "7dae3a618932b90d36e9968c027f08a94d69b8fc6a56b1ea0cc1a2cb771d85b8",
|
||||||
|
"resultStoreSha256": "bbbd7080b8fd7f6e8c3a8132499842bd0e4d644dbfdc3e8dbca7d2c0c1381e93"
|
||||||
|
},
|
||||||
|
"nextOpenFiveYear": {
|
||||||
|
"totalReturn": 0.9922618879291,
|
||||||
|
"tradeCount": 25827,
|
||||||
|
"canonicalSha256": "ac1d167cb1e1073e1d1ecb01e914f94d7560081c1d238e6b4418d86233250719",
|
||||||
|
"resultStoreSha256": "79570e0ae6b07badc1b693dc897dd1381647d259a4fe44ed3e50bc215e2fd088"
|
||||||
|
},
|
||||||
|
"terminalAuditsClean": true
|
||||||
|
},
|
||||||
|
"tradingRuntimeDeployment": {
|
||||||
|
"sourceCommit": "bec62d0f7f6126a36ec0a101f7b1a66b138537eb",
|
||||||
|
"strategyRuntimeBinarySha256": "b28aa9a20bff232d5d24ba6c8072c02ff921dea69a5ca3cb81712323e2d88d24",
|
||||||
|
"healthPorts": [9100, 9101, 9102, 9103, 9104, 9130],
|
||||||
|
"allHealthChecksPassed": true,
|
||||||
|
"orderRoutingConfigurationChanged": false,
|
||||||
|
"observedOrderRoutingMode": "disabled"
|
||||||
|
},
|
||||||
|
"decision": {
|
||||||
|
"status": "accepted",
|
||||||
|
"reason": "all business hashes and runtime rolling semantics remain exact while five-year DataSet construction, data time and steady memory-cold wall time improve",
|
||||||
|
"nextTarget": "construct the immutable Base Panel directly from Arrow column buffers so the remaining SourceRowRecord and DailyFactorSnapshot row materialization can be removed"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Production Portfolio Risk Contract
|
||||||
|
|
||||||
|
Status: implementation in progress. This document does not admit a strategy to production.
|
||||||
|
|
||||||
|
Research breadth/loss rules are not yet production controls: Alpha currently rejects dynamic breadth without full-market PIT input, and the existing Strategy Runtime creates a fresh strategy per request. A single successful request cannot prove stateful drawdown or cooldown behavior.
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
- Source Lake owns market-only aggregates, with a full-market universe distinct from the trading selection, completed-date visibility, formula/adjustment semantics and source identity. A selected Top40 subset is not a market-breadth input.
|
||||||
|
- Engine owns simulated portfolio accounting. Risk observations must be finalized after execution, settlement and fees, not inferred from benchmark returns or recorded before management fees.
|
||||||
|
- Trading Platform owns strategy-instance/generation-scoped observations and state in PostgreSQL. Loading and committing state require the execution lease and optimistic version checks. Account/generation/config identity must be checked before runtime planning; a content hash alone is not authorization.
|
||||||
|
- Strategy Runtime is a pure calculation boundary: restore verified state, consume closed-session facts, calculate intents and return proposed next state. Never silently initialize an established strategy's state on every HTTP request.
|
||||||
|
- AiQuant must calculate its own portfolio observations from its own fills/accounting under the same declared formulas. Historical target weights or researcher-generated risk-off booleans are not production logic.
|
||||||
|
|
||||||
|
## Loss Rule
|
||||||
|
|
||||||
|
The v1 research rule compounds completed net daily unit returns divided by the maximum of beginning/end gross exposure. A zero-exposure session advances continuity but adds no return observation. Window sizes are valid invested observations, not calendar days. Only sessions before the execution day and available by the decision may be consumed.
|
||||||
|
|
||||||
|
The loss trigger, floor and cooldown are explicit. Repeated evaluation within one execution day must not decrement cooldown twice. A reduced current exposure budget still caps the returned target. Corrections, dropped sessions, nonfinite values and wrong configuration are reconciliation errors, not zero-filled history. State is serialized and validated on restore, bounded to 120 observations, and is never shared across accounts or strategies.
|
||||||
|
|
||||||
|
With a zero floor, the original invested-observation rule can remain in cash while its loss window stays unchanged. That behavior must not be described as automatic market re-entry; an alternate rearm policy requires a separately frozen semantic version and research validation. Current v13 research floors are positive.
|
||||||
|
|
||||||
|
## Remaining Integration
|
||||||
|
|
||||||
|
Wire finalized engine events, JSON configuration/capability contracts and runner diagnostics. Add authoritative trading-state storage/restore and fail closed when that state is absent. Add full-market breadth input construction and both-framework consumers. Verify independent daily inputs, state after restart, exact risk decisions, orders/holdings/NAV and real same-bundle backtests before removing production gates. No live orders or production approval are authorized by component tests.
|
||||||
Reference in New Issue
Block a user