Align projected stock order sizing semantics
This commit is contained in:
@@ -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<f64>,
|
||||
gross_limit: Option<f64>,
|
||||
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::<f64>();
|
||||
let market_value = ctx
|
||||
.portfolio
|
||||
.positions()
|
||||
.values()
|
||||
.map(|position| position.market_value())
|
||||
.sum::<f64>();
|
||||
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::<Vec<_>>()
|
||||
@@ -1138,7 +1283,9 @@ impl PlatformExprStrategy {
|
||||
let script = script_parts.join("\n");
|
||||
self.engine
|
||||
.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 {
|
||||
@@ -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::<bool>() {
|
||||
boolean
|
||||
} 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() {
|
||||
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::<bool>() {
|
||||
boolean
|
||||
} 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
|
||||
} 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
|
||||
} 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)? {
|
||||
|
||||
@@ -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<u32> {
|
||||
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<f64>,
|
||||
gross_limit: Option<f64>,
|
||||
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()
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user