Add smart target-portfolio order intent
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user