Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75e5e32281 | |||
| 7d05f8f7c7 | |||
| d01f32ca5b | |||
| 3dd7b2bd50 | |||
| c8f6ed102c | |||
| 4664f1a2d3 | |||
| 40481e8825 | |||
| 2473cc04bb |
@@ -15,6 +15,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
io::stdin().read_to_string(&mut input)?;
|
||||
let output = if input.trim().is_empty() {
|
||||
factor_events::catalog()
|
||||
} else if serde_json::from_str::<Value>(&input)?.get("rank_history").is_some() {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Rank { dates:Vec<chrono::NaiveDate>, universe:Vec<String>, values:std::collections::BTreeMap<String,Vec<Option<f64>>> }
|
||||
let value:Value=serde_json::from_str(&input)?;
|
||||
let request:Rank=serde_json::from_value(value["rank_history"].clone())?;
|
||||
json!({"result":fidc_core::factor_cross_section::rank_history(&request.dates,&request.universe,&request.values)?})
|
||||
} else {
|
||||
let request: Request = serde_json::from_str(&input)?;
|
||||
let results = request
|
||||
|
||||
@@ -3375,6 +3375,12 @@ impl DataSet {
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
pub fn is_reference_only_benchmark(&self, symbol: &str) -> bool {
|
||||
if symbol != self.benchmark_code() { return false; }
|
||||
let Some(symbol_id) = self.symbol_id(symbol) else { return true; };
|
||||
!self.candidate_symbol_ids_by_date.values().any(|ids| ids.contains(&symbol_id))
|
||||
}
|
||||
|
||||
pub fn bundle_on(&self, date: NaiveDate) -> Result<DailySnapshotBundle, DataSetError> {
|
||||
let benchmark = self
|
||||
.benchmark(date)
|
||||
@@ -6229,7 +6235,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_selection_uses_structured_instrument_dates_and_status_only() {
|
||||
fn baseline_selection_uses_dated_lifecycle_not_latest_undated_status() {
|
||||
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
|
||||
let instrument = |name: &str, status: &str, delisted_at: Option<NaiveDate>| Instrument {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
@@ -6257,7 +6263,7 @@ mod tests {
|
||||
Some(&instrument("退市测试", "active", None)),
|
||||
date
|
||||
));
|
||||
assert!(!instrument_passes_baseline_selection(
|
||||
assert!(instrument_passes_baseline_selection(
|
||||
Some(&instrument("正常名称", "delisted", None)),
|
||||
date
|
||||
));
|
||||
|
||||
+112
-18
@@ -468,9 +468,17 @@ pub struct BacktestEngine<S, C, R> {
|
||||
preplanned_decision_quote_symbols_by_date: Option<Arc<BTreeMap<NaiveDate, BTreeSet<String>>>>,
|
||||
execution_quote_request_cache:
|
||||
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>,
|
||||
execution_absence_notes: BTreeMap<NaiveDate, Vec<String>>,
|
||||
execution_lifecycle_reported: BTreeSet<(String, String)>,
|
||||
risk_free_rate_contract: Option<RiskFreeRateContract>,
|
||||
}
|
||||
|
||||
fn all_instruments_have_dated_absence(data: &DataSet, date: NaiveDate) -> bool {
|
||||
let mut instruments = data.instruments().values()
|
||||
.filter(|instrument| !data.is_reference_only_benchmark(&instrument.symbol)).peekable();
|
||||
instruments.peek().is_some() && instruments.all(|instrument| instrument.dated_market_absence_reason(date).is_some())
|
||||
}
|
||||
|
||||
fn backtest_execution_schedule(
|
||||
data: &DataSet,
|
||||
start_date: Option<NaiveDate>,
|
||||
@@ -493,10 +501,15 @@ fn backtest_execution_schedule(
|
||||
if decision_lag_trading_days == 0 {
|
||||
if has_decision_inputs(execution_date) {
|
||||
schedule.push((execution_date, Some((calendar_idx, execution_date))));
|
||||
} else if all_instruments_have_dated_absence(data, execution_date) {
|
||||
schedule.push((execution_date, None));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !has_execution_market(execution_date) {
|
||||
if all_instruments_have_dated_absence(data, execution_date) {
|
||||
schedule.push((execution_date, None));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let decision_slot = calendar_idx
|
||||
@@ -507,6 +520,7 @@ fn backtest_execution_schedule(
|
||||
schedule.push((execution_date, decision_slot));
|
||||
}
|
||||
None => schedule.push((execution_date, None)),
|
||||
Some((_, decision_date)) if all_instruments_have_dated_absence(data, decision_date) => schedule.push((execution_date, None)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -554,6 +568,8 @@ impl<S, C, R> BacktestEngine<S, C, R> {
|
||||
execution_quote_loader: None,
|
||||
preplanned_decision_quote_symbols_by_date: None,
|
||||
execution_quote_request_cache: BTreeSet::new(),
|
||||
execution_absence_notes: BTreeMap::new(),
|
||||
execution_lifecycle_reported: BTreeSet::new(),
|
||||
risk_free_rate_contract: None,
|
||||
}
|
||||
}
|
||||
@@ -768,6 +784,31 @@ where
|
||||
end_time: Option<NaiveTime>,
|
||||
symbols: &mut BTreeSet<String>,
|
||||
) -> Result<(), BacktestError> {
|
||||
let mut available = BTreeSet::new();
|
||||
for symbol in symbols.iter() {
|
||||
let instrument = self.data.instrument(symbol).ok_or_else(|| BacktestError::Execution(format!(
|
||||
"execution_data_missing reason=instrument_metadata_or_code_mapping_missing symbol={symbol} execution_date={execution_date}"
|
||||
)))?;
|
||||
if let Some(reason) = instrument.dated_market_absence_reason(execution_date) {
|
||||
if self.data.price(execution_date, symbol, PriceField::Close).is_some()
|
||||
|| !self.data.execution_quotes_on(execution_date, symbol).is_empty()
|
||||
{
|
||||
return Err(BacktestError::Execution(format!(
|
||||
"execution_data_conflict reason={reason} symbol={symbol} execution_date={execution_date} listed_at={:?} delisted_at={:?}",
|
||||
instrument.listed_at, instrument.delisted_at
|
||||
)));
|
||||
}
|
||||
if self.execution_lifecycle_reported.insert((symbol.clone(), reason.to_string())) {
|
||||
self.execution_absence_notes.entry(execution_date).or_default().push(format!(
|
||||
"execution_data_absence reason={reason} symbol={symbol} execution_date={execution_date} listed_at={:?} delisted_at={:?} no_price_fill=true",
|
||||
instrument.listed_at, instrument.delisted_at
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
available.insert(symbol.clone());
|
||||
}
|
||||
*symbols = available;
|
||||
symbols.retain(|symbol| {
|
||||
let request_key = (execution_date, symbol.clone(), start_time, end_time);
|
||||
if self.execution_quote_request_cache.contains(&request_key) {
|
||||
@@ -835,9 +876,6 @@ where
|
||||
let mut paused_with_quotes = Vec::new();
|
||||
let mut missing_daily_market = Vec::new();
|
||||
for symbol in requested_symbols {
|
||||
let Some(_candidate) = self.data.candidate(execution_date, symbol) else {
|
||||
continue;
|
||||
};
|
||||
let Some(market) = self.data.market(execution_date, symbol) else {
|
||||
missing_daily_market.push(symbol.clone());
|
||||
continue;
|
||||
@@ -2191,12 +2229,13 @@ where
|
||||
date: execution_date,
|
||||
})?;
|
||||
let notes = join_text_parts(corporate_action_notes.into_iter());
|
||||
let absence = all_instruments_have_dated_absence(&self.data, execution_date);
|
||||
let diagnostics = join_text_parts(
|
||||
std::iter::once(format!(
|
||||
"decision_lag_warmup lag_days={} execution_index={}",
|
||||
self.config.decision_lag_trading_days, execution_idx
|
||||
))
|
||||
.chain(broker_diagnostics.into_iter()),
|
||||
std::iter::once(if absence {
|
||||
format!("execution_data_absence reason=all_instruments_outside_dated_lifecycle execution_date={execution_date} cash_period_retained=true no_price_fill=true")
|
||||
} else { format!("decision_lag_warmup lag_days={} execution_index={}", self.config.decision_lag_trading_days, execution_idx) })
|
||||
.chain(broker_diagnostics.into_iter())
|
||||
.chain(self.execution_absence_notes.remove(&execution_date).unwrap_or_default()),
|
||||
);
|
||||
let holdings_for_day = portfolio.holdings_summary(execution_date);
|
||||
let holding_start = result.daily_holdings.len();
|
||||
@@ -2213,7 +2252,7 @@ where
|
||||
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
|
||||
|
||||
result.equity_curve.push(DailyEquityPoint {
|
||||
signal_baseline: true,
|
||||
signal_baseline: execution_idx == 0,
|
||||
date: execution_date,
|
||||
cash: aggregate_cash,
|
||||
market_value: aggregate_market_value,
|
||||
@@ -3364,7 +3403,8 @@ where
|
||||
decision
|
||||
.diagnostics
|
||||
.into_iter()
|
||||
.chain(broker_diagnostics.into_iter()),
|
||||
.chain(broker_diagnostics.into_iter())
|
||||
.chain(self.execution_absence_notes.remove(&execution_date).unwrap_or_default()),
|
||||
);
|
||||
let holdings_for_day = portfolio.holdings_summary(execution_date);
|
||||
let holding_start = result.daily_holdings.len();
|
||||
@@ -3964,17 +4004,11 @@ where
|
||||
let Some(instrument) = self.data.instrument(&symbol) else {
|
||||
continue;
|
||||
};
|
||||
let is_unresolved = instrument.is_delisted_on_or_before(date)
|
||||
|| (instrument.status.eq_ignore_ascii_case("delisted")
|
||||
&& instrument.delisted_at.is_none()
|
||||
&& self.data.market(date, &symbol).is_none());
|
||||
let is_unresolved = instrument.is_delisted_on_or_before(date);
|
||||
if !is_unresolved {
|
||||
continue;
|
||||
}
|
||||
let effective_delisted_at = instrument
|
||||
.delisted_at
|
||||
.or_else(|| self.data.calendar().previous_day(date))
|
||||
.unwrap_or(date);
|
||||
let effective_delisted_at = instrument.delisted_at.expect("dated delisting checked");
|
||||
let reason = format!(
|
||||
concat!(
|
||||
"unresolved_delisted_position symbol={} quantity={} effective_date={} status={} ",
|
||||
@@ -5543,6 +5577,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wholly_prelisting_universe_retains_cash_days_without_fabricating_prices() {
|
||||
let dates = [d(2025, 1, 2), d(2025, 1, 3), d(2025, 1, 6)];
|
||||
let mut engine = engine_with_matching(MatchingType::CurrentBarClose, PriceField::Close, 0);
|
||||
engine.config.end_date = Some(dates[2]);
|
||||
let mut markets = vec![market(dates[2], 10.0, 10.0)];
|
||||
markets.extend(dates.iter().map(|date| DailyMarketSnapshot { symbol: "000852.SH".into(), ..market(*date, 1000.0, 1000.0) }));
|
||||
engine.data = DataSet::from_components(
|
||||
vec![Instrument { listed_at: Some(dates[2]), ..default_instrument() }, Instrument { symbol: "000852.SH".into(), listed_at: None, ..default_instrument() }],
|
||||
markets, vec![factor(dates[2])], vec![candidate(dates[2])],
|
||||
dates.iter().map(|date| benchmark(*date)).collect(),
|
||||
).unwrap();
|
||||
assert_eq!(super::backtest_execution_dates(&engine.data, Some(dates[0]), Some(dates[2]), 0), dates);
|
||||
assert_eq!(super::backtest_execution_dates(&engine.data, Some(dates[0]), Some(dates[2]), 1), dates);
|
||||
let result = engine.run().unwrap();
|
||||
assert_eq!(result.equity_curve.len(), 3);
|
||||
for point in &result.equity_curve[..2] {
|
||||
assert_eq!(point.total_equity, 100_000.0);
|
||||
assert_eq!(point.market_value, 0.0);
|
||||
assert!(point.diagnostics.contains("cash_period_retained=true"));
|
||||
}
|
||||
assert!(result.order_events.is_empty());
|
||||
assert!(engine.data.market(dates[0], SYMBOL).is_none());
|
||||
assert!(result.equity_curve[0].signal_baseline);
|
||||
assert!(!result.equity_curve[1].signal_baseline);
|
||||
assert!(!super::all_instruments_have_dated_absence(&dataset(), dates[0]));
|
||||
}
|
||||
|
||||
fn engine_with_matching(
|
||||
matching_type: MatchingType,
|
||||
execution_price_field: PriceField,
|
||||
@@ -5996,6 +6058,38 @@ mod tests {
|
||||
.expect("zero-volume stock may have no minute bars");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_quote_filter_skips_only_dated_legal_absence_before_loading() {
|
||||
let date = d(2025, 9, 10);
|
||||
for (symbol, listed_at, delisted_at, reason) in [
|
||||
("920038.BJ", Some(d(2026, 8, 5)), None, "not_yet_listed"),
|
||||
("563360.SH", Some(d(2026, 8, 5)), None, "not_yet_listed"),
|
||||
("000001.SZ", Some(d(2010, 1, 1)), Some(d(2025, 9, 9)), "delisted"),
|
||||
] {
|
||||
let instrument = Instrument { symbol: symbol.into(), listed_at, delisted_at, ..default_instrument() };
|
||||
let data = DataSet::from_components(vec![instrument], vec![], vec![], vec![], vec![benchmark(date)]).unwrap();
|
||||
let mut engine = full_day_coverage_engine(data, date);
|
||||
engine.execution_quote_loader = Some(Box::new(|_| panic!("legal lifecycle absence must not load prices")));
|
||||
engine.load_missing_execution_quotes(date, None, None, &mut BTreeSet::from([symbol.to_string()])).unwrap();
|
||||
let notes = engine.execution_absence_notes.get(&date).unwrap();
|
||||
assert!(notes[0].contains(reason));
|
||||
assert!(notes[0].contains(symbol));
|
||||
engine.load_missing_execution_quotes(date, None, None, &mut BTreeSet::from([symbol.to_string()])).unwrap();
|
||||
assert_eq!(engine.execution_absence_notes[&date].len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_identity_or_missing_candidate_does_not_waive_quote_coverage() {
|
||||
let date = d(2025, 9, 10);
|
||||
let data = DataSet::from_components(vec![default_instrument()], vec![], vec![], vec![], vec![benchmark(date)]).unwrap();
|
||||
let mut engine = full_day_coverage_engine(data, date);
|
||||
let error = engine.load_missing_execution_quotes(date, None, None, &mut BTreeSet::from(["unmapped".to_string()])).unwrap_err();
|
||||
assert!(error.to_string().contains("instrument_metadata_or_code_mapping_missing"));
|
||||
let error = engine.validate_full_day_execution_quote_coverage(date, &[SYMBOL.to_string()]).unwrap_err();
|
||||
assert!(error.to_string().contains("missing_daily_market"));
|
||||
}
|
||||
|
||||
fn run_scheduled_next_open_with_dataset(dataset: DataSet) -> super::BacktestResult {
|
||||
run_scheduled_next_open_with_dataset_and_broker(
|
||||
dataset,
|
||||
|
||||
@@ -169,6 +169,12 @@ const OPERATORS: &[&str] = &[
|
||||
];
|
||||
|
||||
pub fn catalog() -> Value {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut implementation = Sha256::new();
|
||||
for file in [include_bytes!("factor_events.rs").as_slice(), include_bytes!("factor_cross_section.rs").as_slice(),
|
||||
include_bytes!("daily_patterns.rs").as_slice(),include_bytes!("market_event_context.rs").as_slice(),
|
||||
include_bytes!("session_events.rs").as_slice(),include_bytes!("pattern_context.rs").as_slice(),TA_REV.as_bytes()] {implementation.update(file);}
|
||||
let implementation_sha256=format!("{:x}",implementation.finalize());
|
||||
let indicators: Vec<Value> = abstract_api::funcs().map(|f| json!({
|
||||
"name":f.name, "group":format!("{:?}",f.group), "description":f.hint,
|
||||
"inputs":f.inputs.iter().map(|p|json!({"name":p.param_name,"kind":format!("{:?}",p.kind),"flags":p.flags.0})).collect::<Vec<_>>(),
|
||||
@@ -176,7 +182,7 @@ pub fn catalog() -> Value {
|
||||
"outputs":f.outputs.iter().enumerate().map(|(i,p)|json!({"index":i,"name":p.param_name,"kind":format!("{:?}",p.kind)})).collect::<Vec<_>>(),
|
||||
"unstable_period":format!("{:?}",f.unst_id), "production_eligible":false,
|
||||
})).collect();
|
||||
json!({"contract":CONTRACT,"library":{"name":"TA-Lib native Rust","revision":TA_REV,"license":"BSD-3-Clause"},
|
||||
json!({"contract":CONTRACT,"expression_kernel_sha256":implementation_sha256,"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,
|
||||
|
||||
@@ -70,8 +70,17 @@ impl Instrument {
|
||||
|
||||
pub fn is_active_on(&self, date: NaiveDate) -> bool {
|
||||
self.listed_at.is_none_or(|listed_at| listed_at <= date)
|
||||
&& !self.is_delisted_before(date)
|
||||
&& !(self.status.eq_ignore_ascii_case("inactive") && self.delisted_at.is_none())
|
||||
&& !self.is_delisted_on_or_before(date)
|
||||
}
|
||||
|
||||
pub fn dated_market_absence_reason(&self, date: NaiveDate) -> Option<&'static str> {
|
||||
if self.listed_at.is_some_and(|listed| date < listed) {
|
||||
Some("not_yet_listed")
|
||||
} else if self.is_delisted_on_or_before(date) {
|
||||
Some("delisted")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +116,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_is_dated_and_latest_undated_terminal_status_is_not_historical_evidence() {
|
||||
let mut item = instrument("BJS", 100);
|
||||
let listing = chrono::NaiveDate::from_ymd_opt(2026, 8, 5).unwrap();
|
||||
let removal = chrono::NaiveDate::from_ymd_opt(2026, 9, 10).unwrap();
|
||||
item.listed_at = Some(listing);
|
||||
item.delisted_at = Some(removal);
|
||||
assert_eq!(item.dated_market_absence_reason(listing.pred_opt().unwrap()), Some("not_yet_listed"));
|
||||
assert!(item.is_active_on(listing));
|
||||
assert!(!item.is_active_on(removal));
|
||||
assert_eq!(item.dated_market_absence_reason(removal), Some("delisted"));
|
||||
item.delisted_at = None;
|
||||
for status in ["delisting", "delisted", "inactive", "terminated"] {
|
||||
item.status = status.into();
|
||||
assert!(item.is_active_on(listing));
|
||||
assert_eq!(item.dated_market_absence_reason(listing), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_quantity_rules_are_case_insensitive_without_allocating_normalized_boards() {
|
||||
let kcb = instrument(" kSh ", 100);
|
||||
|
||||
@@ -25,6 +25,7 @@ pub mod risk_control;
|
||||
pub mod rules;
|
||||
pub mod scheduler;
|
||||
pub mod strategy;
|
||||
pub mod signal_contract;
|
||||
pub mod strategy_ai;
|
||||
pub mod universe;
|
||||
|
||||
|
||||
@@ -525,6 +525,7 @@ pub enum PlatformAccountActionKind {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PlatformTradeAction {
|
||||
ConsumeSignal,
|
||||
Order {
|
||||
kind: PlatformExplicitOrderKind,
|
||||
symbol: String,
|
||||
@@ -607,6 +608,7 @@ pub struct PlatformPositionTargetRule {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlatformExprStrategyConfig {
|
||||
pub signal_book: Option<Arc<crate::signal_contract::ValidatedSignalBook>>,
|
||||
pub strategy_name: String,
|
||||
pub market: String,
|
||||
pub benchmark_symbol: String,
|
||||
@@ -689,6 +691,7 @@ pub struct PlatformExprStrategyConfig {
|
||||
impl PlatformExprStrategyConfig {
|
||||
pub fn generic() -> Self {
|
||||
Self {
|
||||
signal_book: None,
|
||||
strategy_name: "platform-expression".to_string(),
|
||||
market: "CN_A".to_string(),
|
||||
benchmark_symbol: String::new(),
|
||||
@@ -1587,13 +1590,7 @@ impl PlatformExprStrategy {
|
||||
.filter(|position| position.quantity > 0)
|
||||
.filter_map(|position| {
|
||||
let instrument = ctx.data.instrument(&position.symbol)?;
|
||||
let unresolved = instrument.is_delisted_on_or_before(ctx.execution_date)
|
||||
|| (instrument.status.eq_ignore_ascii_case("delisted")
|
||||
&& instrument.delisted_at.is_none()
|
||||
&& ctx
|
||||
.data
|
||||
.market(ctx.execution_date, &position.symbol)
|
||||
.is_none());
|
||||
let unresolved = instrument.is_delisted_on_or_before(ctx.execution_date);
|
||||
unresolved.then(|| position.symbol.clone())
|
||||
})
|
||||
.collect()
|
||||
@@ -1919,6 +1916,7 @@ impl PlatformExprStrategy {
|
||||
}
|
||||
for (index, action) in self.config.explicit_actions.iter().enumerate() {
|
||||
match action {
|
||||
PlatformTradeAction::ConsumeSignal => {}
|
||||
PlatformTradeAction::Order {
|
||||
amount_expr,
|
||||
limit_price_expr,
|
||||
@@ -3859,16 +3857,9 @@ impl PlatformExprStrategy {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !defer_execution_risk
|
||||
&& self
|
||||
.buy_rejection_reason(
|
||||
ctx,
|
||||
execution_date,
|
||||
symbol,
|
||||
self.stock_state(ctx, execution_date, symbol)?.as_ref(),
|
||||
)?
|
||||
.is_some()
|
||||
{
|
||||
if !defer_execution_risk && self.buy_rejection_reason(
|
||||
ctx, execution_date, symbol, self.stock_state(ctx, execution_date, symbol)?.as_ref(),
|
||||
)?.is_some() {
|
||||
continue;
|
||||
}
|
||||
let decision_stock = self.stock_state_with_factor_date(
|
||||
@@ -9124,7 +9115,10 @@ impl PlatformExprStrategy {
|
||||
self.stock_state(ctx, date, symbol).map(Some)
|
||||
}
|
||||
|
||||
fn unscheduled_explicit_actions_are_due(&self, decision_date: NaiveDate) -> bool {
|
||||
fn unscheduled_explicit_actions_are_due(&self, decision_date: NaiveDate, execution_date: NaiveDate) -> bool {
|
||||
if let Some(book) = &self.config.signal_book {
|
||||
return book.is_due_on(execution_date);
|
||||
}
|
||||
self.config.signal_rebalance_dates.is_empty()
|
||||
|| self.config.signal_rebalance_dates.contains(&decision_date)
|
||||
}
|
||||
@@ -9152,6 +9146,12 @@ impl PlatformExprStrategy {
|
||||
let mut diagnostics = Vec::new();
|
||||
for action in &self.config.explicit_actions {
|
||||
match action {
|
||||
PlatformTradeAction::ConsumeSignal => {
|
||||
let book = self.config.signal_book.as_ref().ok_or_else(||
|
||||
BacktestError::Execution("signal_book_not_loaded".into()))?;
|
||||
intents.extend(book.intents(ctx).map_err(BacktestError::Execution)?);
|
||||
diagnostics.push(format!("signal_book_consumed version={} decision_date={}", book.version_sha256(), ctx.decision_date));
|
||||
}
|
||||
PlatformTradeAction::Order {
|
||||
kind,
|
||||
symbol,
|
||||
@@ -11398,6 +11398,7 @@ impl PlatformExprStrategy {
|
||||
matches!(
|
||||
action,
|
||||
PlatformTradeAction::Order { .. }
|
||||
| PlatformTradeAction::ConsumeSignal
|
||||
| PlatformTradeAction::TargetPortfolioSmart { .. }
|
||||
| PlatformTradeAction::Modify { .. }
|
||||
)
|
||||
@@ -12409,7 +12410,7 @@ impl Strategy for PlatformExprStrategy {
|
||||
if self.config.explicit_action_stage == PlatformExplicitActionStage::OpenAuction
|
||||
&& !self.config.explicit_actions.is_empty()
|
||||
&& self.config.explicit_action_schedule.is_none()
|
||||
&& self.unscheduled_explicit_actions_are_due(ctx.decision_date)
|
||||
&& self.unscheduled_explicit_actions_are_due(ctx.decision_date, ctx.execution_date)
|
||||
{
|
||||
let mut decision = self.explicit_action_decision(ctx)?;
|
||||
self.attach_buy_denials(ctx, &mut decision)?;
|
||||
@@ -12429,13 +12430,16 @@ impl Strategy for PlatformExprStrategy {
|
||||
|
||||
impl PlatformExprStrategy {
|
||||
fn attach_buy_denials(&self, ctx: &StrategyContext<'_>, decision: &mut StrategyDecision) -> Result<(), BacktestError> {
|
||||
if self.config.buy_filter_expr.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let symbols = decision.potential_buy_symbols(ctx.open_orders);
|
||||
if symbols.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(book) = &self.config.signal_book {
|
||||
decision.buy_denials.extend(book.buy_denials(ctx).map_err(BacktestError::Execution)?);
|
||||
}
|
||||
if self.config.buy_filter_expr.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let day = self.day_state(ctx, ctx.decision_date)?;
|
||||
let (market_date, _, factor_date) = self.selection_dates(ctx);
|
||||
let execution_time = ctx.active_datetime.filter(|value| value.date() == market_date)
|
||||
@@ -12547,7 +12551,7 @@ impl PlatformExprStrategy {
|
||||
let (explicit_action_intents, mut explicit_action_diagnostics) = if !in_skip_window
|
||||
&& self.config.explicit_action_stage == PlatformExplicitActionStage::OnDay
|
||||
&& self.config.explicit_action_schedule.is_none()
|
||||
&& self.unscheduled_explicit_actions_are_due(decision_date)
|
||||
&& self.unscheduled_explicit_actions_are_due(decision_date, execution_date)
|
||||
{
|
||||
self.explicit_action_intents(ctx, decision_date, &day)?
|
||||
} else {
|
||||
@@ -14019,16 +14023,10 @@ impl PlatformExprStrategy {
|
||||
if target_value <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
if !defer_execution_risk
|
||||
&& self
|
||||
.buy_rejection_reason(
|
||||
ctx,
|
||||
execution_date,
|
||||
symbol,
|
||||
self.stock_state(ctx, execution_date, symbol)?.as_ref(),
|
||||
)?
|
||||
.is_some()
|
||||
{
|
||||
if !defer_execution_risk && let Some(reason) = self.buy_rejection_reason(
|
||||
ctx, execution_date, symbol, self.stock_state(ctx, execution_date, symbol)?.as_ref(),
|
||||
)? {
|
||||
risk_decisions.push(FidcRiskDecisionAudit::rejected_buy_plan(execution_date, symbol, &reason));
|
||||
continue;
|
||||
}
|
||||
if !self.stock_passes_expr(ctx, &day, &decision_stock)? {
|
||||
@@ -14305,6 +14303,37 @@ mod tests {
|
||||
assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn periodic_selected_bjse_buy_rejection_is_audited_without_creating_an_order() {
|
||||
let dates = [d(2026, 8, 5), d(2026, 8, 6)];
|
||||
let symbol = "920038.BJ";
|
||||
let data = single_symbol_platform_data(&dates, symbol);
|
||||
let portfolio = PortfolioState::new(100_000.0);
|
||||
let subscriptions = BTreeSet::new();
|
||||
let ctx = StrategyContext {
|
||||
execution_date: dates[1], decision_date: dates[1], decision_index: 1, data: &data,
|
||||
portfolio: &portfolio, futures_account: None, open_orders: &[], dynamic_universe: None,
|
||||
subscriptions: &subscriptions, process_events: &[], active_process_event: None,
|
||||
active_datetime: None, order_events: &[], fills: &[],
|
||||
};
|
||||
let mut cfg = PlatformExprStrategyConfig::generic();
|
||||
cfg.signal_symbol = symbol.into();
|
||||
cfg.stock_filter_expr = "close > 0".into();
|
||||
cfg.hold_until_exit_enabled = true;
|
||||
cfg.target_portfolio_daily_enabled = true;
|
||||
cfg.daily_top_up_enabled = true;
|
||||
cfg.daily_position_target_adjust_enabled = true;
|
||||
cfg.rebalance_existing_positions = true;
|
||||
cfg.risk_config.static_rules.reject_bjse_selection = false;
|
||||
cfg.risk_config.static_rules.reject_bjse_buy = true;
|
||||
let decision = PlatformExprStrategy::new(cfg.clone()).on_day(&ctx).unwrap();
|
||||
assert!(decision.order_intents.is_empty());
|
||||
assert!(decision.risk_decisions.iter().any(|audit| audit.symbol == symbol && audit.stage == "buy_planning" && audit.rule_code == "bjse" && !audit.accepted));
|
||||
cfg.risk_config.static_rules.reject_bjse_buy = false;
|
||||
let allowed = PlatformExprStrategy::new(cfg).on_day(&ctx).unwrap();
|
||||
assert!(!allowed.order_intents.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_pattern_runtime_uses_the_shared_kernel_and_rejects_early_visibility() {
|
||||
let dates=(0..21).map(|n|d(2025,1,1)+chrono::Duration::days(n)).collect::<Vec<_>>();
|
||||
@@ -25514,9 +25543,9 @@ mod tests {
|
||||
}];
|
||||
let mut strategy = PlatformExprStrategy::new(config);
|
||||
|
||||
assert!(strategy.unscheduled_explicit_actions_are_due(first));
|
||||
assert!(!strategy.unscheduled_explicit_actions_are_due(between));
|
||||
assert!(strategy.unscheduled_explicit_actions_are_due(second));
|
||||
assert!(strategy.unscheduled_explicit_actions_are_due(first, first));
|
||||
assert!(!strategy.unscheduled_explicit_actions_are_due(between, between));
|
||||
assert!(strategy.unscheduled_explicit_actions_are_due(second, second));
|
||||
|
||||
let mut decide = |date, decision_index| {
|
||||
let ctx = StrategyContext {
|
||||
|
||||
@@ -17,6 +17,8 @@ use crate::{
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StrategyRuntimeSpec {
|
||||
#[serde(default)]
|
||||
pub signal_book: Option<crate::signal_contract::SignalBook>,
|
||||
#[serde(default, alias = "strategy_id")]
|
||||
pub strategy_id: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -2599,6 +2601,22 @@ pub fn platform_expr_config_from_spec(
|
||||
}
|
||||
cfg.strict_value_budget = true;
|
||||
|
||||
if let Some(raw) = &spec.signal_book {
|
||||
let book = raw.clone().validate()?;
|
||||
if cfg.explicit_actions.len() != 1 || !matches!(cfg.explicit_actions[0], PlatformTradeAction::ConsumeSignal) {
|
||||
return Err("signal_book_requires_one_consume_signal_action".into());
|
||||
}
|
||||
if !cfg.signal_rebalance_dates.is_empty() && cfg.signal_rebalance_dates != book.decision_dates() {
|
||||
return Err("signal_book_schedule_does_not_match_strategy".into());
|
||||
}
|
||||
cfg.rotation_enabled = false;
|
||||
cfg.signal_rebalance_dates = book.decision_dates();
|
||||
cfg.initial_subscriptions.extend(book.symbols());
|
||||
cfg.signal_book = Some(std::sync::Arc::new(book));
|
||||
} else if cfg.explicit_actions.iter().any(|action| matches!(action, PlatformTradeAction::ConsumeSignal)) {
|
||||
return Err("consume_signal_requires_verified_signal_book".into());
|
||||
}
|
||||
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
@@ -2751,6 +2769,7 @@ fn parse_platform_trade_action(
|
||||
None => None,
|
||||
};
|
||||
match kind.as_str() {
|
||||
"consume_signal" if when_expr.is_none() && time_in_force.is_none() => Some(PlatformTradeAction::ConsumeSignal),
|
||||
"target_portfolio_smart" => Some(PlatformTradeAction::TargetPortfolioSmart {
|
||||
target_weights_expr: action
|
||||
.target_weights_expr
|
||||
|
||||
@@ -1047,8 +1047,6 @@ impl PortfolioState {
|
||||
let unresolved_delisting = current_market_missing
|
||||
&& data.instrument(&position.symbol).is_some_and(|instrument| {
|
||||
instrument.is_delisted_on_or_before(date)
|
||||
|| (instrument.status.eq_ignore_ascii_case("delisted")
|
||||
&& instrument.delisted_at.is_none())
|
||||
});
|
||||
if unresolved_delisting {
|
||||
position.last_price = 0.0;
|
||||
@@ -1068,11 +1066,13 @@ impl PortfolioState {
|
||||
position.refresh_day_pnl();
|
||||
continue;
|
||||
}
|
||||
let confirmed_pause = data.market(date, &position.symbol).is_some_and(|row| row.paused)
|
||||
|| data.candidate(date, &position.symbol).is_some_and(|row| row.is_paused);
|
||||
let price = data
|
||||
.price(date, &position.symbol, field)
|
||||
.or_else(|| data.price_on_or_before(date, &position.symbol, field))
|
||||
.or_else(|| confirmed_pause.then(|| data.price_on_or_before(date, &position.symbol, field)).flatten())
|
||||
.or_else(|| {
|
||||
(position.last_price.is_finite() && position.last_price > 0.0)
|
||||
(confirmed_pause && position.last_price.is_finite() && position.last_price > 0.0)
|
||||
.then_some(position.last_price)
|
||||
})
|
||||
.ok_or_else(|| DataSetError::MissingSnapshot {
|
||||
@@ -1774,7 +1774,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portfolio_carries_last_price_when_position_market_row_is_missing() {
|
||||
fn portfolio_missing_market_requires_formal_suspension_before_carrying_price() {
|
||||
let prev_date = NaiveDate::from_ymd_opt(2025, 5, 26).unwrap();
|
||||
let missing_date = NaiveDate::from_ymd_opt(2025, 5, 27).unwrap();
|
||||
let mut portfolio = PortfolioState::new(10_000.0);
|
||||
@@ -1832,9 +1832,23 @@ mod tests {
|
||||
.update_prices(prev_date, &dataset, PriceField::Close)
|
||||
.expect("previous close");
|
||||
portfolio.begin_trading_day();
|
||||
portfolio
|
||||
let error = portfolio
|
||||
.update_prices(missing_date, &dataset, PriceField::Close)
|
||||
.expect("missing current row should carry previous close");
|
||||
.expect_err("unclassified missing current price must not be filled from history");
|
||||
assert!(error.to_string().contains("601028.SH"));
|
||||
let paused_dataset = DataSet::from_components(
|
||||
vec![dataset.instrument("601028.SH").unwrap().clone()],
|
||||
vec![dataset.market(prev_date, "601028.SH").unwrap().clone()],
|
||||
Vec::new(),
|
||||
vec![crate::data::CandidateEligibility {
|
||||
date: missing_date, symbol: "601028.SH".into(), is_st: false, is_star_st: false,
|
||||
is_new_listing: false, is_paused: true, allow_buy: false, allow_sell: false,
|
||||
is_kcb: false, is_one_yuan: false, risk_level_code: None,
|
||||
}],
|
||||
vec![dataset.benchmark(prev_date).unwrap().clone()],
|
||||
).unwrap();
|
||||
portfolio.update_prices(missing_date, &paused_dataset, PriceField::Close)
|
||||
.expect("dated suspension permits keeping the last known valuation, not creating a fill");
|
||||
|
||||
let position = portfolio.position("601028.SH").expect("position");
|
||||
assert!((position.last_price - 10.3).abs() < 1e-6);
|
||||
|
||||
@@ -138,6 +138,16 @@ pub struct FidcRiskDecisionAudit {
|
||||
}
|
||||
|
||||
impl FidcRiskDecisionAudit {
|
||||
pub fn rejected_buy_plan(date: NaiveDate, symbol: &str, reason: &str) -> Self {
|
||||
Self {
|
||||
date, symbol: symbol.into(), scope: RiskCheckScope::Buy,
|
||||
stage: "buy_planning".into(), accepted: false,
|
||||
rule_code: reason.into(), reason: reason.into(),
|
||||
config_version: Some("inline_risk_policy".into()), data_epoch: date.to_string(),
|
||||
selection_batch_id: None, order_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rejected_selection(
|
||||
date: NaiveDate,
|
||||
symbol: impl Into<String>,
|
||||
@@ -208,14 +218,8 @@ impl ChinaAShareRiskControl {
|
||||
{
|
||||
return Some("inactive_or_delisted");
|
||||
}
|
||||
let status = instrument.status.trim().to_ascii_lowercase();
|
||||
let terminal_status = matches!(
|
||||
status.as_str(),
|
||||
"inactive" | "delisted" | "terminated" | "expired"
|
||||
);
|
||||
if terminal_status && instrument.delisted_at.is_none() {
|
||||
return Some("inactive_or_delisted");
|
||||
}
|
||||
// Latest reference status has no historical as-of date. Execution-day
|
||||
// risk snapshots remain authoritative; missing quotes are not waived.
|
||||
None
|
||||
}
|
||||
|
||||
@@ -843,7 +847,7 @@ mod tests {
|
||||
Some(&instrument("delisted", None)),
|
||||
date,
|
||||
),
|
||||
Some("inactive_or_delisted")
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
ChinaAShareRiskControl::instrument_rejection_reason(
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
//! Immutable, account-independent trading signals. Quantity and execution
|
||||
//! prices are intentionally absent; the existing broker owns those decisions.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
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/v1";
|
||||
|
||||
#[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 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 knowledge_cutoff: 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");
|
||||
let raw=serde_json::to_vec(&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.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 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 >= *expected || snapshot.input_as_of > *expected
|
||||
|| snapshot.input_available_at > *expected || 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
|
||||
|| snapshot.generated_at < self.knowledge_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 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 ctx.is_lagged_execution() && shanghai(snapshot.input_as_of).date() > ctx.decision_date {
|
||||
return Err("next_open_signal_contains_execution_session_inputs".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),
|
||||
knowledge_cutoff: "2024-12-31T15:00:00+08:00".parse().unwrap(),
|
||||
provenance: SignalProvenance::Reconstructed, frequency: SignalFrequency::Daily,
|
||||
expected_decisions: vec![decision], snapshots: vec![SignalSnapshot {
|
||||
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
|
||||
}
|
||||
|
||||
#[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 = 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].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();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use chrono::{Duration, NaiveDate, NaiveTime};
|
||||
use fidc_core::{
|
||||
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
||||
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
||||
IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
|
||||
Instrument, IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
|
||||
StrategyDecision,
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
@@ -16,6 +16,18 @@ fn t(hour: u32, minute: u32, second: u32) -> NaiveTime {
|
||||
NaiveTime::from_hms_opt(hour, minute, second).expect("valid time")
|
||||
}
|
||||
|
||||
fn fixture_instruments() -> Vec<Instrument> {
|
||||
vec![Instrument {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
name: "quote-plan-fixture".to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(d(2020, 1, 1)),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}]
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DecisionQuoteReader {
|
||||
day_count: usize,
|
||||
@@ -90,7 +102,7 @@ impl Strategy for NoLoaderDecisionQuoteStrategy {
|
||||
|
||||
fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
|
||||
DataSet::from_components(
|
||||
Vec::new(),
|
||||
fixture_instruments(),
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
@@ -253,7 +265,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
let first = d(2026, 1, 5);
|
||||
let second = d(2026, 1, 6);
|
||||
let data = DataSet::from_components(
|
||||
Vec::new(),
|
||||
fixture_instruments(),
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: first,
|
||||
@@ -423,7 +435,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
let first = d(2026, 1, 5);
|
||||
let second = d(2026, 1, 6);
|
||||
let data = DataSet::from_components_with_actions_and_quotes(
|
||||
Vec::new(),
|
||||
fixture_instruments(),
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: first,
|
||||
@@ -658,7 +670,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
let first = d(2026, 1, 5);
|
||||
let second = d(2026, 1, 6);
|
||||
let data = DataSet::from_components(
|
||||
Vec::new(),
|
||||
fixture_instruments(),
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: first,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# 生命周期、价格缺失与历史状态
|
||||
|
||||
证券有效区间为 `[listed_at, delisted_at)`。无明确摘牌日期的最新 terminal 标签不能反向污染历史;已知未来摘牌日不阻断此前的正常交易。退市整理期不是已摘牌。
|
||||
|
||||
执行价加载前分别核验证券身份、正式上市/摘牌边界。合法上市前、摘牌后不查询和补价,记录结构化原因;同一日已有正执行价与生命周期边界冲突时报错。未知身份/代码映射、上市后的分钟缺口、候选事实缺失继续失败,不因 missing candidate 而跳过校验。持仓仅在当日正式暂停交易事实成立时允许按既定估值合同沿用历史价格;普通行情缺口不再无条件沿用旧价。
|
||||
|
||||
整个明确证券范围尚未上市时保留官方日历内现金净值点,不缩短回测范围,不伪造成交或 OHLCV。基准只在首个基线点归一,后续无交易日不反复重置。
|
||||
|
||||
513 项核心测试通过,6 项原有测试忽略。新增验证包含沪深北股票和 ETF 上市前、实际摘牌日、未知证券身份、候选缺失、正式停牌和普通价格缺口、全池上市前现金期间。对单个正式分区的数据缺口仍需数据源修复,不从这些测试外推全市场完整性。
|
||||
|
||||
## 真实边界回放补充
|
||||
|
||||
177 回测 `btr_1789041425783_797911_1`:920038.BJ,2026-08-04 至 08-07。真实上市日08-05,原结果只保留08-05至08-07三个净值点。原因是准备面同时加载基准000300.SH,基准不是交易候选但参与了“全部证券生命周期外”的判定。现在只排除已声明且没有交易候选记录的基准,不按代码或名称猜测指数,也不把真实候选排除;补充真实准备结构的回归后,4日现金区间完整保留。
|
||||
|
||||
该草稿沿用源池 `rejectBjseSelection=false`、`rejectBjseBuy=true`,所以选中北交所但不下单符合其买入政策;原规划阶段没有记录拒绝原因则是审计缺项。新增 `scope=buy, stage=buy_planning` 审计,不伪造订单ID,不把买入否决改写成选股排除。测试验证禁止时无订单且有bjse原因,放开买入政策时正常生成意图。最新核心514项通过、6项原有忽略。
|
||||
Reference in New Issue
Block a user