完善策略调度执行价校验
This commit is contained in:
+229
-16
@@ -445,6 +445,38 @@ pub struct EligibleUniverseSnapshot {
|
||||
pub free_float_cap_bn: f64,
|
||||
}
|
||||
|
||||
pub fn decision_adjusted_cap_bn(
|
||||
factor_date: NaiveDate,
|
||||
raw_cap_bn: f64,
|
||||
market: &DailyMarketSnapshot,
|
||||
) -> f64 {
|
||||
if !raw_cap_bn.is_finite() || raw_cap_bn <= 0.0 {
|
||||
return f64::NAN;
|
||||
}
|
||||
if factor_date != market.date {
|
||||
return raw_cap_bn;
|
||||
}
|
||||
if !market.close.is_finite()
|
||||
|| market.close <= 0.0
|
||||
|| !market.prev_close.is_finite()
|
||||
|| market.prev_close <= 0.0
|
||||
{
|
||||
return f64::NAN;
|
||||
}
|
||||
raw_cap_bn * market.prev_close / market.close
|
||||
}
|
||||
|
||||
pub fn decision_market_cap_bn(factor: &DailyFactorSnapshot, market: &DailyMarketSnapshot) -> f64 {
|
||||
decision_adjusted_cap_bn(factor.date, factor.market_cap_bn, market)
|
||||
}
|
||||
|
||||
pub fn decision_free_float_cap_bn(
|
||||
factor: &DailyFactorSnapshot,
|
||||
market: &DailyMarketSnapshot,
|
||||
) -> f64 {
|
||||
decision_adjusted_cap_bn(factor.date, factor.free_float_cap_bn, market)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SymbolPriceSeries {
|
||||
snapshots: Vec<DailyMarketSnapshot>,
|
||||
@@ -597,11 +629,16 @@ impl SymbolPriceSeries {
|
||||
}
|
||||
|
||||
fn decision_volume_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
|
||||
let values = self.decision_volume_values(date, lookback)?;
|
||||
if values.len() < lookback {
|
||||
if lookback == 0 {
|
||||
return None;
|
||||
}
|
||||
let sum = values.iter().sum::<f64>();
|
||||
let end = self.previous_completed_end_index(date)?;
|
||||
let end_count = *self.unpaused_count_prefix.get(end)?;
|
||||
if end_count < lookback {
|
||||
return None;
|
||||
}
|
||||
let start_count = end_count - lookback;
|
||||
let sum = self.unpaused_volume_prefix[end_count] - self.unpaused_volume_prefix[start_count];
|
||||
Some(sum / lookback as f64)
|
||||
}
|
||||
|
||||
@@ -691,6 +728,7 @@ struct BenchmarkPriceSeries {
|
||||
dates: Vec<NaiveDate>,
|
||||
opens: Vec<f64>,
|
||||
closes: Vec<f64>,
|
||||
prev_closes: Vec<f64>,
|
||||
open_prefix: Vec<f64>,
|
||||
close_prefix: Vec<f64>,
|
||||
}
|
||||
@@ -702,12 +740,14 @@ impl BenchmarkPriceSeries {
|
||||
let dates = sorted.iter().map(|row| row.date).collect::<Vec<_>>();
|
||||
let opens = sorted.iter().map(|row| row.open).collect::<Vec<_>>();
|
||||
let closes = sorted.iter().map(|row| row.close).collect::<Vec<_>>();
|
||||
let prev_closes = sorted.iter().map(|row| row.prev_close).collect::<Vec<_>>();
|
||||
let open_prefix = prefix_sums(&opens);
|
||||
let close_prefix = prefix_sums(&closes);
|
||||
Self {
|
||||
dates,
|
||||
opens,
|
||||
closes,
|
||||
prev_closes,
|
||||
open_prefix,
|
||||
close_prefix,
|
||||
}
|
||||
@@ -717,6 +757,24 @@ impl BenchmarkPriceSeries {
|
||||
self.moving_average_for(date, lookback, PriceField::Close)
|
||||
}
|
||||
|
||||
fn decision_close(&self, date: NaiveDate) -> Option<f64> {
|
||||
match self.dates.binary_search(&date) {
|
||||
Ok(idx) => self
|
||||
.prev_closes
|
||||
.get(idx)
|
||||
.copied()
|
||||
.filter(|value| value.is_finite() && *value > 0.0)
|
||||
.or_else(|| {
|
||||
idx.checked_sub(1)
|
||||
.and_then(|prev| self.closes.get(prev).copied())
|
||||
}),
|
||||
Err(0) => None,
|
||||
Err(idx) => idx
|
||||
.checked_sub(1)
|
||||
.and_then(|prev| self.closes.get(prev).copied()),
|
||||
}
|
||||
}
|
||||
|
||||
fn decision_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
|
||||
if lookback == 0 {
|
||||
return None;
|
||||
@@ -734,12 +792,7 @@ impl BenchmarkPriceSeries {
|
||||
Some(sum / lookback as f64)
|
||||
}
|
||||
|
||||
fn decision_values_for(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
lookback: usize,
|
||||
field: PriceField,
|
||||
) -> Vec<f64> {
|
||||
fn decision_values_for(&self, date: NaiveDate, lookback: usize, field: PriceField) -> Vec<f64> {
|
||||
if lookback == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -2278,6 +2331,10 @@ impl DataSet {
|
||||
self.benchmark_series_cache.moving_average(date, lookback)
|
||||
}
|
||||
|
||||
pub fn benchmark_decision_close(&self, date: NaiveDate) -> Option<f64> {
|
||||
self.benchmark_series_cache.decision_close(date)
|
||||
}
|
||||
|
||||
pub fn benchmark_decision_moving_average(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
@@ -2318,11 +2375,9 @@ impl DataSet {
|
||||
"open" | "day_open" | "dayopen" | "benchmark_open" => self
|
||||
.benchmark_series_cache
|
||||
.trailing_values_for(date, lookback, PriceField::Open),
|
||||
_ => self.benchmark_series_cache.decision_values_for(
|
||||
date,
|
||||
lookback,
|
||||
PriceField::Close,
|
||||
),
|
||||
_ => self
|
||||
.benchmark_series_cache
|
||||
.decision_values_for(date, lookback, PriceField::Close),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3409,10 +3464,15 @@ fn build_eligible_universe(
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let market_cap_bn = decision_market_cap_bn(factor, market);
|
||||
if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let free_float_cap_bn = decision_free_float_cap_bn(factor, market);
|
||||
rows.push(EligibleUniverseSnapshot {
|
||||
symbol: factor.symbol.clone(),
|
||||
market_cap_bn: factor.market_cap_bn,
|
||||
free_float_cap_bn: factor.free_float_cap_bn,
|
||||
market_cap_bn,
|
||||
free_float_cap_bn,
|
||||
});
|
||||
}
|
||||
rows.sort_by(|left, right| {
|
||||
@@ -3471,6 +3531,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn benchmark_row(date: &str, close: f64) -> BenchmarkSnapshot {
|
||||
BenchmarkSnapshot {
|
||||
date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(),
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: close,
|
||||
close,
|
||||
prev_close: close - 1.0,
|
||||
volume: 1_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_selection_uses_structured_instrument_dates_and_status_only() {
|
||||
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
|
||||
@@ -3504,6 +3575,14 @@ mod tests {
|
||||
Some(&instrument("正常名称", "delisted", None)),
|
||||
date
|
||||
));
|
||||
assert!(instrument_passes_baseline_selection(
|
||||
Some(&instrument(
|
||||
"正常名称",
|
||||
"delisted",
|
||||
Some(NaiveDate::parse_from_str("2025-04-30", "%Y-%m-%d").unwrap()),
|
||||
)),
|
||||
date
|
||||
));
|
||||
assert!(!instrument_passes_baseline_selection(
|
||||
Some(&instrument(
|
||||
"正常名称",
|
||||
@@ -3545,6 +3624,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_close_average_ignores_current_day_close() {
|
||||
let mut current = market_row("2025-01-06", 12.0, 10_000);
|
||||
current.close = 9_999.0;
|
||||
current.last_price = 9_999.0;
|
||||
let series = SymbolPriceSeries::new(&[
|
||||
market_row("2025-01-02", 10.0, 100),
|
||||
market_row("2025-01-03", 11.0, 200),
|
||||
current,
|
||||
]);
|
||||
let decision_date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
series.decision_close_moving_average(decision_date, 2),
|
||||
Some(11.5)
|
||||
);
|
||||
assert_eq!(
|
||||
series.moving_average(decision_date, 2, PriceField::Close),
|
||||
Some((11.0 + 9_999.0) / 2.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_volume_average_skips_paused_days_before_counting_window() {
|
||||
let mut paused = market_row("2025-01-03", 11.0, 0);
|
||||
@@ -3572,6 +3673,118 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eligible_universe_uses_decision_market_cap_same_date() {
|
||||
let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();
|
||||
let instrument = |symbol: &str| Instrument {
|
||||
symbol: symbol.to_string(),
|
||||
name: symbol.to_string(),
|
||||
board: if symbol.ends_with(".SH") { "SH" } else { "SZ" }.to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(NaiveDate::parse_from_str("2020-01-01", "%Y-%m-%d").unwrap()),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
};
|
||||
let market = |symbol: &str, prev_close: f64, close: f64| DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: Some("2025-01-06 10:18:00".to_string()),
|
||||
day_open: prev_close,
|
||||
open: prev_close,
|
||||
high: close.max(prev_close),
|
||||
low: close.min(prev_close),
|
||||
close,
|
||||
last_price: prev_close,
|
||||
bid1: prev_close,
|
||||
ask1: prev_close,
|
||||
prev_close,
|
||||
volume: 100_000,
|
||||
tick_volume: 1_000,
|
||||
bid1_volume: 1_000,
|
||||
ask1_volume: 1_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: prev_close * 1.1,
|
||||
lower_limit: prev_close * 0.9,
|
||||
price_tick: 0.01,
|
||||
};
|
||||
let factor =
|
||||
|symbol: &str, market_cap_bn: f64, free_float_cap_bn: f64| DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
market_cap_bn,
|
||||
free_float_cap_bn,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
extra_factors: BTreeMap::new(),
|
||||
};
|
||||
let candidate = |symbol: &str| 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,
|
||||
};
|
||||
let data = DataSet::from_components(
|
||||
vec![instrument("000001.SZ"), instrument("000002.SZ")],
|
||||
vec![
|
||||
market("000001.SZ", 10.0, 20.0),
|
||||
market("000002.SZ", 10.0, 10.0),
|
||||
],
|
||||
vec![
|
||||
factor("000001.SZ", 12.0, 4.0),
|
||||
factor("000002.SZ", 10.0, 5.0),
|
||||
],
|
||||
vec![candidate("000001.SZ"), candidate("000002.SZ")],
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 100.0,
|
||||
close: 101.0,
|
||||
prev_close: 99.0,
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
)
|
||||
.expect("dataset");
|
||||
|
||||
let rows = data.eligible_universe_on(date);
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].symbol, "000001.SZ");
|
||||
assert!((rows[0].market_cap_bn - 6.0).abs() < 1e-9);
|
||||
assert!((rows[0].free_float_cap_bn - 2.0).abs() < 1e-9);
|
||||
assert_eq!(rows[1].symbol, "000002.SZ");
|
||||
assert!((rows[1].market_cap_bn - 10.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn benchmark_decision_close_windows_exclude_current_close() {
|
||||
let series = BenchmarkPriceSeries::new(&[
|
||||
benchmark_row("2025-01-02", 100.0),
|
||||
benchmark_row("2025-01-03", 200.0),
|
||||
benchmark_row("2025-01-06", 9_999.0),
|
||||
]);
|
||||
let decision_date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();
|
||||
|
||||
assert_eq!(series.decision_close(decision_date), Some(9_998.0));
|
||||
assert_eq!(
|
||||
series.decision_moving_average(decision_date, 2),
|
||||
Some(150.0)
|
||||
);
|
||||
assert_eq!(
|
||||
series.decision_values_for(decision_date, 2, PriceField::Close),
|
||||
vec![100.0, 200.0]
|
||||
);
|
||||
assert_eq!(
|
||||
series.moving_average(decision_date, 2),
|
||||
Some((200.0 + 9_999.0) / 2.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_mixed_numeric_and_text_extra_factors_from_quoted_csv_json() {
|
||||
let path = temp_csv_path("mixed_factor_maps");
|
||||
|
||||
Reference in New Issue
Block a user