588 lines
29 KiB
Rust
588 lines
29 KiB
Rust
//! Immutable, account-independent trading signals. Quantity and execution
|
|
//! prices are intentionally absent; the existing broker owns those decisions.
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
|
|
|
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
|
|
use crate::strategy::{OrderIntent, StrategyContext};
|
|
use crate::portfolio::PortfolioState;
|
|
|
|
pub const SIGNAL_BOOK_SCHEMA: &str = "fidc.signal-book/v2";
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
pub struct SignalBookReference {
|
|
pub book_id: String,
|
|
pub version_sha256: String,
|
|
pub artifact_sha256: String,
|
|
}
|
|
|
|
impl SignalBookReference {
|
|
pub fn validate(&self) -> Result<(), String> {
|
|
if !valid_sha(&self.version_sha256) || !valid_sha(&self.artifact_sha256)
|
|
|| self.book_id != format!("signal_book_{}",self.version_sha256)
|
|
{ return Err("signal_book_reference_invalid".into()); }
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct SignalCache {
|
|
entries: BTreeMap<String,Weak<ValidatedSignalBook>>,
|
|
retained: std::collections::VecDeque<(String,Arc<ValidatedSignalBook>,usize)>,
|
|
}
|
|
|
|
fn signal_cache() -> &'static Mutex<SignalCache> {
|
|
static CACHE: OnceLock<Mutex<SignalCache>> = OnceLock::new();
|
|
CACHE.get_or_init(||Mutex::new(SignalCache::default()))
|
|
}
|
|
|
|
pub fn cached_signal_book(reference: &SignalBookReference) -> Result<Option<Arc<ValidatedSignalBook>>,String> {
|
|
reference.validate()?;
|
|
let cache=signal_cache().lock().map_err(|_|"signal_cache_lock_failed")?;
|
|
let book=cache.entries.get(&reference.artifact_sha256).and_then(Weak::upgrade);
|
|
if book.as_ref().is_some_and(|book|book.version_sha256()!=reference.version_sha256) {
|
|
return Err("signal_book_cached_version_mismatch".into());
|
|
}
|
|
Ok(book)
|
|
}
|
|
|
|
pub fn register_signal_book(reference: &SignalBookReference, body: &[u8]) -> Result<Arc<ValidatedSignalBook>,String> {
|
|
use sha2::{Digest,Sha256};
|
|
reference.validate()?;
|
|
if body.len()>64*1024*1024 || format!("{:x}",Sha256::digest(body))!=reference.artifact_sha256 {
|
|
return Err("signal_book_artifact_hash_or_size_invalid".into());
|
|
}
|
|
let raw:SignalBook=serde_json::from_slice(body).map_err(|error|format!("signal_book_decode_failed: {error}"))?;
|
|
if raw.version_sha256!=reference.version_sha256 { return Err("signal_book_version_mismatch".into()); }
|
|
let book=Arc::new(raw.validate()?);
|
|
let mut cache=signal_cache().lock().map_err(|_|"signal_cache_lock_failed")?;
|
|
cache.entries.retain(|_,value|value.strong_count()>0);
|
|
if let Some(existing)=cache.entries.get(&reference.artifact_sha256).and_then(Weak::upgrade) { return Ok(existing); }
|
|
cache.entries.insert(reference.artifact_sha256.clone(),Arc::downgrade(&book));
|
|
let estimated=body.len().saturating_mul(4);
|
|
if estimated<=128*1024*1024 {
|
|
cache.retained.push_back((reference.artifact_sha256.clone(),book.clone(),estimated));
|
|
while cache.retained.len()>4 || cache.retained.iter().map(|entry|entry.2).sum::<usize>()>128*1024*1024 {
|
|
cache.retained.pop_front();
|
|
}
|
|
}
|
|
Ok(book)
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum SignalProvenance {
|
|
Observed,
|
|
Reconstructed,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum SignalFrequency {
|
|
Daily,
|
|
Minute,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
|
pub enum SignalAction {
|
|
TargetWeight { symbol: String, weight: f64 },
|
|
BuyCondition { symbol: String, allowed: bool },
|
|
Exit { symbol: String },
|
|
Reduce { symbol: String, remaining_ratio: f64 },
|
|
}
|
|
|
|
impl SignalAction {
|
|
fn symbol(&self) -> &str {
|
|
match self {
|
|
Self::TargetWeight { symbol, .. }
|
|
| Self::BuyCondition { symbol, .. }
|
|
| Self::Exit { symbol }
|
|
| Self::Reduce { symbol, .. } => symbol,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
pub struct SignalSnapshot {
|
|
pub signal_at: DateTime<Utc>,
|
|
pub decision_at: DateTime<Utc>,
|
|
pub input_as_of: DateTime<Utc>,
|
|
pub input_available_at: DateTime<Utc>,
|
|
pub generated_at: DateTime<Utc>,
|
|
pub published_at: DateTime<Utc>,
|
|
pub input_sha256: String,
|
|
pub complete_targets: bool,
|
|
pub actions: Vec<SignalAction>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
pub struct SignalBook {
|
|
pub schema: String,
|
|
pub version_sha256: String,
|
|
pub generator_sha256: String,
|
|
pub model_sha256: Option<String>,
|
|
pub knowledge_cutoff: Option<DateTime<Utc>>,
|
|
pub provenance: SignalProvenance,
|
|
pub frequency: SignalFrequency,
|
|
pub expected_decisions: Vec<DateTime<Utc>>,
|
|
pub snapshots: Vec<SignalSnapshot>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ValidatedSignalBook {
|
|
book: SignalBook,
|
|
index: BTreeMap<NaiveDateTime, usize>,
|
|
}
|
|
|
|
fn valid_sha(value: &str) -> bool {
|
|
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
|
}
|
|
|
|
fn shanghai(value: DateTime<Utc>) -> NaiveDateTime {
|
|
value.with_timezone(&FixedOffset::east_opt(8 * 3600).expect("Shanghai offset")).naive_local()
|
|
}
|
|
|
|
impl SignalBook {
|
|
pub fn content_sha256(&self) -> Result<String, String> {
|
|
let mut value=serde_json::to_value(self).map_err(|error|error.to_string())?;
|
|
value.as_object_mut().ok_or("signal_book_object_required")?.remove("versionSha256");
|
|
value["knowledgeCutoff"]=self.knowledge_cutoff.map(|at|serde_json::json!(at.timestamp_micros())).unwrap_or(serde_json::Value::Null);
|
|
value["expectedDecisions"]=serde_json::json!(self.expected_decisions.iter().map(DateTime::timestamp_micros).collect::<Vec<_>>());
|
|
for (raw,snapshot) in value["snapshots"].as_array_mut().ok_or("signal_snapshots_required")?.iter_mut().zip(&self.snapshots) {
|
|
let object=raw.as_object_mut().ok_or("signal_snapshot_required")?;
|
|
object.remove("generatedAt");
|
|
object.remove("publishedAt");
|
|
for (key,at) in [("signalAt",snapshot.signal_at),("decisionAt",snapshot.decision_at),
|
|
("inputAsOf",snapshot.input_as_of),("inputAvailableAt",snapshot.input_available_at)] {
|
|
object.insert(key.into(),serde_json::json!(at.timestamp_micros()));
|
|
}
|
|
for (raw,action) in object.get_mut("actions").and_then(serde_json::Value::as_array_mut).ok_or("signal_actions_required")?.iter_mut().zip(&snapshot.actions) {
|
|
match action {
|
|
SignalAction::TargetWeight{weight,..}=>raw["weight"]=serde_json::json!(format!("{:016x}",weight.to_bits())),
|
|
SignalAction::Reduce{remaining_ratio,..}=>raw["remaining_ratio"]=serde_json::json!(format!("{:016x}",remaining_ratio.to_bits())),
|
|
_=>{}
|
|
}
|
|
}
|
|
}
|
|
fn sorted(value:serde_json::Value)->serde_json::Value {
|
|
match value {
|
|
serde_json::Value::Object(map)=>serde_json::Value::Object(map.into_iter().map(|(key,value)|(key,sorted(value)))
|
|
.collect::<BTreeMap<_,_>>().into_iter().collect()),
|
|
serde_json::Value::Array(rows)=>serde_json::Value::Array(rows.into_iter().map(sorted).collect()),
|
|
other=>other,
|
|
}
|
|
}
|
|
let raw=serde_json::to_vec(&sorted(value)).map_err(|error|error.to_string())?;
|
|
Ok(format!("{:x}",Sha256::digest(raw)))
|
|
}
|
|
|
|
pub fn validate(self) -> Result<ValidatedSignalBook, String> {
|
|
if self.schema != SIGNAL_BOOK_SCHEMA || !valid_sha(&self.version_sha256)
|
|
|| !valid_sha(&self.generator_sha256)
|
|
{
|
|
return Err("signal_book_identity_invalid".into());
|
|
}
|
|
if self.model_sha256.as_ref().is_some_and(|value| !valid_sha(value))
|
|
|| self.model_sha256.is_some() != self.knowledge_cutoff.is_some()
|
|
{ return Err("signal_model_training_identity_incomplete".into()); }
|
|
if self.expected_decisions.is_empty() || self.expected_decisions.len() > 100_000
|
|
|| self.expected_decisions.len() != self.snapshots.len()
|
|
{
|
|
return Err("signal_book_decision_coverage_incomplete".into());
|
|
}
|
|
let mut index = BTreeMap::new();
|
|
let mut previous = None;
|
|
let mut total_actions = 0usize;
|
|
for (number, (expected, snapshot)) in self.expected_decisions.iter().zip(&self.snapshots).enumerate() {
|
|
if [*expected,snapshot.signal_at,snapshot.input_as_of,snapshot.input_available_at,snapshot.generated_at,snapshot.published_at]
|
|
.iter().any(|at|at.timestamp_subsec_nanos()%1000!=0) || self.knowledge_cutoff.is_some_and(|at|at.timestamp_subsec_nanos()%1000!=0) {
|
|
return Err("signal_timestamp_requires_microsecond_precision".into());
|
|
}
|
|
if snapshot.decision_at != *expected || previous.is_some_and(|value| value >= *expected) {
|
|
return Err("signal_book_decisions_duplicate_or_unordered".into());
|
|
}
|
|
previous = Some(*expected);
|
|
if self.knowledge_cutoff.is_some_and(|cutoff| cutoff > snapshot.signal_at) || snapshot.signal_at > *expected
|
|
|| snapshot.input_available_at > snapshot.signal_at || snapshot.input_as_of > snapshot.input_available_at
|
|
|| snapshot.published_at < snapshot.generated_at || !valid_sha(&snapshot.input_sha256)
|
|
|| snapshot.generated_at < snapshot.input_available_at
|
|
|| self.knowledge_cutoff.is_some_and(|cutoff| snapshot.generated_at < cutoff)
|
|
{
|
|
return Err("signal_book_future_or_invalid_input".into());
|
|
}
|
|
if self.provenance == SignalProvenance::Observed && snapshot.published_at > *expected {
|
|
return Err("observed_signal_not_available_at_decision".into());
|
|
}
|
|
total_actions = total_actions.checked_add(snapshot.actions.len()).ok_or("signal_book_action_limit")?;
|
|
if total_actions > 2_000_000 { return Err("signal_book_action_limit".into()); }
|
|
let mut action_keys = BTreeSet::new();
|
|
let mut target_symbols = BTreeSet::new();
|
|
let mut reductions = BTreeSet::new();
|
|
let mut total_weight = 0.0;
|
|
for action in &snapshot.actions {
|
|
let symbol = action.symbol();
|
|
if symbol.is_empty() || symbol.trim() != symbol { return Err("signal_symbol_invalid".into()); }
|
|
let kind = match action {
|
|
SignalAction::TargetWeight { weight, .. } => {
|
|
if !weight.is_finite() || !(0.0..=1.0).contains(weight) { return Err("signal_target_weight_invalid".into()); }
|
|
target_symbols.insert(symbol);
|
|
total_weight += weight;
|
|
"target"
|
|
}
|
|
SignalAction::BuyCondition { .. } => "buy_condition",
|
|
SignalAction::Exit { .. } => { reductions.insert(symbol); "exit" }
|
|
SignalAction::Reduce { remaining_ratio, .. } => {
|
|
if !remaining_ratio.is_finite() || !(0.0..1.0).contains(remaining_ratio) { return Err("signal_reduction_invalid".into()); }
|
|
reductions.insert(symbol);
|
|
"reduce"
|
|
}
|
|
};
|
|
if !action_keys.insert((symbol, kind)) { return Err("signal_action_duplicate".into()); }
|
|
}
|
|
if total_weight > 1.0 + 1e-12 { return Err("signal_target_exposure_exceeds_one".into()); }
|
|
if snapshot.complete_targets && !reductions.is_empty() {
|
|
return Err("complete_target_snapshot_cannot_mix_relative_exits".into());
|
|
}
|
|
if !target_symbols.is_disjoint(&reductions) { return Err("signal_target_exit_conflict".into()); }
|
|
for symbol in &reductions {
|
|
if action_keys.contains(&(*symbol, "exit")) && action_keys.contains(&(*symbol, "reduce")) {
|
|
return Err("signal_exit_reduction_conflict".into());
|
|
}
|
|
}
|
|
index.insert(shanghai(*expected), number);
|
|
}
|
|
if self.content_sha256()? != self.version_sha256 {
|
|
return Err("signal_book_content_hash_mismatch".into());
|
|
}
|
|
Ok(ValidatedSignalBook { book: self, index })
|
|
}
|
|
}
|
|
|
|
impl ValidatedSignalBook {
|
|
pub fn require_observed(&self) -> Result<(), String> {
|
|
if self.book.provenance != SignalProvenance::Observed {
|
|
return Err("reconstructed_signal_forbidden_in_online_execution".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn version_sha256(&self) -> &str { &self.book.version_sha256 }
|
|
pub fn generator_sha256(&self) -> &str { &self.book.generator_sha256 }
|
|
|
|
pub fn decision_dates(&self) -> BTreeSet<NaiveDate> {
|
|
self.index.keys().map(|value| value.date()).collect()
|
|
}
|
|
|
|
pub fn symbols(&self) -> BTreeSet<String> {
|
|
self.book.snapshots.iter().flat_map(|snapshot| &snapshot.actions)
|
|
.map(|action| action.symbol().to_owned()).collect()
|
|
}
|
|
|
|
pub fn snapshot_for(&self, ctx: &StrategyContext<'_>) -> Result<&SignalSnapshot, String> {
|
|
let snapshot = self.snapshot_at(ctx.execution_date, ctx.current_time(), ctx.is_lagged_execution())?;
|
|
if self.book.provenance == SignalProvenance::Observed && ctx.current_datetime().is_none() {
|
|
return Err("observed_signal_consumption_clock_missing".into());
|
|
}
|
|
let consumption_clock=ctx.current_datetime()
|
|
.unwrap_or(ctx.decision_date.and_hms_opt(15,0,0).expect("completed decision session"));
|
|
let lagged_daily=ctx.is_lagged_execution() && self.book.frequency==SignalFrequency::Daily;
|
|
if lagged_daily && shanghai(snapshot.input_as_of).date()>ctx.decision_date {
|
|
return Err("next_open_signal_contains_execution_session_inputs".into());
|
|
}
|
|
if shanghai(snapshot.input_available_at)>consumption_clock || shanghai(snapshot.signal_at)>consumption_clock {
|
|
return Err("signal_not_available_at_consumption_clock".into());
|
|
}
|
|
if self.book.provenance == SignalProvenance::Observed
|
|
&& (shanghai(snapshot.generated_at)>consumption_clock || shanghai(snapshot.published_at)>consumption_clock) {
|
|
return Err("observed_signal_published_after_consumption_clock".into());
|
|
}
|
|
Ok(snapshot)
|
|
}
|
|
|
|
pub fn is_due_on(&self, execution_date: NaiveDate) -> bool {
|
|
self.index.range(execution_date.and_hms_opt(0,0,0).expect("session start")..)
|
|
.next().is_some_and(|(at,_)|at.date()==execution_date)
|
|
}
|
|
|
|
fn snapshot_at(&self, execution_date: NaiveDate, current_time: Option<NaiveTime>, lagged: bool) -> Result<&SignalSnapshot, String> {
|
|
let at = if self.book.frequency == SignalFrequency::Daily && lagged {
|
|
execution_date.and_hms_opt(9, 30, 0).expect("next open")
|
|
} else {
|
|
execution_date.and_time(current_time.unwrap_or(NaiveTime::from_hms_opt(15, 0, 0).expect("daily close")))
|
|
};
|
|
self.index.get(&at).map(|index| &self.book.snapshots[*index])
|
|
.ok_or_else(|| format!("signal_snapshot_missing_at_decision: {at}"))
|
|
}
|
|
|
|
pub fn intents(&self, ctx: &StrategyContext<'_>) -> Result<Vec<OrderIntent>, String> {
|
|
let snapshot = self.snapshot_for(ctx)?;
|
|
self.snapshot_intents(snapshot, ctx.portfolio)
|
|
}
|
|
|
|
fn snapshot_intents(&self, snapshot: &SignalSnapshot, portfolio: &PortfolioState) -> Result<Vec<OrderIntent>, String> {
|
|
let reason = format!("信号执行 version={} decision={}", self.book.version_sha256, snapshot.decision_at);
|
|
let mut intents = Vec::new();
|
|
let mut weights = BTreeMap::new();
|
|
for action in &snapshot.actions {
|
|
match action {
|
|
SignalAction::TargetWeight { symbol, weight } if snapshot.complete_targets => {
|
|
weights.insert(symbol.clone(), *weight);
|
|
}
|
|
SignalAction::TargetWeight { symbol, weight } => intents.push(OrderIntent::TargetPercent {
|
|
symbol: symbol.clone(), target_percent: *weight, reason: reason.clone(),
|
|
}),
|
|
SignalAction::Exit { symbol } => intents.push(OrderIntent::TargetPercent {
|
|
symbol: symbol.clone(), target_percent: 0.0, reason: reason.clone(),
|
|
}),
|
|
SignalAction::Reduce { symbol, remaining_ratio } => {
|
|
if let Some(position) = portfolio.position(symbol).filter(|position| position.quantity > 0) {
|
|
let quantity = (f64::from(position.quantity) * remaining_ratio).floor() as u32;
|
|
let target_quantity = i32::try_from(quantity).map_err(|_| "signal_reduction_quantity_overflow")?;
|
|
intents.push(OrderIntent::TargetShares { symbol: symbol.clone(), target_quantity, reason: reason.clone() });
|
|
}
|
|
}
|
|
SignalAction::BuyCondition { .. } => {}
|
|
}
|
|
}
|
|
if snapshot.complete_targets {
|
|
if weights.is_empty() {
|
|
for position in portfolio.positions().values().filter(|position| position.quantity > 0) {
|
|
intents.push(OrderIntent::TargetPercent { symbol: position.symbol.clone(), target_percent: 0.0, reason: reason.clone() });
|
|
}
|
|
} else {
|
|
intents.push(OrderIntent::TargetPortfolioSmart { target_weights: weights,
|
|
order_prices: None, valuation_prices: None, reason });
|
|
}
|
|
}
|
|
Ok(intents)
|
|
}
|
|
|
|
pub fn buy_denials(&self, ctx: &StrategyContext<'_>) -> Result<BTreeMap<String, String>, String> {
|
|
Ok(self.snapshot_for(ctx)?.actions.iter().filter_map(|action| match action {
|
|
SignalAction::BuyCondition { symbol, allowed: false } => Some((symbol.clone(), "信号买入条件未满足".into())),
|
|
_ => None,
|
|
}).collect())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use chrono::Duration;
|
|
use serde_json::json;
|
|
|
|
fn book() -> SignalBook {
|
|
let decision: DateTime<Utc> = "2025-01-07T09:30:00+08:00".parse().unwrap();
|
|
let source: DateTime<Utc> = "2025-01-06T15:00:00+08:00".parse().unwrap();
|
|
seal(SignalBook {
|
|
schema: SIGNAL_BOOK_SCHEMA.into(), version_sha256: "a".repeat(64), generator_sha256: "b".repeat(64),
|
|
model_sha256: Some("d".repeat(64)),
|
|
knowledge_cutoff: Some("2024-12-31T15:00:00+08:00".parse().unwrap()),
|
|
provenance: SignalProvenance::Reconstructed, frequency: SignalFrequency::Daily,
|
|
expected_decisions: vec![decision], snapshots: vec![SignalSnapshot {
|
|
signal_at: source,
|
|
decision_at: decision, input_as_of: source, input_available_at: source,
|
|
generated_at: decision + Duration::days(10), published_at: decision + Duration::days(10),
|
|
input_sha256: "c".repeat(64), complete_targets: true,
|
|
actions: vec![SignalAction::TargetWeight { symbol: "000001.SZ".into(), weight: 0.5 }],
|
|
}],
|
|
})
|
|
}
|
|
|
|
fn seal(mut book:SignalBook)->SignalBook {
|
|
book.version_sha256=book.content_sha256().unwrap();
|
|
book
|
|
}
|
|
|
|
fn at_context<T>(at: Option<NaiveDateTime>, action: impl FnOnce(&StrategyContext<'_>) -> T) -> T {
|
|
let data = crate::DataSet::from_components(vec![], vec![], vec![], vec![], vec![]).unwrap();
|
|
let portfolio = PortfolioState::new(10_000.0);
|
|
let symbols = BTreeSet::new();
|
|
action(&StrategyContext {
|
|
execution_date: NaiveDate::from_ymd_opt(2025,1,7).unwrap(),
|
|
decision_date: NaiveDate::from_ymd_opt(2025,1,6).unwrap(), decision_index:0,
|
|
data:&data, portfolio:&portfolio, futures_account:None, open_orders:&[],
|
|
dynamic_universe:None, subscriptions:&symbols, process_events:&[], active_process_event:None,
|
|
active_datetime:at, order_events:&[], fills:&[],
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn observed_next_open_never_backdates_a_morning_publication_into_yesterdays_orders() {
|
|
let mut raw = book();
|
|
raw.provenance=SignalProvenance::Observed;
|
|
raw.snapshots[0].generated_at="2025-01-07T08:45:00+08:00".parse().unwrap();
|
|
raw.snapshots[0].published_at="2025-01-07T08:46:00+08:00".parse().unwrap();
|
|
let value=seal(raw).validate().unwrap();
|
|
for clock in ["2025-01-06 15:00:00", "2025-01-07 08:45:00"] {
|
|
at_context(Some(clock.parse().unwrap()), |ctx| {
|
|
assert_eq!(value.intents(ctx).unwrap_err(),"observed_signal_published_after_consumption_clock");
|
|
assert!(ctx.portfolio.positions().is_empty());
|
|
});
|
|
}
|
|
at_context(Some("2025-01-07 09:30:00".parse().unwrap()), |ctx| {
|
|
assert_eq!(value.intents(ctx).unwrap().len(),1);
|
|
assert!(ctx.portfolio.positions().is_empty());
|
|
});
|
|
at_context(None, |ctx| assert_eq!(value.intents(ctx).unwrap_err(),"observed_signal_consumption_clock_missing"));
|
|
}
|
|
|
|
#[test]
|
|
fn reconstruction_ignores_research_wall_clock_but_never_early_input_availability() {
|
|
let value=book().validate().unwrap();
|
|
at_context(Some("2025-01-06 15:00:00".parse().unwrap()), |ctx| assert!(value.intents(ctx).is_ok()));
|
|
at_context(Some("2025-01-06 14:59:59".parse().unwrap()), |ctx| {
|
|
assert_eq!(value.intents(ctx).unwrap_err(),"signal_not_available_at_consumption_clock");
|
|
});
|
|
let mut raw=book();
|
|
raw.snapshots[0].input_as_of="2025-01-07T08:30:00+08:00".parse().unwrap();
|
|
raw.snapshots[0].input_available_at=raw.snapshots[0].input_as_of;
|
|
raw.snapshots[0].signal_at=raw.snapshots[0].input_as_of;
|
|
let value=seal(raw).validate().unwrap();
|
|
at_context(Some("2025-01-07 09:30:00".parse().unwrap()), |ctx| {
|
|
assert_eq!(value.intents(ctx).unwrap_err(),"next_open_signal_contains_execution_session_inputs");
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn historical_reconstruction_is_not_online_publication() {
|
|
let validated = book().validate().unwrap();
|
|
assert!(validated.require_observed().unwrap_err().contains("reconstructed"));
|
|
let mut observed = book();
|
|
observed.provenance = SignalProvenance::Observed;
|
|
assert!(observed.clone().validate().unwrap_err().contains("not_available"));
|
|
observed.snapshots[0].generated_at = observed.snapshots[0].decision_at;
|
|
observed.snapshots[0].published_at = observed.snapshots[0].decision_at;
|
|
seal(observed).validate().unwrap().require_observed().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_future_inputs_and_model_knowledge() {
|
|
for field in 0..3 {
|
|
let mut value = book();
|
|
let future = value.snapshots[0].decision_at + Duration::seconds(1);
|
|
match field {
|
|
0 => value.snapshots[0].input_as_of = future,
|
|
1 => value.snapshots[0].input_available_at = future,
|
|
_ => value.knowledge_cutoff = Some(future),
|
|
}
|
|
assert!(value.validate().unwrap_err().contains("future"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_quantities_prices_and_unknown_signal_fields() {
|
|
for name in ["quantity", "execution_price", "account_id", "cash"] {
|
|
let mut action = json!({"kind":"target_weight","symbol":"000001.SZ","weight":0.5});
|
|
action[name] = json!(100);
|
|
assert!(serde_json::from_value::<SignalAction>(action).is_err());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn coverage_and_duplicate_actions_fail_closed() {
|
|
let mut value = book();
|
|
value.expected_decisions.push(value.expected_decisions[0] + Duration::days(1));
|
|
assert!(value.validate().unwrap_err().contains("coverage"));
|
|
let mut value = book();
|
|
value.snapshots.push(value.snapshots[0].clone());
|
|
value.expected_decisions.push(value.expected_decisions[0]);
|
|
assert!(value.validate().unwrap_err().contains("duplicate"));
|
|
let mut value = book();
|
|
let repeated = value.snapshots[0].actions[0].clone();
|
|
value.snapshots[0].actions.push(repeated);
|
|
assert!(value.validate().unwrap_err().contains("duplicate"));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_overallocation_nonfinite_and_ambiguous_actions() {
|
|
for weight in [f64::NAN, f64::INFINITY, -0.1, 1.1] {
|
|
let mut value = book();
|
|
value.snapshots[0].actions[0] = SignalAction::TargetWeight { symbol: "000001.SZ".into(), weight };
|
|
assert!(value.validate().is_err());
|
|
}
|
|
let mut value = book();
|
|
value.snapshots[0].actions.push(SignalAction::TargetWeight { symbol:"000002.SZ".into(),weight:0.6 });
|
|
assert!(value.validate().unwrap_err().contains("exposure"));
|
|
let mut value = book();
|
|
value.snapshots[0].actions.push(SignalAction::Exit {symbol:"000001.SZ".into()});
|
|
assert!(value.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn next_open_uses_decision_session_and_never_nearest_signal() {
|
|
let value = book().validate().unwrap();
|
|
let day = NaiveDate::from_ymd_opt(2025,1,7).unwrap();
|
|
assert!(value.snapshot_at(day, NaiveTime::from_hms_opt(9,30,0), true).is_ok());
|
|
assert!(value.snapshot_at(day, NaiveTime::from_hms_opt(14,59,0), false).is_err());
|
|
assert!(value.snapshot_at(day + Duration::days(1), None, true).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn reduction_is_resolved_from_each_accounts_actual_position() {
|
|
let mut raw = book();
|
|
raw.snapshots[0].complete_targets = false;
|
|
raw.snapshots[0].actions = vec![SignalAction::Reduce {symbol:"000001.SZ".into(),remaining_ratio:0.5}];
|
|
let value = seal(raw).validate().unwrap();
|
|
let day = NaiveDate::from_ymd_opt(2025,1,3).unwrap();
|
|
for (held, expected) in [(1000,500),(3000,1500)] {
|
|
let mut portfolio = PortfolioState::new(100_000.0);
|
|
portfolio.position_mut("000001.SZ").buy(day,held,10.0);
|
|
let result = value.snapshot_intents(&value.book.snapshots[0],&portfolio).unwrap();
|
|
assert!(matches!(result[0],OrderIntent::TargetShares {target_quantity,..} if target_quantity==expected));
|
|
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity,held);
|
|
}
|
|
assert!(value.snapshot_intents(&value.book.snapshots[0],&PortfolioState::new(10_000.0)).unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn empty_complete_snapshot_clears_only_that_accounts_holdings() {
|
|
let mut raw = book();
|
|
raw.snapshots[0].actions.clear();
|
|
let value = seal(raw).validate().unwrap();
|
|
let day = NaiveDate::from_ymd_opt(2025,1,3).unwrap();
|
|
let mut portfolio = PortfolioState::new(100_000.0);
|
|
portfolio.position_mut("000002.SZ").buy(day,200,10.0);
|
|
let result = value.snapshot_intents(&value.book.snapshots[0],&portfolio).unwrap();
|
|
assert!(matches!(&result[0],OrderIntent::TargetPercent {symbol,target_percent,..} if symbol=="000002.SZ" && *target_percent==0.0));
|
|
}
|
|
|
|
#[test]
|
|
fn platform_spec_consumes_book_without_running_another_selection() {
|
|
let spec = json!({"signalBook":book(),"runtimeExpressions":{"trading":{"actions":[{"kind":"consume_signal"}]}}});
|
|
let config = crate::platform_strategy_spec::platform_expr_config_from_value("signal-fixture","000001.SZ",&spec).unwrap();
|
|
assert!(!config.rotation_enabled && config.signal_book.is_some());
|
|
assert!(matches!(config.explicit_actions.as_slice(),[crate::PlatformTradeAction::ConsumeSignal]));
|
|
}
|
|
|
|
#[test]
|
|
fn changed_valid_contents_must_not_reuse_a_version_hash() {
|
|
let mut raw=book();
|
|
raw.snapshots[0].actions=vec![SignalAction::TargetWeight{symbol:"000001.SZ".into(),weight:0.4}];
|
|
assert_eq!(raw.clone().validate().unwrap_err(),"signal_book_content_hash_mismatch");
|
|
seal(raw).validate().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn completed_daily_inputs_may_be_published_after_market_close() {
|
|
let mut raw=book();
|
|
raw.expected_decisions=vec!["2026-07-07T09:30:00+08:00".parse().unwrap()];
|
|
raw.snapshots[0].decision_at=raw.expected_decisions[0];
|
|
raw.snapshots[0].input_as_of="2026-07-06T15:30:00+08:00".parse().unwrap();
|
|
raw.snapshots[0].input_available_at="2026-07-06T16:00:00+08:00".parse().unwrap();
|
|
raw.snapshots[0].signal_at=raw.snapshots[0].input_available_at;
|
|
raw.snapshots[0].generated_at=raw.snapshots[0].input_available_at;
|
|
raw.snapshots[0].published_at=raw.snapshots[0].generated_at;
|
|
raw.provenance=SignalProvenance::Observed;
|
|
seal(raw).validate().unwrap().require_observed().unwrap();
|
|
}
|
|
}
|