From 3925c0fa38cab062def6a4c10b606f1123e661ab Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 22 Apr 2026 23:19:15 -0700 Subject: [PATCH] Align projected stock order sizing semantics --- .../fidc-core/src/platform_expr_strategy.rs | 319 ++++++++++++++---- crates/fidc-core/src/strategy.rs | 159 +++++++-- 2 files changed, 379 insertions(+), 99 deletions(-) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index c9f096b..960d31a 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -214,9 +214,16 @@ impl PlatformExprStrategy { engine.register_fn("pow", |lhs: f64, rhs: f64| lhs.powf(rhs)); engine.register_fn("log", |value: f64| value.ln()); engine.register_fn("exp", |value: f64| value.exp()); - engine.register_fn("clamp", |value: f64, low: f64, high: f64| value.clamp(low, high)); - engine.register_fn("between", |value: f64, low: f64, high: f64| value >= low && value <= high); - engine.register_fn("nz", |value: f64, fallback: f64| if value.is_finite() { value } else { fallback }); + engine.register_fn("clamp", |value: f64, low: f64, high: f64| { + value.clamp(low, high) + }); + engine.register_fn("between", |value: f64, low: f64, high: f64| { + value >= low && value <= high + }); + engine.register_fn( + "nz", + |value: f64, fallback: f64| if value.is_finite() { value } else { fallback }, + ); engine.register_fn("safe_div", |lhs: f64, rhs: f64, fallback: f64| { if rhs.abs() <= f64::EPSILON { fallback @@ -224,12 +231,25 @@ impl PlatformExprStrategy { lhs / rhs } }); - engine.register_fn("iff", |condition: bool, when_true: Dynamic, when_false: Dynamic| { - if condition { when_true } else { when_false } + engine.register_fn( + "iff", + |condition: bool, when_true: Dynamic, when_false: Dynamic| { + if condition { + when_true + } else { + when_false + } + }, + ); + engine.register_fn("contains", |value: &str, needle: &str| { + value.contains(needle) + }); + engine.register_fn("starts_with", |value: &str, prefix: &str| { + value.starts_with(prefix) + }); + engine.register_fn("ends_with", |value: &str, suffix: &str| { + value.ends_with(suffix) }); - engine.register_fn("contains", |value: &str, needle: &str| value.contains(needle)); - engine.register_fn("starts_with", |value: &str, prefix: &str| value.starts_with(prefix)); - engine.register_fn("ends_with", |value: &str, suffix: &str| value.ends_with(suffix)); engine.register_fn("lower", |value: &str| value.to_lowercase()); engine.register_fn("upper", |value: &str| value.to_uppercase()); engine.register_fn("trim", |value: &str| value.trim().to_string()); @@ -352,12 +372,42 @@ impl PlatformExprStrategy { fn sell_cost(&self, date: NaiveDate, gross_amount: f64) -> f64 { let model = ChinaAShareCostModel::default(); - model.commission_for(gross_amount) + model.stamp_tax_for(date, OrderSide::Sell, gross_amount) + model.commission_for(gross_amount) + + model.stamp_tax_for(date, OrderSide::Sell, gross_amount) } - fn round_lot_quantity(&self, quantity: u32, round_lot: u32) -> u32 { - let lot = round_lot.max(1); - (quantity / lot) * lot + fn round_lot_quantity( + &self, + quantity: u32, + minimum_order_quantity: u32, + order_step_size: u32, + ) -> u32 { + let step = order_step_size.max(1); + let normalized = (quantity / step) * step; + if normalized < minimum_order_quantity.max(1) { + 0 + } else { + normalized + } + } + + fn decrement_order_quantity( + &self, + quantity: u32, + minimum_order_quantity: u32, + order_step_size: u32, + ) -> u32 { + let minimum = minimum_order_quantity.max(1); + if quantity <= minimum { + 0 + } else { + let next = quantity.saturating_sub(order_step_size.max(1)); + if next < minimum { + 0 + } else { + next + } + } } fn projected_round_lot(&self, ctx: &StrategyContext<'_>, symbol: &str) -> u32 { @@ -368,11 +418,23 @@ impl PlatformExprStrategy { .max(1) } - fn projected_execution_price( - &self, - market: &DailyMarketSnapshot, - side: OrderSide, - ) -> f64 { + fn projected_minimum_order_quantity(&self, ctx: &StrategyContext<'_>, symbol: &str) -> u32 { + ctx.data + .instrument(symbol) + .map(|instrument| instrument.minimum_order_quantity()) + .unwrap_or(100) + .max(1) + } + + fn projected_order_step_size(&self, ctx: &StrategyContext<'_>, symbol: &str) -> u32 { + ctx.data + .instrument(symbol) + .map(|instrument| instrument.order_step_size()) + .unwrap_or(100) + .max(1) + } + + fn projected_execution_price(&self, market: &DailyMarketSnapshot, side: OrderSide) -> f64 { match side { OrderSide::Buy => market.buy_price(PriceField::Last), OrderSide::Sell => market.sell_price(PriceField::Last), @@ -396,6 +458,9 @@ impl PlatformExprStrategy { side: OrderSide, requested_qty: u32, round_lot: u32, + minimum_order_quantity: u32, + order_step_size: u32, + allow_odd_lot_sell: bool, cash_limit: Option, gross_limit: Option, execution_state: &ProjectedExecutionState, @@ -410,19 +475,31 @@ impl PlatformExprStrategy { let quantity = match side { OrderSide::Buy => { let cash = cash_limit.unwrap_or(f64::INFINITY); - let lot = round_lot.max(1); - let mut take_qty = self.round_lot_quantity(requested_qty, lot); + let mut take_qty = self.round_lot_quantity( + requested_qty, + minimum_order_quantity, + order_step_size, + ); while take_qty > 0 { let candidate_gross = execution_price * take_qty as f64; if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { - take_qty = take_qty.saturating_sub(lot); + take_qty = self.decrement_order_quantity( + take_qty, + minimum_order_quantity, + order_step_size, + ); continue; } - let candidate_cash = candidate_gross + self.buy_commission(candidate_gross); + let candidate_cash = + candidate_gross + self.buy_commission(candidate_gross); if candidate_cash <= cash + 1e-6 { break; } - take_qty = take_qty.saturating_sub(lot); + take_qty = self.decrement_order_quantity( + take_qty, + minimum_order_quantity, + order_step_size, + ); } take_qty } @@ -439,7 +516,6 @@ impl PlatformExprStrategy { } } - let lot = round_lot.max(1); let start_cursor = self.projected_execution_start_cursor(date, symbol, execution_state); let quotes = ctx.data.execution_quotes_on(date, symbol); let mut filled_qty = 0_u32; @@ -461,7 +537,7 @@ impl PlatformExprStrategy { OrderSide::Buy => quote.ask1_volume, OrderSide::Sell => quote.bid1_volume, } - .saturating_mul(lot as u64) + .saturating_mul(round_lot.max(1) as u64) .min(u32::MAX as u64) as u32; if available_qty == 0 { continue; @@ -471,7 +547,11 @@ impl PlatformExprStrategy { if remaining_qty == 0 { break; } - let mut take_qty = self.round_lot_quantity(remaining_qty.min(available_qty), lot); + let mut take_qty = remaining_qty.min(available_qty); + if !(side == OrderSide::Sell && allow_odd_lot_sell && take_qty == remaining_qty) { + take_qty = + self.round_lot_quantity(take_qty, minimum_order_quantity, order_step_size); + } if take_qty == 0 { continue; } @@ -480,13 +560,21 @@ impl PlatformExprStrategy { while take_qty > 0 { let candidate_gross = gross_amount + quote_price * take_qty as f64; if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { - take_qty = take_qty.saturating_sub(lot); + take_qty = self.decrement_order_quantity( + take_qty, + minimum_order_quantity, + order_step_size, + ); continue; } if candidate_gross + self.buy_commission(candidate_gross) <= cash + 1e-6 { break; } - take_qty = take_qty.saturating_sub(lot); + take_qty = self.decrement_order_quantity( + take_qty, + minimum_order_quantity, + order_step_size, + ); } if take_qty == 0 { break; @@ -525,6 +613,8 @@ impl PlatformExprStrategy { } let market = ctx.data.market(date, symbol)?; let round_lot = self.projected_round_lot(ctx, symbol); + let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol); + let order_step_size = self.projected_order_step_size(ctx, symbol); let fill = self .projected_select_execution_fill( ctx, @@ -533,6 +623,9 @@ impl PlatformExprStrategy { OrderSide::Sell, quantity, round_lot, + minimum_order_quantity, + order_step_size, + true, None, None, execution_state, @@ -574,6 +667,8 @@ impl PlatformExprStrategy { return 0; } let round_lot = self.projected_round_lot(ctx, symbol); + let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol); + let order_step_size = self.projected_order_step_size(ctx, symbol); let market = match ctx.data.market(date, symbol) { Some(market) => market, None => return 0, @@ -584,16 +679,20 @@ impl PlatformExprStrategy { } let snapshot_requested_qty = self.round_lot_quantity( ((projected.cash().min(order_value)) / sizing_price).floor() as u32, - round_lot, + minimum_order_quantity, + order_step_size, ); let execution_price = self.projected_execution_price(market, OrderSide::Buy); let mut quantity = snapshot_requested_qty; while quantity > 0 { let gross_amount = execution_price * quantity as f64; - if gross_amount <= order_value + 400.0 && gross_amount + self.buy_commission(gross_amount) <= projected.cash() + 1e-6 { + if gross_amount <= order_value + 400.0 + && gross_amount + self.buy_commission(gross_amount) <= projected.cash() + 1e-6 + { break; } - quantity = quantity.saturating_sub(round_lot); + quantity = + self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size); } if quantity == 0 { return 0; @@ -606,6 +705,9 @@ impl PlatformExprStrategy { OrderSide::Buy, quantity, round_lot, + minimum_order_quantity, + order_step_size, + false, Some(projected.cash()), Some(order_value + 400.0), execution_state, @@ -622,7 +724,9 @@ impl PlatformExprStrategy { return 0; } projected.apply_cash_delta(-cash_out); - projected.position_mut(symbol).buy(date, fill.quantity, fill.price); + projected + .position_mut(symbol) + .buy(date, fill.quantity, fill.price); *execution_state .intraday_turnover .entry(symbol.to_string()) @@ -716,9 +820,18 @@ impl PlatformExprStrategy { .market_decision_close_moving_average(date, &self.config.signal_symbol, 30) .unwrap_or(benchmark_ma20); let cash = ctx.portfolio.cash(); - let market_value = ctx.portfolio.positions().values().map(|position| position.market_value()).sum::(); + let market_value = ctx + .portfolio + .positions() + .values() + .map(|position| position.market_value()) + .sum::(); let total_equity = cash + market_value; - let current_exposure = if total_equity > 0.0 { market_value / total_equity } else { 0.0 }; + let current_exposure = if total_equity > 0.0 { + market_value / total_equity + } else { + 0.0 + }; let next_day = date + Duration::days(1); let is_month_end = next_day.month() != date.month(); @@ -850,7 +963,9 @@ impl PlatformExprStrategy { upper_limit: market.upper_limit, lower_limit: market.lower_limit, price_tick: market.price_tick, - round_lot: instrument.map(|item| item.effective_round_lot()).unwrap_or(100) as i64, + round_lot: instrument + .map(|item| item.effective_round_lot()) + .unwrap_or(100) as i64, paused: market.paused || candidate.is_paused, is_st: candidate.is_st || self.special_name(ctx, symbol), is_kcb: candidate.is_kcb, @@ -927,12 +1042,21 @@ impl PlatformExprStrategy { day_factors.insert("benchmark_ma10".into(), Dynamic::from(day.benchmark_ma10)); day_factors.insert("benchmark_ma20".into(), Dynamic::from(day.benchmark_ma20)); day_factors.insert("benchmark_ma30".into(), Dynamic::from(day.benchmark_ma30)); - day_factors.insert("benchmark_ma_short".into(), Dynamic::from(day.benchmark_ma_short)); - day_factors.insert("benchmark_ma_long".into(), Dynamic::from(day.benchmark_ma_long)); + day_factors.insert( + "benchmark_ma_short".into(), + Dynamic::from(day.benchmark_ma_short), + ); + day_factors.insert( + "benchmark_ma_long".into(), + Dynamic::from(day.benchmark_ma_long), + ); day_factors.insert("cash".into(), Dynamic::from(day.cash)); day_factors.insert("market_value".into(), Dynamic::from(day.market_value)); day_factors.insert("total_equity".into(), Dynamic::from(day.total_equity)); - day_factors.insert("current_exposure".into(), Dynamic::from(day.current_exposure)); + day_factors.insert( + "current_exposure".into(), + Dynamic::from(day.current_exposure), + ); day_factors.insert("position_count".into(), Dynamic::from(day.position_count)); day_factors.insert("max_positions".into(), Dynamic::from(day.max_positions)); day_factors.insert("refresh_rate".into(), Dynamic::from(day.refresh_rate)); @@ -947,8 +1071,10 @@ impl PlatformExprStrategy { day_factors.insert("is_month_end".into(), Dynamic::from(day.is_month_end)); scope.push("day_factors", day_factors); if let Some(stock) = stock { - let at_upper_limit = Self::price_is_at_limit(stock.last, stock.upper_limit, stock.price_tick); - let at_lower_limit = Self::price_is_at_limit(stock.last, stock.lower_limit, stock.price_tick); + let at_upper_limit = + Self::price_is_at_limit(stock.last, stock.upper_limit, stock.price_tick); + let at_lower_limit = + Self::price_is_at_limit(stock.last, stock.lower_limit, stock.price_tick); scope.push("symbol", stock.symbol.clone()); scope.push("market_cap", stock.market_cap); scope.push("free_float_cap", stock.free_float_cap); @@ -1038,8 +1164,14 @@ impl PlatformExprStrategy { "touched_lower_limit".into(), Dynamic::from(stock.touched_lower_limit), ); - factors.insert("hit_upper_limit".into(), Dynamic::from(stock.touched_upper_limit)); - factors.insert("hit_lower_limit".into(), Dynamic::from(stock.touched_lower_limit)); + factors.insert( + "hit_upper_limit".into(), + Dynamic::from(stock.touched_upper_limit), + ); + factors.insert( + "hit_lower_limit".into(), + Dynamic::from(stock.touched_lower_limit), + ); factors.insert("listed_days".into(), Dynamic::from(stock.listed_days)); factors.insert("at_upper_limit".into(), Dynamic::from(at_upper_limit)); factors.insert("at_lower_limit".into(), Dynamic::from(at_lower_limit)); @@ -1051,10 +1183,22 @@ impl PlatformExprStrategy { factors.insert("ma10".into(), Dynamic::from(stock.stock_ma10)); factors.insert("ma20".into(), Dynamic::from(stock.stock_ma20)); factors.insert("ma30".into(), Dynamic::from(stock.stock_ma30)); - factors.insert("stock_volume_ma5".into(), Dynamic::from(stock.stock_volume_ma5)); - factors.insert("stock_volume_ma10".into(), Dynamic::from(stock.stock_volume_ma10)); - factors.insert("stock_volume_ma20".into(), Dynamic::from(stock.stock_volume_ma20)); - factors.insert("stock_volume_ma60".into(), Dynamic::from(stock.stock_volume_ma60)); + factors.insert( + "stock_volume_ma5".into(), + Dynamic::from(stock.stock_volume_ma5), + ); + factors.insert( + "stock_volume_ma10".into(), + Dynamic::from(stock.stock_volume_ma10), + ); + factors.insert( + "stock_volume_ma20".into(), + Dynamic::from(stock.stock_volume_ma20), + ); + factors.insert( + "stock_volume_ma60".into(), + Dynamic::from(stock.stock_volume_ma60), + ); factors.insert("volume_ma5".into(), Dynamic::from(stock.stock_volume_ma5)); factors.insert("volume_ma10".into(), Dynamic::from(stock.stock_volume_ma10)); factors.insert("volume_ma20".into(), Dynamic::from(stock.stock_volume_ma20)); @@ -1120,7 +1264,8 @@ impl PlatformExprStrategy { item.extra_factors .keys() .filter(|key| { - Self::is_expression_identifier(key) && !reserved_names.contains(key.as_str()) + Self::is_expression_identifier(key) + && !reserved_names.contains(key.as_str()) }) .map(|key| format!("let {key} = factors[\"{key}\"];")) .collect::>() @@ -1138,7 +1283,9 @@ impl PlatformExprStrategy { let script = script_parts.join("\n"); self.engine .eval_with_scope::(&mut scope, &script) - .map_err(|error| BacktestError::Execution(format!("platform expr eval failed: {}", error))) + .map_err(|error| { + BacktestError::Execution(format!("platform expr eval failed: {}", error)) + }) } fn normalize_expr(expr: &str) -> String { @@ -1265,23 +1412,32 @@ impl PlatformExprStrategy { let value = match field { "benchmark_open" => ctx.data.benchmark_open_moving_average(day.date, lookback), "benchmark_close" => ctx.data.benchmark_moving_average(day.date, lookback), - "signal_open" => ctx - .data - .market_open_moving_average(day.date, &self.config.signal_symbol, lookback), - "signal_close" => ctx - .data - .market_decision_close_moving_average(day.date, &self.config.signal_symbol, lookback), - "signal_volume" => ctx - .data - .market_decision_volume_moving_average(day.date, &self.config.signal_symbol, lookback), + "signal_open" => { + ctx.data + .market_open_moving_average(day.date, &self.config.signal_symbol, lookback) + } + "signal_close" => ctx.data.market_decision_close_moving_average( + day.date, + &self.config.signal_symbol, + lookback, + ), + "signal_volume" => ctx.data.market_decision_volume_moving_average( + day.date, + &self.config.signal_symbol, + lookback, + ), other => { let stock = stock.ok_or_else(|| { BacktestError::Execution(format!( "rolling_mean(\"{other}\", {lookback}) requires stock context" )) })?; - ctx.data - .market_decision_numeric_moving_average(day.date, &stock.symbol, other, lookback) + ctx.data.market_decision_numeric_moving_average( + day.date, + &stock.symbol, + other, + lookback, + ) } }; value.ok_or_else(|| { @@ -1375,9 +1531,7 @@ impl PlatformExprStrategy { } fn quote_rhai_string(value: &str) -> String { - let escaped = value - .replace('\\', "\\\\") - .replace('"', "\\\""); + let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); format!("\"{escaped}\"") } @@ -1486,7 +1640,10 @@ impl PlatformExprStrategy { let condition = Self::rewrite_ternary(expr[..question_idx].trim()); let when_true = Self::rewrite_ternary(expr[question_idx + 1..colon_idx].trim()); let when_false = Self::rewrite_ternary(expr[colon_idx + 1..].trim()); - format!("if {} {{ {} }} else {{ {} }}", condition, when_true, when_false) + format!( + "if {} {{ {} }} else {{ {} }}", + condition, when_true, when_false + ) } fn find_top_level_ternary(expr: &str) -> Option<(usize, usize)> { @@ -1861,7 +2018,9 @@ impl PlatformExprStrategy { let Some(position) = ctx.portfolio.position(symbol) else { return Ok((false, false)); }; - if self.config.stop_loss_expr.trim().is_empty() && self.config.take_profit_expr.trim().is_empty() { + if self.config.stop_loss_expr.trim().is_empty() + && self.config.take_profit_expr.trim().is_empty() + { return Ok((false, false)); } if position.quantity == 0 || position.average_cost <= 0.0 { @@ -1884,7 +2043,13 @@ impl PlatformExprStrategy { let stop_hit = if self.config.stop_loss_expr.trim().is_empty() { false } else { - let stop_result = self.eval_dynamic(ctx, &self.config.stop_loss_expr, day, Some(&stock), Some(&position_state))?; + let stop_result = self.eval_dynamic( + ctx, + &self.config.stop_loss_expr, + day, + Some(&stock), + Some(&position_state), + )?; if let Some(boolean) = stop_result.clone().try_cast::() { boolean } else if let Some(multiplier) = stop_result.clone().try_cast::() { @@ -1898,14 +2063,24 @@ impl PlatformExprStrategy { let profit_hit = if self.config.take_profit_expr.trim().is_empty() { false } else { - let take_result = self.eval_dynamic(ctx, &self.config.take_profit_expr, day, Some(&stock), Some(&position_state))?; + let take_result = self.eval_dynamic( + ctx, + &self.config.take_profit_expr, + day, + Some(&stock), + Some(&position_state), + )?; if let Some(boolean) = take_result.clone().try_cast::() { boolean } else if let Some(multiplier) = take_result.clone().try_cast::() { - !ctx.data.require_market(date, symbol)?.is_at_upper_limit_price(current_price) + !ctx.data + .require_market(date, symbol)? + .is_at_upper_limit_price(current_price) && current_price / position.average_cost > multiplier } else if let Some(multiplier) = take_result.try_cast::() { - !ctx.data.require_market(date, symbol)?.is_at_upper_limit_price(current_price) + !ctx.data + .require_market(date, symbol)? + .is_at_upper_limit_price(current_price) && current_price / position.average_cost > multiplier as f64 } else { false @@ -1998,7 +2173,10 @@ impl Strategy for PlatformExprStrategy { continue; } let stock = self.stock_state(ctx, date, symbol)?; - if self.buy_rejection_reason(ctx, date, symbol, &stock)?.is_some() { + if self + .buy_rejection_reason(ctx, date, symbol, &stock)? + .is_some() + { continue; } if !self.stock_passes_expr(ctx, &day, &stock)? { @@ -2067,7 +2245,10 @@ impl Strategy for PlatformExprStrategy { continue; } let stock = self.stock_state(ctx, date, symbol)?; - if self.buy_rejection_reason(ctx, date, symbol, &stock)?.is_some() { + if self + .buy_rejection_reason(ctx, date, symbol, &stock)? + .is_some() + { continue; } if !self.stock_passes_expr(ctx, &day, &stock)? { diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index a93fcb1..d381439 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -175,7 +175,11 @@ impl CnSmallCapRotationStrategy { let (sum, count) = window.fold((0.0, 0usize), |(sum, count), value| { (sum + value, count + 1) }); - if count == 0 { 0.0 } else { sum / count as f64 } + if count == 0 { + 0.0 + } else { + sum / count as f64 + } } fn gross_exposure(&self, closes: &[f64]) -> f64 { @@ -325,7 +329,7 @@ impl Strategy for CnSmallCapRotationStrategy { order_intents: Vec::new(), notes: vec![format!("warmup: {}", message)], diagnostics: vec![ - "insufficient history; skip trading on warmup dates".to_string(), + "insufficient history; skip trading on warmup dates".to_string() ], }); } @@ -591,12 +595,42 @@ impl JqMicroCapStrategy { fn sell_cost(&self, date: NaiveDate, gross_amount: f64) -> f64 { let model = ChinaAShareCostModel::default(); - model.commission_for(gross_amount) + model.stamp_tax_for(date, OrderSide::Sell, gross_amount) + model.commission_for(gross_amount) + + model.stamp_tax_for(date, OrderSide::Sell, gross_amount) } - fn round_lot_quantity(&self, quantity: u32, round_lot: u32) -> u32 { - let lot = round_lot.max(1); - (quantity / lot) * lot + fn round_lot_quantity( + &self, + quantity: u32, + minimum_order_quantity: u32, + order_step_size: u32, + ) -> u32 { + let step = order_step_size.max(1); + let normalized = (quantity / step) * step; + if normalized < minimum_order_quantity.max(1) { + 0 + } else { + normalized + } + } + + fn decrement_order_quantity( + &self, + quantity: u32, + minimum_order_quantity: u32, + order_step_size: u32, + ) -> u32 { + let minimum = minimum_order_quantity.max(1); + if quantity <= minimum { + 0 + } else { + let next = quantity.saturating_sub(order_step_size.max(1)); + if next < minimum { + 0 + } else { + next + } + } } fn intraday_execution_start_time(&self) -> NaiveTime { @@ -611,17 +645,33 @@ impl JqMicroCapStrategy { .max(1) } + fn projected_minimum_order_quantity(&self, ctx: &StrategyContext<'_>, symbol: &str) -> u32 { + ctx.data + .instrument(symbol) + .map(|instrument| instrument.minimum_order_quantity()) + .unwrap_or(100) + .max(1) + } + + fn projected_order_step_size(&self, ctx: &StrategyContext<'_>, symbol: &str) -> u32 { + ctx.data + .instrument(symbol) + .map(|instrument| instrument.order_step_size()) + .unwrap_or(100) + .max(1) + } + fn projected_buy_quantity(&self, cash: f64, sizing_price: f64, execution_price: f64) -> u32 { if cash <= 0.0 || sizing_price <= 0.0 || execution_price <= 0.0 { return 0; } - let mut quantity = self.round_lot_quantity((cash / sizing_price).floor() as u32, 100); + let mut quantity = self.round_lot_quantity((cash / sizing_price).floor() as u32, 100, 100); while quantity > 0 { let gross_amount = execution_price * quantity as f64; if gross_amount + self.buy_commission(gross_amount) <= cash + 1e-6 { return quantity; } - quantity = quantity.saturating_sub(100); + quantity = self.decrement_order_quantity(quantity, 100, 100); } 0 } @@ -649,6 +699,8 @@ impl JqMicroCapStrategy { return 0; } let round_lot = self.projected_round_lot(ctx, symbol); + let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol); + let order_step_size = self.projected_order_step_size(ctx, symbol); let market = match ctx.data.market(date, symbol) { Some(market) => market, None => return 0, @@ -659,7 +711,8 @@ impl JqMicroCapStrategy { } let snapshot_requested_qty = self.round_lot_quantity( ((projected.cash().min(order_value)) / sizing_price).floor() as u32, - round_lot, + minimum_order_quantity, + order_step_size, ); let projected_execution_price = self.projected_execution_price(market, OrderSide::Buy); let projected_fill = self.projected_select_execution_fill( @@ -669,6 +722,9 @@ impl JqMicroCapStrategy { OrderSide::Buy, u32::MAX, round_lot, + minimum_order_quantity, + order_step_size, + false, Some(projected.cash()), Some(order_value + 400.0), execution_state, @@ -676,12 +732,11 @@ impl JqMicroCapStrategy { let mut quantity = snapshot_requested_qty; while quantity > 0 { let gross_amount = projected_execution_price * quantity as f64; - if gross_amount <= order_value + 400.0 - && gross_amount <= projected.cash() + 1e-6 - { + if gross_amount <= order_value + 400.0 && gross_amount <= projected.cash() + 1e-6 { break; } - quantity = quantity.saturating_sub(round_lot); + quantity = + self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size); } if quantity == 0 { return 0; @@ -695,7 +750,8 @@ impl JqMicroCapStrategy { if gross_amount <= projected.cash() + 1e-6 { break; } - quantity = quantity.saturating_sub(round_lot); + quantity = + self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size); } if quantity == 0 { return 0; @@ -742,6 +798,8 @@ impl JqMicroCapStrategy { } let market = ctx.data.market(date, symbol)?; let round_lot = self.projected_round_lot(ctx, symbol); + let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol); + let order_step_size = self.projected_order_step_size(ctx, symbol); let fill = self .projected_select_execution_fill( ctx, @@ -750,6 +808,9 @@ impl JqMicroCapStrategy { OrderSide::Sell, quantity, round_lot, + minimum_order_quantity, + order_step_size, + true, None, None, execution_state, @@ -788,7 +849,10 @@ impl JqMicroCapStrategy { symbol: &str, side: OrderSide, requested_qty: u32, - round_lot: u32, + _round_lot: u32, + minimum_order_quantity: u32, + order_step_size: u32, + allow_odd_lot_sell: bool, execution_state: &ProjectedExecutionState, ) -> Option { if requested_qty == 0 { @@ -799,7 +863,6 @@ impl JqMicroCapStrategy { return None; } - let lot = round_lot.max(1); let mut max_fill = requested_qty; let top_level_liquidity = match side { OrderSide::Buy => snapshot.liquidity_for_buy(), @@ -809,7 +872,12 @@ impl JqMicroCapStrategy { if top_level_liquidity == 0 { return None; } - max_fill = max_fill.min(self.round_lot_quantity(top_level_liquidity, lot)); + let liquidity_limited = if side == OrderSide::Sell && allow_odd_lot_sell { + top_level_liquidity + } else { + self.round_lot_quantity(top_level_liquidity, minimum_order_quantity, order_step_size) + }; + max_fill = max_fill.min(liquidity_limited); let consumed_turnover = *execution_state.intraday_turnover.get(symbol).unwrap_or(&0); let raw_limit = @@ -817,7 +885,11 @@ impl JqMicroCapStrategy { if raw_limit <= 0 { return None; } - let volume_limited = self.round_lot_quantity(raw_limit as u32, lot); + let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell { + raw_limit as u32 + } else { + self.round_lot_quantity(raw_limit as u32, minimum_order_quantity, order_step_size) + }; if volume_limited == 0 { return None; } @@ -842,6 +914,9 @@ impl JqMicroCapStrategy { side: OrderSide, requested_qty: u32, round_lot: u32, + minimum_order_quantity: u32, + order_step_size: u32, + allow_odd_lot_sell: bool, cash_limit: Option, gross_limit: Option, execution_state: &ProjectedExecutionState, @@ -856,26 +931,39 @@ impl JqMicroCapStrategy { let quantity = match side { OrderSide::Buy => { let cash = cash_limit.unwrap_or(f64::INFINITY); - let mut take_qty = self.round_lot_quantity(requested_qty, round_lot.max(1)); + let mut take_qty = self.round_lot_quantity( + requested_qty, + minimum_order_quantity, + order_step_size, + ); while take_qty > 0 { let candidate_gross = execution_price * take_qty as f64; if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { - take_qty = take_qty.saturating_sub(round_lot.max(1)); + take_qty = self.decrement_order_quantity( + take_qty, + minimum_order_quantity, + order_step_size, + ); continue; } - let candidate_cash = candidate_gross + self.buy_commission(candidate_gross); + let candidate_cash = + candidate_gross + self.buy_commission(candidate_gross); if candidate_cash <= cash + 1e-6 { break; } - take_qty = take_qty.saturating_sub(round_lot.max(1)); + take_qty = self.decrement_order_quantity( + take_qty, + minimum_order_quantity, + order_step_size, + ); } take_qty } OrderSide::Sell => requested_qty, }; if quantity > 0 { - let next_cursor = date.and_time(self.intraday_execution_start_time()) - + Duration::seconds(1); + let next_cursor = + date.and_time(self.intraday_execution_start_time()) + Duration::seconds(1); return Some(ProjectedExecutionFill { price: execution_price, quantity, @@ -885,7 +973,6 @@ impl JqMicroCapStrategy { } } - let lot = round_lot.max(1); let start_cursor = self.projected_execution_start_cursor(date, symbol, execution_state); let quotes = ctx.data.execution_quotes_on(date, symbol); let mut filled_qty = 0_u32; @@ -921,7 +1008,7 @@ impl JqMicroCapStrategy { OrderSide::Buy => quote.ask1_volume, OrderSide::Sell => quote.bid1_volume, } - .saturating_mul(lot as u64) + .saturating_mul(round_lot.max(1) as u64) .min(u32::MAX as u64) as u32; if available_qty == 0 { continue; @@ -931,7 +1018,11 @@ impl JqMicroCapStrategy { if remaining_qty == 0 { break; } - let mut take_qty = self.round_lot_quantity(remaining_qty.min(available_qty), lot); + let mut take_qty = remaining_qty.min(available_qty); + if !(side == OrderSide::Sell && allow_odd_lot_sell && take_qty == remaining_qty) { + take_qty = + self.round_lot_quantity(take_qty, minimum_order_quantity, order_step_size); + } if take_qty == 0 { continue; } @@ -940,13 +1031,21 @@ impl JqMicroCapStrategy { while take_qty > 0 { let candidate_gross = gross_amount + quote_price * take_qty as f64; if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { - take_qty = take_qty.saturating_sub(lot); + take_qty = self.decrement_order_quantity( + take_qty, + minimum_order_quantity, + order_step_size, + ); continue; } if candidate_gross + self.buy_commission(candidate_gross) <= cash + 1e-6 { break; } - take_qty = take_qty.saturating_sub(lot); + take_qty = self.decrement_order_quantity( + take_qty, + minimum_order_quantity, + order_step_size, + ); } if take_qty == 0 { break; @@ -1436,7 +1535,7 @@ impl Strategy for JqMicroCapStrategy { order_intents: Vec::new(), notes: vec![format!("warmup: {}", message)], diagnostics: vec![ - "insufficient history; skip trading on warmup dates".to_string(), + "insufficient history; skip trading on warmup dates".to_string() ], }); }