支持执行日行情价格映射表达式

This commit is contained in:
boris
2026-07-10 03:55:57 +08:00
parent bb51d91b76
commit b1520fcca0
+218 -9
View File
@@ -884,6 +884,32 @@ impl PlatformExprStrategy {
} }
fn is_reserved_scope_name(name: &str) -> bool { fn is_reserved_scope_name(name: &str) -> bool {
if let Some(field) = name
.strip_prefix("decision_")
.or_else(|| name.strip_prefix("execution_"))
{
if matches!(
field,
"day_open"
| "open"
| "high"
| "low"
| "close"
| "last_price"
| "prev_close"
| "upper_limit"
| "lower_limit"
| "volume"
| "minute_volume"
| "bid1"
| "ask1"
| "bid1_volume"
| "ask1_volume"
| "price_tick"
) {
return true;
}
}
matches!( matches!(
name, name,
"signal_close" "signal_close"
@@ -3166,6 +3192,7 @@ impl PlatformExprStrategy {
day: &DayExpressionState, day: &DayExpressionState,
stock: Option<&StockExpressionState>, stock: Option<&StockExpressionState>,
position: Option<&PositionExpressionState>, position: Option<&PositionExpressionState>,
identifiers: &BTreeSet<String>,
include_day_factors: bool, include_day_factors: bool,
include_factors_map: bool, include_factors_map: bool,
include_process_event_counts: bool, include_process_event_counts: bool,
@@ -3183,6 +3210,41 @@ impl PlatformExprStrategy {
scope.push("signal_close", day.signal_close); scope.push("signal_close", day.signal_close);
scope.push("benchmark_open", day.benchmark_open); scope.push("benchmark_open", day.benchmark_open);
scope.push("benchmark_close", day.benchmark_close); scope.push("benchmark_close", day.benchmark_close);
for field in [
"day_open",
"open",
"high",
"low",
"close",
"last_price",
"prev_close",
"upper_limit",
"lower_limit",
"volume",
"minute_volume",
"bid1",
"ask1",
"bid1_volume",
"ask1_volume",
"price_tick",
] {
self.push_market_scope_map(
&mut scope,
ctx,
ctx.decision_date,
"decision",
field,
identifiers,
);
self.push_market_scope_map(
&mut scope,
ctx,
ctx.execution_date,
"execution",
field,
identifiers,
);
}
scope.push("signal_ma5", day.signal_ma5); scope.push("signal_ma5", day.signal_ma5);
scope.push("signal_ma10", day.signal_ma10); scope.push("signal_ma10", day.signal_ma10);
scope.push("signal_ma20", day.signal_ma20); scope.push("signal_ma20", day.signal_ma20);
@@ -3823,6 +3885,7 @@ impl PlatformExprStrategy {
day, day,
stock, stock,
position, position,
&normalized_identifiers,
include_day_factors, include_day_factors,
include_factors_map, include_factors_map,
include_process_event_counts, include_process_event_counts,
@@ -5670,14 +5733,15 @@ impl PlatformExprStrategy {
if trimmed.is_empty() { if trimmed.is_empty() {
return Ok(BTreeMap::new()); return Ok(BTreeMap::new());
} }
let inner = trimmed let Some(inner) = trimmed
.strip_prefix('{') .strip_prefix('{')
.and_then(|value| value.strip_suffix('}')) .and_then(|value| value.strip_suffix('}'))
.ok_or_else(|| { else {
BacktestError::Execution(format!( return Self::dynamic_to_float_map(
"platform float map expr must use {{...}} object literal syntax: {trimmed}" self.eval_dynamic(ctx, trimmed, day, stock, position)?,
)) trimmed,
})?; );
};
let mut output = BTreeMap::new(); let mut output = BTreeMap::new();
for entry in Self::split_top_level_args(inner) { for entry in Self::split_top_level_args(inner) {
let Some((raw_key, raw_value)) = Self::split_top_level_key_value(&entry) else { let Some((raw_key, raw_value)) = Self::split_top_level_key_value(&entry) else {
@@ -5711,6 +5775,11 @@ impl PlatformExprStrategy {
self.eval_float_map_expr(ctx, trimmed, day, stock, position)?, self.eval_float_map_expr(ctx, trimmed, day, stock, position)?,
)); ));
} }
if !trimmed.contains('(') {
return Ok(TargetPortfolioOrderPricing::LimitPrices(
self.eval_float_map_expr(ctx, trimmed, day, stock, position)?,
));
}
self.eval_algo_order_pricing_expr(ctx, trimmed, day, stock, position) self.eval_algo_order_pricing_expr(ctx, trimmed, day, stock, position)
} }
@@ -5850,6 +5919,82 @@ impl PlatformExprStrategy {
))) )))
} }
fn dynamic_to_float_map(
value: Dynamic,
expr: &str,
) -> Result<BTreeMap<String, f64>, BacktestError> {
let Some(map) = value.try_cast::<Map>() else {
return Err(BacktestError::Execution(format!(
"platform float map expr must evaluate to a map or use {{...}} object literal syntax: {expr}"
)));
};
let mut output = BTreeMap::new();
for (key, value) in map {
let number = if let Some(number) = value.clone().try_cast::<f64>() {
number
} else if let Some(number) = value.clone().try_cast::<i64>() {
number as f64
} else if let Some(boolean) = value.try_cast::<bool>() {
if boolean { 1.0 } else { 0.0 }
} else {
return Err(BacktestError::Execution(format!(
"platform float map expr produced non-numeric value for key {key}: {expr}"
)));
};
output.insert(key.to_string(), number);
}
Ok(output)
}
fn market_scope_value(snapshot: &DailyMarketSnapshot, field: &str) -> Option<f64> {
let value = match field {
"day_open" => snapshot.day_open,
"open" => snapshot.open,
"high" => snapshot.high,
"low" => snapshot.low,
"close" => snapshot.close,
"last_price" => snapshot.last_price,
"prev_close" => snapshot.prev_close,
"upper_limit" => snapshot.upper_limit,
"lower_limit" => snapshot.lower_limit,
"volume" => snapshot.volume as f64,
"minute_volume" => snapshot.minute_volume as f64,
"bid1" => snapshot.bid1,
"ask1" => snapshot.ask1,
"bid1_volume" => snapshot.bid1_volume as f64,
"ask1_volume" => snapshot.ask1_volume as f64,
"price_tick" => snapshot.price_tick,
_ => return None,
};
value.is_finite().then_some(value)
}
fn scope_identifier_requested(&self, identifiers: &BTreeSet<String>, name: &str) -> bool {
identifiers.contains(name) || self.prelude_identifier_candidates.contains(name)
}
fn push_market_scope_map(
&self,
scope: &mut Scope<'_>,
ctx: &StrategyContext<'_>,
date: NaiveDate,
prefix: &str,
field: &str,
identifiers: &BTreeSet<String>,
) {
let name = format!("{prefix}_{field}");
if !self.scope_identifier_requested(identifiers, &name) {
return;
}
let mut map = Map::new();
for snapshot in ctx.data.market_snapshots_on(date) {
if let Some(value) = Self::market_scope_value(snapshot, field) {
map.insert(snapshot.symbol.clone().into(), Dynamic::from(value));
}
}
scope.push_dynamic(name, Dynamic::from(map));
}
fn action_stock_state( fn action_stock_state(
&self, &self,
ctx: &StrategyContext<'_>, ctx: &StrategyContext<'_>,
@@ -27077,6 +27222,52 @@ mod tests {
lower_limit: 900.9, lower_limit: 900.9,
price_tick: 0.01, price_tick: 0.01,
}, },
DailyMarketSnapshot {
date: execution_date,
symbol: "000521.SZ".to_string(),
timestamp: Some("2023-01-04 09:30:00".to_string()),
day_open: 12.34,
open: 12.31,
high: 12.80,
low: 12.10,
close: 12.70,
last_price: 12.70,
bid1: 12.69,
ask1: 12.71,
prev_close: 12.00,
volume: 100_000,
minute_volume: 5_000,
bid1_volume: 2_500,
ask1_volume: 2_500,
trading_phase: Some("open_auction".to_string()),
paused: false,
upper_limit: 13.20,
lower_limit: 10.80,
price_tick: 0.01,
},
DailyMarketSnapshot {
date: execution_date,
symbol: "000333.SZ".to_string(),
timestamp: Some("2023-01-04 09:30:00".to_string()),
day_open: 55.60,
open: 55.50,
high: 56.20,
low: 55.00,
close: 56.00,
last_price: 56.00,
bid1: 55.99,
ask1: 56.01,
prev_close: 54.90,
volume: 100_000,
minute_volume: 5_000,
bid1_volume: 2_500,
ask1_volume: 2_500,
trading_phase: Some("open_auction".to_string()),
paused: false,
upper_limit: 60.39,
lower_limit: 49.41,
price_tick: 0.01,
},
], ],
vec![], vec![],
vec![], vec![],
@@ -27131,8 +27322,8 @@ mod tests {
"}" "}"
) )
.to_string(), .to_string(),
order_prices_expr: None, order_prices_expr: Some("execution_day_open".to_string()),
valuation_prices_expr: None, valuation_prices_expr: Some("execution_day_open".to_string()),
when_expr: Some( when_expr: Some(
"decision_date == \"2023-01-03\" && execution_date == \"2023-01-04\"" "decision_date == \"2023-01-03\" && execution_date == \"2023-01-04\""
.to_string(), .to_string(),
@@ -27145,9 +27336,27 @@ mod tests {
assert_eq!(decision.order_intents.len(), 1); assert_eq!(decision.order_intents.len(), 1);
match &decision.order_intents[0] { match &decision.order_intents[0] {
crate::strategy::OrderIntent::TargetPortfolioSmart { target_weights, .. } => { crate::strategy::OrderIntent::TargetPortfolioSmart {
target_weights,
order_prices,
valuation_prices,
..
} => {
assert_eq!(target_weights.get("000521.SZ").copied(), Some(0.025)); assert_eq!(target_weights.get("000521.SZ").copied(), Some(0.025));
assert_eq!(target_weights.get("000333.SZ").copied(), Some(0.05)); assert_eq!(target_weights.get("000333.SZ").copied(), Some(0.05));
match order_prices {
Some(TargetPortfolioOrderPricing::LimitPrices(prices)) => {
assert_eq!(prices.get("000521.SZ").copied(), Some(12.34));
assert_eq!(prices.get("000333.SZ").copied(), Some(55.60));
}
other => panic!("unexpected order pricing: {other:?}"),
}
assert_eq!(
valuation_prices
.as_ref()
.and_then(|prices| prices.get("000521.SZ").copied()),
Some(12.34)
);
} }
other => panic!("unexpected explicit target portfolio intent: {other:?}"), other => panic!("unexpected explicit target portfolio intent: {other:?}"),
} }