Align jq microcap execution with intraday snapshots
This commit is contained in:
@@ -113,6 +113,39 @@ where
|
|||||||
snapshot.price(self.execution_price_field)
|
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(
|
pub fn execute(
|
||||||
&self,
|
&self,
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
@@ -390,10 +423,7 @@ where
|
|||||||
}
|
}
|
||||||
(fill.quantity, fill.price)
|
(fill.quantity, fill.price)
|
||||||
} else {
|
} else {
|
||||||
(
|
(filled_qty, self.sell_price(snapshot))
|
||||||
filled_qty,
|
|
||||||
self.sell_price(snapshot),
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
let gross_amount = execution_price * filled_qty as f64;
|
let gross_amount = execution_price * filled_qty as f64;
|
||||||
let cost = self.cost_model.calculate(OrderSide::Sell, gross_amount);
|
let cost = self.cost_model.calculate(OrderSide::Sell, gross_amount);
|
||||||
@@ -559,16 +589,17 @@ where
|
|||||||
symbol: symbol.to_string(),
|
symbol: symbol.to_string(),
|
||||||
field: price_field_name(self.execution_price_field),
|
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 {
|
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(
|
self.process_buy(
|
||||||
date,
|
date,
|
||||||
portfolio,
|
portfolio,
|
||||||
data,
|
data,
|
||||||
symbol,
|
symbol,
|
||||||
requested_qty,
|
snapshot_requested_qty,
|
||||||
reason,
|
reason,
|
||||||
intraday_turnover,
|
intraday_turnover,
|
||||||
execution_cursors,
|
execution_cursors,
|
||||||
@@ -577,6 +608,11 @@ where
|
|||||||
report,
|
report,
|
||||||
)
|
)
|
||||||
} else {
|
} 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(
|
self.process_sell(
|
||||||
date,
|
date,
|
||||||
portfolio,
|
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(
|
fn process_buy(
|
||||||
&self,
|
&self,
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
@@ -659,7 +925,7 @@ where
|
|||||||
execution_cursors,
|
execution_cursors,
|
||||||
None,
|
None,
|
||||||
Some(portfolio.cash()),
|
Some(portfolio.cash()),
|
||||||
value_budget,
|
None,
|
||||||
);
|
);
|
||||||
let (filled_qty, execution_price) = if let Some(fill) = fill {
|
let (filled_qty, execution_price) = if let Some(fill) = fill {
|
||||||
execution_cursors.insert(symbol.to_string(), fill.next_cursor);
|
execution_cursors.insert(symbol.to_string(), fill.next_cursor);
|
||||||
@@ -668,7 +934,7 @@ where
|
|||||||
}
|
}
|
||||||
(fill.quantity, fill.price)
|
(fill.quantity, fill.price)
|
||||||
} else {
|
} 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(
|
let filled_qty = self.affordable_buy_quantity(
|
||||||
portfolio.cash(),
|
portfolio.cash(),
|
||||||
value_budget,
|
value_budget,
|
||||||
@@ -849,8 +1115,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
if self.volume_limit {
|
if self.volume_limit {
|
||||||
let raw_limit =
|
let raw_limit = ((snapshot.tick_volume as f64) * self.volume_percent).round() as i64
|
||||||
((snapshot.tick_volume as f64) * self.volume_percent).round() as i64
|
|
||||||
- consumed_turnover as i64;
|
- consumed_turnover as i64;
|
||||||
if raw_limit <= 0 {
|
if raw_limit <= 0 {
|
||||||
return Err("tick volume limit".to_string());
|
return Err("tick volume limit".to_string());
|
||||||
@@ -870,7 +1135,7 @@ where
|
|||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
side: OrderSide,
|
side: OrderSide,
|
||||||
_snapshot: &crate::data::DailyMarketSnapshot,
|
snapshot: &crate::data::DailyMarketSnapshot,
|
||||||
data: &DataSet,
|
data: &DataSet,
|
||||||
requested_qty: u32,
|
requested_qty: u32,
|
||||||
round_lot: u32,
|
round_lot: u32,
|
||||||
@@ -883,6 +1148,32 @@ where
|
|||||||
return None;
|
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
|
let start_cursor = execution_cursors
|
||||||
.get(symbol)
|
.get(symbol)
|
||||||
.copied()
|
.copied()
|
||||||
@@ -1010,7 +1301,8 @@ where
|
|||||||
if let Some(residual_price) = last_quote_price {
|
if let Some(residual_price) = last_quote_price {
|
||||||
if let Some(cash) = cash_limit {
|
if let Some(cash) = cash_limit {
|
||||||
while residual_qty > 0 {
|
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) {
|
if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) {
|
||||||
residual_qty = residual_qty.saturating_sub(lot);
|
residual_qty = residual_qty.saturating_sub(lot);
|
||||||
continue;
|
continue;
|
||||||
@@ -1049,7 +1341,9 @@ where
|
|||||||
fn uses_serial_execution_cursor(&self, reason: &str) -> bool {
|
fn uses_serial_execution_cursor(&self, reason: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
reason,
|
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"
|
| "replacement_after_take_profit_exit"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1280,7 +1280,11 @@ mod optional_date_format {
|
|||||||
D: Deserializer<'de>,
|
D: Deserializer<'de>,
|
||||||
{
|
{
|
||||||
let text = Option::<String>::deserialize(deserializer)?;
|
let text = Option::<String>::deserialize(deserializer)?;
|
||||||
match text.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
match text
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
Some(text) => NaiveDate::parse_from_str(text, FORMAT)
|
Some(text) => NaiveDate::parse_from_str(text, FORMAT)
|
||||||
.map(Some)
|
.map(Some)
|
||||||
.map_err(serde::de::Error::custom),
|
.map_err(serde::de::Error::custom),
|
||||||
|
|||||||
@@ -116,7 +116,10 @@ where
|
|||||||
self.run_with_progress(|_| {})
|
self.run_with_progress(|_| {})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_with_progress<F>(&mut self, mut on_progress: F) -> Result<BacktestResult, BacktestError>
|
pub fn run_with_progress<F>(
|
||||||
|
&mut self,
|
||||||
|
mut on_progress: F,
|
||||||
|
) -> Result<BacktestResult, BacktestError>
|
||||||
where
|
where
|
||||||
F: FnMut(&BacktestDayProgress),
|
F: FnMut(&BacktestDayProgress),
|
||||||
{
|
{
|
||||||
@@ -287,7 +290,9 @@ where
|
|||||||
) -> BrokerExecutionReport {
|
) -> BrokerExecutionReport {
|
||||||
result.order_events.extend(report.order_events.clone());
|
result.order_events.extend(report.order_events.clone());
|
||||||
result.fills.extend(report.fill_events.clone());
|
result.fills.extend(report.fill_events.clone());
|
||||||
result.position_events.extend(report.position_events.clone());
|
result
|
||||||
|
.position_events
|
||||||
|
.extend(report.position_events.clone());
|
||||||
result.account_events.extend(report.account_events.clone());
|
result.account_events.extend(report.account_events.clone());
|
||||||
report
|
report
|
||||||
}
|
}
|
||||||
@@ -338,7 +343,11 @@ where
|
|||||||
});
|
});
|
||||||
format!(
|
format!(
|
||||||
"cash_dividend_receivable {} share_cash={:.6} quantity={} payable_date={} cash={:.2}",
|
"cash_dividend_receivable {} share_cash={:.6} quantity={} payable_date={} cash={:.2}",
|
||||||
action.symbol, action.share_cash, quantity_after, payable_date, cash_delta
|
action.symbol,
|
||||||
|
action.share_cash,
|
||||||
|
quantity_after,
|
||||||
|
payable_date,
|
||||||
|
cash_delta
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
notes.push(note.clone());
|
notes.push(note.clone());
|
||||||
@@ -457,7 +466,10 @@ where
|
|||||||
let settlement_price = self
|
let settlement_price = self
|
||||||
.data
|
.data
|
||||||
.price_on_or_before(effective_delisted_at, &symbol, PriceField::Close)
|
.price_on_or_before(effective_delisted_at, &symbol, PriceField::Close)
|
||||||
.or_else(|| self.data.price_on_or_before(date, &symbol, PriceField::Close))
|
.or_else(|| {
|
||||||
|
self.data
|
||||||
|
.price_on_or_before(date, &symbol, PriceField::Close)
|
||||||
|
})
|
||||||
.filter(|price| price.is_finite() && *price > 0.0)
|
.filter(|price| price.is_finite() && *price > 0.0)
|
||||||
.unwrap_or(fallback_reference_price);
|
.unwrap_or(fallback_reference_price);
|
||||||
if !settlement_price.is_finite() || settlement_price <= 0.0 {
|
if !settlement_price.is_finite() || settlement_price <= 0.0 {
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ impl Instrument {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_delisted_before(&self, date: NaiveDate) -> bool {
|
pub fn is_delisted_before(&self, date: NaiveDate) -> bool {
|
||||||
self.delisted_at.is_some_and(|delisted_at| delisted_at < date)
|
self.delisted_at
|
||||||
|
.is_some_and(|delisted_at| delisted_at < date)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -163,7 +163,9 @@ pub fn compute_backtest_metrics(
|
|||||||
);
|
);
|
||||||
let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR);
|
let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR);
|
||||||
|
|
||||||
let turnover_by_date = fills.iter().fold(BTreeMap::<NaiveDate, f64>::new(), |mut acc, fill| {
|
let turnover_by_date = fills
|
||||||
|
.iter()
|
||||||
|
.fold(BTreeMap::<NaiveDate, f64>::new(), |mut acc, fill| {
|
||||||
*acc.entry(fill.date).or_default() += fill.gross_amount.abs();
|
*acc.entry(fill.date).or_default() += fill.gross_amount.abs();
|
||||||
acc
|
acc
|
||||||
});
|
});
|
||||||
@@ -177,7 +179,10 @@ pub fn compute_backtest_metrics(
|
|||||||
equity_curve
|
equity_curve
|
||||||
.iter()
|
.iter()
|
||||||
.map(|point| {
|
.map(|point| {
|
||||||
let traded = turnover_by_date.get(&point.date).copied().unwrap_or_default();
|
let traded = turnover_by_date
|
||||||
|
.get(&point.date)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default();
|
||||||
safe_div(traded, point.total_equity.max(initial_cash * 0.5), 0.0)
|
safe_div(traded, point.total_equity.max(initial_cash * 0.5), 0.0)
|
||||||
})
|
})
|
||||||
.sum::<f64>()
|
.sum::<f64>()
|
||||||
@@ -270,7 +275,10 @@ fn annualized_sharpe(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f
|
|||||||
if returns.len() < 2 {
|
if returns.len() < 2 {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
let adjusted = returns.iter().map(|value| value - daily_rf).collect::<Vec<_>>();
|
let adjusted = returns
|
||||||
|
.iter()
|
||||||
|
.map(|value| value - daily_rf)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
let mean_ret = mean(&adjusted);
|
let mean_ret = mean(&adjusted);
|
||||||
let std = std_dev(&adjusted);
|
let std = std_dev(&adjusted);
|
||||||
if std <= f64::EPSILON {
|
if std <= f64::EPSILON {
|
||||||
@@ -284,7 +292,10 @@ fn annualized_sortino(returns: &[f64], daily_rf: f64, periods_per_year: f64) ->
|
|||||||
if returns.is_empty() {
|
if returns.is_empty() {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
let adjusted = returns.iter().map(|value| value - daily_rf).collect::<Vec<_>>();
|
let adjusted = returns
|
||||||
|
.iter()
|
||||||
|
.map(|value| value - daily_rf)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
let downside = adjusted
|
let downside = adjusted
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|value| **value < 0.0)
|
.filter(|value| **value < 0.0)
|
||||||
@@ -309,7 +320,10 @@ fn alpha_beta(returns: &[f64], benchmark_returns: &[f64], daily_rf: f64) -> (f64
|
|||||||
if returns.len() < 2 || returns.len() != benchmark_returns.len() {
|
if returns.len() < 2 || returns.len() != benchmark_returns.len() {
|
||||||
return (0.0, 0.0);
|
return (0.0, 0.0);
|
||||||
}
|
}
|
||||||
let strategy_excess = returns.iter().map(|value| value - daily_rf).collect::<Vec<_>>();
|
let strategy_excess = returns
|
||||||
|
.iter()
|
||||||
|
.map(|value| value - daily_rf)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
let benchmark_excess = benchmark_returns
|
let benchmark_excess = benchmark_returns
|
||||||
.iter()
|
.iter()
|
||||||
.map(|value| value - daily_rf)
|
.map(|value| value - daily_rf)
|
||||||
|
|||||||
@@ -138,8 +138,7 @@ impl Position {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_split_ratio(&mut self, ratio: f64) -> i32 {
|
pub fn apply_split_ratio(&mut self, ratio: f64) -> i32 {
|
||||||
if self.quantity == 0 || !ratio.is_finite() || ratio <= 0.0 || (ratio - 1.0).abs() < 1e-9
|
if self.quantity == 0 || !ratio.is_finite() || ratio <= 0.0 || (ratio - 1.0).abs() < 1e-9 {
|
||||||
{
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use chrono::{Datelike, NaiveDate};
|
use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime};
|
||||||
|
|
||||||
use crate::data::{DataSet, PriceField};
|
use crate::data::{DataSet, PriceField};
|
||||||
use crate::engine::BacktestError;
|
use crate::engine::BacktestError;
|
||||||
|
use crate::events::OrderSide;
|
||||||
use crate::portfolio::PortfolioState;
|
use crate::portfolio::PortfolioState;
|
||||||
use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSelector};
|
use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSelector};
|
||||||
|
|
||||||
@@ -523,6 +524,20 @@ pub struct JqMicroCapStrategy {
|
|||||||
config: JqMicroCapConfig,
|
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 {
|
impl JqMicroCapStrategy {
|
||||||
pub fn new(config: JqMicroCapConfig) -> Self {
|
pub fn new(config: JqMicroCapConfig) -> Self {
|
||||||
Self { config }
|
Self { config }
|
||||||
@@ -540,15 +555,28 @@ impl JqMicroCapStrategy {
|
|||||||
(gross_amount * 0.0003).max(5.0) + (gross_amount * 0.001)
|
(gross_amount * 0.0003).max(5.0) + (gross_amount * 0.001)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn round_board_lot(&self, quantity: u32) -> u32 {
|
fn round_lot_quantity(&self, quantity: u32, round_lot: u32) -> u32 {
|
||||||
(quantity / 100) * 100
|
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 {
|
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 {
|
if cash <= 0.0 || sizing_price <= 0.0 || execution_price <= 0.0 {
|
||||||
return 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 {
|
while quantity > 0 {
|
||||||
let gross_amount = execution_price * quantity as f64;
|
let gross_amount = execution_price * quantity as f64;
|
||||||
let cash_out = gross_amount + self.buy_commission(gross_amount);
|
let cash_out = gross_amount + self.buy_commission(gross_amount);
|
||||||
@@ -560,46 +588,342 @@ impl JqMicroCapStrategy {
|
|||||||
0
|
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(
|
fn project_order_value(
|
||||||
&self,
|
&self,
|
||||||
|
ctx: &StrategyContext<'_>,
|
||||||
projected: &mut PortfolioState,
|
projected: &mut PortfolioState,
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
sizing_price: f64,
|
|
||||||
execution_price: f64,
|
|
||||||
order_value: f64,
|
order_value: f64,
|
||||||
|
reason: &str,
|
||||||
|
execution_state: &mut ProjectedExecutionState,
|
||||||
) -> u32 {
|
) -> u32 {
|
||||||
let quantity = self.projected_buy_quantity(
|
if order_value <= 0.0 {
|
||||||
projected.cash().min(order_value),
|
return 0;
|
||||||
sizing_price,
|
}
|
||||||
execution_price,
|
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 {
|
if quantity == 0 {
|
||||||
return 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);
|
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.apply_cash_delta(-cash_out);
|
||||||
projected.position_mut(symbol).buy(date, quantity, execution_price);
|
projected
|
||||||
quantity
|
.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(
|
fn project_target_zero(
|
||||||
&self,
|
&self,
|
||||||
|
ctx: &StrategyContext<'_>,
|
||||||
projected: &mut PortfolioState,
|
projected: &mut PortfolioState,
|
||||||
|
date: NaiveDate,
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
sell_price: f64,
|
reason: &str,
|
||||||
|
execution_state: &mut ProjectedExecutionState,
|
||||||
) -> Option<u32> {
|
) -> Option<u32> {
|
||||||
let quantity = projected.position(symbol)?.quantity;
|
let quantity = projected.position(symbol)?.quantity;
|
||||||
if quantity == 0 {
|
if quantity == 0 {
|
||||||
return None;
|
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);
|
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);
|
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();
|
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(
|
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 (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 periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0;
|
||||||
let mut projected = ctx.portfolio.clone();
|
let mut projected = ctx.portfolio.clone();
|
||||||
|
let mut projected_execution_state = ProjectedExecutionState::default();
|
||||||
let mut order_intents = Vec::new();
|
let mut order_intents = Vec::new();
|
||||||
let mut exit_symbols = BTreeSet::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 {
|
let Some(market) = ctx.data.market(date, &position.symbol) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let sell_price = market.sell_price(PriceField::Last);
|
|
||||||
let stop_hit = current_price
|
let stop_hit = current_price
|
||||||
<= position.average_cost * self.config.stop_loss_ratio
|
<= position.average_cost * self.config.stop_loss_ratio
|
||||||
+ self.stop_loss_tolerance(market);
|
+ self.stop_loss_tolerance(market);
|
||||||
@@ -875,7 +1199,14 @@ impl Strategy for JqMicroCapStrategy {
|
|||||||
reason: sell_reason.to_string(),
|
reason: sell_reason.to_string(),
|
||||||
});
|
});
|
||||||
if can_sell {
|
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 {
|
if projected.positions().len() < self.config.stocknum {
|
||||||
@@ -897,16 +1228,15 @@ impl Strategy for JqMicroCapStrategy {
|
|||||||
value: replacement_cash,
|
value: replacement_cash,
|
||||||
reason: format!("replacement_after_{}", sell_reason),
|
reason: format!("replacement_after_{}", sell_reason),
|
||||||
});
|
});
|
||||||
if let Some(market) = ctx.data.market(date, symbol) {
|
|
||||||
self.project_order_value(
|
self.project_order_value(
|
||||||
|
ctx,
|
||||||
&mut projected,
|
&mut projected,
|
||||||
date,
|
date,
|
||||||
symbol,
|
symbol,
|
||||||
market.buy_price(PriceField::Last),
|
|
||||||
market.buy_price(PriceField::Last),
|
|
||||||
replacement_cash,
|
replacement_cash,
|
||||||
|
&format!("replacement_after_{}", sell_reason),
|
||||||
|
&mut projected_execution_state,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -932,13 +1262,14 @@ impl Strategy for JqMicroCapStrategy {
|
|||||||
target_value: 0.0,
|
target_value: 0.0,
|
||||||
reason: "periodic_rebalance_sell".to_string(),
|
reason: "periodic_rebalance_sell".to_string(),
|
||||||
});
|
});
|
||||||
if let Some(price) = ctx
|
self.project_target_zero(
|
||||||
.data
|
ctx,
|
||||||
.market(date, symbol)
|
&mut projected,
|
||||||
.map(|market| market.sell_price(PriceField::Last))
|
date,
|
||||||
{
|
symbol,
|
||||||
self.project_target_zero(&mut projected, symbol, price);
|
"periodic_rebalance_sell",
|
||||||
}
|
&mut projected_execution_state,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let fixed_buy_cash = projected.cash() * trading_ratio / self.config.stocknum as f64;
|
let fixed_buy_cash = projected.cash() * trading_ratio / self.config.stocknum as f64;
|
||||||
@@ -959,18 +1290,17 @@ impl Strategy for JqMicroCapStrategy {
|
|||||||
value: fixed_buy_cash,
|
value: fixed_buy_cash,
|
||||||
reason: "periodic_rebalance_buy".to_string(),
|
reason: "periodic_rebalance_buy".to_string(),
|
||||||
});
|
});
|
||||||
if let Some(market) = ctx.data.market(date, symbol) {
|
|
||||||
self.project_order_value(
|
self.project_order_value(
|
||||||
|
ctx,
|
||||||
&mut projected,
|
&mut projected,
|
||||||
date,
|
date,
|
||||||
symbol,
|
symbol,
|
||||||
market.buy_price(PriceField::Last),
|
|
||||||
market.buy_price(PriceField::Last),
|
|
||||||
fixed_buy_cash,
|
fixed_buy_cash,
|
||||||
|
"periodic_rebalance_buy",
|
||||||
|
&mut projected_execution_state,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let mut diagnostics = vec![
|
let mut diagnostics = vec![
|
||||||
format!(
|
format!(
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ impl Strategy for BuyThenHoldStrategy {
|
|||||||
"buy-then-hold"
|
"buy-then-hold"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
fn on_day(
|
||||||
|
&mut self,
|
||||||
|
ctx: &StrategyContext<'_>,
|
||||||
|
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||||
if ctx.decision_date == d(2025, 1, 2) && ctx.portfolio.position("000001.SZ").is_none() {
|
if ctx.decision_date == d(2025, 1, 2) && ctx.portfolio.position("000001.SZ").is_none() {
|
||||||
return Ok(StrategyDecision {
|
return Ok(StrategyDecision {
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
@@ -238,12 +241,17 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
|||||||
|
|
||||||
let result = engine.run().expect("backtest succeeds");
|
let result = engine.run().expect("backtest succeeds");
|
||||||
assert_eq!(result.fills.len(), 2);
|
assert_eq!(result.fills.len(), 2);
|
||||||
assert!(result
|
assert!(
|
||||||
|
result
|
||||||
.fills
|
.fills
|
||||||
.iter()
|
.iter()
|
||||||
.any(|fill| fill.reason.contains("delisted_cash_settlement") && fill.symbol == "000001.SZ"));
|
.any(|fill| fill.reason.contains("delisted_cash_settlement")
|
||||||
assert!(result
|
&& fill.symbol == "000001.SZ")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
.holdings_summary
|
.holdings_summary
|
||||||
.iter()
|
.iter()
|
||||||
.all(|holding| holding.symbol != "000001.SZ"));
|
.all(|holding| holding.symbol != "000001.SZ")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,9 +289,15 @@ fn same_day_sell_then_rebuy_reinserts_position_at_end() {
|
|||||||
.expect("dataset");
|
.expect("dataset");
|
||||||
|
|
||||||
let mut portfolio = PortfolioState::new(1_000_000.0);
|
let mut portfolio = PortfolioState::new(1_000_000.0);
|
||||||
portfolio.position_mut("000001.SZ").buy(prev_date, 100, 10.0);
|
portfolio
|
||||||
portfolio.position_mut("000002.SZ").buy(prev_date, 100, 10.0);
|
.position_mut("000001.SZ")
|
||||||
portfolio.position_mut("000003.SZ").buy(prev_date, 100, 10.0);
|
.buy(prev_date, 100, 10.0);
|
||||||
|
portfolio
|
||||||
|
.position_mut("000002.SZ")
|
||||||
|
.buy(prev_date, 100, 10.0);
|
||||||
|
portfolio
|
||||||
|
.position_mut("000003.SZ")
|
||||||
|
.buy(prev_date, 100, 10.0);
|
||||||
|
|
||||||
let broker = BrokerSimulator::new_with_execution_price(
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
ChinaAShareCostModel::default(),
|
ChinaAShareCostModel::default(),
|
||||||
|
|||||||
@@ -60,10 +60,9 @@ fn can_load_partitioned_snapshot_dir() {
|
|||||||
.len()
|
.len()
|
||||||
== 1
|
== 1
|
||||||
);
|
);
|
||||||
let market_rows = data.market_snapshots_on(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap());
|
let market_rows =
|
||||||
let snapshot = market_rows
|
data.market_snapshots_on(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap());
|
||||||
.first()
|
let snapshot = market_rows.first().expect("market snapshot");
|
||||||
.expect("market snapshot");
|
|
||||||
assert_eq!(snapshot.day_open, 10.1);
|
assert_eq!(snapshot.day_open, 10.1);
|
||||||
assert_eq!(snapshot.last_price, 10.15);
|
assert_eq!(snapshot.last_price, 10.15);
|
||||||
assert_eq!(snapshot.price_tick, 0.01);
|
assert_eq!(snapshot.price_tick, 0.01);
|
||||||
|
|||||||
Reference in New Issue
Block a user