增加数值表达式字节码虚拟机
This commit is contained in:
@@ -8,6 +8,7 @@ pub mod events;
|
|||||||
pub mod futures;
|
pub mod futures;
|
||||||
pub mod instrument;
|
pub mod instrument;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
|
mod numeric_expr_vm;
|
||||||
pub mod platform_expr_strategy;
|
pub mod platform_expr_strategy;
|
||||||
pub mod platform_runtime_schema;
|
pub mod platform_runtime_schema;
|
||||||
pub mod platform_strategy_spec;
|
pub mod platform_strategy_spec;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,10 @@ use crate::data::{
|
|||||||
};
|
};
|
||||||
use crate::engine::BacktestError;
|
use crate::engine::BacktestError;
|
||||||
use crate::events::OrderSide;
|
use crate::events::OrderSide;
|
||||||
|
use crate::numeric_expr_vm::{
|
||||||
|
self, EvalError as NumericVmEvalError, Program as NumericVmProgram,
|
||||||
|
Scratch as NumericVmScratch, Value as NumericVmValue, ValueType as NumericVmValueType,
|
||||||
|
};
|
||||||
use crate::portfolio::PortfolioState;
|
use crate::portfolio::PortfolioState;
|
||||||
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit};
|
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit};
|
||||||
use crate::scheduler::{
|
use crate::scheduler::{
|
||||||
@@ -812,6 +816,12 @@ struct ExpressionEvalPlan {
|
|||||||
prelude_source: String,
|
prelude_source: String,
|
||||||
prelude_identifiers: BTreeSet<String>,
|
prelude_identifiers: BTreeSet<String>,
|
||||||
prelude_runtime_template: Option<Result<RuntimeExpressionTemplate, String>>,
|
prelude_runtime_template: Option<Result<RuntimeExpressionTemplate, String>>,
|
||||||
|
numeric_vm: Option<NumericExpressionPlan>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NumericExpressionPlan {
|
||||||
|
program: NumericVmProgram,
|
||||||
|
helper_bindings: Vec<Option<RuntimeHelperBinding>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PreludeDependencyPlan {
|
struct PreludeDependencyPlan {
|
||||||
@@ -876,10 +886,12 @@ impl PreludeDependencyPlan {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
struct RuntimeExpressionTemplate {
|
struct RuntimeExpressionTemplate {
|
||||||
segments: Vec<RuntimeExpressionSegment>,
|
segments: Vec<RuntimeExpressionSegment>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
enum RuntimeExpressionSegment {
|
enum RuntimeExpressionSegment {
|
||||||
Literal(String),
|
Literal(String),
|
||||||
Helper {
|
Helper {
|
||||||
@@ -889,6 +901,13 @@ enum RuntimeExpressionSegment {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct RuntimeHelperBinding {
|
||||||
|
name: String,
|
||||||
|
args: Vec<String>,
|
||||||
|
scope_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct PlatformExprStrategy {
|
pub struct PlatformExprStrategy {
|
||||||
config: PlatformExprStrategyConfig,
|
config: PlatformExprStrategyConfig,
|
||||||
engine: Engine,
|
engine: Engine,
|
||||||
@@ -912,6 +931,9 @@ pub struct PlatformExprStrategy {
|
|||||||
cache_hits: RefCell<u64>,
|
cache_hits: RefCell<u64>,
|
||||||
cache_misses: RefCell<u64>,
|
cache_misses: RefCell<u64>,
|
||||||
expression_plan_cache: RefCell<AHashMap<String, Arc<ExpressionEvalPlan>>>,
|
expression_plan_cache: RefCell<AHashMap<String, Arc<ExpressionEvalPlan>>>,
|
||||||
|
numeric_vm_scratch: RefCell<NumericVmScratch>,
|
||||||
|
numeric_vm_hits: RefCell<u64>,
|
||||||
|
numeric_vm_fallbacks: RefCell<u64>,
|
||||||
prelude_dependency_plan: PreludeDependencyPlan,
|
prelude_dependency_plan: PreludeDependencyPlan,
|
||||||
prelude_identifier_candidates: BTreeSet<String>,
|
prelude_identifier_candidates: BTreeSet<String>,
|
||||||
prelude_declared_identifiers: BTreeSet<String>,
|
prelude_declared_identifiers: BTreeSet<String>,
|
||||||
@@ -1223,6 +1245,9 @@ impl PlatformExprStrategy {
|
|||||||
cache_hits: RefCell::new(0),
|
cache_hits: RefCell::new(0),
|
||||||
cache_misses: RefCell::new(0),
|
cache_misses: RefCell::new(0),
|
||||||
expression_plan_cache: RefCell::new(AHashMap::new()),
|
expression_plan_cache: RefCell::new(AHashMap::new()),
|
||||||
|
numeric_vm_scratch: RefCell::new(NumericVmScratch::default()),
|
||||||
|
numeric_vm_hits: RefCell::new(0),
|
||||||
|
numeric_vm_fallbacks: RefCell::new(0),
|
||||||
prelude_dependency_plan,
|
prelude_dependency_plan,
|
||||||
prelude_identifier_candidates,
|
prelude_identifier_candidates,
|
||||||
prelude_declared_identifiers,
|
prelude_declared_identifiers,
|
||||||
@@ -1252,6 +1277,14 @@ impl PlatformExprStrategy {
|
|||||||
self.compiled_cache.borrow().len()
|
self.compiled_cache.borrow().len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn numeric_vm_hits(&self) -> u64 {
|
||||||
|
*self.numeric_vm_hits.borrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn numeric_vm_fallbacks(&self) -> u64 {
|
||||||
|
*self.numeric_vm_fallbacks.borrow()
|
||||||
|
}
|
||||||
|
|
||||||
/// Compile every configured expression before any market data is loaded.
|
/// Compile every configured expression before any market data is loaded.
|
||||||
/// This validates syntax only; identifiers and runtime values are resolved
|
/// This validates syntax only; identifiers and runtime values are resolved
|
||||||
/// later against the point-in-time execution scope.
|
/// later against the point-in-time execution scope.
|
||||||
@@ -4658,6 +4691,353 @@ impl PlatformExprStrategy {
|
|||||||
scope.into_inner()
|
scope.into_inner()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn eval_numeric_vm(
|
||||||
|
&self,
|
||||||
|
ctx: &StrategyContext<'_>,
|
||||||
|
expr: &str,
|
||||||
|
day: &DayExpressionState,
|
||||||
|
stock: Option<&StockExpressionState>,
|
||||||
|
position: Option<&PositionExpressionState>,
|
||||||
|
) -> Result<Option<NumericVmValue>, BacktestError> {
|
||||||
|
let expression_plan = self.expression_eval_plan(expr);
|
||||||
|
let Some(vm_plan) = expression_plan.numeric_vm.as_ref() else {
|
||||||
|
*self.numeric_vm_fallbacks.borrow_mut() += 1;
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let mut helper_scope = Scope::new();
|
||||||
|
let mut scratch = self.numeric_vm_scratch.borrow_mut();
|
||||||
|
let value = vm_plan
|
||||||
|
.program
|
||||||
|
.evaluate(&mut scratch, |index, identifier, expected_type| {
|
||||||
|
if let Some(binding) = vm_plan.helper_bindings[index].as_ref() {
|
||||||
|
return self
|
||||||
|
.numeric_vm_runtime_helper_value(
|
||||||
|
ctx,
|
||||||
|
day,
|
||||||
|
stock,
|
||||||
|
binding,
|
||||||
|
expected_type,
|
||||||
|
&mut helper_scope,
|
||||||
|
)
|
||||||
|
.map_err(|error| NumericVmEvalError::new(error.to_string()));
|
||||||
|
}
|
||||||
|
self.numeric_vm_identifier_value(ctx, day, stock, position, identifier)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
NumericVmEvalError::new(format!(
|
||||||
|
"missing numeric/boolean identifier {identifier}"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|error| {
|
||||||
|
BacktestError::Execution(format!(
|
||||||
|
"platform numeric VM failed for expression {expr:?}: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
*self.numeric_vm_hits.borrow_mut() += 1;
|
||||||
|
Ok(Some(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn numeric_vm_runtime_helper_value(
|
||||||
|
&self,
|
||||||
|
ctx: &StrategyContext<'_>,
|
||||||
|
day: &DayExpressionState,
|
||||||
|
stock: Option<&StockExpressionState>,
|
||||||
|
binding: &RuntimeHelperBinding,
|
||||||
|
expected_type: NumericVmValueType,
|
||||||
|
scope: &mut Scope<'_>,
|
||||||
|
) -> Result<NumericVmValue, BacktestError> {
|
||||||
|
let resolved = self.resolve_runtime_helper(
|
||||||
|
ctx,
|
||||||
|
day,
|
||||||
|
stock,
|
||||||
|
&binding.name,
|
||||||
|
&binding.args,
|
||||||
|
&binding.scope_name,
|
||||||
|
scope,
|
||||||
|
)?;
|
||||||
|
if resolved == binding.scope_name {
|
||||||
|
return match expected_type {
|
||||||
|
NumericVmValueType::Number => scope
|
||||||
|
.get_value::<f64>(&binding.scope_name)
|
||||||
|
.map(NumericVmValue::Number)
|
||||||
|
.or_else(|| {
|
||||||
|
scope
|
||||||
|
.get_value::<i64>(&binding.scope_name)
|
||||||
|
.map(|value| NumericVmValue::Number(value as f64))
|
||||||
|
})
|
||||||
|
.ok_or_else(|| {
|
||||||
|
BacktestError::Execution(format!(
|
||||||
|
"runtime helper {} did not bind a numeric value",
|
||||||
|
binding.name
|
||||||
|
))
|
||||||
|
}),
|
||||||
|
NumericVmValueType::Boolean => scope
|
||||||
|
.get_value::<bool>(&binding.scope_name)
|
||||||
|
.map(NumericVmValue::Boolean)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
BacktestError::Execution(format!(
|
||||||
|
"runtime helper {} did not bind a boolean value",
|
||||||
|
binding.name
|
||||||
|
))
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
match expected_type {
|
||||||
|
NumericVmValueType::Number => resolved
|
||||||
|
.trim()
|
||||||
|
.parse::<f64>()
|
||||||
|
.map(NumericVmValue::Number)
|
||||||
|
.map_err(|_| {
|
||||||
|
BacktestError::Execution(format!(
|
||||||
|
"runtime helper {} produced non-numeric expression {resolved:?}",
|
||||||
|
binding.name
|
||||||
|
))
|
||||||
|
}),
|
||||||
|
NumericVmValueType::Boolean => match resolved.trim() {
|
||||||
|
"true" => Ok(NumericVmValue::Boolean(true)),
|
||||||
|
"false" => Ok(NumericVmValue::Boolean(false)),
|
||||||
|
_ => Err(BacktestError::Execution(format!(
|
||||||
|
"runtime helper {} produced non-boolean expression {resolved:?}",
|
||||||
|
binding.name
|
||||||
|
))),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn numeric_vm_identifier_value(
|
||||||
|
&self,
|
||||||
|
ctx: &StrategyContext<'_>,
|
||||||
|
day: &DayExpressionState,
|
||||||
|
stock: Option<&StockExpressionState>,
|
||||||
|
position: Option<&PositionExpressionState>,
|
||||||
|
identifier: &str,
|
||||||
|
) -> Option<NumericVmValue> {
|
||||||
|
let number = |value: f64| Some(NumericVmValue::Number(value));
|
||||||
|
let integer = |value: i64| Some(NumericVmValue::Number(value as f64));
|
||||||
|
let boolean = |value: bool| Some(NumericVmValue::Boolean(value));
|
||||||
|
match identifier {
|
||||||
|
"signal_open" => number(day.signal_open),
|
||||||
|
"signal_close" => number(day.signal_close),
|
||||||
|
"benchmark_open" => number(day.benchmark_open),
|
||||||
|
"benchmark_close" => number(day.benchmark_close),
|
||||||
|
"benchmark_signal_close" => number(day.benchmark_signal_close),
|
||||||
|
"signal_ma5" => number(day.signal_ma5),
|
||||||
|
"signal_ma10" => number(day.signal_ma10),
|
||||||
|
"signal_ma20" => number(day.signal_ma20),
|
||||||
|
"signal_ma30" => number(day.signal_ma30),
|
||||||
|
"signal_ma_short" => number(day.signal_ma_short),
|
||||||
|
"signal_ma_long" => number(day.signal_ma_long),
|
||||||
|
"benchmark_ma5" => number(day.benchmark_ma5),
|
||||||
|
"benchmark_ma10" => number(day.benchmark_ma10),
|
||||||
|
"benchmark_ma20" => number(day.benchmark_ma20),
|
||||||
|
"benchmark_ma30" => number(day.benchmark_ma30),
|
||||||
|
"benchmark_ma_short" => number(day.benchmark_ma_short),
|
||||||
|
"benchmark_ma_long" => number(day.benchmark_ma_long),
|
||||||
|
"cash" => number(day.cash),
|
||||||
|
"available_cash" => number(day.available_cash),
|
||||||
|
"frozen_cash" => number(day.frozen_cash),
|
||||||
|
"market_value" => number(day.market_value),
|
||||||
|
"total_equity" => number(day.total_equity),
|
||||||
|
"total_value" => number(day.total_value),
|
||||||
|
"portfolio_value" => number(day.portfolio_value),
|
||||||
|
"starting_cash" => number(day.starting_cash),
|
||||||
|
"unit_net_value" => number(day.unit_net_value),
|
||||||
|
"static_unit_net_value" => number(day.static_unit_net_value),
|
||||||
|
"daily_pnl" => number(day.daily_pnl),
|
||||||
|
"daily_returns" => number(day.daily_returns),
|
||||||
|
"total_returns" => number(day.total_returns),
|
||||||
|
"transaction_cost" => {
|
||||||
|
number(position.map_or(day.transaction_cost, |value| value.transaction_cost))
|
||||||
|
}
|
||||||
|
"trading_pnl" => number(position.map_or(day.trading_pnl, |value| value.trading_pnl)),
|
||||||
|
"position_pnl" => number(position.map_or(day.position_pnl, |value| value.position_pnl)),
|
||||||
|
"cash_liabilities" => number(day.cash_liabilities),
|
||||||
|
"management_fee_rate" => number(day.management_fee_rate),
|
||||||
|
"management_fees" => number(day.management_fees),
|
||||||
|
"current_exposure" => number(day.current_exposure),
|
||||||
|
"position_count" => integer(day.position_count),
|
||||||
|
"max_positions" => integer(day.max_positions),
|
||||||
|
"refresh_rate" => integer(day.refresh_rate),
|
||||||
|
"year" => integer(day.year),
|
||||||
|
"month" => integer(day.month),
|
||||||
|
"quarter" => integer(day.quarter),
|
||||||
|
"day_of_month" => integer(day.day_of_month),
|
||||||
|
"day_of_year" => integer(day.day_of_year),
|
||||||
|
"week_of_year" => integer(day.week_of_year),
|
||||||
|
"weekday" => integer(day.weekday),
|
||||||
|
"is_month_start" => boolean(day.is_month_start),
|
||||||
|
"is_month_end" => boolean(day.is_month_end),
|
||||||
|
"has_open_orders" => boolean(ctx.has_open_orders()),
|
||||||
|
"open_order_count" => integer(ctx.open_order_count() as i64),
|
||||||
|
"open_buy_order_count" => integer(ctx.open_buy_order_count() as i64),
|
||||||
|
"open_sell_order_count" => integer(ctx.open_sell_order_count() as i64),
|
||||||
|
"open_buy_qty" => integer(ctx.open_buy_quantity() as i64),
|
||||||
|
"open_sell_qty" => integer(ctx.open_sell_quantity() as i64),
|
||||||
|
"latest_open_order_id" => integer(ctx.latest_open_order_id() as i64),
|
||||||
|
"latest_open_order_unfilled_qty" => {
|
||||||
|
integer(ctx.latest_open_order_unfilled_quantity() as i64)
|
||||||
|
}
|
||||||
|
"has_dynamic_universe" => boolean(ctx.has_dynamic_universe()),
|
||||||
|
"dynamic_universe_count" => integer(ctx.dynamic_universe_count() as i64),
|
||||||
|
"has_subscriptions" => boolean(ctx.has_subscriptions()),
|
||||||
|
"subscription_count" => integer(ctx.subscription_count() as i64),
|
||||||
|
"subscription_guard_required" => boolean(self.config.subscription_guard_required),
|
||||||
|
"has_process_events" => boolean(ctx.has_process_events()),
|
||||||
|
"process_event_count" => integer(ctx.process_event_count() as i64),
|
||||||
|
"current_process_order_id" => integer(ctx.current_process_event_order_id() as i64),
|
||||||
|
"latest_process_order_id" => integer(ctx.latest_process_event_order_id() as i64),
|
||||||
|
_ => {
|
||||||
|
if let Some(stock) = stock {
|
||||||
|
let at_upper_limit = Self::price_is_at_or_above_upper_limit(
|
||||||
|
stock.last,
|
||||||
|
stock.upper_limit,
|
||||||
|
stock.price_tick,
|
||||||
|
);
|
||||||
|
let at_lower_limit = Self::price_is_at_or_below_lower_limit(
|
||||||
|
stock.last,
|
||||||
|
stock.lower_limit,
|
||||||
|
stock.price_tick,
|
||||||
|
);
|
||||||
|
let stock_value = match identifier {
|
||||||
|
"market_cap" => number(stock.market_cap),
|
||||||
|
"market_cap_bn" => number(stock.market_cap_bn),
|
||||||
|
"free_float_cap" | "free_float_market_cap" => number(stock.free_float_cap),
|
||||||
|
"free_float_cap_bn" => number(stock.free_float_cap_bn),
|
||||||
|
"free_float_cap_or_market_cap" => number(if stock.free_float_cap > 0.0 {
|
||||||
|
stock.free_float_cap
|
||||||
|
} else {
|
||||||
|
stock.market_cap
|
||||||
|
}),
|
||||||
|
"pe_ttm" => number(stock.pe_ttm),
|
||||||
|
"volume" => number(stock.volume),
|
||||||
|
"minute_volume" | "intraday_volume" => integer(stock.minute_volume),
|
||||||
|
"bid1_volume" => integer(stock.bid1_volume),
|
||||||
|
"ask1_volume" => integer(stock.ask1_volume),
|
||||||
|
"turnover" | "turnover_ratio" => number(stock.turnover_ratio),
|
||||||
|
"effective_turnover_ratio" => number(stock.effective_turnover_ratio),
|
||||||
|
"open" => number(stock.open),
|
||||||
|
"high" => number(stock.high),
|
||||||
|
"low" => number(stock.low),
|
||||||
|
"close" => number(stock.close),
|
||||||
|
"last" | "last_price" => number(stock.last),
|
||||||
|
"prev_close" => number(stock.prev_close),
|
||||||
|
"amount" => number(stock.amount),
|
||||||
|
"upper_limit" => number(stock.upper_limit),
|
||||||
|
"lower_limit" => number(stock.lower_limit),
|
||||||
|
"price_tick" => number(stock.price_tick),
|
||||||
|
"round_lot" => integer(stock.round_lot),
|
||||||
|
"minimum_order_quantity" => integer(stock.minimum_order_quantity),
|
||||||
|
"order_step_size" => integer(stock.order_step_size),
|
||||||
|
"paused" => boolean(stock.paused),
|
||||||
|
"is_st" => boolean(stock.is_st),
|
||||||
|
"is_star_st" => boolean(stock.is_star_st),
|
||||||
|
"is_kcb" => boolean(stock.is_kcb),
|
||||||
|
"is_bjse" => boolean(stock.is_bjse),
|
||||||
|
"is_one_yuan" => boolean(stock.is_one_yuan),
|
||||||
|
"is_new_listing" => boolean(stock.is_new_listing),
|
||||||
|
"allow_buy" => boolean(stock.allow_buy),
|
||||||
|
"allow_sell" => boolean(stock.allow_sell),
|
||||||
|
"touched_upper_limit" | "hit_upper_limit" => {
|
||||||
|
boolean(stock.touched_upper_limit)
|
||||||
|
}
|
||||||
|
"touched_lower_limit" | "hit_lower_limit" => {
|
||||||
|
boolean(stock.touched_lower_limit)
|
||||||
|
}
|
||||||
|
"listed_days" => integer(stock.listed_days),
|
||||||
|
"at_upper_limit" => boolean(at_upper_limit),
|
||||||
|
"at_lower_limit" => boolean(at_lower_limit),
|
||||||
|
"symbol_open_order_count" => {
|
||||||
|
integer(ctx.symbol_open_order_count(&stock.symbol) as i64)
|
||||||
|
}
|
||||||
|
"symbol_open_buy_qty" => {
|
||||||
|
integer(ctx.symbol_open_buy_quantity(&stock.symbol) as i64)
|
||||||
|
}
|
||||||
|
"symbol_open_sell_qty" => {
|
||||||
|
integer(ctx.symbol_open_sell_quantity(&stock.symbol) as i64)
|
||||||
|
}
|
||||||
|
"latest_symbol_open_order_id" => {
|
||||||
|
integer(ctx.latest_symbol_open_order_id(&stock.symbol) as i64)
|
||||||
|
}
|
||||||
|
"latest_symbol_open_order_unfilled_qty" => integer(
|
||||||
|
ctx.latest_symbol_open_order_unfilled_quantity(&stock.symbol) as i64,
|
||||||
|
),
|
||||||
|
"in_dynamic_universe" => {
|
||||||
|
boolean(ctx.dynamic_universe_contains(&stock.symbol))
|
||||||
|
}
|
||||||
|
"is_subscribed" => boolean(ctx.is_subscribed(&stock.symbol)),
|
||||||
|
"stock_ma_short" => number(stock.stock_ma_short),
|
||||||
|
"stock_ma_mid" => number(stock.stock_ma_mid),
|
||||||
|
"stock_ma_long" => number(stock.stock_ma_long),
|
||||||
|
"stock_ma5" | "ma5" => number(stock.stock_ma5),
|
||||||
|
"stock_ma10" | "ma10" => number(stock.stock_ma10),
|
||||||
|
"stock_ma20" | "ma20" => number(stock.stock_ma20),
|
||||||
|
"stock_ma30" | "ma30" => number(stock.stock_ma30),
|
||||||
|
"stock_volume_ma5" | "volume_ma5" => number(stock.stock_volume_ma5),
|
||||||
|
"stock_volume_ma10" | "volume_ma10" => number(stock.stock_volume_ma10),
|
||||||
|
"stock_volume_ma20" | "volume_ma20" => number(stock.stock_volume_ma20),
|
||||||
|
"stock_volume_ma60" | "volume_ma60" => number(stock.stock_volume_ma60),
|
||||||
|
"stock_volume_ma100" | "volume_ma100" => number(stock.stock_volume_ma100),
|
||||||
|
_ => stock
|
||||||
|
.extra_factors
|
||||||
|
.get(identifier)
|
||||||
|
.copied()
|
||||||
|
.map(NumericVmValue::Number),
|
||||||
|
};
|
||||||
|
if stock_value.is_some() {
|
||||||
|
return stock_value;
|
||||||
|
}
|
||||||
|
if day.available_factor_names.contains(identifier) {
|
||||||
|
return number(f64::NAN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let position = position?;
|
||||||
|
match identifier {
|
||||||
|
"avg_cost" => number(position.avg_cost),
|
||||||
|
"avg_price" => number(position.avg_price),
|
||||||
|
"current_price" => number(position.current_price),
|
||||||
|
"position_prev_close" | "prev_position_close" => number(position.prev_close),
|
||||||
|
"holding_return" => number(position.holding_return),
|
||||||
|
"quantity" => integer(position.quantity),
|
||||||
|
"sellable_qty" => integer(position.sellable_qty),
|
||||||
|
"sellable" => integer(position.sellable),
|
||||||
|
"closable" => integer(position.closable),
|
||||||
|
"old_quantity" => integer(position.old_quantity),
|
||||||
|
"buy_quantity" | "bought_quantity" => integer(position.bought_quantity),
|
||||||
|
"sell_quantity" | "sold_quantity" => integer(position.sold_quantity),
|
||||||
|
"buy_avg_price" => number(position.buy_avg_price),
|
||||||
|
"sell_avg_price" => number(position.sell_avg_price),
|
||||||
|
"bought_value" => number(position.bought_value),
|
||||||
|
"sold_value" => number(position.sold_value),
|
||||||
|
"position_market_value" => number(position.market_value),
|
||||||
|
"equity" => number(position.equity),
|
||||||
|
"value_percent" => number(position.value_percent),
|
||||||
|
"unrealized_pnl" => number(position.unrealized_pnl),
|
||||||
|
"realized_pnl" => number(position.realized_pnl),
|
||||||
|
"pnl" => number(position.pnl),
|
||||||
|
"day_trade_quantity_delta" => integer(position.day_trade_quantity_delta),
|
||||||
|
"dividend_receivable" => number(position.dividend_receivable),
|
||||||
|
"available_sellable_qty" => integer(
|
||||||
|
stock
|
||||||
|
.map(|stock| {
|
||||||
|
ctx.available_sellable_qty(
|
||||||
|
&stock.symbol,
|
||||||
|
position.sellable_qty as u32,
|
||||||
|
) as i64
|
||||||
|
})
|
||||||
|
.unwrap_or(position.sellable_qty.max(0)),
|
||||||
|
),
|
||||||
|
"reserved_open_sell_qty" => integer(
|
||||||
|
stock
|
||||||
|
.map(|stock| ctx.symbol_open_sell_quantity(&stock.symbol) as i64)
|
||||||
|
.unwrap_or(0),
|
||||||
|
),
|
||||||
|
"profit_pct" => number(position.holding_return * 100.0),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn eval_dynamic(
|
fn eval_dynamic(
|
||||||
&self,
|
&self,
|
||||||
ctx: &StrategyContext<'_>,
|
ctx: &StrategyContext<'_>,
|
||||||
@@ -4762,13 +5142,19 @@ impl PlatformExprStrategy {
|
|||||||
.collect::<AHashSet<_>>();
|
.collect::<AHashSet<_>>();
|
||||||
let prelude_runtime_template = (!prelude_source.trim().is_empty())
|
let prelude_runtime_template = (!prelude_source.trim().is_empty())
|
||||||
.then(|| Self::compile_runtime_helper_template(&prelude_source));
|
.then(|| Self::compile_runtime_helper_template(&prelude_source));
|
||||||
|
let runtime_template = Self::compile_runtime_helper_template(&normalized);
|
||||||
|
let numeric_vm = Self::compile_numeric_expression_plan(
|
||||||
|
prelude_runtime_template.as_ref(),
|
||||||
|
&runtime_template,
|
||||||
|
);
|
||||||
let plan = Arc::new(ExpressionEvalPlan {
|
let plan = Arc::new(ExpressionEvalPlan {
|
||||||
identifiers,
|
identifiers,
|
||||||
scope_identifiers,
|
scope_identifiers,
|
||||||
runtime_template: Self::compile_runtime_helper_template(&normalized),
|
runtime_template,
|
||||||
prelude_source,
|
prelude_source,
|
||||||
prelude_identifiers,
|
prelude_identifiers,
|
||||||
prelude_runtime_template,
|
prelude_runtime_template,
|
||||||
|
numeric_vm,
|
||||||
});
|
});
|
||||||
self.expression_plan_cache
|
self.expression_plan_cache
|
||||||
.borrow_mut()
|
.borrow_mut()
|
||||||
@@ -5326,6 +5712,168 @@ impl PlatformExprStrategy {
|
|||||||
Ok(RuntimeExpressionTemplate { segments })
|
Ok(RuntimeExpressionTemplate { segments })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn compile_numeric_expression_plan(
|
||||||
|
prelude: Option<&Result<RuntimeExpressionTemplate, String>>,
|
||||||
|
expression: &Result<RuntimeExpressionTemplate, String>,
|
||||||
|
) -> Option<NumericExpressionPlan> {
|
||||||
|
let mut bindings = AHashMap::<String, RuntimeHelperBinding>::new();
|
||||||
|
let mut script = String::new();
|
||||||
|
if let Some(prelude) = prelude {
|
||||||
|
script.push_str(&Self::numeric_vm_template_source(prelude, &mut bindings).ok()?);
|
||||||
|
script.push('\n');
|
||||||
|
}
|
||||||
|
script.push_str(&Self::numeric_vm_template_source(expression, &mut bindings).ok()?);
|
||||||
|
let program = numeric_expr_vm::compile(&script, |identifier| {
|
||||||
|
bindings
|
||||||
|
.get(identifier)
|
||||||
|
.and_then(|binding| Self::numeric_vm_helper_type(&binding.name))
|
||||||
|
.or_else(|| Self::numeric_vm_identifier_type(identifier))
|
||||||
|
})
|
||||||
|
.ok()?;
|
||||||
|
let helper_bindings = program
|
||||||
|
.variables()
|
||||||
|
.iter()
|
||||||
|
.map(|identifier| bindings.get(identifier).cloned())
|
||||||
|
.collect();
|
||||||
|
Some(NumericExpressionPlan {
|
||||||
|
program,
|
||||||
|
helper_bindings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn numeric_vm_template_source(
|
||||||
|
template: &Result<RuntimeExpressionTemplate, String>,
|
||||||
|
bindings: &mut AHashMap<String, RuntimeHelperBinding>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let template = template.as_ref().map_err(Clone::clone)?;
|
||||||
|
let mut output = String::new();
|
||||||
|
for segment in &template.segments {
|
||||||
|
match segment {
|
||||||
|
RuntimeExpressionSegment::Literal(literal) => output.push_str(literal),
|
||||||
|
RuntimeExpressionSegment::Helper {
|
||||||
|
name,
|
||||||
|
args,
|
||||||
|
scope_name,
|
||||||
|
} => {
|
||||||
|
Self::numeric_vm_helper_type(name).ok_or_else(|| {
|
||||||
|
format!("runtime helper {name} is not numeric-VM compatible")
|
||||||
|
})?;
|
||||||
|
let binding = RuntimeHelperBinding {
|
||||||
|
name: name.clone(),
|
||||||
|
args: args.clone(),
|
||||||
|
scope_name: scope_name.clone(),
|
||||||
|
};
|
||||||
|
if let Some(existing) = bindings.get(scope_name)
|
||||||
|
&& (existing.name != binding.name || existing.args != binding.args)
|
||||||
|
{
|
||||||
|
return Err(format!("runtime helper scope collision for {scope_name}"));
|
||||||
|
}
|
||||||
|
bindings.insert(scope_name.clone(), binding);
|
||||||
|
output.push_str(scope_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn numeric_vm_helper_type(helper: &str) -> Option<NumericVmValueType> {
|
||||||
|
match helper {
|
||||||
|
"has_dividend" | "has_split" | "is_margin_stock" => Some(NumericVmValueType::Boolean),
|
||||||
|
"rolling_mean"
|
||||||
|
| "sma"
|
||||||
|
| "ma"
|
||||||
|
| "rolling_mean_current"
|
||||||
|
| "rolling_max_current"
|
||||||
|
| "rolling_return_stddev_current"
|
||||||
|
| "vma"
|
||||||
|
| "rolling_sum"
|
||||||
|
| "rolling_min"
|
||||||
|
| "rolling_max"
|
||||||
|
| "rolling_stddev"
|
||||||
|
| "stddev"
|
||||||
|
| "rolling_zscore"
|
||||||
|
| "pct_change"
|
||||||
|
| "factor_value"
|
||||||
|
| "get_factor_value"
|
||||||
|
| "dividend_cash"
|
||||||
|
| "split_ratio"
|
||||||
|
| "securities_margin"
|
||||||
|
| "get_securities_margin_value"
|
||||||
|
| "shares"
|
||||||
|
| "get_shares_value"
|
||||||
|
| "turnover_rate"
|
||||||
|
| "get_turnover_rate_value"
|
||||||
|
| "price_change_rate"
|
||||||
|
| "get_price_change_rate_value"
|
||||||
|
| "stock_connect"
|
||||||
|
| "get_stock_connect_value"
|
||||||
|
| "current_performance"
|
||||||
|
| "fundamental"
|
||||||
|
| "get_fundamentals_value"
|
||||||
|
| "financial"
|
||||||
|
| "get_financials_value"
|
||||||
|
| "pit_financial"
|
||||||
|
| "get_pit_financials_value"
|
||||||
|
| "industry_code"
|
||||||
|
| "get_industry_code"
|
||||||
|
| "yield_curve"
|
||||||
|
| "get_yield_curve_value"
|
||||||
|
| "dominant_future_price"
|
||||||
|
| "get_dominant_future_price_value" => Some(NumericVmValueType::Number),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn numeric_vm_identifier_type(identifier: &str) -> Option<NumericVmValueType> {
|
||||||
|
match identifier {
|
||||||
|
"trade_date"
|
||||||
|
| "current_date"
|
||||||
|
| "date"
|
||||||
|
| "decision_date"
|
||||||
|
| "execution_date"
|
||||||
|
| "symbol"
|
||||||
|
| "order_book_id"
|
||||||
|
| "latest_open_order_status"
|
||||||
|
| "latest_symbol_open_order_status"
|
||||||
|
| "current_process_kind"
|
||||||
|
| "current_process_symbol"
|
||||||
|
| "current_process_side"
|
||||||
|
| "current_process_detail"
|
||||||
|
| "latest_process_kind"
|
||||||
|
| "latest_process_symbol"
|
||||||
|
| "latest_process_side"
|
||||||
|
| "latest_process_detail"
|
||||||
|
| "day_factors"
|
||||||
|
| "factors"
|
||||||
|
| "process_event_counts" => None,
|
||||||
|
"is_month_start"
|
||||||
|
| "is_month_end"
|
||||||
|
| "has_open_orders"
|
||||||
|
| "has_dynamic_universe"
|
||||||
|
| "has_subscriptions"
|
||||||
|
| "subscription_guard_required"
|
||||||
|
| "has_process_events"
|
||||||
|
| "paused"
|
||||||
|
| "is_st"
|
||||||
|
| "is_star_st"
|
||||||
|
| "is_kcb"
|
||||||
|
| "is_bjse"
|
||||||
|
| "is_one_yuan"
|
||||||
|
| "is_new_listing"
|
||||||
|
| "allow_buy"
|
||||||
|
| "allow_sell"
|
||||||
|
| "touched_upper_limit"
|
||||||
|
| "touched_lower_limit"
|
||||||
|
| "hit_upper_limit"
|
||||||
|
| "hit_lower_limit"
|
||||||
|
| "at_upper_limit"
|
||||||
|
| "at_lower_limit"
|
||||||
|
| "in_dynamic_universe"
|
||||||
|
| "is_subscribed" => Some(NumericVmValueType::Boolean),
|
||||||
|
_ => Some(NumericVmValueType::Number),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn expand_runtime_helper_template(
|
fn expand_runtime_helper_template(
|
||||||
&self,
|
&self,
|
||||||
ctx: &StrategyContext<'_>,
|
ctx: &StrategyContext<'_>,
|
||||||
@@ -6217,9 +6765,9 @@ impl PlatformExprStrategy {
|
|||||||
matches!(
|
matches!(
|
||||||
error,
|
error,
|
||||||
BacktestError::Execution(message)
|
BacktestError::Execution(message)
|
||||||
if message.starts_with("missing rolling mean for field ")
|
if message.contains("missing rolling mean for field ")
|
||||||
|| message.starts_with("missing current rolling mean for field ")
|
|| message.contains("missing current rolling mean for field ")
|
||||||
|| message.starts_with("missing current rolling values for field ")
|
|| message.contains("missing current rolling values for field ")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6601,6 +7149,12 @@ impl PlatformExprStrategy {
|
|||||||
stock: Option<&StockExpressionState>,
|
stock: Option<&StockExpressionState>,
|
||||||
position: Option<&PositionExpressionState>,
|
position: Option<&PositionExpressionState>,
|
||||||
) -> Result<f64, BacktestError> {
|
) -> Result<f64, BacktestError> {
|
||||||
|
if let Some(value) = self.eval_numeric_vm(ctx, expr, day, stock, position)? {
|
||||||
|
return match value {
|
||||||
|
NumericVmValue::Number(number) => Ok(number),
|
||||||
|
NumericVmValue::Boolean(boolean) => Ok(if boolean { 1.0 } else { 0.0 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
let value = self.eval_dynamic(ctx, expr, day, stock, position)?;
|
let value = self.eval_dynamic(ctx, expr, day, stock, position)?;
|
||||||
if let Some(number) = value.clone().try_cast::<f64>() {
|
if let Some(number) = value.clone().try_cast::<f64>() {
|
||||||
return Ok(number);
|
return Ok(number);
|
||||||
@@ -6625,6 +7179,12 @@ impl PlatformExprStrategy {
|
|||||||
stock: Option<&StockExpressionState>,
|
stock: Option<&StockExpressionState>,
|
||||||
position: Option<&PositionExpressionState>,
|
position: Option<&PositionExpressionState>,
|
||||||
) -> Result<bool, BacktestError> {
|
) -> Result<bool, BacktestError> {
|
||||||
|
if let Some(value) = self.eval_numeric_vm(ctx, expr, day, stock, position)? {
|
||||||
|
return match value {
|
||||||
|
NumericVmValue::Boolean(boolean) => Ok(boolean),
|
||||||
|
NumericVmValue::Number(number) => Ok(number != 0.0),
|
||||||
|
};
|
||||||
|
}
|
||||||
let value = self.eval_dynamic(ctx, expr, day, stock, position)?;
|
let value = self.eval_dynamic(ctx, expr, day, stock, position)?;
|
||||||
if let Some(boolean) = value.clone().try_cast::<bool>() {
|
if let Some(boolean) = value.clone().try_cast::<bool>() {
|
||||||
return Ok(boolean);
|
return Ok(boolean);
|
||||||
@@ -31365,7 +31925,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ast_cache_reuses_compiled_ast_across_invocations() {
|
fn numeric_vm_reuses_compiled_program_across_invocations() {
|
||||||
let date = d(2025, 2, 3);
|
let date = d(2025, 2, 3);
|
||||||
let data = DataSet::from_components(
|
let data = DataSet::from_components(
|
||||||
vec![Instrument {
|
vec![Instrument {
|
||||||
@@ -31468,30 +32028,23 @@ mod tests {
|
|||||||
}];
|
}];
|
||||||
let mut strategy = PlatformExprStrategy::new(cfg);
|
let mut strategy = PlatformExprStrategy::new(cfg);
|
||||||
|
|
||||||
// 第一次调用:所有表达式 cache miss。
|
|
||||||
let _ = strategy.on_day(&ctx).expect("first decision");
|
let _ = strategy.on_day(&ctx).expect("first decision");
|
||||||
let misses_after_first = strategy.ast_cache_misses();
|
let vm_hits_after_first = strategy.numeric_vm_hits();
|
||||||
let hits_after_first = strategy.ast_cache_hits();
|
let vm_fallbacks_after_first = strategy.numeric_vm_fallbacks();
|
||||||
assert!(
|
assert!(
|
||||||
misses_after_first > 0,
|
vm_hits_after_first > 0,
|
||||||
"first run should populate cache, misses={}",
|
"first run should execute compiled numeric expressions"
|
||||||
misses_after_first
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 第二次调用:相同表达式,cache hit 数应当 > 第一次。
|
|
||||||
let _ = strategy.on_day(&ctx).expect("second decision");
|
let _ = strategy.on_day(&ctx).expect("second decision");
|
||||||
let misses_after_second = strategy.ast_cache_misses();
|
|
||||||
let hits_after_second = strategy.ast_cache_hits();
|
|
||||||
assert!(
|
assert!(
|
||||||
hits_after_second > hits_after_first,
|
strategy.numeric_vm_hits() > vm_hits_after_first,
|
||||||
"second run should reuse cached AST, hits {} -> {}",
|
"second run should reuse the compiled numeric VM plan"
|
||||||
hits_after_first,
|
|
||||||
hits_after_second
|
|
||||||
);
|
);
|
||||||
// 缓存条目数不应该再增长(相同 script):misses 不再增加。
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
misses_after_second, misses_after_first,
|
strategy.numeric_vm_fallbacks(),
|
||||||
"second run should not introduce new misses for same scripts"
|
vm_fallbacks_after_first,
|
||||||
|
"supported numeric expressions must not fall back to Rhai"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31574,7 +32127,54 @@ fn passes_threshold(value) { value > stock_threshold }
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ast_cache_reuses_rolling_helper_scripts_across_dates() {
|
fn strategy105_numeric_expressions_compile_to_vm() {
|
||||||
|
let mut config = PlatformExprStrategyConfig::microcap_rotation();
|
||||||
|
config.prelude = r#"
|
||||||
|
let csi_close = rolling_mean_current("signal_close", 1);
|
||||||
|
let csi_ma10 = rolling_mean_current("signal_close", 10);
|
||||||
|
let csi_ma30 = rolling_mean_current("signal_close", 30);
|
||||||
|
let csi_vol20 = rolling_return_stddev_current("signal_close", 20);
|
||||||
|
let csi_high60 = rolling_max_current("signal_close", 60);
|
||||||
|
let csi_mean60 = rolling_mean_current("signal_close", 60);
|
||||||
|
let csi_drawdown60 = csi_high60 > 0.0 ? 1.0 - csi_close / csi_high60 : 0.0;
|
||||||
|
let csi_ready = csi_close > 0.0 && csi_ma10 > 0.0 && csi_ma30 > 0.0 && csi_high60 > 0.0 && csi_mean60 > 0.0;
|
||||||
|
let csi_clamped = clamp(csi_close, 2000.0, 3000.0);
|
||||||
|
let csi_t = (csi_clamped - 2000.0) / 1000.0;
|
||||||
|
let lower_market_cap = csi_ready ? 12.0 + csi_t * 5.0 : 1000000000.0;
|
||||||
|
let upper_market_cap = csi_ready ? 40.0 + csi_t * 5.0 : 0.0;
|
||||||
|
let base_exposure = csi_ma10 > csi_ma30 ? 1.0 : 0.3;
|
||||||
|
let volatility_exposure = csi_vol20 >= 0.025 ? min(base_exposure, 0.3) : base_exposure;
|
||||||
|
let dynamic_exposure = csi_drawdown60 >= 0.08 ? min(volatility_exposure, 0.2) : volatility_exposure;
|
||||||
|
let target_exposure = csi_ready ? dynamic_exposure : 0.0;
|
||||||
|
"#
|
||||||
|
.to_string();
|
||||||
|
let strategy = PlatformExprStrategy::new(config);
|
||||||
|
for expression in [
|
||||||
|
"lower_market_cap",
|
||||||
|
"upper_market_cap",
|
||||||
|
"target_exposure",
|
||||||
|
"30.0 / 31.0",
|
||||||
|
"!is_st && !is_star_st && !is_kcb && !is_bjse && rolling_mean_current(\"close\", 5) > rolling_mean_current(\"close\", 10) && rolling_mean_current(\"close\", 10) > rolling_mean_current(\"close\", 30) && rolling_mean_current(\"volume\", 5) < rolling_mean_current(\"volume\", 100)",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
strategy
|
||||||
|
.expression_eval_plan(expression)
|
||||||
|
.numeric_vm
|
||||||
|
.is_some(),
|
||||||
|
"expression should compile to numeric VM: {expression}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
strategy
|
||||||
|
.expression_eval_plan("contains(symbol, \"SZ\")")
|
||||||
|
.numeric_vm
|
||||||
|
.is_none(),
|
||||||
|
"string expressions must remain on the Rhai path"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn numeric_vm_reuses_rolling_helper_program_across_dates() {
|
||||||
let dates = [d(2025, 2, 3), d(2025, 2, 4)];
|
let dates = [d(2025, 2, 3), d(2025, 2, 4)];
|
||||||
let data = DataSet::from_components(
|
let data = DataSet::from_components(
|
||||||
vec![Instrument {
|
vec![Instrument {
|
||||||
@@ -31680,7 +32280,8 @@ fn passes_threshold(value) { value > stock_threshold }
|
|||||||
}];
|
}];
|
||||||
let mut strategy = PlatformExprStrategy::new(cfg);
|
let mut strategy = PlatformExprStrategy::new(cfg);
|
||||||
|
|
||||||
let mut misses_after_first = 0;
|
let mut vm_hits_after_first = 0;
|
||||||
|
let mut vm_fallbacks_after_first = 0;
|
||||||
for (index, date) in dates.iter().enumerate() {
|
for (index, date) in dates.iter().enumerate() {
|
||||||
let ctx = StrategyContext {
|
let ctx = StrategyContext {
|
||||||
execution_date: *date,
|
execution_date: *date,
|
||||||
@@ -31700,18 +32301,19 @@ fn passes_threshold(value) { value > stock_threshold }
|
|||||||
};
|
};
|
||||||
let _ = strategy.on_day(&ctx).expect("platform decision");
|
let _ = strategy.on_day(&ctx).expect("platform decision");
|
||||||
if index == 0 {
|
if index == 0 {
|
||||||
misses_after_first = strategy.ast_cache_misses();
|
vm_hits_after_first = strategy.numeric_vm_hits();
|
||||||
|
vm_fallbacks_after_first = strategy.numeric_vm_fallbacks();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
strategy.ast_cache_hits() > 0,
|
strategy.numeric_vm_hits() > vm_hits_after_first,
|
||||||
"second date should reuse helper-expanded scripts"
|
"second date should reuse the helper-slot VM program"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
strategy.ast_cache_misses(),
|
strategy.numeric_vm_fallbacks(),
|
||||||
misses_after_first,
|
vm_fallbacks_after_first,
|
||||||
"rolling helper values must not change cached script identity across dates"
|
"rolling helper values must not force a Rhai fallback"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user