修复回测撮合与AiQuant兼容语义
This commit is contained in:
@@ -452,11 +452,11 @@ struct SymbolPriceSeries {
|
||||
closes: Vec<f64>,
|
||||
prev_closes: Vec<f64>,
|
||||
last_prices: Vec<f64>,
|
||||
paused: Vec<bool>,
|
||||
open_prefix: Vec<f64>,
|
||||
close_prefix: Vec<f64>,
|
||||
prev_close_prefix: Vec<f64>,
|
||||
last_prefix: Vec<f64>,
|
||||
volume_prefix: Vec<f64>,
|
||||
}
|
||||
|
||||
impl SymbolPriceSeries {
|
||||
@@ -469,15 +469,11 @@ impl SymbolPriceSeries {
|
||||
let closes = sorted.iter().map(|row| row.close).collect::<Vec<_>>();
|
||||
let prev_closes = sorted.iter().map(|row| row.prev_close).collect::<Vec<_>>();
|
||||
let last_prices = sorted.iter().map(|row| row.last_price).collect::<Vec<_>>();
|
||||
let volumes = sorted
|
||||
.iter()
|
||||
.map(|row| row.volume as f64)
|
||||
.collect::<Vec<_>>();
|
||||
let paused = sorted.iter().map(|row| row.paused).collect::<Vec<_>>();
|
||||
let open_prefix = prefix_sums(&opens);
|
||||
let close_prefix = prefix_sums(&closes);
|
||||
let prev_close_prefix = prefix_sums(&prev_closes);
|
||||
let last_prefix = prefix_sums(&last_prices);
|
||||
let volume_prefix = prefix_sums(&volumes);
|
||||
|
||||
Self {
|
||||
snapshots: sorted,
|
||||
@@ -486,11 +482,11 @@ impl SymbolPriceSeries {
|
||||
closes,
|
||||
prev_closes,
|
||||
last_prices,
|
||||
paused,
|
||||
open_prefix,
|
||||
close_prefix,
|
||||
prev_close_prefix,
|
||||
last_prefix,
|
||||
volume_prefix,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,15 +583,11 @@ impl SymbolPriceSeries {
|
||||
}
|
||||
|
||||
fn decision_volume_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
|
||||
if lookback == 0 {
|
||||
let values = self.decision_volume_values(date, lookback)?;
|
||||
if values.len() < lookback {
|
||||
return None;
|
||||
}
|
||||
let end = self.previous_completed_end_index(date)?;
|
||||
if end < lookback {
|
||||
return None;
|
||||
}
|
||||
let start = end - lookback;
|
||||
let sum = self.volume_prefix[end] - self.volume_prefix[start];
|
||||
let sum = values.iter().sum::<f64>();
|
||||
Some(sum / lookback as f64)
|
||||
}
|
||||
|
||||
@@ -604,11 +596,11 @@ impl SymbolPriceSeries {
|
||||
return None;
|
||||
}
|
||||
let end = self.end_index(date)?;
|
||||
if end < lookback {
|
||||
let values = self.trailing_unpaused_volumes(end, lookback)?;
|
||||
if values.len() < lookback {
|
||||
return None;
|
||||
}
|
||||
let start = end - lookback;
|
||||
let sum = self.volume_prefix[end] - self.volume_prefix[start];
|
||||
let sum = values.iter().sum::<f64>();
|
||||
Some(sum / lookback as f64)
|
||||
}
|
||||
|
||||
@@ -617,16 +609,33 @@ impl SymbolPriceSeries {
|
||||
return None;
|
||||
}
|
||||
let end = self.previous_completed_end_index(date)?;
|
||||
if end < lookback {
|
||||
let values = self.trailing_unpaused_volumes(end, lookback)?;
|
||||
if values.len() < lookback {
|
||||
return None;
|
||||
}
|
||||
let start = end - lookback;
|
||||
Some(
|
||||
self.snapshots[start..end]
|
||||
.iter()
|
||||
.map(|snapshot| snapshot.volume as f64)
|
||||
.collect(),
|
||||
)
|
||||
Some(values)
|
||||
}
|
||||
|
||||
fn trailing_unpaused_volumes(&self, end: usize, lookback: usize) -> Option<Vec<f64>> {
|
||||
if lookback == 0 || end == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut values = Vec::with_capacity(lookback);
|
||||
for idx in (0..end).rev() {
|
||||
if self.paused.get(idx).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
values.push(self.snapshots[idx].volume as f64);
|
||||
if values.len() == lookback {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if values.len() < lookback {
|
||||
None
|
||||
} else {
|
||||
values.reverse();
|
||||
Some(values)
|
||||
}
|
||||
}
|
||||
|
||||
fn end_index(&self, date: NaiveDate) -> Option<usize> {
|
||||
@@ -3385,6 +3394,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_volume_average_skips_paused_days_before_counting_window() {
|
||||
let mut paused = market_row("2025-01-03", 11.0, 0);
|
||||
paused.paused = true;
|
||||
let series = SymbolPriceSeries::new(&[
|
||||
market_row("2025-01-02", 10.0, 100),
|
||||
paused,
|
||||
market_row("2025-01-06", 12.0, 300),
|
||||
market_row("2025-01-07", 13.0, 10_000),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
series.decision_volume_moving_average(
|
||||
NaiveDate::parse_from_str("2025-01-07", "%Y-%m-%d").unwrap(),
|
||||
2
|
||||
),
|
||||
Some(200.0)
|
||||
);
|
||||
assert_eq!(
|
||||
series.decision_volume_moving_average(
|
||||
NaiveDate::parse_from_str("2025-01-07", "%Y-%m-%d").unwrap(),
|
||||
3
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_mixed_numeric_and_text_extra_factors_from_quoted_csv_json() {
|
||||
let path = temp_csv_path("mixed_factor_maps");
|
||||
|
||||
@@ -1393,6 +1393,7 @@ impl PlatformExprStrategy {
|
||||
symbol: &str,
|
||||
) -> Result<StockExpressionState, BacktestError> {
|
||||
let market = ctx.data.require_market(date, symbol)?;
|
||||
let feature_market = ctx.data.market(factor_date, symbol).unwrap_or(market);
|
||||
let factor = ctx.data.require_factor(factor_date, symbol)?;
|
||||
let candidate = ctx.data.require_candidate(date, symbol)?;
|
||||
let instrument = ctx.data.instrument(symbol);
|
||||
@@ -1440,22 +1441,14 @@ impl PlatformExprStrategy {
|
||||
.data
|
||||
.market_decision_volume_moving_average(date, symbol, 60)
|
||||
.unwrap_or(f64::NAN);
|
||||
let touched_upper_limit = factor
|
||||
.extra_factors
|
||||
.get("touched_upper_limit")
|
||||
.or_else(|| factor.extra_factors.get("hit_upper_limit"))
|
||||
.or_else(|| factor.extra_factors.get("limit_up_touched"))
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
>= 0.5;
|
||||
let touched_lower_limit = factor
|
||||
.extra_factors
|
||||
.get("touched_lower_limit")
|
||||
.or_else(|| factor.extra_factors.get("hit_lower_limit"))
|
||||
.or_else(|| factor.extra_factors.get("limit_down_touched"))
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
>= 0.5;
|
||||
let touched_upper_limit = !market.paused
|
||||
&& (market.is_at_upper_limit_price(market.close)
|
||||
|| market.is_at_upper_limit_price(market.open)
|
||||
|| market.is_at_upper_limit_price(market.day_open));
|
||||
let touched_lower_limit = !market.paused
|
||||
&& (market.is_at_lower_limit_price(market.close)
|
||||
|| market.is_at_lower_limit_price(market.open)
|
||||
|| market.is_at_lower_limit_price(market.day_open));
|
||||
let amount = factor.extra_factors.get("amount").copied().unwrap_or(0.0);
|
||||
|
||||
Ok(StockExpressionState {
|
||||
@@ -1463,16 +1456,16 @@ impl PlatformExprStrategy {
|
||||
market_cap: factor.market_cap_bn,
|
||||
free_float_cap: factor.free_float_cap_bn,
|
||||
pe_ttm: factor.pe_ttm,
|
||||
volume: market.volume as i64,
|
||||
volume: feature_market.volume as i64,
|
||||
tick_volume: market.tick_volume as i64,
|
||||
bid1_volume: market.bid1_volume as i64,
|
||||
ask1_volume: market.ask1_volume as i64,
|
||||
turnover_ratio: factor.turnover_ratio.unwrap_or(0.0),
|
||||
effective_turnover_ratio: factor.effective_turnover_ratio.unwrap_or(0.0),
|
||||
open: market.day_open,
|
||||
high: market.high,
|
||||
low: market.low,
|
||||
close: market.close,
|
||||
open: feature_market.day_open,
|
||||
high: feature_market.high,
|
||||
low: feature_market.low,
|
||||
close: feature_market.close,
|
||||
last: market.last_price,
|
||||
prev_close: market.prev_close,
|
||||
amount,
|
||||
@@ -4563,11 +4556,6 @@ impl PlatformExprStrategy {
|
||||
{
|
||||
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()));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -4670,7 +4658,11 @@ impl PlatformExprStrategy {
|
||||
{
|
||||
return Ok((false, false));
|
||||
}
|
||||
if position.quantity == 0 || position.average_cost <= 0.0 {
|
||||
let avg_price = position
|
||||
.average_entry_price()
|
||||
.filter(|value| value.is_finite() && *value > 0.0)
|
||||
.unwrap_or(position.average_cost);
|
||||
if position.quantity == 0 || avg_price <= 0.0 {
|
||||
return Ok((false, false));
|
||||
}
|
||||
let stock = match self.stock_state(ctx, signal_date, symbol) {
|
||||
@@ -4681,8 +4673,8 @@ impl PlatformExprStrategy {
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let current_price = stock.last;
|
||||
let holding_return = if position.average_cost > 0.0 {
|
||||
current_price / position.average_cost - 1.0
|
||||
let holding_return = if avg_price > 0.0 {
|
||||
current_price / avg_price - 1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
@@ -4695,7 +4687,7 @@ impl PlatformExprStrategy {
|
||||
let position_state = PositionExpressionState {
|
||||
order_book_id: position.symbol.clone(),
|
||||
avg_cost: position.average_cost,
|
||||
avg_price: position.average_cost,
|
||||
avg_price,
|
||||
current_price,
|
||||
prev_close: stock.prev_close,
|
||||
holding_return,
|
||||
@@ -4735,9 +4727,9 @@ impl PlatformExprStrategy {
|
||||
if let Some(boolean) = stop_result.clone().try_cast::<bool>() {
|
||||
boolean
|
||||
} else if let Some(multiplier) = stop_result.clone().try_cast::<f64>() {
|
||||
current_price <= position.average_cost * multiplier
|
||||
current_price <= avg_price * multiplier
|
||||
} else if let Some(multiplier) = stop_result.try_cast::<i64>() {
|
||||
current_price <= position.average_cost * multiplier as f64
|
||||
current_price <= avg_price * multiplier as f64
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -4755,15 +4747,9 @@ impl PlatformExprStrategy {
|
||||
if let Some(boolean) = take_result.clone().try_cast::<bool>() {
|
||||
boolean
|
||||
} else if let Some(multiplier) = take_result.clone().try_cast::<f64>() {
|
||||
!ctx.data
|
||||
.require_market(signal_date, symbol)?
|
||||
.is_at_upper_limit_price(current_price)
|
||||
&& current_price / position.average_cost > multiplier
|
||||
current_price / avg_price > multiplier
|
||||
} else if let Some(multiplier) = take_result.try_cast::<i64>() {
|
||||
!ctx.data
|
||||
.require_market(signal_date, symbol)?
|
||||
.is_at_upper_limit_price(current_price)
|
||||
&& current_price / position.average_cost > multiplier as f64
|
||||
current_price / avg_price > multiplier as f64
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -4891,6 +4877,13 @@ impl Strategy for PlatformExprStrategy {
|
||||
let mut intraday_attempted_buys = BTreeSet::<String>::new();
|
||||
let mut delayed_sold_symbols = BTreeSet::<String>::new();
|
||||
let mut unresolved_stop_loss_symbols = BTreeSet::<String>::new();
|
||||
let initial_position_symbols = ctx
|
||||
.portfolio
|
||||
.positions()
|
||||
.values()
|
||||
.filter(|position| position.quantity > 0)
|
||||
.map(|position| position.symbol.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
let mut pending_symbols = self
|
||||
.pending_highlimit_holdings
|
||||
@@ -4900,8 +4893,12 @@ impl Strategy for PlatformExprStrategy {
|
||||
let take_profit_multiplier = self.config.take_profit_expr.trim().parse::<f64>().ok();
|
||||
if let Some(multiplier) = take_profit_multiplier {
|
||||
for position in ctx.portfolio.positions().values() {
|
||||
let avg_price = position
|
||||
.average_entry_price()
|
||||
.filter(|value| value.is_finite() && *value > 0.0)
|
||||
.unwrap_or(position.average_cost);
|
||||
if position.quantity == 0
|
||||
|| position.average_cost <= 0.0
|
||||
|| avg_price <= 0.0
|
||||
|| pending_symbols.contains(&position.symbol)
|
||||
{
|
||||
continue;
|
||||
@@ -4928,7 +4925,7 @@ impl Strategy for PlatformExprStrategy {
|
||||
cursor = ctx.data.market_before(snapshot.date, &position.symbol);
|
||||
}
|
||||
if (closed_at_upper_limit || closed_at_day_high)
|
||||
&& previous.close / position.average_cost > multiplier
|
||||
&& previous.close / avg_price > multiplier
|
||||
&& recent_pause_before_previous
|
||||
{
|
||||
pending_symbols.insert(position.symbol.clone());
|
||||
@@ -4994,7 +4991,11 @@ impl Strategy for PlatformExprStrategy {
|
||||
if delayed_sold_symbols.contains(&position.symbol) {
|
||||
continue;
|
||||
}
|
||||
if position.quantity == 0 || position.average_cost <= 0.0 {
|
||||
let avg_price = position
|
||||
.average_entry_price()
|
||||
.filter(|value| value.is_finite() && *value > 0.0)
|
||||
.unwrap_or(position.average_cost);
|
||||
if position.quantity == 0 || avg_price <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let (stop_hit, profit_hit) =
|
||||
@@ -5136,8 +5137,15 @@ impl Strategy for PlatformExprStrategy {
|
||||
}
|
||||
}
|
||||
|
||||
aiquant_available_cash = projected.cash();
|
||||
let fixed_buy_cash = aiquant_total_value * trading_ratio / selection_limit as f64;
|
||||
let max_periodic_buys =
|
||||
selection_limit.saturating_sub(initial_position_symbols.len().min(selection_limit));
|
||||
let mut periodic_buy_count = 0_usize;
|
||||
for symbol in stock_list.iter().take(selection_limit) {
|
||||
if periodic_buy_count >= max_periodic_buys {
|
||||
break;
|
||||
}
|
||||
if Self::projected_position_count_excluding(
|
||||
&projected,
|
||||
&unresolved_stop_loss_symbols,
|
||||
@@ -5151,16 +5159,24 @@ impl Strategy for PlatformExprStrategy {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let available_rebalance_cash = fixed_buy_cash.min(aiquant_available_cash);
|
||||
if available_rebalance_cash < fixed_buy_cash * 0.5 {
|
||||
break;
|
||||
}
|
||||
let slots_remaining = selection_limit
|
||||
.saturating_sub(Self::projected_position_count_excluding(
|
||||
&projected,
|
||||
&unresolved_stop_loss_symbols,
|
||||
))
|
||||
.max(1);
|
||||
let decision_stock = self.stock_state_with_factor_date(
|
||||
ctx,
|
||||
decision_date,
|
||||
selection_factor_date,
|
||||
symbol,
|
||||
)?;
|
||||
let stock_scale = self.buy_scale(ctx, &day, &decision_stock)?;
|
||||
let buy_cash = (fixed_buy_cash * stock_scale)
|
||||
.min(aiquant_available_cash / slots_remaining as f64);
|
||||
if buy_cash <= 0.0 {
|
||||
break;
|
||||
}
|
||||
let execution_stock = self.stock_state(ctx, execution_date, symbol)?;
|
||||
if self
|
||||
.buy_rejection_reason(ctx, execution_date, symbol, &execution_stock)?
|
||||
@@ -5171,11 +5187,6 @@ impl Strategy for PlatformExprStrategy {
|
||||
if !self.stock_passes_expr(ctx, &day, &decision_stock)? {
|
||||
continue;
|
||||
}
|
||||
let buy_cash =
|
||||
available_rebalance_cash * self.buy_scale(ctx, &day, &decision_stock)?;
|
||||
if buy_cash <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
order_intents.push(OrderIntent::Value {
|
||||
symbol: symbol.clone(),
|
||||
value: buy_cash,
|
||||
@@ -5193,16 +5204,13 @@ impl Strategy for PlatformExprStrategy {
|
||||
if filled_qty > 0 {
|
||||
let spent = (cash_before_buy - projected.cash()).max(0.0);
|
||||
aiquant_available_cash = (aiquant_available_cash - spent).max(0.0);
|
||||
periodic_buy_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.config.rotation_enabled && self.config.rebalance_schedule.is_none() {
|
||||
if periodic_rebalance {
|
||||
self.rebalance_day_counter = if projected.positions().is_empty() {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
};
|
||||
self.rebalance_day_counter = 1;
|
||||
} else {
|
||||
self.rebalance_day_counter = self.rebalance_day_counter.saturating_add(1);
|
||||
}
|
||||
@@ -5436,6 +5444,564 @@ mod tests {
|
||||
assert!((marked - 1_293.0).abs() < 1e-6, "{marked}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_take_profit_sells_position_at_upper_limit() {
|
||||
let prev_date = d(2025, 2, 6);
|
||||
let date = d(2025, 2, 7);
|
||||
let symbol = "001368.SZ";
|
||||
let data = DataSet::from_components(
|
||||
vec![Instrument {
|
||||
symbol: symbol.to_string(),
|
||||
name: symbol.to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(d(2020, 1, 1)),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}],
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: Some("2025-02-07 10:18:00".to_string()),
|
||||
day_open: 23.11,
|
||||
open: 23.11,
|
||||
high: 23.99,
|
||||
low: 23.11,
|
||||
close: 23.99,
|
||||
last_price: 23.99,
|
||||
bid1: 23.99,
|
||||
ask1: 23.99,
|
||||
prev_close: 21.81,
|
||||
volume: 100_161,
|
||||
tick_volume: 1_000,
|
||||
bid1_volume: 1_000,
|
||||
ask1_volume: 1_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 23.99,
|
||||
lower_limit: 19.63,
|
||||
price_tick: 0.01,
|
||||
}],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
market_cap_bn: 24.90,
|
||||
free_float_cap_bn: 6.10,
|
||||
pe_ttm: 8.0,
|
||||
turnover_ratio: Some(35.77),
|
||||
effective_turnover_ratio: Some(35.77),
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
is_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: false,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
}],
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 1000.0,
|
||||
close: 1002.0,
|
||||
prev_close: 998.0,
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
)
|
||||
.expect("dataset");
|
||||
let mut portfolio = PortfolioState::new(1_000_000.0);
|
||||
portfolio.position_mut(symbol).buy(prev_date, 6_200, 19.86);
|
||||
let subscriptions = BTreeSet::new();
|
||||
let ctx = StrategyContext {
|
||||
execution_date: date,
|
||||
decision_date: date,
|
||||
decision_index: 20,
|
||||
data: &data,
|
||||
portfolio: &portfolio,
|
||||
futures_account: None,
|
||||
open_orders: &[],
|
||||
dynamic_universe: None,
|
||||
subscriptions: &subscriptions,
|
||||
process_events: &[],
|
||||
active_process_event: None,
|
||||
active_datetime: None,
|
||||
order_events: &[],
|
||||
fills: &[],
|
||||
};
|
||||
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
|
||||
cfg.rotation_enabled = false;
|
||||
cfg.signal_symbol = symbol.to_string();
|
||||
cfg.stop_loss_expr.clear();
|
||||
cfg.take_profit_expr = "1.07".to_string();
|
||||
let mut strategy = PlatformExprStrategy::new(cfg);
|
||||
|
||||
let decision = strategy.on_day(&ctx).expect("platform decision");
|
||||
|
||||
assert!(
|
||||
decision.order_intents.iter().any(|intent| matches!(
|
||||
intent,
|
||||
OrderIntent::TargetValue {
|
||||
symbol: intent_symbol,
|
||||
target_value,
|
||||
reason,
|
||||
} if intent_symbol == symbol && *target_value == 0.0 && reason == "take_profit_exit"
|
||||
)),
|
||||
"{:?}",
|
||||
decision.order_intents
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_take_profit_uses_strategy_entry_price_not_fee_cost_basis() {
|
||||
let prev_date = d(2025, 3, 13);
|
||||
let date = d(2025, 3, 14);
|
||||
let symbol = "600561.SH";
|
||||
let data = DataSet::from_components(
|
||||
vec![Instrument {
|
||||
symbol: symbol.to_string(),
|
||||
name: symbol.to_string(),
|
||||
board: "SH".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(d(2020, 1, 1)),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}],
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: Some("2025-03-14 10:18:00".to_string()),
|
||||
day_open: 5.96,
|
||||
open: 5.96,
|
||||
high: 6.08,
|
||||
low: 5.95,
|
||||
close: 6.06,
|
||||
last_price: 6.06,
|
||||
bid1: 6.05,
|
||||
ask1: 6.06,
|
||||
prev_close: 5.86,
|
||||
volume: 1_000_000,
|
||||
tick_volume: 1_000,
|
||||
bid1_volume: 1_000,
|
||||
ask1_volume: 1_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 6.45,
|
||||
lower_limit: 5.27,
|
||||
price_tick: 0.01,
|
||||
}],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
market_cap_bn: 3.2,
|
||||
free_float_cap_bn: 2.1,
|
||||
pe_ttm: 8.0,
|
||||
turnover_ratio: Some(3.0),
|
||||
effective_turnover_ratio: Some(3.0),
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
is_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: false,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
}],
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 1000.0,
|
||||
close: 1002.0,
|
||||
prev_close: 998.0,
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
)
|
||||
.expect("dataset");
|
||||
let mut portfolio = PortfolioState::new(1_000_000.0);
|
||||
portfolio.position_mut(symbol).buy(prev_date, 22_200, 5.66);
|
||||
portfolio
|
||||
.position_mut(symbol)
|
||||
.record_buy_trade_cost(22_200, 100.0);
|
||||
assert!(portfolio.position(symbol).unwrap().average_cost > 5.66);
|
||||
assert!(6.06 / portfolio.position(symbol).unwrap().average_cost <= 1.07);
|
||||
|
||||
let subscriptions = BTreeSet::new();
|
||||
let ctx = StrategyContext {
|
||||
execution_date: date,
|
||||
decision_date: date,
|
||||
decision_index: 40,
|
||||
data: &data,
|
||||
portfolio: &portfolio,
|
||||
futures_account: None,
|
||||
open_orders: &[],
|
||||
dynamic_universe: None,
|
||||
subscriptions: &subscriptions,
|
||||
process_events: &[],
|
||||
active_process_event: None,
|
||||
active_datetime: None,
|
||||
order_events: &[],
|
||||
fills: &[],
|
||||
};
|
||||
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
|
||||
cfg.rotation_enabled = false;
|
||||
cfg.signal_symbol = symbol.to_string();
|
||||
cfg.stop_loss_expr.clear();
|
||||
cfg.take_profit_expr = "1.07".to_string();
|
||||
let mut strategy = PlatformExprStrategy::new(cfg);
|
||||
|
||||
let decision = strategy.on_day(&ctx).expect("platform decision");
|
||||
|
||||
assert!(
|
||||
decision.order_intents.iter().any(|intent| matches!(
|
||||
intent,
|
||||
OrderIntent::TargetValue {
|
||||
symbol: intent_symbol,
|
||||
target_value,
|
||||
reason,
|
||||
} if intent_symbol == symbol && *target_value == 0.0 && reason == "take_profit_exit"
|
||||
)),
|
||||
"{:?}",
|
||||
decision.order_intents
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_stock_state_uses_current_day_limit_status_for_buy_scale() {
|
||||
let date = d(2025, 3, 14);
|
||||
let symbol = "603813.SH";
|
||||
let data = DataSet::from_components(
|
||||
vec![Instrument {
|
||||
symbol: symbol.to_string(),
|
||||
name: symbol.to_string(),
|
||||
board: "SH".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(d(2020, 1, 1)),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}],
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: Some("2025-03-14 10:18:00".to_string()),
|
||||
day_open: 14.82,
|
||||
open: 14.82,
|
||||
high: 15.98,
|
||||
low: 14.80,
|
||||
close: 15.98,
|
||||
last_price: 15.05,
|
||||
bid1: 15.04,
|
||||
ask1: 15.05,
|
||||
prev_close: 14.53,
|
||||
volume: 1_000_000,
|
||||
tick_volume: 1_000,
|
||||
bid1_volume: 1_000,
|
||||
ask1_volume: 1_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 15.98,
|
||||
lower_limit: 13.08,
|
||||
price_tick: 0.01,
|
||||
}],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
market_cap_bn: 8.0,
|
||||
free_float_cap_bn: 4.0,
|
||||
pe_ttm: 8.0,
|
||||
turnover_ratio: Some(3.0),
|
||||
effective_turnover_ratio: Some(3.0),
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
is_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
}],
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 1000.0,
|
||||
close: 1002.0,
|
||||
prev_close: 998.0,
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
)
|
||||
.expect("dataset");
|
||||
let portfolio = PortfolioState::new(1_000_000.0);
|
||||
let subscriptions = BTreeSet::new();
|
||||
let ctx = StrategyContext {
|
||||
execution_date: date,
|
||||
decision_date: date,
|
||||
decision_index: 40,
|
||||
data: &data,
|
||||
portfolio: &portfolio,
|
||||
futures_account: None,
|
||||
open_orders: &[],
|
||||
dynamic_universe: None,
|
||||
subscriptions: &subscriptions,
|
||||
process_events: &[],
|
||||
active_process_event: None,
|
||||
active_datetime: None,
|
||||
order_events: &[],
|
||||
fills: &[],
|
||||
};
|
||||
let strategy = PlatformExprStrategy::new(PlatformExprStrategyConfig::microcap_rotation());
|
||||
|
||||
let stock = strategy
|
||||
.stock_state_with_factor_date(&ctx, date, date, symbol)
|
||||
.expect("stock state");
|
||||
|
||||
assert!(stock.touched_upper_limit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_stock_state_uses_factor_date_for_selection_market_fields() {
|
||||
let factor_date = d(2025, 4, 3);
|
||||
let date = d(2025, 4, 7);
|
||||
let symbol = "003008.SZ";
|
||||
let data = DataSet::from_components(
|
||||
vec![Instrument {
|
||||
symbol: symbol.to_string(),
|
||||
name: symbol.to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(d(2020, 1, 1)),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}],
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: factor_date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: Some("2025-04-03 15:00:00".to_string()),
|
||||
day_open: 9.8,
|
||||
open: 9.8,
|
||||
high: 10.2,
|
||||
low: 9.6,
|
||||
close: 9.9,
|
||||
last_price: 9.9,
|
||||
bid1: 9.9,
|
||||
ask1: 9.9,
|
||||
prev_close: 9.7,
|
||||
volume: 12_300,
|
||||
tick_volume: 0,
|
||||
bid1_volume: 0,
|
||||
ask1_volume: 0,
|
||||
trading_phase: Some("close".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 10.67,
|
||||
lower_limit: 8.73,
|
||||
price_tick: 0.01,
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: Some("2025-04-07 10:18:00".to_string()),
|
||||
day_open: 19.0,
|
||||
open: 19.0,
|
||||
high: 20.5,
|
||||
low: 18.8,
|
||||
close: 20.0,
|
||||
last_price: 19.06,
|
||||
bid1: 19.05,
|
||||
ask1: 19.06,
|
||||
prev_close: 9.9,
|
||||
volume: 45_600,
|
||||
tick_volume: 1_000,
|
||||
bid1_volume: 1_000,
|
||||
ask1_volume: 1_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 20.89,
|
||||
lower_limit: 17.11,
|
||||
price_tick: 0.01,
|
||||
},
|
||||
],
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: factor_date,
|
||||
symbol: symbol.to_string(),
|
||||
market_cap_bn: 8.0,
|
||||
free_float_cap_bn: 4.0,
|
||||
pe_ttm: 8.0,
|
||||
turnover_ratio: Some(1.2),
|
||||
effective_turnover_ratio: Some(1.2),
|
||||
extra_factors: BTreeMap::from([("touched_upper_limit".to_string(), 1.0)]),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
market_cap_bn: 9.0,
|
||||
free_float_cap_bn: 5.0,
|
||||
pe_ttm: 8.0,
|
||||
turnover_ratio: Some(2.4),
|
||||
effective_turnover_ratio: Some(2.4),
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
is_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
}],
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 1000.0,
|
||||
close: 1002.0,
|
||||
prev_close: 998.0,
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
)
|
||||
.expect("dataset");
|
||||
let portfolio = PortfolioState::new(1_000_000.0);
|
||||
let subscriptions = BTreeSet::new();
|
||||
let ctx = StrategyContext {
|
||||
execution_date: date,
|
||||
decision_date: date,
|
||||
decision_index: 40,
|
||||
data: &data,
|
||||
portfolio: &portfolio,
|
||||
futures_account: None,
|
||||
open_orders: &[],
|
||||
dynamic_universe: None,
|
||||
subscriptions: &subscriptions,
|
||||
process_events: &[],
|
||||
active_process_event: None,
|
||||
active_datetime: None,
|
||||
order_events: &[],
|
||||
fills: &[],
|
||||
};
|
||||
let strategy = PlatformExprStrategy::new(PlatformExprStrategyConfig::microcap_rotation());
|
||||
|
||||
let stock = strategy
|
||||
.stock_state_with_factor_date(&ctx, date, factor_date, symbol)
|
||||
.expect("stock state");
|
||||
|
||||
assert_eq!(stock.close, 9.9);
|
||||
assert_eq!(stock.volume, 12_300);
|
||||
assert_eq!(stock.last, 19.06);
|
||||
assert_eq!(stock.market_cap, 8.0);
|
||||
assert!(!stock.touched_upper_limit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_buy_rejection_allows_lower_limit_buy_when_ask_is_available() {
|
||||
let date = d(2025, 4, 7);
|
||||
let symbol = "003008.SZ";
|
||||
let data = DataSet::from_components(
|
||||
vec![Instrument {
|
||||
symbol: symbol.to_string(),
|
||||
name: symbol.to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(d(2020, 1, 1)),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}],
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: Some("2025-04-07 10:18:00".to_string()),
|
||||
day_open: 20.0,
|
||||
open: 20.0,
|
||||
high: 20.3,
|
||||
low: 19.05,
|
||||
close: 19.05,
|
||||
last_price: 19.06,
|
||||
bid1: 19.05,
|
||||
ask1: 19.06,
|
||||
prev_close: 21.17,
|
||||
volume: 16_173,
|
||||
tick_volume: 1_000,
|
||||
bid1_volume: 1_000,
|
||||
ask1_volume: 1_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 23.29,
|
||||
lower_limit: 19.05,
|
||||
price_tick: 0.01,
|
||||
}],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
market_cap_bn: 8.0,
|
||||
free_float_cap_bn: 4.0,
|
||||
pe_ttm: 8.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(2.0),
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
is_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
}],
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 1000.0,
|
||||
close: 1002.0,
|
||||
prev_close: 998.0,
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
)
|
||||
.expect("dataset");
|
||||
let portfolio = PortfolioState::new(1_000_000.0);
|
||||
let subscriptions = BTreeSet::new();
|
||||
let ctx = StrategyContext {
|
||||
execution_date: date,
|
||||
decision_date: date,
|
||||
decision_index: 40,
|
||||
data: &data,
|
||||
portfolio: &portfolio,
|
||||
futures_account: None,
|
||||
open_orders: &[],
|
||||
dynamic_universe: None,
|
||||
subscriptions: &subscriptions,
|
||||
process_events: &[],
|
||||
active_process_event: None,
|
||||
active_datetime: None,
|
||||
order_events: &[],
|
||||
fills: &[],
|
||||
};
|
||||
let strategy = PlatformExprStrategy::new(PlatformExprStrategyConfig::microcap_rotation());
|
||||
let stock = strategy
|
||||
.stock_state(&ctx, date, symbol)
|
||||
.expect("stock state");
|
||||
|
||||
let rejection = strategy
|
||||
.buy_rejection_reason(&ctx, date, symbol, &stock)
|
||||
.expect("rejection");
|
||||
|
||||
assert_eq!(rejection, None);
|
||||
}
|
||||
|
||||
fn sample_calendar() -> TradingCalendar {
|
||||
TradingCalendar::new(vec![
|
||||
d(2025, 1, 30),
|
||||
@@ -6627,7 +7193,7 @@ mod tests {
|
||||
last_price: 10.0 + index as f64,
|
||||
bid1: 10.0 + index as f64,
|
||||
ask1: 10.0 + index as f64,
|
||||
prev_close: 9.8 + index as f64,
|
||||
prev_close: if index == 0 { 9.8 } else { 9.0 + index as f64 },
|
||||
volume: 1000 + index as u64 * 100,
|
||||
tick_volume: 0,
|
||||
bid1_volume: 0,
|
||||
@@ -7015,6 +7581,76 @@ mod tests {
|
||||
"{:?}",
|
||||
third.diagnostics
|
||||
);
|
||||
|
||||
let mut no_retry_cfg = PlatformExprStrategyConfig::microcap_rotation();
|
||||
no_retry_cfg.signal_symbol = "000001.SZ".to_string();
|
||||
no_retry_cfg.refresh_rate = 15;
|
||||
no_retry_cfg.max_positions = 2;
|
||||
no_retry_cfg.benchmark_short_ma_days = 1;
|
||||
no_retry_cfg.benchmark_long_ma_days = 1;
|
||||
no_retry_cfg.market_cap_lower_expr = "0".to_string();
|
||||
no_retry_cfg.market_cap_upper_expr = "100".to_string();
|
||||
no_retry_cfg.selection_limit_expr = "2".to_string();
|
||||
no_retry_cfg.stock_filter_expr = "close < 0".to_string();
|
||||
no_retry_cfg.retry_empty_rebalance = false;
|
||||
let mut no_retry_strategy = PlatformExprStrategy::new(no_retry_cfg);
|
||||
let empty_portfolio = PortfolioState::new(30_000.0);
|
||||
let first_empty_ctx = StrategyContext {
|
||||
execution_date: dates[0],
|
||||
decision_date: dates[0],
|
||||
decision_index: 20,
|
||||
data: &data,
|
||||
portfolio: &empty_portfolio,
|
||||
futures_account: None,
|
||||
open_orders: &[],
|
||||
dynamic_universe: None,
|
||||
subscriptions: &subscriptions,
|
||||
process_events: &[],
|
||||
active_process_event: None,
|
||||
active_datetime: None,
|
||||
order_events: &[],
|
||||
fills: &[],
|
||||
};
|
||||
let first_empty = no_retry_strategy
|
||||
.on_day(&first_empty_ctx)
|
||||
.expect("first empty decision");
|
||||
assert!(
|
||||
first_empty
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|item| item.contains("periodic_rebalance=true")),
|
||||
"{:?}",
|
||||
first_empty.diagnostics
|
||||
);
|
||||
assert_eq!(no_retry_strategy.rebalance_day_counter, 1);
|
||||
|
||||
let second_empty_ctx = StrategyContext {
|
||||
execution_date: dates[1],
|
||||
decision_date: dates[1],
|
||||
decision_index: 21,
|
||||
data: &data,
|
||||
portfolio: &empty_portfolio,
|
||||
futures_account: None,
|
||||
open_orders: &[],
|
||||
dynamic_universe: None,
|
||||
subscriptions: &subscriptions,
|
||||
process_events: &[],
|
||||
active_process_event: None,
|
||||
active_datetime: None,
|
||||
order_events: &[],
|
||||
fills: &[],
|
||||
};
|
||||
let second_empty = no_retry_strategy
|
||||
.on_day(&second_empty_ctx)
|
||||
.expect("second empty decision");
|
||||
assert!(
|
||||
second_empty
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|item| item.contains("periodic_rebalance=false")),
|
||||
"{:?}",
|
||||
second_empty.diagnostics
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -7492,7 +8128,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_periodic_rebalance_does_not_use_same_day_sell_cash_for_later_buys() {
|
||||
fn platform_periodic_rebalance_uses_same_day_sell_cash_for_later_buys() {
|
||||
let prev_date = d(2025, 5, 13);
|
||||
let date = d(2025, 5, 14);
|
||||
let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"];
|
||||
@@ -7595,7 +8231,7 @@ mod tests {
|
||||
)
|
||||
.expect("dataset");
|
||||
|
||||
let mut portfolio = PortfolioState::new(1_200.0);
|
||||
let mut portfolio = PortfolioState::new(100.0);
|
||||
portfolio
|
||||
.position_mut("000003.SZ")
|
||||
.buy(prev_date, 100, 10.0);
|
||||
@@ -7643,7 +8279,7 @@ mod tests {
|
||||
})
|
||||
.count();
|
||||
|
||||
assert_eq!(periodic_buys, 1, "{:?}", decision.order_intents);
|
||||
assert_eq!(periodic_buys, 2, "{:?}", decision.order_intents);
|
||||
assert!(
|
||||
decision.order_intents.iter().any(|intent| matches!(
|
||||
intent,
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::data::{DataSet, DataSetError, PriceField};
|
||||
pub struct PositionLot {
|
||||
pub acquired_date: NaiveDate,
|
||||
pub quantity: u32,
|
||||
pub entry_price: f64,
|
||||
pub price: f64,
|
||||
}
|
||||
|
||||
@@ -72,6 +73,7 @@ impl Position {
|
||||
self.lots.push(PositionLot {
|
||||
acquired_date: date,
|
||||
quantity,
|
||||
entry_price: price,
|
||||
price,
|
||||
});
|
||||
self.quantity += quantity;
|
||||
@@ -230,13 +232,28 @@ impl Position {
|
||||
}
|
||||
|
||||
pub fn holding_return(&self, price: f64) -> Option<f64> {
|
||||
if self.quantity == 0 || self.average_cost <= 0.0 {
|
||||
let Some(avg_price) = self.average_entry_price() else {
|
||||
return None;
|
||||
};
|
||||
if avg_price <= 0.0 {
|
||||
None
|
||||
} else {
|
||||
Some((price / self.average_cost) - 1.0)
|
||||
Some((price / avg_price) - 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn average_entry_price(&self) -> Option<f64> {
|
||||
if self.quantity == 0 {
|
||||
return None;
|
||||
}
|
||||
let total = self
|
||||
.lots
|
||||
.iter()
|
||||
.map(|lot| lot.entry_price * lot.quantity as f64)
|
||||
.sum::<f64>();
|
||||
Some(total / self.quantity as f64)
|
||||
}
|
||||
|
||||
fn recalculate_average_cost(&mut self) {
|
||||
if self.quantity == 0 {
|
||||
self.average_cost = 0.0;
|
||||
@@ -258,6 +275,7 @@ impl Position {
|
||||
}
|
||||
|
||||
for lot in &mut self.lots {
|
||||
lot.entry_price -= dividend_per_share;
|
||||
lot.price -= dividend_per_share;
|
||||
}
|
||||
self.average_cost -= dividend_per_share;
|
||||
@@ -280,6 +298,7 @@ impl Position {
|
||||
.map(|lot| PositionLot {
|
||||
acquired_date: lot.acquired_date,
|
||||
quantity: round_half_up_u32(lot.quantity as f64 * ratio),
|
||||
entry_price: lot.entry_price / ratio,
|
||||
price: lot.price / ratio,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -759,6 +778,7 @@ impl PortfolioState {
|
||||
.map(|lot| PositionLot {
|
||||
acquired_date: lot.acquired_date,
|
||||
quantity: round_half_up_u32(lot.quantity as f64 * ratio),
|
||||
entry_price: lot.entry_price / ratio,
|
||||
price: lot.price / ratio,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -855,6 +875,18 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strategy_entry_price_excludes_buy_commission_cost_basis() {
|
||||
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let mut position = Position::new("600561.SH");
|
||||
position.buy(date, 22_200, 5.66);
|
||||
position.record_buy_trade_cost(22_200, 100.0);
|
||||
|
||||
assert!(position.average_cost > 5.66);
|
||||
assert!((position.average_entry_price().unwrap() - 5.66).abs() < 1e-12);
|
||||
assert!((position.holding_return(6.06).unwrap() - (6.06 / 5.66 - 1.0)).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portfolio_tracks_dividend_receivable_and_day_pnl() {
|
||||
let prev_date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
|
||||
@@ -2388,11 +2388,6 @@ impl OmniMicroCapStrategy {
|
||||
{
|
||||
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()));
|
||||
}
|
||||
@@ -2744,8 +2739,7 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
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 profit_hit = 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 {
|
||||
|
||||
Reference in New Issue
Block a user