Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29eafc79e2 | |||
| b6859a3360 | |||
| a00dea58b2 | |||
| 2445dc925a | |||
| 1e8d38f2ee | |||
| d2071e4311 | |||
| 0fba16342f | |||
| bd389de57f | |||
| bfbf898027 | |||
| 2b6d031a55 | |||
| 6160a74d2a | |||
| 4acecda79d | |||
| d847cb5c28 | |||
| fa0b316a8b | |||
| 21786187c9 |
@@ -1,3 +1,5 @@
|
|||||||
|
<p><img src="assets/brand/fiPandaWithTitle.png" alt="FiPanda" width="144" height="144"></p>
|
||||||
|
|
||||||
# fidc-backtest-engine
|
# fidc-backtest-engine
|
||||||
|
|
||||||
面向中国 A 股和期货策略的 Rust 回测核心。仓库目标是提供平台自有的策略 DSL、执行模型、撮合模型和结果分析能力,最终由 `fidc-backtest-service` 对外提供策略运行服务。
|
面向中国 A 股和期货策略的 Rust 回测核心。仓库目标是提供平台自有的策略 DSL、执行模型、撮合模型和结果分析能力,最终由 `fidc-backtest-service` 对外提供策略运行服务。
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# FiPanda 品牌资源
|
||||||
|
|
||||||
|
FIDC 系列共用用户提供的两张原始透明图片,不重绘、不裁切、不染色。
|
||||||
|
|
||||||
|
- `fiPandaWithTitle.png`:带标题版,登录页、项目首页、独立报告使用。
|
||||||
|
- `fipanda.png`:无标题版,顶部导航、小尺寸标识和系统图标使用。
|
||||||
|
- `favicon-32.png`:由无标题版等比生成,供浏览器标签页使用。
|
||||||
|
|
||||||
|
导航图标建议 28–42px;带标题版建议 96–160px。始终等比完整显示,保留透明背景,不为 Logo 增加大块容器或改变业务页面主题。项目名称、权限、交易合同与内部 FIDC 标识不因品牌图片变更而重命名。
|
||||||
|
|
||||||
|
原图保存在 `omniquant/assets/brand/source/`;`manifest.json` 记录原图和显示资源的 SHA-256。需要更新时,在完整工作区运行 `node omniquant/scripts/sync-fipanda-branding.mjs`,统一生成八个仓库及可下载策略技能包的资源,禁止分别维护不同版本。
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 307 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"schema": "fidc.fipanda-brand/v1",
|
||||||
|
"brand": "FiPanda",
|
||||||
|
"sourceRepository": "omniquant",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"file": "fipanda.png",
|
||||||
|
"sha256": "ff7dc0677b7d9c8dc6d008d3391f27fd583e3de35560dab021c1e2f8f2c464e5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "fiPandaWithTitle.png",
|
||||||
|
"sha256": "01bfb6acb36830d9b1ce6f7dc8c718977d43af44572ee6a26991d6c6aa2847d6"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"file": "fipanda.png",
|
||||||
|
"source": "fipanda.png",
|
||||||
|
"pixels": 192,
|
||||||
|
"usage": "navigation, compact icons, apple-touch-icon",
|
||||||
|
"sha256": "450aad3506ec3c67624b7705f4d8f851a9102fe950e7671a06fb1203dc1d9cf7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "fiPandaWithTitle.png",
|
||||||
|
"source": "fiPandaWithTitle.png",
|
||||||
|
"pixels": 384,
|
||||||
|
"usage": "login, project README, standalone reports",
|
||||||
|
"sha256": "d33846b3687ea69d43b4a1f17ceeb02662bd25f26fabc4c86c25b3475f20dede"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "favicon-32.png",
|
||||||
|
"source": "fipanda.png",
|
||||||
|
"pixels": 32,
|
||||||
|
"usage": "browser tab icon",
|
||||||
|
"sha256": "ec3d7783457db284d46ae6e27833685203838637a7d68946ff85402725f8e420"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+166
-112
@@ -7,6 +7,8 @@ use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime};
|
|||||||
use crate::cost::CostModel;
|
use crate::cost::CostModel;
|
||||||
use crate::data::{DataSet, IntradayExecutionQuote, PriceField};
|
use crate::data::{DataSet, IntradayExecutionQuote, PriceField};
|
||||||
use crate::engine::BacktestError;
|
use crate::engine::BacktestError;
|
||||||
|
use crate::execution_capacity::{CapacityError, ParticipationRate, VolumeObservation, VolumeObservationKind};
|
||||||
|
use crate::execution_schedule::TwapSchedule;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
||||||
ProcessEventKind,
|
ProcessEventKind,
|
||||||
@@ -255,6 +257,21 @@ pub enum MatchingType {
|
|||||||
Twap,
|
Twap,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn intraday_reference_price(
|
||||||
|
quote: &IntradayExecutionQuote,
|
||||||
|
matching_type: MatchingType,
|
||||||
|
side: OrderSide,
|
||||||
|
) -> Option<f64> {
|
||||||
|
let price = match (matching_type, side) {
|
||||||
|
(MatchingType::MinuteBestOwn, OrderSide::Buy)
|
||||||
|
| (MatchingType::MinuteBestCounterparty, OrderSide::Sell) => quote.bid1,
|
||||||
|
(MatchingType::MinuteBestOwn, OrderSide::Sell)
|
||||||
|
| (MatchingType::MinuteBestCounterparty, OrderSide::Buy) => quote.ask1,
|
||||||
|
_ => quote.last_price,
|
||||||
|
};
|
||||||
|
(price.is_finite() && price > 0.0).then_some(price)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum EquityExecutionPhase {
|
enum EquityExecutionPhase {
|
||||||
ContinuousAuction,
|
ContinuousAuction,
|
||||||
@@ -401,6 +418,7 @@ pub struct BrokerSimulator<C, R> {
|
|||||||
execution_price_field: PriceField,
|
execution_price_field: PriceField,
|
||||||
slippage_model: SlippageModel,
|
slippage_model: SlippageModel,
|
||||||
volume_percent: f64,
|
volume_percent: f64,
|
||||||
|
volume_rate: Result<ParticipationRate, CapacityError>,
|
||||||
volume_limit: bool,
|
volume_limit: bool,
|
||||||
inactive_limit: bool,
|
inactive_limit: bool,
|
||||||
liquidity_limit: bool,
|
liquidity_limit: bool,
|
||||||
@@ -436,6 +454,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
|||||||
execution_price_field: PriceField::Open,
|
execution_price_field: PriceField::Open,
|
||||||
slippage_model: SlippageModel::None,
|
slippage_model: SlippageModel::None,
|
||||||
volume_percent: 0.25,
|
volume_percent: 0.25,
|
||||||
|
volume_rate: ParticipationRate::new(0.25),
|
||||||
volume_limit: true,
|
volume_limit: true,
|
||||||
inactive_limit: true,
|
inactive_limit: true,
|
||||||
liquidity_limit: true,
|
liquidity_limit: true,
|
||||||
@@ -475,6 +494,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
|||||||
execution_price_field,
|
execution_price_field,
|
||||||
slippage_model: SlippageModel::None,
|
slippage_model: SlippageModel::None,
|
||||||
volume_percent: 0.25,
|
volume_percent: 0.25,
|
||||||
|
volume_rate: ParticipationRate::new(0.25),
|
||||||
volume_limit: true,
|
volume_limit: true,
|
||||||
inactive_limit: true,
|
inactive_limit: true,
|
||||||
liquidity_limit: true,
|
liquidity_limit: true,
|
||||||
@@ -547,6 +567,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
|||||||
pub fn with_risk_config(mut self, config: FidcRiskControlConfig) -> Self {
|
pub fn with_risk_config(mut self, config: FidcRiskControlConfig) -> Self {
|
||||||
self.volume_limit = config.trading_constraints.volume_limit_enabled;
|
self.volume_limit = config.trading_constraints.volume_limit_enabled;
|
||||||
self.volume_percent = config.trading_constraints.volume_percent;
|
self.volume_percent = config.trading_constraints.volume_percent;
|
||||||
|
self.volume_rate = ParticipationRate::new(self.volume_percent);
|
||||||
self.liquidity_limit = config.trading_constraints.liquidity_limit_enabled;
|
self.liquidity_limit = config.trading_constraints.liquidity_limit_enabled;
|
||||||
self.risk_config = config;
|
self.risk_config = config;
|
||||||
self
|
self
|
||||||
@@ -558,6 +579,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
|||||||
|
|
||||||
pub fn with_volume_percent(mut self, volume_percent: f64) -> Self {
|
pub fn with_volume_percent(mut self, volume_percent: f64) -> Self {
|
||||||
self.volume_percent = volume_percent;
|
self.volume_percent = volume_percent;
|
||||||
|
self.volume_rate = ParticipationRate::new(volume_percent);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1347,58 +1369,7 @@ where
|
|||||||
if self.is_post_close_fixed_price(snapshot.date) {
|
if self.is_post_close_fixed_price(snapshot.date) {
|
||||||
return (snapshot.close.is_finite() && snapshot.close > 0.0).then_some(snapshot.close);
|
return (snapshot.close.is_finite() && snapshot.close > 0.0).then_some(snapshot.close);
|
||||||
}
|
}
|
||||||
let raw_price = match matching_type {
|
intraday_reference_price(quote,matching_type,side)
|
||||||
MatchingType::MinuteBestOwn => match side {
|
|
||||||
OrderSide::Buy => {
|
|
||||||
if quote.bid1.is_finite() && quote.bid1 > 0.0 {
|
|
||||||
Some(quote.bid1)
|
|
||||||
} else {
|
|
||||||
quote
|
|
||||||
.last_price
|
|
||||||
.is_finite()
|
|
||||||
.then_some(quote.last_price)
|
|
||||||
.filter(|price| *price > 0.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
OrderSide::Sell => {
|
|
||||||
if quote.ask1.is_finite() && quote.ask1 > 0.0 {
|
|
||||||
Some(quote.ask1)
|
|
||||||
} else {
|
|
||||||
quote
|
|
||||||
.last_price
|
|
||||||
.is_finite()
|
|
||||||
.then_some(quote.last_price)
|
|
||||||
.filter(|price| *price > 0.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
MatchingType::MinuteBestCounterparty => match side {
|
|
||||||
OrderSide::Buy => quote.buy_price(),
|
|
||||||
OrderSide::Sell => quote.sell_price(),
|
|
||||||
},
|
|
||||||
MatchingType::CurrentBarClose
|
|
||||||
| MatchingType::MinuteLast
|
|
||||||
| MatchingType::Vwap
|
|
||||||
| MatchingType::Twap => {
|
|
||||||
if quote.last_price.is_finite() && quote.last_price > 0.0 {
|
|
||||||
Some(quote.last_price)
|
|
||||||
} else {
|
|
||||||
match side {
|
|
||||||
OrderSide::Buy => quote.buy_price(),
|
|
||||||
OrderSide::Sell => quote.sell_price(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => match side {
|
|
||||||
OrderSide::Buy => quote.buy_price(),
|
|
||||||
OrderSide::Sell => quote.sell_price(),
|
|
||||||
},
|
|
||||||
}?;
|
|
||||||
if raw_price.is_finite() && raw_price > 0.0 {
|
|
||||||
Some(raw_price)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn quote_mark_price(&self, quote: &IntradayExecutionQuote, fallback: f64) -> f64 {
|
fn quote_mark_price(&self, quote: &IntradayExecutionQuote, fallback: f64) -> f64 {
|
||||||
@@ -1482,6 +1453,9 @@ where
|
|||||||
data: &DataSet,
|
data: &DataSet,
|
||||||
decision: &StrategyDecision,
|
decision: &StrategyDecision,
|
||||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||||
|
if self.volume_limit {
|
||||||
|
self.volume_rate.map_err(|error| BacktestError::Execution(error.to_string()))?;
|
||||||
|
}
|
||||||
let mut session = std::mem::take(&mut *self.execution_session.borrow_mut());
|
let mut session = std::mem::take(&mut *self.execution_session.borrow_mut());
|
||||||
session.activate(date);
|
session.activate(date);
|
||||||
let result = self.execute_with_daily_session(date, portfolio, data, decision, &mut session);
|
let result = self.execute_with_daily_session(date, portfolio, data, decision, &mut session);
|
||||||
@@ -2233,13 +2207,9 @@ where
|
|||||||
};
|
};
|
||||||
let latest = quotes
|
let latest = quotes
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|quote| {
|
.filter(|quote| quote.timestamp <= cursor)
|
||||||
quote.timestamp <= cursor
|
|
||||||
&& self
|
|
||||||
.select_quote_reference_price(snapshot, quote, side, matching_type)
|
|
||||||
.is_some()
|
|
||||||
})
|
|
||||||
.max_by_key(|quote| quote.timestamp)?;
|
.max_by_key(|quote| quote.timestamp)?;
|
||||||
|
self.select_quote_reference_price(snapshot,latest,side,matching_type)?;
|
||||||
if require_executable_liquidity
|
if require_executable_liquidity
|
||||||
&& !self.quote_has_executable_liquidity(latest, side, matching_type)
|
&& !self.quote_has_executable_liquidity(latest, side, matching_type)
|
||||||
{
|
{
|
||||||
@@ -7311,37 +7281,16 @@ where
|
|||||||
return Ok(max_fill);
|
return Ok(max_fill);
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.liquidity_limit && uses_intraday_quantity && !self.is_open_auction_matching() {
|
|
||||||
let top_level_liquidity = match side {
|
|
||||||
OrderSide::Buy => snapshot.liquidity_for_buy(),
|
|
||||||
OrderSide::Sell => snapshot.liquidity_for_sell(),
|
|
||||||
}
|
|
||||||
.min(u32::MAX as u64) as u32;
|
|
||||||
if top_level_liquidity == 0 {
|
|
||||||
return Err("no quote liquidity".to_string());
|
|
||||||
}
|
|
||||||
let top_level_limit = if side == OrderSide::Sell && allow_odd_lot_sell {
|
|
||||||
top_level_liquidity
|
|
||||||
} else {
|
|
||||||
self.round_buy_quantity(
|
|
||||||
top_level_liquidity,
|
|
||||||
minimum_order_quantity,
|
|
||||||
order_step_size,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
max_fill = max_fill.min(top_level_limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.volume_limit {
|
if self.volume_limit {
|
||||||
let raw_limit = ((available_market_volume as f64) * self.volume_percent).floor() as i64
|
let raw_limit = self.volume_rate.map_err(|error| error.to_string())?
|
||||||
- consumed_turnover as i64;
|
.remaining(available_market_volume, u64::from(consumed_turnover), requested_qty);
|
||||||
if raw_limit <= 0 {
|
if raw_limit == 0 {
|
||||||
return Err(volume_limit_reason.to_string());
|
return Err(volume_limit_reason.to_string());
|
||||||
}
|
}
|
||||||
let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
|
let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
|
||||||
raw_limit as u32
|
raw_limit
|
||||||
} else {
|
} else {
|
||||||
self.round_buy_quantity(raw_limit as u32, minimum_order_quantity, order_step_size)
|
self.round_buy_quantity(raw_limit, minimum_order_quantity, order_step_size)
|
||||||
};
|
};
|
||||||
if volume_limited == 0 {
|
if volume_limited == 0 {
|
||||||
return Err(volume_limit_reason.to_string());
|
return Err(volume_limit_reason.to_string());
|
||||||
@@ -7637,6 +7586,9 @@ where
|
|||||||
|
|
||||||
let quote_quantity_limited =
|
let quote_quantity_limited =
|
||||||
self.quote_quantity_limited_for_window(matching_type, start_cursor, end_cursor);
|
self.quote_quantity_limited_for_window(matching_type, start_cursor, end_cursor);
|
||||||
|
let twap_schedule = (matching_type == MatchingType::Twap)
|
||||||
|
.then(|| TwapSchedule::new(start_cursor, end_cursor, requested_qty))
|
||||||
|
.transpose()?;
|
||||||
let lot = round_lot.max(1);
|
let lot = round_lot.max(1);
|
||||||
let exact_time_order_quote = matching_type != MatchingType::MinuteLast
|
let exact_time_order_quote = matching_type != MatchingType::MinuteLast
|
||||||
&& start_cursor.is_some()
|
&& start_cursor.is_some()
|
||||||
@@ -7686,10 +7638,12 @@ where
|
|||||||
let mut pending_volume_consumption = BTreeMap::<NaiveDateTime, u32>::new();
|
let mut pending_volume_consumption = BTreeMap::<NaiveDateTime, u32>::new();
|
||||||
let mut liquidity_consumption = Vec::new();
|
let mut liquidity_consumption = Vec::new();
|
||||||
|
|
||||||
for (quote_index, quote) in eligible_quotes.iter().enumerate() {
|
for quote in &eligible_quotes {
|
||||||
// Approximate platform-native market-order fills with the evolving L1 book after
|
let execution_at = if use_decision_time_quote {
|
||||||
// the decision time instead of trade VWAP. This keeps quantities/prices
|
start_cursor.expect("as-of orders have an execution clock").max(quote.timestamp)
|
||||||
// closer to the observed 10:18 execution logs.
|
} else {
|
||||||
|
quote.timestamp
|
||||||
|
};
|
||||||
let Some(raw_quote_price) =
|
let Some(raw_quote_price) =
|
||||||
self.select_quote_reference_price(snapshot, quote, side, matching_type)
|
self.select_quote_reference_price(snapshot, quote, side, matching_type)
|
||||||
else {
|
else {
|
||||||
@@ -7697,7 +7651,7 @@ where
|
|||||||
};
|
};
|
||||||
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, raw_quote_price) {
|
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, raw_quote_price) {
|
||||||
execution_block_reason.get_or_insert(reason);
|
execution_block_reason.get_or_insert(reason);
|
||||||
execution_block_timestamp = Some(quote.timestamp);
|
execution_block_timestamp = Some(execution_at);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mark_price = self.quote_mark_price(quote, raw_quote_price);
|
let mark_price = self.quote_mark_price(quote, raw_quote_price);
|
||||||
@@ -7743,12 +7697,6 @@ where
|
|||||||
remaining_qty
|
remaining_qty
|
||||||
};
|
};
|
||||||
if self.volume_limit {
|
if self.volume_limit {
|
||||||
let raw_limit = ((quote.volume_delta as f64) * self.volume_percent).floor() as u32;
|
|
||||||
let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
|
|
||||||
raw_limit
|
|
||||||
} else {
|
|
||||||
self.round_buy_quantity(raw_limit, minimum_order_quantity, order_step_size)
|
|
||||||
};
|
|
||||||
let consumed = execution_ledger
|
let consumed = execution_ledger
|
||||||
.volume_consumed(symbol, quote.timestamp)
|
.volume_consumed(symbol, quote.timestamp)
|
||||||
.saturating_add(
|
.saturating_add(
|
||||||
@@ -7757,17 +7705,31 @@ where
|
|||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(0),
|
.unwrap_or(0),
|
||||||
);
|
);
|
||||||
available_qty = available_qty.min(volume_limited.saturating_sub(consumed));
|
let observation = VolumeObservation {
|
||||||
|
kind: VolumeObservationKind::TradeIncrement,
|
||||||
|
start: quote.timestamp,
|
||||||
|
end: quote.timestamp,
|
||||||
|
available_at: quote.timestamp,
|
||||||
|
shares: quote.volume_delta,
|
||||||
|
};
|
||||||
|
let raw_limit = observation.remaining(
|
||||||
|
execution_at,
|
||||||
|
self.volume_rate.map_err(|error| BacktestError::Execution(error.to_string()))?,
|
||||||
|
u64::from(consumed), remaining_qty,
|
||||||
|
).map_err(|error| BacktestError::Execution(error.to_string()))?;
|
||||||
|
let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
|
||||||
|
raw_limit
|
||||||
|
} else {
|
||||||
|
self.round_buy_quantity(raw_limit, minimum_order_quantity, order_step_size)
|
||||||
|
};
|
||||||
|
available_qty = available_qty.min(volume_limited);
|
||||||
}
|
}
|
||||||
if available_qty == 0 {
|
if available_qty == 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut take_qty = if matching_type == MatchingType::Twap {
|
let mut take_qty = if let Some(schedule) = &twap_schedule {
|
||||||
let remaining_quotes = (eligible_quotes.len() - quote_index) as u32;
|
remaining_qty.min(available_qty).min(schedule.due_quantity(execution_at, filled_qty))
|
||||||
let scheduled_qty =
|
|
||||||
((remaining_qty as f64) / remaining_quotes.max(1) as f64).ceil() as u32;
|
|
||||||
remaining_qty.min(available_qty).min(scheduled_qty.max(1))
|
|
||||||
} else {
|
} else {
|
||||||
remaining_qty.min(available_qty)
|
remaining_qty.min(available_qty)
|
||||||
};
|
};
|
||||||
@@ -7784,7 +7746,7 @@ where
|
|||||||
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
|
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
|
||||||
{
|
{
|
||||||
execution_block_reason.get_or_insert(reason);
|
execution_block_reason.get_or_insert(reason);
|
||||||
execution_block_timestamp = Some(quote.timestamp);
|
execution_block_timestamp = Some(execution_at);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
saw_non_blocked_execution_price = true;
|
saw_non_blocked_execution_price = true;
|
||||||
@@ -7812,7 +7774,7 @@ where
|
|||||||
self.execution_limit_rejection_reason(snapshot, side, quote_price)
|
self.execution_limit_rejection_reason(snapshot, side, quote_price)
|
||||||
{
|
{
|
||||||
execution_block_reason.get_or_insert(reason);
|
execution_block_reason.get_or_insert(reason);
|
||||||
execution_block_timestamp = Some(quote.timestamp);
|
execution_block_timestamp = Some(execution_at);
|
||||||
take_qty = 0;
|
take_qty = 0;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -7857,21 +7819,21 @@ where
|
|||||||
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
|
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
|
||||||
{
|
{
|
||||||
execution_block_reason.get_or_insert(reason);
|
execution_block_reason.get_or_insert(reason);
|
||||||
execution_block_timestamp = Some(quote.timestamp);
|
execution_block_timestamp = Some(execution_at);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
gross_amount += quote_price * take_qty as f64;
|
gross_amount += quote_price * take_qty as f64;
|
||||||
mark_amount += mark_price * take_qty as f64;
|
mark_amount += mark_price * take_qty as f64;
|
||||||
filled_qty += take_qty;
|
filled_qty += take_qty;
|
||||||
first_timestamp.get_or_insert(quote.timestamp);
|
first_timestamp.get_or_insert(execution_at);
|
||||||
last_timestamp = Some(quote.timestamp);
|
last_timestamp = Some(execution_at);
|
||||||
legs.push(ExecutionLeg {
|
legs.push(ExecutionLeg {
|
||||||
price: quote_price,
|
price: quote_price,
|
||||||
mark_price,
|
mark_price,
|
||||||
quantity: take_qty,
|
quantity: take_qty,
|
||||||
execution_start_timestamp: Some(quote.timestamp),
|
execution_start_timestamp: Some(execution_at),
|
||||||
execution_timestamp: Some(quote.timestamp),
|
execution_timestamp: Some(execution_at),
|
||||||
});
|
});
|
||||||
if consume_depth {
|
if consume_depth {
|
||||||
let state = depth_state
|
let state = depth_state
|
||||||
@@ -8095,7 +8057,7 @@ mod tests {
|
|||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
BrokerExecutionReport, BrokerSimulator, EquityExecutionPhase, IntradayExecutionLedger,
|
BrokerExecutionReport, BrokerSimulator, EquityExecutionPhase, IntradayExecutionLedger,
|
||||||
MatchingType, OpenOrder, RebalanceCashMode, SlippageModel,
|
ExecutionFill, MatchingType, OpenOrder, RebalanceCashMode, SlippageModel,
|
||||||
};
|
};
|
||||||
use crate::cost::ChinaAShareCostModel;
|
use crate::cost::ChinaAShareCostModel;
|
||||||
use crate::data::{
|
use crate::data::{
|
||||||
@@ -11582,14 +11544,69 @@ mod tests {
|
|||||||
assert_eq!(fill.quantity, 200);
|
assert_eq!(fill.quantity, 200);
|
||||||
assert_eq!(fill.legs.len(), 1);
|
assert_eq!(fill.legs.len(), 1);
|
||||||
assert_eq!(fill.legs[0].price, 10.8);
|
assert_eq!(fill.legs[0].price, 10.8);
|
||||||
assert_eq!(fill.legs[0].execution_timestamp, Some(quote_timestamp));
|
assert!(quote_timestamp < decision_time);
|
||||||
assert!(fill.legs[0].execution_timestamp.unwrap() <= decision_time);
|
assert_eq!(fill.legs[0].execution_timestamp, Some(decision_time));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fill.next_cursor,
|
fill.next_cursor,
|
||||||
quote_timestamp + chrono::Duration::seconds(1)
|
decision_time + chrono::Duration::seconds(1)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_quote_price_models_do_not_replace_missing_prices_or_reuse_older_ones() {
|
||||||
|
let mut quote = limit_test_quote(10.2,10.1,10.3);
|
||||||
|
assert_eq!(super::intraday_reference_price("e,MatchingType::CurrentBarClose,OrderSide::Buy),Some(10.2));
|
||||||
|
assert_eq!(super::intraday_reference_price("e,MatchingType::MinuteBestOwn,OrderSide::Buy),Some(10.1));
|
||||||
|
assert_eq!(super::intraday_reference_price("e,MatchingType::MinuteBestCounterparty,OrderSide::Buy),Some(10.3));
|
||||||
|
let old = quote.clone();
|
||||||
|
quote.timestamp += chrono::Duration::seconds(1);
|
||||||
|
quote.ask1 = 0.0;
|
||||||
|
assert!(super::intraday_reference_price("e,MatchingType::MinuteBestCounterparty,OrderSide::Buy).is_none());
|
||||||
|
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(),ChinaEquityRuleHooks);
|
||||||
|
let snapshot = limit_test_snapshot();
|
||||||
|
let at = quote.timestamp;
|
||||||
|
assert!(broker.latest_known_quote_at_or_before(&[old,quote.clone()],Some(at),&snapshot,OrderSide::Buy,MatchingType::MinuteBestCounterparty,false).is_none());
|
||||||
|
quote.last_price = f64::NAN;
|
||||||
|
assert!(super::intraday_reference_price("e,MatchingType::MinuteLast,OrderSide::Sell).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn later_execution_clocks_do_not_replenish_the_same_observed_volume() {
|
||||||
|
let date = chrono::NaiveDate::from_ymd_opt(2025,1,2).unwrap();
|
||||||
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
|
ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Last,
|
||||||
|
).with_volume_limit(true).with_volume_percent(0.25).with_liquidity_limit(false);
|
||||||
|
let snapshot = limit_test_snapshot();
|
||||||
|
let mut quote = limit_test_quote(10.8,10.79,10.81);
|
||||||
|
quote.timestamp = date.and_hms_opt(9,32,58).unwrap();
|
||||||
|
quote.volume_delta = 1000;
|
||||||
|
let quotes = [quote];
|
||||||
|
let mut ledger = super::IntradayExecutionLedger::default();
|
||||||
|
let clock = date.and_hms_opt(9,33,0).unwrap();
|
||||||
|
let first = broker.select_execution_fill_with_ledger(
|
||||||
|
&snapshot.symbol,&snapshot,"es,OrderSide::Buy,MatchingType::MinuteLast,
|
||||||
|
Some(clock),Some(clock),200,100,100,100,false,None,None,None,&ledger,None,
|
||||||
|
).unwrap().unwrap();
|
||||||
|
assert_eq!(first.quantity,200);
|
||||||
|
assert_eq!(first.legs[0].execution_timestamp,Some(clock));
|
||||||
|
assert_eq!(first.liquidity_consumption[0].timestamp,quotes[0].timestamp);
|
||||||
|
ledger.apply_liquidity_consumption(&first.liquidity_consumption);
|
||||||
|
let later = clock + chrono::Duration::seconds(1);
|
||||||
|
let second = broker.select_execution_fill_with_ledger(
|
||||||
|
&snapshot.symbol,&snapshot,"es,OrderSide::Sell,MatchingType::MinuteLast,
|
||||||
|
Some(later),Some(later),50,100,100,100,true,None,None,None,&ledger,None,
|
||||||
|
).unwrap().unwrap();
|
||||||
|
assert_eq!(second.quantity,50);
|
||||||
|
assert_eq!(second.legs[0].execution_timestamp,Some(later));
|
||||||
|
ledger.apply_liquidity_consumption(&second.liquidity_consumption);
|
||||||
|
assert_eq!(ledger.volume_consumed(&snapshot.symbol,quotes[0].timestamp),250);
|
||||||
|
let third = broker.select_execution_fill_with_ledger(
|
||||||
|
&snapshot.symbol,&snapshot,"es,OrderSide::Buy,MatchingType::MinuteLast,
|
||||||
|
Some(later),Some(later),100,100,100,100,false,None,None,None,&ledger,None,
|
||||||
|
).unwrap();
|
||||||
|
assert!(third.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn value_buy_process_uses_latest_quote_before_decision_time() {
|
fn value_buy_process_uses_latest_quote_before_decision_time() {
|
||||||
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");
|
||||||
@@ -11794,6 +11811,43 @@ mod tests {
|
|||||||
assert!((fill.price - 7.15428).abs() < 1e-6);
|
assert!((fill.price - 7.15428).abs() < 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn twap_earlier_fills_do_not_depend_on_later_quote_liquidity_or_count() {
|
||||||
|
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||||
|
.with_volume_limit(true).with_volume_percent(0.25).with_liquidity_limit(false);
|
||||||
|
let snapshot = limit_test_snapshot();
|
||||||
|
let start = snapshot.date.and_hms_opt(10, 0, 0).unwrap();
|
||||||
|
let end = snapshot.date.and_hms_opt(10, 10, 0).unwrap();
|
||||||
|
let quote_at = |minute| {
|
||||||
|
let mut quote = limit_test_quote(10.0, 9.99, 10.01);
|
||||||
|
quote.timestamp = snapshot.date.and_hms_opt(10, minute, 0).unwrap();
|
||||||
|
quote.volume_delta = 10_000;
|
||||||
|
quote.ask1_volume = 0;
|
||||||
|
quote.bid1_volume = 0;
|
||||||
|
quote
|
||||||
|
};
|
||||||
|
let original = vec![quote_at(2), quote_at(5), quote_at(10)];
|
||||||
|
let mut changed = original.clone();
|
||||||
|
changed[2].volume_delta = 0;
|
||||||
|
let read = |quotes: &[IntradayExecutionQuote]| broker.select_execution_fill(
|
||||||
|
&snapshot, quotes, OrderSide::Buy, MatchingType::Twap, Some(start), Some(end),
|
||||||
|
1_000, 100, 100, 100, false, None, None, None,
|
||||||
|
).unwrap();
|
||||||
|
let original_fill = read(&original);
|
||||||
|
let changed_fill = read(&changed);
|
||||||
|
let fewer_fill = read(&original[..2]);
|
||||||
|
let prefix = |fill: &ExecutionFill| fill.legs.iter()
|
||||||
|
.filter(|leg| leg.execution_timestamp.unwrap() < end)
|
||||||
|
.map(|leg| (leg.execution_timestamp, leg.quantity, leg.price.to_bits()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(prefix(&original_fill), prefix(&changed_fill));
|
||||||
|
assert_eq!(prefix(&original_fill), prefix(&fewer_fill));
|
||||||
|
assert_eq!(prefix(&original_fill).iter().map(|row| row.1).collect::<Vec<_>>(), vec![200, 300]);
|
||||||
|
assert_eq!(original_fill.quantity, 1_000);
|
||||||
|
assert_eq!(changed_fill.quantity, 500);
|
||||||
|
assert_eq!(fewer_fill.quantity, 500);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn instantaneous_twap_without_limits_does_not_cap_quote_quantity() {
|
fn instantaneous_twap_without_limits_does_not_cap_quote_quantity() {
|
||||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
//! Causal volume budgets. Session totals may audit fills, never size earlier orders.
|
||||||
|
use chrono::{NaiveDate, NaiveDateTime};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum VolumeCapacityMode {
|
||||||
|
#[default]
|
||||||
|
ExecutionObservation,
|
||||||
|
CompletedBar,
|
||||||
|
SessionCapacityAudit,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||||
|
pub enum CapacityError {
|
||||||
|
#[error("execution capacity ratio must be finite and in (0, 1]")]
|
||||||
|
InvalidRatio,
|
||||||
|
#[error("execution capacity decimal cannot be represented exactly")]
|
||||||
|
InvalidDecimal,
|
||||||
|
#[error("execution capacity observation has invalid time bounds")]
|
||||||
|
InvalidWindow,
|
||||||
|
#[error("execution capacity is not visible: available={available_at}, execution={execution_at}")]
|
||||||
|
NotVisible { available_at: NaiveDateTime, execution_at: NaiveDateTime },
|
||||||
|
#[error("execution capacity observation belongs to another session")]
|
||||||
|
WrongSession,
|
||||||
|
#[error("execution-time capacity is missing; daily session volume cannot size an earlier fill")]
|
||||||
|
MissingObservation,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decimal semantics of the frozen JSON rate, evaluated without a float product.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct ParticipationRate {
|
||||||
|
numerator: u128,
|
||||||
|
denominator: u128,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParticipationRate {
|
||||||
|
pub fn new(rate: f64) -> Result<Self, CapacityError> {
|
||||||
|
if !rate.is_finite() || rate <= 0.0 || rate > 1.0 {
|
||||||
|
return Err(CapacityError::InvalidRatio);
|
||||||
|
}
|
||||||
|
if rate < 1e-20 {
|
||||||
|
// Even u64::MAX shares at this rate cannot admit a single share.
|
||||||
|
return Ok(Self { numerator: 0, denominator: 1 });
|
||||||
|
}
|
||||||
|
if rate == 1.0 {
|
||||||
|
return Ok(Self { numerator: 1, denominator: 1 });
|
||||||
|
}
|
||||||
|
let text = rate.to_string();
|
||||||
|
let digits = text.strip_prefix("0.").ok_or(CapacityError::InvalidDecimal)?;
|
||||||
|
let digits = digits.trim_end_matches('0');
|
||||||
|
let numerator = digits.parse::<u128>().map_err(|_| CapacityError::InvalidDecimal)?;
|
||||||
|
let denominator = 10_u128.checked_pow(digits.len() as u32).ok_or(CapacityError::InvalidDecimal)?;
|
||||||
|
if numerator > u128::MAX / u128::from(u64::MAX) {
|
||||||
|
return Err(CapacityError::InvalidDecimal);
|
||||||
|
}
|
||||||
|
Ok(Self { numerator, denominator })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn total_shares(self, market_shares: u64) -> u64 {
|
||||||
|
let total = u128::from(market_shares) * self.numerator / self.denominator;
|
||||||
|
u64::try_from(total).expect("participation rate cannot exceed the market shares")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remaining(self, market_shares: u64, consumed_shares: u64, requested: u32) -> u32 {
|
||||||
|
self.total_shares(market_shares).saturating_sub(consumed_shares).min(u64::from(requested)) as u32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum VolumeObservationKind {
|
||||||
|
TradeIncrement,
|
||||||
|
CompletedBar,
|
||||||
|
CumulativeSession,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
pub struct VolumeObservation {
|
||||||
|
pub kind: VolumeObservationKind,
|
||||||
|
pub start: NaiveDateTime,
|
||||||
|
pub end: NaiveDateTime,
|
||||||
|
pub available_at: NaiveDateTime,
|
||||||
|
pub shares: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VolumeObservation {
|
||||||
|
pub fn visible_shares(self, execution_at: NaiveDateTime) -> Result<u64, CapacityError> {
|
||||||
|
if self.start > self.end || self.available_at < self.end {
|
||||||
|
return Err(CapacityError::InvalidWindow);
|
||||||
|
}
|
||||||
|
if self.available_at > execution_at {
|
||||||
|
return Err(CapacityError::NotVisible { available_at: self.available_at, execution_at });
|
||||||
|
}
|
||||||
|
if self.start.date() != self.end.date() || self.end.date() != execution_at.date() {
|
||||||
|
return Err(CapacityError::WrongSession);
|
||||||
|
}
|
||||||
|
Ok(self.shares)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remaining(self, execution_at: NaiveDateTime, rate: ParticipationRate, consumed: u64, requested: u32) -> Result<u32, CapacityError> {
|
||||||
|
Ok(rate.remaining(self.visible_shares(execution_at)?, consumed, requested))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct SessionCapacityAudit {
|
||||||
|
pub date: NaiveDate,
|
||||||
|
pub symbol: String,
|
||||||
|
pub filled_shares: u64,
|
||||||
|
pub session_shares: u64,
|
||||||
|
pub allowed_shares: u64,
|
||||||
|
pub passed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionCapacityAudit {
|
||||||
|
pub fn new(date: NaiveDate, symbol: String, filled_shares: u64, session_shares: u64, rate: ParticipationRate) -> Self {
|
||||||
|
let allowed_shares = rate.total_shares(session_shares);
|
||||||
|
Self { date, symbol, filled_shares, session_shares, allowed_shares, passed: filled_shares <= allowed_shares }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decimal_participation_never_rounds_a_fractional_share_up_or_overflows() {
|
||||||
|
assert_eq!(ParticipationRate::new(0.58).unwrap().total_shares(50), 29);
|
||||||
|
assert_eq!(ParticipationRate::new(0.25).unwrap().total_shares(3), 0);
|
||||||
|
assert_eq!(ParticipationRate::new(0.5).unwrap().total_shares(3), 1);
|
||||||
|
assert_eq!(ParticipationRate::new(1.).unwrap().total_shares(u64::MAX), u64::MAX);
|
||||||
|
assert_eq!(ParticipationRate::new(0.25).unwrap().remaining(u64::MAX, 0, u32::MAX), u32::MAX);
|
||||||
|
assert_eq!(ParticipationRate::new(f64::MIN_POSITIVE).unwrap().total_shares(u64::MAX), 0);
|
||||||
|
for rate in [0., -1., f64::NAN, f64::INFINITY, 1.001] {
|
||||||
|
assert!(ParticipationRate::new(rate).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completed_volume_cannot_be_used_for_an_earlier_open() {
|
||||||
|
let day = NaiveDate::from_ymd_opt(2025,1,2).unwrap();
|
||||||
|
let opening = day.and_hms_opt(9,30,0).unwrap();
|
||||||
|
let closing = day.and_hms_opt(15,0,0).unwrap();
|
||||||
|
let observation = VolumeObservation { kind:VolumeObservationKind::CompletedBar, start:opening, end:closing, available_at:closing, shares:10000 };
|
||||||
|
assert!(matches!(observation.visible_shares(opening), Err(CapacityError::NotVisible { .. })));
|
||||||
|
assert_eq!(observation.remaining(closing, ParticipationRate::new(0.25).unwrap(), 1000, 5000).unwrap(), 1500);
|
||||||
|
assert!(matches!(observation.visible_shares(closing+chrono::Duration::days(1)), Err(CapacityError::WrongSession)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delayed_publication_and_invalid_bounds_are_not_treated_as_zero_volume() {
|
||||||
|
let at = NaiveDate::from_ymd_opt(2025,1,2).unwrap().and_hms_opt(10,18,0).unwrap();
|
||||||
|
let observation = VolumeObservation { kind:VolumeObservationKind::TradeIncrement, start:at, end:at, available_at:at+chrono::Duration::seconds(1), shares:0 };
|
||||||
|
assert!(matches!(observation.visible_shares(at), Err(CapacityError::NotVisible { .. })));
|
||||||
|
assert_eq!(VolumeObservation { available_at:at-chrono::Duration::seconds(1), ..observation }.visible_shares(at), Err(CapacityError::InvalidWindow));
|
||||||
|
assert_eq!(VolumeObservation { available_at:at, ..observation }.visible_shares(at).unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_audit_changes_verdict_not_executed_quantity() {
|
||||||
|
let day = NaiveDate::from_ymd_opt(2025,1,2).unwrap();
|
||||||
|
let rate = ParticipationRate::new(0.25).unwrap();
|
||||||
|
let a = SessionCapacityAudit::new(day,"TEST".into(),1000,3000,rate);
|
||||||
|
let b = SessionCapacityAudit::new(day,"TEST".into(),1000,5000,rate);
|
||||||
|
assert!(!a.passed); assert!(b.passed);
|
||||||
|
assert_eq!(a.filled_shares,b.filled_shares);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
use chrono::NaiveDateTime;
|
||||||
|
|
||||||
|
use crate::engine::BacktestError;
|
||||||
|
|
||||||
|
/// Cumulative TWAP entitlement depends on the frozen clock, not future quotes.
|
||||||
|
pub(crate) struct TwapSchedule {
|
||||||
|
start: NaiveDateTime,
|
||||||
|
end: NaiveDateTime,
|
||||||
|
quantity: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TwapSchedule {
|
||||||
|
pub(crate) fn new(
|
||||||
|
start: Option<NaiveDateTime>,
|
||||||
|
end: Option<NaiveDateTime>,
|
||||||
|
quantity: u32,
|
||||||
|
) -> Result<Self, BacktestError> {
|
||||||
|
let (Some(start), Some(end)) = (start, end) else {
|
||||||
|
return Err(BacktestError::Execution("TWAP requires an explicit start and end clock".into()));
|
||||||
|
};
|
||||||
|
if end < start || start.date() != end.date() {
|
||||||
|
return Err(BacktestError::Execution("TWAP requires an ordered same-session clock window".into()));
|
||||||
|
}
|
||||||
|
Ok(Self { start, end, quantity })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn due_quantity(&self, at: NaiveDateTime, filled: u32) -> u32 {
|
||||||
|
if at < self.start {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let entitlement = if at >= self.end {
|
||||||
|
self.quantity
|
||||||
|
} else {
|
||||||
|
let elapsed = (at - self.start).num_microseconds().expect("same-day interval") as u128;
|
||||||
|
let duration = (self.end - self.start).num_microseconds().expect("same-day interval") as u128;
|
||||||
|
(u128::from(self.quantity) * elapsed / duration) as u32
|
||||||
|
};
|
||||||
|
entitlement.saturating_sub(filled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::{Duration, NaiveDate};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clock_entitlements_are_exact_and_allow_backlog_without_future_quote_counts() {
|
||||||
|
let start = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap().and_hms_opt(10, 0, 0).unwrap();
|
||||||
|
let end = start + Duration::minutes(10);
|
||||||
|
let schedule = TwapSchedule::new(Some(start), Some(end), 1_000).unwrap();
|
||||||
|
assert_eq!(schedule.due_quantity(start, 0), 0);
|
||||||
|
assert_eq!(schedule.due_quantity(start + Duration::minutes(2), 0), 200);
|
||||||
|
assert_eq!(schedule.due_quantity(start + Duration::minutes(5), 100), 400);
|
||||||
|
assert_eq!(schedule.due_quantity(end, 100), 900);
|
||||||
|
assert_eq!(schedule.due_quantity(end, 1_000), 0);
|
||||||
|
assert!(TwapSchedule::new(Some(start), None, 1_000).is_err());
|
||||||
|
assert!(TwapSchedule::new(Some(end), Some(start), 1_000).is_err());
|
||||||
|
assert_eq!(TwapSchedule::new(Some(start), Some(start), 1_000).unwrap().due_quantity(start, 0), 1_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
//! Indicator metadata is versioned independently from the numerical kernel.
|
||||||
|
use crate::factor_events::{CONTRACT, OPERATORS, TA_REV};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use ta_lib::abstract_api::{self, OptInputType};
|
||||||
|
|
||||||
|
pub fn catalog() -> Value {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let mut implementation = Sha256::new();
|
||||||
|
for file in [include_bytes!("factor_events.rs").as_slice(), include_bytes!("factor_cross_section.rs").as_slice(),
|
||||||
|
include_bytes!("daily_patterns.rs").as_slice(),include_bytes!("market_event_context.rs").as_slice(),
|
||||||
|
include_bytes!("session_events.rs").as_slice(),include_bytes!("pattern_context.rs").as_slice(),TA_REV.as_bytes()] {implementation.update(file);}
|
||||||
|
let implementation_sha256=format!("{:x}",implementation.finalize());
|
||||||
|
let indicators: Vec<Value> = abstract_api::funcs().map(|f| json!({
|
||||||
|
"name":f.name, "group":format!("{:?}",f.group), "description":f.hint,
|
||||||
|
"inputs":f.inputs.iter().map(|p|json!({"name":p.param_name,"kind":format!("{:?}",p.kind),"flags":p.flags.0})).collect::<Vec<_>>(),
|
||||||
|
"parameters":f.opt_inputs.iter().map(|p|json!({"name":p.param_name,"label":p.display_name,"description":p.hint,"domain":parameter_domain(p.kind)})).collect::<Vec<_>>(),
|
||||||
|
"outputs":f.outputs.iter().enumerate().map(|(i,p)|json!({"index":i,"name":p.param_name,"kind":format!("{:?}",p.kind)})).collect::<Vec<_>>(),
|
||||||
|
"unstable_period":format!("{:?}",f.unst_id), "production_eligible":false,
|
||||||
|
})).collect();
|
||||||
|
json!({"contract":CONTRACT,"parameter_domain_contract":"fidc.indicator-parameter-domain/v1","expression_kernel_sha256":implementation_sha256,"library":{"name":"TA-Lib native Rust","revision":TA_REV,"license":"BSD-3-Clause"},
|
||||||
|
"execution_context_contract":crate::pattern_context::CONTRACT,
|
||||||
|
"execution_context_fields":crate::pattern_context::CONTEXT_FIELDS,
|
||||||
|
"market_event_context_contract":crate::market_event_context::CONTRACT,
|
||||||
|
"market_event_kernel_sha256":crate::market_event_context::implementation_sha256(),
|
||||||
|
"market_event_common_fields":crate::market_event_context::COMMON_FIELDS,
|
||||||
|
"market_event_industry_fields":crate::market_event_context::INDUSTRY_FIELDS,
|
||||||
|
"session_events":crate::session_events::EVENTS,"session_event_contract":crate::session_events::CONTRACT,
|
||||||
|
"indicators":indicators,"operators":OPERATORS,"cross_section_operators":crate::factor_cross_section::OPERATORS,"read_only":true,"live_routing":false,
|
||||||
|
"policies":{"null":"unknown_not_false","warmup":"null_until_full_history","recursive_seed":"frozen_input_start",
|
||||||
|
"breakout":"previous_window_excludes_current","boolean":"three_valued_logic","daily_execution":"next_completed_session",
|
||||||
|
"minute_execution":"strictly_after_completed_bar","cross_section":"requires_separate_complete_universe_contract"}})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parameter_domain(kind: OptInputType) -> Value {
|
||||||
|
match kind {
|
||||||
|
OptInputType::IntegerRange { min, max, default, .. } => json!({
|
||||||
|
"value_type":"integer", "minimum":min, "maximum":max, "default":default,
|
||||||
|
}),
|
||||||
|
OptInputType::RealRange { min, max, default, precision, .. } => json!({
|
||||||
|
"value_type":"number", "minimum":min, "maximum":max,
|
||||||
|
"default":default, "display_precision":precision,
|
||||||
|
}),
|
||||||
|
OptInputType::IntegerList { values, default } => json!({
|
||||||
|
"value_type":"integer", "default":default,
|
||||||
|
"choices":values.iter().map(|(value,label)|json!({"value":value,"label":label})).collect::<Vec<_>>(),
|
||||||
|
}),
|
||||||
|
OptInputType::RealList { values, default } => json!({
|
||||||
|
"value_type":"number", "default":default,
|
||||||
|
"choices":values.iter().map(|(value,label)|json!({"value":value,"label":label})).collect::<Vec<_>>(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Causal, typed indicator/event expressions shared by research and trading.
|
//! Causal, typed indicator/event expressions shared by research and trading.
|
||||||
use chrono::{DateTime, FixedOffset};
|
use chrono::{DateTime, FixedOffset};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::Value;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use ta_lib::{
|
use ta_lib::{
|
||||||
Core,
|
Core,
|
||||||
@@ -82,7 +82,7 @@ pub struct Series {
|
|||||||
pub values: Vec<Option<f64>>,
|
pub values: Vec<Option<f64>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const OPERATORS: &[&str] = &[
|
pub(crate) const OPERATORS: &[&str] = &[
|
||||||
"GT",
|
"GT",
|
||||||
"GTE",
|
"GTE",
|
||||||
"LT",
|
"LT",
|
||||||
@@ -168,33 +168,7 @@ const OPERATORS: &[&str] = &[
|
|||||||
"IF",
|
"IF",
|
||||||
];
|
];
|
||||||
|
|
||||||
pub fn catalog() -> Value {
|
pub use crate::factor_event_catalog::catalog;
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
let mut implementation = Sha256::new();
|
|
||||||
for file in [include_bytes!("factor_events.rs").as_slice(), include_bytes!("factor_cross_section.rs").as_slice(),
|
|
||||||
include_bytes!("daily_patterns.rs").as_slice(),include_bytes!("market_event_context.rs").as_slice(),
|
|
||||||
include_bytes!("session_events.rs").as_slice(),include_bytes!("pattern_context.rs").as_slice(),TA_REV.as_bytes()] {implementation.update(file);}
|
|
||||||
let implementation_sha256=format!("{:x}",implementation.finalize());
|
|
||||||
let indicators: Vec<Value> = abstract_api::funcs().map(|f| json!({
|
|
||||||
"name":f.name, "group":format!("{:?}",f.group), "description":f.hint,
|
|
||||||
"inputs":f.inputs.iter().map(|p|json!({"name":p.param_name,"kind":format!("{:?}",p.kind),"flags":p.flags.0})).collect::<Vec<_>>(),
|
|
||||||
"parameters":f.opt_inputs.iter().map(|p|json!({"name":p.param_name,"label":p.display_name,"description":p.hint,"domain":format!("{:?}",p.kind)})).collect::<Vec<_>>(),
|
|
||||||
"outputs":f.outputs.iter().enumerate().map(|(i,p)|json!({"index":i,"name":p.param_name,"kind":format!("{:?}",p.kind)})).collect::<Vec<_>>(),
|
|
||||||
"unstable_period":format!("{:?}",f.unst_id), "production_eligible":false,
|
|
||||||
})).collect();
|
|
||||||
json!({"contract":CONTRACT,"expression_kernel_sha256":implementation_sha256,"library":{"name":"TA-Lib native Rust","revision":TA_REV,"license":"BSD-3-Clause"},
|
|
||||||
"execution_context_contract":crate::pattern_context::CONTRACT,
|
|
||||||
"execution_context_fields":crate::pattern_context::CONTEXT_FIELDS,
|
|
||||||
"market_event_context_contract":crate::market_event_context::CONTRACT,
|
|
||||||
"market_event_kernel_sha256":crate::market_event_context::implementation_sha256(),
|
|
||||||
"market_event_common_fields":crate::market_event_context::COMMON_FIELDS,
|
|
||||||
"market_event_industry_fields":crate::market_event_context::INDUSTRY_FIELDS,
|
|
||||||
"session_events":crate::session_events::EVENTS,"session_event_contract":crate::session_events::CONTRACT,
|
|
||||||
"indicators":indicators,"operators":OPERATORS,"cross_section_operators":crate::factor_cross_section::OPERATORS,"read_only":true,"live_routing":false,
|
|
||||||
"policies":{"null":"unknown_not_false","warmup":"null_until_full_history","recursive_seed":"frozen_input_start",
|
|
||||||
"breakout":"previous_window_excludes_current","boolean":"three_valued_logic","daily_execution":"next_completed_session",
|
|
||||||
"minute_execution":"strictly_after_completed_bar","cross_section":"requires_separate_complete_universe_contract"}})
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Frame {
|
impl Frame {
|
||||||
pub fn validate(&self) -> Result<(), String> {
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
@@ -958,124 +932,5 @@ fn operator(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "factor_events_tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
fn frame(values: Vec<Option<f64>>) -> Frame {
|
|
||||||
let start = DateTime::parse_from_rfc3339("2026-09-01T15:30:00+08:00").unwrap();
|
|
||||||
let times = (0..values.len())
|
|
||||||
.map(|i| start + chrono::Duration::days(i as i64))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
Frame {
|
|
||||||
symbol: "TEST".into(),
|
|
||||||
frequency: "1d".into(),
|
|
||||||
decision_at: *times.last().unwrap(),
|
|
||||||
available_at: times.clone(),
|
|
||||||
timestamps: times,
|
|
||||||
fields: BTreeMap::from([("close".into(), values)]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn expr(v: Value) -> Expr {
|
|
||||||
serde_json::from_value(v).unwrap()
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn ta_sma_real_values_and_parameter_validation() {
|
|
||||||
let frame = frame(vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0)]);
|
|
||||||
let e = expr(
|
|
||||||
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":3}}),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
evaluate(&e, &frame).unwrap().values,
|
|
||||||
vec![None, None, Some(2.0), Some(3.0)]
|
|
||||||
);
|
|
||||||
let bad = expr(
|
|
||||||
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"period":3}}),
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
evaluate(&bad, &frame)
|
|
||||||
.unwrap_err()
|
|
||||||
.contains("parameter_unknown")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn cross_is_event_not_state_and_never_uses_future() {
|
|
||||||
let f = frame(vec![
|
|
||||||
Some(9.0),
|
|
||||||
Some(10.0),
|
|
||||||
Some(11.0),
|
|
||||||
Some(12.0),
|
|
||||||
Some(8.0),
|
|
||||||
]);
|
|
||||||
let e = expr(
|
|
||||||
json!({"kind":"operator","name":"CROSS_ABOVE","args":[{"kind":"field","name":"close"},{"kind":"number","value":10.0}]}),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
evaluate(&e, &f).unwrap().values,
|
|
||||||
vec![None, Some(0.0), Some(1.0), Some(0.0), Some(0.0)]
|
|
||||||
);
|
|
||||||
let mut invalid = f.clone();
|
|
||||||
invalid.available_at[4] = invalid.decision_at + chrono::Duration::seconds(1);
|
|
||||||
assert!(evaluate(&e, &invalid).is_err());
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn missing_is_not_zero_and_breakout_excludes_current() {
|
|
||||||
let f = frame(vec![Some(1.0), Some(2.0), Some(3.0), None, Some(5.0)]);
|
|
||||||
let e = expr(
|
|
||||||
json!({"kind":"operator","name":"BREAK_HIGH","window":2,"args":[{"kind":"field","name":"close"}]}),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
evaluate(&e, &f).unwrap().values,
|
|
||||||
vec![None, None, Some(1.0), None, None]
|
|
||||||
);
|
|
||||||
let zero = expr(
|
|
||||||
json!({"kind":"operator","name":"DIV","args":[{"kind":"field","name":"close"},{"kind":"number","value":0}]}),
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
evaluate(&zero, &f)
|
|
||||||
.unwrap()
|
|
||||||
.values
|
|
||||||
.iter()
|
|
||||||
.all(Option::is_none)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn ta_rewarms_after_gap_and_const_zscore_is_unknown() {
|
|
||||||
let f = frame(vec![Some(1.0), Some(1.0), None, Some(2.0), Some(2.0)]);
|
|
||||||
let e = expr(
|
|
||||||
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":2}}),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
evaluate(&e, &f).unwrap().values,
|
|
||||||
vec![None, Some(1.0), None, None, Some(2.0)]
|
|
||||||
);
|
|
||||||
let e = expr(
|
|
||||||
json!({"kind":"operator","name":"ZSCORE","window":2,"args":[{"kind":"field","name":"close"}]}),
|
|
||||||
);
|
|
||||||
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn no_event_has_no_bars_since_and_type_errors_reject() {
|
|
||||||
let f = frame(vec![Some(1.0), Some(1.0), Some(1.0)]);
|
|
||||||
let state = json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":5}]});
|
|
||||||
let e = expr(json!({"kind":"operator","name":"BARS_SINCE","args":[state]}));
|
|
||||||
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
|
|
||||||
assert!(
|
|
||||||
evaluate(
|
|
||||||
&expr(
|
|
||||||
json!({"kind":"operator","name":"NOT","args":[{"kind":"field","name":"close"}]})
|
|
||||||
),
|
|
||||||
&f
|
|
||||||
)
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn literal_unknown_fields_reject_and_catalog_is_not_trading_permission() {
|
|
||||||
assert!(
|
|
||||||
serde_json::from_value::<Expr>(json!({"kind":"number","value":1,"account_id":2}))
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
let c = catalog();
|
|
||||||
assert!(c["indicators"].as_array().unwrap().len() > 190);
|
|
||||||
assert_eq!(c["live_routing"], false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
use crate::factor_event_catalog::parameter_domain;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_parameter_domain_is_structured_and_matches_native_defaults() {
|
||||||
|
for function in abstract_api::funcs() {
|
||||||
|
let handle = abstract_api::get_func_handle(function.name).unwrap();
|
||||||
|
let core = Core::new();
|
||||||
|
let mut call = handle.new_call(&core);
|
||||||
|
for (index, parameter) in function.opt_inputs.iter().enumerate() {
|
||||||
|
let domain = parameter_domain(parameter.kind);
|
||||||
|
let default = domain["default"].as_f64().unwrap();
|
||||||
|
assert!(default.is_finite(), "{} {}", function.name, parameter.param_name);
|
||||||
|
if let Some(choices) = domain.get("choices") {
|
||||||
|
assert!(choices.as_array().unwrap().iter().any(|v| v["value"].as_f64() == Some(default)));
|
||||||
|
} else {
|
||||||
|
assert!(default >= domain["minimum"].as_f64().unwrap());
|
||||||
|
assert!(default <= domain["maximum"].as_f64().unwrap());
|
||||||
|
}
|
||||||
|
if domain["value_type"] == "integer" {
|
||||||
|
assert_eq!(default.fract(), 0.0);
|
||||||
|
call.set_opt(index, default as i32).unwrap();
|
||||||
|
} else {
|
||||||
|
call.set_opt(index, default).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(call.lookback().is_ok(), "{}", function.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parameter_domains_keep_enumeration_labels_without_debug_string_parsing() {
|
||||||
|
let catalog = catalog();
|
||||||
|
assert_eq!(catalog["parameter_domain_contract"], "fidc.indicator-parameter-domain/v1");
|
||||||
|
let indicators = catalog["indicators"].as_array().unwrap();
|
||||||
|
let rsi = indicators.iter().find(|v| v["name"] == "RSI").unwrap();
|
||||||
|
assert_eq!(rsi["parameters"][0]["domain"]["minimum"], 2);
|
||||||
|
let stoch = indicators.iter().find(|v| v["name"] == "STOCH").unwrap();
|
||||||
|
let ma_type = stoch["parameters"].as_array().unwrap().iter().find(|p| p["name"] == "optInSlowK_MAType").unwrap();
|
||||||
|
assert!(ma_type["domain"]["choices"].as_array().unwrap().iter().any(|v| v["label"] == "EMA" && v["value"] == 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame(values: Vec<Option<f64>>) -> Frame {
|
||||||
|
let start = DateTime::parse_from_rfc3339("2026-09-01T15:30:00+08:00").unwrap();
|
||||||
|
let times = (0..values.len())
|
||||||
|
.map(|i| start + chrono::Duration::days(i as i64))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Frame {
|
||||||
|
symbol: "TEST".into(),
|
||||||
|
frequency: "1d".into(),
|
||||||
|
decision_at: *times.last().unwrap(),
|
||||||
|
available_at: times.clone(),
|
||||||
|
timestamps: times,
|
||||||
|
fields: BTreeMap::from([("close".into(), values)]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn expr(v: Value) -> Expr {
|
||||||
|
serde_json::from_value(v).unwrap()
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn ta_sma_real_values_and_parameter_validation() {
|
||||||
|
let frame = frame(vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0)]);
|
||||||
|
let e = expr(
|
||||||
|
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":3}}),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
evaluate(&e, &frame).unwrap().values,
|
||||||
|
vec![None, None, Some(2.0), Some(3.0)]
|
||||||
|
);
|
||||||
|
let bad = expr(
|
||||||
|
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"period":3}}),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
evaluate(&bad, &frame)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("parameter_unknown")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn cross_is_event_not_state_and_never_uses_future() {
|
||||||
|
let f = frame(vec![
|
||||||
|
Some(9.0),
|
||||||
|
Some(10.0),
|
||||||
|
Some(11.0),
|
||||||
|
Some(12.0),
|
||||||
|
Some(8.0),
|
||||||
|
]);
|
||||||
|
let e = expr(
|
||||||
|
json!({"kind":"operator","name":"CROSS_ABOVE","args":[{"kind":"field","name":"close"},{"kind":"number","value":10.0}]}),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
evaluate(&e, &f).unwrap().values,
|
||||||
|
vec![None, Some(0.0), Some(1.0), Some(0.0), Some(0.0)]
|
||||||
|
);
|
||||||
|
let mut invalid = f.clone();
|
||||||
|
invalid.available_at[4] = invalid.decision_at + chrono::Duration::seconds(1);
|
||||||
|
assert!(evaluate(&e, &invalid).is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn missing_is_not_zero_and_breakout_excludes_current() {
|
||||||
|
let f = frame(vec![Some(1.0), Some(2.0), Some(3.0), None, Some(5.0)]);
|
||||||
|
let e = expr(
|
||||||
|
json!({"kind":"operator","name":"BREAK_HIGH","window":2,"args":[{"kind":"field","name":"close"}]}),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
evaluate(&e, &f).unwrap().values,
|
||||||
|
vec![None, None, Some(1.0), None, None]
|
||||||
|
);
|
||||||
|
let zero = expr(
|
||||||
|
json!({"kind":"operator","name":"DIV","args":[{"kind":"field","name":"close"},{"kind":"number","value":0}]}),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
evaluate(&zero, &f)
|
||||||
|
.unwrap()
|
||||||
|
.values
|
||||||
|
.iter()
|
||||||
|
.all(Option::is_none)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn ta_rewarms_after_gap_and_const_zscore_is_unknown() {
|
||||||
|
let f = frame(vec![Some(1.0), Some(1.0), None, Some(2.0), Some(2.0)]);
|
||||||
|
let e = expr(
|
||||||
|
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":2}}),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
evaluate(&e, &f).unwrap().values,
|
||||||
|
vec![None, Some(1.0), None, None, Some(2.0)]
|
||||||
|
);
|
||||||
|
let e = expr(
|
||||||
|
json!({"kind":"operator","name":"ZSCORE","window":2,"args":[{"kind":"field","name":"close"}]}),
|
||||||
|
);
|
||||||
|
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn no_event_has_no_bars_since_and_type_errors_reject() {
|
||||||
|
let f = frame(vec![Some(1.0), Some(1.0), Some(1.0)]);
|
||||||
|
let state = json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":5}]});
|
||||||
|
let e = expr(json!({"kind":"operator","name":"BARS_SINCE","args":[state]}));
|
||||||
|
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
|
||||||
|
assert!(
|
||||||
|
evaluate(
|
||||||
|
&expr(
|
||||||
|
json!({"kind":"operator","name":"NOT","args":[{"kind":"field","name":"close"}]})
|
||||||
|
),
|
||||||
|
&f
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn literal_unknown_fields_reject_and_catalog_is_not_trading_permission() {
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<Expr>(json!({"kind":"number","value":1,"account_id":2}))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let c = catalog();
|
||||||
|
assert!(c["indicators"].as_array().unwrap().len() > 190);
|
||||||
|
assert_eq!(c["live_routing"], false);
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ pub mod daily_patterns;
|
|||||||
pub mod pattern_context;
|
pub mod pattern_context;
|
||||||
pub mod session_events;
|
pub mod session_events;
|
||||||
pub mod factor_events;
|
pub mod factor_events;
|
||||||
|
pub mod execution_capacity;
|
||||||
|
mod execution_schedule;
|
||||||
|
mod factor_event_catalog;
|
||||||
pub mod factor_cross_section;
|
pub mod factor_cross_section;
|
||||||
pub mod market_event_context;
|
pub mod market_event_context;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
|
|||||||
@@ -558,7 +558,9 @@ fn alpha_beta(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
|
fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
|
||||||
let mut peak = 0.0_f64;
|
// NAV is measured against the pre-period capital. The first real loss
|
||||||
|
// must not become a new zero-drawdown baseline.
|
||||||
|
let mut peak = 1.0_f64;
|
||||||
let mut max_drawdown = 0.0_f64;
|
let mut max_drawdown = 0.0_f64;
|
||||||
let mut duration = 0_usize;
|
let mut duration = 0_usize;
|
||||||
let mut max_duration = 0_usize;
|
let mut max_duration = 0_usize;
|
||||||
@@ -767,6 +769,28 @@ fn safe_div(numerator: f64, denominator: f64, fallback: f64) -> f64 {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drawdown_includes_initial_nav_without_adding_a_trading_day() {
|
||||||
|
let (drawdown, duration) = drawdown_stats(&[0.9, 0.99]);
|
||||||
|
assert!((drawdown + 0.1).abs() < 1e-12);
|
||||||
|
assert_eq!(duration, 2);
|
||||||
|
assert_eq!(drawdown_stats(&[1.0, 1.1, 1.1]), (0.0, 0));
|
||||||
|
assert_eq!(drawdown_stats(&[0.0]), (-1.0, 1));
|
||||||
|
assert_eq!(drawdown_stats(&[]), (0.0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_day_loss_is_preserved_in_shared_backtest_metrics() {
|
||||||
|
let curve = vec![
|
||||||
|
equity_point("2025-01-03", 99.16648349337, 98.81608059815, 100.0),
|
||||||
|
equity_point("2025-01-06", 99.68551588547, 98.65392198168, 98.81608059815),
|
||||||
|
];
|
||||||
|
let metrics = compute_backtest_metrics(&curve, &[], &[], &[], 100.0, None).unwrap();
|
||||||
|
assert!((metrics.max_drawdown + 0.0083351650663).abs() < 1e-12);
|
||||||
|
assert_eq!(metrics.total_trade_days, 2);
|
||||||
|
assert_eq!(metrics.max_drawdown_duration_days, 2);
|
||||||
|
}
|
||||||
|
|
||||||
fn equity_point(
|
fn equity_point(
|
||||||
date: &str,
|
date: &str,
|
||||||
total_equity: f64,
|
total_equity: f64,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use chrono::{Datelike, Duration, FixedOffset, NaiveDate, NaiveDateTime, NaiveTim
|
|||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use rhai::{AST, Dynamic, Engine, ImmutableString, Map, Scope};
|
use rhai::{AST, Dynamic, Engine, ImmutableString, Map, Scope};
|
||||||
|
|
||||||
use crate::broker::{MatchingType, RebalanceCashMode, SlippageModel};
|
use crate::broker::{MatchingType, RebalanceCashMode, SlippageModel, intraday_reference_price};
|
||||||
use crate::cost::ChinaAShareCostModel;
|
use crate::cost::ChinaAShareCostModel;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::data::EligibleUniverseSnapshot;
|
use crate::data::EligibleUniverseSnapshot;
|
||||||
@@ -17,6 +17,7 @@ use crate::data::{
|
|||||||
decision_market_cap_bn,
|
decision_market_cap_bn,
|
||||||
};
|
};
|
||||||
use crate::engine::BacktestError;
|
use crate::engine::BacktestError;
|
||||||
|
use crate::execution_capacity::{CapacityError, ParticipationRate};
|
||||||
use crate::events::{OrderSide, ProcessEvent, ProcessEventKind};
|
use crate::events::{OrderSide, ProcessEvent, ProcessEventKind};
|
||||||
use crate::fixed_point::FixedMoney;
|
use crate::fixed_point::FixedMoney;
|
||||||
use crate::futures::{
|
use crate::futures::{
|
||||||
@@ -253,6 +254,10 @@ fn replenish_target_weight_bps(
|
|||||||
excluded_symbols: &BTreeSet<String>,
|
excluded_symbols: &BTreeSet<String>,
|
||||||
target_count: usize,
|
target_count: usize,
|
||||||
) -> Vec<(String, u32)> {
|
) -> Vec<(String, u32)> {
|
||||||
|
if target_count == 0 || original_weights.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let total_weight = original_weights.iter().map(|(_, weight)| *weight).sum::<u32>();
|
||||||
let original_by_symbol = original_weights.iter().cloned().collect::<BTreeMap<_, _>>();
|
let original_by_symbol = original_weights.iter().cloned().collect::<BTreeMap<_, _>>();
|
||||||
let mut active = original_weights
|
let mut active = original_weights
|
||||||
.iter()
|
.iter()
|
||||||
@@ -285,7 +290,7 @@ fn replenish_target_weight_bps(
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let assigned = result.iter().map(|(_, weight)| *weight).sum::<u32>();
|
let assigned = result.iter().map(|(_, weight)| *weight).sum::<u32>();
|
||||||
let missing = 10_000_u32.saturating_sub(assigned);
|
let missing = total_weight.saturating_sub(assigned);
|
||||||
if missing == 0 {
|
if missing == 0 {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -363,6 +368,7 @@ fn resolved_target_scales(
|
|||||||
excluded_symbols: &BTreeSet<String>,
|
excluded_symbols: &BTreeSet<String>,
|
||||||
target_count: usize,
|
target_count: usize,
|
||||||
redistribute_after_exit: bool,
|
redistribute_after_exit: bool,
|
||||||
|
unavailable_symbols: &BTreeSet<String>,
|
||||||
) -> Result<Vec<(String, f64)>, BacktestError> {
|
) -> Result<Vec<(String, f64)>, BacktestError> {
|
||||||
if !redistribute_after_exit {
|
if !redistribute_after_exit {
|
||||||
return Ok(replenish_target_scales(
|
return Ok(replenish_target_scales(
|
||||||
@@ -373,15 +379,25 @@ fn resolved_target_scales(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let original_weights = ordered_weight_bps_from_scales(original_scales)?;
|
let original_weights = ordered_weight_bps_from_scales(original_scales)?;
|
||||||
|
// 不可报价证券的原始预算及席位保留,不能将其预算重新归一到其他股票。
|
||||||
|
let reserved_slots = original_weights
|
||||||
|
.iter()
|
||||||
|
.filter(|(symbol, _)| unavailable_symbols.contains(symbol))
|
||||||
|
.count();
|
||||||
|
let available_weights = original_weights
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(symbol, _)| !unavailable_symbols.contains(symbol))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
let candidate_symbols = candidate_scales
|
let candidate_symbols = candidate_scales
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|(symbol, _)| !unavailable_symbols.contains(symbol))
|
||||||
.map(|(symbol, _)| symbol.clone())
|
.map(|(symbol, _)| symbol.clone())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
Ok(replenish_target_weight_bps(
|
Ok(replenish_target_weight_bps(
|
||||||
&original_weights,
|
&available_weights,
|
||||||
&candidate_symbols,
|
&candidate_symbols,
|
||||||
excluded_symbols,
|
excluded_symbols,
|
||||||
target_count,
|
target_count.saturating_sub(reserved_slots),
|
||||||
)
|
)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(symbol, weight_bps)| {
|
.map(|(symbol, weight_bps)| {
|
||||||
@@ -1369,6 +1385,7 @@ pub struct PlatformExprStrategy {
|
|||||||
pattern_specs: RefCell<BTreeMap<String,String>>,
|
pattern_specs: RefCell<BTreeMap<String,String>>,
|
||||||
pattern_frame_at:RefCell<Option<NaiveDateTime>>,
|
pattern_frame_at:RefCell<Option<NaiveDateTime>>,
|
||||||
config: PlatformExprStrategyConfig,
|
config: PlatformExprStrategyConfig,
|
||||||
|
volume_rate: Result<ParticipationRate, CapacityError>,
|
||||||
engine: Engine,
|
engine: Engine,
|
||||||
rebalance_day_counter: usize,
|
rebalance_day_counter: usize,
|
||||||
last_rebalance_date: Option<NaiveDate>,
|
last_rebalance_date: Option<NaiveDate>,
|
||||||
@@ -1776,6 +1793,7 @@ impl PlatformExprStrategy {
|
|||||||
.clone()
|
.clone()
|
||||||
.map(PlatformPortfolioDrawdownController::new);
|
.map(PlatformPortfolioDrawdownController::new);
|
||||||
Self {
|
Self {
|
||||||
|
volume_rate: ParticipationRate::new(config.risk_config.trading_constraints.volume_percent),
|
||||||
config,
|
config,
|
||||||
engine,
|
engine,
|
||||||
protection_fill_count: 0,
|
protection_fill_count: 0,
|
||||||
@@ -3087,32 +3105,7 @@ impl PlatformExprStrategy {
|
|||||||
quote: &crate::data::IntradayExecutionQuote,
|
quote: &crate::data::IntradayExecutionQuote,
|
||||||
side: OrderSide,
|
side: OrderSide,
|
||||||
) -> Option<f64> {
|
) -> Option<f64> {
|
||||||
let last =
|
intraday_reference_price(quote,self.config.matching_type,side)
|
||||||
|| (quote.last_price.is_finite() && quote.last_price > 0.0).then_some(quote.last_price);
|
|
||||||
match self.config.matching_type {
|
|
||||||
MatchingType::MinuteBestOwn => match side {
|
|
||||||
OrderSide::Buy => (quote.bid1.is_finite() && quote.bid1 > 0.0)
|
|
||||||
.then_some(quote.bid1)
|
|
||||||
.or_else(last),
|
|
||||||
OrderSide::Sell => (quote.ask1.is_finite() && quote.ask1 > 0.0)
|
|
||||||
.then_some(quote.ask1)
|
|
||||||
.or_else(last),
|
|
||||||
},
|
|
||||||
MatchingType::MinuteBestCounterparty => match side {
|
|
||||||
OrderSide::Buy => quote.buy_price(),
|
|
||||||
OrderSide::Sell => quote.sell_price(),
|
|
||||||
},
|
|
||||||
MatchingType::MinuteLast | MatchingType::Vwap | MatchingType::Twap => {
|
|
||||||
last().or_else(|| match side {
|
|
||||||
OrderSide::Buy => quote.buy_price(),
|
|
||||||
OrderSide::Sell => quote.sell_price(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
_ => match side {
|
|
||||||
OrderSide::Buy => quote.buy_price(),
|
|
||||||
OrderSide::Sell => quote.sell_price(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn projected_execution_limit_rejection_reason(
|
fn projected_execution_limit_rejection_reason(
|
||||||
@@ -3156,9 +3149,9 @@ impl PlatformExprStrategy {
|
|||||||
allow_odd_lot_sell: bool,
|
allow_odd_lot_sell: bool,
|
||||||
current_fill_quantity: u32,
|
current_fill_quantity: u32,
|
||||||
execution_state: &ProjectedExecutionState,
|
execution_state: &ProjectedExecutionState,
|
||||||
) -> Option<u32> {
|
) -> Result<Option<u32>, BacktestError> {
|
||||||
if requested_qty == 0 {
|
if requested_qty == 0 {
|
||||||
return Some(0);
|
return Ok(Some(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
let constraints = self.config.risk_config.trading_constraints;
|
let constraints = self.config.risk_config.trading_constraints;
|
||||||
@@ -3183,7 +3176,7 @@ impl PlatformExprStrategy {
|
|||||||
};
|
};
|
||||||
if top_level_liquidity == 0 {
|
if top_level_liquidity == 0 {
|
||||||
if quote.is_some() {
|
if quote.is_some() {
|
||||||
return None;
|
return Ok(None);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let liquidity_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
|
let liquidity_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
|
||||||
@@ -3196,7 +3189,7 @@ impl PlatformExprStrategy {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
if liquidity_limited == 0 {
|
if liquidity_limited == 0 {
|
||||||
return None;
|
return Ok(None);
|
||||||
}
|
}
|
||||||
max_fill = max_fill.min(liquidity_limited);
|
max_fill = max_fill.min(liquidity_limited);
|
||||||
}
|
}
|
||||||
@@ -3209,7 +3202,7 @@ impl PlatformExprStrategy {
|
|||||||
None => market.volume,
|
None => market.volume,
|
||||||
};
|
};
|
||||||
if volume_basis == 0 {
|
if volume_basis == 0 {
|
||||||
return None;
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let consumed_turnover = execution_state
|
let consumed_turnover = execution_state
|
||||||
.intraday_turnover
|
.intraday_turnover
|
||||||
@@ -3217,23 +3210,23 @@ impl PlatformExprStrategy {
|
|||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
.saturating_add(current_fill_quantity);
|
.saturating_add(current_fill_quantity);
|
||||||
let raw_limit = ((volume_basis as f64) * constraints.volume_percent).floor() as i64
|
let raw_limit = self.volume_rate.map_err(|error| BacktestError::Execution(error.to_string()))?
|
||||||
- consumed_turnover as i64;
|
.remaining(volume_basis, u64::from(consumed_turnover), requested_qty);
|
||||||
if raw_limit <= 0 {
|
if raw_limit == 0 {
|
||||||
return None;
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
|
let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
|
||||||
raw_limit as u32
|
raw_limit
|
||||||
} else {
|
} else {
|
||||||
self.round_lot_quantity(raw_limit as u32, minimum_order_quantity, order_step_size)
|
self.round_lot_quantity(raw_limit, minimum_order_quantity, order_step_size)
|
||||||
};
|
};
|
||||||
if volume_limited == 0 {
|
if volume_limited == 0 {
|
||||||
return None;
|
return Ok(None);
|
||||||
}
|
}
|
||||||
max_fill = max_fill.min(volume_limited);
|
max_fill = max_fill.min(volume_limited);
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(max_fill)
|
Ok(Some(max_fill))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn quote_lacks_level1_depth(quote: &crate::data::IntradayExecutionQuote) -> bool {
|
fn quote_lacks_level1_depth(quote: &crate::data::IntradayExecutionQuote) -> bool {
|
||||||
@@ -3333,7 +3326,7 @@ impl PlatformExprStrategy {
|
|||||||
allow_odd_lot_sell,
|
allow_odd_lot_sell,
|
||||||
filled_qty,
|
filled_qty,
|
||||||
execution_state,
|
execution_state,
|
||||||
)
|
)?
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
if available_qty == 0 {
|
if available_qty == 0 {
|
||||||
break;
|
break;
|
||||||
@@ -3407,7 +3400,7 @@ impl PlatformExprStrategy {
|
|||||||
}
|
}
|
||||||
gross_amount += quote_price * take_qty as f64;
|
gross_amount += quote_price * take_qty as f64;
|
||||||
filled_qty += take_qty;
|
filled_qty += take_qty;
|
||||||
last_timestamp = Some(quote.timestamp);
|
last_timestamp = Some(start_cursor.max(quote.timestamp));
|
||||||
if filled_qty >= requested_qty {
|
if filled_qty >= requested_qty {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -3483,7 +3476,7 @@ impl PlatformExprStrategy {
|
|||||||
let round_lot = self.projected_round_lot(ctx, symbol);
|
let round_lot = self.projected_round_lot(ctx, symbol);
|
||||||
let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol);
|
let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol);
|
||||||
let order_step_size = self.projected_order_step_size(ctx, symbol);
|
let order_step_size = self.projected_order_step_size(ctx, symbol);
|
||||||
let Some(fill) = self
|
let mut fill = self
|
||||||
.projected_select_execution_fill_at_time(
|
.projected_select_execution_fill_at_time(
|
||||||
ctx,
|
ctx,
|
||||||
date,
|
date,
|
||||||
@@ -3498,22 +3491,18 @@ impl PlatformExprStrategy {
|
|||||||
None,
|
None,
|
||||||
execution_state,
|
execution_state,
|
||||||
execution_time,
|
execution_time,
|
||||||
)?
|
)?;
|
||||||
.or_else(|| {
|
if fill.is_none()
|
||||||
if self.uses_intraday_execution_quotes()
|
&& (!self.uses_intraday_execution_quotes() || Self::defer_projection_execution_risk(ctx, date))
|
||||||
&& !Self::defer_projection_execution_risk(ctx, date)
|
&& !self.has_execution_quote_at_or_before_at_time(
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if !self.has_execution_quote_at_or_before_at_time(
|
|
||||||
ctx,
|
ctx,
|
||||||
date,
|
date,
|
||||||
symbol,
|
symbol,
|
||||||
execution_state,
|
execution_state,
|
||||||
execution_time,
|
execution_time,
|
||||||
) && ctx.data.execution_quotes_on(date, symbol).is_empty()
|
) && ctx.data.execution_quotes_on(date, symbol).is_empty()
|
||||||
{
|
{
|
||||||
let fallback_quantity = self.projected_market_fillable_quantity(
|
if let Some(fallback_quantity) = self.projected_market_fillable_quantity(
|
||||||
market,
|
market,
|
||||||
None,
|
None,
|
||||||
symbol,
|
symbol,
|
||||||
@@ -3525,21 +3514,18 @@ impl PlatformExprStrategy {
|
|||||||
sellable_qty >= current_qty,
|
sellable_qty >= current_qty,
|
||||||
0,
|
0,
|
||||||
execution_state,
|
execution_state,
|
||||||
)?;
|
)?.filter(|quantity| *quantity > 0)
|
||||||
if fallback_quantity == 0 {
|
{
|
||||||
return None;
|
fill = Some(ProjectedExecutionFill {
|
||||||
}
|
|
||||||
Some(ProjectedExecutionFill {
|
|
||||||
price: self.projected_execution_price(market, OrderSide::Sell),
|
price: self.projected_execution_price(market, OrderSide::Sell),
|
||||||
quantity: fallback_quantity,
|
quantity: fallback_quantity,
|
||||||
next_cursor: date.and_time(
|
next_cursor: date.and_time(
|
||||||
execution_time.unwrap_or_else(|| self.intraday_execution_start_time()),
|
execution_time.unwrap_or_else(|| self.intraday_execution_start_time()),
|
||||||
) + Duration::seconds(1),
|
) + Duration::seconds(1),
|
||||||
})
|
});
|
||||||
} else {
|
}
|
||||||
None
|
}
|
||||||
}
|
let Some(fill) = fill else { return Ok(None); };
|
||||||
}) else { return Ok(None); };
|
|
||||||
let gross_amount = fill.price * fill.quantity as f64;
|
let gross_amount = fill.price * fill.quantity as f64;
|
||||||
let net_cash = self.sell_net_cash(date, gross_amount);
|
let net_cash = self.sell_net_cash(date, gross_amount);
|
||||||
projected
|
projected
|
||||||
@@ -4120,7 +4106,7 @@ impl PlatformExprStrategy {
|
|||||||
}
|
}
|
||||||
let submitted_quantity = quantity;
|
let submitted_quantity = quantity;
|
||||||
let defer_projection_execution_risk = Self::defer_projection_execution_risk(ctx, date);
|
let defer_projection_execution_risk = Self::defer_projection_execution_risk(ctx, date);
|
||||||
let fill = self
|
let mut fill = self
|
||||||
.projected_select_execution_fill(
|
.projected_select_execution_fill(
|
||||||
ctx,
|
ctx,
|
||||||
date,
|
date,
|
||||||
@@ -4134,23 +4120,22 @@ impl PlatformExprStrategy {
|
|||||||
Some(cash_limit),
|
Some(cash_limit),
|
||||||
gross_limit,
|
gross_limit,
|
||||||
execution_state,
|
execution_state,
|
||||||
)?
|
)?;
|
||||||
.or_else(|| {
|
if fill.is_none()
|
||||||
if !defer_projection_execution_risk
|
&& !(!defer_projection_execution_risk
|
||||||
&& ctx.data.has_execution_quotes_on_date(date)
|
&& ctx.data.has_execution_quotes_on_date(date)
|
||||||
&& ctx.data.execution_quotes_on(date, symbol).is_empty()
|
&& ctx.data.execution_quotes_on(date, symbol).is_empty())
|
||||||
{
|
&& !self.has_execution_quote_at_or_before_at_time(
|
||||||
None
|
|
||||||
} else if !self.has_execution_quote_at_or_before_at_time(
|
|
||||||
ctx,
|
ctx,
|
||||||
date,
|
date,
|
||||||
symbol,
|
symbol,
|
||||||
execution_state,
|
execution_state,
|
||||||
None,
|
None,
|
||||||
) && ctx.data.execution_quotes_on(date, symbol).is_empty()
|
) && ctx.data.execution_quotes_on(date, symbol).is_empty()
|
||||||
{
|
&& let Some(market) = ctx.data.market(date, symbol)
|
||||||
let fallback_quantity = self.projected_market_fillable_quantity(
|
{
|
||||||
ctx.data.market(date, symbol)?,
|
if let Some(fallback_quantity) = self.projected_market_fillable_quantity(
|
||||||
|
market,
|
||||||
None,
|
None,
|
||||||
symbol,
|
symbol,
|
||||||
OrderSide::Buy,
|
OrderSide::Buy,
|
||||||
@@ -4161,20 +4146,16 @@ impl PlatformExprStrategy {
|
|||||||
false,
|
false,
|
||||||
0,
|
0,
|
||||||
execution_state,
|
execution_state,
|
||||||
)?;
|
)?.filter(|quantity| *quantity > 0)
|
||||||
if fallback_quantity == 0 {
|
{
|
||||||
return None;
|
fill = Some(ProjectedExecutionFill {
|
||||||
}
|
|
||||||
Some(ProjectedExecutionFill {
|
|
||||||
price: sizing_price,
|
price: sizing_price,
|
||||||
quantity: fallback_quantity,
|
quantity: fallback_quantity,
|
||||||
next_cursor: date.and_time(self.intraday_execution_start_time())
|
next_cursor: date.and_time(self.intraday_execution_start_time())
|
||||||
+ Duration::seconds(1),
|
+ Duration::seconds(1),
|
||||||
})
|
});
|
||||||
} else {
|
}
|
||||||
None
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
let Some(fill) = fill else {
|
let Some(fill) = fill else {
|
||||||
return Ok(ProjectedOrderValueResult::submitted_without_fill(submitted_quantity));
|
return Ok(ProjectedOrderValueResult::submitted_without_fill(submitted_quantity));
|
||||||
};
|
};
|
||||||
@@ -12697,6 +12678,27 @@ impl PlatformExprStrategy {
|
|||||||
for value in contexts.values() {let record=value.to_string();if !decision.diagnostics.contains(&record){decision.diagnostics.push(record);}}
|
for value in contexts.values() {let record=value.to_string();if !decision.diagnostics.contains(&record){decision.diagnostics.push(record);}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn target_quote_unavailable_symbols(
|
||||||
|
&self,
|
||||||
|
ctx: &StrategyContext<'_>,
|
||||||
|
original_scales: &[(String, f64)],
|
||||||
|
candidates: &[(String, f64)],
|
||||||
|
) -> Result<BTreeSet<String>, BacktestError> {
|
||||||
|
if ctx.is_lagged_execution() {
|
||||||
|
return Ok(BTreeSet::new());
|
||||||
|
}
|
||||||
|
let symbols = original_scales.iter().chain(candidates).map(|(symbol, _)| symbol)
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
let mut unavailable = BTreeSet::new();
|
||||||
|
for symbol in symbols {
|
||||||
|
// 只用实际执行时点已知的停牌事实;未知行情仍由原有严格读取返回错误。
|
||||||
|
if self.stock_state(ctx, ctx.execution_date, symbol)?.paused {
|
||||||
|
unavailable.insert(symbol.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(unavailable)
|
||||||
|
}
|
||||||
|
|
||||||
fn compute_day_decision(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
|
fn compute_day_decision(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
|
||||||
if self.config.signal_book.is_some() && self.config.explicit_action_schedule.is_some() {
|
if self.config.signal_book.is_some() && self.config.explicit_action_schedule.is_some() {
|
||||||
return Ok(StrategyDecision::default());
|
return Ok(StrategyDecision::default());
|
||||||
@@ -13421,12 +13423,16 @@ impl PlatformExprStrategy {
|
|||||||
} else {
|
} else {
|
||||||
(Vec::new(), Vec::new())
|
(Vec::new(), Vec::new())
|
||||||
};
|
};
|
||||||
|
let quote_unavailable_symbols = self.target_quote_unavailable_symbols(
|
||||||
|
ctx, &original_target_scales, &candidate_target_scales,
|
||||||
|
)?;
|
||||||
let target_portfolio_scales = resolved_target_scales(
|
let target_portfolio_scales = resolved_target_scales(
|
||||||
&original_target_scales,
|
&original_target_scales,
|
||||||
&candidate_target_scales,
|
&candidate_target_scales,
|
||||||
&exit_symbols,
|
&exit_symbols,
|
||||||
selection_limit,
|
selection_limit,
|
||||||
self.config.redistribute_target_weights_after_exit,
|
self.config.redistribute_target_weights_after_exit,
|
||||||
|
"e_unavailable_symbols,
|
||||||
)?;
|
)?;
|
||||||
let target_portfolio_scale_by_symbol = target_portfolio_scales
|
let target_portfolio_scale_by_symbol = target_portfolio_scales
|
||||||
.iter()
|
.iter()
|
||||||
@@ -14018,6 +14024,7 @@ impl PlatformExprStrategy {
|
|||||||
&exit_symbols,
|
&exit_symbols,
|
||||||
daily_target_count,
|
daily_target_count,
|
||||||
self.config.redistribute_target_weights_after_exit,
|
self.config.redistribute_target_weights_after_exit,
|
||||||
|
"e_unavailable_symbols,
|
||||||
)?;
|
)?;
|
||||||
if daily_top_up_active && self.config.target_portfolio_daily_enabled {
|
if daily_top_up_active && self.config.target_portfolio_daily_enabled {
|
||||||
for (symbol, target_scale) in &daily_target_portfolio_scales {
|
for (symbol, target_scale) in &daily_target_portfolio_scales {
|
||||||
@@ -14770,14 +14777,14 @@ mod tests {
|
|||||||
];
|
];
|
||||||
let excluded = BTreeSet::from(["B".to_string()]);
|
let excluded = BTreeSet::from(["B".to_string()]);
|
||||||
|
|
||||||
let preserved = resolved_target_scales(&original, &original, &excluded, 3, false)
|
let preserved = resolved_target_scales(&original, &original, &excluded, 3, false, &BTreeSet::new())
|
||||||
.expect("preserved target scales");
|
.expect("preserved target scales");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
preserved,
|
preserved,
|
||||||
vec![("A".to_string(), 1.0008), ("C".to_string(), 0.9984)]
|
vec![("A".to_string(), 1.0008), ("C".to_string(), 0.9984)]
|
||||||
);
|
);
|
||||||
|
|
||||||
let redistributed = resolved_target_scales(&original, &original, &excluded, 3, true)
|
let redistributed = resolved_target_scales(&original, &original, &excluded, 3, true, &BTreeSet::new())
|
||||||
.expect("redistributed target scales");
|
.expect("redistributed target scales");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
redistributed,
|
redistributed,
|
||||||
@@ -14786,6 +14793,56 @@ mod tests {
|
|||||||
assert!((redistributed.iter().map(|(_, scale)| *scale).sum::<f64>() - 3.0).abs() < 1e-12);
|
assert!((redistributed.iter().map(|(_, scale)| *scale).sum::<f64>() - 3.0).abs() < 1e-12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stock_pool_suspension_keeps_budget_and_exit_redistributes_only_available_weights() {
|
||||||
|
let original = (0..24).map(|i| (format!("S{i:02}"), if i < 16 { 1.0008 } else { 0.9984 })).collect::<Vec<_>>();
|
||||||
|
let excluded = BTreeSet::from(["S02".to_string()]);
|
||||||
|
let paused = BTreeSet::from(["S15".to_string()]);
|
||||||
|
let resolved = resolved_target_scales(&original, &original, &excluded, 24, true, &paused).unwrap();
|
||||||
|
let weights = resolved.iter().map(|(s, scale)| (s.clone(), (scale / 24.0 * 10000.0).round() as u32)).collect::<BTreeMap<_, _>>();
|
||||||
|
assert_eq!(weights.len(), 22);
|
||||||
|
assert_eq!(weights.values().sum::<u32>(), 9583);
|
||||||
|
assert!(!weights.contains_key("S15"));
|
||||||
|
assert!(!weights.contains_key("S02"));
|
||||||
|
assert_eq!(weights["S11"], 436);
|
||||||
|
assert_eq!(weights["S14"], 436);
|
||||||
|
assert_eq!(weights["S22"], 435);
|
||||||
|
assert_eq!(weights["S23"], 434);
|
||||||
|
|
||||||
|
let candidates = [("A".to_string(), 1.2), ("PAUSED".to_string(), 0.9),
|
||||||
|
("EXIT".to_string(), 0.9), ("NEW".to_string(), 0.9)];
|
||||||
|
let replacement = resolved_target_scales(&candidates[..3], &candidates,
|
||||||
|
&BTreeSet::from(["EXIT".to_string()]), 3, true,
|
||||||
|
&BTreeSet::from(["PAUSED".to_string()])).unwrap();
|
||||||
|
assert_eq!(replacement, vec![("A".to_string(), 1.2), ("NEW".to_string(), 0.9)]);
|
||||||
|
let all_paused = original.iter().map(|(s, _)| s.clone()).collect::<BTreeSet<_>>();
|
||||||
|
assert!(resolved_target_scales(&original, &original, &BTreeSet::new(), 24, true, &all_paused).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stock_pool_suspension_resolution_never_reads_the_future_execution_day() {
|
||||||
|
let date = d(2025, 9, 29);
|
||||||
|
let symbol = "000001.SZ";
|
||||||
|
let mut parts = single_symbol_platform_data(&[date], symbol).snapshot_components();
|
||||||
|
parts.market[0].paused = true;
|
||||||
|
let data = DataSet::from_components_with_actions_and_quotes(parts.instruments, parts.market,
|
||||||
|
parts.factors, parts.candidates, parts.benchmarks, Vec::new(), Vec::new()).unwrap();
|
||||||
|
let portfolio = PortfolioState::new(100_000.0);
|
||||||
|
let subscriptions = BTreeSet::new();
|
||||||
|
let mut ctx = StrategyContext {
|
||||||
|
execution_date: date, decision_date: date, decision_index: 0, data: &data,
|
||||||
|
portfolio: &portfolio, futures_account: None, open_orders: &[], dynamic_universe: None,
|
||||||
|
subscriptions: &subscriptions, process_events: &[], active_process_event: None,
|
||||||
|
active_datetime: None, order_events: &[], fills: &[],
|
||||||
|
};
|
||||||
|
let strategy = PlatformExprStrategy::new(PlatformExprStrategyConfig::generic());
|
||||||
|
let targets = [(symbol.to_string(), 1.0)];
|
||||||
|
assert_eq!(strategy.target_quote_unavailable_symbols(&ctx, &targets, &targets).unwrap(),
|
||||||
|
BTreeSet::from([symbol.to_string()]));
|
||||||
|
ctx.execution_date = d(2025, 9, 30);
|
||||||
|
assert!(strategy.target_quote_unavailable_symbols(&ctx, &targets, &targets).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn model_target_lifecycle_removes_exits_and_appends_replacements() {
|
fn model_target_lifecycle_removes_exits_and_appends_replacements() {
|
||||||
let date = d(2025, 1, 3);
|
let date = d(2025, 1, 3);
|
||||||
@@ -17958,7 +18015,7 @@ mod tests {
|
|||||||
false,
|
false,
|
||||||
0,
|
0,
|
||||||
&execution_state,
|
&execution_state,
|
||||||
),
|
).expect("valid volume capacity"),
|
||||||
Some(2_500)
|
Some(2_500)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -17978,7 +18035,7 @@ mod tests {
|
|||||||
false,
|
false,
|
||||||
0,
|
0,
|
||||||
&execution_state,
|
&execution_state,
|
||||||
),
|
).expect("valid remaining volume capacity"),
|
||||||
Some(100)
|
Some(100)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3080,23 +3080,24 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
|
|||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
|
|
||||||
assert_eq!(report.fill_events.len(), 3);
|
assert_eq!(report.fill_events.len(), 2);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
report
|
report
|
||||||
.fill_events
|
.fill_events
|
||||||
.iter()
|
.iter()
|
||||||
.map(|fill| fill.quantity)
|
.map(|fill| fill.quantity)
|
||||||
.sum::<u32>(),
|
.sum::<u32>(),
|
||||||
300
|
200
|
||||||
);
|
);
|
||||||
assert!(report.fill_events.iter().all(|fill| fill.quantity == 100));
|
assert!(report.fill_events.iter().all(|fill| fill.quantity == 100));
|
||||||
|
assert!(report.fill_events.iter().all(|fill| fill.execution_timestamp.is_some_and(|at| at >= date.and_hms_opt(10, 15, 0).unwrap())));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
report
|
report
|
||||||
.process_events
|
.process_events
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|event| event.kind == ProcessEventKind::Trade)
|
.filter(|event| event.kind == ProcessEventKind::Trade)
|
||||||
.count(),
|
.count(),
|
||||||
3
|
2
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# FIDC执行合同与开源框架审查
|
||||||
|
|
||||||
|
状态:源码审查和部分通用修复已完成。尚未完成多策略长区间A/B;不能据此宣称某个框架性能最优或FIDC生产闭环已通过。
|
||||||
|
|
||||||
|
## 固定版本
|
||||||
|
|
||||||
|
源码保存在`/Volumes/SystemSSD/WorkSpace/fidc-reference-frameworks`,未执行第三方项目的策略或交易程序。
|
||||||
|
|
||||||
|
| 框架 | 审查版本 | 主要参考位置 |
|
||||||
|
|---|---|---|
|
||||||
|
| NautilusTrader | 6f48cd8e713b72b878626e9a9757c6e4a026b708 | [报价类型](/Volumes/SystemSSD/WorkSpace/fidc-reference-frameworks/nautilus_trader/crates/model/src/data/quote.rs:51)、[归并](/Volumes/SystemSSD/WorkSpace/fidc-reference-frameworks/nautilus_trader/crates/backtest/src/node.rs:442) |
|
||||||
|
| LEAN | 8ee075a39918f2df6fe9e0a5944e366fb60d10dc | [股票撮合](/Volumes/SystemSSD/WorkSpace/fidc-reference-frameworks/Lean/Common/Orders/Fills/EquityFillModel.cs:482) |
|
||||||
|
| Zipline Reloaded | 943010b9da848e317fc520de87edade2b884d329 | [滑点与容量](/Volumes/SystemSSD/WorkSpace/fidc-reference-frameworks/zipline-reloaded/src/zipline/finance/slippage.py:464) |
|
||||||
|
| Backtrader | b853d7c90b6721476eb5a5ea3135224e33db1f14 | [Broker](/Volumes/SystemSSD/WorkSpace/fidc-reference-frameworks/backtrader/backtrader/brokers/bbroker.py:228) |
|
||||||
|
|
||||||
|
## 可采用的设计
|
||||||
|
|
||||||
|
### 事件与时钟
|
||||||
|
|
||||||
|
Nautilus把Quote、Trade、Bar区分为不同类型,保存事件与接收/初始化时钟;数据归并按`ts_init`排序,分块保留同一时间戳的完整组。这适合FIDC后续有界分钟流:Source声明观测类型、覆盖区间和可见时间,执行端持有独立账户状态,而非共享一个同时装着日终与盘中字段的可变字典。
|
||||||
|
|
||||||
|
FIDC本轮实际发现:以09:32:58行情给09:33订单定价时,成交时间也写成09:32:58。已修为实际成交不得早于订单执行时钟;价格仍引用原行情,容量扣减也继续绑定原行情,不能通过换一个订单时钟重新获得额度。
|
||||||
|
|
||||||
|
### 开盘和陈旧行情
|
||||||
|
|
||||||
|
LEAN的MarketOnOpenFill单独处理正式开盘成交标志、bar开始/结束时间和订单时间;不是任意取一个日线字段即可成交。但其默认模型也有整体成交和数据回退假设,不能直接当作真实市场流动性保证。
|
||||||
|
|
||||||
|
FIDC已统一预估与撮合的盘中价格选择。己方价、对手价和最近成交价各用自己的字段;缺失/无效时不改用另一种价,也不越过最新坏行情寻找更旧的可用价格。
|
||||||
|
|
||||||
|
### 成交量与滑点
|
||||||
|
|
||||||
|
Zipline按股票和bar累计已使用量,历史冲击窗口排除当前未完成交易日。FIDC应复用不可变历史校准,但每个账户自己的成交消耗必须独立;不能按订单次数重复分配同一个bar的额度。它在历史不足时的回退不是FIDC的默认准入策略。
|
||||||
|
|
||||||
|
FIDC新增`ParticipationRate`:按冻结JSON小数语义精确向下取整,避免浮点乘法和窄整数转换误差。例如58%乘50股为29,不应因二进制误差成为28;巨大市场量也不能在转u32时截断成另一个额度。比例解析在配置阶段完成,不放入逐成交腿热循环。
|
||||||
|
|
||||||
|
Backtrader把volume filler、滑点开关、cheat-on-open/close等模型选择显式区分。值得采用的是明确暴露假设;不照搬不带成交量限制的默认值,也不为了收益对齐开启能改变可见时间的行为。
|
||||||
|
|
||||||
|
## 已落地通用修复
|
||||||
|
|
||||||
|
- 精确参与率计算用于Broker和策略预估,同一观测的已消耗量跨订单共享、跨账户隔离。
|
||||||
|
- 实际执行时钟与参考行情时钟分开,修复成交早于下单的问题。
|
||||||
|
- 缺失的执行价不由其他价格或更旧行情代替;删除重复价格选择及不可达深度分支。
|
||||||
|
- Runner拒绝空身份、错日期、晚于请求时钟的行情、非有限价格、缺失或非整数数量。
|
||||||
|
- 多查询引用同一观测只有完全一致才合并;同一目标查询的源重复行和跨查询冲突均报错,不采用第一条。
|
||||||
|
- 目录缓存使用Linux变更通知,不能只凭mtime/目录大小判断没有新文件;通知不可用时重新扫描。事件溢出、目录替换、重命名、删除均失效重建。
|
||||||
|
|
||||||
|
这些改变不按策略ID、固定选股条件或10:17/10:18等时间分支。
|
||||||
|
|
||||||
|
## 仍未关闭的问题
|
||||||
|
|
||||||
|
1. **日线全天量仍进入早晨数量计算**:Broker和projection两条路径都需接入容量模式。`VolumeCapacityMode`及`SessionCapacityAudit`目前只是已测试基础类型,未完成配置、撮合、日终结果和页面的完整接线。不得称P0已修。
|
||||||
|
2. **Source可能掩盖缺失**:当前目标分钟序列会先剔除无效close,再寻找旧的有效值;部分缺失volume/amount置0;备用Arrow包装器还会用请求时钟替代缺失行情时间。必须清理这些行为,并按数据/市场缺席合同分类。
|
||||||
|
3. **TWAP早期配额可能依赖未来可成交记录数**:当前实现使用筛选后的`eligible_quotes.len()`分配数量。应按冻结时间表切片,后续缺量只能影响后续执行,不改早期分配。
|
||||||
|
4. **集合竞价**:实时数据与日线/分钟模型必须区分委托进入竞价、等待撮合和实际成交,不能用竞价中间的零量判定股票停牌,不能提前使用最终清算价。
|
||||||
|
5. **数据种类/单位**:源观测类型与价格模型能力要明确;分钟OHLC不能冒充真实L1深度。盘口单位、名义bar区间和可见时间仍须逐源校验。
|
||||||
|
|
||||||
|
## 下一阶段实施合同
|
||||||
|
|
||||||
|
- 普通风险开关和参与率仍是同一个策略三端共享配置。历史回测的容量假设单独冻结,不能用研究审核模式绕过Paper/Live临单风控。
|
||||||
|
- 有真实可见观测时使用`execution_observation`;`completed_bar`须验证bar结束和可见时间,不靠日线日期猜15:00。
|
||||||
|
- 只有日线总量而没有开盘容量时,严格模式须在提交预检失败;用户可明确选择`session_capacity_audit`做研究。日终只判定容量验收,不回写早晨的数量、现金、持仓或净值。
|
||||||
|
- 不能自动关闭参与率限制,也不能悄悄替换为昨日量。
|
||||||
|
- Source、Runner、Engine、研究handoff、Strategy AI和UI成套发布;持有冻结合同的旧任务不改写。当前研究维护方尚未授权共享服务切换。
|
||||||
|
|
||||||
|
## 验证与基准
|
||||||
|
|
||||||
|
Engine当前680项通过、8忽略;Runner375项通过,真实Source的8个JSON/Arrow行情点通过严格解析。小样本中的14:59零量在serving Parquet中确实存在,15:00有成交;这是源事实核对,不是完整集合竞价逻辑验收。历史股票收盘竞价时段应按适用规则处理,不能将后来的规则回填到旧日期。[深交所2023年交易机制说明](https://investor.szse.cn/institute/rules/t20230629_601434.html)
|
||||||
|
|
||||||
|
上交所2026版规则于2026-07-06生效,且通知明确存在暂缓实施条文。后续交易时段/容量合同必须同时核对规则版本和实施范围。[上交所发布通知](https://www.sse.com.cn/lawandrules/sselawsrules2025/stocks/exchange/c/c_20260424_10816482.shtml)
|
||||||
|
|
||||||
|
下一组A/B应覆盖动态Top-N日线、静态组合、逐分钟策略、TWAP/VWAP及共享模型信号,分别检查同条件重复、时间变更、参数变更、短区间和五年区间。先关闭时点错误,再测准备/引擎/持久化/端到端耗时、峰值RSS、缓存代际与逐笔差异。不得用微型元数据读取速度外推完整回测性能。
|
||||||
|
|
||||||
|
本轮证据根:`/srv/fidc/canonical/run/research/historical-slippage-20260911/`。服务均未由本任务重启,没有提交回测任务或证券订单。
|
||||||
Reference in New Issue
Block a user