|
|
|
@@ -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<NaiveDate>,
|
|
|
|
|
pub index_code: String,
|
|
|
|
|
pub closes: Vec<IndexMarketCapRow>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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<Vec<Band>, 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());
|
|
|
|
|
}
|
|
|
|
|
}
|