Align projected stock order sizing semantics

This commit is contained in:
boris
2026-04-22 23:19:15 -07:00
parent d4f4af6221
commit 3925c0fa38
2 changed files with 379 additions and 99 deletions

View File

@@ -214,9 +214,16 @@ impl PlatformExprStrategy {
engine.register_fn("pow", |lhs: f64, rhs: f64| lhs.powf(rhs)); engine.register_fn("pow", |lhs: f64, rhs: f64| lhs.powf(rhs));
engine.register_fn("log", |value: f64| value.ln()); engine.register_fn("log", |value: f64| value.ln());
engine.register_fn("exp", |value: f64| value.exp()); 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("clamp", |value: f64, low: f64, high: f64| {
engine.register_fn("between", |value: f64, low: f64, high: f64| value >= low && value <= high); value.clamp(low, high)
engine.register_fn("nz", |value: f64, fallback: f64| if value.is_finite() { value } else { fallback }); });
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| { engine.register_fn("safe_div", |lhs: f64, rhs: f64, fallback: f64| {
if rhs.abs() <= f64::EPSILON { if rhs.abs() <= f64::EPSILON {
fallback fallback
@@ -224,12 +231,25 @@ impl PlatformExprStrategy {
lhs / rhs lhs / rhs
} }
}); });
engine.register_fn("iff", |condition: bool, when_true: Dynamic, when_false: Dynamic| { engine.register_fn(
if condition { when_true } else { when_false } "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("lower", |value: &str| value.to_lowercase());
engine.register_fn("upper", |value: &str| value.to_uppercase()); engine.register_fn("upper", |value: &str| value.to_uppercase());
engine.register_fn("trim", |value: &str| value.trim().to_string()); 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 { fn sell_cost(&self, date: NaiveDate, gross_amount: f64) -> f64 {
let model = ChinaAShareCostModel::default(); 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 { fn round_lot_quantity(
let lot = round_lot.max(1); &self,
(quantity / lot) * lot 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 { fn projected_round_lot(&self, ctx: &StrategyContext<'_>, symbol: &str) -> u32 {
@@ -368,11 +418,23 @@ impl PlatformExprStrategy {
.max(1) .max(1)
} }
fn projected_execution_price( fn projected_minimum_order_quantity(&self, ctx: &StrategyContext<'_>, symbol: &str) -> u32 {
&self, ctx.data
market: &DailyMarketSnapshot, .instrument(symbol)
side: OrderSide, .map(|instrument| instrument.minimum_order_quantity())
) -> f64 { .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 { match side {
OrderSide::Buy => market.buy_price(PriceField::Last), OrderSide::Buy => market.buy_price(PriceField::Last),
OrderSide::Sell => market.sell_price(PriceField::Last), OrderSide::Sell => market.sell_price(PriceField::Last),
@@ -396,6 +458,9 @@ impl PlatformExprStrategy {
side: OrderSide, side: OrderSide,
requested_qty: u32, requested_qty: u32,
round_lot: u32, round_lot: u32,
minimum_order_quantity: u32,
order_step_size: u32,
allow_odd_lot_sell: bool,
cash_limit: Option<f64>, cash_limit: Option<f64>,
gross_limit: Option<f64>, gross_limit: Option<f64>,
execution_state: &ProjectedExecutionState, execution_state: &ProjectedExecutionState,
@@ -410,19 +475,31 @@ impl PlatformExprStrategy {
let quantity = match side { let quantity = match side {
OrderSide::Buy => { OrderSide::Buy => {
let cash = cash_limit.unwrap_or(f64::INFINITY); let cash = cash_limit.unwrap_or(f64::INFINITY);
let lot = round_lot.max(1); let mut take_qty = self.round_lot_quantity(
let mut take_qty = self.round_lot_quantity(requested_qty, lot); requested_qty,
minimum_order_quantity,
order_step_size,
);
while take_qty > 0 { while take_qty > 0 {
let candidate_gross = execution_price * take_qty as f64; let candidate_gross = execution_price * take_qty as f64;
if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { 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; 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 { if candidate_cash <= cash + 1e-6 {
break; break;
} }
take_qty = take_qty.saturating_sub(lot); take_qty = self.decrement_order_quantity(
take_qty,
minimum_order_quantity,
order_step_size,
);
} }
take_qty 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 start_cursor = self.projected_execution_start_cursor(date, symbol, execution_state);
let quotes = ctx.data.execution_quotes_on(date, symbol); let quotes = ctx.data.execution_quotes_on(date, symbol);
let mut filled_qty = 0_u32; let mut filled_qty = 0_u32;
@@ -461,7 +537,7 @@ impl PlatformExprStrategy {
OrderSide::Buy => quote.ask1_volume, OrderSide::Buy => quote.ask1_volume,
OrderSide::Sell => quote.bid1_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; .min(u32::MAX as u64) as u32;
if available_qty == 0 { if available_qty == 0 {
continue; continue;
@@ -471,7 +547,11 @@ impl PlatformExprStrategy {
if remaining_qty == 0 { if remaining_qty == 0 {
break; 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 { if take_qty == 0 {
continue; continue;
} }
@@ -480,13 +560,21 @@ impl PlatformExprStrategy {
while take_qty > 0 { while take_qty > 0 {
let candidate_gross = gross_amount + quote_price * take_qty as f64; let candidate_gross = gross_amount + quote_price * take_qty as f64;
if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { 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; continue;
} }
if candidate_gross + self.buy_commission(candidate_gross) <= cash + 1e-6 { if candidate_gross + self.buy_commission(candidate_gross) <= cash + 1e-6 {
break; 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 { if take_qty == 0 {
break; break;
@@ -525,6 +613,8 @@ impl PlatformExprStrategy {
} }
let market = ctx.data.market(date, symbol)?; let market = ctx.data.market(date, symbol)?;
let round_lot = self.projected_round_lot(ctx, symbol); let round_lot = self.projected_round_lot(ctx, symbol);
let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol);
let order_step_size = self.projected_order_step_size(ctx, symbol);
let fill = self let fill = self
.projected_select_execution_fill( .projected_select_execution_fill(
ctx, ctx,
@@ -533,6 +623,9 @@ impl PlatformExprStrategy {
OrderSide::Sell, OrderSide::Sell,
quantity, quantity,
round_lot, round_lot,
minimum_order_quantity,
order_step_size,
true,
None, None,
None, None,
execution_state, execution_state,
@@ -574,6 +667,8 @@ impl PlatformExprStrategy {
return 0; return 0;
} }
let round_lot = self.projected_round_lot(ctx, symbol); let round_lot = self.projected_round_lot(ctx, symbol);
let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol);
let order_step_size = self.projected_order_step_size(ctx, symbol);
let market = match ctx.data.market(date, symbol) { let market = match ctx.data.market(date, symbol) {
Some(market) => market, Some(market) => market,
None => return 0, None => return 0,
@@ -584,16 +679,20 @@ impl PlatformExprStrategy {
} }
let snapshot_requested_qty = self.round_lot_quantity( let snapshot_requested_qty = self.round_lot_quantity(
((projected.cash().min(order_value)) / sizing_price).floor() as u32, ((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 execution_price = self.projected_execution_price(market, OrderSide::Buy);
let mut quantity = snapshot_requested_qty; let mut quantity = snapshot_requested_qty;
while quantity > 0 { while quantity > 0 {
let gross_amount = execution_price * quantity as f64; 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; break;
} }
quantity = quantity.saturating_sub(round_lot); quantity =
self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size);
} }
if quantity == 0 { if quantity == 0 {
return 0; return 0;
@@ -606,6 +705,9 @@ impl PlatformExprStrategy {
OrderSide::Buy, OrderSide::Buy,
quantity, quantity,
round_lot, round_lot,
minimum_order_quantity,
order_step_size,
false,
Some(projected.cash()), Some(projected.cash()),
Some(order_value + 400.0), Some(order_value + 400.0),
execution_state, execution_state,
@@ -622,7 +724,9 @@ impl PlatformExprStrategy {
return 0; return 0;
} }
projected.apply_cash_delta(-cash_out); 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 *execution_state
.intraday_turnover .intraday_turnover
.entry(symbol.to_string()) .entry(symbol.to_string())
@@ -716,9 +820,18 @@ impl PlatformExprStrategy {
.market_decision_close_moving_average(date, &self.config.signal_symbol, 30) .market_decision_close_moving_average(date, &self.config.signal_symbol, 30)
.unwrap_or(benchmark_ma20); .unwrap_or(benchmark_ma20);
let cash = ctx.portfolio.cash(); let cash = ctx.portfolio.cash();
let market_value = ctx.portfolio.positions().values().map(|position| position.market_value()).sum::<f64>(); let market_value = ctx
.portfolio
.positions()
.values()
.map(|position| position.market_value())
.sum::<f64>();
let total_equity = cash + market_value; 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 next_day = date + Duration::days(1);
let is_month_end = next_day.month() != date.month(); let is_month_end = next_day.month() != date.month();
@@ -850,7 +963,9 @@ impl PlatformExprStrategy {
upper_limit: market.upper_limit, upper_limit: market.upper_limit,
lower_limit: market.lower_limit, lower_limit: market.lower_limit,
price_tick: market.price_tick, 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, paused: market.paused || candidate.is_paused,
is_st: candidate.is_st || self.special_name(ctx, symbol), is_st: candidate.is_st || self.special_name(ctx, symbol),
is_kcb: candidate.is_kcb, 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_ma10".into(), Dynamic::from(day.benchmark_ma10));
day_factors.insert("benchmark_ma20".into(), Dynamic::from(day.benchmark_ma20)); 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_ma30".into(), Dynamic::from(day.benchmark_ma30));
day_factors.insert("benchmark_ma_short".into(), Dynamic::from(day.benchmark_ma_short)); day_factors.insert(
day_factors.insert("benchmark_ma_long".into(), Dynamic::from(day.benchmark_ma_long)); "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("cash".into(), Dynamic::from(day.cash));
day_factors.insert("market_value".into(), Dynamic::from(day.market_value)); 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("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("position_count".into(), Dynamic::from(day.position_count));
day_factors.insert("max_positions".into(), Dynamic::from(day.max_positions)); day_factors.insert("max_positions".into(), Dynamic::from(day.max_positions));
day_factors.insert("refresh_rate".into(), Dynamic::from(day.refresh_rate)); 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)); day_factors.insert("is_month_end".into(), Dynamic::from(day.is_month_end));
scope.push("day_factors", day_factors); scope.push("day_factors", day_factors);
if let Some(stock) = stock { if let Some(stock) = stock {
let at_upper_limit = Self::price_is_at_limit(stock.last, stock.upper_limit, stock.price_tick); let at_upper_limit =
let at_lower_limit = Self::price_is_at_limit(stock.last, stock.lower_limit, stock.price_tick); 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("symbol", stock.symbol.clone());
scope.push("market_cap", stock.market_cap); scope.push("market_cap", stock.market_cap);
scope.push("free_float_cap", stock.free_float_cap); scope.push("free_float_cap", stock.free_float_cap);
@@ -1038,8 +1164,14 @@ impl PlatformExprStrategy {
"touched_lower_limit".into(), "touched_lower_limit".into(),
Dynamic::from(stock.touched_lower_limit), Dynamic::from(stock.touched_lower_limit),
); );
factors.insert("hit_upper_limit".into(), Dynamic::from(stock.touched_upper_limit)); factors.insert(
factors.insert("hit_lower_limit".into(), Dynamic::from(stock.touched_lower_limit)); "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("listed_days".into(), Dynamic::from(stock.listed_days));
factors.insert("at_upper_limit".into(), Dynamic::from(at_upper_limit)); factors.insert("at_upper_limit".into(), Dynamic::from(at_upper_limit));
factors.insert("at_lower_limit".into(), Dynamic::from(at_lower_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("ma10".into(), Dynamic::from(stock.stock_ma10));
factors.insert("ma20".into(), Dynamic::from(stock.stock_ma20)); factors.insert("ma20".into(), Dynamic::from(stock.stock_ma20));
factors.insert("ma30".into(), Dynamic::from(stock.stock_ma30)); factors.insert("ma30".into(), Dynamic::from(stock.stock_ma30));
factors.insert("stock_volume_ma5".into(), Dynamic::from(stock.stock_volume_ma5)); factors.insert(
factors.insert("stock_volume_ma10".into(), Dynamic::from(stock.stock_volume_ma10)); "stock_volume_ma5".into(),
factors.insert("stock_volume_ma20".into(), Dynamic::from(stock.stock_volume_ma20)); Dynamic::from(stock.stock_volume_ma5),
factors.insert("stock_volume_ma60".into(), Dynamic::from(stock.stock_volume_ma60)); );
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_ma5".into(), Dynamic::from(stock.stock_volume_ma5));
factors.insert("volume_ma10".into(), Dynamic::from(stock.stock_volume_ma10)); factors.insert("volume_ma10".into(), Dynamic::from(stock.stock_volume_ma10));
factors.insert("volume_ma20".into(), Dynamic::from(stock.stock_volume_ma20)); factors.insert("volume_ma20".into(), Dynamic::from(stock.stock_volume_ma20));
@@ -1120,7 +1264,8 @@ impl PlatformExprStrategy {
item.extra_factors item.extra_factors
.keys() .keys()
.filter(|key| { .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}\"];")) .map(|key| format!("let {key} = factors[\"{key}\"];"))
.collect::<Vec<_>>() .collect::<Vec<_>>()
@@ -1138,7 +1283,9 @@ impl PlatformExprStrategy {
let script = script_parts.join("\n"); let script = script_parts.join("\n");
self.engine self.engine
.eval_with_scope::<Dynamic>(&mut scope, &script) .eval_with_scope::<Dynamic>(&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 { fn normalize_expr(expr: &str) -> String {
@@ -1265,23 +1412,32 @@ impl PlatformExprStrategy {
let value = match field { let value = match field {
"benchmark_open" => ctx.data.benchmark_open_moving_average(day.date, lookback), "benchmark_open" => ctx.data.benchmark_open_moving_average(day.date, lookback),
"benchmark_close" => ctx.data.benchmark_moving_average(day.date, lookback), "benchmark_close" => ctx.data.benchmark_moving_average(day.date, lookback),
"signal_open" => ctx "signal_open" => {
.data ctx.data
.market_open_moving_average(day.date, &self.config.signal_symbol, lookback), .market_open_moving_average(day.date, &self.config.signal_symbol, lookback)
"signal_close" => ctx }
.data "signal_close" => ctx.data.market_decision_close_moving_average(
.market_decision_close_moving_average(day.date, &self.config.signal_symbol, lookback), day.date,
"signal_volume" => ctx &self.config.signal_symbol,
.data lookback,
.market_decision_volume_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 => { other => {
let stock = stock.ok_or_else(|| { let stock = stock.ok_or_else(|| {
BacktestError::Execution(format!( BacktestError::Execution(format!(
"rolling_mean(\"{other}\", {lookback}) requires stock context" "rolling_mean(\"{other}\", {lookback}) requires stock context"
)) ))
})?; })?;
ctx.data ctx.data.market_decision_numeric_moving_average(
.market_decision_numeric_moving_average(day.date, &stock.symbol, other, lookback) day.date,
&stock.symbol,
other,
lookback,
)
} }
}; };
value.ok_or_else(|| { value.ok_or_else(|| {
@@ -1375,9 +1531,7 @@ impl PlatformExprStrategy {
} }
fn quote_rhai_string(value: &str) -> String { fn quote_rhai_string(value: &str) -> String {
let escaped = value let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
.replace('\\', "\\\\")
.replace('"', "\\\"");
format!("\"{escaped}\"") format!("\"{escaped}\"")
} }
@@ -1486,7 +1640,10 @@ impl PlatformExprStrategy {
let condition = Self::rewrite_ternary(expr[..question_idx].trim()); let condition = Self::rewrite_ternary(expr[..question_idx].trim());
let when_true = Self::rewrite_ternary(expr[question_idx + 1..colon_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()); 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)> { fn find_top_level_ternary(expr: &str) -> Option<(usize, usize)> {
@@ -1861,7 +2018,9 @@ impl PlatformExprStrategy {
let Some(position) = ctx.portfolio.position(symbol) else { let Some(position) = ctx.portfolio.position(symbol) else {
return Ok((false, false)); 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)); return Ok((false, false));
} }
if position.quantity == 0 || position.average_cost <= 0.0 { 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() { let stop_hit = if self.config.stop_loss_expr.trim().is_empty() {
false false
} else { } 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::<bool>() { if let Some(boolean) = stop_result.clone().try_cast::<bool>() {
boolean boolean
} else if let Some(multiplier) = stop_result.clone().try_cast::<f64>() { } else if let Some(multiplier) = stop_result.clone().try_cast::<f64>() {
@@ -1898,14 +2063,24 @@ impl PlatformExprStrategy {
let profit_hit = if self.config.take_profit_expr.trim().is_empty() { let profit_hit = if self.config.take_profit_expr.trim().is_empty() {
false false
} else { } 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::<bool>() { if let Some(boolean) = take_result.clone().try_cast::<bool>() {
boolean boolean
} else if let Some(multiplier) = take_result.clone().try_cast::<f64>() { } else if let Some(multiplier) = take_result.clone().try_cast::<f64>() {
!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 && current_price / position.average_cost > multiplier
} else if let Some(multiplier) = take_result.try_cast::<i64>() { } else if let Some(multiplier) = take_result.try_cast::<i64>() {
!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 && current_price / position.average_cost > multiplier as f64
} else { } else {
false false
@@ -1998,7 +2173,10 @@ impl Strategy for PlatformExprStrategy {
continue; continue;
} }
let stock = self.stock_state(ctx, date, symbol)?; 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; continue;
} }
if !self.stock_passes_expr(ctx, &day, &stock)? { if !self.stock_passes_expr(ctx, &day, &stock)? {
@@ -2067,7 +2245,10 @@ impl Strategy for PlatformExprStrategy {
continue; continue;
} }
let stock = self.stock_state(ctx, date, symbol)?; 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; continue;
} }
if !self.stock_passes_expr(ctx, &day, &stock)? { if !self.stock_passes_expr(ctx, &day, &stock)? {

View File

@@ -175,7 +175,11 @@ impl CnSmallCapRotationStrategy {
let (sum, count) = window.fold((0.0, 0usize), |(sum, count), value| { let (sum, count) = window.fold((0.0, 0usize), |(sum, count), value| {
(sum + value, count + 1) (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 { fn gross_exposure(&self, closes: &[f64]) -> f64 {
@@ -325,7 +329,7 @@ impl Strategy for CnSmallCapRotationStrategy {
order_intents: Vec::new(), order_intents: Vec::new(),
notes: vec![format!("warmup: {}", message)], notes: vec![format!("warmup: {}", message)],
diagnostics: vec![ 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 { fn sell_cost(&self, date: NaiveDate, gross_amount: f64) -> f64 {
let model = ChinaAShareCostModel::default(); 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 { fn round_lot_quantity(
let lot = round_lot.max(1); &self,
(quantity / lot) * lot 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 { fn intraday_execution_start_time(&self) -> NaiveTime {
@@ -611,17 +645,33 @@ impl JqMicroCapStrategy {
.max(1) .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 { 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 { if cash <= 0.0 || sizing_price <= 0.0 || execution_price <= 0.0 {
return 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 { while quantity > 0 {
let gross_amount = execution_price * quantity as f64; let gross_amount = execution_price * quantity as f64;
if gross_amount + self.buy_commission(gross_amount) <= cash + 1e-6 { if gross_amount + self.buy_commission(gross_amount) <= cash + 1e-6 {
return quantity; return quantity;
} }
quantity = quantity.saturating_sub(100); quantity = self.decrement_order_quantity(quantity, 100, 100);
} }
0 0
} }
@@ -649,6 +699,8 @@ impl JqMicroCapStrategy {
return 0; return 0;
} }
let round_lot = self.projected_round_lot(ctx, symbol); let round_lot = self.projected_round_lot(ctx, symbol);
let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol);
let order_step_size = self.projected_order_step_size(ctx, symbol);
let market = match ctx.data.market(date, symbol) { let market = match ctx.data.market(date, symbol) {
Some(market) => market, Some(market) => market,
None => return 0, None => return 0,
@@ -659,7 +711,8 @@ impl JqMicroCapStrategy {
} }
let snapshot_requested_qty = self.round_lot_quantity( let snapshot_requested_qty = self.round_lot_quantity(
((projected.cash().min(order_value)) / sizing_price).floor() as u32, ((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_execution_price = self.projected_execution_price(market, OrderSide::Buy);
let projected_fill = self.projected_select_execution_fill( let projected_fill = self.projected_select_execution_fill(
@@ -669,6 +722,9 @@ impl JqMicroCapStrategy {
OrderSide::Buy, OrderSide::Buy,
u32::MAX, u32::MAX,
round_lot, round_lot,
minimum_order_quantity,
order_step_size,
false,
Some(projected.cash()), Some(projected.cash()),
Some(order_value + 400.0), Some(order_value + 400.0),
execution_state, execution_state,
@@ -676,12 +732,11 @@ impl JqMicroCapStrategy {
let mut quantity = snapshot_requested_qty; let mut quantity = snapshot_requested_qty;
while quantity > 0 { while quantity > 0 {
let gross_amount = projected_execution_price * quantity as f64; let gross_amount = projected_execution_price * quantity as f64;
if gross_amount <= order_value + 400.0 if gross_amount <= order_value + 400.0 && gross_amount <= projected.cash() + 1e-6 {
&& gross_amount <= projected.cash() + 1e-6
{
break; break;
} }
quantity = quantity.saturating_sub(round_lot); quantity =
self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size);
} }
if quantity == 0 { if quantity == 0 {
return 0; return 0;
@@ -695,7 +750,8 @@ impl JqMicroCapStrategy {
if gross_amount <= projected.cash() + 1e-6 { if gross_amount <= projected.cash() + 1e-6 {
break; break;
} }
quantity = quantity.saturating_sub(round_lot); quantity =
self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size);
} }
if quantity == 0 { if quantity == 0 {
return 0; return 0;
@@ -742,6 +798,8 @@ impl JqMicroCapStrategy {
} }
let market = ctx.data.market(date, symbol)?; let market = ctx.data.market(date, symbol)?;
let round_lot = self.projected_round_lot(ctx, symbol); let round_lot = self.projected_round_lot(ctx, symbol);
let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol);
let order_step_size = self.projected_order_step_size(ctx, symbol);
let fill = self let fill = self
.projected_select_execution_fill( .projected_select_execution_fill(
ctx, ctx,
@@ -750,6 +808,9 @@ impl JqMicroCapStrategy {
OrderSide::Sell, OrderSide::Sell,
quantity, quantity,
round_lot, round_lot,
minimum_order_quantity,
order_step_size,
true,
None, None,
None, None,
execution_state, execution_state,
@@ -788,7 +849,10 @@ impl JqMicroCapStrategy {
symbol: &str, symbol: &str,
side: OrderSide, side: OrderSide,
requested_qty: u32, 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, execution_state: &ProjectedExecutionState,
) -> Option<u32> { ) -> Option<u32> {
if requested_qty == 0 { if requested_qty == 0 {
@@ -799,7 +863,6 @@ impl JqMicroCapStrategy {
return None; return None;
} }
let lot = round_lot.max(1);
let mut max_fill = requested_qty; let mut max_fill = requested_qty;
let top_level_liquidity = match side { let top_level_liquidity = match side {
OrderSide::Buy => snapshot.liquidity_for_buy(), OrderSide::Buy => snapshot.liquidity_for_buy(),
@@ -809,7 +872,12 @@ impl JqMicroCapStrategy {
if top_level_liquidity == 0 { if top_level_liquidity == 0 {
return None; 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 consumed_turnover = *execution_state.intraday_turnover.get(symbol).unwrap_or(&0);
let raw_limit = let raw_limit =
@@ -817,7 +885,11 @@ impl JqMicroCapStrategy {
if raw_limit <= 0 { if raw_limit <= 0 {
return None; 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 { if volume_limited == 0 {
return None; return None;
} }
@@ -842,6 +914,9 @@ impl JqMicroCapStrategy {
side: OrderSide, side: OrderSide,
requested_qty: u32, requested_qty: u32,
round_lot: u32, round_lot: u32,
minimum_order_quantity: u32,
order_step_size: u32,
allow_odd_lot_sell: bool,
cash_limit: Option<f64>, cash_limit: Option<f64>,
gross_limit: Option<f64>, gross_limit: Option<f64>,
execution_state: &ProjectedExecutionState, execution_state: &ProjectedExecutionState,
@@ -856,26 +931,39 @@ impl JqMicroCapStrategy {
let quantity = match side { let quantity = match side {
OrderSide::Buy => { OrderSide::Buy => {
let cash = cash_limit.unwrap_or(f64::INFINITY); 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 { while take_qty > 0 {
let candidate_gross = execution_price * take_qty as f64; let candidate_gross = execution_price * take_qty as f64;
if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { 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; 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 { if candidate_cash <= cash + 1e-6 {
break; 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 take_qty
} }
OrderSide::Sell => requested_qty, OrderSide::Sell => requested_qty,
}; };
if quantity > 0 { if quantity > 0 {
let next_cursor = date.and_time(self.intraday_execution_start_time()) let next_cursor =
+ Duration::seconds(1); date.and_time(self.intraday_execution_start_time()) + Duration::seconds(1);
return Some(ProjectedExecutionFill { return Some(ProjectedExecutionFill {
price: execution_price, price: execution_price,
quantity, 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 start_cursor = self.projected_execution_start_cursor(date, symbol, execution_state);
let quotes = ctx.data.execution_quotes_on(date, symbol); let quotes = ctx.data.execution_quotes_on(date, symbol);
let mut filled_qty = 0_u32; let mut filled_qty = 0_u32;
@@ -921,7 +1008,7 @@ impl JqMicroCapStrategy {
OrderSide::Buy => quote.ask1_volume, OrderSide::Buy => quote.ask1_volume,
OrderSide::Sell => quote.bid1_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; .min(u32::MAX as u64) as u32;
if available_qty == 0 { if available_qty == 0 {
continue; continue;
@@ -931,7 +1018,11 @@ impl JqMicroCapStrategy {
if remaining_qty == 0 { if remaining_qty == 0 {
break; 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 { if take_qty == 0 {
continue; continue;
} }
@@ -940,13 +1031,21 @@ impl JqMicroCapStrategy {
while take_qty > 0 { while take_qty > 0 {
let candidate_gross = gross_amount + quote_price * take_qty as f64; let candidate_gross = gross_amount + quote_price * take_qty as f64;
if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { 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; continue;
} }
if candidate_gross + self.buy_commission(candidate_gross) <= cash + 1e-6 { if candidate_gross + self.buy_commission(candidate_gross) <= cash + 1e-6 {
break; 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 { if take_qty == 0 {
break; break;
@@ -1436,7 +1535,7 @@ impl Strategy for JqMicroCapStrategy {
order_intents: Vec::new(), order_intents: Vec::new(),
notes: vec![format!("warmup: {}", message)], notes: vec![format!("warmup: {}", message)],
diagnostics: vec![ diagnostics: vec![
"insufficient history; skip trading on warmup dates".to_string(), "insufficient history; skip trading on warmup dates".to_string()
], ],
}); });
} }