Align jq microcap execution with intraday snapshots

This commit is contained in:
boris
2026-04-20 12:13:59 +08:00
parent 0e2c25e4c4
commit 0fe681ff5f
10 changed files with 761 additions and 94 deletions

View File

@@ -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"
)
}