diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index e68eb81..5ddb2da 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -908,6 +908,29 @@ where commission_state, report, ), + OrderIntent::TimedTargetValue { + symbol, + target_value, + style, + start_time, + end_time, + reason, + } => self.process_timed_target_value( + date, + portfolio, + data, + symbol, + *target_value, + *style, + *start_time, + *end_time, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ), OrderIntent::LimitTargetValue { symbol, target_value, @@ -3011,6 +3034,118 @@ where Ok(()) } + #[allow(clippy::too_many_arguments)] + fn process_timed_target_value( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + symbol: &str, + target_value: f64, + style: AlgoOrderStyle, + start_time: Option, + end_time: Option, + reason: &str, + intraday_turnover: &mut BTreeMap, + execution_cursors: &mut BTreeMap, + global_execution_cursor: &mut Option, + commission_state: &mut BTreeMap, + report: &mut BrokerExecutionReport, + ) -> Result<(), BacktestError> { + let snapshot = data + .market(date, symbol) + .ok_or_else(|| BacktestError::MissingPrice { + date, + symbol: symbol.to_string(), + field: price_field_name(self.execution_price_field), + })?; + let current_qty = portfolio + .position(symbol) + .map(|pos| pos.quantity) + .unwrap_or(0); + let algo_request = AlgoExecutionRequest { + style: match style { + AlgoOrderStyle::Vwap => AlgoExecutionStyle::Vwap, + AlgoOrderStyle::Twap => AlgoExecutionStyle::Twap, + }, + start_time, + end_time, + }; + + if target_value <= f64::EPSILON { + if current_qty == 0 { + report.order_events.push(OrderEvent { + date, + order_id: None, + symbol: symbol.to_string(), + side: OrderSide::Sell, + requested_quantity: 0, + filled_quantity: 0, + status: OrderStatus::Filled, + reason: format!("{reason}: already at target value"), + }); + return Ok(()); + } + self.process_sell( + date, + portfolio, + data, + symbol, + current_qty, + self.reserve_order_id(), + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + None, + false, + true, + Some(&algo_request), + report, + )?; + return Ok(()); + } + + let valuation_price = self.target_value_valuation_price(date, data, symbol, snapshot); + let current_value = valuation_price * current_qty as f64; + let cash_delta = target_value.max(0.0) - current_value; + if cash_delta.abs() > f64::EPSILON { + self.process_algo_value( + date, + portfolio, + data, + symbol, + cash_delta, + style, + start_time, + end_time, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + )?; + } else { + report.order_events.push(OrderEvent { + date, + order_id: None, + symbol: symbol.to_string(), + side: if current_qty > 0 { + OrderSide::Sell + } else { + OrderSide::Buy + }, + requested_quantity: 0, + filled_quantity: 0, + status: OrderStatus::Filled, + reason: format!("{reason}: already at target value"), + }); + } + Ok(()) + } + fn process_target_shares( &self, date: NaiveDate, @@ -5265,6 +5400,7 @@ mod tests { use crate::instrument::Instrument; use crate::portfolio::PortfolioState; use crate::rules::ChinaEquityRuleHooks; + use crate::strategy::AlgoOrderStyle; fn limit_test_snapshot() -> DailyMarketSnapshot { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); @@ -5410,6 +5546,87 @@ mod tests { ); } + #[test] + fn timed_target_value_zero_sells_full_position_at_requested_time_quote() { + let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); + let prev_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 1).expect("valid date"); + let symbol = "000001.SZ"; + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Last, + ) + .with_intraday_execution_start_time(date.and_hms_opt(10, 18, 0).unwrap().time()) + .with_matching_type(MatchingType::NextTickLast) + .with_slippage_model(SlippageModel::PriceRatio(0.001)) + .with_volume_limit(false) + .with_liquidity_limit(false); + let mut snapshot = limit_test_snapshot(); + snapshot.symbol = symbol.to_string(); + snapshot.last_price = 4.21; + snapshot.close = 4.21; + snapshot.bid1 = 4.20; + snapshot.ask1 = 4.22; + snapshot.prev_close = 4.15; + snapshot.upper_limit = 4.98; + snapshot.lower_limit = 3.32; + let mut quote_0931 = limit_test_quote(4.11, 4.10, 4.12); + quote_0931.symbol = symbol.to_string(); + quote_0931.timestamp = date.and_hms_opt(9, 31, 0).expect("valid timestamp"); + let mut quote_1018 = limit_test_quote(4.21, 4.20, 4.22); + quote_1018.symbol = symbol.to_string(); + quote_1018.timestamp = date.and_hms_opt(10, 18, 0).expect("valid timestamp"); + let data = DataSet::from_components_with_actions_and_quotes( + vec![limit_test_instrument()], + vec![snapshot], + Vec::new(), + vec![limit_test_candidate(true, true)], + vec![limit_test_benchmark()], + Vec::new(), + vec![quote_0931, quote_1018], + ) + .expect("valid dataset"); + let mut portfolio = PortfolioState::new(1_000_000.0); + portfolio.position_mut(symbol).buy(prev_date, 72_600, 4.0); + portfolio.apply_cash_delta(-290_400.0); + let mut report = BrokerExecutionReport::default(); + + broker + .process_timed_target_value( + date, + &mut portfolio, + &data, + symbol, + 0.0, + AlgoOrderStyle::Twap, + Some(date.and_hms_opt(9, 31, 0).unwrap().time()), + Some(date.and_hms_opt(9, 31, 0).unwrap().time()), + "risk_forced_exit", + &mut BTreeMap::new(), + &mut BTreeMap::new(), + &mut None, + &mut BTreeMap::new(), + &mut report, + ) + .expect("timed target value"); + + assert!( + !report.fill_events.is_empty(), + "order_events={:?}", + report.order_events + ); + let fill = report.fill_events.last().expect("fill event"); + assert_eq!(fill.quantity, 72_600); + assert!((fill.price - 4.10589).abs() < 1e-6, "{fill:?}"); + assert_eq!( + portfolio + .position(symbol) + .map(|position| position.quantity) + .unwrap_or(0), + 0 + ); + } + #[test] fn aiquant_target_value_delta_uses_scheduled_mark_price() { let date = chrono::NaiveDate::from_ymd_opt(2023, 5, 8).expect("valid date"); diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 6ed45db..c8da21a 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -3354,6 +3354,7 @@ fn decision_has_algo_execution(decision: &StrategyDecision) -> bool { intent, OrderIntent::AlgoValue { .. } | OrderIntent::AlgoPercent { .. } + | OrderIntent::TimedTargetValue { .. } | OrderIntent::TargetPortfolioSmart { order_prices: Some(TargetPortfolioOrderPricing::AlgoOrder { .. }), .. @@ -3386,6 +3387,7 @@ fn execution_quote_symbols_for_decision( | OrderIntent::TargetShares { symbol, .. } | OrderIntent::LimitTargetShares { symbol, .. } | OrderIntent::TargetValue { symbol, .. } + | OrderIntent::TimedTargetValue { symbol, .. } | OrderIntent::LimitTargetValue { symbol, .. } | OrderIntent::Value { symbol, .. } | OrderIntent::LimitValue { symbol, .. } @@ -3438,6 +3440,12 @@ fn algo_execution_quote_windows_for_decision( start_time, end_time, .. + } + | OrderIntent::TimedTargetValue { + symbol, + start_time, + end_time, + .. } => { if start_time.is_some() || end_time.is_some() { groups diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 15c1b84..92b9836 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -907,6 +907,15 @@ impl PlatformExprStrategy { .unwrap_or_else(|| NaiveTime::from_hms_opt(10, 18, 0).expect("valid 10:18")) } + fn firisk_forced_exit_time(&self) -> NaiveTime { + if self.config.delayed_limit_open_exit_enabled + && let Some(time) = self.config.delayed_limit_open_exit_time + { + return time; + } + self.intraday_execution_start_time() + } + fn buy_commission(&self, gross_amount: f64) -> f64 { self.cost_model().commission_for(gross_amount) } @@ -6834,6 +6843,7 @@ impl Strategy for PlatformExprStrategy { .cloned() .collect::>(); let mut pending_full_close_symbols = BTreeSet::::new(); + let firisk_forced_exit_time = self.firisk_forced_exit_time(); if self.config.aiquant_transaction_cost { for position in ctx.portfolio.positions().values() { if position.quantity == 0 @@ -6854,7 +6864,7 @@ impl Strategy for PlatformExprStrategy { ctx, execution_date, &position.symbol, - self.intraday_execution_start_time(), + firisk_forced_exit_time, )? { pending_full_close_symbols.insert(position.symbol.clone()); pending_firisk_forced_exit_symbols.insert(position.symbol.clone()); @@ -6899,18 +6909,22 @@ impl Strategy for PlatformExprStrategy { continue; } exit_symbols.insert(symbol.clone()); - order_intents.push(OrderIntent::TargetValue { + order_intents.push(OrderIntent::TimedTargetValue { symbol: symbol.clone(), target_value: 0.0, + style: AlgoOrderStyle::Twap, + start_time: Some(firisk_forced_exit_time), + end_time: Some(firisk_forced_exit_time), reason: "risk_forced_exit".to_string(), }); if self - .project_target_zero( + .project_target_zero_at_time( ctx, &mut projected, execution_date, &symbol, &mut projected_execution_state, + Some(firisk_forced_exit_time), ) .is_some() && Self::projected_position_is_flat(&projected, &symbol) @@ -10951,17 +10965,27 @@ mod tests { cfg.stock_filter_expr = "true".to_string(); cfg.stop_loss_expr.clear(); cfg.take_profit_expr.clear(); + cfg.delayed_limit_open_exit_enabled = true; + cfg.delayed_limit_open_exit_time = Some(NaiveTime::from_hms_opt(9, 31, 0).expect("time")); + cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(10, 18, 0).expect("time")); let mut strategy = PlatformExprStrategy::new(cfg); let decision = strategy.on_day(&ctx).expect("platform decision"); assert!(decision.order_intents.iter().any(|intent| matches!( intent, - OrderIntent::TargetValue { + OrderIntent::TimedTargetValue { symbol: intent_symbol, target_value, + start_time, + end_time, reason, - } if intent_symbol == symbol && *target_value == 0.0 && reason == "risk_forced_exit" + .. + } if intent_symbol == symbol + && *target_value == 0.0 + && *start_time == Some(NaiveTime::from_hms_opt(9, 31, 0).expect("time")) + && *end_time == Some(NaiveTime::from_hms_opt(9, 31, 0).expect("time")) + && reason == "risk_forced_exit" ))); assert!(!decision.order_intents.iter().any(|intent| matches!( intent, diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 1aa376d..a1507e3 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -989,6 +989,14 @@ pub enum OrderIntent { target_value: f64, reason: String, }, + TimedTargetValue { + symbol: String, + target_value: f64, + style: AlgoOrderStyle, + start_time: Option, + end_time: Option, + reason: String, + }, LimitTargetValue { symbol: String, target_value: f64,