Add smart target-portfolio order intent

This commit is contained in:
boris
2026-04-23 05:57:29 -07:00
parent f805a4b26d
commit 48f8486e1a
4 changed files with 406 additions and 20 deletions

View File

@@ -756,6 +756,25 @@ where
commission_state,
report,
),
OrderIntent::TargetPortfolioSmart {
target_weights,
order_prices,
valuation_prices,
reason,
} => self.process_target_portfolio_smart(
date,
portfolio,
data,
target_weights,
order_prices.as_ref(),
valuation_prices.as_ref(),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
),
OrderIntent::CancelOrder { order_id, reason } => {
self.cancel_open_order(date, *order_id, reason, report);
Ok(())
@@ -929,6 +948,15 @@ where
.retain(|existing| existing.order_id != order_id);
}
fn extend_report(into: &mut BrokerExecutionReport, mut other: BrokerExecutionReport) {
into.order_events.append(&mut other.order_events);
into.fill_events.append(&mut other.fill_events);
into.position_events.append(&mut other.position_events);
into.account_events.append(&mut other.account_events);
into.process_events.append(&mut other.process_events);
into.diagnostics.append(&mut other.diagnostics);
}
fn reserved_open_sell_quantity(&self, symbol: &str, exclude_order_id: Option<u64>) -> u32 {
self.open_orders
.borrow()
@@ -1187,12 +1215,25 @@ where
data: &DataSet,
target_weights: &BTreeMap<String, f64>,
) -> Result<(BTreeMap<String, u32>, Vec<String>), BacktestError> {
let equity = self.rebalance_total_equity_at(date, portfolio, data)?;
self.target_quantities_with_valuation_prices(date, portfolio, data, target_weights, None)
}
fn target_quantities_with_valuation_prices(
&self,
date: NaiveDate,
portfolio: &PortfolioState,
data: &DataSet,
target_weights: &BTreeMap<String, f64>,
valuation_prices: Option<&BTreeMap<String, f64>>,
) -> Result<(BTreeMap<String, u32>, Vec<String>), BacktestError> {
let equity =
self.rebalance_total_equity_at_with_overrides(date, portfolio, data, valuation_prices)?;
let target_weight_sum = target_weights.values().copied().sum::<f64>();
let mut desired_targets = BTreeMap::new();
let mut diagnostics = Vec::new();
for (symbol, weight) in target_weights {
let price = self.rebalance_valuation_price(date, symbol, data)?;
let price =
self.rebalance_valuation_price_with_overrides(date, symbol, data, valuation_prices)?;
let raw_qty = ((equity * weight) / price).floor() as u32;
desired_targets.insert(
symbol.clone(),
@@ -1216,7 +1257,12 @@ where
.map(|pos| pos.quantity)
.unwrap_or(0);
let desired_qty = *desired_targets.get(&symbol).unwrap_or(&0);
let price = self.rebalance_valuation_price(date, &symbol, data)?;
let price = self.rebalance_valuation_price_with_overrides(
date,
&symbol,
data,
valuation_prices,
)?;
let minimum_order_quantity = self.minimum_order_quantity(data, &symbol);
let order_step_size = self.order_step_size(data, &symbol);
let min_target_qty = self.minimum_target_quantity(
@@ -1411,6 +1457,123 @@ where
Ok((best_targets, diagnostics))
}
fn process_target_portfolio_smart(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
target_weights: &BTreeMap<String, f64>,
order_prices: Option<&BTreeMap<String, f64>>,
valuation_prices: Option<&BTreeMap<String, f64>>,
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 (target_quantities, diagnostics) = self.target_quantities_with_valuation_prices(
date,
portfolio,
data,
target_weights,
valuation_prices,
)?;
report.diagnostics.extend(diagnostics);
let mut symbols = BTreeSet::new();
symbols.extend(portfolio.positions().keys().cloned());
symbols.extend(target_quantities.keys().cloned());
for symbol in &symbols {
let current_qty = portfolio.position(symbol).map(|pos| pos.quantity).unwrap_or(0);
let target_qty = target_quantities.get(symbol).copied().unwrap_or(0);
if current_qty <= target_qty {
continue;
}
let sell_qty = current_qty - target_qty;
let mut local_report = BrokerExecutionReport::default();
if let Some(limit_price) =
self.required_custom_order_price(date, symbol, order_prices)?
{
self.process_limit_shares(
date,
portfolio,
data,
symbol,
-(sell_qty as i32),
limit_price,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
&mut local_report,
)?;
} else {
self.process_shares(
date,
portfolio,
data,
symbol,
-(sell_qty as i32),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
&mut local_report,
)?;
}
Self::extend_report(report, local_report);
}
for symbol in &symbols {
let current_qty = portfolio.position(symbol).map(|pos| pos.quantity).unwrap_or(0);
let target_qty = target_quantities.get(symbol).copied().unwrap_or(0);
if target_qty <= current_qty {
continue;
}
let buy_qty = target_qty - current_qty;
let mut local_report = BrokerExecutionReport::default();
if let Some(limit_price) =
self.required_custom_order_price(date, symbol, order_prices)?
{
self.process_limit_shares(
date,
portfolio,
data,
symbol,
buy_qty as i32,
limit_price,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
&mut local_report,
)?;
} else {
self.process_shares(
date,
portfolio,
data,
symbol,
buy_qty as i32,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
&mut local_report,
)?;
}
Self::extend_report(report, local_report);
}
Ok(())
}
fn minimum_target_quantity(
&self,
date: NaiveDate,
@@ -3380,12 +3543,23 @@ where
}
}
fn rebalance_valuation_price(
fn rebalance_valuation_price_with_overrides(
&self,
date: NaiveDate,
symbol: &str,
data: &DataSet,
valuation_prices: Option<&BTreeMap<String, f64>>,
) -> Result<f64, BacktestError> {
if let Some(prices) = valuation_prices {
if let Some(price) = prices.get(symbol).copied().filter(|price| *price > 0.0) {
return Ok(price);
}
return Err(BacktestError::MissingPrice {
date,
symbol: symbol.to_string(),
field: "custom valuation",
});
}
let snapshot = data
.market(date, symbol)
.ok_or_else(|| BacktestError::MissingPrice {
@@ -3406,28 +3580,50 @@ where
date: NaiveDate,
portfolio: &PortfolioState,
data: &DataSet,
) -> Result<f64, BacktestError> {
self.rebalance_total_equity_at_with_overrides(date, portfolio, data, None)
}
fn rebalance_total_equity_at_with_overrides(
&self,
date: NaiveDate,
portfolio: &PortfolioState,
data: &DataSet,
valuation_prices: Option<&BTreeMap<String, f64>>,
) -> Result<f64, BacktestError> {
let mut market_value = 0.0;
for position in portfolio.positions().values() {
let snapshot =
data.market(date, &position.symbol)
.ok_or_else(|| BacktestError::MissingPrice {
date,
symbol: position.symbol.clone(),
field: self.rebalance_valuation_price_field_name(),
})?;
let price = self
.rebalance_valuation_price_for_snapshot(snapshot)
.ok_or_else(|| BacktestError::MissingPrice {
date,
symbol: position.symbol.clone(),
field: self.rebalance_valuation_price_field_name(),
})?;
let price = self.rebalance_valuation_price_with_overrides(
date,
&position.symbol,
data,
valuation_prices,
)?;
market_value += price * position.quantity as f64;
}
Ok(portfolio.cash() + market_value)
}
fn required_custom_order_price(
&self,
date: NaiveDate,
symbol: &str,
order_prices: Option<&BTreeMap<String, f64>>,
) -> Result<Option<f64>, BacktestError> {
let Some(prices) = order_prices else {
return Ok(None);
};
if let Some(price) = prices.get(symbol).copied().filter(|price| *price > 0.0) {
Ok(Some(price))
} else {
Err(BacktestError::MissingPrice {
date,
symbol: symbol.to_string(),
field: "custom order",
})
}
}
fn round_buy_quantity(
&self,
quantity: u32,

View File

@@ -364,6 +364,12 @@ pub enum OrderIntent {
limit_price: f64,
reason: String,
},
TargetPortfolioSmart {
target_weights: BTreeMap<String, f64>,
order_prices: Option<BTreeMap<String, f64>>,
valuation_prices: Option<BTreeMap<String, f64>>,
reason: String,
},
CancelOrder {
order_id: u64,
reason: String,