From 4c3653e009be1a8e7a628441fd0a309b4321a417 Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 13 Jun 2026 23:32:24 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3AiQuant=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E5=9B=9E=E6=B5=8B=E7=9B=98=E4=B8=AD=E4=BC=B0=E5=80=BC=E5=8F=A3?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 123 +++-- .../fidc-core/src/platform_expr_strategy.rs | 483 ++++++++++++++---- 2 files changed, 456 insertions(+), 150 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 6b1afc8..85482e4 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -361,30 +361,12 @@ where symbol: &str, snapshot: &crate::data::DailyMarketSnapshot, ) -> f64 { - if self.execution_price_field == PriceField::Last { - let start_cursor = self - .runtime_intraday_start_time - .get() - .or(self.intraday_execution_start_time) - .map(|start_time| date.and_time(start_time)); - if let Some(price) = self - .latest_known_quote_at_or_before( - data.execution_quotes_on(date, symbol), - start_cursor, - snapshot, - OrderSide::Buy, - MatchingType::NextTickLast, - false, - ) - .and_then(|quote| { - (quote.last_price.is_finite() && quote.last_price > 0.0) - .then_some(quote.last_price) - }) - { - return price; - } + let _ = (date, data, symbol); + if snapshot.close.is_finite() && snapshot.close > 0.0 { + snapshot.close + } else { + self.sizing_price(snapshot) } - self.sizing_price(snapshot) } fn value_order_sizing_price( @@ -3675,16 +3657,11 @@ where requested_qty } - fn value_buy_gross_limit( - &self, - value_budget: Option, - requested_qty: u32, - reference_price: f64, - ) -> Option { + fn value_buy_gross_limit(&self, value_budget: Option) -> Option { if !self.strict_value_budget { return None; } - value_budget.map(|budget| budget.max(reference_price * requested_qty as f64)) + value_budget.filter(|budget| budget.is_finite() && *budget > 0.0) } fn process_buy( @@ -3843,8 +3820,15 @@ where return Ok(()); } }; - let value_gross_limit = - self.value_buy_gross_limit(value_budget, constrained_qty, self.sizing_price(snapshot)); + let value_gross_limit = self.value_buy_gross_limit(value_budget); + let buy_cash_limit = if self.strict_value_budget { + value_budget + .filter(|budget| budget.is_finite() && *budget > 0.0) + .map(|budget| portfolio.cash().min(budget)) + .unwrap_or_else(|| portfolio.cash()) + } else { + portfolio.cash() + }; let fill = self.resolve_execution_fill( date, @@ -3859,7 +3843,7 @@ where false, execution_cursors, None, - Some(portfolio.cash()), + Some(buy_cash_limit), value_gross_limit, algo_request, limit_price, @@ -3896,7 +3880,7 @@ where self.execution_price_with_limit_slippage(execution_price, limit_price); let filled_qty = self.affordable_buy_quantity( date, - portfolio.cash(), + buy_cash_limit, value_gross_limit, execution_price, constrained_qty, @@ -3913,7 +3897,7 @@ where partial_fill_reason = merge_partial_fill_reason( partial_fill_reason, self.buy_reduction_reason( - portfolio.cash(), + buy_cash_limit, value_gross_limit, execution_price, constrained_qty, @@ -5119,7 +5103,7 @@ mod tests { } #[test] - fn target_value_sizing_uses_intraday_tick_instead_of_daily_snapshot() { + fn target_value_valuation_uses_daily_snapshot_but_value_order_sizing_uses_intraday_tick() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), @@ -5128,7 +5112,7 @@ mod tests { ) .with_intraday_execution_start_time(date.and_hms_opt(9, 33, 0).unwrap().time()); let mut snapshot = limit_test_snapshot(); - snapshot.last_price = 10.0; + snapshot.last_price = 9.0; snapshot.close = 10.0; let mut quote = limit_test_quote(11.0, 10.99, 11.01); quote.timestamp = date.and_hms_opt(9, 32, 58).expect("valid timestamp"); @@ -5146,7 +5130,7 @@ mod tests { assert_eq!( broker.target_value_valuation_price(date, &data, "000001.SZ", snapshot), - 11.0 + 10.0 ); assert_eq!( broker.value_sell_sizing_price(date, &data, "000001.SZ", snapshot), @@ -5266,6 +5250,69 @@ mod tests { assert_eq!(report.order_events[0].filled_quantity, 14_300); } + #[test] + fn strict_value_buy_budget_includes_commission_at_execution_price() { + let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Last, + ) + .with_intraday_execution_start_time(date.and_hms_opt(9, 33, 0).unwrap().time()) + .with_slippage_model(SlippageModel::PriceRatio(0.002)) + .with_strict_value_budget(true) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let mut snapshot = limit_test_snapshot(); + snapshot.day_open = 7.14; + snapshot.open = 7.14; + snapshot.high = 7.20; + snapshot.low = 7.10; + snapshot.last_price = 7.14; + snapshot.bid1 = 7.14; + snapshot.ask1 = 7.15; + snapshot.close = 7.14; + snapshot.upper_limit = 7.85; + snapshot.lower_limit = 6.43; + let mut quote = limit_test_quote(7.14, 7.14, 7.15); + quote.timestamp = date.and_hms_opt(9, 32, 55).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], + ) + .expect("valid dataset"); + let value_budget = 125_216.8131; + let mut portfolio = PortfolioState::new(10_000_000.0); + let mut report = BrokerExecutionReport::default(); + + broker + .process_value( + date, + &mut portfolio, + &data, + "000001.SZ", + value_budget, + "periodic_rebalance_buy", + &mut BTreeMap::new(), + &mut BTreeMap::new(), + &mut None, + &mut BTreeMap::new(), + &mut report, + ) + .expect("value buy processed"); + + let fill = report.fill_events.first().expect("fill event"); + assert_eq!(fill.quantity, 17_400); + assert!(fill.gross_amount + fill.commission <= value_budget + 1e-6); + assert!((fill.price - 7.15428).abs() < 1e-6); + } + #[test] fn instantaneous_twap_without_limits_does_not_cap_quote_quantity() { let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index b95eb88..93b4dc8 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -915,20 +915,30 @@ impl PlatformExprStrategy { if position.quantity == 0 { continue; } - let mark_price = ctx - .data - .price(date, &position.symbol, PriceField::Close) - .or_else(|| ctx.data.price(date, &position.symbol, PriceField::Last)) - .or_else(|| { - ctx.data - .price_on_or_before(date, &position.symbol, PriceField::Close) - }) - .or_else(|| { - ctx.data - .price_on_or_before(date, &position.symbol, PriceField::Last) - }) - .filter(|price| price.is_finite() && *price > 0.0) - .unwrap_or(position.last_price); + let mark_price = if self.config.aiquant_transaction_cost { + ctx.data + .price(date, &position.symbol, PriceField::Last) + .or_else(|| { + ctx.data + .price_on_or_before(date, &position.symbol, PriceField::Last) + }) + .filter(|price| price.is_finite() && *price > 0.0) + .unwrap_or(position.last_price) + } else { + ctx.data + .price(date, &position.symbol, PriceField::Close) + .or_else(|| ctx.data.price(date, &position.symbol, PriceField::Last)) + .or_else(|| { + ctx.data + .price_on_or_before(date, &position.symbol, PriceField::Close) + }) + .or_else(|| { + ctx.data + .price_on_or_before(date, &position.symbol, PriceField::Last) + }) + .filter(|price| price.is_finite() && *price > 0.0) + .unwrap_or(position.last_price) + }; if mark_price.is_finite() && mark_price > 0.0 { total += mark_price * position.quantity as f64; } @@ -1279,6 +1289,108 @@ impl PlatformExprStrategy { Some(fill.quantity) } + fn project_target_value( + &self, + ctx: &StrategyContext<'_>, + projected: &mut PortfolioState, + date: NaiveDate, + symbol: &str, + target_value: f64, + execution_state: &mut ProjectedExecutionState, + ) -> Option { + let current_qty = projected.position(symbol)?.quantity; + if current_qty == 0 { + return None; + } + if target_value <= f64::EPSILON { + return self.project_target_zero(ctx, projected, date, symbol, execution_state); + } + let market = ctx.data.market(date, symbol)?; + let valuation_price = if market.close.is_finite() && market.close > 0.0 { + market.close + } else { + self.projected_execution_price(market, OrderSide::Buy) + }; + if !valuation_price.is_finite() || valuation_price <= 0.0 { + return None; + } + 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 { + return None; + } + if cash_delta > 0.0 { + return Some(self.project_order_value( + ctx, + projected, + date, + symbol, + cash_delta, + execution_state, + )); + } + + if !self.can_sell_position(ctx, date, symbol) { + return None; + } + let sizing_price = self + .aiquant_scheduled_quote(ctx, date, symbol) + .and_then(|quote| { + if quote.last_price.is_finite() && quote.last_price > 0.0 { + Some(quote.last_price) + } else { + quote.sell_price() + } + }) + .unwrap_or_else(|| self.projected_execution_price(market, OrderSide::Sell)); + if !sizing_price.is_finite() || sizing_price <= 0.0 { + return None; + } + let round_lot = self.projected_round_lot(ctx, symbol); + let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol); + let order_step_size = self.projected_order_step_size(ctx, symbol); + let requested_qty = self + .round_lot_quantity( + ((cash_delta.abs()) / sizing_price).floor() as u32, + minimum_order_quantity, + order_step_size, + ) + .min(current_qty); + if requested_qty == 0 { + return None; + } + let fill = self.projected_select_execution_fill( + ctx, + date, + symbol, + OrderSide::Sell, + requested_qty, + round_lot, + minimum_order_quantity, + order_step_size, + requested_qty >= current_qty, + None, + None, + execution_state, + )?; + let gross_amount = fill.price * fill.quantity as f64; + let sell_cost = self.sell_cost(date, gross_amount); + projected + .position_mut(symbol) + .sell(fill.quantity, fill.price) + .ok()?; + projected.apply_cash_delta(gross_amount - sell_cost); + *execution_state + .intraday_turnover + .entry(symbol.to_string()) + .or_default() += fill.quantity; + execution_state + .execution_cursors + .insert(symbol.to_string(), fill.next_cursor); + projected.prune_flat_positions(); + Some(fill.quantity) + } + fn projected_position_count_excluding( projected: &PortfolioState, excluded_symbols: &BTreeSet, @@ -1317,6 +1429,17 @@ impl PlatformExprStrategy { let Some(market) = ctx.data.market(date, symbol) else { return position.market_value(); }; + if self.config.aiquant_transaction_cost { + if let Some(price) = self + .aiquant_scheduled_quote(ctx, date, symbol) + .and_then(|quote| { + (quote.last_price.is_finite() && quote.last_price > 0.0) + .then_some(quote.last_price) + }) + { + return position.quantity as f64 * price; + } + } let execution_price = self.projected_execution_price(market, OrderSide::Buy); if execution_price.is_finite() && execution_price > 0.0 { position.quantity as f64 * execution_price @@ -1325,24 +1448,6 @@ impl PlatformExprStrategy { } } - fn projected_position_value_excluding( - &self, - ctx: &StrategyContext<'_>, - projected: &PortfolioState, - date: NaiveDate, - excluded_symbols: &BTreeSet, - ) -> f64 { - projected - .positions() - .keys() - .filter(|symbol| !excluded_symbols.contains(*symbol)) - .map(|symbol| { - self.projected_position_value_at_execution_price(ctx, projected, date, symbol) - }) - .filter(|value| value.is_finite() && *value > 0.0) - .sum() - } - fn remaining_buy_cash_per_slot( &self, ctx: &StrategyContext<'_>, @@ -1350,20 +1455,27 @@ impl PlatformExprStrategy { date: NaiveDate, target_budget: f64, selection_limit: usize, - excluded_symbols: &BTreeSet, + working_symbols: &BTreeSet, + value_symbols: &BTreeSet, + pending_buy_value: f64, ) -> f64 { if selection_limit == 0 || !target_budget.is_finite() || target_budget <= 0.0 { return 0.0; } - let active_count = Self::projected_position_count_excluding(projected, excluded_symbols); - if active_count >= selection_limit { + if working_symbols.len() >= selection_limit { return 0.0; } - let slots_remaining = selection_limit.saturating_sub(active_count).max(1); - let active_value = - self.projected_position_value_excluding(ctx, projected, date, excluded_symbols); - let remaining_budget = (target_budget - active_value).max(0.0); - remaining_budget / slots_remaining as f64 + let slots_remaining = selection_limit.saturating_sub(working_symbols.len()).max(1); + let active_value = working_symbols + .iter() + .filter(|symbol| value_symbols.contains(*symbol)) + .map(|symbol| { + self.projected_position_value_at_execution_price(ctx, projected, date, symbol) + }) + .filter(|value| value.is_finite() && *value > 0.0) + .sum::(); + let working_value = active_value + pending_buy_value.max(0.0); + (target_budget - working_value).max(0.0) / slots_remaining as f64 } fn project_order_value( @@ -1413,14 +1525,19 @@ impl PlatformExprStrategy { ); let mut quantity = snapshot_requested_qty; let gross_limit = if self.config.strict_value_budget { - Some(order_value.max(execution_price * quantity as f64)) + Some(order_value) } else { None }; + let cash_limit = if self.config.strict_value_budget { + projected.cash().min(order_value) + } else { + projected.cash() + }; while quantity > 0 { let gross_amount = execution_price * quantity as f64; if gross_limit.map_or(true, |limit| gross_amount <= limit + 1e-6) - && gross_amount + self.buy_commission(gross_amount) <= projected.cash() + 1e-6 + && gross_amount + self.buy_commission(gross_amount) <= cash_limit + 1e-6 { break; } @@ -1441,7 +1558,7 @@ impl PlatformExprStrategy { minimum_order_quantity, order_step_size, false, - Some(projected.cash()), + Some(cash_limit), gross_limit, execution_state, ) @@ -1462,7 +1579,7 @@ impl PlatformExprStrategy { }; let gross_amount = fill.price * fill.quantity as f64; let cash_out = gross_amount + self.buy_commission(gross_amount); - if cash_out > projected.cash() + 1e-6 { + if cash_out > cash_limit + 1e-6 { return 0; } projected.apply_cash_delta(-cash_out); @@ -5926,7 +6043,16 @@ impl Strategy for PlatformExprStrategy { target_value, reason: "daily_position_target_adjust".to_string(), }); + self.project_target_value( + ctx, + &mut projected, + execution_date, + &position.symbol, + target_value, + &mut projected_execution_state, + ); } + aiquant_available_cash = projected.cash(); } } @@ -5992,85 +6118,109 @@ impl Strategy for PlatformExprStrategy { .insert(position.symbol.clone()); } } + } - if self.config.daily_top_up_enabled - && self.config.rotation_enabled - && trading_ratio > 0.0 - && { - let excluded_symbols = Self::pending_exit_exclusion_symbols( - &unresolved_stop_loss_symbols, - &exit_symbols, - &delayed_sold_symbols, - ); - Self::projected_position_count_excluding(&projected, &excluded_symbols) - < selection_limit - } - { - let target_budget = aiquant_total_value * trading_ratio; + if self.config.daily_top_up_enabled + && self.config.rotation_enabled + && trading_ratio > 0.0 + && selection_limit > 0 + { + let target_budget = aiquant_total_value * trading_ratio; + let mut working_symbols = projected + .positions() + .keys() + .filter(|symbol| { + !same_day_sold_symbols.contains(*symbol) + && !exit_symbols.contains(*symbol) + && !delayed_sold_symbols.contains(*symbol) + && !unresolved_stop_loss_symbols.contains(*symbol) + }) + .cloned() + .collect::>(); + let value_symbols = working_symbols.clone(); + let mut pending_buy_value = 0.0_f64; + loop { let excluded_symbols = Self::pending_exit_exclusion_symbols( &unresolved_stop_loss_symbols, &exit_symbols, &delayed_sold_symbols, ); + if Self::projected_position_count_excluding(&projected, &excluded_symbols) + >= selection_limit + { + break; + } let slot_buy_cash = self.remaining_buy_cash_per_slot( ctx, &projected, execution_date, target_budget, selection_limit, - &excluded_symbols, + &working_symbols, + &value_symbols, + pending_buy_value, ); let available_buy_cash = slot_buy_cash.min(aiquant_available_cash); - if slot_buy_cash > 0.0 && available_buy_cash >= slot_buy_cash * 0.5 { - for symbol in &stock_list { - if symbol == &position.symbol - || projected.positions().contains_key(symbol) - || same_day_sold_symbols.contains(symbol) - || exit_symbols.contains(symbol) - || delayed_sold_symbols.contains(symbol) - || intraday_attempted_buys.contains(symbol) - { - continue; - } - let execution_stock = self.stock_state(ctx, execution_date, symbol)?; - if self - .buy_rejection_reason(ctx, execution_date, symbol, &execution_stock)? - .is_some() - { - continue; - } - let decision_stock = self.stock_state_with_factor_date( - ctx, - decision_date, - selection_factor_date, - symbol, - )?; - let buy_cash = - available_buy_cash * self.buy_scale(ctx, &day, &decision_stock)?; - if buy_cash <= 0.0 { - break; - } - order_intents.push(OrderIntent::Value { - symbol: symbol.clone(), - value: buy_cash, - reason: "daily_top_up_buy".to_string(), - }); - let cash_before_buy = projected.cash(); - let filled_qty = self.project_order_value( - ctx, - &mut projected, - execution_date, - symbol, - buy_cash, - &mut projected_execution_state, - ); - if filled_qty > 0 { - let spent = (cash_before_buy - projected.cash()).max(0.0); - aiquant_available_cash = (aiquant_available_cash - spent).max(0.0); - intraday_attempted_buys.insert(symbol.clone()); - } + if slot_buy_cash <= 0.0 || available_buy_cash < slot_buy_cash * 0.5 { + break; + } + + let mut attempted_any = false; + let mut filled_any = false; + for symbol in &stock_list { + if projected.positions().contains_key(symbol) + || same_day_sold_symbols.contains(symbol) + || exit_symbols.contains(symbol) + || delayed_sold_symbols.contains(symbol) + || intraday_attempted_buys.contains(symbol) + { + continue; + } + let execution_stock = self.stock_state(ctx, execution_date, symbol)?; + if self + .buy_rejection_reason(ctx, execution_date, symbol, &execution_stock)? + .is_some() + { + continue; + } + let decision_stock = self.stock_state_with_factor_date( + ctx, + decision_date, + selection_factor_date, + symbol, + )?; + let buy_cash = + available_buy_cash * self.buy_scale(ctx, &day, &decision_stock)?; + if buy_cash <= 0.0 { break; } + order_intents.push(OrderIntent::Value { + symbol: symbol.clone(), + value: buy_cash, + reason: "daily_top_up_buy".to_string(), + }); + intraday_attempted_buys.insert(symbol.clone()); + attempted_any = true; + let cash_before_buy = projected.cash(); + let filled_qty = self.project_order_value( + ctx, + &mut projected, + execution_date, + symbol, + buy_cash, + &mut projected_execution_state, + ); + if filled_qty > 0 { + let spent = (cash_before_buy - projected.cash()).max(0.0); + aiquant_available_cash = (aiquant_available_cash - spent).max(0.0); + working_symbols.insert(symbol.clone()); + pending_buy_value += available_buy_cash; + filled_any = true; + break; + } + } + if !attempted_any || !filled_any { + break; } } } @@ -6112,6 +6262,19 @@ impl Strategy for PlatformExprStrategy { } let fixed_buy_cash = aiquant_total_value * trading_ratio / selection_limit as f64; let target_budget = aiquant_total_value * trading_ratio; + let mut rebalance_working_symbols = projected + .positions() + .keys() + .filter(|symbol| { + !same_day_sold_symbols.contains(*symbol) + && !exit_symbols.contains(*symbol) + && !delayed_sold_symbols.contains(*symbol) + && !unresolved_stop_loss_symbols.contains(*symbol) + }) + .cloned() + .collect::>(); + let rebalance_value_symbols = rebalance_working_symbols.clone(); + let mut rebalance_pending_buy_value = 0.0_f64; for symbol in stock_list.iter().take(selection_limit) { let decision_stock = self.stock_state_with_factor_date( ctx, @@ -6191,11 +6354,9 @@ impl Strategy for PlatformExprStrategy { execution_date, target_budget, selection_limit, - &Self::pending_exit_exclusion_symbols( - &unresolved_stop_loss_symbols, - &exit_symbols, - &delayed_sold_symbols, - ), + &rebalance_working_symbols, + &rebalance_value_symbols, + rebalance_pending_buy_value, ); let target_cash = slot_buy_cash * stock_scale; let buy_cash = target_cash.min(aiquant_available_cash); @@ -6232,6 +6393,8 @@ impl Strategy for PlatformExprStrategy { if filled_qty > 0 { let spent = (cash_before_buy - projected.cash()).max(0.0); aiquant_available_cash = (aiquant_available_cash - spent).max(0.0); + rebalance_working_symbols.insert(symbol.clone()); + rebalance_pending_buy_value += buy_cash; } } } @@ -7986,6 +8149,102 @@ mod tests { assert_eq!(quote.last_price, 19.8); } + #[test] + fn platform_aiquant_marked_total_uses_intraday_last_not_close() { + let date = d(2025, 1, 6); + let symbol = "603779.SH"; + let data = DataSet::from_components_with_actions_and_quotes( + vec![Instrument { + symbol: symbol.to_string(), + name: symbol.to_string(), + board: "SH".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some("2025-01-06 10:40:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 22.0, + low: 9.8, + close: 21.5, + last_price: 10.0, + bid1: 9.99, + ask1: 10.0, + prev_close: 10.0, + volume: 1_000_000, + tick_volume: 1_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 22.0, + lower_limit: 8.0, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 10.0, + free_float_cap_bn: 8.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: symbol.to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }], + Vec::new(), + Vec::new(), + ) + .expect("dataset"); + let subscriptions = BTreeSet::new(); + let mut portfolio = PortfolioState::new(1_000.0); + portfolio.position_mut(symbol).buy(date, 100, 9.0); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 1, + data: &data, + portfolio: &portfolio, + futures_account: None, + open_orders: &[], + dynamic_universe: None, + subscriptions: &subscriptions, + process_events: &[], + active_process_event: None, + active_datetime: None, + order_events: &[], + fills: &[], + }; + + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.aiquant_transaction_cost = true; + let strategy = PlatformExprStrategy::new(cfg); + assert_eq!(strategy.marked_total_value(&ctx, date), 2_000.0); + } + #[test] fn platform_aiquant_intraday_selection_filters_limits_on_execution_date() { let factor_date = d(2023, 11, 10);