Improve jq microcap execution semantics
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime};
|
||||
|
||||
use crate::cost::CostModel;
|
||||
use crate::data::{DataSet, PriceField};
|
||||
use crate::data::{DataSet, IntradayExecutionQuote, PriceField};
|
||||
use crate::engine::BacktestError;
|
||||
use crate::events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent};
|
||||
use crate::portfolio::PortfolioState;
|
||||
use crate::rules::EquityRuleHooks;
|
||||
use crate::strategy::StrategyDecision;
|
||||
use crate::strategy::{OrderIntent, StrategyDecision};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct BrokerExecutionReport {
|
||||
@@ -18,10 +18,23 @@ pub struct BrokerExecutionReport {
|
||||
pub account_events: Vec<AccountEvent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct ExecutionFill {
|
||||
price: f64,
|
||||
quantity: u32,
|
||||
next_cursor: NaiveDateTime,
|
||||
}
|
||||
|
||||
pub struct BrokerSimulator<C, R> {
|
||||
cost_model: C,
|
||||
rules: R,
|
||||
board_lot_size: u32,
|
||||
execution_price_field: PriceField,
|
||||
volume_percent: f64,
|
||||
volume_limit: bool,
|
||||
inactive_limit: bool,
|
||||
liquidity_limit: bool,
|
||||
intraday_execution_start_time: Option<NaiveTime>,
|
||||
}
|
||||
|
||||
impl<C, R> BrokerSimulator<C, R> {
|
||||
@@ -30,8 +43,57 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
cost_model,
|
||||
rules,
|
||||
board_lot_size: 100,
|
||||
execution_price_field: PriceField::Open,
|
||||
volume_percent: 0.25,
|
||||
volume_limit: true,
|
||||
inactive_limit: true,
|
||||
liquidity_limit: true,
|
||||
intraday_execution_start_time: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_execution_price(
|
||||
cost_model: C,
|
||||
rules: R,
|
||||
execution_price_field: PriceField,
|
||||
) -> Self {
|
||||
Self {
|
||||
cost_model,
|
||||
rules,
|
||||
board_lot_size: 100,
|
||||
execution_price_field,
|
||||
volume_percent: 0.25,
|
||||
volume_limit: true,
|
||||
inactive_limit: true,
|
||||
liquidity_limit: true,
|
||||
intraday_execution_start_time: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_volume_limit(mut self, enabled: bool) -> Self {
|
||||
self.volume_limit = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_inactive_limit(mut self, enabled: bool) -> Self {
|
||||
self.inactive_limit = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_liquidity_limit(mut self, enabled: bool) -> Self {
|
||||
self.liquidity_limit = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_volume_percent(mut self, volume_percent: f64) -> Self {
|
||||
self.volume_percent = volume_percent;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_intraday_execution_start_time(mut self, start_time: NaiveTime) -> Self {
|
||||
self.intraday_execution_start_time = Some(start_time);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<C, R> BrokerSimulator<C, R>
|
||||
@@ -39,6 +101,18 @@ where
|
||||
C: CostModel,
|
||||
R: EquityRuleHooks,
|
||||
{
|
||||
fn buy_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 {
|
||||
snapshot.buy_price(self.execution_price_field)
|
||||
}
|
||||
|
||||
fn sell_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 {
|
||||
snapshot.sell_price(self.execution_price_field)
|
||||
}
|
||||
|
||||
fn sizing_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 {
|
||||
snapshot.price(self.execution_price_field)
|
||||
}
|
||||
|
||||
pub fn execute(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
@@ -47,6 +121,26 @@ where
|
||||
decision: &StrategyDecision,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
let mut intraday_turnover = BTreeMap::<String, u32>::new();
|
||||
let mut execution_cursors = BTreeMap::<String, NaiveDateTime>::new();
|
||||
let mut global_execution_cursor = None::<NaiveDateTime>;
|
||||
if !decision.order_intents.is_empty() {
|
||||
for intent in &decision.order_intents {
|
||||
self.process_order_intent(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
intent,
|
||||
&mut intraday_turnover,
|
||||
&mut execution_cursors,
|
||||
&mut global_execution_cursor,
|
||||
&mut report,
|
||||
)?;
|
||||
}
|
||||
portfolio.prune_flat_positions();
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
let target_quantities = if decision.rebalance {
|
||||
self.target_quantities(date, portfolio, data, &decision.target_weights)?
|
||||
} else {
|
||||
@@ -59,7 +153,10 @@ where
|
||||
sell_symbols.extend(target_quantities.keys().cloned());
|
||||
|
||||
for symbol in sell_symbols {
|
||||
let current_qty = portfolio.position(&symbol).map(|pos| pos.quantity).unwrap_or(0);
|
||||
let current_qty = portfolio
|
||||
.position(&symbol)
|
||||
.map(|pos| pos.quantity)
|
||||
.unwrap_or(0);
|
||||
if current_qty == 0 {
|
||||
continue;
|
||||
}
|
||||
@@ -81,6 +178,9 @@ where
|
||||
&symbol,
|
||||
requested_qty,
|
||||
sell_reason(decision, &symbol),
|
||||
&mut intraday_turnover,
|
||||
&mut execution_cursors,
|
||||
&mut global_execution_cursor,
|
||||
&mut report,
|
||||
)?;
|
||||
}
|
||||
@@ -88,7 +188,10 @@ where
|
||||
|
||||
if decision.rebalance {
|
||||
for (symbol, target_qty) in target_quantities {
|
||||
let current_qty = portfolio.position(&symbol).map(|pos| pos.quantity).unwrap_or(0);
|
||||
let current_qty = portfolio
|
||||
.position(&symbol)
|
||||
.map(|pos| pos.quantity)
|
||||
.unwrap_or(0);
|
||||
if target_qty > current_qty {
|
||||
let requested_qty = target_qty - current_qty;
|
||||
self.process_buy(
|
||||
@@ -98,6 +201,10 @@ where
|
||||
&symbol,
|
||||
requested_qty,
|
||||
"rebalance_buy",
|
||||
&mut intraday_turnover,
|
||||
&mut execution_cursors,
|
||||
&mut global_execution_cursor,
|
||||
None,
|
||||
&mut report,
|
||||
)?;
|
||||
}
|
||||
@@ -108,6 +215,53 @@ where
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn process_order_intent(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
portfolio: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
intent: &OrderIntent,
|
||||
intraday_turnover: &mut BTreeMap<String, u32>,
|
||||
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
|
||||
global_execution_cursor: &mut Option<NaiveDateTime>,
|
||||
report: &mut BrokerExecutionReport,
|
||||
) -> Result<(), BacktestError> {
|
||||
match intent {
|
||||
OrderIntent::TargetValue {
|
||||
symbol,
|
||||
target_value,
|
||||
reason,
|
||||
} => self.process_target_value(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
symbol,
|
||||
*target_value,
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
global_execution_cursor,
|
||||
report,
|
||||
),
|
||||
OrderIntent::Value {
|
||||
symbol,
|
||||
value,
|
||||
reason,
|
||||
} => self.process_value(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
symbol,
|
||||
*value,
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
global_execution_cursor,
|
||||
report,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn target_quantities(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
@@ -120,14 +274,14 @@ where
|
||||
|
||||
for (symbol, weight) in target_weights {
|
||||
let price = data
|
||||
.price(date, symbol, PriceField::Open)
|
||||
.price(date, symbol, self.execution_price_field)
|
||||
.ok_or_else(|| BacktestError::MissingPrice {
|
||||
date,
|
||||
symbol: symbol.clone(),
|
||||
field: "open",
|
||||
field: price_field_name(self.execution_price_field),
|
||||
})?;
|
||||
let raw_qty = ((equity * weight) / price).floor() as u32;
|
||||
let rounded_qty = self.round_buy_quantity(raw_qty);
|
||||
let rounded_qty = self.round_buy_quantity(raw_qty, self.round_lot(data, symbol));
|
||||
targets.insert(symbol.clone(), rounded_qty);
|
||||
}
|
||||
|
||||
@@ -142,6 +296,9 @@ where
|
||||
symbol: &str,
|
||||
requested_qty: u32,
|
||||
reason: &str,
|
||||
intraday_turnover: &mut BTreeMap<String, u32>,
|
||||
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
|
||||
global_execution_cursor: &mut Option<NaiveDateTime>,
|
||||
report: &mut BrokerExecutionReport,
|
||||
) -> Result<(), BacktestError> {
|
||||
let snapshot = data.require_market(date, symbol)?;
|
||||
@@ -150,22 +307,55 @@ where
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let rule = self.rules.can_sell(date, snapshot, candidate, position);
|
||||
let rule = self.rules.can_sell(
|
||||
date,
|
||||
snapshot,
|
||||
candidate,
|
||||
position,
|
||||
self.execution_price_field,
|
||||
);
|
||||
if !rule.allowed {
|
||||
let status = match rule.reason.as_deref() {
|
||||
Some("paused")
|
||||
| Some("sell disabled by eligibility flags")
|
||||
| Some("open at or below lower limit") => OrderStatus::Canceled,
|
||||
_ => OrderStatus::Rejected,
|
||||
};
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
side: OrderSide::Sell,
|
||||
requested_quantity: requested_qty,
|
||||
filled_quantity: 0,
|
||||
status: OrderStatus::Rejected,
|
||||
status,
|
||||
reason: format!("{reason}: {}", rule.reason.unwrap_or_default()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let sellable = position.sellable_qty(date);
|
||||
let filled_qty = requested_qty.min(sellable);
|
||||
let market_limited_qty = self.market_fillable_quantity(
|
||||
snapshot,
|
||||
OrderSide::Sell,
|
||||
requested_qty.min(sellable),
|
||||
self.round_lot(data, symbol),
|
||||
*intraday_turnover.get(symbol).unwrap_or(&0),
|
||||
);
|
||||
let filled_qty = match market_limited_qty {
|
||||
Ok(quantity) => quantity.min(sellable),
|
||||
Err(limit_reason) => {
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
side: OrderSide::Sell,
|
||||
requested_quantity: requested_qty,
|
||||
filled_quantity: 0,
|
||||
status: OrderStatus::Rejected,
|
||||
reason: format!("{reason}: {limit_reason}"),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if filled_qty == 0 {
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
@@ -180,15 +370,42 @@ where
|
||||
}
|
||||
|
||||
let cash_before = portfolio.cash();
|
||||
let gross_amount = snapshot.open * filled_qty as f64;
|
||||
let fill = self.resolve_execution_fill(
|
||||
date,
|
||||
symbol,
|
||||
OrderSide::Sell,
|
||||
snapshot,
|
||||
data,
|
||||
filled_qty,
|
||||
self.round_lot(data, symbol),
|
||||
execution_cursors,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let (filled_qty, execution_price) = if let Some(fill) = fill {
|
||||
execution_cursors.insert(symbol.to_string(), fill.next_cursor);
|
||||
if self.uses_serial_execution_cursor(reason) {
|
||||
*global_execution_cursor = Some(fill.next_cursor);
|
||||
}
|
||||
(fill.quantity, fill.price)
|
||||
} else {
|
||||
(
|
||||
filled_qty,
|
||||
self.sell_price(snapshot),
|
||||
)
|
||||
};
|
||||
let gross_amount = execution_price * filled_qty as f64;
|
||||
let cost = self.cost_model.calculate(OrderSide::Sell, gross_amount);
|
||||
let net_cash = gross_amount - cost.total();
|
||||
|
||||
let realized_pnl = portfolio
|
||||
.position_mut(symbol)
|
||||
.sell(filled_qty, snapshot.open)
|
||||
.sell(filled_qty, execution_price)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
portfolio.apply_cash_delta(net_cash);
|
||||
portfolio.prune_flat_positions();
|
||||
*intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty;
|
||||
|
||||
let status = if filled_qty < requested_qty {
|
||||
OrderStatus::PartiallyFilled
|
||||
@@ -210,7 +427,7 @@ where
|
||||
symbol: symbol.to_string(),
|
||||
side: OrderSide::Sell,
|
||||
quantity: filled_qty,
|
||||
price: snapshot.open,
|
||||
price: execution_price,
|
||||
gross_amount,
|
||||
commission: cost.commission,
|
||||
stamp_tax: cost.stamp_tax,
|
||||
@@ -221,7 +438,10 @@ where
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
delta_quantity: -(filled_qty as i32),
|
||||
quantity_after: portfolio.position(symbol).map(|pos| pos.quantity).unwrap_or(0),
|
||||
quantity_after: portfolio
|
||||
.position(symbol)
|
||||
.map(|pos| pos.quantity)
|
||||
.unwrap_or(0),
|
||||
average_cost: portfolio
|
||||
.position(symbol)
|
||||
.map(|pos| pos.average_cost)
|
||||
@@ -239,6 +459,139 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_target_value(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
portfolio: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
symbol: &str,
|
||||
target_value: f64,
|
||||
reason: &str,
|
||||
intraday_turnover: &mut BTreeMap<String, u32>,
|
||||
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
|
||||
global_execution_cursor: &mut Option<NaiveDateTime>,
|
||||
report: &mut BrokerExecutionReport,
|
||||
) -> Result<(), BacktestError> {
|
||||
let price = data
|
||||
.market(date, symbol)
|
||||
.map(|snapshot| self.sizing_price(snapshot))
|
||||
.ok_or_else(|| BacktestError::MissingPrice {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
field: price_field_name(self.execution_price_field),
|
||||
})?;
|
||||
let current_qty = portfolio
|
||||
.position(symbol)
|
||||
.map(|pos| pos.quantity)
|
||||
.unwrap_or(0);
|
||||
let current_value = price * current_qty as f64;
|
||||
let target_qty = self.round_buy_quantity(
|
||||
((target_value.max(0.0)) / price).floor() as u32,
|
||||
self.round_lot(data, symbol),
|
||||
);
|
||||
|
||||
if current_qty > target_qty {
|
||||
self.process_sell(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
symbol,
|
||||
current_qty - target_qty,
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
global_execution_cursor,
|
||||
report,
|
||||
)?;
|
||||
} else if target_qty > current_qty {
|
||||
self.process_buy(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
symbol,
|
||||
target_qty - current_qty,
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
global_execution_cursor,
|
||||
None,
|
||||
report,
|
||||
)?;
|
||||
} else if (current_value - target_value).abs() <= f64::EPSILON {
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
side: if current_qty > 0 {
|
||||
OrderSide::Sell
|
||||
} else {
|
||||
OrderSide::Buy
|
||||
},
|
||||
requested_quantity: 0,
|
||||
filled_quantity: 0,
|
||||
status: OrderStatus::Filled,
|
||||
reason: format!("{reason}: already at target value"),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_value(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
portfolio: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
symbol: &str,
|
||||
value: f64,
|
||||
reason: &str,
|
||||
intraday_turnover: &mut BTreeMap<String, u32>,
|
||||
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
|
||||
global_execution_cursor: &mut Option<NaiveDateTime>,
|
||||
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 price = self.sizing_price(snapshot);
|
||||
let requested_qty =
|
||||
self.round_buy_quantity(((value.abs()) / price).floor() as u32, self.round_lot(data, symbol));
|
||||
if value > 0.0 {
|
||||
self.process_buy(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
symbol,
|
||||
requested_qty,
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
global_execution_cursor,
|
||||
Some(value.abs()),
|
||||
report,
|
||||
)
|
||||
} else {
|
||||
self.process_sell(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
symbol,
|
||||
requested_qty,
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
global_execution_cursor,
|
||||
report,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn process_buy(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
@@ -247,12 +600,18 @@ where
|
||||
symbol: &str,
|
||||
requested_qty: u32,
|
||||
reason: &str,
|
||||
intraday_turnover: &mut BTreeMap<String, u32>,
|
||||
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
|
||||
global_execution_cursor: &mut Option<NaiveDateTime>,
|
||||
value_budget: Option<f64>,
|
||||
report: &mut BrokerExecutionReport,
|
||||
) -> Result<(), BacktestError> {
|
||||
let snapshot = data.require_market(date, symbol)?;
|
||||
let candidate = data.require_candidate(date, symbol)?;
|
||||
|
||||
let rule = self.rules.can_buy(date, snapshot, candidate);
|
||||
let rule = self
|
||||
.rules
|
||||
.can_buy(date, snapshot, candidate, self.execution_price_field);
|
||||
if !rule.allowed {
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
@@ -266,8 +625,59 @@ where
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let filled_qty =
|
||||
self.affordable_buy_quantity(portfolio.cash(), snapshot.open, requested_qty);
|
||||
let market_limited_qty = self.market_fillable_quantity(
|
||||
snapshot,
|
||||
OrderSide::Buy,
|
||||
requested_qty,
|
||||
self.round_lot(data, symbol),
|
||||
*intraday_turnover.get(symbol).unwrap_or(&0),
|
||||
);
|
||||
let constrained_qty = match market_limited_qty {
|
||||
Ok(quantity) => quantity,
|
||||
Err(limit_reason) => {
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
side: OrderSide::Buy,
|
||||
requested_quantity: requested_qty,
|
||||
filled_quantity: 0,
|
||||
status: OrderStatus::Rejected,
|
||||
reason: format!("{reason}: {limit_reason}"),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let fill = self.resolve_execution_fill(
|
||||
date,
|
||||
symbol,
|
||||
OrderSide::Buy,
|
||||
snapshot,
|
||||
data,
|
||||
constrained_qty,
|
||||
self.round_lot(data, symbol),
|
||||
execution_cursors,
|
||||
None,
|
||||
Some(portfolio.cash()),
|
||||
value_budget,
|
||||
);
|
||||
let (filled_qty, execution_price) = if let Some(fill) = fill {
|
||||
execution_cursors.insert(symbol.to_string(), fill.next_cursor);
|
||||
if self.uses_serial_execution_cursor(reason) {
|
||||
*global_execution_cursor = Some(fill.next_cursor);
|
||||
}
|
||||
(fill.quantity, fill.price)
|
||||
} else {
|
||||
let execution_price = self.buy_price(snapshot);
|
||||
let filled_qty = self.affordable_buy_quantity(
|
||||
portfolio.cash(),
|
||||
value_budget,
|
||||
execution_price,
|
||||
constrained_qty,
|
||||
self.round_lot(data, symbol),
|
||||
);
|
||||
(filled_qty, execution_price)
|
||||
};
|
||||
if filled_qty == 0 {
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
@@ -282,12 +692,15 @@ where
|
||||
}
|
||||
|
||||
let cash_before = portfolio.cash();
|
||||
let gross_amount = snapshot.open * filled_qty as f64;
|
||||
let gross_amount = execution_price * filled_qty as f64;
|
||||
let cost = self.cost_model.calculate(OrderSide::Buy, gross_amount);
|
||||
let cash_out = gross_amount + cost.total();
|
||||
|
||||
portfolio.apply_cash_delta(-cash_out);
|
||||
portfolio.position_mut(symbol).buy(date, filled_qty, snapshot.open);
|
||||
portfolio
|
||||
.position_mut(symbol)
|
||||
.buy(date, filled_qty, execution_price);
|
||||
*intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty;
|
||||
|
||||
let status = if filled_qty < requested_qty {
|
||||
OrderStatus::PartiallyFilled
|
||||
@@ -309,7 +722,7 @@ where
|
||||
symbol: symbol.to_string(),
|
||||
side: OrderSide::Buy,
|
||||
quantity: filled_qty,
|
||||
price: snapshot.open,
|
||||
price: execution_price,
|
||||
gross_amount,
|
||||
commission: cost.commission,
|
||||
stamp_tax: cost.stamp_tax,
|
||||
@@ -320,7 +733,10 @@ where
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
delta_quantity: filled_qty as i32,
|
||||
quantity_after: portfolio.position(symbol).map(|pos| pos.quantity).unwrap_or(0),
|
||||
quantity_after: portfolio
|
||||
.position(symbol)
|
||||
.map(|pos| pos.quantity)
|
||||
.unwrap_or(0),
|
||||
average_cost: portfolio
|
||||
.position(symbol)
|
||||
.map(|pos| pos.average_cost)
|
||||
@@ -347,38 +763,304 @@ where
|
||||
) -> Result<f64, BacktestError> {
|
||||
let mut market_value = 0.0;
|
||||
for position in portfolio.positions().values() {
|
||||
let price = data
|
||||
.price(date, &position.symbol, field)
|
||||
.ok_or_else(|| BacktestError::MissingPrice {
|
||||
let price = data.price(date, &position.symbol, field).ok_or_else(|| {
|
||||
BacktestError::MissingPrice {
|
||||
date,
|
||||
symbol: position.symbol.clone(),
|
||||
field: match field {
|
||||
PriceField::Open => "open",
|
||||
PriceField::Close => "close",
|
||||
PriceField::Last => "last",
|
||||
},
|
||||
})?;
|
||||
}
|
||||
})?;
|
||||
market_value += price * position.quantity as f64;
|
||||
}
|
||||
|
||||
Ok(portfolio.cash() + market_value)
|
||||
}
|
||||
|
||||
fn round_buy_quantity(&self, quantity: u32) -> u32 {
|
||||
(quantity / self.board_lot_size) * self.board_lot_size
|
||||
fn round_lot(&self, data: &DataSet, symbol: &str) -> u32 {
|
||||
data.instruments()
|
||||
.get(symbol)
|
||||
.map(|instrument| instrument.effective_round_lot())
|
||||
.unwrap_or(self.board_lot_size.max(1))
|
||||
}
|
||||
|
||||
fn affordable_buy_quantity(&self, cash: f64, price: f64, requested_qty: u32) -> u32 {
|
||||
let mut quantity = self.round_buy_quantity(requested_qty);
|
||||
fn round_buy_quantity(&self, quantity: u32, round_lot: u32) -> u32 {
|
||||
let lot = round_lot.max(1);
|
||||
(quantity / lot) * lot
|
||||
}
|
||||
|
||||
fn affordable_buy_quantity(
|
||||
&self,
|
||||
cash: f64,
|
||||
gross_limit: Option<f64>,
|
||||
price: f64,
|
||||
requested_qty: u32,
|
||||
round_lot: u32,
|
||||
) -> u32 {
|
||||
let lot = round_lot.max(1);
|
||||
let mut quantity = self.round_buy_quantity(requested_qty, lot);
|
||||
while quantity > 0 {
|
||||
let gross = price * quantity as f64;
|
||||
if gross_limit.is_some_and(|limit| gross > limit + 1e-6) {
|
||||
quantity = quantity.saturating_sub(lot);
|
||||
continue;
|
||||
}
|
||||
let cost = self.cost_model.calculate(OrderSide::Buy, gross);
|
||||
if gross + cost.total() <= cash + 1e-6 {
|
||||
return quantity;
|
||||
}
|
||||
quantity = quantity.saturating_sub(self.board_lot_size);
|
||||
quantity = quantity.saturating_sub(lot);
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
fn market_fillable_quantity(
|
||||
&self,
|
||||
snapshot: &crate::data::DailyMarketSnapshot,
|
||||
side: OrderSide,
|
||||
requested_qty: u32,
|
||||
round_lot: u32,
|
||||
consumed_turnover: u32,
|
||||
) -> Result<u32, String> {
|
||||
if requested_qty == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
if self.inactive_limit && snapshot.tick_volume == 0 {
|
||||
return Err("tick no volume".to_string());
|
||||
}
|
||||
|
||||
let mut max_fill = requested_qty;
|
||||
let lot = round_lot.max(1);
|
||||
|
||||
if self.liquidity_limit {
|
||||
let top_level_liquidity = match side {
|
||||
OrderSide::Buy => snapshot.liquidity_for_buy(),
|
||||
OrderSide::Sell => snapshot.liquidity_for_sell(),
|
||||
}
|
||||
.min(u32::MAX as u64) as u32;
|
||||
if top_level_liquidity == 0 {
|
||||
return Err("no quote liquidity".to_string());
|
||||
}
|
||||
max_fill = max_fill.min(self.round_buy_quantity(top_level_liquidity, lot));
|
||||
}
|
||||
|
||||
if self.volume_limit {
|
||||
let raw_limit =
|
||||
((snapshot.tick_volume as f64) * self.volume_percent).round() as i64
|
||||
- consumed_turnover as i64;
|
||||
if raw_limit <= 0 {
|
||||
return Err("tick volume limit".to_string());
|
||||
}
|
||||
let volume_limited = self.round_buy_quantity(raw_limit as u32, lot);
|
||||
if volume_limited == 0 {
|
||||
return Err("tick volume limit".to_string());
|
||||
}
|
||||
max_fill = max_fill.min(volume_limited);
|
||||
}
|
||||
|
||||
Ok(max_fill)
|
||||
}
|
||||
|
||||
fn resolve_execution_fill(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
symbol: &str,
|
||||
side: OrderSide,
|
||||
_snapshot: &crate::data::DailyMarketSnapshot,
|
||||
data: &DataSet,
|
||||
requested_qty: u32,
|
||||
round_lot: u32,
|
||||
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
|
||||
global_execution_cursor: Option<NaiveDateTime>,
|
||||
cash_limit: Option<f64>,
|
||||
gross_limit: Option<f64>,
|
||||
) -> Option<ExecutionFill> {
|
||||
if self.execution_price_field != PriceField::Last {
|
||||
return None;
|
||||
}
|
||||
|
||||
let start_cursor = execution_cursors
|
||||
.get(symbol)
|
||||
.copied()
|
||||
.into_iter()
|
||||
.chain(global_execution_cursor)
|
||||
.chain(
|
||||
self.intraday_execution_start_time
|
||||
.map(|start_time| date.and_time(start_time)),
|
||||
)
|
||||
.max();
|
||||
let quotes = data.execution_quotes_on(date, symbol);
|
||||
self.select_execution_fill(
|
||||
quotes,
|
||||
side,
|
||||
start_cursor,
|
||||
requested_qty,
|
||||
round_lot,
|
||||
cash_limit,
|
||||
gross_limit,
|
||||
)
|
||||
}
|
||||
|
||||
fn select_execution_fill(
|
||||
&self,
|
||||
quotes: &[IntradayExecutionQuote],
|
||||
side: OrderSide,
|
||||
start_cursor: Option<NaiveDateTime>,
|
||||
requested_qty: u32,
|
||||
round_lot: u32,
|
||||
cash_limit: Option<f64>,
|
||||
gross_limit: Option<f64>,
|
||||
) -> Option<ExecutionFill> {
|
||||
if requested_qty == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let lot = round_lot.max(1);
|
||||
let mut filled_qty = 0_u32;
|
||||
let mut gross_amount = 0.0_f64;
|
||||
let mut last_timestamp = None;
|
||||
let mut last_quote_price = None;
|
||||
|
||||
for quote in quotes {
|
||||
if start_cursor.is_some_and(|cursor| quote.timestamp < cursor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let fallback_quote_price = match side {
|
||||
OrderSide::Buy => quote.buy_price(),
|
||||
OrderSide::Sell => quote.sell_price(),
|
||||
};
|
||||
if fallback_quote_price.is_some() {
|
||||
last_quote_price = fallback_quote_price;
|
||||
last_timestamp = Some(quote.timestamp);
|
||||
}
|
||||
|
||||
// Approximate JoinQuant market-order fills with the evolving L1 book after
|
||||
// the decision time instead of trade VWAP. This keeps quantities/prices
|
||||
// closer to the observed 10:18 execution logs.
|
||||
if quote.volume_delta == 0 {
|
||||
continue;
|
||||
}
|
||||
let quote_price = match side {
|
||||
OrderSide::Buy => quote.buy_price(),
|
||||
OrderSide::Sell => quote.sell_price(),
|
||||
};
|
||||
let Some(quote_price) = quote_price else {
|
||||
continue;
|
||||
};
|
||||
if !quote_price.is_finite() || quote_price <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let top_level_liquidity = match side {
|
||||
OrderSide::Buy => quote.ask1_volume,
|
||||
OrderSide::Sell => quote.bid1_volume,
|
||||
};
|
||||
let available_qty = top_level_liquidity
|
||||
.saturating_mul(lot as u64)
|
||||
.min(u32::MAX as u64) as u32;
|
||||
if available_qty == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let remaining_qty = requested_qty.saturating_sub(filled_qty);
|
||||
if remaining_qty == 0 {
|
||||
break;
|
||||
}
|
||||
let mut take_qty = remaining_qty.min(available_qty);
|
||||
take_qty = self.round_buy_quantity(take_qty, lot);
|
||||
if take_qty == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(cash) = cash_limit {
|
||||
while take_qty > 0 {
|
||||
let candidate_gross = gross_amount + quote_price * take_qty as f64;
|
||||
if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) {
|
||||
take_qty = take_qty.saturating_sub(lot);
|
||||
continue;
|
||||
}
|
||||
let candidate_cost = self.cost_model.calculate(OrderSide::Buy, candidate_gross);
|
||||
if candidate_gross + candidate_cost.total() <= cash + 1e-6 {
|
||||
break;
|
||||
}
|
||||
take_qty = take_qty.saturating_sub(lot);
|
||||
}
|
||||
if take_qty == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
gross_amount += quote_price * take_qty as f64;
|
||||
filled_qty += take_qty;
|
||||
last_timestamp = Some(quote.timestamp);
|
||||
|
||||
if filled_qty >= requested_qty {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if filled_qty < requested_qty {
|
||||
let remaining_qty = requested_qty.saturating_sub(filled_qty);
|
||||
let mut residual_qty = self.round_buy_quantity(remaining_qty, lot);
|
||||
if residual_qty > 0 {
|
||||
if let Some(residual_price) = last_quote_price {
|
||||
if let Some(cash) = cash_limit {
|
||||
while residual_qty > 0 {
|
||||
let candidate_gross = gross_amount + residual_price * residual_qty as f64;
|
||||
if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) {
|
||||
residual_qty = residual_qty.saturating_sub(lot);
|
||||
continue;
|
||||
}
|
||||
let candidate_cost =
|
||||
self.cost_model.calculate(OrderSide::Buy, candidate_gross);
|
||||
if candidate_gross + candidate_cost.total() <= cash + 1e-6 {
|
||||
break;
|
||||
}
|
||||
residual_qty = residual_qty.saturating_sub(lot);
|
||||
}
|
||||
}
|
||||
if residual_qty > 0 {
|
||||
let execution_price = match side {
|
||||
OrderSide::Buy => residual_price,
|
||||
OrderSide::Sell => residual_price,
|
||||
};
|
||||
gross_amount += execution_price * residual_qty as f64;
|
||||
filled_qty += residual_qty;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if filled_qty == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ExecutionFill {
|
||||
price: gross_amount / filled_qty as f64,
|
||||
quantity: filled_qty,
|
||||
next_cursor: last_timestamp.unwrap() + Duration::seconds(1),
|
||||
})
|
||||
}
|
||||
|
||||
fn uses_serial_execution_cursor(&self, reason: &str) -> bool {
|
||||
matches!(
|
||||
reason,
|
||||
"stop_loss_exit" | "take_profit_exit" | "replacement_after_stop_loss_exit"
|
||||
| "replacement_after_take_profit_exit"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn price_field_name(field: PriceField) -> &'static str {
|
||||
match field {
|
||||
PriceField::Open => "open",
|
||||
PriceField::Close => "close",
|
||||
PriceField::Last => "last",
|
||||
}
|
||||
}
|
||||
|
||||
fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str {
|
||||
|
||||
Reference in New Issue
Block a user