Compare commits

..

40 Commits

Author SHA1 Message Date
boris 942ba84ca5 保留模型评分显式调仓日期 2026-07-11 23:50:00 +08:00
boris ab3c821e59 修复滞后执行卖出资金投影 2026-07-11 23:39:18 +08:00
boris 1953e92b7b 更新策略生成三年收益目标 2026-07-10 15:36:11 +08:00
boris 9cc625409f 统一退出信号与显式调仓语义 2026-07-10 14:41:18 +08:00
boris 558d92fe23 禁止退出信号股票当日补仓 2026-07-10 14:21:13 +08:00
boris 0aef8f9491 删除目标组合错误回补分支 2026-07-10 13:39:41 +08:00
boris e275f4632d 修正AiQuant目标权重fallback执行口径 2026-07-10 13:02:53 +08:00
boris 5166916926 修正目标组合全仓卖出失败回补语义 2026-07-10 12:40:42 +08:00
boris 56859dbe32 修正AiQuant目标组合执行风控延后语义 2026-07-10 12:01:11 +08:00
boris 1272e427a1 修正目标组合现金安全搜索 2026-07-10 11:37:12 +08:00
boris e396c895dc 修正AiQuant兼容持仓成本止损口径 2026-07-10 10:35:58 +08:00
boris f7d0889bbc 补充目标组合执行日展开测试 2026-07-10 04:21:17 +08:00
boris 9b84f3a1b9 补充目标仓位估值价回归测试 2026-07-10 04:07:06 +08:00
boris b1520fcca0 支持执行日行情价格映射表达式 2026-07-10 03:55:57 +08:00
boris bb51d91b76 修复开盘调仓估值价格口径 2026-07-10 03:41:38 +08:00
boris 2c43feec3e 兼容百分比滑点模型别名 2026-07-10 03:00:17 +08:00
boris 825de1d886 禁止目标组合调仓放大目标权重 2026-07-09 20:27:50 +08:00
boris 7397a2d69f 精简平台选股缺排名字段诊断 2026-07-08 11:14:43 +08:00
boris 7951ba67e3 修正执行日退市缺行情拒单原因 2026-07-08 11:07:30 +08:00
boris 2fcacb4313 修正弱市止盈前减仓顺序 2026-07-08 07:02:17 +08:00
boris 5e480cd69b 修正延迟卖出后止盈止损补仓槽位 2026-07-08 05:32:59 +08:00
boris bfbbac8952 修正AiQuant兼容策略退出槽位默认语义 2026-07-08 05:24:45 +08:00
boris bb04864436 增强补仓调试诊断 2026-07-08 05:20:05 +08:00
boris b87e1b4a02 修正延迟卖出后止损补仓槽位 2026-07-08 05:13:13 +08:00
boris 185ed49fe2 修正数字止损边界口径 2026-07-08 05:06:55 +08:00
boris a30face86a 修正分钟止损缺少quote误触发 2026-07-08 04:58:56 +08:00
boris 188376b75a 修正预计算rolling缺失回退 2026-07-08 04:52:18 +08:00
boris 6a98d9b0bd 释放全仓待清仓补仓槽位 2026-07-08 04:40:55 +08:00
boris a562a8e2ed 修正延迟日待清仓补仓槽位 2026-07-08 04:38:21 +08:00
boris 215c4046d1 修正满仓待清仓补仓槽位 2026-07-08 04:28:28 +08:00
boris d30c93989c 修正弱市部分止损补仓槽位 2026-07-08 04:21:15 +08:00
boris 4554f92fb4 Revert "修正普通日部分退出补仓槽位"
This reverts commit 556ed9b848.
2026-07-08 04:15:23 +08:00
boris 556ed9b848 修正普通日部分退出补仓槽位 2026-07-08 04:11:44 +08:00
boris 344e7e90c2 Revert "修正部分延迟卖出槽位计数"
This reverts commit c64bf16c8b.
2026-07-08 04:09:16 +08:00
boris c64bf16c8b 修正部分延迟卖出槽位计数 2026-07-08 04:06:34 +08:00
boris ce5ef3b77d 修正延迟卖出日止盈补仓槽位 2026-07-08 03:58:55 +08:00
boris 8f47ee3679 回退延迟卖出补仓槽位计数 2026-07-08 03:46:15 +08:00
boris f15f229a09 修正延迟卖出补仓槽位计数 2026-07-08 03:43:11 +08:00
boris da12cdddd4 Revert "修正延迟卖出后的补仓槽位"
This reverts commit 0bb47812e5.
2026-07-08 03:35:03 +08:00
boris 0bb47812e5 修正延迟卖出后的补仓槽位 2026-07-08 03:33:04 +08:00
8 changed files with 3212 additions and 245 deletions
+662 -56
View File
@@ -12,7 +12,7 @@ use crate::events::{
};
use crate::instrument::Instrument;
use crate::portfolio::PortfolioState;
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig};
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, RiskCheckScope};
use crate::rules::{EquityRuleHooks, RuleCheck};
use crate::strategy::{
AlgoOrderStyle, OpenOrderView, OrderIntent, StrategyDecision, TargetPortfolioOrderPricing,
@@ -62,8 +62,6 @@ struct TargetConstraint {
symbol: String,
current_qty: u32,
desired_qty: u32,
min_target_qty: u32,
max_target_qty: u32,
provisional_target_qty: u32,
price: f64,
minimum_order_quantity: u32,
@@ -1554,16 +1552,19 @@ where
date: NaiveDate,
symbol: &str,
) -> Option<&'static str> {
if self
if !self
.risk_config
.static_rules
.forbid_same_day_rebuy_after_sell
&& self
.same_day_sold_symbols
.borrow()
.get(&date)
.is_some_and(|symbols| symbols.contains(symbol))
{
return None;
}
let sold_today = self
.same_day_sold_symbols
.borrow()
.get(&date)
.is_some_and(|symbols| symbols.contains(symbol));
if sold_today {
return Some("same_day_rebuy_forbidden");
}
None
@@ -1878,6 +1879,63 @@ where
);
}
fn reject_missing_market_or_execution_risk_order(
&self,
report: &mut BrokerExecutionReport,
date: NaiveDate,
data: &DataSet,
symbol: &str,
side: OrderSide,
requested_quantity: u32,
reason: &str,
) {
if let Some(unavailable_reason) =
self.missing_market_execution_risk_rejection_reason(date, data, symbol, side)
{
let order_id = self.reserve_order_id();
Self::reject_unavailable_order(
report,
date,
order_id,
symbol,
side,
requested_quantity,
reason,
unavailable_reason,
false,
);
return;
}
self.reject_missing_market_snapshot_order(
report,
date,
symbol,
side,
requested_quantity,
reason,
);
}
fn missing_market_execution_risk_rejection_reason(
&self,
date: NaiveDate,
data: &DataSet,
symbol: &str,
side: OrderSide,
) -> Option<&'static str> {
let scope = match side {
OrderSide::Buy => RiskCheckScope::Buy,
OrderSide::Sell => RiskCheckScope::Sell,
};
ChinaAShareRiskControl::active_status_rejection_reason_with_config(
date,
data.candidate(date, symbol),
data.instrument(symbol),
&self.risk_config,
scope,
)
}
fn reject_unavailable_order(
report: &mut BrokerExecutionReport,
date: NaiveDate,
@@ -2076,8 +2134,6 @@ where
symbol: symbol.clone(),
current_qty,
desired_qty,
min_target_qty,
max_target_qty,
provisional_target_qty,
price,
minimum_order_quantity,
@@ -2105,24 +2161,22 @@ where
let mut best_targets = targets.clone();
let mut best_proportion_diff = f64::INFINITY;
let initial_safety = if target_weight_sum > 0.95 { 1.2 } else { 1.0 };
let mut safety = initial_safety;
loop {
let mut best_safety = f64::NEG_INFINITY;
let mut record_candidate = |safety_value: f64| -> bool {
let safety_value = safety_value.clamp(0.0, 1.0);
let mut candidate_targets = targets.clone();
let mut buy_cash_out = 0.0;
for constraint in &buy_constraints {
let scaled_desired_qty = ((constraint.desired_qty as f64) * safety).floor() as u32;
let mut target_qty = self
let scaled_desired_qty =
((constraint.desired_qty as f64) * safety_value).floor() as u32;
let target_qty = self
.round_buy_quantity(
scaled_desired_qty,
constraint.minimum_order_quantity,
constraint.order_step_size,
)
.clamp(constraint.min_target_qty, constraint.max_target_qty)
.max(constraint.current_qty);
if target_qty < constraint.current_qty {
target_qty = constraint.current_qty;
}
.max(constraint.current_qty)
.min(constraint.provisional_target_qty);
if target_qty > constraint.current_qty {
buy_cash_out += self.estimated_buy_cash_out(
date,
@@ -2151,29 +2205,46 @@ where
0.0
};
if buy_cash_out <= projected_cash + 1e-6 {
if proportion_diff <= best_proportion_diff + 1e-12 {
if proportion_diff < best_proportion_diff - 1e-12
|| ((proportion_diff - best_proportion_diff).abs() <= 1e-12
&& safety_value > best_safety)
{
best_targets = candidate_targets;
best_proportion_diff = proportion_diff;
} else if best_proportion_diff.is_finite() {
break;
best_safety = safety_value;
}
return true;
}
false
};
if !record_candidate(1.0) && record_candidate(0.0) {
let mut low = 0.0;
let mut high = 1.0;
for _ in 0..24 {
let mid = (low + high) / 2.0;
if record_candidate(mid) {
low = mid;
} else {
high = mid;
}
}
if safety <= 0.0 {
break;
let high_toward_zero = if high > 0.0 {
f64::from_bits(high.to_bits().saturating_sub(1))
} else {
high
};
for safety in [0.0, low, high_toward_zero, high, 1.0] {
if (0.0..=1.0).contains(&safety) {
record_candidate(safety);
}
}
let step = (proportion_diff / 10.0).clamp(0.0001, 0.002);
let next_safety = (safety - step).max(0.0);
if (next_safety - safety).abs() < f64::EPSILON {
break;
}
safety = next_safety;
}
if safety < initial_safety && diagnostics.len() < 16 {
if best_safety.is_finite() && best_safety < 1.0 && diagnostics.len() < 16 {
diagnostics.push(format!(
"rebalance_safety_scaled final_safety={:.4} target_weight_sum={:.4} projected_cash={:.2}",
safety, target_weight_sum, projected_cash
best_safety, target_weight_sum, projected_cash
));
}
@@ -2652,6 +2723,12 @@ where
let Some(position) = portfolio.position(symbol) else {
return 0;
};
if self.aiquant_execution_rules {
let sellable = position
.sellable_qty(date)
.saturating_sub(self.reserved_open_sell_quantity(symbol, None));
return current_qty.saturating_sub(sellable.min(current_qty));
}
let Ok(snapshot) = data.require_market(date, symbol) else {
return current_qty;
};
@@ -2699,6 +2776,9 @@ where
minimum_order_quantity: u32,
order_step_size: u32,
) -> u32 {
if self.aiquant_execution_rules {
return u32::MAX;
}
let Ok(snapshot) = data.require_market(date, symbol) else {
return current_qty;
};
@@ -2870,6 +2950,15 @@ where
return Ok(());
};
let Some(snapshot) = data.market(date, symbol) else {
let unavailable_reason = self
.missing_market_execution_risk_rejection_reason(date, data, symbol, OrderSide::Sell)
.map(str::to_string)
.unwrap_or_else(|| {
format!(
"market snapshot is missing for price field {}",
price_field_name(self.execution_price_field)
)
});
Self::reject_unavailable_order(
report,
date,
@@ -2878,10 +2967,7 @@ where
OrderSide::Sell,
requested_qty,
reason,
format!(
"market snapshot is missing for price field {}",
price_field_name(self.execution_price_field)
),
unavailable_reason,
emit_creation_events,
);
return Ok(());
@@ -3471,9 +3557,10 @@ where
return Ok(());
}
if data.market(date, symbol).is_none() {
self.reject_missing_market_snapshot_order(
self.reject_missing_market_or_execution_risk_order(
report,
date,
data,
symbol,
OrderSide::Sell,
current_qty,
@@ -3503,9 +3590,10 @@ where
}
let Some(snapshot) = data.market(date, symbol) else {
self.reject_missing_market_snapshot_order(
self.reject_missing_market_or_execution_risk_order(
report,
date,
data,
symbol,
if current_qty > 0 {
OrderSide::Sell
@@ -3797,14 +3885,18 @@ where
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let price = data
.market(date, symbol)
.map(|snapshot| self.sizing_price(snapshot))
.ok_or_else(|| BacktestError::MissingPrice {
date,
symbol: symbol.to_string(),
field: price_field_name(self.execution_price_field),
})?;
let price = if self.aiquant_execution_rules && limit_price.is_finite() && limit_price > 0.0
{
limit_price
} else {
data.market(date, symbol)
.map(|snapshot| self.sizing_price(snapshot))
.ok_or_else(|| BacktestError::MissingPrice {
date,
symbol: symbol.to_string(),
field: price_field_name(self.execution_price_field),
})?
};
let current_qty = portfolio
.position(symbol)
.map(|pos| pos.quantity)
@@ -4030,9 +4122,10 @@ where
} else {
0
};
self.reject_missing_market_snapshot_order(
self.reject_missing_market_or_execution_risk_order(
report,
date,
data,
symbol,
if value > 0.0 {
OrderSide::Buy
@@ -4561,6 +4654,15 @@ where
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let Some(snapshot) = data.market(date, symbol) else {
let unavailable_reason = self
.missing_market_execution_risk_rejection_reason(date, data, symbol, OrderSide::Buy)
.map(str::to_string)
.unwrap_or_else(|| {
format!(
"market snapshot is missing for price field {}",
price_field_name(self.execution_price_field)
)
});
Self::reject_unavailable_order(
report,
date,
@@ -4569,10 +4671,7 @@ where
OrderSide::Buy,
requested_qty,
reason,
format!(
"market snapshot is missing for price field {}",
price_field_name(self.execution_price_field)
),
unavailable_reason,
emit_creation_events,
);
return Ok(());
@@ -5173,7 +5272,7 @@ where
fn rebalance_valuation_price_field_name(&self) -> &'static str {
if self.is_open_auction_matching() {
"prev_close"
"day_open"
} else {
price_field_name(self.execution_price_field)
}
@@ -5184,7 +5283,7 @@ where
snapshot: &crate::data::DailyMarketSnapshot,
) -> Option<f64> {
let price = if self.is_open_auction_matching() {
snapshot.prev_close
snapshot.price(PriceField::DayOpen)
} else {
snapshot.price(self.execution_price_field)
};
@@ -6521,6 +6620,63 @@ mod tests {
);
}
#[test]
fn next_open_buy_risk_rejects_execution_date_delisted_even_without_market_snapshot() {
let signal_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
let execution_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date");
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_limit(false)
.with_liquidity_limit(false);
let mut instrument = limit_test_instrument();
instrument.delisted_at = Some(execution_date);
let data = DataSet::from_components_with_actions_and_quotes(
vec![instrument],
vec![dated_limit_test_snapshot(signal_date)],
Vec::new(),
vec![
dated_limit_test_candidate(signal_date, false, false, true, true),
dated_limit_test_candidate(execution_date, false, false, true, true),
],
vec![
dated_limit_test_benchmark(signal_date),
dated_limit_test_benchmark(execution_date),
],
Vec::new(),
Vec::new(),
)
.expect("valid dataset");
let mut portfolio = PortfolioState::new(20_000.0);
let report = broker
.execute(
execution_date,
&mut portfolio,
&data,
&next_open_buy_decision(),
)
.expect("execute next open buy");
assert!(report.fill_events.is_empty());
assert!(portfolio.position("000001.SZ").is_none());
let rejected_order = report
.order_events
.iter()
.find(|event| event.side == OrderSide::Buy)
.expect("buy order event");
assert_eq!(rejected_order.date, execution_date);
assert_eq!(rejected_order.status, OrderStatus::Rejected);
assert!(
rejected_order.reason.contains("inactive_or_delisted"),
"{}",
rejected_order.reason
);
assert!(
!rejected_order.reason.contains("market snapshot is missing"),
"{}",
rejected_order.reason
);
}
#[test]
fn next_open_buy_limit_risk_uses_open_not_close() {
let execution_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date");
@@ -7110,6 +7266,336 @@ mod tests {
);
}
#[test]
fn aiquant_target_portfolio_smart_defers_buy_risk_during_target_sizing() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
let data = DataSet::from_components_with_actions_and_quotes(
vec![limit_test_instrument()],
vec![limit_test_snapshot()],
Vec::new(),
vec![limit_test_candidate(false, true)],
vec![limit_test_benchmark()],
Vec::new(),
Vec::new(),
)
.expect("valid dataset");
let portfolio = PortfolioState::new(1_000_000.0);
let mut target_weights = BTreeMap::new();
target_weights.insert("000001.SZ".to_string(), 0.50);
let default_broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_volume_limit(false)
.with_liquidity_limit(false);
let (default_targets, _) = default_broker
.target_quantities(date, &portfolio, &data, &target_weights)
.expect("default target quantities");
assert_eq!(default_targets.get("000001.SZ").copied().unwrap_or(0), 0);
let aiquant_broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_aiquant_execution_rules(true)
.with_volume_limit(false)
.with_liquidity_limit(false);
let (aiquant_targets, _) = aiquant_broker
.target_quantities(date, &portfolio, &data, &target_weights)
.expect("aiquant target quantities");
assert_eq!(aiquant_targets.get("000001.SZ").copied(), Some(50_000));
}
#[test]
fn aiquant_target_portfolio_smart_rejects_deferred_buy_risk_at_order_stage() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
let data = DataSet::from_components_with_actions_and_quotes(
vec![limit_test_instrument()],
vec![limit_test_snapshot()],
Vec::new(),
vec![limit_test_candidate(false, true)],
vec![limit_test_benchmark()],
Vec::new(),
Vec::new(),
)
.expect("valid dataset");
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_aiquant_execution_rules(true)
.with_volume_limit(false)
.with_liquidity_limit(false);
let mut portfolio = PortfolioState::new(1_000_000.0);
let mut target_weights = BTreeMap::new();
target_weights.insert("000001.SZ".to_string(), 0.50);
let mut report = BrokerExecutionReport::default();
broker
.process_target_portfolio_smart(
date,
&mut portfolio,
&data,
&target_weights,
None,
None,
"aiquant_deferred_buy_risk",
&mut BTreeMap::new(),
&mut BTreeMap::new(),
&mut None,
&mut BTreeMap::new(),
&mut report,
)
.expect("deferred buy risk is handled at order stage");
assert!(portfolio.position("000001.SZ").is_none());
assert!(report.fill_events.is_empty());
let rejected = report
.order_events
.iter()
.find(|event| event.symbol == "000001.SZ")
.expect("buy-disabled target should be rejected at order stage");
assert_eq!(rejected.side, OrderSide::Buy);
assert_ne!(rejected.status, OrderStatus::Filled);
assert_eq!(rejected.filled_quantity, 0);
assert!(rejected.reason.contains("buy_disabled"), "{rejected:?}");
}
#[test]
fn aiquant_target_portfolio_smart_keeps_batch_semantics_after_failed_full_close_sell() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
let prev_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 1).expect("valid date");
let symbols = ["000001.SZ", "000002.SZ"];
let instruments = symbols
.iter()
.map(|symbol| Instrument {
symbol: (*symbol).to_string(),
name: (*symbol).to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
})
.collect::<Vec<_>>();
let snapshots = symbols
.iter()
.map(|symbol| {
let mut snapshot = limit_test_snapshot();
snapshot.symbol = (*symbol).to_string();
if *symbol == "000001.SZ" {
snapshot.day_open = 9.0;
snapshot.open = 9.0;
snapshot.low = 9.0;
snapshot.close = 9.0;
snapshot.last_price = 9.0;
snapshot.bid1 = 9.0;
snapshot.ask1 = 9.0;
snapshot.lower_limit = 9.0;
}
snapshot
})
.collect::<Vec<_>>();
let candidates = symbols
.iter()
.map(|symbol| {
let mut candidate = limit_test_candidate(true, true);
candidate.symbol = (*symbol).to_string();
candidate
})
.collect::<Vec<_>>();
let data = DataSet::from_components_with_actions_and_quotes(
instruments,
snapshots,
Vec::new(),
candidates,
vec![limit_test_benchmark()],
Vec::new(),
Vec::new(),
)
.expect("valid dataset");
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_aiquant_execution_rules(true)
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_inactive_limit(false);
let mut portfolio = PortfolioState::new(100_000.0);
portfolio
.position_mut("000001.SZ")
.buy(prev_date, 1_000, 10.0);
portfolio
.position_mut("000002.SZ")
.buy(prev_date, 1_000, 10.0);
let mut report = BrokerExecutionReport::default();
let mut intraday_turnover = BTreeMap::new();
let mut execution_cursors = BTreeMap::new();
let mut global_execution_cursor = None;
let mut commission_state = BTreeMap::new();
broker
.process_target_value(
date,
&mut portfolio,
&data,
"000001.SZ",
0.0,
"stop_loss_exit",
&mut intraday_turnover,
&mut execution_cursors,
&mut global_execution_cursor,
&mut commission_state,
&mut report,
)
.expect("failed lower-limit full close should be recorded");
assert_eq!(
portfolio
.position("000001.SZ")
.map(|position| position.quantity),
Some(1_000)
);
assert!(report.order_events.iter().any(|event| {
event.symbol == "000001.SZ"
&& event.side == OrderSide::Sell
&& event.status == OrderStatus::Canceled
&& event.filled_quantity == 0
}));
let mut target_weights = BTreeMap::new();
target_weights.insert("000001.SZ".to_string(), 0.50);
target_weights.insert("000002.SZ".to_string(), 0.50);
broker
.process_target_portfolio_smart(
date,
&mut portfolio,
&data,
&target_weights,
None,
None,
"signal_target_weights",
&mut intraday_turnover,
&mut execution_cursors,
&mut global_execution_cursor,
&mut commission_state,
&mut report,
)
.expect("failed full close must not change batch target-portfolio semantics");
assert!(
portfolio
.position("000001.SZ")
.is_some_and(|position| position.quantity > 1_000),
"{:?}",
report.fill_events
);
assert!(
portfolio
.position("000002.SZ")
.is_some_and(|position| position.quantity > 1_000),
"{:?}",
report.fill_events
);
assert!(
report.order_events.iter().all(|event| {
!(event.symbol == "000001.SZ"
&& event.side == OrderSide::Buy
&& event.reason.contains("same_day_rebuy_forbidden"))
}),
"{:?}",
report.order_events
);
assert!(
report
.diagnostics
.iter()
.all(|line| { !line.contains("fallback_after_failed_full_close") })
);
}
#[test]
fn target_portfolio_smart_scales_buys_when_full_targets_exceed_cash_by_fees() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
let symbols = ["000001.SZ", "000002.SZ"];
let instruments = symbols
.iter()
.map(|symbol| Instrument {
symbol: (*symbol).to_string(),
name: (*symbol).to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
})
.collect::<Vec<_>>();
let snapshots = symbols
.iter()
.map(|symbol| {
let mut snapshot = limit_test_snapshot();
snapshot.symbol = (*symbol).to_string();
snapshot
})
.collect::<Vec<_>>();
let candidates = symbols
.iter()
.map(|symbol| {
let mut candidate = limit_test_candidate(true, true);
candidate.symbol = (*symbol).to_string();
candidate
})
.collect::<Vec<_>>();
let data = DataSet::from_components_with_actions_and_quotes(
instruments,
snapshots,
Vec::new(),
candidates,
vec![limit_test_benchmark()],
Vec::new(),
Vec::new(),
)
.expect("valid dataset");
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_inactive_limit(false);
let portfolio = PortfolioState::new(10_000.0);
let mut target_weights = BTreeMap::new();
target_weights.insert("000001.SZ".to_string(), 0.50);
target_weights.insert("000002.SZ".to_string(), 0.50);
let (target_quantities, diagnostics) = broker
.target_quantities(date, &portfolio, &data, &target_weights)
.expect("target quantities");
assert_eq!(target_quantities.get("000001.SZ").copied(), Some(400));
assert_eq!(target_quantities.get("000002.SZ").copied(), Some(400));
assert!(
diagnostics
.iter()
.any(|line| line.contains("rebalance_safety_scaled")),
"{diagnostics:?}"
);
assert!(
diagnostics
.iter()
.any(|line| line.contains("rebalance_buy_reduced")
&& line.contains("provisional=500")
&& line.contains("final=400")),
"{diagnostics:?}"
);
}
#[test]
fn target_portfolio_smart_pre_open_cash_does_not_spend_same_batch_sell_proceeds() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
@@ -7248,6 +7734,126 @@ mod tests {
);
}
#[test]
fn target_portfolio_smart_open_auction_uses_day_open_for_valuation() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
let mut snapshot = limit_test_snapshot();
snapshot.day_open = 12.0;
snapshot.open = 12.0;
snapshot.last_price = 12.0;
snapshot.prev_close = 10.0;
snapshot.upper_limit = 20.0;
snapshot.lower_limit = 1.0;
let data = DataSet::from_components_with_actions_and_quotes(
vec![limit_test_instrument()],
vec![snapshot],
Vec::new(),
vec![limit_test_candidate(true, true)],
vec![limit_test_benchmark()],
Vec::new(),
Vec::new(),
)
.expect("valid dataset");
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::DayOpen,
)
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_inactive_limit(false);
let portfolio = PortfolioState::new(20_000.0);
let mut target_weights = BTreeMap::new();
target_weights.insert("000001.SZ".to_string(), 1.0);
let (target_quantities, diagnostics) = broker
.target_quantities(date, &portfolio, &data, &target_weights)
.expect("target quantities");
assert_eq!(
target_quantities.get("000001.SZ").copied(),
Some(1_600),
"{diagnostics:?}"
);
}
#[test]
fn target_portfolio_smart_custom_valuation_does_not_create_limit_orders() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
let mut snapshot = limit_test_snapshot();
snapshot.day_open = 12.0;
snapshot.open = 12.0;
snapshot.last_price = 12.0;
snapshot.prev_close = 10.0;
snapshot.upper_limit = 20.0;
snapshot.lower_limit = 1.0;
let data = DataSet::from_components_with_actions_and_quotes(
vec![limit_test_instrument()],
vec![snapshot],
Vec::new(),
vec![limit_test_candidate(true, true)],
vec![limit_test_benchmark()],
Vec::new(),
Vec::new(),
)
.expect("valid dataset");
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::DayOpen,
)
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_inactive_limit(false);
let mut portfolio = PortfolioState::new(20_000.0);
let mut target_weights = BTreeMap::new();
target_weights.insert("000001.SZ".to_string(), 1.0);
let mut valuation_prices = BTreeMap::new();
valuation_prices.insert("000001.SZ".to_string(), 12.0);
let mut report = BrokerExecutionReport::default();
broker
.process_target_portfolio_smart(
date,
&mut portfolio,
&data,
&target_weights,
None,
Some(&valuation_prices),
"custom_valuation_market_order_test",
&mut BTreeMap::new(),
&mut BTreeMap::new(),
&mut None,
&mut BTreeMap::new(),
&mut report,
)
.expect("custom valuation should not turn rebalance into limit order");
assert!(
report
.order_events
.iter()
.any(|event| event.symbol == "000001.SZ" && event.status == OrderStatus::Filled),
"{:?}",
report.order_events
);
assert!(
report
.order_events
.iter()
.all(|event| !event.reason.contains("limit price not marketable yet")),
"{:?}",
report.order_events
);
assert!(
portfolio
.position("000001.SZ")
.is_some_and(|position| position.quantity == 1_600),
"{:?}",
portfolio.position("000001.SZ")
);
}
#[test]
fn target_value_zero_rejects_sell_when_market_snapshot_missing() {
let trade_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
+82
View File
@@ -4286,6 +4286,43 @@ mod tests {
}
}
#[derive(Debug)]
struct ScheduledTargetPortfolioSmartStrategy {
rule: ScheduleRule,
decision_date: NaiveDate,
target_weights: BTreeMap<String, f64>,
}
impl Strategy for ScheduledTargetPortfolioSmartStrategy {
fn name(&self) -> &str {
"scheduled_target_portfolio_smart"
}
fn schedule_rules(&self) -> Vec<ScheduleRule> {
vec![self.rule.clone()]
}
fn on_scheduled(
&mut self,
ctx: &StrategyContext<'_>,
rule: &ScheduleRule,
) -> Result<StrategyDecision, super::BacktestError> {
assert_eq!(rule.name, self.rule.name);
if ctx.decision_date != self.decision_date {
return Ok(StrategyDecision::default());
}
Ok(StrategyDecision {
order_intents: vec![OrderIntent::TargetPortfolioSmart {
target_weights: self.target_weights.clone(),
order_prices: None,
valuation_prices: None,
reason: "scheduled_target_portfolio_smart".to_string(),
}],
..StrategyDecision::default()
})
}
}
#[derive(Debug)]
struct ScheduledEligibleUniverseBuyStrategy {
rule: ScheduleRule,
@@ -4921,6 +4958,51 @@ mod tests {
assert_eq!(result.fills[0].price, 12.0);
}
#[test]
fn next_bar_open_target_portfolio_smart_sizes_with_execution_day_open() {
let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
let dataset = DataSet::from_components(
vec![default_instrument()],
vec![market(first, 10.0, 10.0), market(second, 12.0, 12.0)],
vec![factor(first), factor(second)],
vec![candidate(first), candidate(second)],
vec![benchmark(first), benchmark(second)],
)
.expect("dataset");
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default());
let config = BacktestConfig {
initial_cash: 100_000.0,
benchmark_code: "000852.SH".to_string(),
start_date: Some(first),
end_date: Some(second),
decision_lag_trading_days: 1,
execution_price_field: PriceField::Open,
};
let mut target_weights = BTreeMap::new();
target_weights.insert(SYMBOL.to_string(), 1.0);
let result = BacktestEngine::new(
dataset,
ScheduledTargetPortfolioSmartStrategy {
rule: ScheduleRule::daily("daily_target_portfolio", ScheduleStage::OnDay),
decision_date: first,
target_weights,
},
broker,
config,
)
.run()
.expect("backtest run");
assert_eq!(result.fills.len(), 1);
assert_eq!(result.fills[0].date, second);
assert_eq!(result.fills[0].decision_date, Some(first));
assert_eq!(result.fills[0].execution_date, Some(second));
assert_eq!(result.fills[0].price, 12.0);
assert_eq!(result.fills[0].quantity, 8_300);
}
#[test]
fn next_bar_open_executes_last_decision_without_execution_day_factor_snapshot() {
let first = d(2025, 1, 2);
File diff suppressed because it is too large Load Diff
+65 -9
View File
@@ -1001,6 +1001,22 @@ fn normalize_model_name(value: &str) -> String {
value.trim().to_ascii_lowercase().replace('-', "_")
}
fn normalize_slippage_model_name(value: &str) -> String {
match normalize_model_name(value).as_str() {
"percent"
| "percentage"
| "rate"
| "ratio"
| "price_percent"
| "price_percentage"
| "price_rate"
| "price_ratio_slippage"
| "priceratioslippage" => "price_ratio".to_string(),
"dynamic_volume_volatility" => "dynamic".to_string(),
other => other.to_string(),
}
}
fn parse_matching_type(value: Option<&str>) -> Result<Option<MatchingType>, String> {
let Some(raw) = value.map(str::trim).filter(|item| !item.is_empty()) else {
return Ok(None);
@@ -1047,7 +1063,7 @@ fn parse_slippage_model(
let volatility_coefficient = valid_non_negative(volatility_coefficient);
let max_value = valid_non_negative(max_value);
let model = model
.map(normalize_model_name)
.map(normalize_slippage_model_name)
.filter(|item| !item.is_empty())
.unwrap_or_else(|| {
if value.is_some_and(|item| item > 0.0) {
@@ -1062,13 +1078,11 @@ fn parse_slippage_model(
"price_ratio" => Some(SlippageModel::PriceRatio(value.unwrap_or(0.0))),
"tick_size" => Some(SlippageModel::TickSize(value.unwrap_or(0.0))),
"limit_price" => Some(SlippageModel::LimitPrice),
"dynamic" | "dynamic_volume_volatility" => {
Some(SlippageModel::Dynamic(DynamicSlippageConfig::new(
impact_coefficient.unwrap_or(0.5),
volatility_coefficient.unwrap_or(0.3),
max_value.or(value).unwrap_or(0.01),
)))
}
"dynamic" => Some(SlippageModel::Dynamic(DynamicSlippageConfig::new(
impact_coefficient.unwrap_or(0.5),
volatility_coefficient.unwrap_or(0.3),
max_value.or(value).unwrap_or(0.01),
))),
_ => None,
}
}
@@ -1891,7 +1905,15 @@ fn signal_rebalance_dates(rebalance: &StrategyRebalanceSpec) -> Option<BTreeSet<
.unwrap_or("")
.trim()
.to_ascii_lowercase();
if frequency != "signal_dates" && frequency != "signal-dates" && frequency != "signal dates" {
if !matches!(
frequency.as_str(),
"signal_dates"
| "signal-dates"
| "signal dates"
| "daily_model_score_rank"
| "dynamic_model_score_rank"
| "model_rank_rotation"
) {
return None;
}
let dates = rebalance
@@ -2402,6 +2424,26 @@ mod tests {
assert_eq!(cfg.signal_rebalance_dates.len(), 2);
}
#[test]
fn parses_dynamic_model_score_dates_into_platform_config() {
let spec = serde_json::json!({
"rebalance": {
"frequency": "daily_model_score_rank",
"dates": ["2025-11-10", "2025-11-17"]
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert_eq!(
cfg.signal_rebalance_dates,
BTreeSet::from([
NaiveDate::from_ymd_opt(2025, 11, 10).unwrap(),
NaiveDate::from_ymd_opt(2025, 11, 17).unwrap(),
])
);
}
#[test]
fn parses_execution_cost_overrides_into_platform_config() {
let spec = serde_json::json!({
@@ -2718,6 +2760,20 @@ mod tests {
assert!(cfg.strict_value_budget);
}
#[test]
fn parses_percent_slippage_alias_into_platform_config() {
let spec = serde_json::json!({
"execution": {
"slippageModel": "percent",
"slippageValue": 0.001
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert_eq!(cfg.slippage_model, SlippageModel::PriceRatio(0.001));
}
#[test]
fn parses_rebalance_cash_mode_and_forces_minute_to_actual_sequence() {
let spec = serde_json::json!({
+70 -4
View File
@@ -82,6 +82,8 @@ impl Position {
return;
}
let previous_quantity = self.quantity;
let previous_average_cost = self.average_cost;
self.lots.push(PositionLot {
acquired_date: date,
quantity,
@@ -93,7 +95,18 @@ impl Position {
self.day_trade_quantity_delta += quantity as i32;
self.day_buy_quantity += quantity;
self.day_buy_value += execution_price * quantity as f64;
self.recalculate_average_cost();
if previous_quantity > 0
&& previous_average_cost.is_finite()
&& previous_average_cost > 0.0
&& execution_price.is_finite()
&& execution_price > 0.0
{
self.average_cost = (previous_average_cost * previous_quantity as f64
+ execution_price * quantity as f64)
/ self.quantity as f64;
} else {
self.recalculate_average_cost();
}
self.refresh_day_pnl();
}
@@ -259,7 +272,11 @@ impl Position {
}
if let Some(lot) = self.lots.last_mut() {
lot.price += cost / quantity as f64;
self.recalculate_average_cost();
if self.quantity > 0 && self.average_cost.is_finite() && self.average_cost > 0.0 {
self.average_cost += cost / self.quantity as f64;
} else {
self.recalculate_average_cost();
}
}
self.day_trade_cost += cost;
self.refresh_day_pnl();
@@ -377,7 +394,11 @@ impl Position {
self.lots = scaled_lots;
self.quantity = self.lots.iter().map(|lot| lot.quantity).sum();
self.last_price /= ratio;
self.recalculate_average_cost();
if self.average_cost.is_finite() && self.average_cost > 0.0 {
self.average_cost /= ratio;
} else {
self.recalculate_average_cost();
}
self.day_split_ratio *= ratio;
self.refresh_day_pnl();
self.quantity as i32 - old_quantity as i32
@@ -844,6 +865,7 @@ impl PortfolioState {
let old_quantity = old_position.quantity;
let last_price = old_position.last_price;
let old_average_cost = old_position.average_cost;
let realized_pnl = old_position.realized_pnl;
let realized_entry_pnl = old_position.realized_entry_pnl;
let mut converted_lots = old_position
@@ -879,6 +901,8 @@ impl PortfolioState {
.positions
.entry(new_symbol.to_string())
.or_insert_with(|| Position::new(new_symbol));
let successor_quantity_before = successor.quantity;
let successor_average_cost_before = successor.average_cost;
successor.lots.extend(converted_lots);
successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum();
successor.realized_pnl += realized_pnl;
@@ -886,7 +910,30 @@ impl PortfolioState {
if converted_last_price > 0.0 {
successor.last_price = converted_last_price;
}
successor.recalculate_average_cost();
let converted_average_cost = if old_average_cost.is_finite()
&& old_average_cost > 0.0
&& ratio.is_finite()
&& ratio > 0.0
{
Some(old_average_cost / ratio)
} else {
None
};
if let Some(converted_average_cost) = converted_average_cost {
if successor_quantity_before > 0
&& successor_average_cost_before.is_finite()
&& successor_average_cost_before > 0.0
{
successor.average_cost = (successor_average_cost_before
* successor_quantity_before as f64
+ converted_average_cost * converted_quantity as f64)
/ successor.quantity as f64;
} else {
successor.average_cost = converted_average_cost;
}
} else {
successor.recalculate_average_cost();
}
successor.refresh_day_pnl();
Some(SuccessorConversionOutcome {
@@ -982,6 +1029,25 @@ mod tests {
assert!((position.average_cost - average_cost_before).abs() < 1e-12);
}
#[test]
fn buy_after_partial_sell_continues_moving_average_cost_basis() {
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let mut position = Position::new("300405.SZ");
position.buy(date, 100, 10.0);
position.buy(date, 100, 5.0);
assert!((position.average_cost - 7.5).abs() < 1e-12);
position.sell(100, 6.0).expect("partial sell");
assert_eq!(position.quantity, 100);
assert!((position.average_cost - 7.5).abs() < 1e-12);
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
position.buy(date, 100, 5.0);
assert_eq!(position.quantity, 200);
assert!((position.average_cost - 6.25).abs() < 1e-12);
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
}
#[test]
fn holdings_summary_reports_entry_price_pnl_excluding_buy_commission() {
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
+15
View File
@@ -223,6 +223,21 @@ impl ChinaAShareRiskControl {
Self::instrument_rejection_reason(instrument, date)
}
pub fn active_status_rejection_reason_with_config(
date: NaiveDate,
candidate: Option<&CandidateEligibility>,
instrument: Option<&Instrument>,
config: &FidcRiskControlConfig,
scope: RiskCheckScope,
) -> Option<&'static str> {
if let Some(reason) =
Self::instrument_rejection_reason_with_config(instrument, date, config, scope)
{
return Some(reason);
}
candidate.and_then(|candidate| candidate_active_status_rejection(candidate, config, scope))
}
pub fn selection_rejection_reason(
date: NaiveDate,
candidate: &CandidateEligibility,
+3 -3
View File
@@ -118,7 +118,7 @@ pub struct StrategyAiOptimizeRequest {
pub holding_count_contract: Option<StrategyAiHoldingCountContract>,
}
const DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT: &str = "默认收益目标:用户没有明确指定更高收益阈值时,三年回测区间策略总收益 >= 150% 视为满足收益目标;达到该阈值后可以继续优化夏普、回撤、换手和稳定性,但不得把已达标策略判为失败或为了追更高收益破坏无未来数据、持仓数量和同条件对账合同。";
const DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT: &str = "默认收益目标:用户没有明确指定更高收益阈值时,三年回测区间策略总收益严格 > 120% 视为满足收益目标120.00% 本身不算达标;达到该阈值后可以继续优化夏普、回撤、换手和稳定性,但不得把已达标策略判为失败或为了追更高收益破坏无未来数据、持仓数量和同条件对账合同。";
const DEFAULT_RISK_POLICY_DSL_PROMPT: &str = "reject_st_selection=false、reject_st_buy=true、reject_star_st_selection=false、reject_star_st_buy=true、reject_paused_selection=false、reject_paused_buy=true、reject_paused_sell=true、reject_inactive_selection=false、reject_inactive_buy=true、reject_inactive_sell=true、reject_new_listing_selection=false、reject_new_listing_buy=true、reject_kcb_selection=false、reject_kcb_buy=true、reject_bjse_selection=false、reject_bjse_buy=true、reject_one_yuan_selection=false、reject_one_yuan_buy=true、respect_allow_buy_sell=true、reject_upper_limit_selection=false、reject_lower_limit_selection=false、reject_upper_limit_buy=true、reject_lower_limit_sell=true、forbid_same_day_rebuy_after_sell=true、blacklist_enabled=true、allow_market_orders=true、live_trading_enabled=false、volume_limit_enabled=true、liquidity_limit_enabled=true、volume_percent=0.25、commission_rate=0.0003、minimum_commission=5、stamp_tax_rate_before_change=0.001、stamp_tax_rate_after_change=0.0005、stamp_tax_change_date=\"2023-08-28\"";
const DEFAULT_RISK_POLICY_DSL_CODE: &str = "reject_st_selection=false, reject_st_buy=true, reject_star_st_selection=false, reject_star_st_buy=true, reject_paused_selection=false, reject_paused_buy=true, reject_paused_sell=true, reject_inactive_selection=false, reject_inactive_buy=true, reject_inactive_sell=true, reject_new_listing_selection=false, reject_new_listing_buy=true, reject_kcb_selection=false, reject_kcb_buy=true, reject_bjse_selection=false, reject_bjse_buy=true, reject_one_yuan_selection=false, reject_one_yuan_buy=true, respect_allow_buy_sell=true, reject_upper_limit_selection=false, reject_lower_limit_selection=false, reject_upper_limit_buy=true, reject_lower_limit_sell=true, forbid_same_day_rebuy_after_sell=true, blacklist_enabled=true, allow_market_orders=true, live_trading_enabled=false, volume_limit_enabled=true, liquidity_limit_enabled=true, volume_percent=0.25, commission_rate=0.0003, minimum_commission=5, stamp_tax_rate_before_change=0.001, stamp_tax_rate_after_change=0.0005, stamp_tax_change_date=\"2023-08-28\"";
@@ -640,7 +640,7 @@ mod tests {
},
);
assert!(prompt.contains("三年回测区间策略总收益 >= 150% 视为满足收益目标"));
assert!(prompt.contains("三年回测区间策略总收益严格 > 120% 视为满足收益目标"));
assert!(prompt.contains("不得把已达标策略判为失败"));
assert!(prompt.contains("Strategy Factory Source Lake 已注册 source rows 字段"));
assert!(prompt.contains("不要回退 ficlaw-data"));
@@ -673,7 +673,7 @@ mod tests {
},
);
assert!(prompt.contains("三年回测区间策略总收益 >= 150% 视为满足收益目标"));
assert!(prompt.contains("三年回测区间策略总收益严格 > 120% 视为满足收益目标"));
assert!(prompt.contains("继续优化夏普、回撤、换手和稳定性"));
assert!(prompt.contains("Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计"));
assert!(prompt.contains("不要回退 ficlaw-data"));
+176 -3
View File
@@ -3349,7 +3349,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
}
#[test]
fn rebalance_uses_prev_close_for_open_auction_valuation() {
fn rebalance_uses_day_open_for_open_auction_valuation() {
let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let data = DataSet::from_components(
@@ -3515,7 +3515,7 @@ fn rebalance_uses_prev_close_for_open_auction_valuation() {
let held = portfolio.position("000001.SZ").expect("held position");
let target = portfolio.position("000002.SZ").expect("target position");
assert_eq!(held.quantity, 500);
assert_eq!(target.quantity, 400);
assert_eq!(target.quantity, 900);
assert_eq!(report.fill_events.len(), 2);
assert!(
report
@@ -3531,7 +3531,7 @@ fn rebalance_uses_prev_close_for_open_auction_valuation() {
.iter()
.any(|fill| fill.symbol == "000002.SZ"
&& fill.side == fidc_core::OrderSide::Buy
&& fill.quantity == 400)
&& fill.quantity == 900)
);
}
@@ -3725,6 +3725,179 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
);
}
#[test]
fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let data = DataSet::from_components(
vec![
Instrument {
symbol: "000001.SZ".to_string(),
name: "TargetA".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
},
Instrument {
symbol: "000002.SZ".to_string(),
name: "TargetB".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
},
],
vec![
DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
timestamp: Some("2024-01-10 10:18:00".to_string()),
day_open: 82.0,
open: 82.0,
high: 83.0,
low: 81.0,
close: 82.0,
last_price: 82.0,
bid1: 81.99,
ask1: 82.01,
prev_close: 82.0,
volume: 100_000,
minute_volume: 100_000,
bid1_volume: 80_000,
ask1_volume: 80_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 90.2,
lower_limit: 73.8,
price_tick: 0.01,
},
DailyMarketSnapshot {
date,
symbol: "000002.SZ".to_string(),
timestamp: Some("2024-01-10 10:18:00".to_string()),
day_open: 82.0,
open: 82.0,
high: 83.0,
low: 81.0,
close: 82.0,
last_price: 82.0,
bid1: 81.99,
ask1: 82.01,
prev_close: 82.0,
volume: 100_000,
minute_volume: 100_000,
bid1_volume: 80_000,
ask1_volume: 80_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 90.2,
lower_limit: 73.8,
price_tick: 0.01,
},
],
vec![
DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
market_cap_bn: 50.0,
free_float_cap_bn: 45.0,
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
date,
symbol: "000002.SZ".to_string(),
market_cap_bn: 60.0,
free_float_cap_bn: 50.0,
pe_ttm: 18.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
extra_factors: BTreeMap::new(),
},
],
vec![
CandidateEligibility {
date,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
CandidateEligibility {
date,
symbol: "000002.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
],
vec![BenchmarkSnapshot {
date,
benchmark: "000300.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 99.0,
volume: 1_000_000,
}],
)
.expect("dataset");
let mut portfolio = PortfolioState::new(100_000.0);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
broker
.execute(
date,
&mut portfolio,
&data,
&StrategyDecision {
rebalance: true,
target_weights: BTreeMap::from([
("000001.SZ".to_string(), 0.48),
("000002.SZ".to_string(), 0.48),
]),
exit_symbols: BTreeSet::new(),
order_intents: Vec::new(),
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
assert_eq!(
portfolio
.position("000001.SZ")
.map(|position| position.quantity),
Some(500)
);
assert_eq!(
portfolio
.position("000002.SZ")
.map(|position| position.quantity),
Some(500)
);
}
#[test]
fn broker_uses_board_specific_min_quantity_and_step_size_for_buy_sizing() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();