Align jq microcap execution with intraday snapshots
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime};
|
||||
|
||||
use crate::data::{DataSet, PriceField};
|
||||
use crate::engine::BacktestError;
|
||||
use crate::events::OrderSide;
|
||||
use crate::portfolio::PortfolioState;
|
||||
use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSelector};
|
||||
|
||||
@@ -523,6 +524,20 @@ pub struct JqMicroCapStrategy {
|
||||
config: JqMicroCapConfig,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ProjectedExecutionState {
|
||||
execution_cursors: BTreeMap<String, NaiveDateTime>,
|
||||
global_execution_cursor: Option<NaiveDateTime>,
|
||||
intraday_turnover: BTreeMap<String, u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct ProjectedExecutionFill {
|
||||
price: f64,
|
||||
quantity: u32,
|
||||
next_cursor: NaiveDateTime,
|
||||
}
|
||||
|
||||
impl JqMicroCapStrategy {
|
||||
pub fn new(config: JqMicroCapConfig) -> Self {
|
||||
Self { config }
|
||||
@@ -540,15 +555,28 @@ impl JqMicroCapStrategy {
|
||||
(gross_amount * 0.0003).max(5.0) + (gross_amount * 0.001)
|
||||
}
|
||||
|
||||
fn round_board_lot(&self, quantity: u32) -> u32 {
|
||||
(quantity / 100) * 100
|
||||
fn round_lot_quantity(&self, quantity: u32, round_lot: u32) -> u32 {
|
||||
let lot = round_lot.max(1);
|
||||
(quantity / lot) * lot
|
||||
}
|
||||
|
||||
fn intraday_execution_start_time(&self) -> NaiveTime {
|
||||
NaiveTime::from_hms_opt(10, 18, 0).expect("valid 10:18")
|
||||
}
|
||||
|
||||
fn projected_round_lot(&self, ctx: &StrategyContext<'_>, symbol: &str) -> u32 {
|
||||
ctx.data
|
||||
.instrument(symbol)
|
||||
.map(|instrument| instrument.effective_round_lot())
|
||||
.unwrap_or(100)
|
||||
.max(1)
|
||||
}
|
||||
|
||||
fn projected_buy_quantity(&self, cash: f64, sizing_price: f64, execution_price: f64) -> u32 {
|
||||
if cash <= 0.0 || sizing_price <= 0.0 || execution_price <= 0.0 {
|
||||
return 0;
|
||||
}
|
||||
let mut quantity = self.round_board_lot((cash / sizing_price).floor() as u32);
|
||||
let mut quantity = self.round_lot_quantity((cash / sizing_price).floor() as u32, 100);
|
||||
while quantity > 0 {
|
||||
let gross_amount = execution_price * quantity as f64;
|
||||
let cash_out = gross_amount + self.buy_commission(gross_amount);
|
||||
@@ -560,46 +588,342 @@ impl JqMicroCapStrategy {
|
||||
0
|
||||
}
|
||||
|
||||
fn projected_execution_price(
|
||||
&self,
|
||||
market: &crate::data::DailyMarketSnapshot,
|
||||
side: OrderSide,
|
||||
) -> f64 {
|
||||
let tick = market.effective_price_tick();
|
||||
let base_price = market.price(PriceField::Last);
|
||||
let adjusted = match side {
|
||||
OrderSide::Buy => base_price + tick * 2.0,
|
||||
OrderSide::Sell => base_price - tick,
|
||||
};
|
||||
let lower = if market.lower_limit.is_finite() && market.lower_limit > 0.0 {
|
||||
market.lower_limit
|
||||
} else {
|
||||
tick
|
||||
};
|
||||
let upper = if market.upper_limit.is_finite() && market.upper_limit > 0.0 {
|
||||
market.upper_limit
|
||||
} else {
|
||||
f64::INFINITY
|
||||
};
|
||||
adjusted.clamp(lower, upper)
|
||||
}
|
||||
|
||||
fn project_order_value(
|
||||
&self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
projected: &mut PortfolioState,
|
||||
date: NaiveDate,
|
||||
symbol: &str,
|
||||
sizing_price: f64,
|
||||
execution_price: f64,
|
||||
order_value: f64,
|
||||
reason: &str,
|
||||
execution_state: &mut ProjectedExecutionState,
|
||||
) -> u32 {
|
||||
let quantity = self.projected_buy_quantity(
|
||||
projected.cash().min(order_value),
|
||||
sizing_price,
|
||||
execution_price,
|
||||
if order_value <= 0.0 {
|
||||
return 0;
|
||||
}
|
||||
let round_lot = self.projected_round_lot(ctx, symbol);
|
||||
let market = match ctx.data.market(date, symbol) {
|
||||
Some(market) => market,
|
||||
None => return 0,
|
||||
};
|
||||
let sizing_price = market.price(PriceField::Last);
|
||||
if !sizing_price.is_finite() || sizing_price <= 0.0 {
|
||||
return 0;
|
||||
}
|
||||
let requested_qty = self.round_lot_quantity(
|
||||
((projected.cash().min(order_value)) / sizing_price).floor() as u32,
|
||||
round_lot,
|
||||
);
|
||||
if requested_qty == 0 {
|
||||
return 0;
|
||||
}
|
||||
let execution_price = self.projected_execution_price(market, OrderSide::Buy);
|
||||
let mut quantity = requested_qty;
|
||||
while quantity > 0 {
|
||||
let gross_amount = execution_price * quantity as f64;
|
||||
let cash_out = gross_amount + self.buy_commission(gross_amount);
|
||||
if cash_out <= projected.cash() + 1e-6 {
|
||||
break;
|
||||
}
|
||||
quantity = quantity.saturating_sub(round_lot);
|
||||
}
|
||||
if quantity == 0 {
|
||||
return 0;
|
||||
}
|
||||
let gross_amount = execution_price * quantity as f64;
|
||||
let fill = ProjectedExecutionFill {
|
||||
price: execution_price,
|
||||
quantity,
|
||||
next_cursor: date.and_time(self.intraday_execution_start_time()) + Duration::seconds(1),
|
||||
};
|
||||
let gross_amount = fill.price * fill.quantity as f64;
|
||||
let cash_out = gross_amount + self.buy_commission(gross_amount);
|
||||
if cash_out > projected.cash() + 1e-6 {
|
||||
return 0;
|
||||
}
|
||||
projected.apply_cash_delta(-cash_out);
|
||||
projected.position_mut(symbol).buy(date, quantity, execution_price);
|
||||
quantity
|
||||
projected
|
||||
.position_mut(symbol)
|
||||
.buy(date, fill.quantity, fill.price);
|
||||
*execution_state
|
||||
.intraday_turnover
|
||||
.entry(symbol.to_string())
|
||||
.or_default() += fill.quantity;
|
||||
execution_state
|
||||
.execution_cursors
|
||||
.insert(symbol.to_string(), fill.next_cursor);
|
||||
if self.uses_serial_execution_cursor(reason) {
|
||||
execution_state.global_execution_cursor = Some(fill.next_cursor);
|
||||
}
|
||||
fill.quantity
|
||||
}
|
||||
|
||||
fn project_target_zero(
|
||||
&self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
projected: &mut PortfolioState,
|
||||
date: NaiveDate,
|
||||
symbol: &str,
|
||||
sell_price: f64,
|
||||
reason: &str,
|
||||
execution_state: &mut ProjectedExecutionState,
|
||||
) -> Option<u32> {
|
||||
let quantity = projected.position(symbol)?.quantity;
|
||||
if quantity == 0 {
|
||||
return None;
|
||||
}
|
||||
let gross_amount = sell_price * quantity as f64;
|
||||
let market = ctx.data.market(date, symbol)?;
|
||||
let fill = ProjectedExecutionFill {
|
||||
price: self.projected_execution_price(market, OrderSide::Sell),
|
||||
quantity,
|
||||
next_cursor: date.and_time(self.intraday_execution_start_time()) + Duration::seconds(1),
|
||||
};
|
||||
let gross_amount = fill.price * fill.quantity as f64;
|
||||
let net_cash = gross_amount - self.sell_cost(gross_amount);
|
||||
projected.position_mut(symbol).sell(quantity, sell_price).ok()?;
|
||||
projected
|
||||
.position_mut(symbol)
|
||||
.sell(fill.quantity, fill.price)
|
||||
.ok()?;
|
||||
projected.apply_cash_delta(net_cash);
|
||||
*execution_state
|
||||
.intraday_turnover
|
||||
.entry(symbol.to_string())
|
||||
.or_default() += fill.quantity;
|
||||
execution_state
|
||||
.execution_cursors
|
||||
.insert(symbol.to_string(), fill.next_cursor);
|
||||
if self.uses_serial_execution_cursor(reason) {
|
||||
execution_state.global_execution_cursor = Some(fill.next_cursor);
|
||||
}
|
||||
projected.prune_flat_positions();
|
||||
Some(quantity)
|
||||
Some(fill.quantity)
|
||||
}
|
||||
|
||||
fn projected_market_fillable_quantity(
|
||||
&self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
date: NaiveDate,
|
||||
symbol: &str,
|
||||
side: OrderSide,
|
||||
requested_qty: u32,
|
||||
round_lot: u32,
|
||||
execution_state: &ProjectedExecutionState,
|
||||
) -> Option<u32> {
|
||||
if requested_qty == 0 {
|
||||
return Some(0);
|
||||
}
|
||||
let snapshot = ctx.data.market(date, symbol)?;
|
||||
if snapshot.tick_volume == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let lot = round_lot.max(1);
|
||||
let mut max_fill = requested_qty;
|
||||
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 None;
|
||||
}
|
||||
max_fill = max_fill.min(self.round_lot_quantity(top_level_liquidity, lot));
|
||||
|
||||
let consumed_turnover = *execution_state.intraday_turnover.get(symbol).unwrap_or(&0);
|
||||
let raw_limit =
|
||||
((snapshot.tick_volume as f64) * 0.25).round() as i64 - consumed_turnover as i64;
|
||||
if raw_limit <= 0 {
|
||||
return None;
|
||||
}
|
||||
let volume_limited = self.round_lot_quantity(raw_limit as u32, lot);
|
||||
if volume_limited == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(max_fill.min(volume_limited))
|
||||
}
|
||||
|
||||
fn projected_execution_start_cursor(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
symbol: &str,
|
||||
execution_state: &ProjectedExecutionState,
|
||||
) -> Option<NaiveDateTime> {
|
||||
execution_state
|
||||
.execution_cursors
|
||||
.get(symbol)
|
||||
.copied()
|
||||
.into_iter()
|
||||
.chain(execution_state.global_execution_cursor)
|
||||
.chain(Some(date.and_time(self.intraday_execution_start_time())))
|
||||
.max()
|
||||
}
|
||||
|
||||
fn projected_select_execution_fill(
|
||||
&self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
date: NaiveDate,
|
||||
symbol: &str,
|
||||
side: OrderSide,
|
||||
requested_qty: u32,
|
||||
round_lot: u32,
|
||||
cash_limit: Option<f64>,
|
||||
gross_limit: Option<f64>,
|
||||
execution_state: &ProjectedExecutionState,
|
||||
) -> Option<ProjectedExecutionFill> {
|
||||
if requested_qty == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let lot = round_lot.max(1);
|
||||
let start_cursor = self.projected_execution_start_cursor(date, symbol, execution_state);
|
||||
let quotes = ctx.data.execution_quotes_on(date, symbol);
|
||||
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 => {
|
||||
if quote.last_price.is_finite() && quote.last_price > 0.0 {
|
||||
Some(quote.last_price)
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
|
||||
if quote.volume_delta == 0 {
|
||||
continue;
|
||||
}
|
||||
let Some(quote_price) = fallback_quote_price else {
|
||||
continue;
|
||||
};
|
||||
let available_qty = match side {
|
||||
OrderSide::Buy => quote.ask1_volume,
|
||||
OrderSide::Sell => quote.bid1_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 = self.round_lot_quantity(remaining_qty.min(available_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_cash_out = candidate_gross + self.buy_commission(candidate_gross);
|
||||
if candidate_cash_out <= 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_lot_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_cash_out =
|
||||
candidate_gross + self.buy_commission(candidate_gross);
|
||||
if candidate_cash_out <= 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(ProjectedExecutionFill {
|
||||
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 trading_ratio(
|
||||
@@ -841,6 +1165,7 @@ impl Strategy for JqMicroCapStrategy {
|
||||
let (stock_list, selection_notes) = self.select_symbols(ctx, date, band_low, band_high)?;
|
||||
let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0;
|
||||
let mut projected = ctx.portfolio.clone();
|
||||
let mut projected_execution_state = ProjectedExecutionState::default();
|
||||
let mut order_intents = Vec::new();
|
||||
let mut exit_symbols = BTreeSet::new();
|
||||
|
||||
@@ -855,7 +1180,6 @@ impl Strategy for JqMicroCapStrategy {
|
||||
let Some(market) = ctx.data.market(date, &position.symbol) else {
|
||||
continue;
|
||||
};
|
||||
let sell_price = market.sell_price(PriceField::Last);
|
||||
let stop_hit = current_price
|
||||
<= position.average_cost * self.config.stop_loss_ratio
|
||||
+ self.stop_loss_tolerance(market);
|
||||
@@ -875,7 +1199,14 @@ impl Strategy for JqMicroCapStrategy {
|
||||
reason: sell_reason.to_string(),
|
||||
});
|
||||
if can_sell {
|
||||
self.project_target_zero(&mut projected, &position.symbol, sell_price);
|
||||
self.project_target_zero(
|
||||
ctx,
|
||||
&mut projected,
|
||||
date,
|
||||
&position.symbol,
|
||||
sell_reason,
|
||||
&mut projected_execution_state,
|
||||
);
|
||||
}
|
||||
|
||||
if projected.positions().len() < self.config.stocknum {
|
||||
@@ -897,16 +1228,15 @@ impl Strategy for JqMicroCapStrategy {
|
||||
value: replacement_cash,
|
||||
reason: format!("replacement_after_{}", sell_reason),
|
||||
});
|
||||
if let Some(market) = ctx.data.market(date, symbol) {
|
||||
self.project_order_value(
|
||||
&mut projected,
|
||||
date,
|
||||
symbol,
|
||||
market.buy_price(PriceField::Last),
|
||||
market.buy_price(PriceField::Last),
|
||||
replacement_cash,
|
||||
);
|
||||
}
|
||||
self.project_order_value(
|
||||
ctx,
|
||||
&mut projected,
|
||||
date,
|
||||
symbol,
|
||||
replacement_cash,
|
||||
&format!("replacement_after_{}", sell_reason),
|
||||
&mut projected_execution_state,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -932,13 +1262,14 @@ impl Strategy for JqMicroCapStrategy {
|
||||
target_value: 0.0,
|
||||
reason: "periodic_rebalance_sell".to_string(),
|
||||
});
|
||||
if let Some(price) = ctx
|
||||
.data
|
||||
.market(date, symbol)
|
||||
.map(|market| market.sell_price(PriceField::Last))
|
||||
{
|
||||
self.project_target_zero(&mut projected, symbol, price);
|
||||
}
|
||||
self.project_target_zero(
|
||||
ctx,
|
||||
&mut projected,
|
||||
date,
|
||||
symbol,
|
||||
"periodic_rebalance_sell",
|
||||
&mut projected_execution_state,
|
||||
);
|
||||
}
|
||||
|
||||
let fixed_buy_cash = projected.cash() * trading_ratio / self.config.stocknum as f64;
|
||||
@@ -959,16 +1290,15 @@ impl Strategy for JqMicroCapStrategy {
|
||||
value: fixed_buy_cash,
|
||||
reason: "periodic_rebalance_buy".to_string(),
|
||||
});
|
||||
if let Some(market) = ctx.data.market(date, symbol) {
|
||||
self.project_order_value(
|
||||
&mut projected,
|
||||
date,
|
||||
symbol,
|
||||
market.buy_price(PriceField::Last),
|
||||
market.buy_price(PriceField::Last),
|
||||
fixed_buy_cash,
|
||||
);
|
||||
}
|
||||
self.project_order_value(
|
||||
ctx,
|
||||
&mut projected,
|
||||
date,
|
||||
symbol,
|
||||
fixed_buy_cash,
|
||||
"periodic_rebalance_buy",
|
||||
&mut projected_execution_state,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user