Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3c36e9478 | |||
| c32807db1d | |||
| 20d723e2a1 | |||
| c2b939e818 | |||
| 6684f48f95 | |||
| adbfadcc07 | |||
| cb6f57be6f | |||
| e75db2d7b0 | |||
| e42dc6938b | |||
| 4f4c1ab7e0 | |||
| e549a23c66 | |||
| 4b21fc4f3f | |||
| e4bac1cf40 | |||
| 1de96494b3 | |||
| d9bac529d6 | |||
| 3e8d652af1 | |||
| 23043ee18b | |||
| c7c2e69b88 | |||
| e6746a7a0e | |||
| 123467d7ae | |||
| b3a3bdbdfd | |||
| 3f9cff1ee5 | |||
| db88abb9e0 | |||
| 75e5e32281 | |||
| 7d05f8f7c7 | |||
| d01f32ca5b | |||
| 3dd7b2bd50 | |||
| c8f6ed102c | |||
| 4664f1a2d3 |
Generated
+999
-9
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/fidc-core",
|
||||
"crates/fidc-signal-client",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
use std::io::{Read, Write};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut raw=Vec::new();
|
||||
std::io::stdin().take(64*1024*1024+1).read_to_end(&mut raw)?;
|
||||
if raw.len()>64*1024*1024 {return Err("signal_book_transport_limit".into());}
|
||||
let book:fidc_core::signal_contract::SignalBook=serde_json::from_slice(&raw)?;
|
||||
let version=book.content_sha256()?;
|
||||
let validated=book.validate()?;
|
||||
let result=serde_json::json!({"schema":fidc_core::signal_contract::SIGNAL_BOOK_SCHEMA,
|
||||
"versionSha256":version,"symbols":validated.symbols(),
|
||||
"onlineAllowed":validated.require_observed().is_ok()});
|
||||
std::io::stdout().write_all(serde_json::to_string(&result)?.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -6166,6 +6166,24 @@ where
|
||||
} else {
|
||||
rule
|
||||
};
|
||||
if (rule.allowed || rule.reason.as_deref() == Some("invalid execution price"))
|
||||
&& let Some(missing_reason) =
|
||||
self.missing_daily_execution_price_reason(snapshot, algo_request)
|
||||
{
|
||||
Self::reject_missing_execution_price_order(
|
||||
report,
|
||||
date,
|
||||
order_id,
|
||||
symbol,
|
||||
OrderSide::Buy,
|
||||
requested_qty,
|
||||
reason,
|
||||
missing_reason,
|
||||
emit_creation_events,
|
||||
);
|
||||
self.clear_open_order(order_id);
|
||||
return Ok(());
|
||||
}
|
||||
if !rule.allowed {
|
||||
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
|
||||
let status = match rule.reason.as_deref() {
|
||||
@@ -6202,24 +6220,6 @@ where
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(missing_reason) =
|
||||
self.missing_daily_execution_price_reason(snapshot, algo_request)
|
||||
{
|
||||
Self::reject_missing_execution_price_order(
|
||||
report,
|
||||
date,
|
||||
order_id,
|
||||
symbol,
|
||||
OrderSide::Buy,
|
||||
requested_qty,
|
||||
reason,
|
||||
missing_reason,
|
||||
emit_creation_events,
|
||||
);
|
||||
self.clear_open_order(order_id);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let current_position_quantity = portfolio
|
||||
.position(symbol)
|
||||
.map(|position| position.quantity)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -474,7 +474,9 @@ pub struct BacktestEngine<S, C, R> {
|
||||
}
|
||||
|
||||
fn all_instruments_have_dated_absence(data: &DataSet, date: NaiveDate) -> bool {
|
||||
!data.instruments().is_empty() && data.instruments().values().all(|instrument| instrument.dated_market_absence_reason(date).is_some())
|
||||
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(
|
||||
@@ -5580,9 +5582,11 @@ mod tests {
|
||||
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() }],
|
||||
vec![market(dates[2], 10.0, 10.0)], vec![factor(dates[2])], vec![candidate(dates[2])],
|
||||
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);
|
||||
@@ -6695,17 +6699,33 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_bar_open_execution_risk_rejects_execution_day_one_yuan_state() {
|
||||
fn next_bar_open_execution_risk_rejects_one_yuan_open_despite_higher_close() {
|
||||
let first = d(2025, 1, 2);
|
||||
let second = d(2025, 1, 3);
|
||||
let result = run_scheduled_next_open_with_dataset(dataset_with(
|
||||
market(first, 10.0, 11.5),
|
||||
market(second, 12.0, 99.0),
|
||||
market(second, 0.9, 1.2),
|
||||
candidate(first),
|
||||
candidate(second),
|
||||
));
|
||||
|
||||
assert_next_open_canceled_with_reason(&result, "one_yuan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_bar_open_execution_risk_ignores_later_one_yuan_close() {
|
||||
let first = d(2025, 1, 2);
|
||||
let second = d(2025, 1, 3);
|
||||
let result = run_scheduled_next_open_with_dataset(dataset_with(
|
||||
market(first, 10.0, 11.5),
|
||||
market(second, 1.2, 0.9),
|
||||
candidate(first),
|
||||
one_yuan_candidate(second),
|
||||
));
|
||||
|
||||
assert_next_open_canceled_with_reason(&result, "one_yuan");
|
||||
assert_eq!(result.fills.len(), 1);
|
||||
assert_eq!(result.fills[0].date, second);
|
||||
assert_eq!(result.fills[0].price, 1.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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(),
|
||||
@@ -1913,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,
|
||||
@@ -3853,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(
|
||||
@@ -9118,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)
|
||||
}
|
||||
@@ -9146,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,
|
||||
@@ -10087,7 +10093,7 @@ impl PlatformExprStrategy {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
exit_symbols: if self.config.signal_book.is_some() { exit_symbols } else { BTreeSet::new() },
|
||||
order_intents,
|
||||
notes: Vec::new(),
|
||||
diagnostics,
|
||||
@@ -11392,6 +11398,7 @@ impl PlatformExprStrategy {
|
||||
matches!(
|
||||
action,
|
||||
PlatformTradeAction::Order { .. }
|
||||
| PlatformTradeAction::ConsumeSignal
|
||||
| PlatformTradeAction::TargetPortfolioSmart { .. }
|
||||
| PlatformTradeAction::Modify { .. }
|
||||
)
|
||||
@@ -12403,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)?;
|
||||
@@ -12423,13 +12430,19 @@ 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() {
|
||||
if self.config.signal_book.is_none() && 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)
|
||||
@@ -12481,6 +12494,9 @@ impl PlatformExprStrategy {
|
||||
}
|
||||
|
||||
fn compute_day_decision(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
|
||||
if self.config.signal_book.is_some() && self.config.explicit_action_schedule.is_some() {
|
||||
return Ok(StrategyDecision::default());
|
||||
}
|
||||
if self.config.rotation_enabled
|
||||
&& self
|
||||
.config
|
||||
@@ -12541,7 +12557,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 {
|
||||
@@ -14013,16 +14029,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)? {
|
||||
@@ -14299,6 +14309,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<_>>();
|
||||
@@ -14708,6 +14749,71 @@ mod tests {
|
||||
.expect("single-symbol platform dataset")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_signal_reduction_and_stops_use_each_accounts_cost_quantity_and_sold_state() {
|
||||
use crate::{BrokerSimulator, ChinaAShareCostModel, ChinaEquityRuleHooks, PriceField};
|
||||
use crate::signal_contract::{SignalBook, ValidatedSignalBook};
|
||||
use serde_json::json;
|
||||
let previous=d(2025,1,6);
|
||||
let current=d(2025,1,7);
|
||||
let symbol="000001.SZ";
|
||||
let data=single_symbol_platform_data(&[previous,current],symbol);
|
||||
let make_book=|action| -> Arc<ValidatedSignalBook> {
|
||||
let mut book:SignalBook=serde_json::from_value(json!({
|
||||
"schema":"fidc.signal-book/v2","versionSha256":"0".repeat(64),"generatorSha256":"a".repeat(64),
|
||||
"modelSha256":null,"knowledgeCutoff":null,"provenance":"reconstructed","frequency":"daily",
|
||||
"expectedDecisions":["2025-01-07T15:00:00+08:00"],"snapshots":[{
|
||||
"signalAt":"2025-01-06T16:00:00+08:00","decisionAt":"2025-01-07T15:00:00+08:00",
|
||||
"inputAsOf":"2025-01-06T16:00:00+08:00","inputAvailableAt":"2025-01-06T16:00:00+08:00",
|
||||
"generatedAt":"2026-09-11T08:00:00+08:00","publishedAt":"2026-09-11T08:00:00+08:00",
|
||||
"inputSha256":"b".repeat(64),"completeTargets":false,"actions":[action]}]
|
||||
})).unwrap();
|
||||
book.version_sha256=book.content_sha256().unwrap();
|
||||
Arc::new(book.validate().unwrap())
|
||||
};
|
||||
let shared=make_book(json!({"kind":"reduce","symbol":symbol,"remaining_ratio":0.5}));
|
||||
let shared_version=shared.version_sha256().to_owned();
|
||||
let buy=make_book(json!({"kind":"target_weight","symbol":symbol,"weight":0.5}));
|
||||
let subscriptions=BTreeSet::new();
|
||||
let plan=|portfolio:&PortfolioState,book:Arc<ValidatedSignalBook>,stops:bool| {
|
||||
let ctx=StrategyContext {
|
||||
execution_date:current,decision_date:current,decision_index:1,data:&data,portfolio,
|
||||
futures_account:None,open_orders:&[],dynamic_universe:None,subscriptions:&subscriptions,
|
||||
process_events:&[],active_process_event:None,active_datetime:Some(current.and_hms_opt(15,0,0).unwrap()),
|
||||
order_events:&[],fills:&[],
|
||||
};
|
||||
let mut cfg=PlatformExprStrategyConfig::generic();
|
||||
cfg.signal_symbol=symbol.into();
|
||||
cfg.rotation_enabled=false;
|
||||
cfg.signal_book=Some(book);
|
||||
cfg.explicit_actions=vec![PlatformTradeAction::ConsumeSignal];
|
||||
if stops { cfg.stop_loss_expr="0.1".into();cfg.take_profit_expr="0.2".into(); }
|
||||
PlatformExprStrategy::new(cfg).on_day(&ctx).unwrap()
|
||||
};
|
||||
let broker=|| BrokerSimulator::new_with_execution_price(ChinaAShareCostModel::default(),ChinaEquityRuleHooks::default(),PriceField::Close)
|
||||
.with_matching_type(MatchingType::CurrentBarClose).with_volume_limit(false).with_liquidity_limit(false);
|
||||
for (quantity,entry,fees,expected) in [(1000,8.0,0.0,0),(1000,10.0,0.0,500),(3000,10.0,0.0,1500),
|
||||
(1000,12.0,0.0,0),(1000,11.11,0.0,500),(1000,11.11,2.0,0)] {
|
||||
let mut account=PortfolioState::new(100_000.0);
|
||||
account.position_mut(symbol).buy(previous,quantity,entry);
|
||||
account.position_mut(symbol).record_buy_trade_cost(quantity,fees);
|
||||
let decision=plan(&account,shared.clone(),true);
|
||||
assert_eq!(account.position(symbol).unwrap().quantity,quantity);
|
||||
let executor=broker();
|
||||
let report=executor.execute(current,&mut account,&data,&decision).unwrap();
|
||||
assert_eq!(account.position(symbol).map_or(0,|p|p.quantity),expected,"entry={entry} fees={fees} decision={decision:?} report={report:?}");
|
||||
assert!(!report.fill_events.is_empty());
|
||||
let attempted_rebuy=plan(&account,buy.clone(),false);
|
||||
let rejected=executor.execute(current,&mut account,&data,&attempted_rebuy).unwrap();
|
||||
assert!(rejected.fill_events.iter().all(|fill|fill.side!=OrderSide::Buy),"{rejected:?}");
|
||||
}
|
||||
let mut untouched=PortfolioState::new(100_000.0);
|
||||
let allowed=plan(&untouched,buy,false);
|
||||
let result=broker().execute(current,&mut untouched,&data,&allowed).unwrap();
|
||||
assert!(result.fill_events.iter().any(|fill|fill.side==OrderSide::Buy));
|
||||
assert_eq!(shared.version_sha256(),shared_version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portfolio_loss_observes_finalized_nav_after_fees_and_cash_flows() {
|
||||
use std::sync::Mutex;
|
||||
@@ -25508,9 +25614,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,10 @@ 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)]
|
||||
pub signal_book_ref: Option<crate::signal_contract::SignalBookReference>,
|
||||
#[serde(default, alias = "strategy_id")]
|
||||
pub strategy_id: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -644,7 +648,7 @@ fn normalize_risk_policy_aliases_in_value(value: &mut Value) -> Result<(), Strin
|
||||
/// contract can legitimately arrive with both spellings. Canonicalise those
|
||||
/// pairs once at the boundary, while rejecting conflicting values instead of
|
||||
/// silently choosing one.
|
||||
fn normalize_strategy_aliases_in_value(value: &mut Value) -> Result<(), String> {
|
||||
pub fn normalize_strategy_aliases_in_value(value: &mut Value) -> Result<(), String> {
|
||||
normalize_strategy_aliases_in_value_inner(value, false)
|
||||
}
|
||||
|
||||
@@ -675,6 +679,8 @@ fn normalize_strategy_aliases_in_value_inner(
|
||||
}
|
||||
|
||||
const STRATEGY_ALIAS_GROUPS: &[(&str, &[&str])] = &[
|
||||
("signalBook", &["signal_book"]),
|
||||
("signalBookRef", &["signal_book_ref"]),
|
||||
("strategyId", &["strategy_id"]),
|
||||
("tradeTimes", &["trade_times"]),
|
||||
("signalSymbol", &["signal_symbol"]),
|
||||
@@ -714,10 +720,8 @@ const STRATEGY_ALIAS_GROUPS: &[(&str, &[&str])] = &[
|
||||
),
|
||||
("stampTaxRateAfterChange", &["stamp_tax_rate_after_change"]),
|
||||
("stampTaxChangeDate", &["stamp_tax_change_date"]),
|
||||
("volumeLimit", &["volume_limit"]),
|
||||
("volumeLimitEnabled", &["volume_limit_enabled"]),
|
||||
("liquidityLimit", &["liquidity_limit"]),
|
||||
("liquidityLimitEnabled", &["liquidity_limit_enabled"]),
|
||||
("volumeLimit", &["volume_limit", "volumeLimitEnabled", "volume_limit_enabled"]),
|
||||
("liquidityLimit", &["liquidity_limit", "liquidityLimitEnabled", "liquidity_limit_enabled"]),
|
||||
("volumePercent", &["volume_percent"]),
|
||||
("riskPolicy", &["risk_policy"]),
|
||||
("strictValueBudget", &["strict_value_budget"]),
|
||||
@@ -741,6 +745,16 @@ fn strategy_alias_values_semantically_equal(left: &Value, right: &Value) -> bool
|
||||
return true;
|
||||
}
|
||||
match (left, right) {
|
||||
(Value::Number(left), Value::Number(right)) => {
|
||||
const MAX_EXACT: i64 = 9_007_199_254_740_992;
|
||||
let exact_integer = |value: &serde_json::Number| {
|
||||
value.as_i64().filter(|v| (-MAX_EXACT..=MAX_EXACT).contains(v)).map(|v| v as f64)
|
||||
.or_else(|| value.as_u64().filter(|v| *v <= MAX_EXACT as u64).map(|v| v as f64))
|
||||
};
|
||||
if left.is_f64() && !right.is_f64() { exact_integer(right).zip(left.as_f64()).is_some_and(|(a,b)| a==b) }
|
||||
else if right.is_f64() && !left.is_f64() { exact_integer(left).zip(right.as_f64()).is_some_and(|(a,b)| a==b) }
|
||||
else { false }
|
||||
}
|
||||
(Value::String(left), Value::String(right)) => left.trim() == right.trim(),
|
||||
(Value::String(left), Value::Number(right))
|
||||
| (Value::Number(right), Value::String(left)) => left
|
||||
@@ -2599,6 +2613,32 @@ pub fn platform_expr_config_from_spec(
|
||||
}
|
||||
cfg.strict_value_budget = true;
|
||||
|
||||
let signal_book = match (&spec.signal_book,&spec.signal_book_ref) {
|
||||
(Some(_),Some(_)) => return Err("inline_and_registered_signal_book_are_mutually_exclusive".into()),
|
||||
(Some(raw),None) => Some(std::sync::Arc::new(raw.clone().validate()?)),
|
||||
(None,Some(reference)) => crate::signal_contract::cached_signal_book(reference)?,
|
||||
(None,None) => None,
|
||||
};
|
||||
if let Some(book) = signal_book {
|
||||
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(book);
|
||||
} else if spec.signal_book_ref.is_some() {
|
||||
if cfg.explicit_actions.len()!=1 || !matches!(cfg.explicit_actions[0],PlatformTradeAction::ConsumeSignal) {
|
||||
return Err("signal_book_requires_one_consume_signal_action".into());
|
||||
}
|
||||
cfg.rotation_enabled=false;
|
||||
} 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 +2791,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
|
||||
@@ -3149,6 +3190,16 @@ fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn numeric_strategy_aliases_accept_exact_zero_but_never_hide_rounding_or_conflicts() {
|
||||
let cfg = platform_expr_config_from_value("fees", "000001.SZ", &serde_json::json!({
|
||||
"execution":{"minimumCommission":0.0,"minimum_commission":0}
|
||||
})).unwrap();
|
||||
assert_eq!(cfg.minimum_commission, Some(0.0));
|
||||
assert!(!strategy_alias_values_semantically_equal(&serde_json::json!(9007199254740992u64), &serde_json::json!(9007199254740993u64)));
|
||||
assert!(!strategy_alias_values_semantically_equal(&serde_json::json!(0.0), &serde_json::json!(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_buy_filter_as_a_separate_trading_condition() {
|
||||
let cfg = platform_expr_config_from_value("buy-guard", "000001.SZ", &serde_json::json!({
|
||||
@@ -4066,6 +4117,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_limit_aliases_normalize_to_one_serde_field_without_touching_policy() {
|
||||
for section in ["execution", "engineConfig"] {
|
||||
let mut spec = serde_json::json!({});
|
||||
spec[section] = serde_json::json!({
|
||||
"volumeLimit": false, "volumeLimitEnabled": false, "volume_limit_enabled": false,
|
||||
"liquidityLimit": true, "liquidityLimitEnabled": true, "liquidity_limit_enabled": true,
|
||||
"riskPolicy": {"volumeLimitEnabled": false, "liquidityLimitEnabled": true}
|
||||
});
|
||||
let cfg = platform_expr_config_from_value("test", "000300.SH", &spec).unwrap();
|
||||
assert!(!cfg.risk_config.trading_constraints.volume_limit_enabled);
|
||||
assert!(cfg.risk_config.trading_constraints.liquidity_limit_enabled);
|
||||
super::normalize_strategy_aliases_in_value(&mut spec).unwrap();
|
||||
assert!(spec[section].get("volumeLimitEnabled").is_none());
|
||||
assert!(spec[section].get("liquidity_limit_enabled").is_none());
|
||||
assert_eq!(spec[section]["riskPolicy"]["liquidityLimitEnabled"], true);
|
||||
spec[section]["liquidity_limit_enabled"] = serde_json::json!(false);
|
||||
assert!(platform_expr_config_from_value("test", "000300.SH", &spec)
|
||||
.unwrap_err().to_string().contains("conflicting alias values"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonicalizes_duplicate_execution_aliases_without_changing_strategy_intent() {
|
||||
let spec = serde_json::json!({
|
||||
|
||||
@@ -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>,
|
||||
@@ -404,7 +414,7 @@ impl ChinaAShareRiskControl {
|
||||
}
|
||||
let reject_one_yuan = match scope {
|
||||
RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection,
|
||||
RiskCheckScope::Buy => config.static_rules.reject_one_yuan_buy,
|
||||
RiskCheckScope::Buy => false,
|
||||
RiskCheckScope::Sell => false,
|
||||
};
|
||||
if reject_one_yuan
|
||||
@@ -477,6 +487,14 @@ impl ChinaAShareRiskControl {
|
||||
) {
|
||||
return Some(reason);
|
||||
}
|
||||
if !check_price.is_finite() || check_price <= 0.0 {
|
||||
return Some("invalid execution price");
|
||||
}
|
||||
// Daily candidate flags can describe the later close. Execution
|
||||
// price constraints must use this order's actual pricing clock.
|
||||
if config.static_rules.reject_one_yuan_buy && check_price <= 1.0 {
|
||||
return Some("one_yuan");
|
||||
}
|
||||
if config.static_rules.respect_allow_buy_sell && !candidate.allow_buy {
|
||||
return Some("buy_disabled");
|
||||
}
|
||||
@@ -658,7 +676,6 @@ fn missing_buy_risk_state_rejected(code: &str, config: &FidcRiskControlConfig) -
|
||||
|| config.static_rules.reject_new_listing_buy
|
||||
|| config.static_rules.reject_kcb_buy
|
||||
|| config.static_rules.reject_bjse_buy
|
||||
|| config.static_rules.reject_one_yuan_buy
|
||||
|| config.static_rules.reject_upper_limit_buy
|
||||
|| config.static_rules.respect_allow_buy_sell;
|
||||
}
|
||||
@@ -735,7 +752,7 @@ fn missing_single_field_rejected(
|
||||
},
|
||||
"is_one_yuan" | "one_yuan" => match scope {
|
||||
RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection,
|
||||
RiskCheckScope::Buy => config.static_rules.reject_one_yuan_buy,
|
||||
RiskCheckScope::Buy => false,
|
||||
RiskCheckScope::Sell => false,
|
||||
},
|
||||
"allow_buy" => match scope {
|
||||
@@ -779,7 +796,6 @@ fn missing_single_field_rejected(
|
||||
|| config.static_rules.reject_new_listing_buy
|
||||
|| config.static_rules.reject_kcb_buy
|
||||
|| config.static_rules.reject_bjse_buy
|
||||
|| config.static_rules.reject_one_yuan_buy
|
||||
|| config.static_rules.reject_upper_limit_buy
|
||||
|| config.static_rules.respect_allow_buy_sell
|
||||
}
|
||||
@@ -896,6 +912,61 @@ mod tests {
|
||||
position
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_yuan_buy_rule_uses_execution_price_not_later_close_or_earlier_open() {
|
||||
let day = d(2025, 2, 6);
|
||||
let mut candidate = candidate(day);
|
||||
let mut snapshot = market(day, 1.2, 0.5);
|
||||
let config = FidcRiskControlConfig::default();
|
||||
candidate.is_one_yuan = true;
|
||||
snapshot.day_open = 0.9;
|
||||
snapshot.close = 0.8;
|
||||
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||
day, &candidate, &snapshot, None, 1.2, &config), None);
|
||||
candidate.is_one_yuan = false;
|
||||
snapshot.day_open = 1.2;
|
||||
snapshot.close = 1.3;
|
||||
for price in [0.9, 1.0] {
|
||||
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||
day, &candidate, &snapshot, None, price, &config), Some("one_yuan"));
|
||||
}
|
||||
let mut relaxed = config;
|
||||
relaxed.static_rules.reject_one_yuan_buy = false;
|
||||
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||
day, &candidate, &snapshot, None, 0.9, &relaxed), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_quote_covers_missing_one_yuan_flag_but_not_other_risk_facts() {
|
||||
let day = d(2025, 2, 6);
|
||||
let mut candidate = candidate(day);
|
||||
let snapshot = market(day, 1.2, 0.5);
|
||||
let config = FidcRiskControlConfig::default();
|
||||
candidate.risk_level_code = Some("missing_risk_state:is_one_yuan".into());
|
||||
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||
day, &candidate, &snapshot, None, 1.2, &config), None);
|
||||
candidate.risk_level_code = Some("missing_risk_state:is_st".into());
|
||||
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||
day, &candidate, &snapshot, None, 1.2, &config), Some("missing_risk_state"));
|
||||
candidate.risk_level_code = None;
|
||||
for price in [0.0, f64::NAN, f64::INFINITY] {
|
||||
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||
day, &candidate, &snapshot, None, price, &config), Some("invalid execution price"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_one_yuan_selection_policy_still_uses_selection_facts() {
|
||||
let day = d(2025, 2, 6);
|
||||
let mut candidate = candidate(day);
|
||||
candidate.is_one_yuan = true;
|
||||
let snapshot = market(day, 1.2, 0.5);
|
||||
let mut config = FidcRiskControlConfig::default();
|
||||
config.static_rules.reject_one_yuan_selection = true;
|
||||
assert_eq!(ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
day, &candidate, &snapshot, None, &config), Some("one_yuan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sell_rejection_respects_allow_sell_policy_on_execution_day() {
|
||||
let prev_date = d(2024, 4, 16);
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
//! 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![crate::BenchmarkSnapshot {
|
||||
date:NaiveDate::from_ymd_opt(2025,1,6).unwrap(), benchmark:"clock-fixture".into(),
|
||||
open:100.0, close:100.0, prev_close:100.0, volume:1,
|
||||
}]).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-06T15:00:00", "2025-01-07T08: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-07T09: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-06T15:00:00".parse().unwrap()), |ctx| assert!(value.intents(ctx).is_ok()));
|
||||
at_context(Some("2025-01-06T14: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-07T09: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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -2143,15 +2143,15 @@ fn strategy_context_exposes_advanced_data_helpers() {
|
||||
fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
||||
let date = d(2025, 1, 2);
|
||||
let data = DataSet::from_components_with_actions_and_quotes(
|
||||
vec![Instrument {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
["000001.SZ", "000002.SZ"].into_iter().map(|symbol| Instrument {
|
||||
symbol: symbol.to_string(),
|
||||
name: "Anchor".to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(d(2020, 1, 1)),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}],
|
||||
}).collect(),
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
@@ -2174,7 +2174,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
||||
upper_limit: 11.0,
|
||||
lower_limit: 9.0,
|
||||
price_tick: 0.01,
|
||||
}],
|
||||
}, market_row(date, "000002.SZ", 20.0, 20.4)],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
@@ -4162,7 +4162,7 @@ impl Strategy for BuyMissingRowThenHoldStrategy {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_carries_position_price_when_current_market_row_is_missing() {
|
||||
fn engine_rejects_an_unexplained_missing_holding_close() {
|
||||
let date1 = d(2025, 5, 26);
|
||||
let date2 = d(2025, 5, 27);
|
||||
let data = DataSet::from_components(
|
||||
@@ -4230,20 +4230,16 @@ fn engine_carries_position_price_when_current_market_row_is_missing() {
|
||||
},
|
||||
);
|
||||
|
||||
let result = engine
|
||||
let error = engine
|
||||
.run()
|
||||
.expect("backtest should not fail on one missing holding row");
|
||||
assert_eq!(result.equity_curve.len(), 2);
|
||||
assert!(
|
||||
result
|
||||
.daily_holdings
|
||||
.iter()
|
||||
.any(|holding| holding.date == date2 && holding.symbol == "601028.SH")
|
||||
);
|
||||
.expect_err("unknown missing market data must not become a carried close");
|
||||
let detail = format!("{error:?}");
|
||||
assert!(detail.contains("MissingSnapshot") && detail.contains("close price"));
|
||||
assert!(detail.contains("601028.SH") && detail.contains("2025-05-27"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_strategy_skips_position_stop_take_when_current_market_row_is_missing() {
|
||||
fn platform_strategy_cannot_hide_missing_valuation_by_skipping_stop_take() {
|
||||
let date1 = d(2025, 5, 26);
|
||||
let date2 = d(2025, 5, 27);
|
||||
let data = DataSet::from_components(
|
||||
@@ -4333,14 +4329,10 @@ fn platform_strategy_skips_position_stop_take_when_current_market_row_is_missing
|
||||
},
|
||||
);
|
||||
|
||||
let result = engine
|
||||
let error = engine
|
||||
.run()
|
||||
.expect("platform strategy should hold through a missing current market row");
|
||||
assert_eq!(result.equity_curve.len(), 2);
|
||||
assert!(
|
||||
result
|
||||
.daily_holdings
|
||||
.iter()
|
||||
.any(|holding| holding.date == date2 && holding.symbol == "601028.SH")
|
||||
);
|
||||
.expect_err("skipping a stop condition cannot fabricate the missing valuation");
|
||||
let detail = format!("{error:?}");
|
||||
assert!(detail.contains("MissingSnapshot") && detail.contains("close price"));
|
||||
assert!(detail.contains("601028.SH") && detail.contains("2025-05-27"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "fidc-signal-client"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fidc-core = { path = "../fidc-core" }
|
||||
reqwest.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Shared signal transport for FIDC backtest and trading services.
|
||||
|
||||
use std::sync::Arc;
|
||||
use fidc_core::signal_contract::{SignalBookReference,ValidatedSignalBook,cached_signal_book,register_signal_book};
|
||||
use reqwest::Client;
|
||||
use serde_json::{Value,json};
|
||||
|
||||
#[derive(Clone,Copy)]
|
||||
pub enum Purpose { Backtest, Online }
|
||||
|
||||
pub async fn load(client:&Client, source_url:&str, token:&str, reference:&SignalBookReference, purpose:Purpose)
|
||||
-> Result<Arc<ValidatedSignalBook>,String>
|
||||
{
|
||||
reference.validate()?;
|
||||
if token.len()<32 {return Err("signal_service_auth_not_configured".into());}
|
||||
let purpose_name=match purpose {Purpose::Backtest=>"backtest",Purpose::Online=>"online"};
|
||||
let payload=json!({"reference":reference,"purpose":purpose_name});
|
||||
let root=format!("{}/api/strategy-signals/internal",source_url.trim_end_matches('/'));
|
||||
// Registration/purpose validation always precedes a process-cache hit.
|
||||
let response=client.post(format!("{root}/validate"))
|
||||
.header("X-FIDC-Lifecycle-Token",token).json(&payload).send().await
|
||||
.map_err(|_|"signal_validation_service_unavailable")?;
|
||||
if !response.status().is_success() {return Err(format!("signal_validation_rejected_http_{}",response.status()));}
|
||||
let validation:Value=response.json().await.map_err(|_|"signal_validation_response_invalid")?;
|
||||
if validation.get("ok")!=Some(&Value::Bool(true)) || validation.get("reference")!=Some(&json!(reference)) {
|
||||
return Err("signal_validation_identity_mismatch".into());
|
||||
}
|
||||
let book=if let Some(book)=cached_signal_book(reference)? {book} else {
|
||||
let mut response=client.post(format!("{root}/book"))
|
||||
.header("X-FIDC-Lifecycle-Token",token).json(&payload).send().await
|
||||
.map_err(|_|"signal_book_service_unavailable")?;
|
||||
if !response.status().is_success() {return Err(format!("signal_book_rejected_http_{}",response.status()));}
|
||||
if response.content_length().is_some_and(|bytes|bytes>64*1024*1024) {return Err("signal_book_transport_size_exceeded".into());}
|
||||
let mut bytes=Vec::new();
|
||||
while let Some(chunk)=response.chunk().await.map_err(|_|"signal_book_transport_incomplete")? {
|
||||
if bytes.len().saturating_add(chunk.len())>64*1024*1024 {return Err("signal_book_transport_size_exceeded".into());}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
register_signal_book(reference,&bytes)?
|
||||
};
|
||||
if matches!(purpose,Purpose::Online) {book.require_observed()?;}
|
||||
Ok(book)
|
||||
}
|
||||
@@ -7,3 +7,9 @@
|
||||
整个明确证券范围尚未上市时保留官方日历内现金净值点,不缩短回测范围,不伪造成交或 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