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 {
let price = self.rebalance_valuation_price_with_overrides(
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(),
})?;
&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,

View File

@@ -323,6 +323,190 @@ fn broker_executes_target_shares_like_order_to() {
assert_eq!(report.fill_events[0].quantity, 100);
}
#[test]
fn broker_executes_target_portfolio_smart_with_custom_prices() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let data = DataSet::from_components(
vec![
Instrument {
symbol: "000001.SZ".to_string(),
name: "Old".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
},
Instrument {
symbol: "000002.SZ".to_string(),
name: "New".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
},
],
vec![
DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
timestamp: Some("2024-01-10 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 10.1,
low: 9.9,
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,
},
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.1,
low: 9.9,
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: "000001.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(),
},
DailyFactorSnapshot {
date,
symbol: "000002.SZ".to_string(),
market_cap_bn: 45.0,
free_float_cap_bn: 40.0,
pe_ttm: 14.0,
turnover_ratio: Some(2.2),
effective_turnover_ratio: Some(2.0),
extra_factors: BTreeMap::new(),
},
],
vec![
CandidateEligibility {
date,
symbol: "000001.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,
},
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: "000852.SH".to_string(),
open: 1000.0,
close: 1002.0,
prev_close: 998.0,
volume: 1_000_000,
}],
)
.expect("dataset");
let mut portfolio = PortfolioState::new(1_000.0);
portfolio
.position_mut("000001.SZ")
.buy(date.pred_opt().expect("previous day"), 300, 10.0);
let broker = BrokerSimulator::new(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
);
let report = broker
.execute(
date,
&mut portfolio,
&data,
&StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
order_intents: vec![OrderIntent::TargetPortfolioSmart {
target_weights: BTreeMap::from([
("000001.SZ".to_string(), 0.0),
("000002.SZ".to_string(), 0.5),
]),
order_prices: Some(BTreeMap::from([
("000001.SZ".to_string(), 9.8),
("000002.SZ".to_string(), 10.2),
])),
valuation_prices: Some(BTreeMap::from([
("000001.SZ".to_string(), 10.0),
("000002.SZ".to_string(), 20.0),
])),
reason: "test_target_portfolio_smart".to_string(),
}],
notes: Vec::new(),
diagnostics: Vec::new(),
},
)
.expect("broker execution");
assert_eq!(report.order_events.len(), 2);
assert_eq!(report.fill_events.len(), 2);
assert_eq!(report.fill_events[0].symbol, "000001.SZ");
assert_eq!(report.fill_events[0].side, fidc_core::OrderSide::Sell);
assert_eq!(report.fill_events[0].quantity, 300);
assert_eq!(report.fill_events[1].symbol, "000002.SZ");
assert_eq!(report.fill_events[1].side, fidc_core::OrderSide::Buy);
assert_eq!(report.fill_events[1].quantity, 100);
assert_eq!(portfolio.position("000001.SZ").map(|pos| pos.quantity).unwrap_or(0), 0);
assert_eq!(
portfolio.position("000002.SZ").map(|pos| pos.quantity),
Some(100)
);
}
#[test]
fn broker_executes_order_percent_and_target_percent() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();

View File

@@ -14,7 +14,7 @@ current alignment pass.
### Phase 1: Strategy API parity
- [ ] `order_to` / target-shares style explicit order primitive
- [x] `order_to` / target-shares style explicit order primitive
- [ ] `order_target_portfolio(_smart)` style public API surface
- [ ] richer explicit order styles exposed to platform scripts
@@ -57,4 +57,4 @@ current alignment pass.
## Current Step
Active implementation target: Phase 1, target-shares / `order_to` parity.
Active implementation target: Phase 1, batch target-portfolio smart semantics.