Compare commits

..

1 Commits

Author SHA1 Message Date
boris 2f2258f208 feat(stock-pool): add explicit index-mapped market-cap bands 2026-09-12 07:29:10 +08:00
2 changed files with 109 additions and 0 deletions
+1
View File
@@ -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;
@@ -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());
}
}