Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fa1004ec5 | |||
| c4632bacf1 | |||
| 7dcaae594a | |||
| 999bf5bd01 |
@@ -0,0 +1,9 @@
|
||||
use std::io::{self, Read};
|
||||
fn main() {
|
||||
let mut input=String::new();io::stdin().read_to_string(&mut input).unwrap();
|
||||
let request=serde_json::from_str(&input).unwrap();
|
||||
match fidc_core::market_event_context::aggregate(request) {
|
||||
Ok(value)=>println!("{}",serde_json::to_string(&value).unwrap()),
|
||||
Err(error)=>{eprintln!("{error}");std::process::exit(1);}
|
||||
}
|
||||
}
|
||||
@@ -233,7 +233,7 @@ pub(crate) fn evaluate_with_context(
|
||||
.iter()
|
||||
.map(|b| (b.date, b))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
if by_day.len() != series.bars.len() || series.bars.iter().any(|b| !days.contains(&b.date)) {
|
||||
if by_day.len() != series.bars.len() || series.bars.iter().any(|b| days.binary_search(&b.date).is_err()) {
|
||||
return Err(format!(
|
||||
"pattern_input_invalid: symbol={}, reason=duplicate_or_out_of_scope",
|
||||
series.symbol
|
||||
@@ -393,7 +393,7 @@ pub(crate) fn evaluate_with_context(
|
||||
)?;
|
||||
if value < 0.0 || (name == "prev_close" && value == 0.0) {
|
||||
return Err(format!(
|
||||
"pattern_input_invalid: {} {d} {name}",
|
||||
"pattern_input_invalid: symbol={}, date={d}, field={name}, reason=invalid_value",
|
||||
series.symbol
|
||||
));
|
||||
}
|
||||
@@ -674,6 +674,13 @@ pub fn evaluate_batch(
|
||||
spec: PatternSpec,
|
||||
days: &[NaiveDate],
|
||||
series: &[PatternSeries],
|
||||
) -> Result<Value, String> {
|
||||
evaluate_batch_with_policy(spec, days, series, false)
|
||||
}
|
||||
|
||||
/// Partial results are research diagnostics, never strategy execution inputs.
|
||||
pub fn evaluate_batch_with_policy(
|
||||
spec: PatternSpec, days: &[NaiveDate], series: &[PatternSeries], isolate_data_errors: bool,
|
||||
) -> Result<Value, String> {
|
||||
let spec = spec.validate()?;
|
||||
if series.is_empty()
|
||||
@@ -689,13 +696,26 @@ pub fn evaluate_batch(
|
||||
}
|
||||
let rows = series
|
||||
.iter()
|
||||
.map(|s| evaluate(&spec, days, s))
|
||||
.map(|s| research_row(evaluate(&spec, days, s), s, isolate_data_errors))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(
|
||||
json!({"contract":CONTRACT,"spec":spec,"required_history":spec.history_len(),"rows":rows,"read_only":true}),
|
||||
)
|
||||
}
|
||||
|
||||
fn research_row(result: Result<PatternResult, String>, series: &PatternSeries, isolate: bool) -> Result<Value, String> {
|
||||
match result {
|
||||
Ok(row) => Ok(json!(row)),
|
||||
Err(detail) if isolate && detail.starts_with(&format!("pattern_input_invalid: symbol={},", series.symbol)) => {
|
||||
let fields = detail.split(", ").filter_map(|p| p.split_once('=')).collect::<BTreeMap<_,_>>();
|
||||
Ok(json!({"symbol":series.symbol,"name":series.name,"matched":null,"score":null,
|
||||
"checks":[],"values":{},"anchor":null,"exclusion":null,
|
||||
"data_issue":{"reason":fields.get("reason"),"date":fields.get("date"),"field":fields.get("field"),"detail":detail}}))
|
||||
},
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Values are supplied only by the verified research transport or dataset context builder.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -710,8 +730,17 @@ pub fn evaluate_research_batch(
|
||||
spec: PatternSpec, days: &[NaiveDate], series: &[PatternSeries],
|
||||
context: &ResearchContext, numeric_output: bool,
|
||||
) -> Result<Value, String> {
|
||||
let common_fields = ["index_open", "index_high", "index_low", "index_close"];
|
||||
let symbol_fields = ["scope_rank", "scope_percentile", "scope_size"];
|
||||
evaluate_research_batch_with_policy(spec, days, series, context, numeric_output, false)
|
||||
}
|
||||
|
||||
pub fn evaluate_research_batch_with_policy(
|
||||
spec: PatternSpec, days: &[NaiveDate], series: &[PatternSeries],
|
||||
context: &ResearchContext, numeric_output: bool, isolate_data_errors: bool,
|
||||
) -> Result<Value, String> {
|
||||
let common_fields = ["index_open", "index_high", "index_low", "index_close"].into_iter()
|
||||
.chain(crate::market_event_context::COMMON_FIELDS.iter().copied()).collect::<Vec<_>>();
|
||||
let symbol_fields = ["scope_rank", "scope_percentile", "scope_size"].into_iter()
|
||||
.chain(crate::market_event_context::INDUSTRY_FIELDS.iter().copied()).collect::<Vec<_>>();
|
||||
if spec.template != "expression" || series.is_empty() || series.len() > 200
|
||||
|| series.iter().map(|s| &s.symbol).collect::<BTreeSet<_>>().len() != series.len()
|
||||
|| context.common.keys().any(|k| !common_fields.contains(&k.as_str()))
|
||||
@@ -720,7 +749,9 @@ pub fn evaluate_research_batch(
|
||||
return Err("research_context_scope_or_fields_invalid".into());
|
||||
}
|
||||
for (name, values) in &context.common {
|
||||
if values.len() != days.len() || values.iter().any(|v| !v.is_some_and(|x| x.is_finite() && x > 0.0)) {
|
||||
if values.len() != days.len() || values.iter().any(|v| if name.starts_with("index_") {
|
||||
!v.is_some_and(|x| x.is_finite() && x > 0.0)
|
||||
} else { v.is_some_and(|x| !x.is_finite()) }) {
|
||||
return Err(format!("research_index_window_incomplete: {name}"));
|
||||
}
|
||||
}
|
||||
@@ -741,8 +772,8 @@ pub fn evaluate_research_batch(
|
||||
return Err(format!("research_context_invalid: {} {name}", item.symbol));
|
||||
}
|
||||
}
|
||||
let mut result = evaluate_with_context(&spec, days, item, &fields, numeric_output)?;
|
||||
result.values["research_context_latest"] = json!(fields.iter().map(|(k,v)|(k,v.last().copied().flatten())).collect::<BTreeMap<_,_>>());
|
||||
let mut result = research_row(evaluate_with_context(&spec, days, item, &fields, numeric_output), item, isolate_data_errors)?;
|
||||
result["values"]["research_context_latest"] = json!(fields.iter().map(|(k,v)|(k,v.last().copied().flatten())).collect::<BTreeMap<_,_>>());
|
||||
rows.push(result);
|
||||
}
|
||||
Ok(json!({"contract":CONTRACT,"context_contract":"fidc_research_event_context_v1","spec":spec,
|
||||
@@ -786,6 +817,22 @@ pub fn expression_specs(expression: &str) -> Result<Vec<PatternSpec>, String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn research_isolates_missing_listing_day_without_weakening_execution() {
|
||||
let days = ["2026-06-11", "2026-06-12"].map(|d|d.parse::<NaiveDate>().unwrap());
|
||||
let spec: PatternSpec = serde_json::from_value(json!({"template":"ma_below","parameters":{"ma_window":2}})).unwrap();
|
||||
let make = |symbol: &str| -> PatternSeries { serde_json::from_value(json!({"symbol":symbol,"listed_at":"2026-06-11","bars":days.map(|d|json!({"date":d,"open":10.,"high":11.,"low":9.,"close":10.,"volume":100.,"adjustment_factor_backward1":1.,"paused":false,"source_path":"/controlled/source.parquet"}))})).unwrap() };
|
||||
let complete=make("300395.SZ");let mut missing=make("920083.BJ");missing.bars.remove(0);
|
||||
let members=[complete.clone(),missing];
|
||||
assert!(evaluate_batch(spec.clone(),&days,&members).unwrap_err().contains("missing_market_row"));
|
||||
let partial=evaluate_batch_with_policy(spec.clone(),&days,&members,true).unwrap();
|
||||
assert_eq!(partial["rows"][0],json!(evaluate(&spec.validate().unwrap(),&days,&complete).unwrap()));
|
||||
assert!(partial["rows"][1]["matched"].is_null());
|
||||
assert_eq!(partial["rows"][1]["data_issue"]["date"],"2026-06-11");
|
||||
assert_eq!(partial["rows"][1]["data_issue"]["reason"],"missing_market_row");
|
||||
let invalid:PatternSpec=serde_json::from_value(json!({"template":"not-a-template"})).unwrap();
|
||||
assert!(evaluate_batch_with_policy(invalid,&days,&members,true).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn research_index_and_ranking_context_never_unlock_strategy_mapping() {
|
||||
let days=["2026-09-04","2026-09-07","2026-09-08"].map(|s|s.parse::<NaiveDate>().unwrap());
|
||||
let spec:PatternSpec=serde_json::from_value(json!({"template":"expression","parameters":{"history_window":3},
|
||||
|
||||
@@ -179,6 +179,10 @@ pub fn catalog() -> Value {
|
||||
json!({"contract":CONTRACT,"library":{"name":"TA-Lib native Rust","revision":TA_REV,"license":"BSD-3-Clause"},
|
||||
"execution_context_contract":crate::pattern_context::CONTRACT,
|
||||
"execution_context_fields":crate::pattern_context::CONTEXT_FIELDS,
|
||||
"market_event_context_contract":crate::market_event_context::CONTRACT,
|
||||
"market_event_kernel_sha256":crate::market_event_context::implementation_sha256(),
|
||||
"market_event_common_fields":crate::market_event_context::COMMON_FIELDS,
|
||||
"market_event_industry_fields":crate::market_event_context::INDUSTRY_FIELDS,
|
||||
"session_events":crate::session_events::EVENTS,"session_event_contract":crate::session_events::CONTRACT,
|
||||
"indicators":indicators,"operators":OPERATORS,"cross_section_operators":crate::factor_cross_section::OPERATORS,"read_only":true,"live_routing":false,
|
||||
"policies":{"null":"unknown_not_false","warmup":"null_until_full_history","recursive_seed":"frozen_input_start",
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod pattern_context;
|
||||
pub mod session_events;
|
||||
pub mod factor_events;
|
||||
pub mod factor_cross_section;
|
||||
pub mod market_event_context;
|
||||
pub mod engine;
|
||||
pub mod event_bus;
|
||||
pub mod events;
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
//! Complete published daily cross sections, independent of trading candidates and accounts.
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub const CONTRACT: &str = "fidc_market_event_context_v1";
|
||||
pub fn implementation_sha256() -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
format!("{:x}", Sha256::digest(include_bytes!("market_event_context.rs")))
|
||||
}
|
||||
pub const COMMON_FIELDS: &[&str] = &[
|
||||
"market_breadth", "market_return", "market_limit_up_count", "market_limit_down_count",
|
||||
"market_limit_up_rate", "market_broken_limit_rate", "market_high_board", "market_profit_effect",
|
||||
];
|
||||
pub const INDUSTRY_FIELDS: &[&str] = &[
|
||||
"industry_close", "industry_return_20", "industry_breadth", "industry_rank", "industry_size",
|
||||
];
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Observation {
|
||||
pub symbol: String,
|
||||
pub industry: Option<String>,
|
||||
pub close: Option<f64>,
|
||||
pub high: Option<f64>,
|
||||
pub previous_close: Option<f64>,
|
||||
pub upper_limit: Option<f64>,
|
||||
pub lower_limit: Option<f64>,
|
||||
pub no_limit: Option<bool>,
|
||||
pub paused: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Day {
|
||||
pub date: NaiveDate,
|
||||
pub universe: Vec<String>,
|
||||
pub rows: Vec<Observation>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Deserialize, Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct State {
|
||||
pub last_date: Option<NaiveDate>,
|
||||
pub streaks: BTreeMap<String, Option<u32>>,
|
||||
pub limit_ups: BTreeSet<String>,
|
||||
pub industry_history: BTreeMap<String, Vec<f64>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Request {
|
||||
pub days: Vec<Day>,
|
||||
#[serde(default)]
|
||||
pub previous: State,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct OutputDay {
|
||||
pub date: NaiveDate,
|
||||
pub common: BTreeMap<String, Option<f64>>,
|
||||
pub industries: BTreeMap<String, BTreeMap<String, Option<f64>>>,
|
||||
pub members: BTreeMap<String, Option<String>>,
|
||||
pub securities: usize,
|
||||
pub active: usize,
|
||||
pub paused: usize,
|
||||
pub no_limit: usize,
|
||||
pub profit_effect_members: Vec<String>,
|
||||
pub profit_effect_missing: Vec<String>,
|
||||
pub industry_missing: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Output {
|
||||
pub contract: &'static str,
|
||||
pub days: Vec<OutputDay>,
|
||||
pub state: State,
|
||||
}
|
||||
|
||||
fn positive(value: Option<f64>, symbol: &str, field: &str) -> Result<f64, String> {
|
||||
value.filter(|v| v.is_finite() && *v > 0.0)
|
||||
.ok_or_else(|| format!("market_event_input_invalid: {symbol} {field}"))
|
||||
}
|
||||
fn average(values: impl Iterator<Item = f64>, n: usize) -> f64 {
|
||||
values.map(|v| v / n as f64).sum()
|
||||
}
|
||||
|
||||
pub fn aggregate(request: Request) -> Result<Output, String> {
|
||||
let mut state = request.previous;
|
||||
if request.days.is_empty() || request.days.len() > 30
|
||||
|| request.days.iter().map(|d| d.rows.len()).sum::<usize>() > 60_000
|
||||
|| state.streaks.len() > 20_000 || state.limit_ups.len() > 20_000
|
||||
|| state.industry_history.len() > 2000
|
||||
|| state.industry_history.values().any(|v| v.is_empty() || v.len() > 21
|
||||
|| v.iter().any(|x| !x.is_finite() || *x <= 0.0))
|
||||
|| state.last_date.is_none() && (!state.streaks.is_empty() || !state.limit_ups.is_empty() || !state.industry_history.is_empty()) {
|
||||
return Err("market_event_history_budget_or_state_invalid".into());
|
||||
}
|
||||
let mut output = Vec::new();
|
||||
for day in request.days {
|
||||
if state.last_date.is_some_and(|d| d >= day.date)
|
||||
|| day.universe.is_empty() || day.universe.len() > 20_000
|
||||
|| day.universe.iter().collect::<BTreeSet<_>>().len() != day.universe.len()
|
||||
|| day.rows.len() != day.universe.len()
|
||||
|| day.rows.iter().map(|r| &r.symbol).collect::<BTreeSet<_>>() != day.universe.iter().collect::<BTreeSet<_>>() {
|
||||
return Err(format!("market_event_incomplete_cross_section: {}", day.date));
|
||||
}
|
||||
let mut returns = BTreeMap::new();
|
||||
let mut groups: BTreeMap<String, Vec<f64>> = BTreeMap::new();
|
||||
let mut members = BTreeMap::new();
|
||||
let mut streaks = BTreeMap::new();
|
||||
let mut ups = BTreeSet::new();
|
||||
let mut downs = 0; let mut touched = 0; let mut broken = 0; let mut paused = 0; let mut unlimited = 0;
|
||||
for row in &day.rows {
|
||||
let industry = row.industry.clone().filter(|s| !s.trim().is_empty());
|
||||
members.insert(row.symbol.clone(), industry.clone());
|
||||
match row.paused {
|
||||
Some(true) => {
|
||||
paused += 1;
|
||||
streaks.insert(row.symbol.clone(), state.streaks.get(&row.symbol).copied().flatten());
|
||||
continue;
|
||||
},
|
||||
Some(false) => {},
|
||||
None => return Err(format!("market_event_pause_state_missing: {} {}", day.date, row.symbol)),
|
||||
}
|
||||
let c = positive(row.close, &row.symbol, "close")?;
|
||||
let h = positive(row.high, &row.symbol, "high")?;
|
||||
let p = positive(row.previous_close, &row.symbol, "previous_close")?;
|
||||
if h + 1e-8 < c { return Err(format!("market_event_high_below_close: {}", row.symbol)); }
|
||||
let change = c / p - 1.0;
|
||||
returns.insert(row.symbol.clone(), change);
|
||||
if let Some(industry) = industry { groups.entry(industry).or_default().push(change); }
|
||||
let is_up = match row.no_limit {
|
||||
Some(true) => { unlimited += 1; false },
|
||||
Some(false) => {
|
||||
let upper = positive(row.upper_limit, &row.symbol, "upper_limit")?;
|
||||
let lower = positive(row.lower_limit, &row.symbol, "lower_limit")?;
|
||||
if lower >= upper || c > upper + 1e-8 || c < lower - 1e-8 {
|
||||
return Err(format!("market_event_limit_bounds_invalid: {} {}", day.date, row.symbol));
|
||||
}
|
||||
let at_up = (c - upper).abs() <= 1e-8;
|
||||
if (c - lower).abs() <= 1e-8 { downs += 1; }
|
||||
if h >= upper - 1e-8 { touched += 1; if !at_up { broken += 1; } }
|
||||
at_up
|
||||
},
|
||||
None => return Err(format!("market_event_limit_policy_missing: {}", row.symbol)),
|
||||
};
|
||||
if is_up {
|
||||
ups.insert(row.symbol.clone());
|
||||
// The first observed limit-up may already be a continuing streak.
|
||||
streaks.insert(row.symbol.clone(), state.streaks.get(&row.symbol).copied().flatten().map(|v| v + 1));
|
||||
} else { streaks.insert(row.symbol.clone(), Some(0)); }
|
||||
}
|
||||
let active = returns.len();
|
||||
if active == 0 { return Err(format!("market_event_no_active_market: {}", day.date)); }
|
||||
let previous_ups = state.limit_ups.iter().cloned().collect::<Vec<_>>();
|
||||
let profit_missing = previous_ups.iter().filter(|s| !returns.contains_key(*s)).cloned().collect::<Vec<_>>();
|
||||
let profit = if previous_ups.is_empty() || !profit_missing.is_empty() { None }
|
||||
else { Some(average(previous_ups.iter().map(|s| returns[s]), previous_ups.len())) };
|
||||
let board = if ups.iter().any(|s| streaks[s].is_none()) { None }
|
||||
else { Some(ups.iter().map(|s| streaks[s].unwrap()).max().unwrap_or(0) as f64) };
|
||||
let common = BTreeMap::from([
|
||||
("market_breadth".into(), Some(returns.values().filter(|v| **v > 0.0).count() as f64 / active as f64)),
|
||||
("market_return".into(), Some(average(returns.values().copied(), active))),
|
||||
("market_limit_up_count".into(), Some(ups.len() as f64)),
|
||||
("market_limit_down_count".into(), Some(downs as f64)),
|
||||
("market_limit_up_rate".into(), (touched > 0).then(|| ups.len() as f64 / touched as f64)),
|
||||
("market_broken_limit_rate".into(), (touched > 0).then(|| broken as f64 / touched as f64)),
|
||||
("market_high_board".into(), board),
|
||||
("market_profit_effect".into(), profit),
|
||||
]);
|
||||
let mut industries = BTreeMap::new();
|
||||
// A disappeared group breaks its continuous history; no stale NAV is carried forward.
|
||||
state.industry_history.retain(|key, _| groups.contains_key(key));
|
||||
for (industry, values) in groups {
|
||||
let history = state.industry_history.entry(industry.clone()).or_default();
|
||||
let nav = history.last().copied().unwrap_or(1.0) * (1.0 + average(values.iter().copied(), values.len()));
|
||||
history.push(nav);
|
||||
if history.len() > 21 { history.remove(0); }
|
||||
let momentum = (history.len() == 21).then(|| nav / history[0] - 1.0);
|
||||
industries.insert(industry, BTreeMap::from([
|
||||
("industry_close".into(), Some(nav)), ("industry_return_20".into(), momentum),
|
||||
("industry_breadth".into(), Some(values.iter().filter(|v| **v > 0.0).count() as f64 / values.len() as f64)),
|
||||
]));
|
||||
}
|
||||
let universe = industries.keys().cloned().collect::<Vec<_>>();
|
||||
let known = industries.values().all(|g| g["industry_return_20"].is_some());
|
||||
let ranks = if known && !universe.is_empty() {
|
||||
crate::factor_cross_section::evaluate("RANK", &universe, &industries.iter().map(|(s,g)|
|
||||
crate::factor_cross_section::Observation {symbol:s.clone(), value:g["industry_return_20"].unwrap(),industry:None,market_cap:None}).collect::<Vec<_>>(),0.0)?
|
||||
.into_iter().map(|r|(r.symbol,r.value)).collect::<BTreeMap<_,_>>()
|
||||
} else { BTreeMap::new() };
|
||||
for (name, fields) in &mut industries {
|
||||
fields.insert("industry_rank".into(), ranks.get(name).copied());
|
||||
fields.insert("industry_size".into(), Some(universe.len() as f64));
|
||||
}
|
||||
let industry_missing=members.iter().filter(|(_,group)|group.is_none()).map(|(s,_)|s.clone()).collect::<Vec<_>>();
|
||||
if !industry_missing.is_empty() {
|
||||
// An unclassified member may belong to any group; never silently shrink a group.
|
||||
state.industry_history.clear();
|
||||
for fields in industries.values_mut() { for value in fields.values_mut() { *value=None; } }
|
||||
}
|
||||
output.push(OutputDay { date:day.date, common, industries, members, securities:day.rows.len(), active, paused,
|
||||
no_limit:unlimited, profit_effect_members:previous_ups, profit_effect_missing:profit_missing, industry_missing });
|
||||
state.last_date = Some(day.date); state.streaks = streaks; state.limit_ups = ups;
|
||||
}
|
||||
Ok(Output {contract:CONTRACT, days:output, state})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn day(n: u32, up: bool) -> Day {
|
||||
Day {date:NaiveDate::from_ymd_opt(2026,9,n).unwrap(), universe:vec!["A".into(),"B".into()], rows:vec![
|
||||
Observation{symbol:"A".into(),industry:Some("I".into()),close:Some(if up {11.0}else{10.0}),high:Some(11.0),previous_close:Some(10.0),upper_limit:Some(11.0),lower_limit:Some(9.0),no_limit:Some(false),paused:Some(false)},
|
||||
Observation{symbol:"B".into(),industry:Some("J".into()),close:Some(9.0),high:Some(10.0),previous_close:Some(10.0),upper_limit:Some(11.0),lower_limit:Some(9.0),no_limit:Some(false),paused:Some(false)}]}
|
||||
}
|
||||
#[test]
|
||||
fn formulas_use_real_limits_and_full_denominators() {
|
||||
let r=aggregate(Request{days:vec![day(1,false),day(2,true),day(3,true)],previous:State::default()}).unwrap();
|
||||
let d=&r.days[1];
|
||||
assert_eq!(d.common["market_breadth"],Some(0.5));
|
||||
assert_eq!(d.common["market_limit_down_count"],Some(1.0));
|
||||
assert_eq!(d.common["market_limit_up_rate"],Some(1.0));
|
||||
assert_eq!(r.days[0].common["market_limit_up_rate"],Some(0.0));
|
||||
assert_eq!(r.days[0].common["market_broken_limit_rate"],Some(1.0));
|
||||
assert_eq!(r.days[2].common["market_high_board"],Some(2.0));
|
||||
assert!((r.days[2].common["market_profit_effect"].unwrap()-0.1).abs()<1e-12);
|
||||
assert_eq!(r.days[0].common["market_profit_effect"],None);
|
||||
}
|
||||
#[test]
|
||||
fn missing_duplicate_and_unproven_limit_states_fail() {
|
||||
let mut d=day(1,true);d.rows.pop();assert!(aggregate(Request{days:vec![d],previous:State::default()}).is_err());
|
||||
let mut d=day(1,true);d.rows[0].upper_limit=None;assert!(aggregate(Request{days:vec![d],previous:State::default()}).is_err());
|
||||
let mut d=day(1,true);d.rows[0].no_limit=Some(true);d.rows[0].upper_limit=None;
|
||||
assert_eq!(aggregate(Request{days:vec![d],previous:State::default()}).unwrap().days[0].no_limit,1);
|
||||
}
|
||||
#[test]
|
||||
fn chunking_and_future_append_preserve_history() {
|
||||
let first=aggregate(Request{days:vec![day(1,false),day(2,true)],previous:State::default()}).unwrap();
|
||||
let next=aggregate(Request{days:vec![day(3,true)],previous:first.state}).unwrap();
|
||||
let full=aggregate(Request{days:vec![day(1,false),day(2,true),day(3,true)],previous:State::default()}).unwrap();
|
||||
assert_eq!(serde_json::to_value(&first.days).unwrap(),serde_json::to_value(&full.days[..2]).unwrap());
|
||||
assert_eq!(serde_json::to_value(&next.days).unwrap(),serde_json::to_value(&full.days[2..]).unwrap());
|
||||
let unknown=aggregate(Request{days:vec![day(1,true)],previous:State::default()}).unwrap();
|
||||
assert_eq!(unknown.days[0].common["market_high_board"],None);
|
||||
}
|
||||
#[test]
|
||||
fn missing_industry_does_not_invent_groups_or_disable_independent_market_facts() {
|
||||
let mut missing=day(2,true);missing.rows[0].industry=None;
|
||||
let r=aggregate(Request{days:vec![day(1,false),missing,day(3,true)],previous:State::default()}).unwrap();
|
||||
assert_eq!(r.days[1].common["market_breadth"],Some(0.5));
|
||||
assert_eq!(r.days[1].industry_missing,vec!["A"]);
|
||||
assert!(r.days[1].industries.values().flat_map(|g|g.values()).all(Option::is_none));
|
||||
assert_eq!(r.days[2].industries["I"]["industry_return_20"],None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user