Align jq microcap execution with intraday snapshots
This commit is contained in:
@@ -113,6 +113,39 @@ where
|
||||
snapshot.price(self.execution_price_field)
|
||||
}
|
||||
|
||||
fn snapshot_execution_price(
|
||||
&self,
|
||||
snapshot: &crate::data::DailyMarketSnapshot,
|
||||
side: OrderSide,
|
||||
) -> f64 {
|
||||
if self.execution_price_field == PriceField::Last
|
||||
&& self.intraday_execution_start_time.is_some()
|
||||
{
|
||||
let tick = snapshot.effective_price_tick();
|
||||
let base_price = snapshot.price(PriceField::Last);
|
||||
let adjusted = match side {
|
||||
OrderSide::Buy => base_price + tick * 2.0,
|
||||
OrderSide::Sell => base_price - tick,
|
||||
};
|
||||
let lower = if snapshot.lower_limit.is_finite() && snapshot.lower_limit > 0.0 {
|
||||
snapshot.lower_limit
|
||||
} else {
|
||||
tick
|
||||
};
|
||||
let upper = if snapshot.upper_limit.is_finite() && snapshot.upper_limit > 0.0 {
|
||||
snapshot.upper_limit
|
||||
} else {
|
||||
f64::INFINITY
|
||||
};
|
||||
return adjusted.clamp(lower, upper);
|
||||
}
|
||||
|
||||
match side {
|
||||
OrderSide::Buy => self.buy_price(snapshot),
|
||||
OrderSide::Sell => self.sell_price(snapshot),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn execute(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
@@ -390,10 +423,7 @@ where
|
||||
}
|
||||
(fill.quantity, fill.price)
|
||||
} else {
|
||||
(
|
||||
filled_qty,
|
||||
self.sell_price(snapshot),
|
||||
)
|
||||
(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);
|
||||
@@ -559,16 +589,17 @@ where
|
||||
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 {
|
||||
let round_lot = self.round_lot(data, symbol);
|
||||
let price = self.sizing_price(snapshot);
|
||||
let snapshot_requested_qty =
|
||||
self.round_buy_quantity(((value.abs()) / price).floor() as u32, round_lot);
|
||||
self.process_buy(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
symbol,
|
||||
requested_qty,
|
||||
snapshot_requested_qty,
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
@@ -577,6 +608,11 @@ where
|
||||
report,
|
||||
)
|
||||
} else {
|
||||
let price = self.sizing_price(snapshot);
|
||||
let requested_qty = self.round_buy_quantity(
|
||||
((value.abs()) / price).floor() as u32,
|
||||
self.round_lot(data, symbol),
|
||||
);
|
||||
self.process_sell(
|
||||
date,
|
||||
portfolio,
|
||||
@@ -592,6 +628,236 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
fn estimate_value_buy_quantity(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
portfolio: &PortfolioState,
|
||||
data: &DataSet,
|
||||
symbol: &str,
|
||||
round_lot: u32,
|
||||
value_budget: f64,
|
||||
intraday_turnover: &BTreeMap<String, u32>,
|
||||
execution_cursors: &BTreeMap<String, NaiveDateTime>,
|
||||
global_execution_cursor: Option<NaiveDateTime>,
|
||||
) -> Option<u32> {
|
||||
if self.execution_price_field != PriceField::Last {
|
||||
return None;
|
||||
}
|
||||
let snapshot = data.market(date, symbol)?;
|
||||
let market_limited_qty = self
|
||||
.market_fillable_quantity(
|
||||
snapshot,
|
||||
OrderSide::Buy,
|
||||
u32::MAX,
|
||||
round_lot,
|
||||
*intraday_turnover.get(symbol).unwrap_or(&0),
|
||||
)
|
||||
.ok()?;
|
||||
let max_requested_qty = market_limited_qty;
|
||||
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);
|
||||
let estimated = self.select_buy_sizing_fill(
|
||||
quotes,
|
||||
start_cursor,
|
||||
max_requested_qty,
|
||||
round_lot,
|
||||
Some(portfolio.cash()),
|
||||
Some(value_budget),
|
||||
)?;
|
||||
Some(estimated.quantity)
|
||||
}
|
||||
|
||||
fn maybe_expand_periodic_value_buy_quantity(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
portfolio: &PortfolioState,
|
||||
data: &DataSet,
|
||||
symbol: &str,
|
||||
requested_qty: u32,
|
||||
round_lot: u32,
|
||||
value_budget: f64,
|
||||
reason: &str,
|
||||
execution_cursors: &BTreeMap<String, NaiveDateTime>,
|
||||
global_execution_cursor: Option<NaiveDateTime>,
|
||||
) -> u32 {
|
||||
const PERIODIC_BUY_OVERSHOOT_TOLERANCE: f64 = 400.0;
|
||||
|
||||
if requested_qty == 0 || reason != "periodic_rebalance_buy" {
|
||||
return requested_qty;
|
||||
}
|
||||
|
||||
let candidate_qty = requested_qty.saturating_add(round_lot.max(1));
|
||||
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);
|
||||
let Some(fill) = self.select_execution_fill(
|
||||
quotes,
|
||||
OrderSide::Buy,
|
||||
start_cursor,
|
||||
candidate_qty,
|
||||
round_lot,
|
||||
Some(portfolio.cash()),
|
||||
None,
|
||||
) else {
|
||||
return requested_qty;
|
||||
};
|
||||
if fill.quantity < candidate_qty {
|
||||
return requested_qty;
|
||||
}
|
||||
let candidate_gross = fill.price * fill.quantity as f64;
|
||||
let candidate_cost = self.cost_model.calculate(OrderSide::Buy, candidate_gross);
|
||||
let candidate_cash_out = candidate_gross + candidate_cost.total();
|
||||
if candidate_cash_out <= value_budget + PERIODIC_BUY_OVERSHOOT_TOLERANCE
|
||||
&& candidate_cash_out <= portfolio.cash() + 1e-6
|
||||
{
|
||||
candidate_qty
|
||||
} else {
|
||||
requested_qty
|
||||
}
|
||||
}
|
||||
|
||||
fn select_buy_sizing_fill(
|
||||
&self,
|
||||
quotes: &[IntradayExecutionQuote],
|
||||
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 = if quote.last_price.is_finite() && quote.last_price > 0.0 {
|
||||
Some(quote.last_price)
|
||||
} else {
|
||||
quote.buy_price()
|
||||
};
|
||||
if fallback_quote_price.is_some() {
|
||||
last_quote_price = fallback_quote_price;
|
||||
last_timestamp = Some(quote.timestamp);
|
||||
}
|
||||
|
||||
if quote.volume_delta == 0 {
|
||||
continue;
|
||||
}
|
||||
let Some(quote_price) = fallback_quote_price else {
|
||||
continue;
|
||||
};
|
||||
let available_qty = quote
|
||||
.ask1_volume
|
||||
.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 {
|
||||
gross_amount += residual_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 process_buy(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
@@ -659,7 +925,7 @@ where
|
||||
execution_cursors,
|
||||
None,
|
||||
Some(portfolio.cash()),
|
||||
value_budget,
|
||||
None,
|
||||
);
|
||||
let (filled_qty, execution_price) = if let Some(fill) = fill {
|
||||
execution_cursors.insert(symbol.to_string(), fill.next_cursor);
|
||||
@@ -668,7 +934,7 @@ where
|
||||
}
|
||||
(fill.quantity, fill.price)
|
||||
} else {
|
||||
let execution_price = self.buy_price(snapshot);
|
||||
let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy);
|
||||
let filled_qty = self.affordable_buy_quantity(
|
||||
portfolio.cash(),
|
||||
value_budget,
|
||||
@@ -849,9 +1115,8 @@ where
|
||||
}
|
||||
|
||||
if self.volume_limit {
|
||||
let raw_limit =
|
||||
((snapshot.tick_volume as f64) * self.volume_percent).round() as i64
|
||||
- consumed_turnover as i64;
|
||||
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());
|
||||
}
|
||||
@@ -870,7 +1135,7 @@ where
|
||||
date: NaiveDate,
|
||||
symbol: &str,
|
||||
side: OrderSide,
|
||||
_snapshot: &crate::data::DailyMarketSnapshot,
|
||||
snapshot: &crate::data::DailyMarketSnapshot,
|
||||
data: &DataSet,
|
||||
requested_qty: u32,
|
||||
round_lot: u32,
|
||||
@@ -883,6 +1148,32 @@ where
|
||||
return None;
|
||||
}
|
||||
|
||||
if self.intraday_execution_start_time.is_some() {
|
||||
let execution_price = self.snapshot_execution_price(snapshot, side);
|
||||
let quantity = match side {
|
||||
OrderSide::Buy => self.affordable_buy_quantity(
|
||||
cash_limit.unwrap_or(f64::INFINITY),
|
||||
gross_limit,
|
||||
execution_price,
|
||||
requested_qty,
|
||||
round_lot,
|
||||
),
|
||||
OrderSide::Sell => requested_qty,
|
||||
};
|
||||
if quantity == 0 {
|
||||
return None;
|
||||
}
|
||||
let next_cursor = self
|
||||
.intraday_execution_start_time
|
||||
.map(|start_time| date.and_time(start_time) + Duration::seconds(1))
|
||||
.unwrap_or_else(|| date.and_hms_opt(0, 0, 1).expect("valid midnight"));
|
||||
return Some(ExecutionFill {
|
||||
price: execution_price,
|
||||
quantity,
|
||||
next_cursor,
|
||||
});
|
||||
}
|
||||
|
||||
let start_cursor = execution_cursors
|
||||
.get(symbol)
|
||||
.copied()
|
||||
@@ -1010,7 +1301,8 @@ where
|
||||
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;
|
||||
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;
|
||||
@@ -1049,7 +1341,9 @@ where
|
||||
fn uses_serial_execution_cursor(&self, reason: &str) -> bool {
|
||||
matches!(
|
||||
reason,
|
||||
"stop_loss_exit" | "take_profit_exit" | "replacement_after_stop_loss_exit"
|
||||
"stop_loss_exit"
|
||||
| "take_profit_exit"
|
||||
| "replacement_after_stop_loss_exit"
|
||||
| "replacement_after_take_profit_exit"
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user