diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index d0471ca..3349a08 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -33,6 +33,7 @@ pub mod stock_pool_candidates; pub mod stock_pool_indicators; pub mod stock_pool_execution; pub mod stock_pool_index_policy; +pub mod stock_pool_market_cap; pub mod stock_pool_state; pub mod signal_contract; pub mod strategy_ai; diff --git a/crates/fidc-core/src/stock_pool_market_cap.rs b/crates/fidc-core/src/stock_pool_market_cap.rs new file mode 100644 index 0000000..e780f28 --- /dev/null +++ b/crates/fidc-core/src/stock_pool_market_cap.rs @@ -0,0 +1,108 @@ +//! Configurable index-to-market-cap band. Values are CNY, not implicit yi. +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IndexMarketCapPolicy { + pub schema_version: u32, + pub index_code: String, + pub field: String, + pub value_unit: String, + pub index_low: f64, + pub index_high: f64, + pub lower_at_low: f64, + pub lower_at_high: f64, + pub upper_at_low: f64, + pub upper_at_high: f64, +} + +impl IndexMarketCapPolicy { + pub fn validate(&self) -> Result<(), String> { + if self.schema_version != 1 || self.value_unit != "CNY" + || !matches!(self.field.as_str(), "market_cap" | "float_market_cap") + { return Err("index_market_cap_contract_invalid".into()); } + let index = self.index_code.split_once('.').is_some_and(|(code, exchange)| { + (6..=12).contains(&code.len()) + && code.bytes().all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) + && matches!(exchange, "SH" | "SZ" | "CSI" | "CNI") + }); + if !index { return Err("index_market_cap_index_invalid".into()); } + if [self.index_low,self.index_high,self.lower_at_low,self.lower_at_high,self.upper_at_low,self.upper_at_high] + .iter().any(|value| !value.is_finite() || *value <= 0.) + || self.index_low >= self.index_high || self.lower_at_low > self.upper_at_low + || self.lower_at_high > self.upper_at_high + { return Err("index_market_cap_bounds_invalid".into()); } + Ok(()) + } + pub fn band(&self, close: f64) -> Result<(f64, f64), String> { + self.validate()?; + if !close.is_finite() || close <= 0. { return Err("index_market_cap_close_invalid".into()); } + let t = (close.clamp(self.index_low,self.index_high) - self.index_low) / (self.index_high-self.index_low); + Ok((self.lower_at_low + t*(self.lower_at_high-self.lower_at_low), + self.upper_at_low + t*(self.upper_at_high-self.upper_at_low))) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IndexMarketCapRow { pub date: NaiveDate, pub close: f64 } + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Request { + pub policy: IndexMarketCapPolicy, + pub official_dates: Vec, + pub index_code: String, + pub closes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct Band { pub date: NaiveDate, pub index_close: f64, pub lower: f64, pub upper: f64 } + +pub fn implementation_sha256() -> String { format!("{:x}", Sha256::digest(include_bytes!("stock_pool_market_cap.rs"))) } + +pub fn evaluate(input: &Request) -> Result, String> { + input.policy.validate()?; + if input.index_code != input.policy.index_code || input.official_dates.is_empty() + || input.official_dates.len() > 4000 || input.official_dates.len() != input.closes.len() + || input.official_dates.windows(2).any(|pair| pair[0]>=pair[1]) + || input.closes.iter().zip(&input.official_dates).any(|(row, day)| row.date != *day) + { return Err("index_market_cap_calendar_or_identity_mismatch".into()); } + input.closes.iter().map(|row| { + let (lower,upper)=input.policy.band(row.close)?; + Ok(Band{date:row.date,index_close:row.close,lower,upper}) + }).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + fn policy()->IndexMarketCapPolicy { + serde_json::from_value(serde_json::json!({"schema_version":1,"index_code":"000300.SH","field":"market_cap","value_unit":"CNY", + "index_low":4000,"index_high":6000,"lower_at_low":2000000000_f64,"lower_at_high":3000000000_f64, + "upper_at_low":5000000000_f64,"upper_at_high":8000000000_f64})).unwrap() + } + #[test] + fn interpolates_declared_endpoints_and_clamps_without_business_defaults(){ + assert_eq!(policy().band(3000.).unwrap(),(2e9,5e9)); + assert_eq!(policy().band(5000.).unwrap(),(2.5e9,6.5e9)); + assert_eq!(policy().band(7000.).unwrap(),(3e9,8e9)); + let mut decreasing=policy();decreasing.lower_at_low=3e9;decreasing.lower_at_high=2e9; + assert_eq!(decreasing.band(5000.).unwrap(),(2.5e9,6.5e9)); + assert!(policy().band(f64::NAN).is_err()); + let mut invalid=policy();invalid.value_unit="亿元".into();assert!(invalid.validate().is_err()); + invalid=policy();invalid.index_high=4000.;assert!(invalid.validate().is_err()); + invalid=policy();invalid.lower_at_low=9e9;assert!(invalid.validate().is_err()); + } + #[test] + fn missing_duplicate_or_mismatched_index_inputs_do_not_shrink_the_calendar(){ + let day=NaiveDate::from_ymd_opt(2026,9,11).unwrap(); + let mut input=Request{policy:policy(),official_dates:vec![day],index_code:"000300.SH".into(),closes:vec![IndexMarketCapRow{date:day,close:5000.}]}; + assert_eq!(evaluate(&input).unwrap()[0].lower,2.5e9); + input.official_dates.push(day);assert!(evaluate(&input).is_err());input.official_dates.pop(); + input.index_code="932000.CSI".into();assert!(evaluate(&input).is_err()); + input.index_code="000300.SH".into();input.closes.clear();assert!(evaluate(&input).is_err()); + } +}