feat: connect portfolio loss to finalized accounting and daily risk clock
This commit is contained in:
+2
-1
@@ -11,6 +11,7 @@ version = "0.1.0"
|
|||||||
authors = ["OpenAI Codex"]
|
authors = ["OpenAI Codex"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
sha2 = "=0.10.9"
|
||||||
ahash = "=0.8.12"
|
ahash = "=0.8.12"
|
||||||
chrono = { version = "=0.4.44", features = ["serde"] }
|
chrono = { version = "=0.4.44", features = ["serde"] }
|
||||||
indexmap = { version = "=2.11.4", features = ["serde"] }
|
indexmap = { version = "=2.11.4", features = ["serde"] }
|
||||||
@@ -18,5 +19,5 @@ reqwest = { version = "=0.12.24", default-features = false, features = ["json",
|
|||||||
rayon = "=1.12.0"
|
rayon = "=1.12.0"
|
||||||
rhai = { version = "=1.23.6", features = ["sync"] }
|
rhai = { version = "=1.23.6", features = ["sync"] }
|
||||||
serde = { version = "=1.0.228", features = ["derive"] }
|
serde = { version = "=1.0.228", features = ["derive"] }
|
||||||
serde_json = "=1.0.145"
|
serde_json = { version = "=1.0.145", features = ["float_roundtrip"] }
|
||||||
thiserror = "=2.0.18"
|
thiserror = "=2.0.18"
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ rayon.workspace = true
|
|||||||
rhai.workspace = true
|
rhai.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ pub use platform_strategy_spec::{
|
|||||||
platform_expr_config_from_value, validate_strategy_risk_policy_fields,
|
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 portfolio_loss::{ClosedPortfolioSession, PortfolioLossConfig, PortfolioLossDecision, PortfolioLossError, PortfolioLossState};
|
||||||
pub use risk_control::{
|
pub use risk_control::{
|
||||||
ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit, RiskCheckScope,
|
ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit, RiskCheckScope,
|
||||||
StaticRiskRuleConfig, TradingConstraintConfig,
|
StaticRiskRuleConfig, TradingConstraintConfig,
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ use std::collections::{BTreeMap, BTreeSet};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use ahash::{AHashMap, AHashSet};
|
use ahash::{AHashMap, AHashSet};
|
||||||
use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime, Timelike};
|
use chrono::{Datelike, Duration, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Timelike, Utc};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use rhai::{AST, Dynamic, Engine, ImmutableString, Map, Scope};
|
use rhai::{AST, Dynamic, Engine, ImmutableString, Map, Scope};
|
||||||
|
|
||||||
use crate::broker::{MatchingType, RebalanceCashMode, SlippageModel};
|
use crate::broker::{MatchingType, RebalanceCashMode, SlippageModel};
|
||||||
@@ -16,7 +17,7 @@ use crate::data::{
|
|||||||
NumericFactorMap, decision_market_cap_bn,
|
NumericFactorMap, decision_market_cap_bn,
|
||||||
};
|
};
|
||||||
use crate::engine::BacktestError;
|
use crate::engine::BacktestError;
|
||||||
use crate::events::OrderSide;
|
use crate::events::{OrderSide, ProcessEvent, ProcessEventKind};
|
||||||
use crate::fixed_point::FixedMoney;
|
use crate::fixed_point::FixedMoney;
|
||||||
use crate::futures::{
|
use crate::futures::{
|
||||||
FuturesContractSpec, FuturesDirection, FuturesOrderIntent, FuturesPositionEffect,
|
FuturesContractSpec, FuturesDirection, FuturesOrderIntent, FuturesPositionEffect,
|
||||||
@@ -26,6 +27,7 @@ use crate::numeric_expr_vm::{
|
|||||||
Scratch as NumericVmScratch, Value as NumericVmValue, ValueType as NumericVmValueType,
|
Scratch as NumericVmScratch, Value as NumericVmValue, ValueType as NumericVmValueType,
|
||||||
};
|
};
|
||||||
use crate::portfolio::PortfolioState;
|
use crate::portfolio::PortfolioState;
|
||||||
|
use crate::portfolio_loss::{ClosedPortfolioSession, PortfolioLossConfig, PortfolioLossState};
|
||||||
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit};
|
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit};
|
||||||
use crate::scheduler::{ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler};
|
use crate::scheduler::{ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler};
|
||||||
use crate::strategy::{
|
use crate::strategy::{
|
||||||
@@ -179,6 +181,22 @@ impl PlatformPortfolioDrawdownController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn portfolio_gross_exposure(portfolio: &PortfolioState) -> Result<f64, BacktestError> {
|
||||||
|
let equity = portfolio.total_equity();
|
||||||
|
let market_value: f64 = portfolio.positions().values().map(|position| position.market_value().abs()).sum();
|
||||||
|
if !equity.is_finite() || equity <= 0.0 || !market_value.is_finite() {
|
||||||
|
return Err(BacktestError::Execution("portfolio loss requires finite positive accounting equity".to_owned()));
|
||||||
|
}
|
||||||
|
Ok(market_value / equity)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn portfolio_loss_decision_at(ctx: &StrategyContext<'_>) -> chrono::DateTime<Utc> {
|
||||||
|
let local = ctx.active_datetime.filter(|value| value.date() == ctx.execution_date)
|
||||||
|
.unwrap_or_else(|| ctx.execution_date.and_hms_opt(9, 30, 0).unwrap());
|
||||||
|
FixedOffset::east_opt(8 * 3600).unwrap().from_local_datetime(&local)
|
||||||
|
.single().unwrap().with_timezone(&Utc)
|
||||||
|
}
|
||||||
|
|
||||||
fn validated_target_scales(scales: &[(String, f64)]) -> Result<Vec<(String, f64)>, BacktestError> {
|
fn validated_target_scales(scales: &[(String, f64)]) -> Result<Vec<(String, f64)>, BacktestError> {
|
||||||
if scales.is_empty() {
|
if scales.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@@ -610,6 +628,7 @@ pub struct PlatformExprStrategyConfig {
|
|||||||
pub exposure_expr: String,
|
pub exposure_expr: String,
|
||||||
pub position_exposure_schedule: BTreeMap<NaiveDate, f64>,
|
pub position_exposure_schedule: BTreeMap<NaiveDate, f64>,
|
||||||
pub portfolio_drawdown_control: Option<PlatformPortfolioDrawdownControlConfig>,
|
pub portfolio_drawdown_control: Option<PlatformPortfolioDrawdownControlConfig>,
|
||||||
|
pub portfolio_loss_control: Option<PortfolioLossConfig>,
|
||||||
pub stop_loss_expr: String,
|
pub stop_loss_expr: String,
|
||||||
pub take_profit_expr: String,
|
pub take_profit_expr: String,
|
||||||
pub position_target_rules: Vec<PlatformPositionTargetRule>,
|
pub position_target_rules: Vec<PlatformPositionTargetRule>,
|
||||||
@@ -690,6 +709,7 @@ impl PlatformExprStrategyConfig {
|
|||||||
exposure_expr: "1.0".to_string(),
|
exposure_expr: "1.0".to_string(),
|
||||||
position_exposure_schedule: BTreeMap::new(),
|
position_exposure_schedule: BTreeMap::new(),
|
||||||
portfolio_drawdown_control: None,
|
portfolio_drawdown_control: None,
|
||||||
|
portfolio_loss_control: None,
|
||||||
stop_loss_expr: String::new(),
|
stop_loss_expr: String::new(),
|
||||||
take_profit_expr: String::new(),
|
take_profit_expr: String::new(),
|
||||||
position_target_rules: Vec::new(),
|
position_target_rules: Vec::new(),
|
||||||
@@ -1335,6 +1355,8 @@ pub struct PlatformExprStrategy {
|
|||||||
last_target_order: Option<Vec<String>>,
|
last_target_order: Option<Vec<String>>,
|
||||||
last_trading_ratio: Option<f64>,
|
last_trading_ratio: Option<f64>,
|
||||||
portfolio_drawdown_controller: Option<PlatformPortfolioDrawdownController>,
|
portfolio_drawdown_controller: Option<PlatformPortfolioDrawdownController>,
|
||||||
|
portfolio_loss_state: Option<PortfolioLossState>,
|
||||||
|
portfolio_loss_opening: Option<(NaiveDate, f64, f64)>,
|
||||||
pending_highlimit_holdings: BTreeSet<String>,
|
pending_highlimit_holdings: BTreeSet<String>,
|
||||||
pending_full_close_symbols: BTreeSet<String>,
|
pending_full_close_symbols: BTreeSet<String>,
|
||||||
position_entry_dates: BTreeMap<String, NaiveDate>,
|
position_entry_dates: BTreeMap<String, NaiveDate>,
|
||||||
@@ -1433,6 +1455,20 @@ fn completed_session_factor_date(
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PlatformExprStrategy {
|
impl PlatformExprStrategy {
|
||||||
|
pub fn portfolio_loss_state(&self) -> Option<&PortfolioLossState> {
|
||||||
|
self.portfolio_loss_state.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn restore_portfolio_loss_state(&mut self, state: PortfolioLossState) -> Result<(), BacktestError> {
|
||||||
|
let expected = self.config.portfolio_loss_control.as_ref().ok_or_else(|| BacktestError::Execution(
|
||||||
|
"portfolio loss state supplied for a strategy without that control".to_owned()))?;
|
||||||
|
state.validate(expected).map_err(|error| BacktestError::Execution(error.to_string()))?;
|
||||||
|
if self.portfolio_loss_opening.is_some() {
|
||||||
|
return Err(BacktestError::Execution("cannot restore portfolio loss state during an open session".to_owned()));
|
||||||
|
}
|
||||||
|
self.portfolio_loss_state = Some(state);
|
||||||
|
Ok(())
|
||||||
|
|
||||||
fn market_cap_storage_to_strategy_unit(value: f64) -> f64 {
|
fn market_cap_storage_to_strategy_unit(value: f64) -> f64 {
|
||||||
value
|
value
|
||||||
}
|
}
|
||||||
@@ -1729,6 +1765,8 @@ impl PlatformExprStrategy {
|
|||||||
last_target_order: None,
|
last_target_order: None,
|
||||||
last_trading_ratio: None,
|
last_trading_ratio: None,
|
||||||
portfolio_drawdown_controller,
|
portfolio_drawdown_controller,
|
||||||
|
portfolio_loss_state: None,
|
||||||
|
portfolio_loss_opening: None,
|
||||||
pending_highlimit_holdings: BTreeSet::new(),
|
pending_highlimit_holdings: BTreeSet::new(),
|
||||||
pending_full_close_symbols: BTreeSet::new(),
|
pending_full_close_symbols: BTreeSet::new(),
|
||||||
position_entry_dates: BTreeMap::new(),
|
position_entry_dates: BTreeMap::new(),
|
||||||
@@ -8488,12 +8526,18 @@ impl PlatformExprStrategy {
|
|||||||
)
|
)
|
||||||
.unwrap_or(strategy_exposure)
|
.unwrap_or(strategy_exposure)
|
||||||
.clamp(0.0, 1.0);
|
.clamp(0.0, 1.0);
|
||||||
let Some(controller) = self.portfolio_drawdown_controller.as_mut() else {
|
let mut exposure = risk_on_exposure;
|
||||||
return Ok(risk_on_exposure);
|
if let Some(controller) = self.portfolio_drawdown_controller.as_mut() {
|
||||||
};
|
exposure = controller.update(ctx.decision_date, day.total_value, exposure)?.target_exposure;
|
||||||
controller
|
}
|
||||||
.update(ctx.decision_date, day.total_value, risk_on_exposure)
|
if self.config.portfolio_loss_control.is_some() {
|
||||||
.map(|decision| decision.target_exposure.clamp(0.0, 1.0))
|
let state = self.portfolio_loss_state.as_mut().ok_or_else(|| BacktestError::Execution(
|
||||||
|
"portfolio loss state must be initialized or restored before planning".to_owned()))?;
|
||||||
|
let previous = ctx.data.previous_trading_date(ctx.execution_date, 1);
|
||||||
|
exposure = state.decide(ctx.execution_date, previous, portfolio_loss_decision_at(ctx), exposure)
|
||||||
|
.map_err(|error| BacktestError::Execution(error.to_string()))?.target_exposure;
|
||||||
|
}
|
||||||
|
Ok(exposure.clamp(0.0, 1.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn market_cap_band(
|
fn market_cap_band(
|
||||||
@@ -12147,6 +12191,59 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
self.config.strategy_name.as_str()
|
self.config.strategy_name.as_str()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn before_trading(&mut self, ctx: &StrategyContext<'_>) -> Result<(), BacktestError> {
|
||||||
|
let Some(config) = self.config.portfolio_loss_control.clone() else { return Ok(()); };
|
||||||
|
if ctx.futures_account.is_some() {
|
||||||
|
return Err(BacktestError::Execution("portfolio loss control currently requires equity-only accounting".to_owned()));
|
||||||
|
}
|
||||||
|
if self.portfolio_loss_state.is_none() {
|
||||||
|
self.portfolio_loss_state = Some(PortfolioLossState::new(config, ctx.execution_date)
|
||||||
|
.map_err(|error| BacktestError::Execution(error.to_string()))?);
|
||||||
|
}
|
||||||
|
if let Some((date, _, _)) = self.portfolio_loss_opening {
|
||||||
|
if date == ctx.execution_date { return Ok(()); }
|
||||||
|
return Err(BacktestError::Execution("portfolio loss previous session was not finalized".to_owned()));
|
||||||
|
}
|
||||||
|
let state = self.portfolio_loss_state.as_ref().unwrap();
|
||||||
|
let start_nav = state.last_session().map(|row| row.end_unit_nav).unwrap_or_else(|| ctx.portfolio.unit_net_value());
|
||||||
|
let gross = portfolio_gross_exposure(ctx.portfolio)?;
|
||||||
|
self.portfolio_loss_opening = Some((ctx.execution_date, start_nav, gross));
|
||||||
|
// Advance the daily risk clock even when the selection schedule is not
|
||||||
|
// due. The actual current exposure budget is applied during planning.
|
||||||
|
self.portfolio_loss_state.as_mut().unwrap().decide(ctx.execution_date,
|
||||||
|
ctx.data.previous_trading_date(ctx.execution_date, 1), portfolio_loss_decision_at(ctx), 1.0)
|
||||||
|
.map_err(|error| BacktestError::Execution(error.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_process_event(&mut self, ctx: &StrategyContext<'_>, event: &ProcessEvent) -> Result<(), BacktestError> {
|
||||||
|
if self.config.portfolio_loss_control.is_none() || event.kind != ProcessEventKind::PostSettlement { return Ok(()); }
|
||||||
|
let Some((date, start_nav, start_gross)) = self.portfolio_loss_opening else {
|
||||||
|
return Err(BacktestError::Execution("portfolio loss settlement has no opening accounting snapshot".to_owned()));
|
||||||
|
};
|
||||||
|
if date != ctx.execution_date {
|
||||||
|
return Err(BacktestError::Execution("portfolio loss settlement date differs from opening snapshot".to_owned()));
|
||||||
|
}
|
||||||
|
let end_nav = ctx.portfolio.unit_net_value();
|
||||||
|
let end_gross = portfolio_gross_exposure(ctx.portfolio)?;
|
||||||
|
let state = self.portfolio_loss_state.as_mut().unwrap();
|
||||||
|
let previous = state.last_session().map(|row| row.date);
|
||||||
|
let mut hash = Sha256::new();
|
||||||
|
hash.update(b"fidc.engine-finalized-portfolio-session/v1\0");
|
||||||
|
hash.update(self.config.strategy_name.as_bytes());
|
||||||
|
hash.update(date.to_string().as_bytes());
|
||||||
|
for value in [start_nav, end_nav, start_gross, end_gross] { hash.update(value.to_bits().to_le_bytes()); }
|
||||||
|
state.observe(ClosedPortfolioSession {
|
||||||
|
date, previous_session_date: previous,
|
||||||
|
available_at: date.and_hms_opt(7, 30, 0).unwrap().and_utc(),
|
||||||
|
start_unit_nav: start_nav, end_unit_nav: end_nav,
|
||||||
|
start_gross_exposure: start_gross, end_gross_exposure: end_gross,
|
||||||
|
source_sha256: format!("{:x}", hash.finalize()),
|
||||||
|
}).map_err(|error| BacktestError::Execution(error.to_string()))?;
|
||||||
|
self.portfolio_loss_opening = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn initial_subscriptions(&self) -> BTreeSet<String> {
|
fn initial_subscriptions(&self) -> BTreeSet<String> {
|
||||||
self.config.initial_subscriptions.clone()
|
self.config.initial_subscriptions.clone()
|
||||||
}
|
}
|
||||||
@@ -14009,6 +14106,10 @@ impl PlatformExprStrategy {
|
|||||||
{
|
{
|
||||||
diagnostics.push(diagnostic);
|
diagnostics.push(diagnostic);
|
||||||
}
|
}
|
||||||
|
if let Some(decision) = self.portfolio_loss_state.as_ref().and_then(PortfolioLossState::last_decision) {
|
||||||
|
diagnostics.push(format!("portfolio_loss_control {}", serde_json::to_string(decision)
|
||||||
|
.map_err(|error| BacktestError::Execution(error.to_string()))?));
|
||||||
|
}
|
||||||
|
|
||||||
let notes = vec![
|
let notes = vec![
|
||||||
format!("stock_list={}", stock_list.len()),
|
format!("stock_list={}", stock_list.len()),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet};
|
|||||||
use chrono::{NaiveDate, NaiveTime};
|
use chrono::{NaiveDate, NaiveTime};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use crate::portfolio_loss::PortfolioLossConfig;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage,
|
DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage,
|
||||||
@@ -915,6 +916,8 @@ pub struct StrategyExpressionRiskConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>,
|
pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub portfolio_loss_control: Option<StrategyPortfolioLossControlConfig>,
|
||||||
|
#[serde(default)]
|
||||||
pub stop_loss_expr: Option<String>,
|
pub stop_loss_expr: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub take_profit_expr: Option<String>,
|
pub take_profit_expr: Option<String>,
|
||||||
@@ -963,6 +966,16 @@ pub struct StrategyPortfolioDrawdownControlConfig {
|
|||||||
pub cooldown_trading_days: Option<usize>,
|
pub cooldown_trading_days: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct StrategyPortfolioLossControlConfig {
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
pub lookback: Option<usize>,
|
||||||
|
pub loss_trigger: Option<f64>,
|
||||||
|
pub floor_exposure: Option<f64>,
|
||||||
|
pub cooldown_trading_days: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct StrategyExpressionOrderingConfig {
|
pub struct StrategyExpressionOrderingConfig {
|
||||||
@@ -2176,6 +2189,18 @@ pub fn platform_expr_config_from_spec(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(control) = risk.portfolio_loss_control.as_ref()
|
||||||
|
&& control.enabled.unwrap_or(true)
|
||||||
|
{
|
||||||
|
let parsed = PortfolioLossConfig {
|
||||||
|
lookback: control.lookback.ok_or("portfolioLossControl.lookback is required")?,
|
||||||
|
loss_trigger: control.loss_trigger.ok_or("portfolioLossControl.lossTrigger is required")?,
|
||||||
|
floor_exposure: control.floor_exposure.ok_or("portfolioLossControl.floorExposure is required")?,
|
||||||
|
cooldown_trading_days: control.cooldown_trading_days.ok_or("portfolioLossControl.cooldownTradingDays is required")?,
|
||||||
|
};
|
||||||
|
parsed.validate().map_err(|error| error.to_string())?;
|
||||||
|
cfg.portfolio_loss_control = Some(parsed);
|
||||||
|
}
|
||||||
if let Some(control) = risk.portfolio_drawdown_control.as_ref()
|
if let Some(control) = risk.portfolio_drawdown_control.as_ref()
|
||||||
&& control.enabled.unwrap_or(true)
|
&& control.enabled.unwrap_or(true)
|
||||||
{
|
{
|
||||||
@@ -4564,6 +4589,27 @@ mod tests {
|
|||||||
assert_eq!(control.cooldown_trading_days, 30);
|
assert_eq!(control.cooldown_trading_days, 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn portfolio_loss_contract_is_explicit_and_validated() {
|
||||||
|
let spec = serde_json::json!({"runtimeExpressions":{"risk":{"portfolioLossControl":{
|
||||||
|
"enabled":true,"lookback":20,"lossTrigger":0.05,"floorExposure":0.2,"cooldownTradingDays":10
|
||||||
|
}}}});
|
||||||
|
let cfg = platform_expr_config_from_value("", "", &spec).unwrap();
|
||||||
|
assert_eq!(cfg.portfolio_loss_control.unwrap(), PortfolioLossConfig {
|
||||||
|
lookback:20, loss_trigger:0.05, floor_exposure:0.2, cooldown_trading_days:10,
|
||||||
|
});
|
||||||
|
for (field, value) in [("lookback", serde_json::json!(0)),
|
||||||
|
("lossTrigger", serde_json::json!(0.01)), ("floorExposure", serde_json::json!(1.1)),
|
||||||
|
("cooldownTradingDays", serde_json::json!(0))] {
|
||||||
|
let mut invalid = spec.clone();
|
||||||
|
invalid["runtimeExpressions"]["risk"]["portfolioLossControl"][field] = value;
|
||||||
|
assert!(platform_expr_config_from_value("", "", &invalid).is_err());
|
||||||
|
}
|
||||||
|
let mut missing = spec.clone();
|
||||||
|
missing["runtimeExpressions"]["risk"]["portfolioLossControl"].as_object_mut().unwrap().remove("lossTrigger");
|
||||||
|
assert!(platform_expr_config_from_value("", "", &missing).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_invalid_portfolio_drawdown_control() {
|
fn rejects_invalid_portfolio_drawdown_control() {
|
||||||
let spec = serde_json::json!({
|
let spec = serde_json::json!({
|
||||||
|
|||||||
@@ -453,8 +453,10 @@ mod tests {
|
|||||||
state.decide(date(2), Some(date(1)), time(2, 1), 0.9),
|
state.decide(date(2), Some(date(1)), time(2, 1), 0.9),
|
||||||
Err(PortfolioLossError::SessionDiscontinuity)
|
Err(PortfolioLossError::SessionDiscontinuity)
|
||||||
);
|
);
|
||||||
let mut late = state.clone();
|
let mut late = PortfolioLossState::new(config(), date(0)).unwrap();
|
||||||
late.last_session.as_mut().unwrap().available_at = time(2, 1);
|
let mut delayed = session(0, 1.0, 0.99, 1.0);
|
||||||
|
delayed.available_at = time(2, 1);
|
||||||
|
late.observe(delayed).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
late.decide(date(1), Some(date(0)), time(1, 1), 0.9),
|
late.decide(date(1), Some(date(0)), time(1, 1), 0.9),
|
||||||
Err(PortfolioLossError::ObservationNotVisible)
|
Err(PortfolioLossError::ObservationNotVisible)
|
||||||
@@ -481,4 +483,35 @@ mod tests {
|
|||||||
assert_eq!(decision.target_exposure, 0.9);
|
assert_eq!(decision.target_exposure, 0.9);
|
||||||
assert_eq!(state.last_session().unwrap().date, date(19));
|
assert_eq!(state.last_session().unwrap().date, date(19));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restored_state_rejects_changed_policy_and_forged_cooldown() {
|
||||||
|
let state = PortfolioLossState::new(config(), date(0)).unwrap();
|
||||||
|
let mut changed = config();
|
||||||
|
changed.floor_exposure = 0.5;
|
||||||
|
assert_eq!(
|
||||||
|
state.validate(&changed),
|
||||||
|
Err(PortfolioLossError::StateMismatch)
|
||||||
|
);
|
||||||
|
let mut forged = state.clone();
|
||||||
|
forged.cooldown_remaining = 1;
|
||||||
|
assert_eq!(
|
||||||
|
forged.validate(&config()),
|
||||||
|
Err(PortfolioLossError::StateMismatch)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nav_serialization_preserves_float_bits() {
|
||||||
|
let mut seed = 0xabcddcba12345678_u64;
|
||||||
|
for _ in 0..2000 {
|
||||||
|
seed ^= seed << 13;
|
||||||
|
seed ^= seed >> 7;
|
||||||
|
seed ^= seed << 17;
|
||||||
|
let value = 0.01 + (seed as f64 / u64::MAX as f64) * 9.99;
|
||||||
|
let serialized = serde_json::to_string(&value).unwrap();
|
||||||
|
let restored: f64 = serde_json::from_str(&serialized).unwrap();
|
||||||
|
assert_eq!(value.to_bits(), restored.to_bits());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user