Merge remote-tracking branch 'refs/remotes/bundle/main'
This commit is contained in:
@@ -2388,6 +2388,19 @@ where
|
|||||||
.sum()
|
.sum()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reserved_open_buy_quantity(&self, symbol: &str, exclude_order_id: Option<u64>) -> u32 {
|
||||||
|
self.open_orders
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.filter(|order| {
|
||||||
|
order.side == OrderSide::Buy
|
||||||
|
&& order.symbol == symbol
|
||||||
|
&& exclude_order_id.is_none_or(|order_id| order.order_id != order_id)
|
||||||
|
})
|
||||||
|
.map(|order| order.remaining_quantity)
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
fn process_open_orders(
|
fn process_open_orders(
|
||||||
&self,
|
&self,
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
@@ -4251,6 +4264,49 @@ where
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let size_check_price = limit_price.unwrap_or_else(|| {
|
||||||
|
self.execution_order_limit_check_price(
|
||||||
|
date,
|
||||||
|
data,
|
||||||
|
symbol,
|
||||||
|
snapshot,
|
||||||
|
OrderSide::Sell,
|
||||||
|
algo_request,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if let Some(rule_reason) = ChinaAShareRiskControl::order_size_rejection_reason_with_config(
|
||||||
|
OrderSide::Sell,
|
||||||
|
requested_qty,
|
||||||
|
position.quantity,
|
||||||
|
size_check_price,
|
||||||
|
&self.risk_config,
|
||||||
|
) {
|
||||||
|
report.order_events.push(OrderEvent {
|
||||||
|
date,
|
||||||
|
decision_date: None,
|
||||||
|
order_created_date: None,
|
||||||
|
execution_date: None,
|
||||||
|
order_id: Some(order_id),
|
||||||
|
symbol: symbol.to_string(),
|
||||||
|
side: OrderSide::Sell,
|
||||||
|
requested_quantity: requested_qty,
|
||||||
|
filled_quantity: 0,
|
||||||
|
status: OrderStatus::Rejected,
|
||||||
|
reason: format!("{reason}: {rule_reason}"),
|
||||||
|
});
|
||||||
|
Self::emit_order_process_event(
|
||||||
|
report,
|
||||||
|
date,
|
||||||
|
Self::creation_reject_kind(emit_creation_events),
|
||||||
|
order_id,
|
||||||
|
symbol,
|
||||||
|
OrderSide::Sell,
|
||||||
|
format!("status=Rejected reason={rule_reason}"),
|
||||||
|
);
|
||||||
|
self.clear_open_order(order_id);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
if emit_creation_events {
|
if emit_creation_events {
|
||||||
Self::emit_order_process_event(
|
Self::emit_order_process_event(
|
||||||
report,
|
report,
|
||||||
@@ -5984,6 +6040,54 @@ where
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let current_position_quantity = portfolio
|
||||||
|
.position(symbol)
|
||||||
|
.map(|position| position.quantity)
|
||||||
|
.unwrap_or(0)
|
||||||
|
.saturating_add(self.reserved_open_buy_quantity(symbol, Some(order_id)));
|
||||||
|
let size_check_price = limit_price.unwrap_or_else(|| {
|
||||||
|
self.execution_order_limit_check_price(
|
||||||
|
date,
|
||||||
|
data,
|
||||||
|
symbol,
|
||||||
|
snapshot,
|
||||||
|
OrderSide::Buy,
|
||||||
|
algo_request,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if let Some(rule_reason) = ChinaAShareRiskControl::order_size_rejection_reason_with_config(
|
||||||
|
OrderSide::Buy,
|
||||||
|
requested_qty,
|
||||||
|
current_position_quantity,
|
||||||
|
size_check_price,
|
||||||
|
&self.risk_config,
|
||||||
|
) {
|
||||||
|
report.order_events.push(OrderEvent {
|
||||||
|
date,
|
||||||
|
decision_date: None,
|
||||||
|
order_created_date: None,
|
||||||
|
execution_date: None,
|
||||||
|
order_id: Some(order_id),
|
||||||
|
symbol: symbol.to_string(),
|
||||||
|
side: OrderSide::Buy,
|
||||||
|
requested_quantity: requested_qty,
|
||||||
|
filled_quantity: 0,
|
||||||
|
status: OrderStatus::Rejected,
|
||||||
|
reason: format!("{reason}: {rule_reason}"),
|
||||||
|
});
|
||||||
|
Self::emit_order_process_event(
|
||||||
|
report,
|
||||||
|
date,
|
||||||
|
Self::creation_reject_kind(emit_creation_events),
|
||||||
|
order_id,
|
||||||
|
symbol,
|
||||||
|
OrderSide::Buy,
|
||||||
|
format!("status=Rejected reason={rule_reason}"),
|
||||||
|
);
|
||||||
|
self.clear_open_order(order_id);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
if emit_creation_events {
|
if emit_creation_events {
|
||||||
Self::emit_order_process_event(
|
Self::emit_order_process_event(
|
||||||
report,
|
report,
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ pub use platform_strategy_spec::{
|
|||||||
StrategyRebalanceSpec, StrategyRiskPolicySpec, StrategyRuntimeEnvironment,
|
StrategyRebalanceSpec, StrategyRiskPolicySpec, StrategyRuntimeEnvironment,
|
||||||
StrategyRuntimeExpressions, StrategyRuntimeSpec, StrategyUniverseSpec,
|
StrategyRuntimeExpressions, StrategyRuntimeSpec, StrategyUniverseSpec,
|
||||||
platform_expr_config_from_spec, platform_expr_config_from_value,
|
platform_expr_config_from_spec, platform_expr_config_from_value,
|
||||||
|
validate_strategy_risk_policy_fields,
|
||||||
};
|
};
|
||||||
pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position};
|
pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position};
|
||||||
pub use risk_control::{
|
pub use risk_control::{
|
||||||
|
|||||||
@@ -268,6 +268,12 @@ pub struct StrategyEngineConfig {
|
|||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct StrategyRiskPolicySpec {
|
pub struct StrategyRiskPolicySpec {
|
||||||
|
#[serde(default, alias = "max_order_quantity", alias = "maxOrderQuantity")]
|
||||||
|
pub max_order_quantity: Option<f64>,
|
||||||
|
#[serde(default, alias = "max_order_notional", alias = "maxOrderNotional")]
|
||||||
|
pub max_order_notional: Option<f64>,
|
||||||
|
#[serde(default, alias = "max_symbol_position", alias = "maxSymbolPosition")]
|
||||||
|
pub max_symbol_position: Option<f64>,
|
||||||
#[serde(default, alias = "reject_st_selection", alias = "rejectStSelection")]
|
#[serde(default, alias = "reject_st_selection", alias = "rejectStSelection")]
|
||||||
pub reject_st_selection: Option<bool>,
|
pub reject_st_selection: Option<bool>,
|
||||||
#[serde(default, alias = "reject_st_buy", alias = "rejectStBuy")]
|
#[serde(default, alias = "reject_st_buy", alias = "rejectStBuy")]
|
||||||
@@ -412,6 +418,9 @@ const RISK_POLICY_BOOL_ALIAS_GROUPS: &[(&str, &[&str])] = &[
|
|||||||
];
|
];
|
||||||
|
|
||||||
const RISK_POLICY_VALUE_ALIAS_GROUPS: &[(&str, &[&str])] = &[
|
const RISK_POLICY_VALUE_ALIAS_GROUPS: &[(&str, &[&str])] = &[
|
||||||
|
("maxOrderQuantity", &["max_order_quantity"]),
|
||||||
|
("maxOrderNotional", &["max_order_notional"]),
|
||||||
|
("maxSymbolPosition", &["max_symbol_position"]),
|
||||||
("volumePercent", &["volume_percent"]),
|
("volumePercent", &["volume_percent"]),
|
||||||
("commissionRate", &["commission_rate"]),
|
("commissionRate", &["commission_rate"]),
|
||||||
(
|
(
|
||||||
@@ -1042,6 +1051,7 @@ pub fn platform_expr_config_from_value(
|
|||||||
.map_err(platform_config_error);
|
.map_err(platform_config_error);
|
||||||
}
|
}
|
||||||
reject_removed_compatibility_fields(value).map_err(platform_config_error)?;
|
reject_removed_compatibility_fields(value).map_err(platform_config_error)?;
|
||||||
|
validate_strategy_risk_policy_fields(value).map_err(platform_config_error)?;
|
||||||
let mut value = value.clone();
|
let mut value = value.clone();
|
||||||
normalize_strategy_aliases_in_value(&mut value).map_err(platform_config_error)?;
|
normalize_strategy_aliases_in_value(&mut value).map_err(platform_config_error)?;
|
||||||
normalize_risk_policy_aliases_in_value(&mut value).map_err(platform_config_error)?;
|
normalize_risk_policy_aliases_in_value(&mut value).map_err(platform_config_error)?;
|
||||||
@@ -1050,6 +1060,69 @@ pub fn platform_expr_config_from_value(
|
|||||||
.map_err(platform_config_error)
|
.map_err(platform_config_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reject misspelled or unsupported fields inside a strategy risk policy.
|
||||||
|
/// Generic JSON deserialization otherwise ignores unknown keys and makes a
|
||||||
|
/// strategy appear protected while silently using the process default.
|
||||||
|
pub fn validate_strategy_risk_policy_fields(value: &Value) -> Result<(), String> {
|
||||||
|
let mut allowed = BTreeSet::<&str>::new();
|
||||||
|
for (canonical, aliases) in RISK_POLICY_BOOL_ALIAS_GROUPS
|
||||||
|
.iter()
|
||||||
|
.chain(RISK_POLICY_VALUE_ALIAS_GROUPS.iter())
|
||||||
|
{
|
||||||
|
allowed.insert(*canonical);
|
||||||
|
for key in *aliases {
|
||||||
|
allowed.insert(*key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key in [
|
||||||
|
"blacklistedSymbols",
|
||||||
|
"blacklisted_symbols",
|
||||||
|
"blacklistedInstruments",
|
||||||
|
"blacklisted_instruments",
|
||||||
|
"blacklist",
|
||||||
|
// Legacy execution aliases are accepted by StrategyExecutionSpec and
|
||||||
|
// normalized into the same shared switches.
|
||||||
|
"volumeLimit",
|
||||||
|
"volume_limit",
|
||||||
|
"liquidityLimit",
|
||||||
|
"liquidity_limit",
|
||||||
|
] {
|
||||||
|
allowed.insert(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk(value: &Value, allowed: &BTreeSet<&str>, path: &str) -> Result<(), String> {
|
||||||
|
let Some(object) = value.as_object() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
for (key, child) in object {
|
||||||
|
let child_path = if path.is_empty() {
|
||||||
|
key.clone()
|
||||||
|
} else {
|
||||||
|
format!("{path}.{key}")
|
||||||
|
};
|
||||||
|
if matches!(key.as_str(), "riskPolicy" | "risk_policy") {
|
||||||
|
if child.is_null() {
|
||||||
|
// Typed specs serialize an omitted Option policy as null;
|
||||||
|
// that is equivalent to an absent strategy policy.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(policy) = child.as_object() else {
|
||||||
|
return Err(format!("{child_path} must be a JSON object"));
|
||||||
|
};
|
||||||
|
for policy_key in policy.keys() {
|
||||||
|
if !allowed.contains(policy_key.as_str()) {
|
||||||
|
return Err(format!("unsupported riskPolicy field: {policy_key}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(child, allowed, &child_path)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(value, &allowed, "")
|
||||||
|
}
|
||||||
|
|
||||||
fn reject_removed_compatibility_fields(value: &Value) -> Result<(), String> {
|
fn reject_removed_compatibility_fields(value: &Value) -> Result<(), String> {
|
||||||
const SECTION_NAMES: [&str; 3] = ["engineConfig", "engine_config", "execution"];
|
const SECTION_NAMES: [&str; 3] = ["engineConfig", "engine_config", "execution"];
|
||||||
const FIELD_NAMES: [&str; 4] = [
|
const FIELD_NAMES: [&str; 4] = [
|
||||||
@@ -1095,6 +1168,14 @@ fn valid_non_negative(value: Option<f64>) -> Option<f64> {
|
|||||||
value.filter(|item| item.is_finite() && *item >= 0.0)
|
value.filter(|item| item.is_finite() && *item >= 0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn valid_positive_limit(value: Option<f64>, field_name: &str) -> Result<Option<f64>, String> {
|
||||||
|
match value {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(item) if item.is_finite() && item > 0.0 => Ok(Some(item)),
|
||||||
|
Some(_) => Err(format!("{field_name} must be a positive finite number")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_percent_ratio(value: f64, field_name: &str) -> Result<f64, String> {
|
fn normalize_percent_ratio(value: f64, field_name: &str) -> Result<f64, String> {
|
||||||
if !value.is_finite() || value <= 0.0 {
|
if !value.is_finite() || value <= 0.0 {
|
||||||
return Err(format!("{field_name} must be a positive finite number"));
|
return Err(format!("{field_name} must be a positive finite number"));
|
||||||
@@ -1214,6 +1295,27 @@ fn apply_risk_policy_overrides(
|
|||||||
let Some(policy) = policy else {
|
let Some(policy) = policy else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
let max_order_quantity = valid_positive_limit(
|
||||||
|
policy.max_order_quantity,
|
||||||
|
"riskPolicy.maxOrderQuantity",
|
||||||
|
)?;
|
||||||
|
let max_order_notional = valid_positive_limit(
|
||||||
|
policy.max_order_notional,
|
||||||
|
"riskPolicy.maxOrderNotional",
|
||||||
|
)?;
|
||||||
|
let max_symbol_position = valid_positive_limit(
|
||||||
|
policy.max_symbol_position,
|
||||||
|
"riskPolicy.maxSymbolPosition",
|
||||||
|
)?;
|
||||||
|
if let Some(value) = max_order_quantity {
|
||||||
|
cfg.risk_config.trading_constraints.max_order_quantity = value;
|
||||||
|
}
|
||||||
|
if let Some(value) = max_order_notional {
|
||||||
|
cfg.risk_config.trading_constraints.max_order_notional = value;
|
||||||
|
}
|
||||||
|
if let Some(value) = max_symbol_position {
|
||||||
|
cfg.risk_config.trading_constraints.max_symbol_position = value;
|
||||||
|
}
|
||||||
let static_rules = &mut cfg.risk_config.static_rules;
|
let static_rules = &mut cfg.risk_config.static_rules;
|
||||||
if let Some(value) = policy.reject_st_selection {
|
if let Some(value) = policy.reject_st_selection {
|
||||||
static_rules.reject_st_selection = value;
|
static_rules.reject_st_selection = value;
|
||||||
@@ -3287,6 +3389,9 @@ mod tests {
|
|||||||
"rejectUpperLimitBuy": false,
|
"rejectUpperLimitBuy": false,
|
||||||
"rejectLowerLimitSell": false,
|
"rejectLowerLimitSell": false,
|
||||||
"forbidSameDayRebuyAfterSell": true,
|
"forbidSameDayRebuyAfterSell": true,
|
||||||
|
"maxOrderQuantity": 8000,
|
||||||
|
"maxOrderNotional": 2000000,
|
||||||
|
"maxSymbolPosition": 12000,
|
||||||
"blacklist": [" 600000.SH ", ""],
|
"blacklist": [" 600000.SH ", ""],
|
||||||
"volumeLimitEnabled": true,
|
"volumeLimitEnabled": true,
|
||||||
"volumePercent": 0.1,
|
"volumePercent": 0.1,
|
||||||
@@ -3328,6 +3433,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(cfg.risk_config.trading_constraints.volume_limit_enabled);
|
assert!(cfg.risk_config.trading_constraints.volume_limit_enabled);
|
||||||
assert!(cfg.risk_config.trading_constraints.liquidity_limit_enabled);
|
assert!(cfg.risk_config.trading_constraints.liquidity_limit_enabled);
|
||||||
|
assert_eq!(cfg.risk_config.trading_constraints.max_order_quantity, 8000.0);
|
||||||
|
assert_eq!(cfg.risk_config.trading_constraints.max_order_notional, 2_000_000.0);
|
||||||
|
assert_eq!(cfg.risk_config.trading_constraints.max_symbol_position, 12_000.0);
|
||||||
assert!((cfg.risk_config.trading_constraints.volume_percent - 0.25).abs() < 1e-12);
|
assert!((cfg.risk_config.trading_constraints.volume_percent - 0.25).abs() < 1e-12);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.risk_config
|
cfg.risk_config
|
||||||
@@ -3350,6 +3458,22 @@ mod tests {
|
|||||||
assert!(cfg.quote_quantity_limit);
|
assert!(cfg.quote_quantity_limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_positive_shared_order_limits() {
|
||||||
|
for (field, value) in [
|
||||||
|
("maxOrderQuantity", 0.0),
|
||||||
|
("maxOrderNotional", -1.0),
|
||||||
|
("maxSymbolPosition", -0.5),
|
||||||
|
] {
|
||||||
|
let spec = serde_json::json!({
|
||||||
|
"execution": { "riskPolicy": { field: value } }
|
||||||
|
});
|
||||||
|
let error = platform_expr_config_from_value("", "", &spec)
|
||||||
|
.expect_err("invalid shared order limit must fail");
|
||||||
|
assert!(error.to_string().contains("riskPolicy"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn volume_limit_does_not_enable_quote_quantity_limit_for_minute_last() {
|
fn volume_limit_does_not_enable_quote_quantity_limit_for_minute_last() {
|
||||||
let spec = serde_json::json!({
|
let spec = serde_json::json!({
|
||||||
@@ -3482,6 +3606,21 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unknown_strategy_risk_policy_fields_before_deserialization() {
|
||||||
|
let error = validate_strategy_risk_policy_fields(&serde_json::json!({
|
||||||
|
"execution": {
|
||||||
|
"riskPolicy": {"rejectStBuy": true, "typoRiskSwitch": false}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.expect_err("unsupported risk policy fields must fail closed");
|
||||||
|
assert!(error.contains("typoRiskSwitch"), "{error}");
|
||||||
|
validate_strategy_risk_policy_fields(&serde_json::json!({
|
||||||
|
"execution": {"riskPolicy": {"maxOrderQuantity": 1000}}
|
||||||
|
}))
|
||||||
|
.expect("supported strategy risk fields should pass");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn accepts_equivalent_risk_policy_alias_values() {
|
fn accepts_equivalent_risk_policy_alias_values() {
|
||||||
let spec = serde_json::json!({
|
let spec = serde_json::json!({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use crate::data::{CandidateEligibility, DailyMarketSnapshot, PriceField};
|
use crate::data::{CandidateEligibility, DailyMarketSnapshot, PriceField};
|
||||||
use crate::instrument::Instrument;
|
use crate::instrument::Instrument;
|
||||||
use crate::portfolio::Position;
|
use crate::portfolio::Position;
|
||||||
|
use crate::OrderSide;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
pub struct ChinaAShareRiskControl;
|
pub struct ChinaAShareRiskControl;
|
||||||
@@ -77,6 +78,13 @@ impl Default for StaticRiskRuleConfig {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct TradingConstraintConfig {
|
pub struct TradingConstraintConfig {
|
||||||
|
/// Shared execution limits. These fields intentionally use the same
|
||||||
|
/// names and defaults as the FIDC trading-core RiskLimits contract so a
|
||||||
|
/// strategy cannot appear protected in paper/live while being unlimited
|
||||||
|
/// in a backtest.
|
||||||
|
pub max_order_quantity: f64,
|
||||||
|
pub max_order_notional: f64,
|
||||||
|
pub max_symbol_position: f64,
|
||||||
pub volume_limit_enabled: bool,
|
pub volume_limit_enabled: bool,
|
||||||
pub volume_percent: f64,
|
pub volume_percent: f64,
|
||||||
pub liquidity_limit_enabled: bool,
|
pub liquidity_limit_enabled: bool,
|
||||||
@@ -91,6 +99,9 @@ pub struct TradingConstraintConfig {
|
|||||||
impl Default for TradingConstraintConfig {
|
impl Default for TradingConstraintConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
max_order_quantity: 1_000_000.0,
|
||||||
|
max_order_notional: 100_000_000.0,
|
||||||
|
max_symbol_position: 10_000_000.0,
|
||||||
volume_limit_enabled: true,
|
volume_limit_enabled: true,
|
||||||
volume_percent: 0.25,
|
volume_percent: 0.25,
|
||||||
liquidity_limit_enabled: true,
|
liquidity_limit_enabled: true,
|
||||||
@@ -479,6 +490,36 @@ impl ChinaAShareRiskControl {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply the shared quantity/notional/position limits at the same stage
|
||||||
|
/// as paper/live `RiskLimits`. Static instrument rules remain in the
|
||||||
|
/// side-specific methods above; this helper only checks order sizing and
|
||||||
|
/// never changes selection semantics.
|
||||||
|
pub fn order_size_rejection_reason_with_config(
|
||||||
|
side: OrderSide,
|
||||||
|
requested_quantity: u32,
|
||||||
|
current_position_quantity: u32,
|
||||||
|
check_price: f64,
|
||||||
|
config: &FidcRiskControlConfig,
|
||||||
|
) -> Option<&'static str> {
|
||||||
|
let limits = &config.trading_constraints;
|
||||||
|
if (requested_quantity as f64) > limits.max_order_quantity {
|
||||||
|
return Some("quantity exceeds max_order_quantity");
|
||||||
|
}
|
||||||
|
if check_price.is_finite()
|
||||||
|
&& check_price > 0.0
|
||||||
|
&& (requested_quantity as f64) * check_price > limits.max_order_notional
|
||||||
|
{
|
||||||
|
return Some("notional exceeds max_order_notional");
|
||||||
|
}
|
||||||
|
if side == OrderSide::Buy
|
||||||
|
&& (current_position_quantity as f64) + (requested_quantity as f64)
|
||||||
|
> limits.max_symbol_position
|
||||||
|
{
|
||||||
|
return Some("position exceeds max_symbol_position");
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
pub fn sell_rejection_reason(
|
pub fn sell_rejection_reason(
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
candidate: &CandidateEligibility,
|
candidate: &CandidateEligibility,
|
||||||
@@ -1426,4 +1467,53 @@ mod tests {
|
|||||||
assert_eq!(enabled_reason, Some("lower_limit"));
|
assert_eq!(enabled_reason, Some("lower_limit"));
|
||||||
assert_eq!(configured_reason, None);
|
assert_eq!(configured_reason, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_order_size_limits_apply_to_both_sides_and_buy_position() {
|
||||||
|
let mut config = FidcRiskControlConfig::default();
|
||||||
|
config.trading_constraints.max_order_quantity = 500.0;
|
||||||
|
config.trading_constraints.max_order_notional = 5_000.0;
|
||||||
|
config.trading_constraints.max_symbol_position = 800.0;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ChinaAShareRiskControl::order_size_rejection_reason_with_config(
|
||||||
|
OrderSide::Buy,
|
||||||
|
600,
|
||||||
|
0,
|
||||||
|
5.0,
|
||||||
|
&config,
|
||||||
|
),
|
||||||
|
Some("quantity exceeds max_order_quantity")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ChinaAShareRiskControl::order_size_rejection_reason_with_config(
|
||||||
|
OrderSide::Sell,
|
||||||
|
400,
|
||||||
|
10_000,
|
||||||
|
20.0,
|
||||||
|
&config,
|
||||||
|
),
|
||||||
|
Some("notional exceeds max_order_notional")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ChinaAShareRiskControl::order_size_rejection_reason_with_config(
|
||||||
|
OrderSide::Buy,
|
||||||
|
300,
|
||||||
|
600,
|
||||||
|
5.0,
|
||||||
|
&config,
|
||||||
|
),
|
||||||
|
Some("position exceeds max_symbol_position")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ChinaAShareRiskControl::order_size_rejection_reason_with_config(
|
||||||
|
OrderSide::Sell,
|
||||||
|
200,
|
||||||
|
10_000,
|
||||||
|
5.0,
|
||||||
|
&config,
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,8 +119,8 @@ pub struct StrategyAiOptimizeRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PERFORMANCE_ACCEPTANCE_CONTRACT_PROMPT: &str = "收益验收合同:收益、回撤、年度收益、样本外区间及比较运算符只能来自用户目标、请求约束或不可变 candidate/promotion contract;不得注入 120% 或其他默认数值,也不得提高、降低或替换已经明确的门槛。没有明确数值合同时只做策略有效性、数据时序和风险审计,禁止声称收益已经达标;存在冻结合同时必须逐项按原运算符验证,不能只看总收益。";
|
const PERFORMANCE_ACCEPTANCE_CONTRACT_PROMPT: &str = "收益验收合同:收益、回撤、年度收益、样本外区间及比较运算符只能来自用户目标、请求约束或不可变 candidate/promotion contract;不得注入 120% 或其他默认数值,也不得提高、降低或替换已经明确的门槛。没有明确数值合同时只做策略有效性、数据时序和风险审计,禁止声称收益已经达标;存在冻结合同时必须逐项按原运算符验证,不能只看总收益。";
|
||||||
const DEFAULT_RISK_POLICY_DSL_PROMPT: &str = "reject_st_selection=false、reject_st_buy=true、reject_star_st_selection=false、reject_star_st_buy=true、reject_paused_selection=false、reject_paused_buy=true、reject_paused_sell=true、reject_inactive_selection=false、reject_inactive_buy=true、reject_inactive_sell=true、reject_new_listing_selection=false、reject_new_listing_buy=true、reject_kcb_selection=false、reject_kcb_buy=true、reject_bjse_selection=false、reject_bjse_buy=true、reject_one_yuan_selection=false、reject_one_yuan_buy=true、respect_allow_buy_sell=true、reject_upper_limit_selection=false、reject_lower_limit_selection=false、reject_upper_limit_buy=true、reject_lower_limit_sell=true、forbid_same_day_rebuy_after_sell=true、blacklist_enabled=true、allow_market_orders=true、live_trading_enabled=false、volume_limit_enabled=true、liquidity_limit_enabled=true、volume_percent=0.25、commission_rate=0.0003、minimum_commission=5、stamp_tax_rate_before_change=0.001、stamp_tax_rate_after_change=0.0005、stamp_tax_change_date=\"2023-08-28\"";
|
const DEFAULT_RISK_POLICY_DSL_PROMPT: &str = "max_order_quantity=1000000、max_order_notional=100000000、max_symbol_position=10000000、reject_st_selection=false、reject_st_buy=true、reject_star_st_selection=false、reject_star_st_buy=true、reject_paused_selection=false、reject_paused_buy=true、reject_paused_sell=true、reject_inactive_selection=false、reject_inactive_buy=true、reject_inactive_sell=true、reject_new_listing_selection=false、reject_new_listing_buy=true、reject_kcb_selection=false、reject_kcb_buy=true、reject_bjse_selection=false、reject_bjse_buy=true、reject_one_yuan_selection=false、reject_one_yuan_buy=true、respect_allow_buy_sell=true、reject_upper_limit_selection=false、reject_lower_limit_selection=false、reject_upper_limit_buy=true、reject_lower_limit_sell=true、forbid_same_day_rebuy_after_sell=true、blacklist_enabled=true、allow_market_orders=true、live_trading_enabled=false、volume_limit_enabled=true、liquidity_limit_enabled=true、volume_percent=0.25、commission_rate=0.0003、minimum_commission=5、stamp_tax_rate_before_change=0.001、stamp_tax_rate_after_change=0.0005、stamp_tax_change_date=\"2023-08-28\"";
|
||||||
const DEFAULT_RISK_POLICY_DSL_CODE: &str = "reject_st_selection=false, reject_st_buy=true, reject_star_st_selection=false, reject_star_st_buy=true, reject_paused_selection=false, reject_paused_buy=true, reject_paused_sell=true, reject_inactive_selection=false, reject_inactive_buy=true, reject_inactive_sell=true, reject_new_listing_selection=false, reject_new_listing_buy=true, reject_kcb_selection=false, reject_kcb_buy=true, reject_bjse_selection=false, reject_bjse_buy=true, reject_one_yuan_selection=false, reject_one_yuan_buy=true, respect_allow_buy_sell=true, reject_upper_limit_selection=false, reject_lower_limit_selection=false, reject_upper_limit_buy=true, reject_lower_limit_sell=true, forbid_same_day_rebuy_after_sell=true, blacklist_enabled=true, allow_market_orders=true, live_trading_enabled=false, volume_limit_enabled=true, liquidity_limit_enabled=true, volume_percent=0.25, commission_rate=0.0003, minimum_commission=5, stamp_tax_rate_before_change=0.001, stamp_tax_rate_after_change=0.0005, stamp_tax_change_date=\"2023-08-28\"";
|
const DEFAULT_RISK_POLICY_DSL_CODE: &str = "max_order_quantity=1000000, max_order_notional=100000000, max_symbol_position=10000000, reject_st_selection=false, reject_st_buy=true, reject_star_st_selection=false, reject_star_st_buy=true, reject_paused_selection=false, reject_paused_buy=true, reject_paused_sell=true, reject_inactive_selection=false, reject_inactive_buy=true, reject_inactive_sell=true, reject_new_listing_selection=false, reject_new_listing_buy=true, reject_kcb_selection=false, reject_kcb_buy=true, reject_bjse_selection=false, reject_bjse_buy=true, reject_one_yuan_selection=false, reject_one_yuan_buy=true, respect_allow_buy_sell=true, reject_upper_limit_selection=false, reject_lower_limit_selection=false, reject_upper_limit_buy=true, reject_lower_limit_sell=true, forbid_same_day_rebuy_after_sell=true, blacklist_enabled=true, allow_market_orders=true, live_trading_enabled=false, volume_limit_enabled=true, liquidity_limit_enabled=true, volume_percent=0.25, commission_rate=0.0003, minimum_commission=5, stamp_tax_rate_before_change=0.001, stamp_tax_rate_after_change=0.0005, stamp_tax_change_date=\"2023-08-28\"";
|
||||||
|
|
||||||
pub fn built_in_strategy_manual() -> StrategyAiManual {
|
pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||||
StrategyAiManual {
|
StrategyAiManual {
|
||||||
@@ -250,7 +250,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
|||||||
},
|
},
|
||||||
ManualSection {
|
ManualSection {
|
||||||
title: "risk.policy / risk.blacklist".to_string(),
|
title: "risk.policy / risk.blacklist".to_string(),
|
||||||
detail: "统一配置 FIDC 基础风控。risk.policy(...) 支持 reject_st_selection、reject_st_buy、reject_star_st_selection、reject_star_st_buy、reject_paused_selection、reject_paused_buy、reject_paused_sell、reject_inactive_selection、reject_inactive_buy、reject_inactive_sell、reject_new_listing_selection、reject_new_listing_buy、reject_kcb_selection、reject_kcb_buy、reject_bjse_selection、reject_bjse_buy、reject_one_yuan_selection、reject_one_yuan_buy、respect_allow_buy_sell、reject_upper_limit_selection、reject_lower_limit_selection、reject_upper_limit_buy、reject_lower_limit_sell、forbid_same_day_rebuy_after_sell、blacklist_enabled、allow_market_orders、live_trading_enabled、blacklisted_symbols、volume_limit_enabled、liquidity_limit_enabled、volume_percent、commission_rate、minimum_commission、stamp_tax_rate_before_change、stamp_tax_rate_after_change、stamp_tax_change_date 等命名参数;risk.blacklist([\"600000.SH\"]) 写策略级黑名单。框架默认的 ST、*ST、停牌、退市、科创、北交所、一元、涨跌停、同日卖出禁买、黑名单、成交量和费用等基础风控必须走 risk.policy 或运行态 RiskLimits,不能被转换器隐式写进 universe.exclude 或 filter.stock_expr;但源策略明确写出的业务选股排除属于策略本身,必须原样保留在 filter.stock_expr,并且不能反向修改冻结的 reject_*_selection 开关。PG/Source Lake 是真相源,Redis 只可做当日锁、热配置缓存和配置变更通知。".to_string(),
|
detail: "统一配置 FIDC 基础风控。risk.policy(...) 支持 max_order_quantity、max_order_notional、max_symbol_position,以及 ST/*ST、停牌、退市、新股、科创、北交所、一元、涨跌停、同日卖出禁买、黑名单、成交量、流动性和交易成本等命名参数;risk.blacklist([\"600000.SH\"]) 写策略级黑名单。框架默认基础风控必须走 risk.policy 或运行态 RiskLimits,不能被转换器隐式写进 universe.exclude 或 filter.stock_expr;源策略明确写出的业务选股排除属于策略本身,必须原样保留,不能反向修改冻结的 reject_*_selection 开关;冻结的 `reject_*_selection` 值不得改变。PG/Source Lake 是真相源,Redis 只可做当日锁、热配置缓存和配置变更通知。".to_string(),
|
||||||
},
|
},
|
||||||
ManualSection {
|
ManualSection {
|
||||||
title: "corporate_actions.dividend_reinvestment".to_string(),
|
title: "corporate_actions.dividend_reinvestment".to_string(),
|
||||||
|
|||||||
Reference in New Issue
Block a user