Add algo-order platform actions

This commit is contained in:
boris
2026-04-23 07:36:20 -07:00
parent 152b5c3141
commit ac308c8d68
7 changed files with 883 additions and 32 deletions

View File

@@ -12,7 +12,7 @@ use crate::events::{
}; };
use crate::portfolio::PortfolioState; use crate::portfolio::PortfolioState;
use crate::rules::EquityRuleHooks; use crate::rules::EquityRuleHooks;
use crate::strategy::{OpenOrderView, OrderIntent, StrategyDecision}; use crate::strategy::{AlgoOrderStyle, OpenOrderView, OrderIntent, StrategyDecision};
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct BrokerExecutionReport { pub struct BrokerExecutionReport {
@@ -73,6 +73,7 @@ pub enum MatchingType {
NextTickBestCounterparty, NextTickBestCounterparty,
CounterpartyOffer, CounterpartyOffer,
Vwap, Vwap,
Twap,
} }
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
@@ -83,6 +84,19 @@ pub enum SlippageModel {
LimitPrice, LimitPrice,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AlgoExecutionStyle {
Vwap,
Twap,
}
#[derive(Debug, Clone, Copy)]
struct AlgoExecutionRequest {
style: AlgoExecutionStyle,
start_time: Option<NaiveTime>,
end_time: Option<NaiveTime>,
}
pub struct BrokerSimulator<C, R> { pub struct BrokerSimulator<C, R> {
cost_model: C, cost_model: C,
rules: R, rules: R,
@@ -306,13 +320,25 @@ where
self.apply_slippage(snapshot, side, raw_price) self.apply_slippage(snapshot, side, raw_price)
} }
fn matching_type_for_algo_request(
&self,
algo_request: Option<&AlgoExecutionRequest>,
) -> MatchingType {
match algo_request.map(|request| request.style) {
Some(AlgoExecutionStyle::Vwap) => MatchingType::Vwap,
Some(AlgoExecutionStyle::Twap) => MatchingType::Twap,
None => self.matching_type,
}
}
fn select_quote_reference_price( fn select_quote_reference_price(
&self, &self,
snapshot: &crate::data::DailyMarketSnapshot, snapshot: &crate::data::DailyMarketSnapshot,
quote: &IntradayExecutionQuote, quote: &IntradayExecutionQuote,
side: OrderSide, side: OrderSide,
matching_type: MatchingType,
) -> Option<f64> { ) -> Option<f64> {
let raw_price = match self.matching_type { let raw_price = match matching_type {
MatchingType::NextTickBestOwn => match side { MatchingType::NextTickBestOwn => match side {
OrderSide::Buy => { OrderSide::Buy => {
if quote.bid1.is_finite() && quote.bid1 > 0.0 { if quote.bid1.is_finite() && quote.bid1 > 0.0 {
@@ -343,7 +369,7 @@ where
OrderSide::Sell => quote.sell_price(), OrderSide::Sell => quote.sell_price(),
} }
} }
MatchingType::NextTickLast | MatchingType::Vwap => { MatchingType::NextTickLast | MatchingType::Vwap | MatchingType::Twap => {
if quote.last_price.is_finite() && quote.last_price > 0.0 { if quote.last_price.is_finite() && quote.last_price > 0.0 {
Some(quote.last_price) Some(quote.last_price)
} else { } else {
@@ -452,6 +478,7 @@ where
None, None,
false, false,
true, true,
None,
&mut report, &mut report,
)?; )?;
} }
@@ -481,6 +508,7 @@ where
None, None,
false, false,
true, true,
None,
&mut report, &mut report,
)?; )?;
} }
@@ -756,6 +784,52 @@ where
commission_state, commission_state,
report, report,
), ),
OrderIntent::AlgoValue {
symbol,
value,
style,
start_time,
end_time,
reason,
} => self.process_algo_value(
date,
portfolio,
data,
symbol,
*value,
*style,
*start_time,
*end_time,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
),
OrderIntent::AlgoPercent {
symbol,
percent,
style,
start_time,
end_time,
reason,
} => self.process_algo_percent(
date,
portfolio,
data,
symbol,
*percent,
*style,
*start_time,
*end_time,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
),
OrderIntent::TargetPortfolioSmart { OrderIntent::TargetPortfolioSmart {
target_weights, target_weights,
order_prices, order_prices,
@@ -893,6 +967,7 @@ where
Some(limit_price), Some(limit_price),
true, true,
emit_creation_events, emit_creation_events,
None,
report, report,
) )
} else { } else {
@@ -911,6 +986,7 @@ where
Some(limit_price), Some(limit_price),
true, true,
emit_creation_events, emit_creation_events,
None,
report, report,
) )
} }
@@ -1817,6 +1893,7 @@ where
limit_price: Option<f64>, limit_price: Option<f64>,
allow_pending_limit: bool, allow_pending_limit: bool,
emit_creation_events: bool, emit_creation_events: bool,
algo_request: Option<&AlgoExecutionRequest>,
report: &mut BrokerExecutionReport, report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> { ) -> Result<(), BacktestError> {
let snapshot = data.require_market(date, symbol)?; let snapshot = data.require_market(date, symbol)?;
@@ -2046,6 +2123,7 @@ where
None, None,
None, None,
None, None,
algo_request,
limit_price, limit_price,
); );
let (filled_qty, execution_legs) = if let Some(fill) = fill { let (filled_qty, execution_legs) = if let Some(fill) = fill {
@@ -2348,6 +2426,7 @@ where
None, None,
false, false,
true, true,
None,
report, report,
)?; )?;
} else if target_qty > current_qty { } else if target_qty > current_qty {
@@ -2367,6 +2446,7 @@ where
None, None,
false, false,
true, true,
None,
report, report,
)?; )?;
} else if (current_value - target_value).abs() <= f64::EPSILON { } else if (current_value - target_value).abs() <= f64::EPSILON {
@@ -2435,6 +2515,7 @@ where
None, None,
false, false,
true, true,
None,
report, report,
)?; )?;
} }
@@ -2461,6 +2542,7 @@ where
None, None,
false, false,
true, true,
None,
report, report,
)?; )?;
} }
@@ -2533,6 +2615,7 @@ where
Some(limit_price), Some(limit_price),
true, true,
true, true,
None,
report, report,
)?; )?;
} else if target_qty > current_qty { } else if target_qty > current_qty {
@@ -2552,6 +2635,7 @@ where
Some(limit_price), Some(limit_price),
true, true,
true, true,
None,
report, report,
)?; )?;
} }
@@ -2606,6 +2690,7 @@ where
Some(limit_price), Some(limit_price),
true, true,
true, true,
None,
report, report,
)?; )?;
} }
@@ -2632,6 +2717,7 @@ where
Some(limit_price), Some(limit_price),
true, true,
true, true,
None,
report, report,
)?; )?;
} }
@@ -2764,6 +2850,7 @@ where
None, None,
false, false,
true, true,
None,
report, report,
) )
} else { } else {
@@ -2788,6 +2875,7 @@ where
None, None,
false, false,
true, true,
None,
report, report,
) )
} }
@@ -2856,6 +2944,7 @@ where
Some(limit_price), Some(limit_price),
true, true,
true, true,
None,
report, report,
) )
} else { } else {
@@ -2880,6 +2969,7 @@ where
Some(limit_price), Some(limit_price),
true, true,
true, true,
None,
report, report,
) )
} }
@@ -2947,6 +3037,146 @@ where
) )
} }
fn process_algo_value(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
value: f64,
style: AlgoOrderStyle,
start_time: Option<NaiveTime>,
end_time: Option<NaiveTime>,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
if value.abs() <= f64::EPSILON {
return Ok(());
}
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 algo_request = AlgoExecutionRequest {
style: match style {
AlgoOrderStyle::Vwap => AlgoExecutionStyle::Vwap,
AlgoOrderStyle::Twap => AlgoExecutionStyle::Twap,
},
start_time,
end_time,
};
if value > 0.0 {
let round_lot = self.round_lot(data, symbol);
let minimum_order_quantity = self.minimum_order_quantity(data, symbol);
let order_step_size = self.order_step_size(data, symbol);
let price = self.sizing_price(snapshot);
let snapshot_requested_qty = self.round_buy_quantity(
(value.abs() / price).floor() as u32,
minimum_order_quantity,
order_step_size,
);
let requested_qty = self.maybe_expand_periodic_value_buy_quantity(
date,
portfolio,
data,
symbol,
snapshot_requested_qty,
round_lot,
value.abs(),
reason,
execution_cursors,
*global_execution_cursor,
);
self.process_buy(
date,
portfolio,
data,
symbol,
requested_qty,
self.reserve_order_id(),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
Some(value.abs()),
None,
false,
true,
Some(&algo_request),
report,
)
} else {
let price = self.sizing_price(snapshot);
let requested_qty = self.round_buy_quantity(
(value.abs() / price).floor() as u32,
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
);
self.process_sell(
date,
portfolio,
data,
symbol,
requested_qty,
self.reserve_order_id(),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
None,
false,
true,
Some(&algo_request),
report,
)
}
}
fn process_algo_percent(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
percent: f64,
style: AlgoOrderStyle,
start_time: Option<NaiveTime>,
end_time: Option<NaiveTime>,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let total_equity = self.rebalance_total_equity_at(date, portfolio, data)?;
self.process_algo_value(
date,
portfolio,
data,
symbol,
total_equity * percent,
style,
start_time,
end_time,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
)
}
fn process_shares( fn process_shares(
&self, &self,
date: NaiveDate, date: NaiveDate,
@@ -2986,6 +3216,7 @@ where
None, None,
false, false,
true, true,
None,
report, report,
) )
} else { } else {
@@ -3004,6 +3235,7 @@ where
None, None,
false, false,
true, true,
None,
report, report,
) )
} }
@@ -3078,6 +3310,7 @@ where
limit_price: Option<f64>, limit_price: Option<f64>,
allow_pending_limit: bool, allow_pending_limit: bool,
emit_creation_events: bool, emit_creation_events: bool,
algo_request: Option<&AlgoExecutionRequest>,
report: &mut BrokerExecutionReport, report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> { ) -> Result<(), BacktestError> {
let snapshot = data.require_market(date, symbol)?; let snapshot = data.require_market(date, symbol)?;
@@ -3232,6 +3465,7 @@ where
None, None,
Some(portfolio.cash()), Some(portfolio.cash()),
value_budget.map(|budget| budget + 400.0), value_budget.map(|budget| budget + 400.0),
algo_request,
limit_price, limit_price,
); );
let (filled_qty, execution_legs) = if let Some(fill) = fill { let (filled_qty, execution_legs) = if let Some(fill) = fill {
@@ -3858,22 +4092,32 @@ where
_global_execution_cursor: Option<NaiveDateTime>, _global_execution_cursor: Option<NaiveDateTime>,
cash_limit: Option<f64>, cash_limit: Option<f64>,
gross_limit: Option<f64>, gross_limit: Option<f64>,
algo_request: Option<&AlgoExecutionRequest>,
limit_price: Option<f64>, limit_price: Option<f64>,
) -> Option<ExecutionFill> { ) -> Option<ExecutionFill> {
if self.execution_price_field != PriceField::Last { let matching_type = self.matching_type_for_algo_request(algo_request);
let use_intraday_quotes =
algo_request.is_some() || self.execution_price_field == PriceField::Last;
if !use_intraday_quotes {
return None; return None;
} }
let start_cursor = self let start_cursor = algo_request
.intraday_execution_start_time .and_then(|request| request.start_time)
.or(self.intraday_execution_start_time)
.map(|start_time| date.and_time(start_time)); .map(|start_time| date.and_time(start_time));
let end_cursor = algo_request
.and_then(|request| request.end_time)
.map(|end_time| date.and_time(end_time));
let quotes = data.execution_quotes_on(date, symbol); let quotes = data.execution_quotes_on(date, symbol);
if let Some(fill) = self.select_execution_fill( if let Some(fill) = self.select_execution_fill(
snapshot, snapshot,
quotes, quotes,
side, side,
matching_type,
start_cursor, start_cursor,
end_cursor,
requested_qty, requested_qty,
round_lot, round_lot,
minimum_order_quantity, minimum_order_quantity,
@@ -3886,7 +4130,7 @@ where
return Some(fill); return Some(fill);
} }
if self.intraday_execution_start_time.is_some() { if algo_request.is_some() || self.intraday_execution_start_time.is_some() {
let execution_price = self.snapshot_execution_price(snapshot, side); let execution_price = self.snapshot_execution_price(snapshot, side);
if !self.price_satisfies_limit( if !self.price_satisfies_limit(
side, side,
@@ -3913,8 +4157,9 @@ where
if quantity == 0 { if quantity == 0 {
return None; return None;
} }
let next_cursor = self let next_cursor = algo_request
.intraday_execution_start_time .and_then(|request| request.start_time)
.or(self.intraday_execution_start_time)
.map(|start_time| date.and_time(start_time) + Duration::seconds(1)) .map(|start_time| date.and_time(start_time) + Duration::seconds(1))
.unwrap_or_else(|| date.and_hms_opt(0, 0, 1).expect("valid midnight")); .unwrap_or_else(|| date.and_hms_opt(0, 0, 1).expect("valid midnight"));
return Some(ExecutionFill { return Some(ExecutionFill {
@@ -3942,7 +4187,9 @@ where
snapshot: &crate::data::DailyMarketSnapshot, snapshot: &crate::data::DailyMarketSnapshot,
quotes: &[IntradayExecutionQuote], quotes: &[IntradayExecutionQuote],
side: OrderSide, side: OrderSide,
matching_type: MatchingType,
start_cursor: Option<NaiveDateTime>, start_cursor: Option<NaiveDateTime>,
end_cursor: Option<NaiveDateTime>,
requested_qty: u32, requested_qty: u32,
round_lot: u32, round_lot: u32,
minimum_order_quantity: u32, minimum_order_quantity: u32,
@@ -3957,26 +4204,28 @@ where
} }
let lot = round_lot.max(1); let lot = round_lot.max(1);
let eligible_quotes: Vec<&IntradayExecutionQuote> = quotes
.iter()
.filter(|quote| {
!start_cursor.is_some_and(|cursor| quote.timestamp < cursor)
&& !end_cursor.is_some_and(|cursor| quote.timestamp > cursor)
&& quote.volume_delta != 0
})
.collect();
let mut filled_qty = 0_u32; let mut filled_qty = 0_u32;
let mut gross_amount = 0.0_f64; let mut gross_amount = 0.0_f64;
let mut last_timestamp = None; let mut last_timestamp = None;
let mut legs = Vec::new(); let mut legs = Vec::new();
let mut budget_block_reason = None; let mut budget_block_reason = None;
let mut saw_quote_after_cursor = false; let saw_quote_after_cursor = !eligible_quotes.is_empty();
for quote in quotes {
if start_cursor.is_some_and(|cursor| quote.timestamp < cursor) {
continue;
}
saw_quote_after_cursor = true;
for (quote_index, quote) in eligible_quotes.iter().enumerate() {
// Approximate JoinQuant market-order fills with the evolving L1 book after // Approximate JoinQuant market-order fills with the evolving L1 book after
// the decision time instead of trade VWAP. This keeps quantities/prices // the decision time instead of trade VWAP. This keeps quantities/prices
// closer to the observed 10:18 execution logs. // closer to the observed 10:18 execution logs.
if quote.volume_delta == 0 { let Some(quote_price) =
continue; self.select_quote_reference_price(snapshot, quote, side, matching_type)
} else {
let Some(quote_price) = self.select_quote_reference_price(snapshot, quote, side) else {
continue; continue;
}; };
if !self.price_satisfies_limit( if !self.price_satisfies_limit(
@@ -4003,7 +4252,14 @@ where
if remaining_qty == 0 { if remaining_qty == 0 {
break; break;
} }
let mut take_qty = remaining_qty.min(available_qty); let mut take_qty = if matching_type == MatchingType::Twap {
let remaining_quotes = (eligible_quotes.len() - quote_index) as u32;
let scheduled_qty =
((remaining_qty as f64) / remaining_quotes.max(1) as f64).ceil() as u32;
remaining_qty.min(available_qty).min(scheduled_qty.max(1))
} else {
remaining_qty.min(available_qty)
};
if !(side == OrderSide::Sell && allow_odd_lot_sell && take_qty == remaining_qty) { if !(side == OrderSide::Sell && allow_odd_lot_sell && take_qty == remaining_qty) {
take_qty = take_qty =
self.round_buy_quantity(take_qty, minimum_order_quantity, order_step_size); self.round_buy_quantity(take_qty, minimum_order_quantity, order_step_size);
@@ -4059,7 +4315,7 @@ where
Some(ExecutionFill { Some(ExecutionFill {
quantity: filled_qty, quantity: filled_qty,
next_cursor: last_timestamp.unwrap() + Duration::seconds(1), next_cursor: last_timestamp.unwrap() + Duration::seconds(1),
legs: if self.matching_type == MatchingType::Vwap { legs: if matching_type == MatchingType::Vwap {
vec![ExecutionLeg { vec![ExecutionLeg {
price: gross_amount / filled_qty as f64, price: gross_amount / filled_qty as f64,
quantity: filled_qty, quantity: filled_qty,

View File

@@ -46,7 +46,7 @@ pub use scheduler::{
}; };
pub use strategy::{ pub use strategy::{
CnSmallCapRotationConfig, CnSmallCapRotationStrategy, JqMicroCapConfig, JqMicroCapStrategy, CnSmallCapRotationConfig, CnSmallCapRotationStrategy, JqMicroCapConfig, JqMicroCapStrategy,
OpenOrderView, OrderIntent, Strategy, StrategyContext, StrategyDecision, AlgoOrderStyle, OpenOrderView, OrderIntent, Strategy, StrategyContext, StrategyDecision,
}; };
pub use strategy_ai::{ pub use strategy_ai::{
ManualExample, ManualFactorSource, ManualField, ManualFieldGroup, ManualFunction, ManualExample, ManualFactorSource, ManualField, ManualFieldGroup, ManualFunction,

View File

@@ -11,7 +11,7 @@ use crate::portfolio::PortfolioState;
use crate::scheduler::{ use crate::scheduler::{
ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time, ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time,
}; };
use crate::strategy::{OrderIntent, Strategy, StrategyContext, StrategyDecision}; use crate::strategy::{AlgoOrderStyle, OrderIntent, Strategy, StrategyContext, StrategyDecision};
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlatformScheduleFrequency { pub enum PlatformScheduleFrequency {
@@ -84,8 +84,12 @@ pub enum PlatformExplicitOrderKind {
TargetShares, TargetShares,
LimitTargetShares, LimitTargetShares,
Value, Value,
VwapValue,
TwapValue,
LimitValue, LimitValue,
Percent, Percent,
VwapPercent,
TwapPercent,
LimitPercent, LimitPercent,
TargetValue, TargetValue,
LimitTargetValue, LimitTargetValue,
@@ -114,6 +118,8 @@ pub enum PlatformTradeAction {
symbol: String, symbol: String,
amount_expr: String, amount_expr: String,
limit_price_expr: Option<String>, limit_price_expr: Option<String>,
start_time_expr: Option<String>,
end_time_expr: Option<String>,
when_expr: Option<String>, when_expr: Option<String>,
reason: String, reason: String,
}, },
@@ -2208,6 +2214,31 @@ impl PlatformExprStrategy {
Ok(value.round().max(0.0).min(u64::MAX as f64) as u64) Ok(value.round().max(0.0).min(u64::MAX as f64) as u64)
} }
fn eval_time_expr(
&self,
ctx: &StrategyContext<'_>,
expr: &str,
day: &DayExpressionState,
stock: Option<&StockExpressionState>,
position: Option<&PositionExpressionState>,
) -> Result<NaiveTime, BacktestError> {
let value = self.eval_dynamic(ctx, expr, day, stock, position)?;
let Some(raw) = value.try_cast::<String>() else {
return Err(BacktestError::Execution(format!(
"platform expr did not produce a time string: {}",
expr
)));
};
NaiveTime::parse_from_str(raw.trim(), "%H:%M")
.or_else(|_| NaiveTime::parse_from_str(raw.trim(), "%H:%M:%S"))
.map_err(|_| {
BacktestError::Execution(format!(
"platform expr did not produce a valid HH:MM or HH:MM:SS time: {}",
raw
))
})
}
fn eval_float_map_expr( fn eval_float_map_expr(
&self, &self,
ctx: &StrategyContext<'_>, ctx: &StrategyContext<'_>,
@@ -2376,6 +2407,8 @@ impl PlatformExprStrategy {
symbol, symbol,
amount_expr, amount_expr,
limit_price_expr, limit_price_expr,
start_time_expr,
end_time_expr,
when_expr, when_expr,
reason, reason,
} => { } => {
@@ -2491,6 +2524,38 @@ impl PlatformExprStrategy {
reason: reason.clone(), reason: reason.clone(),
}); });
} }
PlatformExplicitOrderKind::VwapValue
| PlatformExplicitOrderKind::TwapValue => {
let value =
self.eval_float(ctx, amount_expr, day, stock_state.as_ref(), None)?;
if value.abs() <= f64::EPSILON {
continue;
}
let start_time = start_time_expr
.as_deref()
.map(|expr| {
self.eval_time_expr(ctx, expr, day, stock_state.as_ref(), None)
})
.transpose()?;
let end_time = end_time_expr
.as_deref()
.map(|expr| {
self.eval_time_expr(ctx, expr, day, stock_state.as_ref(), None)
})
.transpose()?;
intents.push(OrderIntent::AlgoValue {
symbol: symbol.clone(),
value,
style: if *kind == PlatformExplicitOrderKind::VwapValue {
AlgoOrderStyle::Vwap
} else {
AlgoOrderStyle::Twap
},
start_time,
end_time,
reason: reason.clone(),
});
}
PlatformExplicitOrderKind::LimitValue => { PlatformExplicitOrderKind::LimitValue => {
let value = let value =
self.eval_float(ctx, amount_expr, day, stock_state.as_ref(), None)?; self.eval_float(ctx, amount_expr, day, stock_state.as_ref(), None)?;
@@ -2523,6 +2588,38 @@ impl PlatformExprStrategy {
reason: reason.clone(), reason: reason.clone(),
}); });
} }
PlatformExplicitOrderKind::VwapPercent
| PlatformExplicitOrderKind::TwapPercent => {
let percent =
self.eval_float(ctx, amount_expr, day, stock_state.as_ref(), None)?;
if percent.abs() <= f64::EPSILON {
continue;
}
let start_time = start_time_expr
.as_deref()
.map(|expr| {
self.eval_time_expr(ctx, expr, day, stock_state.as_ref(), None)
})
.transpose()?;
let end_time = end_time_expr
.as_deref()
.map(|expr| {
self.eval_time_expr(ctx, expr, day, stock_state.as_ref(), None)
})
.transpose()?;
intents.push(OrderIntent::AlgoPercent {
symbol: symbol.clone(),
percent,
style: if *kind == PlatformExplicitOrderKind::VwapPercent {
AlgoOrderStyle::Vwap
} else {
AlgoOrderStyle::Twap
},
start_time,
end_time,
reason: reason.clone(),
});
}
PlatformExplicitOrderKind::LimitPercent => { PlatformExplicitOrderKind::LimitPercent => {
let percent = let percent =
self.eval_float(ctx, amount_expr, day, stock_state.as_ref(), None)?; self.eval_float(ctx, amount_expr, day, stock_state.as_ref(), None)?;
@@ -3366,9 +3463,10 @@ mod tests {
PlatformScheduleFrequency, PlatformTradeAction, PlatformUniverseActionKind, PlatformScheduleFrequency, PlatformTradeAction, PlatformUniverseActionKind,
}; };
use crate::{ use crate::{
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot,
Instrument, OpenOrderView, PortfolioState, ProcessEvent, ProcessEventKind, ScheduleStage, DailyMarketSnapshot, DataSet, Instrument, OpenOrderView, PortfolioState, ProcessEvent,
ScheduleTimeRule, Strategy, StrategyContext, TradingCalendar, default_stage_time, ProcessEventKind, ScheduleStage, ScheduleTimeRule, Strategy, StrategyContext,
TradingCalendar, default_stage_time,
}; };
fn d(year: i32, month: u32, day: u32) -> NaiveDate { fn d(year: i32, month: u32, day: u32) -> NaiveDate {
@@ -3546,6 +3644,8 @@ mod tests {
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
amount_expr: "cash * 0.1".to_string(), amount_expr: "cash * 0.1".to_string(),
limit_price_expr: None, limit_price_expr: None,
start_time_expr: None,
end_time_expr: None,
when_expr: Some("allow_buy && !touched_upper_limit".to_string()), when_expr: Some("allow_buy && !touched_upper_limit".to_string()),
reason: "platform_explicit_value".to_string(), reason: "platform_explicit_value".to_string(),
}, },
@@ -3680,6 +3780,8 @@ mod tests {
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
amount_expr: "2000".to_string(), amount_expr: "2000".to_string(),
limit_price_expr: None, limit_price_expr: None,
start_time_expr: None,
end_time_expr: None,
when_expr: Some("allow_buy".to_string()), when_expr: Some("allow_buy".to_string()),
reason: "platform_target_shares".to_string(), reason: "platform_target_shares".to_string(),
}]; }];
@@ -3702,6 +3804,153 @@ mod tests {
} }
} }
#[test]
fn platform_strategy_emits_algo_order_actions() {
let date = d(2025, 2, 3);
let data = DataSet::from_components(
vec![Instrument {
symbol: "000001.SZ".to_string(),
name: "Ping An Bank".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(d(2020, 1, 1)),
delisted_at: None,
status: "active".to_string(),
}],
vec![DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
timestamp: Some("2025-02-03 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 10.4,
low: 9.8,
close: 10.2,
last_price: 10.2,
bid1: 10.18,
ask1: 10.22,
prev_close: 9.9,
volume: 100_000,
tick_volume: 5_000,
bid1_volume: 2_500,
ask1_volume: 2_500,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 10.89,
lower_limit: 8.91,
price_tick: 0.01,
}],
vec![DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
market_cap_bn: 50.0,
free_float_cap_bn: 45.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.2),
effective_turnover_ratio: Some(1.0),
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
date,
symbol: "000001.SZ".to_string(),
is_st: false,
is_new_listing: false,
is_kcb: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_one_yuan: false,
}],
vec![BenchmarkSnapshot {
date,
benchmark: "000852.SH".to_string(),
open: 1000.0,
close: 1001.0,
prev_close: 999.0,
volume: 100_000,
}],
)
.expect("dataset");
let portfolio = PortfolioState::new(1_000_000.0);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
decision_date: date,
decision_index: 0,
data: &data,
portfolio: &portfolio,
open_orders: &[],
dynamic_universe: None,
subscriptions: &subscriptions,
process_events: &[],
active_process_event: None,
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = "000001.SZ".to_string();
cfg.rotation_enabled = false;
cfg.benchmark_short_ma_days = 1;
cfg.benchmark_long_ma_days = 1;
cfg.explicit_actions = vec![
PlatformTradeAction::Order {
kind: PlatformExplicitOrderKind::VwapValue,
symbol: "000001.SZ".to_string(),
amount_expr: "cash * 0.1".to_string(),
limit_price_expr: None,
start_time_expr: Some("\"09:31\"".to_string()),
end_time_expr: Some("\"09:40\"".to_string()),
when_expr: Some("allow_buy".to_string()),
reason: "algo_vwap_entry".to_string(),
},
PlatformTradeAction::Order {
kind: PlatformExplicitOrderKind::TwapPercent,
symbol: "000001.SZ".to_string(),
amount_expr: "0.05".to_string(),
limit_price_expr: None,
start_time_expr: Some("\"10:00\"".to_string()),
end_time_expr: Some("\"10:30\"".to_string()),
when_expr: Some("allow_buy".to_string()),
reason: "algo_twap_entry".to_string(),
},
];
let mut strategy = PlatformExprStrategy::new(cfg);
let decision = strategy.on_day(&ctx).expect("platform decision");
assert_eq!(decision.order_intents.len(), 2);
match &decision.order_intents[0] {
crate::strategy::OrderIntent::AlgoValue {
style,
start_time,
end_time,
..
} => {
assert_eq!(*style, AlgoOrderStyle::Vwap);
assert_eq!(
*start_time,
Some(NaiveTime::from_hms_opt(9, 31, 0).unwrap())
);
assert_eq!(*end_time, Some(NaiveTime::from_hms_opt(9, 40, 0).unwrap()));
}
other => panic!("unexpected algo value intent: {other:?}"),
}
match &decision.order_intents[1] {
crate::strategy::OrderIntent::AlgoPercent {
style,
start_time,
end_time,
..
} => {
assert_eq!(*style, AlgoOrderStyle::Twap);
assert_eq!(
*start_time,
Some(NaiveTime::from_hms_opt(10, 0, 0).unwrap())
);
assert_eq!(*end_time, Some(NaiveTime::from_hms_opt(10, 30, 0).unwrap()));
}
other => panic!("unexpected algo percent intent: {other:?}"),
}
}
#[test] #[test]
fn platform_strategy_emits_target_portfolio_smart_explicit_action() { fn platform_strategy_emits_target_portfolio_smart_explicit_action() {
let date = d(2025, 2, 3); let date = d(2025, 2, 3);
@@ -3904,6 +4153,8 @@ mod tests {
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
amount_expr: "0.25".to_string(), amount_expr: "0.25".to_string(),
limit_price_expr: None, limit_price_expr: None,
start_time_expr: None,
end_time_expr: None,
when_expr: Some("allow_buy".to_string()), when_expr: Some("allow_buy".to_string()),
reason: "auction_percent_entry".to_string(), reason: "auction_percent_entry".to_string(),
}]; }];
@@ -4021,6 +4272,8 @@ mod tests {
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
amount_expr: "0.25".to_string(), amount_expr: "0.25".to_string(),
limit_price_expr: None, limit_price_expr: None,
start_time_expr: None,
end_time_expr: None,
when_expr: Some("allow_buy".to_string()), when_expr: Some("allow_buy".to_string()),
reason: "auction_percent_entry".to_string(), reason: "auction_percent_entry".to_string(),
}]; }];
@@ -4131,6 +4384,8 @@ mod tests {
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
amount_expr: "cash * 0.1".to_string(), amount_expr: "cash * 0.1".to_string(),
limit_price_expr: None, limit_price_expr: None,
start_time_expr: None,
end_time_expr: None,
when_expr: Some( when_expr: Some(
"has_open_orders && open_order_count == 1 && open_sell_qty == 200 && symbol_open_sell_qty == 200 && symbol_open_order_count == 1".to_string(), "has_open_orders && open_order_count == 1 && open_sell_qty == 200 && symbol_open_sell_qty == 200 && symbol_open_order_count == 1".to_string(),
), ),
@@ -4491,6 +4746,8 @@ mod tests {
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
amount_expr: "cash * 0.1".to_string(), amount_expr: "cash * 0.1".to_string(),
limit_price_expr: None, limit_price_expr: None,
start_time_expr: None,
end_time_expr: None,
when_expr: Some( when_expr: Some(
"has_process_events && process_event_count == 1 && latest_process_kind == \"order_creation_reject\" && latest_process_order_id == 55 && latest_process_symbol == \"000001.SZ\" && latest_process_side == \"buy\" && process_event_counts[\"order_creation_reject\"] == 1".to_string(), "has_process_events && process_event_count == 1 && latest_process_kind == \"order_creation_reject\" && latest_process_order_id == 55 && latest_process_symbol == \"000001.SZ\" && latest_process_side == \"buy\" && process_event_counts[\"order_creation_reject\"] == 1".to_string(),
), ),

View File

@@ -328,6 +328,12 @@ impl StrategyDecision {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AlgoOrderStyle {
Vwap,
Twap,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum OrderIntent { pub enum OrderIntent {
Shares { Shares {
@@ -407,6 +413,22 @@ pub enum OrderIntent {
limit_price: f64, limit_price: f64,
reason: String, reason: String,
}, },
AlgoValue {
symbol: String,
value: f64,
style: AlgoOrderStyle,
start_time: Option<NaiveTime>,
end_time: Option<NaiveTime>,
reason: String,
},
AlgoPercent {
symbol: String,
percent: f64,
style: AlgoOrderStyle,
start_time: Option<NaiveTime>,
end_time: Option<NaiveTime>,
reason: String,
},
TargetPortfolioSmart { TargetPortfolioSmart {
target_weights: BTreeMap<String, f64>, target_weights: BTreeMap<String, f64>,
order_prices: Option<BTreeMap<String, f64>>, order_prices: Option<BTreeMap<String, f64>>,

View File

@@ -120,7 +120,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
}, },
ManualSection { ManualSection {
title: "trading.rotation / order.* / cancel.* / update_universe / subscribe".to_string(), title: "trading.rotation / order.* / cancel.* / update_universe / subscribe".to_string(),
detail: "支持显式下单、撤单和动态 universe 管理。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段,用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99)、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices={\"600000.SH\": open * 0.99}, valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])。其中 order.target_shares(...) 对应 rqalpha 的 order_toorder.target_portfolio_smart(...) 对应 rqalpha 的 order_target_portfolio_smart 批量目标权重语义,而 update_universe/subscribe/unsubscribe 对应 rqalpha 的动态 universe 与订阅接口。symbol 使用标准证券代码数量、金额、仓位、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(), detail: "支持显式下单、撤单、AlgoOrder 和动态 universe 管理。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段,用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99)、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices={\"600000.SH\": open * 0.99}, valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])。其中 order.target_shares(...) 对应 rqalpha 的 order_toorder.target_portfolio_smart(...) 对应 rqalpha 的 order_target_portfolio_smart 批量目标权重语义,order.vwap_* / order.twap_* 对应 rqalpha 的 AlgoOrder 时间窗订单风格,而 update_universe/subscribe/unsubscribe 对应 rqalpha 的动态 universe 与订阅接口。symbol 使用标准证券代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
}, },
ManualSection { ManualSection {
title: "when / unless / else".to_string(), title: "when / unless / else".to_string(),

View File

@@ -1,6 +1,6 @@
use chrono::NaiveDate; use chrono::{NaiveDate, NaiveTime};
use fidc_core::{ use fidc_core::{
BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, AlgoOrderStyle, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel,
ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, Instrument, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, Instrument,
IntradayExecutionQuote, MatchingType, OrderIntent, OrderStatus, PortfolioState, PriceField, IntradayExecutionQuote, MatchingType, OrderIntent, OrderStatus, PortfolioState, PriceField,
ProcessEventKind, SlippageModel, StrategyDecision, ProcessEventKind, SlippageModel, StrategyDecision,
@@ -1684,6 +1684,322 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
); );
} }
#[test]
fn broker_executes_algo_vwap_value_with_time_window() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let data = DataSet::from_components_with_actions_and_quotes(
vec![Instrument {
symbol: "000002.SZ".to_string(),
name: "Test".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
}],
vec![DailyMarketSnapshot {
date,
symbol: "000002.SZ".to_string(),
timestamp: Some("2024-01-10 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 10.2,
low: 9.8,
close: 10.0,
last_price: 10.0,
bid1: 9.99,
ask1: 10.01,
prev_close: 10.0,
volume: 100_000,
tick_volume: 100_000,
bid1_volume: 80_000,
ask1_volume: 80_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
}],
vec![DailyFactorSnapshot {
date,
symbol: "000002.SZ".to_string(),
market_cap_bn: 50.0,
free_float_cap_bn: 45.0,
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
date,
symbol: "000002.SZ".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: "000300.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 99.0,
volume: 1_000_000,
}],
Vec::new(),
vec![
IntradayExecutionQuote {
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 17, 59).unwrap(),
last_price: 9.98,
bid1: 9.97,
ask1: 9.99,
bid1_volume: 1,
ask1_volume: 1,
volume_delta: 1,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
last_price: 10.01,
bid1: 10.0,
ask1: 10.02,
bid1_volume: 1,
ask1_volume: 1,
volume_delta: 1,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 6).unwrap(),
last_price: 10.03,
bid1: 10.02,
ask1: 10.04,
bid1_volume: 1,
ask1_volume: 1,
volume_delta: 1,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 40).unwrap(),
last_price: 10.10,
bid1: 10.09,
ask1: 10.11,
bid1_volume: 1,
ask1_volume: 1,
volume_delta: 1,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
],
)
.expect("dataset");
let mut portfolio = PortfolioState::new(1_000_000.0);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Last,
);
let report = broker
.execute(
date,
&mut portfolio,
&data,
&StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
order_intents: vec![OrderIntent::AlgoValue {
symbol: "000002.SZ".to_string(),
value: 2_500.0,
style: AlgoOrderStyle::Vwap,
start_time: Some(NaiveTime::from_hms_opt(10, 18, 0).unwrap()),
end_time: Some(NaiveTime::from_hms_opt(10, 18, 10).unwrap()),
reason: "algo_vwap_window".to_string(),
}],
notes: Vec::new(),
diagnostics: Vec::new(),
},
)
.expect("broker execution");
assert_eq!(report.fill_events.len(), 1);
assert_eq!(report.fill_events[0].quantity, 200);
assert!((report.fill_events[0].price - 10.02).abs() < 1e-9);
}
#[test]
fn broker_executes_algo_twap_percent_across_window_quotes() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let data = DataSet::from_components_with_actions_and_quotes(
vec![Instrument {
symbol: "000002.SZ".to_string(),
name: "Test".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
}],
vec![DailyMarketSnapshot {
date,
symbol: "000002.SZ".to_string(),
timestamp: Some("2024-01-10 10:18:00".to_string()),
day_open: 12.0,
open: 12.0,
high: 12.2,
low: 11.8,
close: 12.0,
last_price: 12.0,
bid1: 11.99,
ask1: 12.01,
prev_close: 12.0,
volume: 100_000,
tick_volume: 100_000,
bid1_volume: 80_000,
ask1_volume: 80_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 13.2,
lower_limit: 10.8,
price_tick: 0.01,
}],
vec![DailyFactorSnapshot {
date,
symbol: "000002.SZ".to_string(),
market_cap_bn: 50.0,
free_float_cap_bn: 45.0,
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
date,
symbol: "000002.SZ".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: "000300.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 99.0,
volume: 1_000_000,
}],
Vec::new(),
vec![
IntradayExecutionQuote {
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 0, 0).unwrap(),
last_price: 12.00,
bid1: 11.99,
ask1: 12.01,
bid1_volume: 1,
ask1_volume: 1,
volume_delta: 1,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 15, 0).unwrap(),
last_price: 12.03,
bid1: 12.02,
ask1: 12.04,
bid1_volume: 1,
ask1_volume: 1,
volume_delta: 1,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 30, 0).unwrap(),
last_price: 12.06,
bid1: 12.05,
ask1: 12.07,
bid1_volume: 1,
ask1_volume: 1,
volume_delta: 1,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
],
)
.expect("dataset");
let mut portfolio = PortfolioState::new(1_000_000.0);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Last,
);
let report = broker
.execute(
date,
&mut portfolio,
&data,
&StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
order_intents: vec![OrderIntent::AlgoPercent {
symbol: "000002.SZ".to_string(),
percent: 0.0036,
style: AlgoOrderStyle::Twap,
start_time: Some(NaiveTime::from_hms_opt(10, 0, 0).unwrap()),
end_time: Some(NaiveTime::from_hms_opt(10, 30, 0).unwrap()),
reason: "algo_twap_window".to_string(),
}],
notes: Vec::new(),
diagnostics: Vec::new(),
},
)
.expect("broker execution");
assert_eq!(report.fill_events.len(), 3);
assert_eq!(
report
.fill_events
.iter()
.map(|fill| fill.quantity)
.sum::<u32>(),
300
);
assert!(report.fill_events.iter().all(|fill| fill.quantity == 100));
assert_eq!(
report
.process_events
.iter()
.filter(|event| event.kind == ProcessEventKind::Trade)
.count(),
3
);
}
#[test] #[test]
fn broker_uses_best_own_price_for_intraday_matching() { fn broker_uses_best_own_price_for_intraday_matching() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();

View File

@@ -35,8 +35,8 @@ current alignment pass.
### Phase 4: Algo order parity ### Phase 4: Algo order parity
- [ ] `VWAPOrder` - [x] `VWAPOrder` first-class explicit action parity (`order.vwap_value/percent`)
- [ ] `TWAPOrder` - [x] `TWAPOrder` first-class explicit action parity (`order.twap_value/percent`)
- [ ] `order_target_portfolio_smart(..., order_prices=AlgoOrder, valuation_prices=...)` - [ ] `order_target_portfolio_smart(..., order_prices=AlgoOrder, valuation_prices=...)`
### Phase 5: Position accounting parity ### Phase 5: Position accounting parity