Improve jq microcap execution semantics

This commit is contained in:
boris
2026-04-18 18:02:50 +08:00
parent 9f4165e689
commit 0e2c25e4c4
26 changed files with 5058 additions and 362 deletions

View File

@@ -25,10 +25,25 @@ pub struct StrategyDecision {
pub rebalance: bool,
pub target_weights: BTreeMap<String, f64>,
pub exit_symbols: BTreeSet<String>,
pub order_intents: Vec<OrderIntent>,
pub notes: Vec<String>,
pub diagnostics: Vec<String>,
}
#[derive(Debug, Clone)]
pub enum OrderIntent {
TargetValue {
symbol: String,
target_value: f64,
reason: String,
},
Value {
symbol: String,
value: f64,
reason: String,
},
}
#[derive(Debug, Clone)]
pub struct CnSmallCapRotationConfig {
pub strategy_name: String,
@@ -97,7 +112,13 @@ impl CnSmallCapRotationConfig {
take_profit_pct: 0.07,
signal_symbol: Some("000852.SH".to_string()),
skip_months: vec![],
skip_month_day_ranges: vec![(1, 15, 30), (4, 15, 29), (8, 15, 31), (10, 20, 30), (12, 20, 30)],
skip_month_day_ranges: vec![
(1, 15, 30),
(4, 15, 29),
(8, 15, 31),
(10, 20, 30),
(12, 20, 30),
],
}
}
@@ -136,12 +157,10 @@ impl CnSmallCapRotationStrategy {
fn moving_average(values: &[f64], lookback: usize) -> f64 {
let len = values.len();
let window = values.iter().skip(len.saturating_sub(lookback));
let (sum, count) = window.fold((0.0, 0usize), |(sum, count), value| (sum + value, count + 1));
if count == 0 {
0.0
} else {
sum / count as f64
}
let (sum, count) = window.fold((0.0, 0usize), |(sum, count), value| {
(sum + value, count + 1)
});
if count == 0 { 0.0 } else { sum / count as f64 }
}
fn gross_exposure(&self, closes: &[f64]) -> f64 {
@@ -166,38 +185,46 @@ impl CnSmallCapRotationStrategy {
&self,
ctx: &StrategyContext<'_>,
) -> Result<(String, Vec<f64>, f64), BacktestError> {
let symbol = self
.config
.signal_symbol
.as_deref()
.ok_or_else(|| BacktestError::Execution(
"cn-dyn-smallcap-band requires a real signal_symbol; degraded fallback disabled"
.to_string(),
))?;
if let Some(symbol) = self.config.signal_symbol.as_deref() {
let closes =
ctx.data
.market_closes_up_to(ctx.decision_date, symbol, self.config.long_ma_days);
if closes.len() >= self.config.long_ma_days {
let close = ctx
.data
.price(ctx.decision_date, symbol, PriceField::Close)
.ok_or_else(|| BacktestError::MissingPrice {
date: ctx.decision_date,
symbol: symbol.to_string(),
field: "close",
})?;
return Ok((symbol.to_string(), closes, close));
}
}
let closes = ctx
.data
.market_closes_up_to(ctx.decision_date, symbol, self.config.long_ma_days);
.benchmark_closes_up_to(ctx.decision_date, self.config.long_ma_days);
if closes.len() < self.config.long_ma_days {
return Err(BacktestError::Execution(format!(
"real signal series missing or insufficient for {} on/before {}; degraded fallback disabled",
symbol, ctx.decision_date
"signal series insufficient on/before {} for long_ma_days={}",
ctx.decision_date, self.config.long_ma_days
)));
}
let close = ctx
.data
.price(ctx.decision_date, symbol, PriceField::Close)
.ok_or_else(|| BacktestError::MissingPrice {
.benchmark(ctx.decision_date)
.ok_or(BacktestError::MissingBenchmark {
date: ctx.decision_date,
symbol: symbol.to_string(),
field: "close",
})?;
Ok((symbol.to_string(), closes, close))
})?
.close;
Ok((ctx.data.benchmark_code().to_string(), closes, close))
}
fn stock_passes_ma_filter(&self, ctx: &StrategyContext<'_>, symbol: &str) -> bool {
let closes = ctx
.data
.market_closes_up_to(ctx.decision_date, symbol, self.config.stock_long_ma_days);
let closes =
ctx.data
.market_closes_up_to(ctx.decision_date, symbol, self.config.stock_long_ma_days);
if closes.len() < self.config.stock_long_ma_days {
return false;
}
@@ -207,7 +234,10 @@ impl CnSmallCapRotationStrategy {
ma_short > ma_mid * self.config.rsi_rate && ma_mid > ma_long
}
fn stop_exit_symbols(&self, ctx: &StrategyContext<'_>) -> Result<BTreeSet<String>, BacktestError> {
fn stop_exit_symbols(
&self,
ctx: &StrategyContext<'_>,
) -> Result<BTreeSet<String>, BacktestError> {
let mut exits = BTreeSet::new();
for position in ctx.portfolio.positions().values() {
if position.quantity == 0 {
@@ -244,12 +274,12 @@ impl Strategy for CnSmallCapRotationStrategy {
}
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
let benchmark = ctx
.data
.benchmark(ctx.decision_date)
.ok_or(BacktestError::MissingBenchmark {
date: ctx.decision_date,
})?;
let benchmark =
ctx.data
.benchmark(ctx.decision_date)
.ok_or(BacktestError::MissingBenchmark {
date: ctx.decision_date,
})?;
if self.config.in_skip_window(ctx.execution_date) {
self.last_gross_exposure = Some(0.0);
@@ -257,15 +287,35 @@ impl Strategy for CnSmallCapRotationStrategy {
rebalance: true,
target_weights: BTreeMap::new(),
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
order_intents: Vec::new(),
notes: vec![format!("skip-window active on {}", ctx.execution_date)],
diagnostics: vec![
"seasonal stop window approximated at daily granularity".to_string(),
"run_daily(10:17/10:18) mapped to T-1 decision and T open execution".to_string(),
"run_daily(10:17/10:18) mapped to T-1 decision and T open execution"
.to_string(),
],
});
}
let (resolved_signal_symbol, signal_closes, signal_level) = self.resolve_signal_series(ctx)?;
let (resolved_signal_symbol, signal_closes, signal_level) =
match self.resolve_signal_series(ctx) {
Ok(value) => value,
Err(BacktestError::Execution(message))
if message.contains("signal series insufficient") =>
{
return Ok(StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
order_intents: Vec::new(),
notes: vec![format!("warmup: {}", message)],
diagnostics: vec![
"insufficient history; skip trading on warmup dates".to_string(),
],
});
}
Err(err) => return Err(err),
};
let gross_exposure = self.gross_exposure(&signal_closes);
let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0;
let exposure_changed = self
@@ -295,16 +345,19 @@ impl Strategy for CnSmallCapRotationStrategy {
1.0 - self.config.stop_loss_pct,
1.0 + self.config.take_profit_pct,
)];
diagnostics.push("run_daily(10:17/10:18) approximated by daily decision/open execution".to_string());
diagnostics.push(
"run_daily(10:17/10:18) approximated by daily decision/open execution".to_string(),
);
diagnostics.push("market_cap field mapped from daily_features[_enriched]_v1.market_cap to market_cap_bn without intraday fundamentals refresh".to_string());
if rebalance && gross_exposure > 0.0 {
let (selected_before_ma, selection_diag) = self.selector.select_with_diagnostics(&SelectionContext {
decision_date: ctx.decision_date,
benchmark,
reference_level: signal_level,
data: ctx.data,
});
let (selected_before_ma, selection_diag) =
self.selector.select_with_diagnostics(&SelectionContext {
decision_date: ctx.decision_date,
benchmark,
reference_level: signal_level,
data: ctx.data,
});
let before_ma_count = selected_before_ma.len();
let mut ma_rejects = Vec::new();
let selected = selected_before_ma
@@ -353,7 +406,10 @@ impl Strategy for CnSmallCapRotationStrategy {
));
}
if !ma_rejects.is_empty() {
diagnostics.push(format!("ma_filter_rejections sample={}", ma_rejects.join("|")));
diagnostics.push(format!(
"ma_filter_rejections sample={}",
ma_rejects.join("|")
));
}
if !selected.is_empty() {
@@ -398,8 +454,581 @@ impl Strategy for CnSmallCapRotationStrategy {
rebalance,
target_weights,
exit_symbols,
order_intents: Vec::new(),
notes,
diagnostics,
})
}
}
#[derive(Debug, Clone)]
pub struct JqMicroCapConfig {
pub strategy_name: String,
pub refresh_rate: usize,
pub stocknum: usize,
pub xs: f64,
pub base_index_level: f64,
pub base_cap_floor: f64,
pub cap_span: f64,
pub benchmark_signal_symbol: String,
pub benchmark_short_ma_days: usize,
pub benchmark_long_ma_days: usize,
pub stock_short_ma_days: usize,
pub stock_mid_ma_days: usize,
pub stock_long_ma_days: usize,
pub rsi_rate: f64,
pub trade_rate: f64,
pub stop_loss_ratio: f64,
pub take_profit_ratio: f64,
pub skip_month_day_ranges: Vec<(u32, u32, u32)>,
}
impl JqMicroCapConfig {
pub fn jq_microcap() -> Self {
Self {
strategy_name: "jq-microcap".to_string(),
refresh_rate: 15,
stocknum: 40,
xs: 4.0 / 500.0,
base_index_level: 2000.0,
base_cap_floor: 7.0,
cap_span: 10.0,
benchmark_signal_symbol: "000001.SH".to_string(),
benchmark_short_ma_days: 5,
benchmark_long_ma_days: 10,
stock_short_ma_days: 5,
stock_mid_ma_days: 10,
stock_long_ma_days: 20,
rsi_rate: 1.0001,
trade_rate: 0.5,
stop_loss_ratio: 0.93,
take_profit_ratio: 1.07,
// The source JQ script calls validate_date() but then immediately forces
// g.OpenYN = 1 inside check_stocks(), so the seasonal stop windows are
// effectively disabled in real execution logs.
skip_month_day_ranges: Vec::new(),
}
}
fn in_skip_window(&self, date: NaiveDate) -> bool {
let month = date.month();
let day = date.day();
self.skip_month_day_ranges
.iter()
.any(|(m, start_day, end_day)| month == *m && day >= *start_day && day <= *end_day)
}
}
pub struct JqMicroCapStrategy {
config: JqMicroCapConfig,
}
impl JqMicroCapStrategy {
pub fn new(config: JqMicroCapConfig) -> Self {
Self { config }
}
fn stop_loss_tolerance(&self, market: &crate::data::DailyMarketSnapshot) -> f64 {
market.effective_price_tick() * 6.0
}
fn buy_commission(&self, gross_amount: f64) -> f64 {
(gross_amount * 0.0003).max(5.0)
}
fn sell_cost(&self, gross_amount: f64) -> f64 {
(gross_amount * 0.0003).max(5.0) + (gross_amount * 0.001)
}
fn round_board_lot(&self, quantity: u32) -> u32 {
(quantity / 100) * 100
}
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);
while quantity > 0 {
let gross_amount = execution_price * quantity as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount);
if cash_out <= cash + 1e-6 {
return quantity;
}
quantity = quantity.saturating_sub(100);
}
0
}
fn project_order_value(
&self,
projected: &mut PortfolioState,
date: NaiveDate,
symbol: &str,
sizing_price: f64,
execution_price: f64,
order_value: f64,
) -> u32 {
let quantity = self.projected_buy_quantity(
projected.cash().min(order_value),
sizing_price,
execution_price,
);
if quantity == 0 {
return 0;
}
let gross_amount = execution_price * quantity as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount);
projected.apply_cash_delta(-cash_out);
projected.position_mut(symbol).buy(date, quantity, execution_price);
quantity
}
fn project_target_zero(
&self,
projected: &mut PortfolioState,
symbol: &str,
sell_price: f64,
) -> Option<u32> {
let quantity = projected.position(symbol)?.quantity;
if quantity == 0 {
return None;
}
let gross_amount = sell_price * quantity as f64;
let net_cash = gross_amount - self.sell_cost(gross_amount);
projected.position_mut(symbol).sell(quantity, sell_price).ok()?;
projected.apply_cash_delta(net_cash);
projected.prune_flat_positions();
Some(quantity)
}
fn trading_ratio(
&self,
ctx: &StrategyContext<'_>,
date: NaiveDate,
) -> Result<(f64, f64, f64, f64), BacktestError> {
let current_level = ctx
.data
.market_decision_close(date, &self.config.benchmark_signal_symbol)
.ok_or_else(|| BacktestError::MissingPrice {
date,
symbol: self.config.benchmark_signal_symbol.clone(),
field: "decision_close",
})?;
let ma_short = ctx
.data
.market_decision_close_moving_average(
date,
&self.config.benchmark_signal_symbol,
self.config.benchmark_short_ma_days,
)
.ok_or_else(|| {
BacktestError::Execution(format!(
"insufficient benchmark short MA history for {} on {}",
self.config.benchmark_signal_symbol, date
))
})?;
let ma_long = ctx
.data
.market_decision_close_moving_average(
date,
&self.config.benchmark_signal_symbol,
self.config.benchmark_long_ma_days,
)
.ok_or_else(|| {
BacktestError::Execution(format!(
"insufficient benchmark long MA history for {} on {}",
self.config.benchmark_signal_symbol, date
))
})?;
let trading_ratio = if ma_short < ma_long * self.config.rsi_rate {
self.config.trade_rate
} else {
1.0
};
Ok((current_level, ma_short, ma_long, trading_ratio))
}
fn market_cap_band(&self, index_level: f64) -> (f64, f64) {
let y = (index_level - self.config.base_index_level) * self.config.xs
+ self.config.base_cap_floor;
let start = y.round();
(start, start + self.config.cap_span)
}
fn stock_passes_ma_filter(
&self,
ctx: &StrategyContext<'_>,
date: NaiveDate,
symbol: &str,
) -> bool {
let Some(ma_short) = ctx.data.market_decision_close_moving_average(
date,
symbol,
self.config.stock_short_ma_days,
) else {
return false;
};
let Some(ma_mid) = ctx.data.market_decision_close_moving_average(
date,
symbol,
self.config.stock_mid_ma_days,
) else {
return false;
};
let Some(ma_long) = ctx.data.market_decision_close_moving_average(
date,
symbol,
self.config.stock_long_ma_days,
) else {
return false;
};
ma_short > ma_mid * self.config.rsi_rate && ma_mid > ma_long
}
fn special_name(&self, ctx: &StrategyContext<'_>, symbol: &str) -> bool {
let instrument_name = ctx
.data
.instruments()
.get(symbol)
.map(|instrument| instrument.name.as_str())
.unwrap_or("");
instrument_name.contains("ST")
|| instrument_name.contains('*')
|| instrument_name.contains('退')
}
fn can_sell_position(&self, ctx: &StrategyContext<'_>, date: NaiveDate, symbol: &str) -> bool {
let Some(position) = ctx.portfolio.position(symbol) else {
return false;
};
if position.quantity == 0 || position.sellable_qty(date) == 0 {
return false;
}
let Ok(market) = ctx.data.require_market(date, symbol) else {
return false;
};
let Ok(candidate) = ctx.data.require_candidate(date, symbol) else {
return false;
};
!(market.paused
|| candidate.is_paused
|| !candidate.allow_sell
|| market.is_at_lower_limit_price(market.sell_price(PriceField::Last)))
}
fn buy_rejection_reason(
&self,
ctx: &StrategyContext<'_>,
date: NaiveDate,
symbol: &str,
) -> Result<Option<String>, BacktestError> {
let market = ctx.data.require_market(date, symbol)?;
let candidate = ctx.data.require_candidate(date, symbol)?;
if market.paused || candidate.is_paused {
return Ok(Some("paused".to_string()));
}
if candidate.is_st || self.special_name(ctx, symbol) {
return Ok(Some("st_or_special_name".to_string()));
}
if candidate.is_kcb {
return Ok(Some("kcb".to_string()));
}
if !candidate.allow_buy {
return Ok(Some("buy_disabled".to_string()));
}
if market.is_at_upper_limit_price(market.day_open)
|| market.is_at_upper_limit_price(market.buy_price(PriceField::Last))
{
return Ok(Some("upper_limit".to_string()));
}
if market.is_at_lower_limit_price(market.day_open)
|| market.is_at_lower_limit_price(market.sell_price(PriceField::Last))
{
return Ok(Some("lower_limit".to_string()));
}
if market.day_open <= 1.0 {
return Ok(Some("one_yuan".to_string()));
}
if !self.stock_passes_ma_filter(ctx, date, symbol) {
return Ok(Some("ma_filter".to_string()));
}
Ok(None)
}
fn select_symbols(
&self,
ctx: &StrategyContext<'_>,
date: NaiveDate,
band_low: f64,
band_high: f64,
) -> Result<(Vec<String>, Vec<String>), BacktestError> {
let universe = ctx.data.eligible_universe_on(date);
let mut diagnostics = Vec::new();
let mut selected = Vec::new();
let start = lower_bound_eligible(universe, band_low);
for candidate in universe.iter().skip(start) {
if candidate.market_cap_bn > band_high {
break;
}
if let Some(reason) = self.buy_rejection_reason(ctx, date, &candidate.symbol)? {
if diagnostics.len() < 12 {
diagnostics.push(format!("{} rejected by {}", candidate.symbol, reason));
}
continue;
}
selected.push(candidate.symbol.clone());
if selected.len() >= self.config.stocknum {
break;
}
}
Ok((selected, diagnostics))
}
}
impl Strategy for JqMicroCapStrategy {
fn name(&self) -> &str {
self.config.strategy_name.as_str()
}
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
let date = ctx.execution_date;
if self.config.in_skip_window(date) {
return Ok(StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
order_intents: ctx
.portfolio
.positions()
.keys()
.cloned()
.map(|symbol| OrderIntent::TargetValue {
symbol,
target_value: 0.0,
reason: "seasonal_stop_window".to_string(),
})
.collect(),
notes: vec![format!("seasonal stop window on {}", date)],
diagnostics: vec!["jq-style skip window forced all cash".to_string()],
});
}
let (index_level, ma_short, ma_long, trading_ratio) = match self.trading_ratio(ctx, date) {
Ok(value) => value,
Err(BacktestError::Execution(message))
if message.contains("insufficient benchmark") =>
{
return Ok(StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
order_intents: Vec::new(),
notes: vec![format!("warmup: {}", message)],
diagnostics: vec![
"insufficient history; skip trading on warmup dates".to_string(),
],
});
}
Err(err) => return Err(err),
};
let (band_low, band_high) = self.market_cap_band(index_level);
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 order_intents = Vec::new();
let mut exit_symbols = BTreeSet::new();
for position in ctx.portfolio.positions().values() {
if position.quantity == 0 || position.average_cost <= 0.0 {
continue;
}
let Some(current_price) = ctx.data.price(date, &position.symbol, PriceField::Last)
else {
continue;
};
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);
let profit_hit = !market.is_at_upper_limit_price(current_price)
&& current_price / position.average_cost > self.config.take_profit_ratio;
let can_sell = self.can_sell_position(ctx, date, &position.symbol);
if stop_hit || profit_hit {
let sell_reason = if stop_hit {
"stop_loss_exit"
} else {
"take_profit_exit"
};
exit_symbols.insert(position.symbol.clone());
order_intents.push(OrderIntent::TargetValue {
symbol: position.symbol.clone(),
target_value: 0.0,
reason: sell_reason.to_string(),
});
if can_sell {
self.project_target_zero(&mut projected, &position.symbol, sell_price);
}
if projected.positions().len() < self.config.stocknum {
let remaining_slots = self.config.stocknum - projected.positions().len();
if remaining_slots > 0 {
let replacement_cash =
projected.cash() * trading_ratio / remaining_slots as f64;
for symbol in &stock_list {
if symbol == &position.symbol
|| projected.positions().contains_key(symbol)
{
continue;
}
if self.buy_rejection_reason(ctx, date, symbol)?.is_some() {
continue;
}
order_intents.push(OrderIntent::Value {
symbol: symbol.clone(),
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,
);
}
break;
}
}
}
}
}
if periodic_rebalance {
let pre_rebalance_symbols = projected
.positions()
.keys()
.cloned()
.collect::<BTreeSet<_>>();
for symbol in pre_rebalance_symbols.iter() {
if stock_list.iter().any(|candidate| candidate == symbol) {
continue;
}
if !self.can_sell_position(ctx, date, symbol) {
continue;
}
order_intents.push(OrderIntent::TargetValue {
symbol: symbol.clone(),
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);
}
}
let fixed_buy_cash = projected.cash() * trading_ratio / self.config.stocknum as f64;
for symbol in &stock_list {
if projected.positions().len() >= self.config.stocknum {
break;
}
if pre_rebalance_symbols.contains(symbol)
|| projected.positions().contains_key(symbol)
{
continue;
}
if self.buy_rejection_reason(ctx, date, symbol)?.is_some() {
continue;
}
order_intents.push(OrderIntent::Value {
symbol: symbol.clone(),
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,
);
}
}
}
let mut diagnostics = vec![
format!(
"jq_microcap signal={} last={:.2} ma_short={:.2} ma_long={:.2} band={:.0}-{:.0} tr={:.2}",
self.config.benchmark_signal_symbol, index_level, ma_short, ma_long, band_low, band_high, trading_ratio
),
format!(
"selected={} periodic_rebalance={} exits={} projected_positions={} intents={}",
stock_list.len(),
periodic_rebalance,
exit_symbols.len(),
projected.positions().len(),
order_intents.len()
),
"run_daily(10:17/10:18) approximated as same-day decision with snapshot last_price signals and bid1/ask1 side-aware execution".to_string(),
];
if std::env::var("FIDC_BT_DEBUG_POSITION_ORDER")
.map(|value| value == "1")
.unwrap_or(false)
{
diagnostics.push(format!(
"positions_order={}",
ctx.portfolio
.positions()
.keys()
.cloned()
.collect::<Vec<_>>()
.join("|")
));
}
diagnostics.extend(selection_notes);
let notes = vec![
format!("stock_list={}", stock_list.len()),
format!("projected_positions={}", projected.positions().len()),
];
Ok(StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols,
order_intents,
notes,
diagnostics,
})
}
}
fn lower_bound_eligible(rows: &[crate::data::EligibleUniverseSnapshot], target: f64) -> usize {
let mut left = 0usize;
let mut right = rows.len();
while left < right {
let mid = left + (right - left) / 2;
if rows[mid].market_cap_bn < target {
left = mid + 1;
} else {
right = mid;
}
}
left
}