Compare commits
50 Commits
2de7127f02
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 67f15f12ca | |||
| 992d0e063c | |||
| 428434d98d | |||
| 20b07ddd7d | |||
| c094e78bef | |||
| 57345e8230 | |||
| d5d67102ac | |||
| 30a4071ee0 | |||
| 942ba84ca5 | |||
| ab3c821e59 | |||
| 1953e92b7b | |||
| 9cc625409f | |||
| 558d92fe23 | |||
| 0aef8f9491 | |||
| e275f4632d | |||
| 5166916926 | |||
| 56859dbe32 | |||
| 1272e427a1 | |||
| e396c895dc | |||
| f7d0889bbc | |||
| 9b84f3a1b9 | |||
| b1520fcca0 | |||
| bb51d91b76 | |||
| 2c43feec3e | |||
| 825de1d886 | |||
| 7397a2d69f | |||
| 7951ba67e3 | |||
| 2fcacb4313 | |||
| 5e480cd69b | |||
| bfbbac8952 | |||
| bb04864436 | |||
| b87e1b4a02 | |||
| 185ed49fe2 | |||
| a30face86a | |||
| 188376b75a | |||
| 6a98d9b0bd | |||
| a562a8e2ed | |||
| 215c4046d1 | |||
| d30c93989c | |||
| 4554f92fb4 | |||
| 556ed9b848 | |||
| 344e7e90c2 | |||
| c64bf16c8b | |||
| ce5ef3b77d | |||
| 8f47ee3679 | |||
| f15f229a09 | |||
| da12cdddd4 | |||
| 0bb47812e5 | |||
| 203e17ce87 | |||
| 32a417d6d1 |
+695
-51
@@ -12,7 +12,7 @@ use crate::events::{
|
|||||||
};
|
};
|
||||||
use crate::instrument::Instrument;
|
use crate::instrument::Instrument;
|
||||||
use crate::portfolio::PortfolioState;
|
use crate::portfolio::PortfolioState;
|
||||||
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig};
|
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, RiskCheckScope};
|
||||||
use crate::rules::{EquityRuleHooks, RuleCheck};
|
use crate::rules::{EquityRuleHooks, RuleCheck};
|
||||||
use crate::strategy::{
|
use crate::strategy::{
|
||||||
AlgoOrderStyle, OpenOrderView, OrderIntent, StrategyDecision, TargetPortfolioOrderPricing,
|
AlgoOrderStyle, OpenOrderView, OrderIntent, StrategyDecision, TargetPortfolioOrderPricing,
|
||||||
@@ -62,8 +62,6 @@ struct TargetConstraint {
|
|||||||
symbol: String,
|
symbol: String,
|
||||||
current_qty: u32,
|
current_qty: u32,
|
||||||
desired_qty: u32,
|
desired_qty: u32,
|
||||||
min_target_qty: u32,
|
|
||||||
max_target_qty: u32,
|
|
||||||
provisional_target_qty: u32,
|
provisional_target_qty: u32,
|
||||||
price: f64,
|
price: f64,
|
||||||
minimum_order_quantity: u32,
|
minimum_order_quantity: u32,
|
||||||
@@ -429,6 +427,12 @@ where
|
|||||||
symbol: &str,
|
symbol: &str,
|
||||||
snapshot: &crate::data::DailyMarketSnapshot,
|
snapshot: &crate::data::DailyMarketSnapshot,
|
||||||
) -> f64 {
|
) -> f64 {
|
||||||
|
if self.matching_type == MatchingType::NextBarOpen
|
||||||
|
&& snapshot.prev_close.is_finite()
|
||||||
|
&& snapshot.prev_close > 0.0
|
||||||
|
{
|
||||||
|
return snapshot.prev_close;
|
||||||
|
}
|
||||||
if self.aiquant_execution_rules && self.execution_price_field == PriceField::Last {
|
if self.aiquant_execution_rules && self.execution_price_field == PriceField::Last {
|
||||||
let start_cursor = self
|
let start_cursor = self
|
||||||
.runtime_intraday_start_time
|
.runtime_intraday_start_time
|
||||||
@@ -1554,16 +1558,19 @@ where
|
|||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
) -> Option<&'static str> {
|
) -> Option<&'static str> {
|
||||||
if self
|
if !self
|
||||||
.risk_config
|
.risk_config
|
||||||
.static_rules
|
.static_rules
|
||||||
.forbid_same_day_rebuy_after_sell
|
.forbid_same_day_rebuy_after_sell
|
||||||
&& self
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let sold_today = self
|
||||||
.same_day_sold_symbols
|
.same_day_sold_symbols
|
||||||
.borrow()
|
.borrow()
|
||||||
.get(&date)
|
.get(&date)
|
||||||
.is_some_and(|symbols| symbols.contains(symbol))
|
.is_some_and(|symbols| symbols.contains(symbol));
|
||||||
{
|
if sold_today {
|
||||||
return Some("same_day_rebuy_forbidden");
|
return Some("same_day_rebuy_forbidden");
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
@@ -1878,6 +1885,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(
|
fn reject_unavailable_order(
|
||||||
report: &mut BrokerExecutionReport,
|
report: &mut BrokerExecutionReport,
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
@@ -2076,8 +2140,6 @@ where
|
|||||||
symbol: symbol.clone(),
|
symbol: symbol.clone(),
|
||||||
current_qty,
|
current_qty,
|
||||||
desired_qty,
|
desired_qty,
|
||||||
min_target_qty,
|
|
||||||
max_target_qty,
|
|
||||||
provisional_target_qty,
|
provisional_target_qty,
|
||||||
price,
|
price,
|
||||||
minimum_order_quantity,
|
minimum_order_quantity,
|
||||||
@@ -2105,24 +2167,22 @@ where
|
|||||||
|
|
||||||
let mut best_targets = targets.clone();
|
let mut best_targets = targets.clone();
|
||||||
let mut best_proportion_diff = f64::INFINITY;
|
let mut best_proportion_diff = f64::INFINITY;
|
||||||
let initial_safety = if target_weight_sum > 0.95 { 1.2 } else { 1.0 };
|
let mut best_safety = f64::NEG_INFINITY;
|
||||||
let mut safety = initial_safety;
|
let mut record_candidate = |safety_value: f64| -> bool {
|
||||||
loop {
|
let safety_value = safety_value.clamp(0.0, 1.0);
|
||||||
let mut candidate_targets = targets.clone();
|
let mut candidate_targets = targets.clone();
|
||||||
let mut buy_cash_out = 0.0;
|
let mut buy_cash_out = 0.0;
|
||||||
for constraint in &buy_constraints {
|
for constraint in &buy_constraints {
|
||||||
let scaled_desired_qty = ((constraint.desired_qty as f64) * safety).floor() as u32;
|
let scaled_desired_qty =
|
||||||
let mut target_qty = self
|
((constraint.desired_qty as f64) * safety_value).floor() as u32;
|
||||||
|
let target_qty = self
|
||||||
.round_buy_quantity(
|
.round_buy_quantity(
|
||||||
scaled_desired_qty,
|
scaled_desired_qty,
|
||||||
constraint.minimum_order_quantity,
|
constraint.minimum_order_quantity,
|
||||||
constraint.order_step_size,
|
constraint.order_step_size,
|
||||||
)
|
)
|
||||||
.clamp(constraint.min_target_qty, constraint.max_target_qty)
|
.max(constraint.current_qty)
|
||||||
.max(constraint.current_qty);
|
.min(constraint.provisional_target_qty);
|
||||||
if target_qty < constraint.current_qty {
|
|
||||||
target_qty = constraint.current_qty;
|
|
||||||
}
|
|
||||||
if target_qty > constraint.current_qty {
|
if target_qty > constraint.current_qty {
|
||||||
buy_cash_out += self.estimated_buy_cash_out(
|
buy_cash_out += self.estimated_buy_cash_out(
|
||||||
date,
|
date,
|
||||||
@@ -2151,29 +2211,46 @@ where
|
|||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
if buy_cash_out <= projected_cash + 1e-6 {
|
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_targets = candidate_targets;
|
||||||
best_proportion_diff = proportion_diff;
|
best_proportion_diff = proportion_diff;
|
||||||
} else if best_proportion_diff.is_finite() {
|
best_safety = safety_value;
|
||||||
break;
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if safety <= 0.0 {
|
if best_safety.is_finite() && best_safety < 1.0 && diagnostics.len() < 16 {
|
||||||
break;
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
diagnostics.push(format!(
|
diagnostics.push(format!(
|
||||||
"rebalance_safety_scaled final_safety={:.4} target_weight_sum={:.4} projected_cash={:.2}",
|
"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 +2729,12 @@ where
|
|||||||
let Some(position) = portfolio.position(symbol) else {
|
let Some(position) = portfolio.position(symbol) else {
|
||||||
return 0;
|
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 {
|
let Ok(snapshot) = data.require_market(date, symbol) else {
|
||||||
return current_qty;
|
return current_qty;
|
||||||
};
|
};
|
||||||
@@ -2699,6 +2782,9 @@ where
|
|||||||
minimum_order_quantity: u32,
|
minimum_order_quantity: u32,
|
||||||
order_step_size: u32,
|
order_step_size: u32,
|
||||||
) -> u32 {
|
) -> u32 {
|
||||||
|
if self.aiquant_execution_rules {
|
||||||
|
return u32::MAX;
|
||||||
|
}
|
||||||
let Ok(snapshot) = data.require_market(date, symbol) else {
|
let Ok(snapshot) = data.require_market(date, symbol) else {
|
||||||
return current_qty;
|
return current_qty;
|
||||||
};
|
};
|
||||||
@@ -2870,6 +2956,15 @@ where
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let Some(snapshot) = data.market(date, symbol) else {
|
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(
|
Self::reject_unavailable_order(
|
||||||
report,
|
report,
|
||||||
date,
|
date,
|
||||||
@@ -2878,10 +2973,7 @@ where
|
|||||||
OrderSide::Sell,
|
OrderSide::Sell,
|
||||||
requested_qty,
|
requested_qty,
|
||||||
reason,
|
reason,
|
||||||
format!(
|
unavailable_reason,
|
||||||
"market snapshot is missing for price field {}",
|
|
||||||
price_field_name(self.execution_price_field)
|
|
||||||
),
|
|
||||||
emit_creation_events,
|
emit_creation_events,
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -3471,9 +3563,10 @@ where
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if data.market(date, symbol).is_none() {
|
if data.market(date, symbol).is_none() {
|
||||||
self.reject_missing_market_snapshot_order(
|
self.reject_missing_market_or_execution_risk_order(
|
||||||
report,
|
report,
|
||||||
date,
|
date,
|
||||||
|
data,
|
||||||
symbol,
|
symbol,
|
||||||
OrderSide::Sell,
|
OrderSide::Sell,
|
||||||
current_qty,
|
current_qty,
|
||||||
@@ -3503,9 +3596,10 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
let Some(snapshot) = data.market(date, symbol) else {
|
let Some(snapshot) = data.market(date, symbol) else {
|
||||||
self.reject_missing_market_snapshot_order(
|
self.reject_missing_market_or_execution_risk_order(
|
||||||
report,
|
report,
|
||||||
date,
|
date,
|
||||||
|
data,
|
||||||
symbol,
|
symbol,
|
||||||
if current_qty > 0 {
|
if current_qty > 0 {
|
||||||
OrderSide::Sell
|
OrderSide::Sell
|
||||||
@@ -3797,14 +3891,18 @@ where
|
|||||||
commission_state: &mut BTreeMap<u64, f64>,
|
commission_state: &mut BTreeMap<u64, f64>,
|
||||||
report: &mut BrokerExecutionReport,
|
report: &mut BrokerExecutionReport,
|
||||||
) -> Result<(), BacktestError> {
|
) -> Result<(), BacktestError> {
|
||||||
let price = data
|
let price = if self.aiquant_execution_rules && limit_price.is_finite() && limit_price > 0.0
|
||||||
.market(date, symbol)
|
{
|
||||||
|
limit_price
|
||||||
|
} else {
|
||||||
|
data.market(date, symbol)
|
||||||
.map(|snapshot| self.sizing_price(snapshot))
|
.map(|snapshot| self.sizing_price(snapshot))
|
||||||
.ok_or_else(|| BacktestError::MissingPrice {
|
.ok_or_else(|| BacktestError::MissingPrice {
|
||||||
date,
|
date,
|
||||||
symbol: symbol.to_string(),
|
symbol: symbol.to_string(),
|
||||||
field: price_field_name(self.execution_price_field),
|
field: price_field_name(self.execution_price_field),
|
||||||
})?;
|
})?
|
||||||
|
};
|
||||||
let current_qty = portfolio
|
let current_qty = portfolio
|
||||||
.position(symbol)
|
.position(symbol)
|
||||||
.map(|pos| pos.quantity)
|
.map(|pos| pos.quantity)
|
||||||
@@ -4030,9 +4128,10 @@ where
|
|||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
self.reject_missing_market_snapshot_order(
|
self.reject_missing_market_or_execution_risk_order(
|
||||||
report,
|
report,
|
||||||
date,
|
date,
|
||||||
|
data,
|
||||||
symbol,
|
symbol,
|
||||||
if value > 0.0 {
|
if value > 0.0 {
|
||||||
OrderSide::Buy
|
OrderSide::Buy
|
||||||
@@ -4561,6 +4660,15 @@ where
|
|||||||
report: &mut BrokerExecutionReport,
|
report: &mut BrokerExecutionReport,
|
||||||
) -> Result<(), BacktestError> {
|
) -> Result<(), BacktestError> {
|
||||||
let Some(snapshot) = data.market(date, symbol) else {
|
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(
|
Self::reject_unavailable_order(
|
||||||
report,
|
report,
|
||||||
date,
|
date,
|
||||||
@@ -4569,10 +4677,7 @@ where
|
|||||||
OrderSide::Buy,
|
OrderSide::Buy,
|
||||||
requested_qty,
|
requested_qty,
|
||||||
reason,
|
reason,
|
||||||
format!(
|
unavailable_reason,
|
||||||
"market snapshot is missing for price field {}",
|
|
||||||
price_field_name(self.execution_price_field)
|
|
||||||
),
|
|
||||||
emit_creation_events,
|
emit_creation_events,
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -5173,7 +5278,7 @@ where
|
|||||||
|
|
||||||
fn rebalance_valuation_price_field_name(&self) -> &'static str {
|
fn rebalance_valuation_price_field_name(&self) -> &'static str {
|
||||||
if self.is_open_auction_matching() {
|
if self.is_open_auction_matching() {
|
||||||
"prev_close"
|
"day_open"
|
||||||
} else {
|
} else {
|
||||||
price_field_name(self.execution_price_field)
|
price_field_name(self.execution_price_field)
|
||||||
}
|
}
|
||||||
@@ -5184,7 +5289,7 @@ where
|
|||||||
snapshot: &crate::data::DailyMarketSnapshot,
|
snapshot: &crate::data::DailyMarketSnapshot,
|
||||||
) -> Option<f64> {
|
) -> Option<f64> {
|
||||||
let price = if self.is_open_auction_matching() {
|
let price = if self.is_open_auction_matching() {
|
||||||
snapshot.prev_close
|
snapshot.price(PriceField::DayOpen)
|
||||||
} else {
|
} else {
|
||||||
snapshot.price(self.execution_price_field)
|
snapshot.price(self.execution_price_field)
|
||||||
};
|
};
|
||||||
@@ -6521,6 +6626,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]
|
#[test]
|
||||||
fn next_open_buy_limit_risk_uses_open_not_close() {
|
fn next_open_buy_limit_risk_uses_open_not_close() {
|
||||||
let execution_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date");
|
let execution_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date");
|
||||||
@@ -6991,6 +7153,38 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_open_target_value_valuation_uses_previous_close() {
|
||||||
|
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
||||||
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
|
ChinaAShareCostModel::default(),
|
||||||
|
ChinaEquityRuleHooks,
|
||||||
|
PriceField::Open,
|
||||||
|
)
|
||||||
|
.with_matching_type(MatchingType::NextBarOpen);
|
||||||
|
let mut snapshot = limit_test_snapshot();
|
||||||
|
snapshot.date = date;
|
||||||
|
snapshot.prev_close = 10.0;
|
||||||
|
snapshot.open = 11.0;
|
||||||
|
snapshot.close = 20.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 snapshot = data.market(date, "000001.SZ").expect("market snapshot");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
broker.target_value_valuation_price(date, &data, "000001.SZ", snapshot),
|
||||||
|
10.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn target_portfolio_smart_ignores_zero_weight_symbols_without_market_snapshot() {
|
fn target_portfolio_smart_ignores_zero_weight_symbols_without_market_snapshot() {
|
||||||
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");
|
||||||
@@ -7110,6 +7304,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]
|
#[test]
|
||||||
fn target_portfolio_smart_pre_open_cash_does_not_spend_same_batch_sell_proceeds() {
|
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");
|
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
||||||
@@ -7248,6 +7772,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]
|
#[test]
|
||||||
fn target_value_zero_rejects_sell_when_market_snapshot_missing() {
|
fn target_value_zero_rejects_sell_when_market_snapshot_missing() {
|
||||||
let trade_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
let trade_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
||||||
|
|||||||
@@ -448,49 +448,12 @@ pub struct EligibleUniverseSnapshot {
|
|||||||
pub free_float_cap_bn: f64,
|
pub free_float_cap_bn: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decision_adjusted_cap_bn(
|
pub fn decision_market_cap_bn(factor: &DailyFactorSnapshot) -> f64 {
|
||||||
factor_date: NaiveDate,
|
factor.market_cap_bn
|
||||||
raw_cap_bn: f64,
|
|
||||||
market: &DailyMarketSnapshot,
|
|
||||||
) -> f64 {
|
|
||||||
if !raw_cap_bn.is_finite() || raw_cap_bn <= 0.0 {
|
|
||||||
return f64::NAN;
|
|
||||||
}
|
|
||||||
if factor_date != market.date {
|
|
||||||
return raw_cap_bn;
|
|
||||||
}
|
|
||||||
if !market.close.is_finite()
|
|
||||||
|| market.close <= 0.0
|
|
||||||
|| !market.prev_close.is_finite()
|
|
||||||
|| market.prev_close <= 0.0
|
|
||||||
{
|
|
||||||
return f64::NAN;
|
|
||||||
}
|
|
||||||
raw_cap_bn * market.prev_close / market.close
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn factor_market_cap_is_decision_adjusted(factor: &DailyFactorSnapshot) -> bool {
|
pub fn decision_free_float_cap_bn(factor: &DailyFactorSnapshot) -> f64 {
|
||||||
factor
|
factor.free_float_cap_bn
|
||||||
.extra_factors
|
|
||||||
.get("__market_cap_decision_adjusted")
|
|
||||||
.is_some_and(|value| value.is_finite() && *value > 0.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decision_market_cap_bn(factor: &DailyFactorSnapshot, market: &DailyMarketSnapshot) -> f64 {
|
|
||||||
if factor_market_cap_is_decision_adjusted(factor) {
|
|
||||||
return factor.market_cap_bn;
|
|
||||||
}
|
|
||||||
decision_adjusted_cap_bn(factor.date, factor.market_cap_bn, market)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decision_free_float_cap_bn(
|
|
||||||
factor: &DailyFactorSnapshot,
|
|
||||||
market: &DailyMarketSnapshot,
|
|
||||||
) -> f64 {
|
|
||||||
if factor_market_cap_is_decision_adjusted(factor) {
|
|
||||||
return factor.free_float_cap_bn;
|
|
||||||
}
|
|
||||||
decision_adjusted_cap_bn(factor.date, factor.free_float_cap_bn, market)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -3048,20 +3011,21 @@ fn build_fundamental_universe_for_date(
|
|||||||
return rows;
|
return rows;
|
||||||
};
|
};
|
||||||
for factor in factors {
|
for factor in factors {
|
||||||
let Some(market) = market_by_date
|
if market_by_date
|
||||||
.get(&date)
|
.get(&date)
|
||||||
.and_then(|rows| find_arc_by_symbol(rows, &factor.symbol, |row| row.symbol.as_str()))
|
.and_then(|rows| find_arc_by_symbol(rows, &factor.symbol, |row| row.symbol.as_str()))
|
||||||
else {
|
.is_none()
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
};
|
}
|
||||||
let market_cap_bn = decision_market_cap_bn(factor, market);
|
let market_cap_bn = decision_market_cap_bn(factor);
|
||||||
if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() {
|
if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
rows.push(EligibleUniverseSnapshot {
|
rows.push(EligibleUniverseSnapshot {
|
||||||
symbol: factor.symbol.clone(),
|
symbol: factor.symbol.clone(),
|
||||||
market_cap_bn,
|
market_cap_bn,
|
||||||
free_float_cap_bn: decision_free_float_cap_bn(factor, market),
|
free_float_cap_bn: decision_free_float_cap_bn(factor),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
rows.sort_by(|left, right| {
|
rows.sort_by(|left, right| {
|
||||||
@@ -3136,11 +3100,11 @@ fn build_eligible_universe_for_date_from_factors(
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let market_cap_bn = decision_market_cap_bn(factor, market);
|
let market_cap_bn = decision_market_cap_bn(factor);
|
||||||
if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() {
|
if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let free_float_cap_bn = decision_free_float_cap_bn(factor, market);
|
let free_float_cap_bn = decision_free_float_cap_bn(factor);
|
||||||
rows.push(EligibleUniverseSnapshot {
|
rows.push(EligibleUniverseSnapshot {
|
||||||
symbol: factor.symbol.clone(),
|
symbol: factor.symbol.clone(),
|
||||||
market_cap_bn,
|
market_cap_bn,
|
||||||
@@ -3444,11 +3408,11 @@ mod tests {
|
|||||||
|
|
||||||
let rows = data.eligible_universe_on(date);
|
let rows = data.eligible_universe_on(date);
|
||||||
assert_eq!(rows.len(), 2);
|
assert_eq!(rows.len(), 2);
|
||||||
assert_eq!(rows[0].symbol, "000001.SZ");
|
assert_eq!(rows[0].symbol, "000002.SZ");
|
||||||
assert!((rows[0].market_cap_bn - 6.0).abs() < 1e-9);
|
assert!((rows[0].market_cap_bn - 10.0).abs() < 1e-9);
|
||||||
assert!((rows[0].free_float_cap_bn - 2.0).abs() < 1e-9);
|
assert_eq!(rows[1].symbol, "000001.SZ");
|
||||||
assert_eq!(rows[1].symbol, "000002.SZ");
|
assert!((rows[1].market_cap_bn - 12.0).abs() < 1e-9);
|
||||||
assert!((rows[1].market_cap_bn - 10.0).abs() < 1e-9);
|
assert!((rows[1].free_float_cap_bn - 4.0).abs() < 1e-9);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3621,33 +3585,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decision_market_cap_keeps_pre_adjusted_factor() {
|
fn decision_market_cap_uses_factor_date_snapshot_without_price_reconstruction() {
|
||||||
let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();
|
let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();
|
||||||
let market = DailyMarketSnapshot {
|
|
||||||
date,
|
|
||||||
symbol: "000001.SZ".to_string(),
|
|
||||||
timestamp: Some("2025-01-06 10:18:00".to_string()),
|
|
||||||
day_open: 10.0,
|
|
||||||
open: 10.0,
|
|
||||||
high: 20.0,
|
|
||||||
low: 10.0,
|
|
||||||
close: 20.0,
|
|
||||||
last_price: 10.0,
|
|
||||||
bid1: 10.0,
|
|
||||||
ask1: 10.0,
|
|
||||||
prev_close: 10.0,
|
|
||||||
volume: 100_000,
|
|
||||||
minute_volume: 1_000,
|
|
||||||
bid1_volume: 1_000,
|
|
||||||
ask1_volume: 1_000,
|
|
||||||
trading_phase: Some("continuous".to_string()),
|
|
||||||
paused: false,
|
|
||||||
upper_limit: 11.0,
|
|
||||||
lower_limit: 9.0,
|
|
||||||
price_tick: 0.01,
|
|
||||||
};
|
|
||||||
let mut extra_factors = BTreeMap::new();
|
|
||||||
extra_factors.insert("__market_cap_decision_adjusted".to_string(), 1.0);
|
|
||||||
let factor = DailyFactorSnapshot {
|
let factor = DailyFactorSnapshot {
|
||||||
date,
|
date,
|
||||||
symbol: "000001.SZ".to_string(),
|
symbol: "000001.SZ".to_string(),
|
||||||
@@ -3656,11 +3595,11 @@ mod tests {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
extra_factors,
|
extra_factors: BTreeMap::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!((decision_market_cap_bn(&factor, &market) - 12.0).abs() < 1e-9);
|
assert!((decision_market_cap_bn(&factor) - 12.0).abs() < 1e-9);
|
||||||
assert!((decision_free_float_cap_bn(&factor, &market) - 4.0).abs() < 1e-9);
|
assert!((decision_free_float_cap_bn(&factor) - 4.0).abs() < 1e-9);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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)]
|
#[derive(Debug)]
|
||||||
struct ScheduledEligibleUniverseBuyStrategy {
|
struct ScheduledEligibleUniverseBuyStrategy {
|
||||||
rule: ScheduleRule,
|
rule: ScheduleRule,
|
||||||
@@ -4921,6 +4958,51 @@ mod tests {
|
|||||||
assert_eq!(result.fills[0].price, 12.0);
|
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]
|
#[test]
|
||||||
fn next_bar_open_executes_last_decision_without_execution_day_factor_snapshot() {
|
fn next_bar_open_executes_last_decision_without_execution_day_factor_snapshot() {
|
||||||
let first = d(2025, 1, 2);
|
let first = d(2025, 1, 2);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -691,6 +691,12 @@ pub struct StrategyExpressionTradingConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub daily_top_up: Option<bool>,
|
pub daily_top_up: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub daily_position_target_adjust: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rebalance_existing_positions: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub selection_buffer_multiple: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
pub retry_empty_rebalance: Option<bool>,
|
pub retry_empty_rebalance: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub weak_market_shrink_overweight_threshold: Option<f64>,
|
pub weak_market_shrink_overweight_threshold: Option<f64>,
|
||||||
@@ -1001,6 +1007,22 @@ fn normalize_model_name(value: &str) -> String {
|
|||||||
value.trim().to_ascii_lowercase().replace('-', "_")
|
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> {
|
fn parse_matching_type(value: Option<&str>) -> Result<Option<MatchingType>, String> {
|
||||||
let Some(raw) = value.map(str::trim).filter(|item| !item.is_empty()) else {
|
let Some(raw) = value.map(str::trim).filter(|item| !item.is_empty()) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -1047,7 +1069,7 @@ fn parse_slippage_model(
|
|||||||
let volatility_coefficient = valid_non_negative(volatility_coefficient);
|
let volatility_coefficient = valid_non_negative(volatility_coefficient);
|
||||||
let max_value = valid_non_negative(max_value);
|
let max_value = valid_non_negative(max_value);
|
||||||
let model = model
|
let model = model
|
||||||
.map(normalize_model_name)
|
.map(normalize_slippage_model_name)
|
||||||
.filter(|item| !item.is_empty())
|
.filter(|item| !item.is_empty())
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
if value.is_some_and(|item| item > 0.0) {
|
if value.is_some_and(|item| item > 0.0) {
|
||||||
@@ -1062,13 +1084,11 @@ fn parse_slippage_model(
|
|||||||
"price_ratio" => Some(SlippageModel::PriceRatio(value.unwrap_or(0.0))),
|
"price_ratio" => Some(SlippageModel::PriceRatio(value.unwrap_or(0.0))),
|
||||||
"tick_size" => Some(SlippageModel::TickSize(value.unwrap_or(0.0))),
|
"tick_size" => Some(SlippageModel::TickSize(value.unwrap_or(0.0))),
|
||||||
"limit_price" => Some(SlippageModel::LimitPrice),
|
"limit_price" => Some(SlippageModel::LimitPrice),
|
||||||
"dynamic" | "dynamic_volume_volatility" => {
|
"dynamic" => Some(SlippageModel::Dynamic(DynamicSlippageConfig::new(
|
||||||
Some(SlippageModel::Dynamic(DynamicSlippageConfig::new(
|
|
||||||
impact_coefficient.unwrap_or(0.5),
|
impact_coefficient.unwrap_or(0.5),
|
||||||
volatility_coefficient.unwrap_or(0.3),
|
volatility_coefficient.unwrap_or(0.3),
|
||||||
max_value.or(value).unwrap_or(0.01),
|
max_value.or(value).unwrap_or(0.01),
|
||||||
)))
|
))),
|
||||||
}
|
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1219,6 +1239,17 @@ fn stock_volume_ma_expr(days: usize) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_index_throttle_exposure_expr(expr: &str) -> String {
|
||||||
|
let mut normalized = expr.to_string();
|
||||||
|
for suffix in ["short", "long", "5", "10", "20", "30"] {
|
||||||
|
normalized = normalized.replace(
|
||||||
|
&format!("benchmark_ma{suffix}"),
|
||||||
|
&format!("signal_ma{suffix}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
|
||||||
fn infer_expression_windows(
|
fn infer_expression_windows(
|
||||||
cfg: &mut PlatformExprStrategyConfig,
|
cfg: &mut PlatformExprStrategyConfig,
|
||||||
benchmark_short_explicit: bool,
|
benchmark_short_explicit: bool,
|
||||||
@@ -1230,6 +1261,7 @@ fn infer_expression_windows(
|
|||||||
let mut benchmark_days = Vec::new();
|
let mut benchmark_days = Vec::new();
|
||||||
for expr in [&cfg.exposure_expr, &cfg.buy_scale_expr] {
|
for expr in [&cfg.exposure_expr, &cfg.buy_scale_expr] {
|
||||||
benchmark_days.extend(prefixed_ma_lookbacks(expr, "benchmark_ma"));
|
benchmark_days.extend(prefixed_ma_lookbacks(expr, "benchmark_ma"));
|
||||||
|
benchmark_days.extend(prefixed_ma_lookbacks(expr, "signal_ma"));
|
||||||
benchmark_days.extend(rolling_mean_lookbacks(expr, "benchmark_close"));
|
benchmark_days.extend(rolling_mean_lookbacks(expr, "benchmark_close"));
|
||||||
}
|
}
|
||||||
let benchmark_days = sorted_unique_positive(benchmark_days);
|
let benchmark_days = sorted_unique_positive(benchmark_days);
|
||||||
@@ -1539,7 +1571,16 @@ pub fn platform_expr_config_from_spec(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.filter(|value| !value.trim().is_empty())
|
.filter(|value| !value.trim().is_empty())
|
||||||
{
|
{
|
||||||
cfg.exposure_expr = expr.clone();
|
cfg.exposure_expr = if spec
|
||||||
|
.engine_config
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|engine| engine.index_throttle.as_ref())
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
normalize_index_throttle_exposure_expr(expr)
|
||||||
|
} else {
|
||||||
|
expr.clone()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if let Some(expr) = risk
|
if let Some(expr) = risk
|
||||||
.stop_loss_expr
|
.stop_loss_expr
|
||||||
@@ -1593,6 +1634,18 @@ pub fn platform_expr_config_from_spec(
|
|||||||
if let Some(enabled) = trading.daily_top_up {
|
if let Some(enabled) = trading.daily_top_up {
|
||||||
cfg.daily_top_up_enabled = enabled;
|
cfg.daily_top_up_enabled = enabled;
|
||||||
}
|
}
|
||||||
|
if let Some(enabled) = trading.daily_position_target_adjust {
|
||||||
|
cfg.daily_position_target_adjust_enabled = enabled;
|
||||||
|
}
|
||||||
|
if let Some(enabled) = trading.rebalance_existing_positions {
|
||||||
|
cfg.rebalance_existing_positions = enabled;
|
||||||
|
}
|
||||||
|
if let Some(multiple) = trading
|
||||||
|
.selection_buffer_multiple
|
||||||
|
.filter(|value| value.is_finite() && *value >= 1.0)
|
||||||
|
{
|
||||||
|
cfg.selection_buffer_multiple = multiple;
|
||||||
|
}
|
||||||
if let Some(enabled) = trading.retry_empty_rebalance {
|
if let Some(enabled) = trading.retry_empty_rebalance {
|
||||||
cfg.retry_empty_rebalance = enabled;
|
cfg.retry_empty_rebalance = enabled;
|
||||||
}
|
}
|
||||||
@@ -1715,7 +1768,7 @@ pub fn platform_expr_config_from_spec(
|
|||||||
let defensive = index_throttle.defensive_exposure.unwrap_or(0.5);
|
let defensive = index_throttle.defensive_exposure.unwrap_or(0.5);
|
||||||
let full = index_throttle.full_exposure.unwrap_or(1.0);
|
let full = index_throttle.full_exposure.unwrap_or(1.0);
|
||||||
cfg.exposure_expr = format!(
|
cfg.exposure_expr = format!(
|
||||||
"benchmark_ma_short < benchmark_ma_long * {} ? {} : {}",
|
"signal_ma_short < signal_ma_long * {} ? {} : {}",
|
||||||
ratio, defensive, full
|
ratio, defensive, full
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1870,7 +1923,15 @@ fn signal_rebalance_dates(rebalance: &StrategyRebalanceSpec) -> Option<BTreeSet<
|
|||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.trim()
|
.trim()
|
||||||
.to_ascii_lowercase();
|
.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;
|
return None;
|
||||||
}
|
}
|
||||||
let dates = rebalance
|
let dates = rebalance
|
||||||
@@ -2381,6 +2442,26 @@ mod tests {
|
|||||||
assert_eq!(cfg.signal_rebalance_dates.len(), 2);
|
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]
|
#[test]
|
||||||
fn parses_execution_cost_overrides_into_platform_config() {
|
fn parses_execution_cost_overrides_into_platform_config() {
|
||||||
let spec = serde_json::json!({
|
let spec = serde_json::json!({
|
||||||
@@ -2697,6 +2778,20 @@ mod tests {
|
|||||||
assert!(cfg.strict_value_budget);
|
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]
|
#[test]
|
||||||
fn parses_rebalance_cash_mode_and_forces_minute_to_actual_sequence() {
|
fn parses_rebalance_cash_mode_and_forces_minute_to_actual_sequence() {
|
||||||
let spec = serde_json::json!({
|
let spec = serde_json::json!({
|
||||||
@@ -2868,6 +2963,9 @@ mod tests {
|
|||||||
"runtimeExpressions": {
|
"runtimeExpressions": {
|
||||||
"trading": {
|
"trading": {
|
||||||
"dailyTopUp": false,
|
"dailyTopUp": false,
|
||||||
|
"dailyPositionTargetAdjust": false,
|
||||||
|
"rebalanceExistingPositions": true,
|
||||||
|
"selectionBufferMultiple": 1.5,
|
||||||
"retryEmptyRebalance": false
|
"retryEmptyRebalance": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2876,6 +2974,9 @@ mod tests {
|
|||||||
let cfg = platform_expr_config_from_value("", "", &explicit_off).expect("config");
|
let cfg = platform_expr_config_from_value("", "", &explicit_off).expect("config");
|
||||||
|
|
||||||
assert!(!cfg.daily_top_up_enabled);
|
assert!(!cfg.daily_top_up_enabled);
|
||||||
|
assert!(!cfg.daily_position_target_adjust_enabled);
|
||||||
|
assert!(cfg.rebalance_existing_positions);
|
||||||
|
assert_eq!(cfg.selection_buffer_multiple, 1.5);
|
||||||
assert!(!cfg.retry_empty_rebalance);
|
assert!(!cfg.retry_empty_rebalance);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2963,6 +3064,40 @@ mod tests {
|
|||||||
assert_eq!(cfg.stock_long_ma_days, 21);
|
assert_eq!(cfg.stock_long_ma_days, 21);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn index_throttle_uses_signal_ma_not_performance_benchmark_ma() {
|
||||||
|
let spec = serde_json::json!({
|
||||||
|
"signalSymbol": "000852.SH",
|
||||||
|
"benchmark": {
|
||||||
|
"instrumentId": "932000.CSI"
|
||||||
|
},
|
||||||
|
"engineConfig": {
|
||||||
|
"profileName": "aiquant",
|
||||||
|
"indexThrottle": {
|
||||||
|
"shortDays": 10,
|
||||||
|
"longDays": 30,
|
||||||
|
"defensiveExposure": 0.5,
|
||||||
|
"fullExposure": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runtimeExpressions": {
|
||||||
|
"risk": {
|
||||||
|
"exposureExpr": "if benchmark_ma10 > benchmark_ma30 { 1.0 } else { 0.5 }"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let cfg = platform_expr_config_from_value("strategy88", "", &spec).expect("config");
|
||||||
|
|
||||||
|
assert_eq!(cfg.signal_symbol, "000852.SH");
|
||||||
|
assert_eq!(
|
||||||
|
cfg.exposure_expr,
|
||||||
|
"if signal_ma10 > signal_ma30 { 1.0 } else { 0.5 }"
|
||||||
|
);
|
||||||
|
assert_eq!(cfg.benchmark_short_ma_days, 10);
|
||||||
|
assert_eq!(cfg.benchmark_long_ma_days, 30);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_daily_schedule_time_for_aiquant_execution_quotes() {
|
fn parses_daily_schedule_time_for_aiquant_execution_quotes() {
|
||||||
let spec = serde_json::json!({
|
let spec = serde_json::json!({
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ impl Position {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let previous_quantity = self.quantity;
|
||||||
|
let previous_average_cost = self.average_cost;
|
||||||
self.lots.push(PositionLot {
|
self.lots.push(PositionLot {
|
||||||
acquired_date: date,
|
acquired_date: date,
|
||||||
quantity,
|
quantity,
|
||||||
@@ -93,7 +95,18 @@ impl Position {
|
|||||||
self.day_trade_quantity_delta += quantity as i32;
|
self.day_trade_quantity_delta += quantity as i32;
|
||||||
self.day_buy_quantity += quantity;
|
self.day_buy_quantity += quantity;
|
||||||
self.day_buy_value += execution_price * quantity as f64;
|
self.day_buy_value += execution_price * quantity as f64;
|
||||||
|
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.recalculate_average_cost();
|
||||||
|
}
|
||||||
self.refresh_day_pnl();
|
self.refresh_day_pnl();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,8 +272,12 @@ impl Position {
|
|||||||
}
|
}
|
||||||
if let Some(lot) = self.lots.last_mut() {
|
if let Some(lot) = self.lots.last_mut() {
|
||||||
lot.price += cost / quantity as f64;
|
lot.price += cost / quantity as f64;
|
||||||
|
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.recalculate_average_cost();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
self.day_trade_cost += cost;
|
self.day_trade_cost += cost;
|
||||||
self.refresh_day_pnl();
|
self.refresh_day_pnl();
|
||||||
}
|
}
|
||||||
@@ -377,7 +394,11 @@ impl Position {
|
|||||||
self.lots = scaled_lots;
|
self.lots = scaled_lots;
|
||||||
self.quantity = self.lots.iter().map(|lot| lot.quantity).sum();
|
self.quantity = self.lots.iter().map(|lot| lot.quantity).sum();
|
||||||
self.last_price /= ratio;
|
self.last_price /= ratio;
|
||||||
|
if self.average_cost.is_finite() && self.average_cost > 0.0 {
|
||||||
|
self.average_cost /= ratio;
|
||||||
|
} else {
|
||||||
self.recalculate_average_cost();
|
self.recalculate_average_cost();
|
||||||
|
}
|
||||||
self.day_split_ratio *= ratio;
|
self.day_split_ratio *= ratio;
|
||||||
self.refresh_day_pnl();
|
self.refresh_day_pnl();
|
||||||
self.quantity as i32 - old_quantity as i32
|
self.quantity as i32 - old_quantity as i32
|
||||||
@@ -844,6 +865,7 @@ impl PortfolioState {
|
|||||||
|
|
||||||
let old_quantity = old_position.quantity;
|
let old_quantity = old_position.quantity;
|
||||||
let last_price = old_position.last_price;
|
let last_price = old_position.last_price;
|
||||||
|
let old_average_cost = old_position.average_cost;
|
||||||
let realized_pnl = old_position.realized_pnl;
|
let realized_pnl = old_position.realized_pnl;
|
||||||
let realized_entry_pnl = old_position.realized_entry_pnl;
|
let realized_entry_pnl = old_position.realized_entry_pnl;
|
||||||
let mut converted_lots = old_position
|
let mut converted_lots = old_position
|
||||||
@@ -879,6 +901,8 @@ impl PortfolioState {
|
|||||||
.positions
|
.positions
|
||||||
.entry(new_symbol.to_string())
|
.entry(new_symbol.to_string())
|
||||||
.or_insert_with(|| Position::new(new_symbol));
|
.or_insert_with(|| Position::new(new_symbol));
|
||||||
|
let successor_quantity_before = successor.quantity;
|
||||||
|
let successor_average_cost_before = successor.average_cost;
|
||||||
successor.lots.extend(converted_lots);
|
successor.lots.extend(converted_lots);
|
||||||
successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum();
|
successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum();
|
||||||
successor.realized_pnl += realized_pnl;
|
successor.realized_pnl += realized_pnl;
|
||||||
@@ -886,7 +910,30 @@ impl PortfolioState {
|
|||||||
if converted_last_price > 0.0 {
|
if converted_last_price > 0.0 {
|
||||||
successor.last_price = converted_last_price;
|
successor.last_price = converted_last_price;
|
||||||
}
|
}
|
||||||
|
let converted_average_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.recalculate_average_cost();
|
||||||
|
}
|
||||||
successor.refresh_day_pnl();
|
successor.refresh_day_pnl();
|
||||||
|
|
||||||
Some(SuccessorConversionOutcome {
|
Some(SuccessorConversionOutcome {
|
||||||
@@ -982,6 +1029,25 @@ mod tests {
|
|||||||
assert!((position.average_cost - average_cost_before).abs() < 1e-12);
|
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]
|
#[test]
|
||||||
fn holdings_summary_reports_entry_price_pnl_excluding_buy_commission() {
|
fn holdings_summary_reports_entry_price_pnl_excluding_buy_commission() {
|
||||||
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||||
|
|||||||
@@ -223,6 +223,21 @@ impl ChinaAShareRiskControl {
|
|||||||
Self::instrument_rejection_reason(instrument, date)
|
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(
|
pub fn selection_rejection_reason(
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
candidate: &CandidateEligibility,
|
candidate: &CandidateEligibility,
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ pub struct StrategyAiOptimizeRequest {
|
|||||||
pub holding_count_contract: Option<StrategyAiHoldingCountContract>,
|
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_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\"";
|
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("不得把已达标策略判为失败"));
|
||||||
assert!(prompt.contains("Strategy Factory Source Lake 已注册 source rows 字段"));
|
assert!(prompt.contains("Strategy Factory Source Lake 已注册 source rows 字段"));
|
||||||
assert!(prompt.contains("不要回退 ficlaw-data"));
|
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("继续优化夏普、回撤、换手和稳定性"));
|
||||||
assert!(prompt.contains("Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计"));
|
assert!(prompt.contains("Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计"));
|
||||||
assert!(prompt.contains("不要回退 ficlaw-data"));
|
assert!(prompt.contains("不要回退 ficlaw-data"));
|
||||||
|
|||||||
@@ -3349,7 +3349,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();
|
||||||
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||||
let data = DataSet::from_components(
|
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 held = portfolio.position("000001.SZ").expect("held position");
|
||||||
let target = portfolio.position("000002.SZ").expect("target position");
|
let target = portfolio.position("000002.SZ").expect("target position");
|
||||||
assert_eq!(held.quantity, 500);
|
assert_eq!(held.quantity, 500);
|
||||||
assert_eq!(target.quantity, 400);
|
assert_eq!(target.quantity, 900);
|
||||||
assert_eq!(report.fill_events.len(), 2);
|
assert_eq!(report.fill_events.len(), 2);
|
||||||
assert!(
|
assert!(
|
||||||
report
|
report
|
||||||
@@ -3531,7 +3531,7 @@ fn rebalance_uses_prev_close_for_open_auction_valuation() {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|fill| fill.symbol == "000002.SZ"
|
.any(|fill| fill.symbol == "000002.SZ"
|
||||||
&& fill.side == fidc_core::OrderSide::Buy
|
&& 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]
|
#[test]
|
||||||
fn broker_uses_board_specific_min_quantity_and_step_size_for_buy_sizing() {
|
fn broker_uses_board_specific_min_quantity_and_step_size_for_buy_sizing() {
|
||||||
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user