Compare commits
2 Commits
v2026.5.13
...
v2026.05.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94662b6e75 | ||
|
|
616cab0e7e |
@@ -2919,6 +2919,7 @@ where
|
|||||||
let order_step_size = self.order_step_size(data, symbol);
|
let order_step_size = self.order_step_size(data, symbol);
|
||||||
let price = self.sizing_price(snapshot);
|
let price = self.sizing_price(snapshot);
|
||||||
let snapshot_requested_qty = self.value_buy_quantity(
|
let snapshot_requested_qty = self.value_buy_quantity(
|
||||||
|
date,
|
||||||
value.abs(),
|
value.abs(),
|
||||||
price,
|
price,
|
||||||
minimum_order_quantity,
|
minimum_order_quantity,
|
||||||
@@ -3014,6 +3015,7 @@ where
|
|||||||
let order_step_size = self.order_step_size(data, symbol);
|
let order_step_size = self.order_step_size(data, symbol);
|
||||||
let price = self.sizing_price(snapshot);
|
let price = self.sizing_price(snapshot);
|
||||||
let snapshot_requested_qty = self.value_buy_quantity(
|
let snapshot_requested_qty = self.value_buy_quantity(
|
||||||
|
date,
|
||||||
value.abs(),
|
value.abs(),
|
||||||
price,
|
price,
|
||||||
minimum_order_quantity,
|
minimum_order_quantity,
|
||||||
@@ -3181,6 +3183,7 @@ where
|
|||||||
let order_step_size = self.order_step_size(data, symbol);
|
let order_step_size = self.order_step_size(data, symbol);
|
||||||
let price = self.sizing_price(snapshot);
|
let price = self.sizing_price(snapshot);
|
||||||
let snapshot_requested_qty = self.value_buy_quantity(
|
let snapshot_requested_qty = self.value_buy_quantity(
|
||||||
|
date,
|
||||||
value.abs(),
|
value.abs(),
|
||||||
price,
|
price,
|
||||||
minimum_order_quantity,
|
minimum_order_quantity,
|
||||||
@@ -4066,6 +4069,7 @@ where
|
|||||||
|
|
||||||
fn value_buy_quantity(
|
fn value_buy_quantity(
|
||||||
&self,
|
&self,
|
||||||
|
date: NaiveDate,
|
||||||
value_budget: f64,
|
value_budget: f64,
|
||||||
price: f64,
|
price: f64,
|
||||||
minimum_order_quantity: u32,
|
minimum_order_quantity: u32,
|
||||||
@@ -4075,13 +4079,17 @@ where
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
let minimum = minimum_order_quantity.max(1);
|
let minimum = minimum_order_quantity.max(1);
|
||||||
let step = order_step_size.max(1);
|
let raw_quantity = (value_budget / price).floor() as u32;
|
||||||
if price * minimum as f64 > value_budget + 1e-6 {
|
let mut quantity =
|
||||||
return 0;
|
self.round_buy_quantity(raw_quantity, minimum_order_quantity, order_step_size);
|
||||||
|
while quantity >= minimum {
|
||||||
|
if self.estimated_buy_cash_out(date, price, quantity) <= value_budget + 1e-6 {
|
||||||
|
return quantity;
|
||||||
|
}
|
||||||
|
quantity =
|
||||||
|
self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size);
|
||||||
}
|
}
|
||||||
let raw_steps = (value_budget / price / step as f64).round();
|
0
|
||||||
let requested = ((raw_steps.max(1.0) as u32) * step).max(minimum);
|
|
||||||
self.round_buy_quantity(requested, minimum_order_quantity, order_step_size)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decrement_order_quantity(
|
fn decrement_order_quantity(
|
||||||
@@ -4364,13 +4372,15 @@ where
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let quote_quantity_limited = self.quote_quantity_limited(matching_type);
|
||||||
let lot = round_lot.max(1);
|
let lot = round_lot.max(1);
|
||||||
let eligible_quotes: Vec<&IntradayExecutionQuote> = quotes
|
let eligible_quotes: Vec<&IntradayExecutionQuote> = quotes
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|quote| {
|
.filter(|quote| {
|
||||||
!start_cursor.is_some_and(|cursor| quote.timestamp < cursor)
|
!start_cursor.is_some_and(|cursor| quote.timestamp < cursor)
|
||||||
&& !end_cursor.is_some_and(|cursor| quote.timestamp > cursor)
|
&& !end_cursor.is_some_and(|cursor| quote.timestamp > cursor)
|
||||||
&& self.quote_has_executable_liquidity(quote, side, matching_type)
|
&& (!quote_quantity_limited
|
||||||
|
|| self.quote_has_executable_liquidity(quote, side, matching_type))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let mut filled_qty = 0_u32;
|
let mut filled_qty = 0_u32;
|
||||||
@@ -4398,21 +4408,26 @@ where
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let quote_price = self.execution_price_with_limit_slippage(quote_price, limit_price);
|
let quote_price = self.execution_price_with_limit_slippage(quote_price, limit_price);
|
||||||
let top_level_liquidity = match side {
|
|
||||||
OrderSide::Buy => quote.ask1_volume,
|
|
||||||
OrderSide::Sell => quote.bid1_volume,
|
|
||||||
};
|
|
||||||
let available_qty = top_level_liquidity
|
|
||||||
.saturating_mul(lot as u64)
|
|
||||||
.min(u32::MAX as u64) as u32;
|
|
||||||
if available_qty == 0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let remaining_qty = requested_qty.saturating_sub(filled_qty);
|
let remaining_qty = requested_qty.saturating_sub(filled_qty);
|
||||||
if remaining_qty == 0 {
|
if remaining_qty == 0 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
let available_qty = if quote_quantity_limited {
|
||||||
|
let top_level_liquidity = match side {
|
||||||
|
OrderSide::Buy => quote.ask1_volume,
|
||||||
|
OrderSide::Sell => quote.bid1_volume,
|
||||||
|
};
|
||||||
|
top_level_liquidity
|
||||||
|
.saturating_mul(lot as u64)
|
||||||
|
.min(u32::MAX as u64) as u32
|
||||||
|
} else {
|
||||||
|
remaining_qty
|
||||||
|
};
|
||||||
|
if available_qty == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let mut take_qty = if matching_type == MatchingType::Twap {
|
let mut take_qty = if matching_type == MatchingType::Twap {
|
||||||
let remaining_quotes = (eligible_quotes.len() - quote_index) as u32;
|
let remaining_quotes = (eligible_quotes.len() - quote_index) as u32;
|
||||||
let scheduled_qty =
|
let scheduled_qty =
|
||||||
@@ -4514,6 +4529,10 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn quote_quantity_limited(&self, matching_type: MatchingType) -> bool {
|
||||||
|
self.volume_limit || self.liquidity_limit || matching_type != MatchingType::NextTickLast
|
||||||
|
}
|
||||||
|
|
||||||
fn uses_serial_execution_cursor(&self, reason: &str) -> bool {
|
fn uses_serial_execution_cursor(&self, reason: &str) -> bool {
|
||||||
let _ = reason;
|
let _ = reason;
|
||||||
false
|
false
|
||||||
@@ -4579,3 +4598,35 @@ fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str {
|
|||||||
"rebalance_sell"
|
"rebalance_sell"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{BrokerSimulator, MatchingType};
|
||||||
|
use crate::cost::ChinaAShareCostModel;
|
||||||
|
use crate::rules::ChinaEquityRuleHooks;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_tick_last_without_volume_or_liquidity_limit_does_not_cap_quote_quantity() {
|
||||||
|
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||||
|
.with_volume_limit(false)
|
||||||
|
.with_liquidity_limit(false);
|
||||||
|
|
||||||
|
assert!(!broker.quote_quantity_limited(MatchingType::NextTickLast));
|
||||||
|
assert!(broker.quote_quantity_limited(MatchingType::CounterpartyOffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_tick_last_keeps_quote_quantity_cap_when_limits_enabled() {
|
||||||
|
let volume_limited =
|
||||||
|
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||||
|
.with_volume_limit(true)
|
||||||
|
.with_liquidity_limit(false);
|
||||||
|
let liquidity_limited =
|
||||||
|
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||||
|
.with_volume_limit(false)
|
||||||
|
.with_liquidity_limit(true);
|
||||||
|
|
||||||
|
assert!(volume_limited.quote_quantity_limited(MatchingType::NextTickLast));
|
||||||
|
assert!(liquidity_limited.quote_quantity_limited(MatchingType::NextTickLast));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ pub struct ChinaAShareCostModel {
|
|||||||
impl Default for ChinaAShareCostModel {
|
impl Default for ChinaAShareCostModel {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
commission_rate: 0.0003,
|
commission_rate: 0.0008,
|
||||||
stamp_tax_rate_before_change: 0.001,
|
stamp_tax_rate_before_change: 0.001,
|
||||||
stamp_tax_rate_after_change: 0.0005,
|
stamp_tax_rate_after_change: 0.0005,
|
||||||
minimum_commission: 5.0,
|
minimum_commission: 5.0,
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ pub struct PlatformExprStrategyConfig {
|
|||||||
pub stock_short_ma_days: usize,
|
pub stock_short_ma_days: usize,
|
||||||
pub stock_mid_ma_days: usize,
|
pub stock_mid_ma_days: usize,
|
||||||
pub stock_long_ma_days: usize,
|
pub stock_long_ma_days: usize,
|
||||||
pub skip_month_day_ranges: Vec<(u32, u32, u32)>,
|
pub skip_month_day_ranges: Vec<(Option<u32>, u32, u32, u32)>,
|
||||||
pub rebalance_schedule: Option<PlatformRebalanceSchedule>,
|
pub rebalance_schedule: Option<PlatformRebalanceSchedule>,
|
||||||
pub rotation_enabled: bool,
|
pub rotation_enabled: bool,
|
||||||
pub daily_top_up_enabled: bool,
|
pub daily_top_up_enabled: bool,
|
||||||
@@ -263,11 +263,17 @@ fn band_low(index_close) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn in_skip_window(&self, date: NaiveDate) -> bool {
|
fn in_skip_window(&self, date: NaiveDate) -> bool {
|
||||||
|
let year = date.year() as u32;
|
||||||
let month = date.month();
|
let month = date.month();
|
||||||
let day = date.day();
|
let day = date.day();
|
||||||
self.skip_month_day_ranges
|
self.skip_month_day_ranges
|
||||||
.iter()
|
.iter()
|
||||||
.any(|(m, start_day, end_day)| month == *m && day >= *start_day && day <= *end_day)
|
.any(|(window_year, m, start_day, end_day)| {
|
||||||
|
window_year.map(|value| value == year).unwrap_or(true)
|
||||||
|
&& month == *m
|
||||||
|
&& day >= *start_day
|
||||||
|
&& day <= *end_day
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,13 +822,18 @@ impl PlatformExprStrategy {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
let minimum = minimum_order_quantity.max(1);
|
let minimum = minimum_order_quantity.max(1);
|
||||||
let step = order_step_size.max(1);
|
let raw_quantity = (value_budget / price).floor() as u32;
|
||||||
if price * minimum as f64 > value_budget + 1e-6 {
|
let mut quantity =
|
||||||
return 0;
|
self.round_lot_quantity(raw_quantity, minimum_order_quantity, order_step_size);
|
||||||
|
while quantity >= minimum {
|
||||||
|
let gross_amount = price * quantity as f64;
|
||||||
|
if gross_amount + self.buy_commission(gross_amount) <= value_budget + 1e-6 {
|
||||||
|
return quantity;
|
||||||
|
}
|
||||||
|
quantity =
|
||||||
|
self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size);
|
||||||
}
|
}
|
||||||
let raw_steps = (value_budget / price / step as f64).round();
|
0
|
||||||
let requested = ((raw_steps.max(1.0) as u32) * step).max(minimum);
|
|
||||||
self.round_lot_quantity(requested, minimum_order_quantity, order_step_size)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decrement_order_quantity(
|
fn decrement_order_quantity(
|
||||||
@@ -5087,6 +5098,16 @@ mod tests {
|
|||||||
assert!(!rewritten.contains('?'));
|
assert!(!rewritten.contains('?'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn platform_order_value_quantity_includes_buy_commission_budget() {
|
||||||
|
let strategy = PlatformExprStrategy::new(PlatformExprStrategyConfig::microcap_rotation());
|
||||||
|
|
||||||
|
assert_eq!(strategy.value_buy_quantity(4_000.0, 20.38, 100, 100), 100);
|
||||||
|
assert_eq!(strategy.value_buy_quantity(4_000.0, 15.20, 100, 100), 200);
|
||||||
|
assert_eq!(strategy.value_buy_quantity(4_000.0, 28.85, 100, 100), 100);
|
||||||
|
assert_eq!(strategy.value_buy_quantity(4_000.0, 37.40, 100, 100), 100);
|
||||||
|
}
|
||||||
|
|
||||||
fn sample_calendar() -> TradingCalendar {
|
fn sample_calendar() -> TradingCalendar {
|
||||||
TradingCalendar::new(vec![
|
TradingCalendar::new(vec![
|
||||||
d(2025, 1, 30),
|
d(2025, 1, 30),
|
||||||
|
|||||||
@@ -156,6 +156,8 @@ pub struct IndexThrottleConfig {
|
|||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SkipWindowConfig {
|
pub struct SkipWindowConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub year: Option<u32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub month: Option<u32>,
|
pub month: Option<u32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -391,7 +393,14 @@ pub fn platform_expr_config_from_spec(
|
|||||||
cfg.skip_month_day_ranges = engine
|
cfg.skip_month_day_ranges = engine
|
||||||
.skip_windows
|
.skip_windows
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|window| Some((window.month?, window.start_day?, window.end_day?)))
|
.filter_map(|window| {
|
||||||
|
Some((
|
||||||
|
window.year,
|
||||||
|
window.month?,
|
||||||
|
window.start_day?,
|
||||||
|
window.end_day?,
|
||||||
|
))
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
}
|
}
|
||||||
if let Some(spec_signal_symbol) = engine
|
if let Some(spec_signal_symbol) = engine
|
||||||
|
|||||||
@@ -1104,7 +1104,7 @@ pub struct CnSmallCapRotationConfig {
|
|||||||
pub take_profit_pct: f64,
|
pub take_profit_pct: f64,
|
||||||
pub signal_symbol: Option<String>,
|
pub signal_symbol: Option<String>,
|
||||||
pub skip_months: Vec<u32>,
|
pub skip_months: Vec<u32>,
|
||||||
pub skip_month_day_ranges: Vec<(u32, u32, u32)>,
|
pub skip_month_day_ranges: Vec<(Option<u32>, u32, u32, u32)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CnSmallCapRotationConfig {
|
impl CnSmallCapRotationConfig {
|
||||||
@@ -1159,23 +1159,29 @@ impl CnSmallCapRotationConfig {
|
|||||||
signal_symbol: Some("000852.SH".to_string()),
|
signal_symbol: Some("000852.SH".to_string()),
|
||||||
skip_months: vec![],
|
skip_months: vec![],
|
||||||
skip_month_day_ranges: vec![
|
skip_month_day_ranges: vec![
|
||||||
(1, 15, 30),
|
(None, 1, 15, 30),
|
||||||
(4, 15, 29),
|
(None, 4, 15, 29),
|
||||||
(8, 15, 31),
|
(None, 8, 15, 31),
|
||||||
(10, 20, 30),
|
(None, 10, 20, 30),
|
||||||
(12, 20, 30),
|
(None, 12, 20, 30),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn in_skip_window(&self, date: NaiveDate) -> bool {
|
fn in_skip_window(&self, date: NaiveDate) -> bool {
|
||||||
|
let year = date.year() as u32;
|
||||||
let month = date.month();
|
let month = date.month();
|
||||||
let day = date.day();
|
let day = date.day();
|
||||||
self.skip_months.contains(&month)
|
self.skip_months.contains(&month)
|
||||||
|| self
|
|| self
|
||||||
.skip_month_day_ranges
|
.skip_month_day_ranges
|
||||||
.iter()
|
.iter()
|
||||||
.any(|(m, start_day, end_day)| month == *m && day >= *start_day && day <= *end_day)
|
.any(|(window_year, m, start_day, end_day)| {
|
||||||
|
window_year.map(|value| value == year).unwrap_or(true)
|
||||||
|
&& month == *m
|
||||||
|
&& day >= *start_day
|
||||||
|
&& day <= *end_day
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1533,7 +1539,7 @@ pub struct OmniMicroCapConfig {
|
|||||||
pub trade_rate: f64,
|
pub trade_rate: f64,
|
||||||
pub stop_loss_ratio: f64,
|
pub stop_loss_ratio: f64,
|
||||||
pub take_profit_ratio: f64,
|
pub take_profit_ratio: f64,
|
||||||
pub skip_month_day_ranges: Vec<(u32, u32, u32)>,
|
pub skip_month_day_ranges: Vec<(Option<u32>, u32, u32, u32)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OmniMicroCapConfig {
|
impl OmniMicroCapConfig {
|
||||||
@@ -1592,11 +1598,17 @@ impl OmniMicroCapConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn in_skip_window(&self, date: NaiveDate) -> bool {
|
fn in_skip_window(&self, date: NaiveDate) -> bool {
|
||||||
|
let year = date.year() as u32;
|
||||||
let month = date.month();
|
let month = date.month();
|
||||||
let day = date.day();
|
let day = date.day();
|
||||||
self.skip_month_day_ranges
|
self.skip_month_day_ranges
|
||||||
.iter()
|
.iter()
|
||||||
.any(|(m, start_day, end_day)| month == *m && day >= *start_day && day <= *end_day)
|
.any(|(window_year, m, start_day, end_day)| {
|
||||||
|
window_year.map(|value| value == year).unwrap_or(true)
|
||||||
|
&& month == *m
|
||||||
|
&& day >= *start_day
|
||||||
|
&& day <= *end_day
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ fn china_cost_model_applies_minimum_commission_and_stamp_tax() {
|
|||||||
assert_eq!(buy.stamp_tax, 0.0);
|
assert_eq!(buy.stamp_tax, 0.0);
|
||||||
|
|
||||||
let sell = model.calculate(d(2023, 8, 25), OrderSide::Sell, 100_000.0);
|
let sell = model.calculate(d(2023, 8, 25), OrderSide::Sell, 100_000.0);
|
||||||
assert!((sell.commission - 30.0).abs() < 1e-9);
|
assert!((sell.commission - 80.0).abs() < 1e-9);
|
||||||
assert!((sell.stamp_tax - 100.0).abs() < 1e-9);
|
assert!((sell.stamp_tax - 100.0).abs() < 1e-9);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ fn china_cost_model_tracks_minimum_commission_per_order_id() {
|
|||||||
|
|
||||||
assert!((first.commission - 5.0).abs() < 1e-9);
|
assert!((first.commission - 5.0).abs() < 1e-9);
|
||||||
assert!(second.commission.abs() < 1e-9);
|
assert!(second.commission.abs() < 1e-9);
|
||||||
assert!((third.commission - 1.6).abs() < 1e-9);
|
assert!((third.commission - 12.6).abs() < 1e-9);
|
||||||
assert!((another_order.commission - 5.0).abs() < 1e-9);
|
assert!((another_order.commission - 5.0).abs() < 1e-9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user