Compare commits
26 Commits
v2026.9.14.5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 534ab42906 | |||
| ba4b77fd74 | |||
| ad76bdb6ae | |||
| 59a0c95aae | |||
| d4e7cdd5b5 | |||
| 984f9d308d | |||
| fbc4233dcd | |||
| 05f1cbbe00 | |||
| ef9cc39882 | |||
| b4c68be29b | |||
| e9c9ecbd48 | |||
| b2eaaa0d26 | |||
| 13c89e8d59 | |||
| 232e9ae154 | |||
| f8955bfb18 | |||
| fb8192a286 | |||
| 7f0c6a008a | |||
| 93de28d369 | |||
| 665653c3fe | |||
| a29c434be9 | |||
| 4c96d0c31f | |||
| 5e11f3da22 | |||
| 8e7ae69b0b | |||
| 9a54156df9 | |||
| 996b909608 | |||
| c62ae1206f |
@@ -944,9 +944,17 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
}
|
||||
|
||||
fn new_open_order_submission_time(&self) -> Option<NaiveTime> {
|
||||
if self.matching_type == MatchingType::NextBarOpen && !self.runtime_stock_pool_followup.get() {
|
||||
NaiveTime::from_hms_opt(9, 30, 0)
|
||||
} else { self.order_origin().1 }
|
||||
if self.runtime_resting_order_origin.get().is_some() {
|
||||
return self.order_origin().1;
|
||||
}
|
||||
if self.matching_type == MatchingType::NextBarOpen
|
||||
&& !self.runtime_stock_pool_followup.get()
|
||||
{
|
||||
let open = NaiveTime::from_hms_opt(9, 30, 0).unwrap();
|
||||
Some(self.execution_clock().map_or(open, |clock| clock.max(open)))
|
||||
} else {
|
||||
self.execution_clock().or(self.order_origin().1)
|
||||
}
|
||||
}
|
||||
|
||||
fn resting_order_session_close(&self, date: NaiveDate, order: &OpenOrder) -> NaiveTime {
|
||||
@@ -2754,6 +2762,13 @@ where
|
||||
.insert(symbol.to_string());
|
||||
}
|
||||
|
||||
pub(crate) fn record_observed_manual_execution(&self, execution: &crate::manual_execution::ManualReplayApplication) {
|
||||
if execution.side == OrderSide::Sell {
|
||||
let date = execution.executed_at.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).date_naive();
|
||||
self.mark_same_day_sold(date, &execution.symbol);
|
||||
}
|
||||
}
|
||||
|
||||
fn same_day_rebuy_rejection_reason(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
@@ -5311,6 +5326,7 @@ where
|
||||
.map_err(BacktestError::Execution)?;
|
||||
|
||||
report.fill_events.push(FillEvent {
|
||||
origin: crate::events::FillOrigin::MarketExecution,
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
@@ -7144,6 +7160,7 @@ where
|
||||
}
|
||||
|
||||
report.fill_events.push(FillEvent {
|
||||
origin: crate::events::FillOrigin::MarketExecution,
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
@@ -8018,17 +8035,26 @@ where
|
||||
&& origin.accepted_date == date
|
||||
&& let Some(submitted) = origin.submission_time
|
||||
{
|
||||
Some(start_cursor.map_or(date.and_time(submitted), |cursor| cursor.max(date.and_time(submitted))))
|
||||
} else { start_cursor };
|
||||
let start_cursor = if algo_request.is_some() {
|
||||
match (start_cursor, self.execution_clock().map(|time| date.and_time(time))) {
|
||||
Some(start_cursor.map_or(date.and_time(submitted), |cursor| {
|
||||
cursor.max(date.and_time(submitted))
|
||||
}))
|
||||
} else {
|
||||
start_cursor
|
||||
};
|
||||
// A configured session start is not the current submission clock.
|
||||
// Coarse callbacks and resting-order retries cannot execute backwards
|
||||
// into an earlier quote, even when they are not algorithm orders.
|
||||
let start_cursor = match (
|
||||
start_cursor,
|
||||
self.execution_clock().map(|time| date.and_time(time)),
|
||||
) {
|
||||
(Some(declared), Some(clock)) => Some(declared.max(clock)),
|
||||
(start, _) => start,
|
||||
}
|
||||
} else { start_cursor };
|
||||
let end_cursor = post_close_window.map(|window| {
|
||||
runtime_end_time.map_or(window.1, |end| window.1.min(date.and_time(end)))
|
||||
}).or_else(|| {
|
||||
(None, Some(clock)) => Some(clock),
|
||||
(start, None) => start,
|
||||
};
|
||||
let end_cursor = post_close_window
|
||||
.map(|window| runtime_end_time.map_or(window.1, |end| window.1.min(date.and_time(end))))
|
||||
.or_else(|| {
|
||||
algo_request
|
||||
.and_then(|request| request.end_time)
|
||||
.or(runtime_end_time)
|
||||
@@ -8246,6 +8272,7 @@ where
|
||||
let mut last_timestamp = None;
|
||||
let mut legs = Vec::new();
|
||||
let mut budget_block_reason = None;
|
||||
let mut budget_block_timestamp = None;
|
||||
let mut execution_block_reason = None;
|
||||
let mut execution_block_timestamp = None;
|
||||
let mut saw_non_blocked_execution_price = false;
|
||||
@@ -8386,6 +8413,7 @@ where
|
||||
self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty), calibration)?;
|
||||
if !quote_price.is_finite() || quote_price <= 0.0 {
|
||||
budget_block_reason = Some("invalid execution price");
|
||||
budget_block_timestamp = Some(execution_at);
|
||||
take_qty = 0;
|
||||
break;
|
||||
}
|
||||
@@ -8404,6 +8432,7 @@ where
|
||||
.is_some_and(|limit| !Self::fixed_cash_fits(candidate_gross, limit))
|
||||
{
|
||||
budget_block_reason = Some("value budget limit");
|
||||
budget_block_timestamp = Some(execution_at);
|
||||
take_qty = self.decrement_order_quantity(
|
||||
take_qty,
|
||||
minimum_order_quantity,
|
||||
@@ -8429,6 +8458,7 @@ where
|
||||
break;
|
||||
}
|
||||
budget_block_reason = Some("insufficient cash after fees");
|
||||
budget_block_timestamp = Some(execution_at);
|
||||
take_qty = self.decrement_order_quantity(
|
||||
take_qty,
|
||||
minimum_order_quantity,
|
||||
@@ -8509,6 +8539,16 @@ where
|
||||
unfilled_reason: Some(reason),
|
||||
}));
|
||||
}
|
||||
if let Some(reason) = budget_block_reason {
|
||||
return Ok(Some(ExecutionFill {
|
||||
quantity: 0,
|
||||
next_cursor: budget_block_timestamp.expect("budget-blocked quote timestamp")
|
||||
+ Duration::seconds(1),
|
||||
legs: Vec::new(),
|
||||
liquidity_consumption: Vec::new(),
|
||||
unfilled_reason: Some(reason),
|
||||
}));
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
@@ -8710,6 +8750,31 @@ mod tests {
|
||||
|
||||
include!("broker_stock_pool_batch_tests.rs");
|
||||
|
||||
#[test]
|
||||
fn queued_order_retains_the_real_creation_clock_when_retried() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
|
||||
let open = NaiveTime::from_hms_opt(9, 30, 0).unwrap();
|
||||
let created = NaiveTime::from_hms_opt(9, 31, 0).unwrap();
|
||||
let later = NaiveTime::from_hms_opt(10, 0, 0).unwrap();
|
||||
for matching in [MatchingType::NextBarOpen, MatchingType::MinuteLast] {
|
||||
let broker =
|
||||
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(matching)
|
||||
.with_intraday_execution_start_time(open);
|
||||
broker.runtime_execution_clock.set(Some(created));
|
||||
assert_eq!(broker.new_open_order_submission_time(), Some(created));
|
||||
broker
|
||||
.runtime_resting_order_origin
|
||||
.set(Some(super::RestingOrderOrigin {
|
||||
created_date: Some(date),
|
||||
submission_time: Some(created),
|
||||
accepted_date: date,
|
||||
}));
|
||||
broker.runtime_execution_clock.set(Some(later));
|
||||
assert_eq!(broker.new_open_order_submission_time(), Some(created));
|
||||
}
|
||||
}
|
||||
|
||||
fn test_open_order(order_id: u64) -> OpenOrder {
|
||||
OpenOrder {
|
||||
order_id,
|
||||
|
||||
@@ -69,7 +69,89 @@ fn pool_positions(
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod successor_protection_tests {
|
||||
use super::*;
|
||||
use crate::holding_policy::{AutomaticTradeLock, AutomaticTradeProtection};
|
||||
fn day(n: u32) -> NaiveDate { NaiveDate::from_ymd_opt(2026, 9, n).unwrap() }
|
||||
|
||||
#[test]
|
||||
fn deferred_etf_open_rechecks_inherited_locks_and_cooldown_before_any_order() {
|
||||
let old = "159915.SZ";
|
||||
let new = "159999.SZ";
|
||||
let data = DataSet::from_components(
|
||||
[old, new].into_iter().map(|symbol| crate::Instrument {
|
||||
symbol: symbol.into(), name: "isolated ETF fixture".into(), board: "ETF".into(), round_lot: 100,
|
||||
listed_at: Some(day(1)), delisted_at: None, status: "active".into(),
|
||||
}).collect(), vec![crate::DailyMarketSnapshot {
|
||||
date: day(15), symbol: new.into(), timestamp: None, day_open: 5., open: 5., high: 5., low: 5.,
|
||||
close: 5., last_price: 5., bid1: 5., ask1: 5., prev_close: 5., volume: 100000,
|
||||
minute_volume: 0, bid1_volume: 100000, ask1_volume: 100000, trading_phase: None,
|
||||
paused: false, upper_limit: 5.5, lower_limit: 4.5, price_tick: 0.001,
|
||||
}], vec![], vec![crate::CandidateEligibility {
|
||||
date: day(15), symbol: new.into(), is_st: false, is_star_st: false, is_new_listing: false,
|
||||
is_paused: false, allow_buy: true, allow_sell: true, is_kcb: false, is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
}], [11,14,15].into_iter().map(|n| crate::BenchmarkSnapshot {
|
||||
date: day(n), benchmark: "000300.SH".into(), open: 100., close: 100., prev_close: 100., volume: 10000,
|
||||
}).collect()).unwrap();
|
||||
for mode in ["lock", "cooldown", "expired"] {
|
||||
let broker = BrokerSimulator::new(crate::ChinaAShareCostModel::default(), crate::ChinaEquityRuleHooks)
|
||||
.with_volume_limit(false).with_liquidity_limit(false);
|
||||
let mut portfolio = PortfolioState::new(10000.);
|
||||
portfolio.position_mut(old).buy(day(11), 200, 10.);
|
||||
portfolio.position_mut(old).sell(100, 10.).unwrap();
|
||||
broker.mark_same_day_sold(day(11), old);
|
||||
portfolio.apply_successor_conversion(old, new, 2., 0.).unwrap();
|
||||
let policy = AutomaticTradeProtection {
|
||||
sell_cooldown_days: if mode == "cooldown" { 3 } else { 0 },
|
||||
locks: if mode != "cooldown" { vec![AutomaticTradeLock {
|
||||
symbol: old.into(), start_date: day(11), end_date: Some(day(if mode == "expired" {14} else {15})),
|
||||
}] } else { vec![] }, ..Default::default()
|
||||
};
|
||||
let rule = pool::StockPoolExecutionRule { automatic_trade_protection: policy, ..Default::default() };
|
||||
broker.deferred_etf_targets.borrow_mut().replace_generation("pool", "latest");
|
||||
broker.deferred_etf_targets.borrow_mut().upsert(crate::etf_execution::DeferredEtfTarget {
|
||||
pool_id: "pool".into(), generation: "latest".into(), symbol: new.into(),
|
||||
signal_date: day(14), signal_at: day(14).and_hms_opt(13,0,0).unwrap(), execute_on: Some(day(15)),
|
||||
target_value: 5000.into(), target_weight_bps: 10000, side: pool::OrderSide::Buy, max_positions: 1,
|
||||
rule: std::sync::Arc::new(rule), members: std::sync::Arc::new(vec![pool::StockPoolMemberSpec {
|
||||
symbol: new.into(), requested_order: 0, recommendation_reason: String::new(),
|
||||
target_weight_bps: None, stop_loss: None, take_profit: None,
|
||||
}]), reason: "isolated deferred ETF target".into(),
|
||||
});
|
||||
let report = broker.execute_deferred_etf_targets(day(15), &mut portfolio, &data).unwrap();
|
||||
if mode == "expired" {
|
||||
assert_eq!(report.fill_events.len(), 1, "{report:?}");
|
||||
assert_eq!(portfolio.position(new).unwrap().quantity, 1000);
|
||||
} else {
|
||||
assert!(report.order_events.is_empty(), "{mode}: {report:?}");
|
||||
assert!(report.fill_events.is_empty());
|
||||
assert_eq!(portfolio.position(new).unwrap().quantity, 200);
|
||||
assert!(report.diagnostics.iter().any(|text| text.contains(if mode == "lock" {"automatic_trade_locked"} else {"sell_fill_cooldown"})));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
fn pool_automatic_permission(&self, symbol: &str, date: NaiveDate,
|
||||
policy: &crate::holding_policy::AutomaticTradeProtection,
|
||||
portfolio: &PortfolioState, data: &DataSet)
|
||||
-> Result<crate::holding_policy::AutomaticTradePermission, BacktestError> {
|
||||
let position = portfolio.position(symbol).filter(|position| position.quantity > 0);
|
||||
let sold = self.same_day_sold_symbols.borrow().iter().rev()
|
||||
.find(|(day, symbols)| **day <= date && (symbols.contains(symbol)
|
||||
|| portfolio.corporate_predecessors(symbol).any(|previous| symbols.contains(previous))))
|
||||
.map(|(day, _)| *day);
|
||||
let evidence = HoldingLifecycleEvidence {
|
||||
has_position: position.is_some(), opened_date: position.and_then(|position| position.opened_date()),
|
||||
last_buy_date: position.and_then(|position| position.last_buy_date()), last_sell_date: sold,
|
||||
};
|
||||
policy.evaluate_with_predecessors(symbol, date, &evidence, data.calendar(),
|
||||
portfolio.corporate_predecessors(symbol)).map_err(BacktestError::Execution)
|
||||
}
|
||||
|
||||
pub(super) fn resume_stock_pool_executions(&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet,
|
||||
session: &mut BrokerExecutionSession, report: &mut BrokerExecutionReport) -> Result<(), BacktestError> {
|
||||
let clock = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time);
|
||||
@@ -463,25 +545,8 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
constraints.automatic_permissions.clear();
|
||||
if contract.rule.automatic_trade_protection.enabled() {
|
||||
for symbol in &scope {
|
||||
let position = portfolio.position(symbol).filter(|p| p.quantity > 0);
|
||||
let sold = self
|
||||
.same_day_sold_symbols
|
||||
.borrow()
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(day, symbols)| **day <= date && symbols.contains(symbol))
|
||||
.map(|(day, _)| *day);
|
||||
let evidence = HoldingLifecycleEvidence {
|
||||
has_position: position.is_some(),
|
||||
opened_date: position.and_then(|p| p.opened_date()),
|
||||
last_buy_date: position.and_then(|p| p.last_buy_date()),
|
||||
last_sell_date: sold,
|
||||
};
|
||||
let permission = contract
|
||||
.rule
|
||||
.automatic_trade_protection
|
||||
.evaluate(symbol, date, &evidence, data.calendar())
|
||||
.map_err(BacktestError::Execution)?;
|
||||
let permission = self.pool_automatic_permission(symbol, date,
|
||||
&contract.rule.automatic_trade_protection, portfolio, data)?;
|
||||
constraints
|
||||
.automatic_permissions
|
||||
.insert(symbol.clone(), permission);
|
||||
@@ -708,10 +773,8 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
}
|
||||
let position = portfolio.position(&target.symbol).filter(|p| p.quantity > 0);
|
||||
let before_quantity = position.map_or(0, |p| p.quantity);
|
||||
let permission = target.rule.automatic_trade_protection.evaluate(&target.symbol, date, &HoldingLifecycleEvidence {
|
||||
has_position:position.is_some(), opened_date:position.and_then(|p| p.opened_date()), last_buy_date:position.and_then(|p| p.last_buy_date()),
|
||||
last_sell_date:self.same_day_sold_symbols.borrow().iter().rev().find(|(day, symbols)| **day <= date && symbols.contains(&target.symbol)).map(|(day, _)| *day),
|
||||
}, data.calendar()).map_err(BacktestError::Execution)?;
|
||||
let permission = self.pool_automatic_permission(&target.symbol, date,
|
||||
&target.rule.automatic_trade_protection, portfolio, data)?;
|
||||
let denial = if target.side == pool::OrderSide::Buy {
|
||||
permission.buy_denial.or(permission.max_holding_exit.then_some("max_holding_exit_pending"))
|
||||
} else { permission.sell_denial };
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
use crate::{
|
||||
AccountEvent, BacktestError, BrokerExecutionReport, CashReceivable, DataSet, FillEvent,
|
||||
OrderSide, PortfolioState, PositionEvent, PriceField, ProcessEvent, ProcessEventKind,
|
||||
};
|
||||
use chrono::NaiveDate;
|
||||
|
||||
pub(crate) fn validate_action<'a>(
|
||||
action: &'a crate::CorporateAction,
|
||||
data: &DataSet,
|
||||
) -> Result<Option<(&'a str, f64, f64)>, String> {
|
||||
let terms = action.validated_successor_terms()?;
|
||||
crate::finite_serialization::validate(action).map_err(|error| {
|
||||
format!(
|
||||
"corporate_action_invalid_number: symbol={} action_date={} detail={error}",
|
||||
action.symbol, action.date
|
||||
)
|
||||
})?;
|
||||
if let Some((successor, _, _)) = terms {
|
||||
for (symbol, role) in [(&*action.symbol, "source"), (successor, "successor")] {
|
||||
if data.instrument(symbol).is_none() {
|
||||
return Err(format!(
|
||||
"corporate_action_{role}_instrument_missing: symbol={symbol} action_date={} source_symbol={}",
|
||||
action.date, action.symbol
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(terms)
|
||||
}
|
||||
|
||||
/// One corporate-action calculation for normal processing and audited replay.
|
||||
pub(crate) fn apply(
|
||||
date: NaiveDate,
|
||||
data: &DataSet,
|
||||
portfolio: &mut PortfolioState,
|
||||
notes: &mut Vec<String>,
|
||||
cash_dividends_enabled: bool,
|
||||
cash_dividend_adjusts_cost_basis: bool,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let actions = data.corporate_actions_on(date);
|
||||
for action in actions {
|
||||
validate_action(action, data).map_err(BacktestError::Execution)?;
|
||||
}
|
||||
if !actions.iter().any(|action| {
|
||||
action.has_effect()
|
||||
&& portfolio
|
||||
.position(&action.symbol)
|
||||
.is_some_and(|position| position.quantity > 0)
|
||||
}) {
|
||||
return Ok(BrokerExecutionReport::default());
|
||||
}
|
||||
// An entire settlement batch is a single ledger update. A later invalid
|
||||
// cash leg must not leave an earlier split, receivable, target unit, or
|
||||
// note applied to the observed account.
|
||||
let mut next = portfolio.clone();
|
||||
let mut recorded = Vec::new();
|
||||
let report = apply_inner(
|
||||
date,
|
||||
data,
|
||||
&mut next,
|
||||
&mut recorded,
|
||||
cash_dividends_enabled,
|
||||
cash_dividend_adjusts_cost_basis,
|
||||
)?;
|
||||
*portfolio = next;
|
||||
notes.extend(recorded);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn apply_inner(
|
||||
date: NaiveDate,
|
||||
data: &DataSet,
|
||||
portfolio: &mut PortfolioState,
|
||||
notes: &mut Vec<String>,
|
||||
cash_dividends_enabled: bool,
|
||||
cash_dividend_adjusts_cost_basis: bool,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
for action in data.corporate_actions_on(date) {
|
||||
if !action.has_effect() {
|
||||
continue;
|
||||
}
|
||||
let Some(existing_position) = portfolio.position(&action.symbol) else {
|
||||
continue;
|
||||
};
|
||||
if existing_position.quantity == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if cash_dividends_enabled && action.share_cash.abs() > f64::EPSILON {
|
||||
let cash_before = portfolio.cash();
|
||||
let (cash_delta, quantity_after, average_cost) = {
|
||||
let position = portfolio
|
||||
.position_mut_if_exists(&action.symbol)
|
||||
.expect("position exists for dividend action");
|
||||
let cash_delta = if cash_dividend_adjusts_cost_basis {
|
||||
position.apply_cash_dividend(action.share_cash)
|
||||
} else {
|
||||
position.apply_cash_dividend_preserve_cost_basis(action.share_cash)
|
||||
};
|
||||
(cash_delta, position.quantity, position.average_cost)
|
||||
};
|
||||
if cash_delta.abs() > f64::EPSILON {
|
||||
let payable_date = action.payable_date.unwrap_or(date);
|
||||
portfolio.add_cash_receivable(CashReceivable {
|
||||
symbol: action.symbol.clone(),
|
||||
ex_date: date,
|
||||
payable_date,
|
||||
amount: cash_delta,
|
||||
reason: format!("cash_dividend {:.6}", action.share_cash),
|
||||
});
|
||||
let note = format!(
|
||||
"cash_dividend_receivable {} share_cash={:.6} quantity={} payable_date={} cash={:.2}",
|
||||
action.symbol, action.share_cash, quantity_after, payable_date, cash_delta
|
||||
);
|
||||
notes.push(note.clone());
|
||||
report.account_events.push(AccountEvent {
|
||||
date,
|
||||
cash_before,
|
||||
cash_after: portfolio.cash(),
|
||||
total_equity: portfolio.total_equity(),
|
||||
note,
|
||||
});
|
||||
report.position_events.push(PositionEvent {
|
||||
date,
|
||||
symbol: action.symbol.clone(),
|
||||
delta_quantity: 0,
|
||||
quantity_after,
|
||||
average_cost,
|
||||
realized_pnl_delta: 0.0,
|
||||
reason: format!("cash_dividend {:.6}", action.share_cash),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let split_ratio = action.split_ratio();
|
||||
if (split_ratio - 1.0).abs() > f64::EPSILON {
|
||||
checked_quantity(
|
||||
&action.symbol,
|
||||
date,
|
||||
portfolio
|
||||
.position(&action.symbol)
|
||||
.expect("position exists for split")
|
||||
.quantity,
|
||||
split_ratio,
|
||||
0,
|
||||
)?;
|
||||
portfolio
|
||||
.adjust_stock_pool_split(&action.symbol, split_ratio)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
let (delta_quantity, quantity_after, average_cost) = {
|
||||
let position = portfolio
|
||||
.position_mut_if_exists(&action.symbol)
|
||||
.expect("position exists for split action");
|
||||
let delta_quantity = position.apply_split_ratio(split_ratio);
|
||||
(delta_quantity, position.quantity, position.average_cost)
|
||||
};
|
||||
if delta_quantity != 0 {
|
||||
let note = format!(
|
||||
"stock_split {} ratio={:.6} delta_qty={}",
|
||||
action.symbol, split_ratio, delta_quantity
|
||||
);
|
||||
notes.push(note);
|
||||
report.position_events.push(PositionEvent {
|
||||
date,
|
||||
symbol: action.symbol.clone(),
|
||||
delta_quantity,
|
||||
quantity_after,
|
||||
average_cost,
|
||||
realized_pnl_delta: 0.0,
|
||||
reason: format!("stock_split {:.6}", split_ratio),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((successor_symbol, ratio, cash_per_share)) = action
|
||||
.validated_successor_terms()
|
||||
.map_err(BacktestError::Execution)?
|
||||
{
|
||||
checked_quantity(
|
||||
&action.symbol,
|
||||
date,
|
||||
portfolio
|
||||
.position(&action.symbol)
|
||||
.expect("position exists for conversion")
|
||||
.quantity,
|
||||
ratio,
|
||||
portfolio
|
||||
.position(successor_symbol)
|
||||
.map_or(0, |position| position.quantity),
|
||||
)?;
|
||||
let Some(outcome) = portfolio.apply_successor_conversion(
|
||||
&action.symbol,
|
||||
successor_symbol,
|
||||
ratio,
|
||||
cash_per_share,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let reason = format!(
|
||||
"successor_conversion {}->{} ratio={:.6} cash_per_share={:.6}",
|
||||
outcome.old_symbol, outcome.new_symbol, ratio, cash_per_share
|
||||
);
|
||||
notes.push(reason.clone());
|
||||
report.position_events.push(PositionEvent {
|
||||
date,
|
||||
symbol: outcome.old_symbol.clone(),
|
||||
delta_quantity: -(outcome.old_quantity as i32),
|
||||
quantity_after: 0,
|
||||
average_cost: 0.0,
|
||||
realized_pnl_delta: 0.0,
|
||||
reason: reason.clone(),
|
||||
});
|
||||
report.position_events.push(PositionEvent {
|
||||
date,
|
||||
symbol: outcome.new_symbol.clone(),
|
||||
delta_quantity: outcome.new_quantity_delta,
|
||||
quantity_after: outcome.new_quantity_after,
|
||||
average_cost: outcome.new_average_cost_after,
|
||||
realized_pnl_delta: 0.0,
|
||||
reason: reason.clone(),
|
||||
});
|
||||
if outcome.cash_delta.abs() > f64::EPSILON {
|
||||
let cash_before = portfolio.cash();
|
||||
portfolio
|
||||
.apply_cash_delta(outcome.cash_delta)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
report.account_events.push(AccountEvent {
|
||||
date,
|
||||
cash_before,
|
||||
cash_after: portfolio.cash(),
|
||||
total_equity: portfolio.total_equity(),
|
||||
note: format!("{} cash={:.2}", reason, outcome.cash_delta),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
portfolio.prune_flat_positions();
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn checked_quantity(
|
||||
symbol: &str,
|
||||
date: NaiveDate,
|
||||
quantity: u32,
|
||||
ratio: f64,
|
||||
merged: u32,
|
||||
) -> Result<(), BacktestError> {
|
||||
let scaled = (f64::from(quantity) * ratio).round();
|
||||
if !scaled.is_finite()
|
||||
|| scaled < 0.
|
||||
|| scaled > f64::from(i32::MAX)
|
||||
|| scaled + f64::from(merged) > f64::from(u32::MAX)
|
||||
{
|
||||
return Err(BacktestError::Execution(format!(
|
||||
"corporate_action_quantity_overflow: symbol={symbol} action_date={date}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Preserve the declared fee-free accounting allocation model; this does not
|
||||
/// submit a market order or use a later opening quote as an earlier fact.
|
||||
pub(crate) fn settle_receivables(
|
||||
date: NaiveDate,
|
||||
data: &DataSet,
|
||||
portfolio: &mut PortfolioState,
|
||||
notes: &mut Vec<String>,
|
||||
reinvest_enabled: bool,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
if !portfolio
|
||||
.cash_receivables()
|
||||
.iter()
|
||||
.any(|row| row.payable_date <= date)
|
||||
{
|
||||
return Ok(BrokerExecutionReport::default());
|
||||
}
|
||||
let mut next = portfolio.clone();
|
||||
let mut recorded = Vec::new();
|
||||
let report = settle_receivables_inner(date, data, &mut next, &mut recorded, reinvest_enabled)?;
|
||||
*portfolio = next;
|
||||
notes.extend(recorded);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn settle_receivables_inner(
|
||||
date: NaiveDate,
|
||||
data: &DataSet,
|
||||
portfolio: &mut PortfolioState,
|
||||
notes: &mut Vec<String>,
|
||||
reinvest_enabled: bool,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
let due = portfolio.take_due_cash_receivables(date);
|
||||
for receivable in due {
|
||||
let cash_before = portfolio.cash();
|
||||
portfolio
|
||||
.settle_cash_receivable(&receivable)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
let mut note = format!(
|
||||
"cash_receivable_settled {} ex_date={} payable_date={} cash={:.2}",
|
||||
receivable.symbol, receivable.ex_date, receivable.payable_date, receivable.amount
|
||||
);
|
||||
if reinvest_enabled
|
||||
&& receivable.reason.starts_with("cash_dividend")
|
||||
&& receivable.amount > 0.0
|
||||
{
|
||||
let instrument = data.instrument(&receivable.symbol).ok_or_else(|| BacktestError::Execution(format!(
|
||||
"dividend_reinvestment: instrument metadata missing symbol={} payable_date={date}", receivable.symbol)))?;
|
||||
if let Some(reason) = instrument.dated_market_absence_reason(date) {
|
||||
note.push_str(&format!(
|
||||
" dividend_reinvestment_not_applied reason={reason} cash_retained=true"
|
||||
));
|
||||
} else {
|
||||
let (reinvest_price, reference_basis) = if let Some(position) = portfolio
|
||||
.position(&receivable.symbol)
|
||||
.filter(|position| position.quantity > 0)
|
||||
{
|
||||
(Some(position.last_price), "adjusted_carried_mark")
|
||||
} else {
|
||||
(
|
||||
data.calendar().previous_day(date).and_then(|prev_date| {
|
||||
data.price_on_or_before(
|
||||
prev_date,
|
||||
&receivable.symbol,
|
||||
PriceField::Close,
|
||||
)
|
||||
}),
|
||||
"previous_completed_close",
|
||||
)
|
||||
};
|
||||
let price = reinvest_price.filter(|price| price.is_finite() && *price > 0.).ok_or_else(|| BacktestError::Execution(format!(
|
||||
"dividend_reinvestment: accounting reference missing or invalid symbol={} payable_date={date} basis={reference_basis}", receivable.symbol)))?;
|
||||
let round_lot = instrument.round_lot;
|
||||
if round_lot == 0 {
|
||||
return Err(BacktestError::Execution(format!(
|
||||
"dividend_reinvestment: invalid quantity unit symbol={}",
|
||||
receivable.symbol
|
||||
)));
|
||||
}
|
||||
{
|
||||
let raw = (receivable.amount / price).floor();
|
||||
if !raw.is_finite() || raw > i32::MAX as f64 {
|
||||
return Err(BacktestError::Execution("dividend_reinvestment: accounting allocation quantity exceeds the ledger contract".into()));
|
||||
}
|
||||
let raw_quantity = raw as u32;
|
||||
let reinvest_quantity = (raw_quantity / round_lot) * round_lot;
|
||||
if reinvest_quantity > 0 {
|
||||
let reinvest_cash = reinvest_quantity as f64 * price;
|
||||
let residual_cash = receivable.amount - reinvest_cash;
|
||||
portfolio
|
||||
.apply_cash_delta(-reinvest_cash)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
portfolio.position_mut(&receivable.symbol).buy(
|
||||
date,
|
||||
reinvest_quantity,
|
||||
price,
|
||||
);
|
||||
|
||||
note = format!(
|
||||
"cash_receivable_reinvested {} ex_date={} payable_date={} cash={:.2} reinvest_qty={} reinvest_price={:.4} residual_cash={:.2}",
|
||||
receivable.symbol,
|
||||
receivable.ex_date,
|
||||
receivable.payable_date,
|
||||
receivable.amount,
|
||||
reinvest_quantity,
|
||||
price,
|
||||
residual_cash
|
||||
);
|
||||
report.fill_events.push(FillEvent {
|
||||
origin: crate::events::FillOrigin::DividendReinvestment,
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
execution_start_timestamp: date.and_hms_opt(0, 0, 0),
|
||||
execution_timestamp: date.and_hms_opt(0, 0, 0),
|
||||
order_id: None,
|
||||
symbol: receivable.symbol.clone(),
|
||||
side: OrderSide::Buy,
|
||||
quantity: reinvest_quantity,
|
||||
price,
|
||||
gross_amount: reinvest_cash,
|
||||
commission: 0.0,
|
||||
stamp_tax: 0.0,
|
||||
transfer_fee: 0.0,
|
||||
net_cash_flow: -reinvest_cash,
|
||||
reason: "dividend_reinvestment".to_string(),
|
||||
});
|
||||
report.position_events.push(PositionEvent {
|
||||
date,
|
||||
symbol: receivable.symbol.clone(),
|
||||
delta_quantity: reinvest_quantity as i32,
|
||||
quantity_after: portfolio
|
||||
.position(&receivable.symbol)
|
||||
.map(|position| position.quantity)
|
||||
.unwrap_or(0),
|
||||
average_cost: portfolio
|
||||
.position(&receivable.symbol)
|
||||
.map(|position| position.average_cost)
|
||||
.unwrap_or(0.0),
|
||||
realized_pnl_delta: 0.0,
|
||||
reason: "dividend_reinvestment".to_string(),
|
||||
});
|
||||
report.process_events.push(ProcessEvent {
|
||||
date,
|
||||
kind: ProcessEventKind::Trade,
|
||||
order_id: None,
|
||||
symbol: Some(receivable.symbol.clone()),
|
||||
side: Some(OrderSide::Buy),
|
||||
detail: format!("dividend_reinvestment model=fee_free_accounting booked_at={} quantity={} price={} reference_basis={} ex_date={} payable_date={} residual_cash={}",
|
||||
date.and_hms_opt(0,0,0).unwrap(), reinvest_quantity, price, reference_basis,
|
||||
receivable.ex_date, receivable.payable_date, residual_cash),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
notes.push(note.clone());
|
||||
report.account_events.push(AccountEvent {
|
||||
date,
|
||||
cash_before,
|
||||
cash_after: portfolio.cash(),
|
||||
total_equity: portfolio.total_equity(),
|
||||
note,
|
||||
});
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn date() -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 9, 14).unwrap()
|
||||
}
|
||||
fn data(delisted: bool) -> DataSet {
|
||||
DataSet::from_components(
|
||||
vec![crate::Instrument {
|
||||
symbol: "000001.SZ".into(),
|
||||
name: "fixture".into(),
|
||||
board: "SZ".into(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()),
|
||||
delisted_at: delisted.then_some(date()),
|
||||
status: "active".into(),
|
||||
}],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![crate::BenchmarkSnapshot {
|
||||
date: date(),
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 100.,
|
||||
close: 100.,
|
||||
prev_close: 100.,
|
||||
volume: 0,
|
||||
}],
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn book() -> PortfolioState {
|
||||
let mut book = PortfolioState::new(10.);
|
||||
book.add_cash_receivable(CashReceivable {
|
||||
symbol: "000001.SZ".into(),
|
||||
ex_date: date().pred_opt().unwrap(),
|
||||
payable_date: date(),
|
||||
amount: 100.,
|
||||
reason: "cash_dividend 1".into(),
|
||||
});
|
||||
book
|
||||
}
|
||||
#[test]
|
||||
fn missing_accounting_reference_is_atomic_not_a_silent_cash_only_success() {
|
||||
let mut book = book();
|
||||
let before = book.financial_replay_identity();
|
||||
let error =
|
||||
settle_receivables(date(), &data(false), &mut book, &mut Vec::new(), true).unwrap_err();
|
||||
assert!(error.to_string().contains("accounting reference missing"));
|
||||
assert_eq!(book.financial_replay_identity(), before);
|
||||
}
|
||||
#[test]
|
||||
fn terminated_security_keeps_paid_cash_and_is_not_recreated_by_reinvestment() {
|
||||
let mut book = book();
|
||||
let mut notes = Vec::new();
|
||||
let report = settle_receivables(date(), &data(true), &mut book, &mut notes, true).unwrap();
|
||||
assert_eq!(book.cash(), 110.);
|
||||
assert!(book.positions().is_empty());
|
||||
assert!(book.cash_receivables().is_empty());
|
||||
assert!(report.fill_events.is_empty());
|
||||
assert!(notes[0].contains("dividend_reinvestment_not_applied"));
|
||||
}
|
||||
|
||||
fn conversion() -> crate::CorporateAction {
|
||||
crate::CorporateAction {
|
||||
date: date(),
|
||||
symbol: "000001.SZ".into(),
|
||||
payable_date: None,
|
||||
share_cash: 0.,
|
||||
share_bonus: 0.,
|
||||
share_gift: 0.,
|
||||
issue_quantity: 0.,
|
||||
issue_price: 0.,
|
||||
reform: false,
|
||||
adjust_factor: None,
|
||||
successor_symbol: Some("000002.SZ".into()),
|
||||
successor_ratio: Some(1.5),
|
||||
successor_cash: Some(0.5),
|
||||
}
|
||||
}
|
||||
|
||||
fn conversion_data(actions: Vec<crate::CorporateAction>, include_successor: bool) -> DataSet {
|
||||
let mut instruments = data(false)
|
||||
.instruments()
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if include_successor {
|
||||
let mut successor = instruments[0].clone();
|
||||
successor.symbol = "000002.SZ".into();
|
||||
instruments.push(successor);
|
||||
}
|
||||
DataSet::from_components_with_actions(
|
||||
instruments,
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![crate::BenchmarkSnapshot {
|
||||
date: date(),
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 100.,
|
||||
close: 100.,
|
||||
prev_close: 100.,
|
||||
volume: 0,
|
||||
}],
|
||||
actions,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn conversion_book() -> PortfolioState {
|
||||
let mut book = PortfolioState::new(1000.);
|
||||
book.position_mut("000001.SZ")
|
||||
.buy(date().pred_opt().unwrap(), 100, 10.);
|
||||
book
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successor_without_frozen_instrument_metadata_is_not_an_implicit_new_security() {
|
||||
let mut action = conversion();
|
||||
action.share_cash = 1.;
|
||||
action.share_bonus = 1.;
|
||||
let data = conversion_data(vec![action], false);
|
||||
let mut book = conversion_book();
|
||||
let before = book.financial_replay_identity();
|
||||
let mut notes = vec!["prior".into()];
|
||||
let error = apply(date(), &data, &mut book, &mut notes, true, true).unwrap_err();
|
||||
assert!(error.to_string().contains("successor_instrument_missing"));
|
||||
assert_eq!(book.financial_replay_identity(), before);
|
||||
assert_eq!(notes, ["prior"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_successor_terms_cannot_be_replaced_with_one_share_or_zero_cash() {
|
||||
let base = conversion();
|
||||
let mut cases = Vec::new();
|
||||
for ratio in [
|
||||
None,
|
||||
Some(0.),
|
||||
Some(-1.),
|
||||
Some(f64::NAN),
|
||||
Some(f64::INFINITY),
|
||||
] {
|
||||
let mut row = base.clone();
|
||||
row.successor_ratio = ratio;
|
||||
cases.push(row);
|
||||
}
|
||||
for symbol in [
|
||||
None,
|
||||
Some(""),
|
||||
Some(" "),
|
||||
Some("000001.SZ"),
|
||||
Some(" 000002.SZ"),
|
||||
] {
|
||||
let mut row = base.clone();
|
||||
row.successor_symbol = symbol.map(str::to_owned);
|
||||
cases.push(row);
|
||||
}
|
||||
for cash in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let mut row = base.clone();
|
||||
row.successor_cash = Some(cash);
|
||||
cases.push(row);
|
||||
}
|
||||
for action in cases {
|
||||
let mut book = conversion_book();
|
||||
let before = book.financial_replay_identity();
|
||||
let data = conversion_data(vec![action.clone()], true);
|
||||
let mut notes = Vec::new();
|
||||
assert!(
|
||||
apply(date(), &data, &mut book, &mut notes, true, true).is_err(),
|
||||
"accepted {action:?}"
|
||||
);
|
||||
assert_eq!(book.financial_replay_identity(), before);
|
||||
assert!(notes.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_corporate_batch_failure_keeps_prior_cash_positions_and_notes() {
|
||||
let mut dividend = conversion();
|
||||
dividend.successor_symbol = None;
|
||||
dividend.successor_ratio = None;
|
||||
dividend.successor_cash = None;
|
||||
dividend.share_cash = 1.;
|
||||
dividend.share_bonus = 1.;
|
||||
let mut failure = conversion();
|
||||
failure.successor_cash = Some(1e100);
|
||||
let data = conversion_data(vec![dividend, failure], true);
|
||||
let mut book = conversion_book();
|
||||
let mut state = crate::stock_pool_state::StockPoolExecutionState {
|
||||
last_execution_date: date().pred_opt(),
|
||||
..Default::default()
|
||||
};
|
||||
state.position_action_bases.insert(
|
||||
"000001.SZ".into(),
|
||||
crate::stock_pool_state::StockPoolPositionActionBasis {
|
||||
generation: "original".into(),
|
||||
first_execution_date: date().pred_opt().unwrap(),
|
||||
quantity: rust_decimal::Decimal::from(100),
|
||||
},
|
||||
);
|
||||
state.last_target_weights.insert("000001.SZ".into(), 10000);
|
||||
book.set_stock_pool_execution_state("pool", state.clone())
|
||||
.unwrap();
|
||||
let before = book.financial_replay_identity();
|
||||
let mut notes = vec!["prior".into()];
|
||||
assert!(apply(date(), &data, &mut book, &mut notes, true, true).is_err());
|
||||
assert_eq!(book.financial_replay_identity(), before);
|
||||
assert_eq!(book.stock_pool_execution_state("pool"), state);
|
||||
assert_eq!(notes, ["prior"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corporate_quantity_overflow_fails_without_saturation_or_a_negative_delta() {
|
||||
for split in [false, true] {
|
||||
let mut action = conversion();
|
||||
if split {
|
||||
action.share_bonus = 1e100;
|
||||
} else {
|
||||
action.successor_ratio = Some(1e100);
|
||||
}
|
||||
let data = conversion_data(vec![action], true);
|
||||
let mut book = conversion_book();
|
||||
let before = book.financial_replay_identity();
|
||||
let mut notes = Vec::new();
|
||||
let error = apply(date(), &data, &mut book, &mut notes, true, true).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("corporate_action_quantity_overflow")
|
||||
);
|
||||
assert_eq!(book.financial_replay_identity(), before);
|
||||
assert!(notes.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verified_conversion_preserves_lots_without_creating_orders_or_fills() {
|
||||
let data = conversion_data(vec![conversion()], true);
|
||||
let mut book = conversion_book();
|
||||
book.position_mut("000002.SZ").buy(date(), 50, 20.);
|
||||
let mut notes = Vec::new();
|
||||
let report = apply(date(), &data, &mut book, &mut notes, true, true).unwrap();
|
||||
assert!(book.position("000001.SZ").is_none());
|
||||
let successor = book.position("000002.SZ").unwrap();
|
||||
assert_eq!(successor.quantity, 200);
|
||||
assert_eq!(successor.opened_date(), date().pred_opt());
|
||||
assert_eq!(successor.last_buy_date(), Some(date()));
|
||||
assert_eq!(book.cash(), 1050.);
|
||||
assert!(report.fill_events.is_empty());
|
||||
assert!(report.order_events.is_empty());
|
||||
assert_eq!(report.position_events.len(), 2);
|
||||
assert!(notes[0].contains("ratio=1.500000"));
|
||||
}
|
||||
}
|
||||
+170
-56
@@ -313,8 +313,8 @@ pub enum QuoteObservationKind {
|
||||
|
||||
/// Sparse same-day fields layered onto an already-built immutable daily panel.
|
||||
///
|
||||
/// These fields do not participate in daily price series, adjustment series,
|
||||
/// symbol indexes, or rolling windows. Applying them in place lets the runner
|
||||
/// These fields leave daily OHLC, adjustment series and symbol indexes intact,
|
||||
/// but update quote history and Last-price rolling windows. Applying them lets the runner
|
||||
/// reuse the candidate-planning `DataSet` as the final execution `DataSet`
|
||||
/// without rebuilding the full market panel.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -459,19 +459,29 @@ impl CorporateAction {
|
||||
self.successor_symbol
|
||||
.as_ref()
|
||||
.is_some_and(|symbol| !symbol.trim().is_empty())
|
||||
&& self.successor_ratio_value() > 0.0
|
||||
}
|
||||
|
||||
pub fn successor_ratio_value(&self) -> f64 {
|
||||
self.successor_ratio
|
||||
.filter(|ratio| ratio.is_finite() && *ratio > 0.0)
|
||||
.unwrap_or(1.0)
|
||||
/// A code mapping alone is not evidence for a 1:1 financial conversion.
|
||||
/// An absent cash component means no declared cash leg; an invalid one
|
||||
/// must never be replaced with zero.
|
||||
pub(crate) fn validated_successor_terms(&self) -> Result<Option<(&str, f64, f64)>, String> {
|
||||
let fail = |reason: &str| format!(
|
||||
"corporate_action_{reason}: symbol={} action_date={}", self.symbol, self.date);
|
||||
let Some(symbol) = self.successor_symbol.as_deref() else {
|
||||
if self.successor_ratio.is_some() || self.successor_cash.is_some() {
|
||||
return Err(fail("successor_symbol_missing"));
|
||||
}
|
||||
|
||||
pub fn successor_cash_value(&self) -> f64 {
|
||||
self.successor_cash
|
||||
.filter(|cash| cash.is_finite())
|
||||
.unwrap_or(0.0)
|
||||
return Ok(None);
|
||||
};
|
||||
if symbol.is_empty() || symbol.trim() != symbol || symbol == self.symbol
|
||||
|| self.symbol.is_empty() || self.symbol.trim() != self.symbol {
|
||||
return Err(fail("successor_symbol_invalid"));
|
||||
}
|
||||
let ratio = self.successor_ratio.filter(|ratio| ratio.is_finite() && *ratio > 0.)
|
||||
.ok_or_else(|| fail("successor_ratio_missing_or_invalid"))?;
|
||||
let cash = self.successor_cash.unwrap_or(0.);
|
||||
if !cash.is_finite() { return Err(fail("successor_cash_invalid")); }
|
||||
Ok(Some((symbol, ratio, cash)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,17 +607,21 @@ pub fn decision_free_float_cap_bn(factor: &DailyFactorSnapshot) -> f64 {
|
||||
#[derive(Debug, Clone)]
|
||||
struct SymbolPriceSeries {
|
||||
base: Arc<SymbolDailySeriesBase>,
|
||||
timestamps: Vec<Option<String>>,
|
||||
last_prices: Vec<f64>,
|
||||
bid1s: Vec<f64>,
|
||||
ask1s: Vec<f64>,
|
||||
minute_volumes: Vec<u64>,
|
||||
bid1_volumes: Vec<u64>,
|
||||
ask1_volumes: Vec<u64>,
|
||||
trading_phases: Vec<Option<String>>,
|
||||
last_prefix: Vec<f64>,
|
||||
timestamps: RepeatedValues<Option<String>>,
|
||||
last_prices: ReferenceMatchedValues,
|
||||
bid1s: ReferenceMatchedValues,
|
||||
ask1s: ReferenceMatchedValues,
|
||||
minute_volumes: RepeatedValues<u64>,
|
||||
bid1_volumes: RepeatedValues<u64>,
|
||||
ask1_volumes: RepeatedValues<u64>,
|
||||
trading_phases: RepeatedValues<Option<String>>,
|
||||
last_prefix: ReferenceMatchedValues,
|
||||
}
|
||||
|
||||
#[path = "series_columns.rs"]
|
||||
mod series_columns;
|
||||
use series_columns::{ReferenceMatchedValues, RepeatedValues};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SymbolDailySeriesBase {
|
||||
symbol: String,
|
||||
@@ -623,6 +637,7 @@ struct SymbolDailySeriesBase {
|
||||
upper_limits: Vec<f64>,
|
||||
lower_limits: Vec<f64>,
|
||||
price_ticks: Vec<f64>,
|
||||
day_open_prefix: Vec<f64>,
|
||||
open_prefix: Vec<f64>,
|
||||
close_prefix: Vec<f64>,
|
||||
prev_close_prefix: Vec<f64>,
|
||||
@@ -839,51 +854,52 @@ impl SymbolPriceSeries {
|
||||
);
|
||||
let row_count = rows.len();
|
||||
let mut dates = Vec::with_capacity(row_count);
|
||||
let mut timestamps = Vec::with_capacity(row_count);
|
||||
let mut timestamps = RepeatedValues::new();
|
||||
let mut day_opens = Vec::with_capacity(row_count);
|
||||
let mut opens = Vec::with_capacity(row_count);
|
||||
let mut highs = Vec::with_capacity(row_count);
|
||||
let mut lows = Vec::with_capacity(row_count);
|
||||
let mut closes = Vec::with_capacity(row_count);
|
||||
let mut prev_closes = Vec::with_capacity(row_count);
|
||||
let mut last_prices = Vec::with_capacity(row_count);
|
||||
let mut bid1s = Vec::with_capacity(row_count);
|
||||
let mut ask1s = Vec::with_capacity(row_count);
|
||||
let mut last_prices = ReferenceMatchedValues::Identical;
|
||||
let mut bid1s = ReferenceMatchedValues::Identical;
|
||||
let mut ask1s = ReferenceMatchedValues::Identical;
|
||||
let mut volumes = Vec::with_capacity(row_count);
|
||||
let mut minute_volumes = Vec::with_capacity(row_count);
|
||||
let mut bid1_volumes = Vec::with_capacity(row_count);
|
||||
let mut ask1_volumes = Vec::with_capacity(row_count);
|
||||
let mut trading_phases = Vec::with_capacity(row_count);
|
||||
let mut minute_volumes = RepeatedValues::new();
|
||||
let mut bid1_volumes = RepeatedValues::new();
|
||||
let mut ask1_volumes = RepeatedValues::new();
|
||||
let mut trading_phases = RepeatedValues::new();
|
||||
let mut paused = Vec::with_capacity(row_count);
|
||||
let mut upper_limits = Vec::with_capacity(row_count);
|
||||
let mut lower_limits = Vec::with_capacity(row_count);
|
||||
let mut price_ticks = Vec::with_capacity(row_count);
|
||||
for row in rows {
|
||||
dates.push(row.date);
|
||||
timestamps.push(row.timestamp.clone());
|
||||
timestamps.push(&row.timestamp, row_count);
|
||||
day_opens.push(row.day_open);
|
||||
opens.push(row.open);
|
||||
highs.push(row.high);
|
||||
lows.push(row.low);
|
||||
closes.push(row.close);
|
||||
prev_closes.push(row.prev_close);
|
||||
last_prices.push(row.last_price);
|
||||
bid1s.push(row.bid1);
|
||||
ask1s.push(row.ask1);
|
||||
last_prices.push(row.last_price, &closes, row_count);
|
||||
bid1s.push(row.bid1, &closes, row_count);
|
||||
ask1s.push(row.ask1, &closes, row_count);
|
||||
volumes.push(row.volume);
|
||||
minute_volumes.push(row.minute_volume);
|
||||
bid1_volumes.push(row.bid1_volume);
|
||||
ask1_volumes.push(row.ask1_volume);
|
||||
trading_phases.push(row.trading_phase.clone());
|
||||
minute_volumes.push(&row.minute_volume, row_count);
|
||||
bid1_volumes.push(&row.bid1_volume, row_count);
|
||||
ask1_volumes.push(&row.ask1_volume, row_count);
|
||||
trading_phases.push(&row.trading_phase, row_count);
|
||||
paused.push(row.paused);
|
||||
upper_limits.push(row.upper_limit);
|
||||
lower_limits.push(row.lower_limit);
|
||||
price_ticks.push(row.price_tick);
|
||||
}
|
||||
let day_open_prefix = prefix_sums(&day_opens);
|
||||
let open_prefix = prefix_sums(&opens);
|
||||
let close_prefix = prefix_sums(&closes);
|
||||
let prev_close_prefix = prefix_sums(&prev_closes);
|
||||
let last_prefix = prefix_sums(&last_prices);
|
||||
let last_prefix = last_prices.prefix();
|
||||
let mut valid_volume_sum_prefix = Vec::with_capacity(volumes.len() + 1);
|
||||
let mut valid_volume_count_prefix = Vec::with_capacity(volumes.len() + 1);
|
||||
valid_volume_sum_prefix.push(0.0);
|
||||
@@ -926,6 +942,7 @@ impl SymbolPriceSeries {
|
||||
upper_limits,
|
||||
lower_limits,
|
||||
price_ticks,
|
||||
day_open_prefix,
|
||||
open_prefix,
|
||||
close_prefix,
|
||||
prev_close_prefix,
|
||||
@@ -955,23 +972,23 @@ impl SymbolPriceSeries {
|
||||
.dates
|
||||
.binary_search(&overlay.date)
|
||||
.map_err(|_| overlay.date)?;
|
||||
self.timestamps[index] = overlay.timestamp.clone();
|
||||
self.timestamps.set(index, overlay.timestamp.clone());
|
||||
if let Some(last_price) = overlay
|
||||
.last_price
|
||||
.filter(|value| value.is_finite() && *value > 0.0)
|
||||
{
|
||||
self.last_prices[index] = last_price;
|
||||
self.last_prices.set(index, last_price, &self.base.closes);
|
||||
last_price_changed = true;
|
||||
}
|
||||
self.bid1s[index] = overlay.bid1;
|
||||
self.ask1s[index] = overlay.ask1;
|
||||
self.minute_volumes[index] = overlay.minute_volume;
|
||||
self.bid1_volumes[index] = overlay.bid1_volume;
|
||||
self.ask1_volumes[index] = overlay.ask1_volume;
|
||||
self.trading_phases[index] = overlay.trading_phase.clone();
|
||||
self.bid1s.set(index, overlay.bid1, &self.base.closes);
|
||||
self.ask1s.set(index, overlay.ask1, &self.base.closes);
|
||||
self.minute_volumes.set(index, overlay.minute_volume);
|
||||
self.bid1_volumes.set(index, overlay.bid1_volume);
|
||||
self.ask1_volumes.set(index, overlay.ask1_volume);
|
||||
self.trading_phases.set(index, overlay.trading_phase.clone());
|
||||
}
|
||||
if last_price_changed {
|
||||
self.last_prefix = prefix_sums(&self.last_prices);
|
||||
self.last_prefix = self.last_prices.prefix();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1191,7 +1208,7 @@ impl SymbolPriceSeries {
|
||||
PriceField::DayOpen => &self.day_opens,
|
||||
PriceField::Open => &self.opens,
|
||||
PriceField::Close => &self.closes,
|
||||
PriceField::Last => &self.last_prices,
|
||||
PriceField::Last => self.last_prices.values(&self.closes),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1205,10 +1222,10 @@ impl SymbolPriceSeries {
|
||||
|
||||
fn prefix_for(&self, field: PriceField) -> &[f64] {
|
||||
match field {
|
||||
PriceField::DayOpen => &self.open_prefix,
|
||||
PriceField::DayOpen => &self.day_open_prefix,
|
||||
PriceField::Open => &self.open_prefix,
|
||||
PriceField::Close => &self.close_prefix,
|
||||
PriceField::Last => &self.last_prefix,
|
||||
PriceField::Last => self.last_prefix.values(&self.close_prefix),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1222,9 +1239,9 @@ impl SymbolPriceSeries {
|
||||
high: self.highs[index],
|
||||
low: self.lows[index],
|
||||
close: self.closes[index],
|
||||
last_price: self.last_prices[index],
|
||||
bid1: self.bid1s[index],
|
||||
ask1: self.ask1s[index],
|
||||
last_price: self.last_prices.values(&self.closes)[index],
|
||||
bid1: self.bid1s.values(&self.closes)[index],
|
||||
ask1: self.ask1s.values(&self.closes)[index],
|
||||
prev_close: self.prev_closes[index],
|
||||
volume: self.volumes[index],
|
||||
minute_volume: self.minute_volumes[index],
|
||||
@@ -1245,12 +1262,12 @@ impl SymbolPriceSeries {
|
||||
"high" => Some(self.highs[index]),
|
||||
"low" => Some(self.lows[index]),
|
||||
"close" | "price" => Some(self.closes[index]),
|
||||
"last" | "last_price" => Some(self.last_prices[index]),
|
||||
"last" | "last_price" => Some(self.last_prices.values(&self.closes)[index]),
|
||||
"prev_close" | "pre_close" => Some(self.prev_closes[index]),
|
||||
"volume" => Some(self.volumes[index] as f64),
|
||||
"minute_volume" => Some(self.minute_volumes[index] as f64),
|
||||
"bid1" => Some(self.bid1s[index]),
|
||||
"ask1" => Some(self.ask1s[index]),
|
||||
"bid1" => Some(self.bid1s.values(&self.closes)[index]),
|
||||
"ask1" => Some(self.ask1s.values(&self.closes)[index]),
|
||||
"bid1_volume" => Some(self.bid1_volumes[index] as f64),
|
||||
"ask1_volume" => Some(self.ask1_volumes[index] as f64),
|
||||
"upper_limit" => Some(self.upper_limits[index]),
|
||||
@@ -6553,6 +6570,103 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn series_columns_preserve_full_snapshots_and_distinct_price_bits() {
|
||||
for mixed in [false, true] {
|
||||
let mut rows = (0..6).map(|index| {
|
||||
let date = NaiveDate::from_ymd_opt(2025, 1, 2 + index).unwrap();
|
||||
let mut row = market_row(&date.to_string(), 10. + index as f64, 1_000);
|
||||
row.minute_volume = 7;
|
||||
row.trading_phase = Some("continuous".to_string());
|
||||
row
|
||||
}).collect::<Vec<_>>();
|
||||
if mixed {
|
||||
rows[2].last_price = 0.;
|
||||
rows[3].bid1 = -0.;
|
||||
rows[4].ask1 = f64::from_bits(0x7ff8_0000_0000_0042);
|
||||
rows[4].timestamp = Some("2025-01-06 10:21:00".to_string());
|
||||
rows[4].trading_phase = None;
|
||||
rows[4].minute_volume = 10_000;
|
||||
}
|
||||
let series = SymbolPriceSeries::new("000001.SZ".to_string(), &rows);
|
||||
for (index, expected) in rows.iter().enumerate() {
|
||||
let actual = series.snapshot_at(index);
|
||||
assert_eq!(serde_json::to_value(&actual).unwrap(), serde_json::to_value(expected).unwrap());
|
||||
assert_eq!(actual.last_price.to_bits(), expected.last_price.to_bits());
|
||||
assert_eq!(actual.bid1.to_bits(), expected.bid1.to_bits());
|
||||
assert_eq!(actual.ask1.to_bits(), expected.ask1.to_bits());
|
||||
}
|
||||
let expected_prefix = prefix_sums(&rows.iter().map(|row| row.last_price).collect::<Vec<_>>());
|
||||
let bits = |values: &[f64]| values.iter().map(|value| value.to_bits()).collect::<Vec<_>>();
|
||||
assert_eq!(bits(series.prefix_for(PriceField::Last)), bits(&expected_prefix));
|
||||
if !mixed {
|
||||
assert!(matches!(series.last_prices, ReferenceMatchedValues::Identical));
|
||||
assert!(matches!(series.bid1s, ReferenceMatchedValues::Identical));
|
||||
assert!(matches!(series.ask1s, ReferenceMatchedValues::Identical));
|
||||
assert_eq!(series.price_values_for(PriceField::Last).as_ptr(), series.closes.as_ptr());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn series_overlay_materializes_only_changed_values_and_preserves_history_cutoff() {
|
||||
let rows = [
|
||||
market_row("2025-01-02", 10., 1_000),
|
||||
market_row("2025-01-03", 12., 2_000),
|
||||
market_row("2025-01-06", 14., 3_000),
|
||||
];
|
||||
let original = SymbolPriceSeries::new("000001.SZ".to_string(), &rows);
|
||||
let mut changed = original.clone();
|
||||
let overlay = IntradayMarketSnapshotOverlay {
|
||||
date: rows[2].date, symbol: "000001.SZ".to_string(),
|
||||
timestamp: Some("2025-01-06 13:20:00".to_string()), last_price: Some(15.),
|
||||
bid1: 14., ask1: 15.01, minute_volume: 30, bid1_volume: 20, ask1_volume: 10,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
};
|
||||
changed.apply_intraday_market_overlays(&[&overlay]).unwrap();
|
||||
assert!(Arc::ptr_eq(&original.base, &changed.base));
|
||||
assert!(matches!(original.last_prices, ReferenceMatchedValues::Identical));
|
||||
assert!(matches!(changed.last_prices, ReferenceMatchedValues::Owned(_)));
|
||||
assert!(matches!(changed.bid1s, ReferenceMatchedValues::Identical));
|
||||
let mut expected = rows[2].clone();
|
||||
expected.timestamp = overlay.timestamp.clone();
|
||||
expected.last_price = 15.;
|
||||
expected.bid1 = overlay.bid1;
|
||||
expected.ask1 = overlay.ask1;
|
||||
expected.minute_volume = overlay.minute_volume;
|
||||
expected.bid1_volume = overlay.bid1_volume;
|
||||
expected.ask1_volume = overlay.ask1_volume;
|
||||
expected.trading_phase = overlay.trading_phase.clone();
|
||||
assert_eq!(serde_json::to_value(changed.snapshot_at(2)).unwrap(), serde_json::to_value(expected).unwrap());
|
||||
assert_eq!(original.snapshot_at(2).last_price, 14.);
|
||||
assert_eq!(changed.moving_average(rows[1].date, 2, PriceField::Last), Some(11.));
|
||||
assert_eq!(changed.trailing_values(rows[1].date, 2, PriceField::Last), vec![10., 12.]);
|
||||
assert_eq!(changed.trailing_snapshots(rows[2].date, 2, false).len(), 2);
|
||||
assert_eq!(changed.trailing_numeric_values(rows[2].date, 2, "last", false), vec![10., 12.]);
|
||||
assert_eq!(changed.moving_average(rows[2].date, 2, PriceField::Last), Some(13.5));
|
||||
let mut unknown = overlay;
|
||||
unknown.date = NaiveDate::from_ymd_opt(2025, 2, 1).unwrap();
|
||||
assert_eq!(changed.apply_intraday_market_overlays(&[&unknown]), Err(unknown.date));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn day_open_moving_average_uses_its_own_historical_column() {
|
||||
let mut first = market_row("2025-01-02", 10.0, 100);
|
||||
first.day_open = 10.0;
|
||||
first.open = 20.0;
|
||||
let mut second = market_row("2025-01-03", 12.0, 200);
|
||||
second.day_open = 12.0;
|
||||
second.open = 24.0;
|
||||
let rows = [first, second];
|
||||
let series = SymbolPriceSeries::new("000001.SZ".to_string(), &rows);
|
||||
let date = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
|
||||
assert_eq!(series.trailing_values(date, 2, PriceField::DayOpen), vec![10.0, 12.0]);
|
||||
assert_eq!(series.moving_average(date, 2, PriceField::DayOpen), Some(11.0));
|
||||
assert_eq!(series.moving_average(date, 2, PriceField::Open), Some(22.0));
|
||||
assert_eq!(series.moving_average(date, 0, PriceField::DayOpen), None);
|
||||
assert_eq!(series.moving_average(date, 3, PriceField::DayOpen), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_price_series_test_constructor_sorts_unsorted_rows() {
|
||||
let series = SymbolPriceSeries::new(
|
||||
|
||||
+1934
-543
File diff suppressed because it is too large
Load Diff
@@ -181,8 +181,22 @@ impl OrderEvent {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FillOrigin {
|
||||
#[default]
|
||||
MarketExecution,
|
||||
DividendReinvestment,
|
||||
}
|
||||
|
||||
impl FillOrigin {
|
||||
pub fn is_market_execution(&self) -> bool { *self == Self::MarketExecution }
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FillEvent {
|
||||
#[serde(default, skip_serializing_if = "FillOrigin::is_market_execution")]
|
||||
pub origin: FillOrigin,
|
||||
#[serde(with = "date_format")]
|
||||
pub date: NaiveDate,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
@@ -219,6 +233,14 @@ pub struct FillEvent {
|
||||
|
||||
impl FillEvent {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.origin == FillOrigin::DividendReinvestment && (
|
||||
self.order_id.is_some() || self.side != OrderSide::Buy
|
||||
|| self.commission != 0. || self.stamp_tax != 0. || self.transfer_fee != 0.
|
||||
|| self.execution_timestamp != self.date.and_hms_opt(0, 0, 0)
|
||||
|| self.execution_start_timestamp != self.execution_timestamp
|
||||
) {
|
||||
return Err("dividend accounting allocation cannot carry an exchange order, fees, or a market clock".into());
|
||||
}
|
||||
if self.symbol.trim().is_empty()
|
||||
|| self.quantity == 0
|
||||
|| !self.price.is_finite()
|
||||
@@ -311,6 +333,7 @@ pub enum ProcessEventKind {
|
||||
OrderUpdateReject,
|
||||
OrderUnsolicitedUpdate,
|
||||
Trade,
|
||||
ManualExecutionObserved,
|
||||
UniverseUpdated,
|
||||
UniverseSubscribed,
|
||||
UniverseUnsubscribed,
|
||||
@@ -358,6 +381,7 @@ impl ProcessEventKind {
|
||||
Self::OrderUpdateReject => "order_update_reject",
|
||||
Self::OrderUnsolicitedUpdate => "order_unsolicited_update",
|
||||
Self::Trade => "trade",
|
||||
Self::ManualExecutionObserved => "manual_execution_observed",
|
||||
Self::UniverseUpdated => "universe_updated",
|
||||
Self::UniverseSubscribed => "universe_subscribed",
|
||||
Self::UniverseUnsubscribed => "universe_unsubscribed",
|
||||
@@ -391,6 +415,7 @@ impl ProcessEventKind {
|
||||
| Self::OrderUpdateReject
|
||||
| Self::OrderUnsolicitedUpdate
|
||||
| Self::Trade
|
||||
| Self::ManualExecutionObserved
|
||||
| Self::UniverseUpdated
|
||||
| Self::UniverseSubscribed
|
||||
| Self::UniverseUnsubscribed
|
||||
@@ -422,7 +447,7 @@ pub struct ProcessEvent {
|
||||
mod tests {
|
||||
use chrono::{NaiveDate, NaiveDateTime};
|
||||
|
||||
use super::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEventKind};
|
||||
use super::{FillEvent, FillOrigin, OrderEvent, OrderSide, OrderStatus, ProcessEventKind};
|
||||
|
||||
fn order_event(status: OrderStatus, filled_quantity: u32) -> OrderEvent {
|
||||
OrderEvent {
|
||||
@@ -466,6 +491,7 @@ mod tests {
|
||||
|
||||
fn fill_event(start: Option<NaiveDateTime>, end: Option<NaiveDateTime>) -> FillEvent {
|
||||
FillEvent {
|
||||
origin: crate::events::FillOrigin::MarketExecution,
|
||||
date: NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(),
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
@@ -486,6 +512,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounting_origin_cannot_disguise_an_exchange_order_or_fee() {
|
||||
let at = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap().and_hms_opt(0, 0, 0).unwrap();
|
||||
let mut fill = fill_event(Some(at), Some(at));
|
||||
fill.origin = FillOrigin::DividendReinvestment;
|
||||
fill.order_id = None;
|
||||
fill.commission = 0.;
|
||||
fill.net_cash_flow = -1000.;
|
||||
assert!(fill.validate().is_ok());
|
||||
for kind in 0..3 {
|
||||
let mut invalid = fill.clone();
|
||||
match kind {
|
||||
0 => invalid.order_id = Some(1),
|
||||
1 => invalid.commission = 1.,
|
||||
_ => { invalid.execution_timestamp = Some(at + chrono::Duration::hours(9)); invalid.execution_start_timestamp = invalid.execution_timestamp; }
|
||||
}
|
||||
assert!(invalid.validate().is_err());
|
||||
}
|
||||
assert!(serde_json::to_value(fill_event(None, None)).unwrap().get("origin").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_execution_timestamp_range_is_explicit_and_backward_compatible() {
|
||||
let start = NaiveDate::from_ymd_opt(2025, 1, 2)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
//! Check typed pending intent numbers before JSON could replace NaN/Inf with null.
|
||||
//! This traverses the original Serialize representation without materializing it.
|
||||
use serde::{Serialize, Serializer, ser};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Finite;
|
||||
|
||||
pub(crate) fn validate(value: &impl Serialize) -> Result<(), serde_json::Error> {
|
||||
value.serialize(Finite)
|
||||
}
|
||||
|
||||
macro_rules! scalar {
|
||||
($($method:ident: $ty:ty),* $(,)?) => {$(
|
||||
fn $method(self, _: $ty) -> Result<(), Self::Error> { Ok(()) }
|
||||
)*};
|
||||
}
|
||||
|
||||
impl Serializer for Finite {
|
||||
type Ok = ();
|
||||
type Error = serde_json::Error;
|
||||
type SerializeSeq = Self;
|
||||
type SerializeTuple = Self;
|
||||
type SerializeTupleStruct = Self;
|
||||
type SerializeTupleVariant = Self;
|
||||
type SerializeMap = Self;
|
||||
type SerializeStruct = Self;
|
||||
type SerializeStructVariant = Self;
|
||||
|
||||
scalar!(serialize_bool: bool, serialize_i8: i8, serialize_i16: i16,
|
||||
serialize_i32: i32, serialize_i64: i64, serialize_i128: i128,
|
||||
serialize_u8: u8, serialize_u16: u16, serialize_u32: u32,
|
||||
serialize_u64: u64, serialize_u128: u128, serialize_char: char,
|
||||
serialize_str: &str, serialize_bytes: &[u8]);
|
||||
fn serialize_f32(self, value: f32) -> Result<(), Self::Error> {
|
||||
self.serialize_f64(f64::from(value))
|
||||
}
|
||||
fn serialize_f64(self, value: f64) -> Result<(), Self::Error> {
|
||||
if value.is_finite() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ser::Error::custom(
|
||||
"pending strategy intent contains a non-finite number",
|
||||
))
|
||||
}
|
||||
}
|
||||
fn serialize_none(self) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
fn serialize_some<T: ?Sized + Serialize>(self, value: &T) -> Result<(), Self::Error> {
|
||||
value.serialize(self)
|
||||
}
|
||||
fn serialize_unit(self) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
fn serialize_unit_struct(self, _: &'static str) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
fn serialize_unit_variant(
|
||||
self,
|
||||
_: &'static str,
|
||||
_: u32,
|
||||
_: &'static str,
|
||||
) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
fn serialize_newtype_struct<T: ?Sized + Serialize>(
|
||||
self,
|
||||
_: &'static str,
|
||||
value: &T,
|
||||
) -> Result<(), Self::Error> {
|
||||
value.serialize(self)
|
||||
}
|
||||
fn serialize_newtype_variant<T: ?Sized + Serialize>(
|
||||
self,
|
||||
_: &'static str,
|
||||
_: u32,
|
||||
_: &'static str,
|
||||
value: &T,
|
||||
) -> Result<(), Self::Error> {
|
||||
value.serialize(self)
|
||||
}
|
||||
fn serialize_seq(self, _: Option<usize>) -> Result<Self, Self::Error> {
|
||||
Ok(self)
|
||||
}
|
||||
fn serialize_tuple(self, _: usize) -> Result<Self, Self::Error> {
|
||||
Ok(self)
|
||||
}
|
||||
fn serialize_tuple_struct(self, _: &'static str, _: usize) -> Result<Self, Self::Error> {
|
||||
Ok(self)
|
||||
}
|
||||
fn serialize_tuple_variant(
|
||||
self,
|
||||
_: &'static str,
|
||||
_: u32,
|
||||
_: &'static str,
|
||||
_: usize,
|
||||
) -> Result<Self, Self::Error> {
|
||||
Ok(self)
|
||||
}
|
||||
fn serialize_map(self, _: Option<usize>) -> Result<Self, Self::Error> {
|
||||
Ok(self)
|
||||
}
|
||||
fn serialize_struct(self, _: &'static str, _: usize) -> Result<Self, Self::Error> {
|
||||
Ok(self)
|
||||
}
|
||||
fn serialize_struct_variant(
|
||||
self,
|
||||
_: &'static str,
|
||||
_: u32,
|
||||
_: &'static str,
|
||||
_: usize,
|
||||
) -> Result<Self, Self::Error> {
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! sequence {
|
||||
($trait:ident, $method:ident) => {
|
||||
impl ser::$trait for Finite {
|
||||
type Ok = ();
|
||||
type Error = serde_json::Error;
|
||||
fn $method<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> {
|
||||
value.serialize(*self)
|
||||
}
|
||||
fn end(self) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
sequence!(SerializeSeq, serialize_element);
|
||||
sequence!(SerializeTuple, serialize_element);
|
||||
sequence!(SerializeTupleStruct, serialize_field);
|
||||
sequence!(SerializeTupleVariant, serialize_field);
|
||||
|
||||
impl ser::SerializeMap for Finite {
|
||||
type Ok = ();
|
||||
type Error = serde_json::Error;
|
||||
fn serialize_key<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> {
|
||||
value.serialize(*self)
|
||||
}
|
||||
fn serialize_value<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> {
|
||||
value.serialize(*self)
|
||||
}
|
||||
fn end(self) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! structure {
|
||||
($trait:ident) => {
|
||||
impl ser::$trait for Finite {
|
||||
type Ok = ();
|
||||
type Error = serde_json::Error;
|
||||
fn serialize_field<T: ?Sized + Serialize>(
|
||||
&mut self,
|
||||
_: &'static str,
|
||||
value: &T,
|
||||
) -> Result<(), Self::Error> {
|
||||
value.serialize(*self)
|
||||
}
|
||||
fn end(self) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
structure!(SerializeStruct);
|
||||
structure!(SerializeStructVariant);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::strategy::{OrderIntent, StrategyDecision};
|
||||
|
||||
#[test]
|
||||
fn pending_numbers_cannot_be_silently_serialized_as_optional_nulls() {
|
||||
for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let decision = StrategyDecision {
|
||||
order_intents: vec![
|
||||
OrderIntent::LimitTargetPercent {
|
||||
symbol: "000001.SZ".into(),
|
||||
target_percent: 0.5,
|
||||
limit_price: value,
|
||||
reason: "test".into(),
|
||||
}
|
||||
.with_time_in_force(crate::strategy::OrderTimeInForce::Day),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(validate(&decision).is_err());
|
||||
assert!(validate(&vec![Some(value)]).is_err());
|
||||
}
|
||||
assert!(validate(&(None::<f64>, vec![0., -0., 0.123456789], "NaN")).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,17 @@ impl FixedMoney {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn to_decimal_string(self) -> String {
|
||||
let magnitude = self.0.unsigned_abs();
|
||||
let scale = MONEY_SCALE as u128;
|
||||
let sign = if self.0 < 0 { "-" } else { "" };
|
||||
let width = MONEY_SCALE.ilog10() as usize;
|
||||
format!("{sign}{}.{:0width$}", magnitude / scale, magnitude % scale)
|
||||
.trim_end_matches('0')
|
||||
.trim_end_matches('.')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn from_decimal_str(value: &str) -> Result<Self, String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
|
||||
@@ -26,7 +26,7 @@ fn sum_futures_money(values: impl IntoIterator<Item = FixedMoney>, label: &str)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||
pub enum FuturesDirection {
|
||||
Long,
|
||||
Short,
|
||||
@@ -62,7 +62,7 @@ impl FuturesDirection {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub enum FuturesPositionEffect {
|
||||
Open,
|
||||
Close,
|
||||
@@ -81,7 +81,7 @@ impl FuturesPositionEffect {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
pub struct FuturesContractSpec {
|
||||
pub contract_multiplier: f64,
|
||||
pub long_margin_rate: f64,
|
||||
@@ -190,7 +190,7 @@ impl FuturesTransactionCostModel {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FuturesOrderIntent {
|
||||
pub symbol: String,
|
||||
pub direction: FuturesDirection,
|
||||
@@ -1048,6 +1048,7 @@ impl FuturesAccountState {
|
||||
)
|
||||
.to_f64();
|
||||
report.fill_events.push(FillEvent {
|
||||
origin: crate::events::FillOrigin::MarketExecution,
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
|
||||
@@ -63,7 +63,7 @@ pub struct HoldingLifecycleEvidence {
|
||||
pub last_sell_date: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct AutomaticTradePermission {
|
||||
pub buy_denial: Option<&'static str>,
|
||||
pub sell_denial: Option<&'static str>,
|
||||
@@ -120,10 +120,25 @@ impl AutomaticTradeProtection {
|
||||
execution_date: NaiveDate,
|
||||
evidence: &HoldingLifecycleEvidence,
|
||||
calendar: &TradingCalendar,
|
||||
) -> Result<AutomaticTradePermission, String> {
|
||||
self.evaluate_with_predecessors(symbol, execution_date, evidence, calendar, std::iter::empty())
|
||||
}
|
||||
|
||||
/// Only accept predecessors from validated, actually applied holding
|
||||
/// conversions. Catalog aliases or requested strategy symbols are not
|
||||
/// evidence that a configured lock covers another security.
|
||||
pub fn evaluate_with_predecessors<'a>(
|
||||
&self,
|
||||
symbol: &str,
|
||||
execution_date: NaiveDate,
|
||||
evidence: &HoldingLifecycleEvidence,
|
||||
calendar: &TradingCalendar,
|
||||
verified_predecessors: impl IntoIterator<Item = &'a str>,
|
||||
) -> Result<AutomaticTradePermission, String> {
|
||||
self.validate()?;
|
||||
let predecessors = verified_predecessors.into_iter().collect::<std::collections::BTreeSet<_>>();
|
||||
if self.locks.iter().any(|lock| {
|
||||
lock.symbol == symbol
|
||||
(lock.symbol == symbol || predecessors.contains(lock.symbol.as_str()))
|
||||
&& lock.start_date <= execution_date
|
||||
&& lock.end_date.is_none_or(|end| execution_date <= end)
|
||||
}) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod broker;
|
||||
pub mod calendar;
|
||||
pub mod cost;
|
||||
mod corporate_book;
|
||||
pub mod data;
|
||||
mod numeric_factors;
|
||||
pub mod daily_patterns;
|
||||
@@ -17,9 +18,12 @@ pub mod engine;
|
||||
pub mod event_bus;
|
||||
pub mod events;
|
||||
pub mod fixed_point;
|
||||
mod finite_serialization;
|
||||
pub mod futures;
|
||||
pub mod instrument;
|
||||
pub mod metrics;
|
||||
pub mod manual_execution;
|
||||
mod manual_corporate_replay;
|
||||
mod numeric_expr_vm;
|
||||
pub mod platform_expr_strategy;
|
||||
pub mod platform_runtime_schema;
|
||||
@@ -61,11 +65,11 @@ pub use engine::{
|
||||
AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError,
|
||||
BacktestResult, BacktestTerminalAssetClass, BacktestTerminalAudit, BacktestTerminalOpenOrder,
|
||||
BacktestTerminalStatus, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig,
|
||||
ProcessEventRetention, backtest_execution_dates,
|
||||
ProcessEventRetention, backtest_execution_dates, backtest_execution_dates_with_rules,
|
||||
};
|
||||
pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus};
|
||||
pub use events::{
|
||||
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
||||
AccountEvent, FillEvent, FillOrigin, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
||||
ProcessEventKind,
|
||||
};
|
||||
pub use fixed_point::{
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, Utc};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::manual_execution::{
|
||||
AppliedManualFill, ManualCorporateActionReference, ManualCorporateAdjustment,
|
||||
ManualCorporatePositionChange, ManualExecutionReplay, ManualFillObservation,
|
||||
};
|
||||
use crate::{DataSet, FillEvent, FixedMoney, MatchingType, OrderSide, PortfolioState, PriceField};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CashEffect {
|
||||
at: NaiveDateTime,
|
||||
amount: FixedMoney,
|
||||
}
|
||||
|
||||
/// Replays issued financial facts, never strategy callbacks or hypothetical orders.
|
||||
/// The ordinary book remains observable until an actual receipt is delivered.
|
||||
pub(crate) struct ManualCorporateReplay {
|
||||
first_date: NaiveDate,
|
||||
sessions: RefCell<BTreeSet<NaiveDate>>,
|
||||
closed: RefCell<BTreeSet<NaiveDate>>,
|
||||
cash: RefCell<Vec<CashEffect>>,
|
||||
reconciled_count: Cell<usize>,
|
||||
cash_dividends: bool,
|
||||
adjust_cost: bool,
|
||||
reinvest: bool,
|
||||
matching: MatchingType,
|
||||
daily_price: PriceField,
|
||||
same_day_mark_at_fill: bool,
|
||||
}
|
||||
|
||||
impl ManualCorporateReplay {
|
||||
pub(crate) fn new(
|
||||
first_date: NaiveDate,
|
||||
cash_dividends: bool,
|
||||
adjust_cost: bool,
|
||||
reinvest: bool,
|
||||
matching: MatchingType,
|
||||
daily_price: PriceField,
|
||||
same_day_mark_at_fill: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
first_date,
|
||||
sessions: RefCell::new(BTreeSet::new()),
|
||||
closed: RefCell::new(BTreeSet::new()),
|
||||
cash: RefCell::new(Vec::new()),
|
||||
reconciled_count: Cell::new(0),
|
||||
cash_dividends,
|
||||
adjust_cost,
|
||||
reinvest,
|
||||
matching,
|
||||
daily_price,
|
||||
same_day_mark_at_fill,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_session(&self, date: NaiveDate) {
|
||||
self.sessions.borrow_mut().insert(date);
|
||||
}
|
||||
pub(crate) fn record_close(&self, date: NaiveDate) {
|
||||
self.closed.borrow_mut().insert(date);
|
||||
}
|
||||
pub(crate) fn committed(&self, count: usize) {
|
||||
self.reconciled_count.set(count);
|
||||
}
|
||||
|
||||
pub(crate) fn record_cash(
|
||||
&self,
|
||||
at: Option<NaiveDateTime>,
|
||||
before: FixedMoney,
|
||||
after: FixedMoney,
|
||||
) -> Result<(), String> {
|
||||
let amount = after
|
||||
.checked_sub(before)
|
||||
.ok_or("manual corporate cash observation overflow")?;
|
||||
if amount != FixedMoney::ZERO {
|
||||
self.cash.borrow_mut().push(CashEffect {
|
||||
at: at.ok_or("manual corporate cash observation has no execution clock")?,
|
||||
amount,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn action_references(
|
||||
&self,
|
||||
observation: ManualFillObservation<'_>,
|
||||
data: &DataSet,
|
||||
) -> Result<Vec<ManualCorporateActionReference>, String> {
|
||||
let mut symbols = BTreeSet::from([observation.order.symbol.clone()]);
|
||||
let observed_date = local(observation.fill.observed_at).date();
|
||||
let mut actions = Vec::new();
|
||||
for date in self.sessions.borrow().range((
|
||||
std::ops::Bound::Excluded(observation.fill.trade_date),
|
||||
std::ops::Bound::Included(observed_date),
|
||||
)) {
|
||||
for action in data.corporate_actions_on(*date) {
|
||||
if !symbols.contains(&action.symbol) {
|
||||
continue;
|
||||
}
|
||||
let successor_terms = crate::corporate_book::validate_action(action, data)?;
|
||||
let effective = (action.split_ratio() - 1.).abs() > f64::EPSILON
|
||||
|| action.has_successor_conversion()
|
||||
|| (self.cash_dividends && action.share_cash.abs() > f64::EPSILON);
|
||||
if !effective {
|
||||
continue;
|
||||
}
|
||||
if let Some((successor, _, _)) = successor_terms {
|
||||
symbols.insert(successor.to_owned());
|
||||
}
|
||||
actions.push(ManualCorporateActionReference {
|
||||
date: *date,
|
||||
symbol: action.symbol.clone(),
|
||||
successor_symbol: action.successor_symbol.clone(),
|
||||
share_cash: action.share_cash.to_string(),
|
||||
split_ratio: action.split_ratio().to_string(),
|
||||
successor_ratio: action.successor_ratio.map(|value| value.to_string()),
|
||||
successor_cash: action.successor_cash.map(|value| value.to_string()),
|
||||
sha256: digest(
|
||||
&serde_json::to_value(action).map_err(|error| error.to_string())?,
|
||||
)?,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(actions)
|
||||
}
|
||||
|
||||
pub(crate) fn required(
|
||||
&self,
|
||||
observation: ManualFillObservation<'_>,
|
||||
data: &DataSet,
|
||||
) -> Result<bool, String> {
|
||||
Ok(!self.action_references(observation, data)?.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn project(
|
||||
&self,
|
||||
source: &ManualExecutionReplay,
|
||||
applied_count: usize,
|
||||
observation: ManualFillObservation<'_>,
|
||||
current: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
fills: &[FillEvent],
|
||||
has_pending: bool,
|
||||
) -> Result<(AppliedManualFill, ManualCorporateAdjustment), String> {
|
||||
if has_pending {
|
||||
return Err("manual observation conflicts with pending shadow orders".into());
|
||||
}
|
||||
let all = source.observations()?;
|
||||
if all.get(applied_count).is_none_or(|next| {
|
||||
next.fill.observation_event_id != observation.fill.observation_event_id
|
||||
}) {
|
||||
return Err(
|
||||
"manual corporate observation prefix differs from the immutable trace".into(),
|
||||
);
|
||||
}
|
||||
let actions = self.action_references(observation, data)?;
|
||||
if actions.is_empty() {
|
||||
return Err("manual corporate projection has no processed corporate action".into());
|
||||
}
|
||||
let at = local(observation.fill.observed_at);
|
||||
let reference = self.replay(
|
||||
current.initial_cash_fixed(),
|
||||
&all[..applied_count],
|
||||
self.reconciled_count.get(),
|
||||
data,
|
||||
fills,
|
||||
at,
|
||||
)?;
|
||||
let expected = current.financial_replay_identity();
|
||||
let reference_identity = reference.financial_replay_identity();
|
||||
if reference_identity != expected {
|
||||
return Err(format!(
|
||||
"manual corporate ledger coverage mismatch: symbol={} observed_at={} expected={} replayed={}",
|
||||
observation.order.symbol,
|
||||
observation.fill.observed_at,
|
||||
digest(&expected)?,
|
||||
digest(&reference_identity)?
|
||||
));
|
||||
}
|
||||
let replayed = self.replay(
|
||||
current.initial_cash_fixed(),
|
||||
&all[..=applied_count],
|
||||
applied_count + 1,
|
||||
data,
|
||||
fills,
|
||||
at,
|
||||
)?;
|
||||
let replayed_identity = replayed.financial_replay_identity();
|
||||
let gross = FixedMoney::from_decimal_str(&observation.fill.gross_amount()?.to_string())?;
|
||||
let fees = FixedMoney::from_decimal_str(&observation.fill.total_fees()?.to_string())?;
|
||||
let cash_delta = match observation.order.side {
|
||||
OrderSide::Buy => gross.checked_add(fees).and_then(FixedMoney::checked_neg),
|
||||
OrderSide::Sell => gross.checked_sub(fees),
|
||||
}
|
||||
.ok_or("manual corporate trade cash overflow")?;
|
||||
let before = current.cash_fixed();
|
||||
let after = replayed.cash_fixed();
|
||||
let corporate_cash = after
|
||||
.checked_sub(before)
|
||||
.and_then(|delta| delta.checked_sub(cash_delta))
|
||||
.ok_or("manual corporate adjustment overflow")?;
|
||||
let symbols = current
|
||||
.positions()
|
||||
.keys()
|
||||
.chain(replayed.positions().keys())
|
||||
.cloned()
|
||||
.chain(std::iter::once(observation.order.symbol.clone()))
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut positions = BTreeMap::new();
|
||||
for symbol in symbols {
|
||||
let change = ManualCorporatePositionChange {
|
||||
quantity_before: current
|
||||
.position(&symbol)
|
||||
.map_or(0, |position| position.quantity),
|
||||
quantity_after: replayed
|
||||
.position(&symbol)
|
||||
.map_or(0, |position| position.quantity),
|
||||
cost_basis_before: current
|
||||
.financial_position_basis(&symbol)
|
||||
.to_decimal_string(),
|
||||
cost_basis_after: replayed
|
||||
.financial_position_basis(&symbol)
|
||||
.to_decimal_string(),
|
||||
};
|
||||
if change.quantity_before != change.quantity_after
|
||||
|| change.cost_basis_before != change.cost_basis_after
|
||||
{
|
||||
positions.insert(symbol, change);
|
||||
}
|
||||
}
|
||||
let quantity_after = replayed
|
||||
.position(&observation.order.symbol)
|
||||
.map_or(0, |position| position.quantity);
|
||||
let adjustment = ManualCorporateAdjustment {
|
||||
schema: "fidc.manual-corporate-adjustment/v1".into(),
|
||||
observed_at: observation.fill.observed_at,
|
||||
cash_dividends_enabled: self.cash_dividends,
|
||||
dividend_cost_basis_adjustment: self.adjust_cost,
|
||||
dividend_reinvestment: self.reinvest,
|
||||
actions,
|
||||
cash_before: before.to_decimal_string(),
|
||||
cash_after: after.to_decimal_string(),
|
||||
corporate_cash_delta: corporate_cash.to_decimal_string(),
|
||||
positions,
|
||||
reference_sha256: digest(&reference_identity)?,
|
||||
replayed_sha256: digest(&replayed_identity)?,
|
||||
};
|
||||
current.replace_replayed_financial_book(replayed)?;
|
||||
Ok((
|
||||
AppliedManualFill {
|
||||
gross,
|
||||
fees,
|
||||
cash_delta,
|
||||
quantity_after,
|
||||
},
|
||||
adjustment,
|
||||
))
|
||||
}
|
||||
|
||||
fn replay(
|
||||
&self,
|
||||
initial_cash: FixedMoney,
|
||||
manual: &[ManualFillObservation<'_>],
|
||||
economic_count: usize,
|
||||
data: &DataSet,
|
||||
fills: &[FillEvent],
|
||||
at: NaiveDateTime,
|
||||
) -> Result<PortfolioState, String> {
|
||||
enum Event<'a> {
|
||||
Session,
|
||||
Cash(&'a CashEffect),
|
||||
Corporate(NaiveDate),
|
||||
Settle(NaiveDate),
|
||||
Manual(ManualFillObservation<'a>),
|
||||
Simulated(&'a FillEvent),
|
||||
Close(NaiveDate),
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
let sessions = self.sessions.borrow();
|
||||
let closed = self.closed.borrow();
|
||||
let cash = self.cash.borrow();
|
||||
for date in &*sessions {
|
||||
let clock = date.and_hms_opt(0, 0, 0).unwrap();
|
||||
events.push((clock, 0, 0, Event::Session));
|
||||
events.push((clock, 2, 0, Event::Corporate(*date)));
|
||||
events.push((clock, 3, 0, Event::Settle(*date)));
|
||||
}
|
||||
for (index, effect) in cash.iter().enumerate() {
|
||||
events.push((effect.at, 1, index, Event::Cash(effect)));
|
||||
}
|
||||
for (index, observation) in manual.iter().enumerate() {
|
||||
let clock = if index < economic_count {
|
||||
local(observation.fill.executed_at)
|
||||
} else {
|
||||
local(observation.fill.observed_at)
|
||||
};
|
||||
if clock.date() < self.first_date {
|
||||
return Err("manual corporate execution precedes the represented initial ledger; opening facts are required".into());
|
||||
}
|
||||
events.push((clock, 4, fills.len() + index, Event::Manual(*observation)));
|
||||
}
|
||||
for (index, fill) in fills.iter().enumerate() {
|
||||
fill.validate()?;
|
||||
if fill.origin == crate::events::FillOrigin::DividendReinvestment {
|
||||
// The declared accounting model is recalculated from the
|
||||
// corrected entitlements; this was never a submitted order.
|
||||
continue;
|
||||
}
|
||||
let date = fill.execution_date.unwrap_or(fill.date);
|
||||
// This is the frozen daily matching model, not a broker timestamp.
|
||||
// Intraday contracts must supply their actual execution clock.
|
||||
let clock = match fill.execution_timestamp {
|
||||
Some(clock) => clock,
|
||||
None if matches!(
|
||||
self.matching,
|
||||
MatchingType::OpenAuction | MatchingType::NextBarOpen
|
||||
) =>
|
||||
{
|
||||
date.and_hms_opt(9, 30, 0).unwrap()
|
||||
}
|
||||
None if self.matching == MatchingType::CurrentBarClose
|
||||
&& self.daily_price == PriceField::Close =>
|
||||
{
|
||||
date.and_hms_opt(15, 0, 0).unwrap()
|
||||
}
|
||||
None => {
|
||||
return Err(
|
||||
"manual corporate replay lacks an intraday fill execution clock".into(),
|
||||
);
|
||||
}
|
||||
};
|
||||
events.push((clock, 4, index, Event::Simulated(fill)));
|
||||
}
|
||||
for date in &*closed {
|
||||
events.push((
|
||||
date.and_hms_nano_opt(23, 59, 59, 999_999_999)
|
||||
.unwrap()
|
||||
.min(at),
|
||||
5,
|
||||
0,
|
||||
Event::Close(*date),
|
||||
));
|
||||
}
|
||||
events.sort_by_key(|(clock, priority, sequence, _)| (*clock, *priority, *sequence));
|
||||
let mut book = PortfolioState::from_fixed_initial_cash(initial_cash);
|
||||
for (clock, _, _, event) in events {
|
||||
if clock > at {
|
||||
return Err("manual corporate replay contains a future financial fact".into());
|
||||
}
|
||||
match event {
|
||||
Event::Session => book.begin_trading_day(),
|
||||
Event::Cash(effect) => {
|
||||
book.apply_cash_delta_fixed(effect.amount)?;
|
||||
if book.cash_fixed() < FixedMoney::ZERO {
|
||||
return Err(
|
||||
"manual corporate replay conflicts with prior cash facts".into()
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::Corporate(date) => {
|
||||
crate::corporate_book::apply(
|
||||
date,
|
||||
data,
|
||||
&mut book,
|
||||
&mut Vec::new(),
|
||||
self.cash_dividends,
|
||||
self.adjust_cost,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
Event::Settle(date) => {
|
||||
crate::corporate_book::settle_receivables(date, data, &mut book, &mut Vec::new(), self.reinvest)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
Event::Manual(observation) => {
|
||||
observation.apply(&mut book, data, false)?;
|
||||
}
|
||||
Event::Simulated(fill) => {
|
||||
let gross = FixedMoney::from_f64(fill.gross_amount)
|
||||
.ok_or("invalid simulated gross amount")?;
|
||||
let fees = FixedMoney::checked_sum_f64([
|
||||
fill.commission,
|
||||
fill.stamp_tax,
|
||||
fill.transfer_fee,
|
||||
])
|
||||
.ok_or("invalid simulated fee amount")?;
|
||||
book.apply_observed_manual_fill(
|
||||
fill.execution_date.unwrap_or(fill.date),
|
||||
&fill.symbol,
|
||||
fill.side,
|
||||
fill.quantity,
|
||||
fill.price,
|
||||
fill.price,
|
||||
gross,
|
||||
fees,
|
||||
)?;
|
||||
book.prune_flat_positions();
|
||||
}
|
||||
Event::Close(date) => {
|
||||
book.update_prices_with_options(
|
||||
date,
|
||||
data,
|
||||
PriceField::Close,
|
||||
self.same_day_mark_at_fill,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
}
|
||||
if book.cash_fixed() < FixedMoney::ZERO {
|
||||
return Err("manual corporate replay would borrow unobserved cash".into());
|
||||
}
|
||||
}
|
||||
Ok(book)
|
||||
}
|
||||
}
|
||||
|
||||
fn local(value: DateTime<Utc>) -> NaiveDateTime {
|
||||
value
|
||||
.with_timezone(&FixedOffset::east_opt(8 * 3600).unwrap())
|
||||
.naive_local()
|
||||
}
|
||||
fn digest(value: &serde_json::Value) -> Result<String, String> {
|
||||
Ok(format!(
|
||||
"{:x}",
|
||||
Sha256::digest(serde_json::to_vec(value).map_err(|error| error.to_string())?)
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,748 @@
|
||||
//! Confirmed manual fills are external observations, not simulated broker fills.
|
||||
//! The producer must bind these records to the runtime's durable order/audit facts.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use chrono::{DateTime, FixedOffset, NaiveDate, Timelike, Utc};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::events::OrderSide;
|
||||
use crate::{DataSet, FixedMoney, PortfolioState};
|
||||
use rust_decimal::prelude::ToPrimitive;
|
||||
|
||||
pub const MANUAL_REPLAY_SCHEMA: &str = "fidc.observed-manual-executions/v3";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct ManualExecutionReplay {
|
||||
pub schema: String,
|
||||
pub runtime_id: String,
|
||||
pub account_id: String,
|
||||
pub source_contract_sha256: String,
|
||||
pub content_sha256: String,
|
||||
pub observation_cutoff: DateTime<Utc>,
|
||||
pub actions: Vec<ManualExecutionAction>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub position_exposure_events: Vec<crate::position_exposure::PositionExposureEvent>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub legacy_position_exposure_bps: BTreeMap<NaiveDate, i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct ManualExecutionAction {
|
||||
pub action_id: String,
|
||||
pub source: ManualExecutionSource,
|
||||
pub audit_event_ids: Vec<String>,
|
||||
pub confirmed_at: DateTime<Utc>,
|
||||
pub confirmation_observed_at: DateTime<Utc>,
|
||||
pub outcome: ManualActionOutcome,
|
||||
pub orders: Vec<ManualExecutionOrder>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ManualActionOutcome {
|
||||
NoOrdersNeeded,
|
||||
NotExecuted,
|
||||
OrdersTerminal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ManualExecutionSource {
|
||||
ManualSecurityTrade,
|
||||
ManualPositionAction,
|
||||
ManualRebalance,
|
||||
StockPoolAllocation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct ManualExecutionOrder {
|
||||
pub order_id: String,
|
||||
pub broker_order_id: Option<String>,
|
||||
pub source_adapter: Option<String>,
|
||||
pub symbol: String,
|
||||
pub side: OrderSide,
|
||||
pub quantity: u32,
|
||||
pub order_created_at: DateTime<Utc>,
|
||||
pub terminal_observed_at: DateTime<Utc>,
|
||||
pub terminal_status: ManualOrderTerminalStatus,
|
||||
pub fills: Vec<ManualExecutionFill>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ManualOrderTerminalStatus {
|
||||
Filled,
|
||||
Cancelled,
|
||||
Rejected,
|
||||
Expired,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct ManualExecutionFill {
|
||||
pub trade_id: String,
|
||||
pub observation_event_id: String,
|
||||
pub observation_sequence: u64,
|
||||
pub fee_observation_event_id: String,
|
||||
pub fee_observation_sequence: u64,
|
||||
pub fee_observed_at: DateTime<Utc>,
|
||||
pub trade_date: NaiveDate,
|
||||
pub executed_at: DateTime<Utc>,
|
||||
pub observed_at: DateTime<Utc>,
|
||||
pub timestamp_precision: ManualTimestampPrecision,
|
||||
pub quantity: u32,
|
||||
#[serde(with = "rust_decimal::serde::str")]
|
||||
pub price: Decimal,
|
||||
#[serde(default, with = "rust_decimal::serde::str_option")]
|
||||
pub commission: Option<Decimal>,
|
||||
#[serde(default, with = "rust_decimal::serde::str_option")]
|
||||
pub stamp_tax: Option<Decimal>,
|
||||
#[serde(default, with = "rust_decimal::serde::str_option")]
|
||||
pub transfer_fee: Option<Decimal>,
|
||||
/// Full observed charge, including any venue fees not itemized above.
|
||||
#[serde(with = "rust_decimal::serde::str")]
|
||||
pub total_fee: Decimal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ManualTimestampPrecision {
|
||||
Second,
|
||||
Millisecond,
|
||||
Microsecond,
|
||||
Nanosecond,
|
||||
}
|
||||
|
||||
impl ManualTimestampPrecision {
|
||||
fn nanoseconds(self) -> i64 {
|
||||
match self {
|
||||
Self::Second => 1_000_000_000,
|
||||
Self::Millisecond => 1_000_000,
|
||||
Self::Microsecond => 1_000,
|
||||
Self::Nanosecond => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ManualExecutionFill {
|
||||
pub fn gross_amount(&self) -> Result<Decimal, String> {
|
||||
self.price
|
||||
.checked_mul(Decimal::from(self.quantity))
|
||||
.ok_or_else(|| "manual fill gross amount overflow".into())
|
||||
}
|
||||
|
||||
pub fn total_fees(&self) -> Result<Decimal, String> {
|
||||
let known = [self.commission, self.stamp_tax, self.transfer_fee]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.try_fold(Decimal::ZERO, |sum, fee| {
|
||||
if fee < Decimal::ZERO {
|
||||
return Err("manual fill fee component is negative");
|
||||
}
|
||||
sum.checked_add(fee).ok_or("manual fill fees overflow")
|
||||
})?;
|
||||
if self.total_fee < known {
|
||||
return Err("manual total fee is below its known components".into());
|
||||
}
|
||||
Ok(self.total_fee)
|
||||
}
|
||||
}
|
||||
|
||||
fn identifier(value: &str) -> Result<(), String> {
|
||||
if value.is_empty()
|
||||
|| value.trim() != value
|
||||
|| value.len() > 256
|
||||
|| value.chars().any(char::is_control)
|
||||
{
|
||||
return Err("manual execution identity is empty, untrimmed or invalid".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl ManualExecutionReplay {
|
||||
/// Market/indicator data is needed for securities whose observed fills
|
||||
/// change the portfolio. A rejected, never-filled order is not data demand.
|
||||
pub fn required_data_symbols(&self) -> Result<BTreeSet<String>, String> {
|
||||
self.validate()?;
|
||||
Ok(self
|
||||
.actions
|
||||
.iter()
|
||||
.flat_map(|action| &action.orders)
|
||||
.filter(|order| !order.fills.is_empty())
|
||||
.map(|order| order.symbol.clone())
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn observations(&self) -> Result<Vec<ManualFillObservation<'_>>, String> {
|
||||
self.validate()?;
|
||||
let mut observations = Vec::new();
|
||||
for action in &self.actions {
|
||||
for order in &action.orders {
|
||||
for fill in &order.fills {
|
||||
observations.push(ManualFillObservation {
|
||||
action,
|
||||
order,
|
||||
fill,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
observations.sort_by_key(|entry| (entry.fill.observed_at, entry.fill.observation_sequence));
|
||||
Ok(observations)
|
||||
}
|
||||
pub fn content_digest(&self) -> Result<String, String> {
|
||||
let mut value = serde_json::to_value(self).map_err(|error| error.to_string())?;
|
||||
value
|
||||
.as_object_mut()
|
||||
.ok_or("manual replay is not an object")?
|
||||
.remove("contentSha256");
|
||||
let bytes = serde_json::to_vec(&value).map_err(|error| error.to_string())?;
|
||||
Ok(format!("{:x}", Sha256::digest(bytes)))
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.schema != MANUAL_REPLAY_SCHEMA
|
||||
&& self.schema != "fidc.observed-manual-executions/v2"
|
||||
{
|
||||
return Err("unsupported manual replay schema".into());
|
||||
}
|
||||
if self.schema == "fidc.observed-manual-executions/v2"
|
||||
&& (!self.position_exposure_events.is_empty()
|
||||
|| !self.legacy_position_exposure_bps.is_empty())
|
||||
{
|
||||
return Err("runtime configuration requires manual replay v3".into());
|
||||
}
|
||||
crate::position_exposure::PositionExposureTimeline::from_events(
|
||||
&self.position_exposure_events,
|
||||
)?;
|
||||
if self.position_exposure_events.iter().any(|event| event.effective_at > self.observation_cutoff) {
|
||||
return Err("observed runtime position event is after the evidence cutoff".into());
|
||||
}
|
||||
if self
|
||||
.legacy_position_exposure_bps
|
||||
.values()
|
||||
.any(|value| !(0..=10000).contains(value))
|
||||
{
|
||||
return Err("legacy manual exposure is outside 0..10000 bps".into());
|
||||
}
|
||||
identifier(&self.runtime_id)?;
|
||||
identifier(&self.account_id)?;
|
||||
if self.source_contract_sha256.len() != 64
|
||||
|| !self
|
||||
.source_contract_sha256
|
||||
.bytes()
|
||||
.all(|v| v.is_ascii_hexdigit())
|
||||
{
|
||||
return Err("manual replay source contract hash is invalid".into());
|
||||
}
|
||||
if self.content_digest()? != self.content_sha256 {
|
||||
return Err("manual replay content digest mismatch".into());
|
||||
}
|
||||
if self.actions.len() > 100_000 {
|
||||
return Err("manual replay action limit exceeded; trace was not truncated".into());
|
||||
}
|
||||
let shanghai = FixedOffset::east_opt(8 * 3600).unwrap();
|
||||
let mut actions = BTreeSet::new();
|
||||
let mut audits = BTreeSet::new();
|
||||
let mut orders = BTreeSet::new();
|
||||
let mut broker_orders = BTreeSet::new();
|
||||
let mut trades = BTreeSet::new();
|
||||
let mut observation_events = BTreeSet::new();
|
||||
let mut observation_sequences = BTreeSet::new();
|
||||
let mut fee_observations = BTreeSet::new();
|
||||
let mut receipt_ids = BTreeMap::new();
|
||||
let mut receipt_sequences = BTreeMap::new();
|
||||
for action in &self.actions {
|
||||
identifier(&action.action_id)?;
|
||||
if !actions.insert(action.action_id.as_str())
|
||||
|| action.confirmed_at > self.observation_cutoff
|
||||
|| action.confirmation_observed_at < action.confirmed_at
|
||||
|| action.confirmation_observed_at > self.observation_cutoff
|
||||
{
|
||||
return Err("duplicate manual action or confirmation after cutoff".into());
|
||||
}
|
||||
if action.audit_event_ids.is_empty() {
|
||||
return Err("manual action has no immutable audit binding".into());
|
||||
}
|
||||
if (action.outcome != ManualActionOutcome::OrdersTerminal) != action.orders.is_empty() {
|
||||
return Err("manual action outcome does not prove its order coverage".into());
|
||||
}
|
||||
for id in &action.audit_event_ids {
|
||||
identifier(id)?;
|
||||
if !audits.insert(id.as_str()) {
|
||||
return Err("manual audit event is bound more than once".into());
|
||||
}
|
||||
}
|
||||
for order in &action.orders {
|
||||
identifier(&order.order_id)?;
|
||||
if let Some(adapter) = &order.source_adapter {
|
||||
identifier(adapter)?;
|
||||
}
|
||||
identifier(&order.symbol)?;
|
||||
if let Some(id) = &order.broker_order_id {
|
||||
identifier(id)?;
|
||||
if !broker_orders.insert((
|
||||
order
|
||||
.source_adapter
|
||||
.as_deref()
|
||||
.ok_or("broker identity requires its source adapter")?,
|
||||
order.order_created_at.with_timezone(&shanghai).date_naive(),
|
||||
id.as_str(),
|
||||
)) {
|
||||
return Err("manual local orders share one broker order identity".into());
|
||||
}
|
||||
}
|
||||
if !order.fills.is_empty() && order.source_adapter.is_none() {
|
||||
return Err("manual fills require a known source adapter".into());
|
||||
}
|
||||
if !order.fills.is_empty()
|
||||
&& order.source_adapter.as_deref() != Some("paper")
|
||||
&& order.broker_order_id.is_none()
|
||||
{
|
||||
return Err(
|
||||
"manual broker fills require their original broker order identity".into(),
|
||||
);
|
||||
}
|
||||
if !orders.insert(order.order_id.as_str())
|
||||
|| order.quantity == 0
|
||||
|| order.quantity > i32::MAX as u32
|
||||
{
|
||||
return Err("duplicate manual order or invalid quantity".into());
|
||||
}
|
||||
if order.order_created_at < action.confirmed_at
|
||||
|| order.terminal_observed_at < order.order_created_at
|
||||
|| order.terminal_observed_at > self.observation_cutoff
|
||||
{
|
||||
return Err(
|
||||
"manual order confirmation/submission/terminal time is inconsistent".into(),
|
||||
);
|
||||
}
|
||||
let mut filled = 0_u32;
|
||||
for fill in &order.fills {
|
||||
identifier(&fill.trade_id)?;
|
||||
identifier(&fill.observation_event_id)?;
|
||||
identifier(&fill.fee_observation_event_id)?;
|
||||
if fill.observation_sequence == 0
|
||||
|| fill.observation_sequence > i64::MAX as u64
|
||||
|| !observation_events.insert(fill.observation_event_id.as_str())
|
||||
|| !observation_sequences.insert(fill.observation_sequence)
|
||||
{
|
||||
return Err(
|
||||
"manual fill requires a unique durable observation event and sequence"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if fill.fee_observation_sequence == 0
|
||||
|| fill.fee_observation_sequence > i64::MAX as u64
|
||||
|| fill.fee_observed_at < fill.observed_at
|
||||
|| fill.fee_observed_at > self.observation_cutoff
|
||||
|| !fee_observations.insert((
|
||||
fill.fee_observation_event_id.as_str(),
|
||||
fill.fee_observation_sequence,
|
||||
))
|
||||
{
|
||||
return Err("manual finalized fees require their own unique observation within the cutoff".into());
|
||||
}
|
||||
if (fill.fee_observation_event_id == fill.observation_event_id)
|
||||
!= (fill.fee_observation_sequence == fill.observation_sequence)
|
||||
|| (fill.fee_observation_event_id == fill.observation_event_id
|
||||
&& fill.fee_observed_at != fill.observed_at)
|
||||
{
|
||||
return Err("manual fill and fee observation identities disagree".into());
|
||||
}
|
||||
if !trades.insert((fill.trade_date, fill.trade_id.as_str()))
|
||||
|| fill.quantity == 0
|
||||
{
|
||||
return Err("duplicate manual trade or zero fill quantity".into());
|
||||
}
|
||||
for (id, sequence) in [
|
||||
(&fill.observation_event_id, fill.observation_sequence),
|
||||
(
|
||||
&fill.fee_observation_event_id,
|
||||
fill.fee_observation_sequence,
|
||||
),
|
||||
] {
|
||||
if receipt_ids
|
||||
.insert(id, (&fill.trade_id, sequence))
|
||||
.is_some_and(|owner| owner != (&fill.trade_id, sequence))
|
||||
|| receipt_sequences
|
||||
.insert(sequence, (&fill.trade_id, id))
|
||||
.is_some_and(|owner| owner != (&fill.trade_id, id))
|
||||
{
|
||||
return Err("manual observation identity is reused by a different trade or sequence".into());
|
||||
}
|
||||
}
|
||||
if fill.executed_at.with_timezone(&shanghai).date_naive() != fill.trade_date
|
||||
|| fill.observed_at > self.observation_cutoff
|
||||
|| fill.observed_at < order.order_created_at
|
||||
|| fill.observed_at < action.confirmation_observed_at
|
||||
|| fill.observed_at < fill.executed_at
|
||||
|| fill.executed_at > order.terminal_observed_at
|
||||
{
|
||||
return Err("manual fill execution/observation time is inconsistent".into());
|
||||
}
|
||||
if i64::from(fill.executed_at.nanosecond())
|
||||
% fill.timestamp_precision.nanoseconds()
|
||||
!= 0
|
||||
{
|
||||
return Err(
|
||||
"broker timestamp contains digits finer than its declared precision"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let upper = fill
|
||||
.executed_at
|
||||
.checked_add_signed(chrono::Duration::nanoseconds(
|
||||
fill.timestamp_precision.nanoseconds(),
|
||||
))
|
||||
.ok_or("manual execution timestamp overflow")?;
|
||||
let earliest = order.order_created_at.max(action.confirmation_observed_at);
|
||||
if fill.executed_at < earliest && earliest >= upper {
|
||||
return Err("manual fill predates its order or durable confirmation".into());
|
||||
}
|
||||
if fill.price <= Decimal::ZERO {
|
||||
return Err("manual fill requires a positive price".into());
|
||||
}
|
||||
fill.gross_amount()?
|
||||
.checked_add(fill.total_fees()?)
|
||||
.ok_or("manual fill cash amount overflow")?;
|
||||
filled = filled
|
||||
.checked_add(fill.quantity)
|
||||
.ok_or("manual cumulative fill quantity overflow")?;
|
||||
}
|
||||
if filled > order.quantity
|
||||
|| (order.terminal_status == ManualOrderTerminalStatus::Filled
|
||||
&& filled != order.quantity)
|
||||
|| (order.terminal_status == ManualOrderTerminalStatus::Rejected && filled != 0)
|
||||
|| (matches!(
|
||||
order.terminal_status,
|
||||
ManualOrderTerminalStatus::Cancelled | ManualOrderTerminalStatus::Expired
|
||||
) && filled == order.quantity)
|
||||
{
|
||||
return Err("manual terminal status disagrees with cumulative fills".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ManualFillObservation<'a> {
|
||||
pub action: &'a ManualExecutionAction,
|
||||
pub order: &'a ManualExecutionOrder,
|
||||
pub fill: &'a ManualExecutionFill,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AppliedManualFill {
|
||||
pub gross: FixedMoney,
|
||||
pub fees: FixedMoney,
|
||||
pub cash_delta: FixedMoney,
|
||||
pub quantity_after: u32,
|
||||
}
|
||||
|
||||
/// One replay owns its immutable trace and progress. Advancing is atomic even
|
||||
/// if a later receipt in the same step disagrees with the shadow account.
|
||||
pub struct ManualReplayCursor {
|
||||
replay: std::sync::Arc<ManualExecutionReplay>,
|
||||
indices: Vec<(usize, usize, usize)>,
|
||||
cursor: usize,
|
||||
clock: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ManualReplayApplication {
|
||||
pub action_id: String,
|
||||
pub order_id: String,
|
||||
pub trade_id: String,
|
||||
pub observation_event_id: String,
|
||||
pub observation_sequence: u64,
|
||||
pub observed_at: DateTime<Utc>,
|
||||
pub fee_observation_event_id: String,
|
||||
pub fee_observed_at: DateTime<Utc>,
|
||||
pub executed_at: DateTime<Utc>,
|
||||
pub symbol: String,
|
||||
pub side: OrderSide,
|
||||
pub quantity: u32,
|
||||
pub quantity_after: u32,
|
||||
pub price: String,
|
||||
pub commission: Option<String>,
|
||||
pub stamp_tax: Option<String>,
|
||||
pub transfer_fee: Option<String>,
|
||||
pub source_total_fee: String,
|
||||
pub source_gross_amount: String,
|
||||
pub ledger_gross_amount: String,
|
||||
pub ledger_fees: String,
|
||||
pub cash_delta: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub corporate_adjustment: Option<ManualCorporateAdjustment>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct ManualCorporateAdjustment {
|
||||
pub schema: String,
|
||||
pub observed_at: DateTime<Utc>,
|
||||
pub cash_dividends_enabled: bool,
|
||||
pub dividend_cost_basis_adjustment: bool,
|
||||
#[serde(default, skip_serializing_if = "disabled_flag")]
|
||||
pub dividend_reinvestment: bool,
|
||||
pub actions: Vec<ManualCorporateActionReference>,
|
||||
pub cash_before: String,
|
||||
pub cash_after: String,
|
||||
pub corporate_cash_delta: String,
|
||||
pub positions: BTreeMap<String, ManualCorporatePositionChange>,
|
||||
pub reference_sha256: String,
|
||||
pub replayed_sha256: String,
|
||||
}
|
||||
|
||||
fn disabled_flag(value: &bool) -> bool { !value }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct ManualCorporateActionReference {
|
||||
pub date: NaiveDate,
|
||||
pub symbol: String,
|
||||
pub successor_symbol: Option<String>,
|
||||
pub share_cash: String,
|
||||
pub split_ratio: String,
|
||||
pub successor_ratio: Option<String>,
|
||||
pub successor_cash: Option<String>,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct ManualCorporatePositionChange {
|
||||
pub quantity_before: u32,
|
||||
pub quantity_after: u32,
|
||||
pub cost_basis_before: String,
|
||||
pub cost_basis_after: String,
|
||||
}
|
||||
|
||||
impl ManualReplayCursor {
|
||||
pub(crate) fn frozen_source(&self) -> std::sync::Arc<ManualExecutionReplay> {
|
||||
self.replay.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn next_observation(&self) -> Option<ManualFillObservation<'_>> {
|
||||
self.indices.get(self.cursor).map(|&(a, o, f)| ManualFillObservation {
|
||||
action: &self.replay.actions[a], order: &self.replay.actions[a].orders[o],
|
||||
fill: &self.replay.actions[a].orders[o].fills[f],
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn advance_next_projected<F>(
|
||||
&mut self, portfolio: &mut PortfolioState, project: F,
|
||||
) -> Result<Option<ManualReplayApplication>, String>
|
||||
where F: FnOnce(ManualFillObservation<'_>, &mut PortfolioState) -> Result<(AppliedManualFill, ManualCorporateAdjustment), String> {
|
||||
let Some(observation) = self.next_observation() else { return Ok(None); };
|
||||
let at = observation.fill.observed_at;
|
||||
if at > self.replay.observation_cutoff || self.clock.is_some_and(|clock| at < clock) {
|
||||
return Err("manual projected observation clock violates the frozen trace".into());
|
||||
}
|
||||
let mut next = portfolio.clone();
|
||||
let (applied, adjustment) = project(observation, &mut next)?;
|
||||
let mut application = observation.application(applied)?;
|
||||
application.corporate_adjustment = Some(adjustment);
|
||||
crate::finite_serialization::validate(&application).map_err(|error| error.to_string())?;
|
||||
*portfolio = next;
|
||||
self.cursor += 1;
|
||||
self.clock = Some(at);
|
||||
Ok(Some(application))
|
||||
}
|
||||
|
||||
pub fn new(replay: ManualExecutionReplay) -> Result<Self, String> {
|
||||
Self::from_shared(std::sync::Arc::new(replay))
|
||||
}
|
||||
|
||||
pub fn from_shared(replay: std::sync::Arc<ManualExecutionReplay>) -> Result<Self, String> {
|
||||
replay.validate()?;
|
||||
let mut indices = Vec::new();
|
||||
for (a, action) in replay.actions.iter().enumerate() {
|
||||
for (o, order) in action.orders.iter().enumerate() {
|
||||
for f in 0..order.fills.len() {
|
||||
indices.push((a, o, f));
|
||||
}
|
||||
}
|
||||
}
|
||||
indices.sort_by_key(|&(a, o, f)| {
|
||||
let fill = &replay.actions[a].orders[o].fills[f];
|
||||
(fill.observed_at, fill.observation_sequence)
|
||||
});
|
||||
Ok(Self {
|
||||
replay,
|
||||
indices,
|
||||
cursor: 0,
|
||||
clock: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn next_observation_at(&self) -> Option<DateTime<Utc>> {
|
||||
self.indices
|
||||
.get(self.cursor)
|
||||
.map(|&(a, o, f)| self.replay.actions[a].orders[o].fills[f].observed_at)
|
||||
}
|
||||
|
||||
pub fn applied_count(&self) -> usize {
|
||||
self.cursor
|
||||
}
|
||||
|
||||
pub fn advance(
|
||||
&mut self,
|
||||
at: DateTime<Utc>,
|
||||
portfolio: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
has_pending_orders: bool,
|
||||
) -> Result<Vec<ManualReplayApplication>, String> {
|
||||
let end = self.cursor
|
||||
+ self.indices[self.cursor..].iter().take_while(|&&(a, o, f)| {
|
||||
self.replay.actions[a].orders[o].fills[f].observed_at <= at
|
||||
}).count();
|
||||
self.advance_through(at, end, portfolio, data, has_pending_orders)
|
||||
}
|
||||
|
||||
/// One receipt at a time lets callbacks observe the intermediate state
|
||||
/// when multiple fills share a timestamp but have distinct durable sequences.
|
||||
pub fn advance_next(
|
||||
&mut self, portfolio: &mut PortfolioState, data: &DataSet, has_pending_orders: bool,
|
||||
) -> Result<Option<ManualReplayApplication>, String> {
|
||||
let Some(at) = self.next_observation_at() else { return Ok(None); };
|
||||
let mut applications = self.advance_through(at, self.cursor + 1, portfolio, data, has_pending_orders)?;
|
||||
Ok(applications.pop())
|
||||
}
|
||||
|
||||
fn advance_through(
|
||||
&mut self, at: DateTime<Utc>, end: usize, portfolio: &mut PortfolioState,
|
||||
data: &DataSet, has_pending_orders: bool,
|
||||
) -> Result<Vec<ManualReplayApplication>, String> {
|
||||
if at > self.replay.observation_cutoff {
|
||||
return Err("manual observation clock exceeds the frozen evidence cutoff".into());
|
||||
}
|
||||
if self.clock.is_some_and(|clock| at < clock) {
|
||||
return Err("manual observation clock moved backwards".into());
|
||||
}
|
||||
if end == self.cursor {
|
||||
self.clock = Some(at);
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let mut next = portfolio.clone();
|
||||
let mut applications = Vec::with_capacity(end - self.cursor);
|
||||
for &(a, o, f) in &self.indices[self.cursor..end] {
|
||||
let action = &self.replay.actions[a];
|
||||
let order = &action.orders[o];
|
||||
let fill = &order.fills[f];
|
||||
let applied = ManualFillObservation {
|
||||
action,
|
||||
order,
|
||||
fill,
|
||||
}
|
||||
.apply(&mut next, data, has_pending_orders)?;
|
||||
applications.push(ManualReplayApplication {
|
||||
action_id: action.action_id.clone(),
|
||||
order_id: order.order_id.clone(),
|
||||
trade_id: fill.trade_id.clone(),
|
||||
observation_event_id: fill.observation_event_id.clone(),
|
||||
observation_sequence: fill.observation_sequence,
|
||||
observed_at: fill.observed_at,
|
||||
fee_observation_event_id: fill.fee_observation_event_id.clone(),
|
||||
fee_observed_at: fill.fee_observed_at,
|
||||
executed_at: fill.executed_at,
|
||||
symbol: order.symbol.clone(),
|
||||
side: order.side,
|
||||
quantity: fill.quantity,
|
||||
quantity_after: applied.quantity_after,
|
||||
price: fill.price.to_string(),
|
||||
commission: fill.commission.map(|fee| fee.to_string()),
|
||||
stamp_tax: fill.stamp_tax.map(|fee| fee.to_string()),
|
||||
transfer_fee: fill.transfer_fee.map(|fee| fee.to_string()),
|
||||
source_total_fee: fill.total_fee.to_string(),
|
||||
source_gross_amount: fill.gross_amount()?.to_string(),
|
||||
ledger_gross_amount: applied.gross.to_decimal_string(),
|
||||
ledger_fees: applied.fees.to_decimal_string(),
|
||||
cash_delta: applied.cash_delta.to_decimal_string(),
|
||||
corporate_adjustment: None,
|
||||
});
|
||||
}
|
||||
*portfolio = next;
|
||||
self.cursor = end;
|
||||
self.clock = Some(at);
|
||||
Ok(applications)
|
||||
}
|
||||
}
|
||||
|
||||
impl ManualFillObservation<'_> {
|
||||
fn application(&self, applied: AppliedManualFill) -> Result<ManualReplayApplication, String> {
|
||||
Ok(ManualReplayApplication {
|
||||
action_id: self.action.action_id.clone(), order_id: self.order.order_id.clone(),
|
||||
trade_id: self.fill.trade_id.clone(), observation_event_id: self.fill.observation_event_id.clone(),
|
||||
observation_sequence: self.fill.observation_sequence, observed_at: self.fill.observed_at,
|
||||
fee_observation_event_id: self.fill.fee_observation_event_id.clone(), fee_observed_at: self.fill.fee_observed_at,
|
||||
executed_at: self.fill.executed_at, symbol: self.order.symbol.clone(), side: self.order.side,
|
||||
quantity: self.fill.quantity, quantity_after: applied.quantity_after, price: self.fill.price.to_string(),
|
||||
commission: self.fill.commission.map(|fee| fee.to_string()), stamp_tax: self.fill.stamp_tax.map(|fee| fee.to_string()),
|
||||
transfer_fee: self.fill.transfer_fee.map(|fee| fee.to_string()), source_total_fee: self.fill.total_fee.to_string(),
|
||||
source_gross_amount: self.fill.gross_amount()?.to_string(), ledger_gross_amount: applied.gross.to_decimal_string(),
|
||||
ledger_fees: applied.fees.to_decimal_string(), cash_delta: applied.cash_delta.to_decimal_string(), corporate_adjustment: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn apply(
|
||||
&self,
|
||||
portfolio: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
has_pending_orders: bool,
|
||||
) -> Result<AppliedManualFill, String> {
|
||||
if has_pending_orders {
|
||||
return Err("manual observation conflicts with pending shadow orders".into());
|
||||
}
|
||||
let instrument = data
|
||||
.instrument(&self.order.symbol)
|
||||
.ok_or("manual observation instrument is absent from frozen source data")?;
|
||||
if instrument
|
||||
.dated_market_absence_reason(self.fill.trade_date)
|
||||
.is_some()
|
||||
{
|
||||
return Err("manual execution contradicts the frozen instrument lifecycle".into());
|
||||
}
|
||||
let gross = FixedMoney::from_decimal_str(&self.fill.gross_amount()?.to_string())?;
|
||||
let fees = FixedMoney::from_decimal_str(&self.fill.total_fees()?.to_string())?;
|
||||
let price = self
|
||||
.fill
|
||||
.price
|
||||
.to_f64()
|
||||
.filter(|price| price.is_finite() && *price > 0.)
|
||||
.ok_or("manual execution price cannot be represented for valuation")?;
|
||||
// This is the real observed trade price, not a fabricated quote. The
|
||||
// normal market clock remains responsible for subsequent marks.
|
||||
let cash_delta = portfolio.apply_observed_manual_fill(
|
||||
self.fill.trade_date,
|
||||
&self.order.symbol,
|
||||
self.order.side,
|
||||
self.fill.quantity,
|
||||
price,
|
||||
price,
|
||||
gross,
|
||||
fees,
|
||||
)?;
|
||||
Ok(AppliedManualFill {
|
||||
gross,
|
||||
fees,
|
||||
cash_delta,
|
||||
quantity_after: portfolio
|
||||
.position(&self.order.symbol)
|
||||
.map_or(0, |position| position.quantity),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,761 @@
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn sample() -> ManualExecutionReplay {
|
||||
let fill = json!({"tradeId":"trade-1","observationEventId":"received-1","observationSequence":1,"tradeDate":"2026-09-14","executedAt":"2026-09-14T01:30:00Z",
|
||||
"feeObservationEventId":"received-1","feeObservationSequence":1,"feeObservedAt":"2026-09-14T01:30:01Z",
|
||||
"observedAt":"2026-09-14T01:30:01Z","timestampPrecision":"second","quantity":100,
|
||||
"price":"10.1234567891","commission":"0.1000001","stampTax":"0","transferFee":"0.02","totalFee":"0.1200001"});
|
||||
let mut input:ManualExecutionReplay=serde_json::from_value(json!({
|
||||
"schema":MANUAL_REPLAY_SCHEMA,"runtimeId":"runtime-1","accountId":"account-1",
|
||||
"sourceContractSha256":"a".repeat(64),"contentSha256":"", "observationCutoff":"2026-09-14T08:00:00Z",
|
||||
"actions":[{"actionId":"action-1","source":"manual_security_trade","auditEventIds":["audit-1"],
|
||||
"confirmedAt":"2026-09-14T01:30:00.500Z","confirmationObservedAt":"2026-09-14T01:30:00.550Z","outcome":"orders_terminal","orders":[{
|
||||
"orderId":"order-1","brokerOrderId":"broker-1","sourceAdapter":"gt-api","symbol":"000001.SZ","side":"Buy","quantity":100,
|
||||
"orderCreatedAt":"2026-09-14T01:30:00.600Z","terminalObservedAt":"2026-09-14T01:30:00.900Z","terminalStatus":"filled",
|
||||
"fills":[fill]
|
||||
}]}]
|
||||
})).unwrap();
|
||||
reseal(&mut input);
|
||||
input
|
||||
}
|
||||
|
||||
fn reseal(input: &mut ManualExecutionReplay) {
|
||||
input.content_sha256 = input.content_digest().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_corporate_projection_does_not_change_the_book_or_receipt_cursor() {
|
||||
let mut cursor = ManualReplayCursor::new(sample()).unwrap();
|
||||
let mut portfolio = PortfolioState::new(10000.);
|
||||
let before = portfolio.financial_replay_identity();
|
||||
let error = cursor.advance_next_projected(&mut portfolio, |_, next| {
|
||||
next.apply_cash_delta(-50.)?;
|
||||
next.position_mut("000001.SZ").buy(NaiveDate::from_ymd_opt(2026, 9, 11).unwrap(), 100, 10.);
|
||||
Err("financial coverage mismatch".into())
|
||||
}).unwrap_err();
|
||||
assert_eq!(error, "financial coverage mismatch");
|
||||
assert_eq!(portfolio.financial_replay_identity(), before);
|
||||
assert_eq!(cursor.applied_count(), 0);
|
||||
}
|
||||
|
||||
fn delayed_buy_replay() -> ManualExecutionReplay {
|
||||
let mut input = sample();
|
||||
let template = input.actions[0].clone();
|
||||
input.actions.clear();
|
||||
for (index, side, executed, observed, price, fee) in [
|
||||
(
|
||||
0,
|
||||
OrderSide::Buy,
|
||||
"2026-09-14T01:30:00Z",
|
||||
"2026-09-14T01:30:01Z",
|
||||
"20",
|
||||
"0.25",
|
||||
),
|
||||
(
|
||||
1,
|
||||
OrderSide::Buy,
|
||||
"2026-09-11T06:00:00Z",
|
||||
"2026-09-14T01:30:02Z",
|
||||
"10",
|
||||
"0.75",
|
||||
),
|
||||
(
|
||||
2,
|
||||
OrderSide::Sell,
|
||||
"2026-09-14T01:31:00Z",
|
||||
"2026-09-14T01:31:01Z",
|
||||
"10",
|
||||
"0.5",
|
||||
),
|
||||
(
|
||||
3,
|
||||
OrderSide::Sell,
|
||||
"2026-09-14T01:32:00Z",
|
||||
"2026-09-14T01:32:01Z",
|
||||
"10",
|
||||
"0.5",
|
||||
),
|
||||
] {
|
||||
let executed: DateTime<Utc> = executed.parse().unwrap();
|
||||
let observed: DateTime<Utc> = observed.parse().unwrap();
|
||||
let mut action = template.clone();
|
||||
action.action_id = format!("action-{index}");
|
||||
action.audit_event_ids = vec![format!("audit-{index}")];
|
||||
action.confirmed_at = executed - chrono::Duration::seconds(2);
|
||||
action.confirmation_observed_at = action.confirmed_at;
|
||||
let order = &mut action.orders[0];
|
||||
order.order_id = format!("order-{index}");
|
||||
order.broker_order_id = Some(format!("broker-{index}"));
|
||||
order.side = side;
|
||||
order.order_created_at = executed - chrono::Duration::seconds(1);
|
||||
order.terminal_observed_at = observed;
|
||||
let fill = &mut order.fills[0];
|
||||
fill.trade_id = format!("trade-{index}");
|
||||
fill.observation_event_id = format!("receipt-{index}");
|
||||
fill.observation_sequence = index + 1;
|
||||
fill.fee_observation_event_id = fill.observation_event_id.clone();
|
||||
fill.fee_observation_sequence = fill.observation_sequence;
|
||||
fill.trade_date = executed
|
||||
.with_timezone(&FixedOffset::east_opt(8 * 3600).unwrap())
|
||||
.date_naive();
|
||||
fill.executed_at = executed;
|
||||
fill.observed_at = observed;
|
||||
fill.fee_observed_at = observed;
|
||||
fill.price = price.parse().unwrap();
|
||||
fill.commission = None;
|
||||
fill.stamp_tax = None;
|
||||
fill.transfer_fee = None;
|
||||
fill.total_fee = fee.parse().unwrap();
|
||||
input.actions.push(action);
|
||||
}
|
||||
reseal(&mut input);
|
||||
input.validate().unwrap();
|
||||
input
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_buy_retains_the_earliest_opening_and_latest_buy_dates() {
|
||||
let mut cursor = ManualReplayCursor::new(delayed_buy_replay()).unwrap();
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let mut portfolio = PortfolioState::new(10000.);
|
||||
let applications = cursor
|
||||
.advance(
|
||||
"2026-09-14T01:30:02Z".parse().unwrap(),
|
||||
&mut portfolio,
|
||||
&data,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
applications
|
||||
.iter()
|
||||
.map(|row| row.trade_id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["trade-0", "trade-1"]
|
||||
);
|
||||
let position = portfolio.position("000001.SZ").unwrap();
|
||||
assert_eq!(position.opened_date(), NaiveDate::from_ymd_opt(2026, 9, 11));
|
||||
assert_eq!(
|
||||
position.last_buy_date(),
|
||||
NaiveDate::from_ymd_opt(2026, 9, 14)
|
||||
);
|
||||
assert_eq!(position.quantity, 200);
|
||||
let calendar = crate::TradingCalendar::new(
|
||||
[11, 14, 15, 16, 17, 18]
|
||||
.map(|day| NaiveDate::from_ymd_opt(2026, 9, day).unwrap())
|
||||
.into(),
|
||||
);
|
||||
let evidence = crate::holding_policy::HoldingLifecycleEvidence {
|
||||
has_position: true,
|
||||
opened_date: position.opened_date(),
|
||||
last_buy_date: position.last_buy_date(),
|
||||
last_sell_date: None,
|
||||
};
|
||||
let mut policy = crate::holding_policy::AutomaticTradeProtection {
|
||||
max_holding_days: 1,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
policy
|
||||
.evaluate(
|
||||
"000001.SZ",
|
||||
NaiveDate::from_ymd_opt(2026, 9, 14).unwrap(),
|
||||
&evidence,
|
||||
&calendar
|
||||
)
|
||||
.unwrap()
|
||||
.max_holding_exit
|
||||
);
|
||||
policy.buy_protection_days = 3;
|
||||
for day in [14, 15, 16, 17] {
|
||||
let permission = policy
|
||||
.evaluate(
|
||||
"000001.SZ",
|
||||
NaiveDate::from_ymd_opt(2026, 9, day).unwrap(),
|
||||
&evidence,
|
||||
&calendar,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(permission.sell_denial, Some("buy_fill_protection"));
|
||||
assert!(!permission.max_holding_exit);
|
||||
}
|
||||
assert!(
|
||||
policy
|
||||
.evaluate(
|
||||
"000001.SZ",
|
||||
NaiveDate::from_ymd_opt(2026, 9, 18).unwrap(),
|
||||
&evidence,
|
||||
&calendar
|
||||
)
|
||||
.unwrap()
|
||||
.max_holding_exit
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_buy_fifo_depletion_preserves_costs_and_cannot_unlock_today_lots() {
|
||||
let mut cursor = ManualReplayCursor::new(delayed_buy_replay()).unwrap();
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let mut portfolio = PortfolioState::new(10000.);
|
||||
let applications = cursor
|
||||
.advance(
|
||||
"2026-09-14T01:31:01Z".parse().unwrap(),
|
||||
&mut portfolio,
|
||||
&data,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(applications.len(), 3);
|
||||
let position = portfolio.position("000001.SZ").unwrap();
|
||||
assert_eq!(position.quantity, 100);
|
||||
assert_eq!(position.unrealized_pnl(), -1000.25);
|
||||
assert_eq!(
|
||||
position.sellable_qty(NaiveDate::from_ymd_opt(2026, 9, 14).unwrap()),
|
||||
0
|
||||
);
|
||||
assert_eq!(position.realized_pnl(), -0.75);
|
||||
assert_eq!(portfolio.cash(), 7998.5);
|
||||
assert_eq!(portfolio.external_cash_flow_total(), 0.);
|
||||
assert!(
|
||||
cursor
|
||||
.advance(
|
||||
"2026-09-14T01:32:01Z".parse().unwrap(),
|
||||
&mut portfolio,
|
||||
&data,
|
||||
false
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("T+1")
|
||||
);
|
||||
assert_eq!(cursor.applied_count(), 3);
|
||||
assert_eq!(portfolio.cash(), 7998.5);
|
||||
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 100);
|
||||
}
|
||||
fn semantic_result(input: &ManualExecutionReplay) -> Result<(), String> {
|
||||
let mut input = input.clone();
|
||||
reseal(&mut input);
|
||||
input.validate()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_exact_decimal_evidence_allows_later_observation_and_retains_source_digits() {
|
||||
let input = sample();
|
||||
input.validate().unwrap();
|
||||
let fill = &input.actions[0].orders[0].fills[0];
|
||||
assert_eq!(fill.gross_amount().unwrap().to_string(), "1012.3456789100");
|
||||
assert_eq!(fill.total_fees().unwrap().to_string(), "0.1200001");
|
||||
assert_eq!(
|
||||
serde_json::to_value(&input).unwrap()["actions"][0]["orders"][0]["fills"][0]["price"],
|
||||
"10.1234567891"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_scope_only_contains_actual_filled_securities_and_validates_the_source() {
|
||||
let mut input = sample();
|
||||
let mut rejected = input.actions[0].orders[0].clone();
|
||||
rejected.order_id = "rejected-order".into();
|
||||
rejected.broker_order_id = None;
|
||||
rejected.source_adapter = None;
|
||||
rejected.symbol = "510300.SH".into();
|
||||
rejected.terminal_status = ManualOrderTerminalStatus::Rejected;
|
||||
rejected.fills.clear();
|
||||
input.actions[0].orders.push(rejected);
|
||||
reseal(&mut input);
|
||||
assert_eq!(
|
||||
input.required_data_symbols().unwrap(),
|
||||
BTreeSet::from(["000001.SZ".into()])
|
||||
);
|
||||
input.actions[0].orders[0].symbol = "600000.SH".into();
|
||||
assert!(input.required_data_symbols().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_facts_keep_their_encoding_but_cannot_silently_carry_new_runtime_settings() {
|
||||
let mut input = sample();
|
||||
input.schema = "fidc.observed-manual-executions/v2".into();
|
||||
reseal(&mut input);
|
||||
input.validate().unwrap();
|
||||
let old = serde_json::to_value(&input).unwrap();
|
||||
assert!(old.get("positionExposureEvents").is_none());
|
||||
assert!(old.get("legacyPositionExposureBps").is_none());
|
||||
input
|
||||
.legacy_position_exposure_bps
|
||||
.insert(NaiveDate::from_ymd_opt(2026, 9, 14).unwrap(), 5000);
|
||||
reseal(&mut input);
|
||||
assert!(input.validate().is_err());
|
||||
input.schema = MANUAL_REPLAY_SCHEMA.into();
|
||||
reseal(&mut input);
|
||||
input.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_position_events_cannot_claim_observations_after_the_source_cutoff() {
|
||||
let mut input = sample();
|
||||
input.position_exposure_events.push(serde_json::from_value(json!({
|
||||
"eventId": "position-event", "sequence": 1, "effectiveAt": input.observation_cutoff,
|
||||
"action": "scale", "requestedBps": 5000
|
||||
})).unwrap());
|
||||
semantic_result(&input).unwrap();
|
||||
input.position_exposure_events[0].effective_at += chrono::Duration::nanoseconds(1);
|
||||
assert!(semantic_result(&input).unwrap_err().contains("after the evidence cutoff"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_required_money_and_binding_fields_reject_missing_or_wrong_values() {
|
||||
let original = serde_json::to_value(sample()).unwrap();
|
||||
for field in ["price", "totalFee"] {
|
||||
let mut missing = original.clone();
|
||||
missing["actions"][0]["orders"][0]["fills"][0]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove(field);
|
||||
assert!(
|
||||
serde_json::from_value::<ManualExecutionReplay>(missing).is_err(),
|
||||
"{field}"
|
||||
);
|
||||
let mut numeric = original.clone();
|
||||
numeric["actions"][0]["orders"][0]["fills"][0][field] = json!(1.1);
|
||||
assert!(
|
||||
serde_json::from_value::<ManualExecutionReplay>(numeric).is_err(),
|
||||
"numeric {field}"
|
||||
);
|
||||
}
|
||||
for mutate in [
|
||||
("schema", json!("unknown")),
|
||||
("sourceContractSha256", json!("broken")),
|
||||
("accountId", json!(" ")),
|
||||
] {
|
||||
let mut value = original.clone();
|
||||
value[mutate.0] = mutate.1;
|
||||
assert!(
|
||||
semantic_result(&serde_json::from_value::<ManualExecutionReplay>(value).unwrap())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inconsistent_counts_terminals_audits_and_duplicate_facts_are_rejected() {
|
||||
let original = sample();
|
||||
let mut invalid = original.clone();
|
||||
invalid.actions[0].orders[0].quantity = 200;
|
||||
assert!(semantic_result(&invalid).is_err());
|
||||
let mut invalid = original.clone();
|
||||
invalid.actions[0].orders[0].terminal_status = ManualOrderTerminalStatus::Rejected;
|
||||
assert!(semantic_result(&invalid).is_err());
|
||||
let mut invalid = original.clone();
|
||||
invalid.actions[0].audit_event_ids.clear();
|
||||
assert!(semantic_result(&invalid).is_err());
|
||||
let mut invalid = original.clone();
|
||||
invalid.actions.push(invalid.actions[0].clone());
|
||||
assert!(semantic_result(&invalid).is_err());
|
||||
let mut invalid = original.clone();
|
||||
let duplicate = invalid.actions[0].orders[0].fills[0].clone();
|
||||
invalid.actions[0].orders[0].fills.push(duplicate);
|
||||
assert!(semantic_result(&invalid).is_err());
|
||||
let mut invalid = original.clone();
|
||||
invalid.actions[0].orders[0].broker_order_id = None;
|
||||
assert!(semantic_result(&invalid).is_err());
|
||||
invalid.actions[0].orders[0].source_adapter = Some("paper".into());
|
||||
reseal(&mut invalid);
|
||||
invalid.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_time_precision_is_not_invented_and_submitted_time_must_fit_the_interval() {
|
||||
let mut input = sample();
|
||||
input.actions[0].orders[0].order_created_at = "2026-09-14T01:30:00.999999Z".parse().unwrap();
|
||||
input.actions[0].orders[0].terminal_observed_at = "2026-09-14T01:30:01.500Z".parse().unwrap();
|
||||
input.actions[0].orders[0].fills[0].observed_at = "2026-09-14T01:30:02Z".parse().unwrap();
|
||||
input.actions[0].orders[0].fills[0].fee_observed_at =
|
||||
input.actions[0].orders[0].fills[0].observed_at;
|
||||
reseal(&mut input);
|
||||
input.validate().unwrap();
|
||||
input.actions[0].orders[0].order_created_at = "2026-09-14T01:30:01Z".parse().unwrap();
|
||||
assert!(semantic_result(&input).is_err());
|
||||
let mut input = sample();
|
||||
input.actions[0].orders[0].fills[0].executed_at = "2026-09-14T01:30:00.800Z".parse().unwrap();
|
||||
assert!(semantic_result(&input).is_err());
|
||||
input.actions[0].orders[0].fills[0].timestamp_precision = ManualTimestampPrecision::Millisecond;
|
||||
reseal(&mut input);
|
||||
input.validate().unwrap();
|
||||
input.actions[0].orders[0].fills[0].executed_at =
|
||||
"2026-09-14T01:30:00.800001Z".parse().unwrap();
|
||||
assert!(semantic_result(&input).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_no_order_outcome_is_distinct_from_unconfirmed_or_unknown_work() {
|
||||
let mut input = sample();
|
||||
input.actions[0].orders.clear();
|
||||
assert!(semantic_result(&input).is_err());
|
||||
input.actions[0].outcome = ManualActionOutcome::NoOrdersNeeded;
|
||||
reseal(&mut input);
|
||||
input.validate().unwrap();
|
||||
input.actions[0].outcome = ManualActionOutcome::NotExecuted;
|
||||
reseal(&mut input);
|
||||
input.validate().unwrap();
|
||||
let mut value = serde_json::to_value(input).unwrap();
|
||||
value["actions"][0]["outcome"] = json!("result_unknown");
|
||||
assert!(serde_json::from_value::<ManualExecutionReplay>(value).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_timezone_and_cutoff_are_required() {
|
||||
let mut value = serde_json::to_value(sample()).unwrap();
|
||||
value["actions"][0]["orders"][0]["fills"][0]["executedAt"] = json!("2026-09-14T09:30:00");
|
||||
assert!(serde_json::from_value::<ManualExecutionReplay>(value).is_err());
|
||||
let mut input = sample();
|
||||
input.observation_cutoff = "2026-09-14T01:30:00.700Z".parse().unwrap();
|
||||
assert!(semantic_result(&input).is_err());
|
||||
let mut value = serde_json::to_value(sample()).unwrap();
|
||||
value["actions"][0]["orders"][0]["fills"][0]["totalFee"] = Value::Null;
|
||||
assert!(serde_json::from_value::<ManualExecutionReplay>(value).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authoritative_total_fee_does_not_require_inventing_unknown_components() {
|
||||
let mut input = sample();
|
||||
let fill = &mut input.actions[0].orders[0].fills[0];
|
||||
fill.commission = None;
|
||||
fill.stamp_tax = None;
|
||||
fill.transfer_fee = None;
|
||||
assert_eq!(
|
||||
fill.total_fees().unwrap(),
|
||||
"0.1200001".parse::<Decimal>().unwrap()
|
||||
);
|
||||
assert!(semantic_result(&input).is_ok());
|
||||
let value = serde_json::to_value(&input).unwrap();
|
||||
assert!(value["actions"][0]["orders"][0]["fills"][0]["commission"].is_null());
|
||||
assert_eq!(
|
||||
value["actions"][0]["orders"][0]["fills"][0]["totalFee"],
|
||||
"0.1200001"
|
||||
);
|
||||
for field in ["commission", "stampTax", "transferFee"] {
|
||||
let mut numeric = value.clone();
|
||||
numeric["actions"][0]["orders"][0]["fills"][0][field] = json!(0.1);
|
||||
assert!(serde_json::from_value::<ManualExecutionReplay>(numeric).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_fee_total_includes_extra_charges_and_rejects_inconsistent_components() {
|
||||
let mut input = sample();
|
||||
input.actions[0].orders[0].fills[0].total_fee = "0.15".parse().unwrap();
|
||||
assert!(semantic_result(&input).is_ok());
|
||||
assert_eq!(
|
||||
input.actions[0].orders[0].fills[0]
|
||||
.total_fees()
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
"0.15"
|
||||
);
|
||||
input.actions[0].orders[0].fills[0].total_fee = "0.1".parse().unwrap();
|
||||
assert!(semantic_result(&input).is_err());
|
||||
input.actions[0].orders[0].fills[0].total_fee = "0.15".parse().unwrap();
|
||||
input.actions[0].orders[0].fills[0].commission = Some(Decimal::NEGATIVE_ONE);
|
||||
assert!(semantic_result(&input).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_fee_evidence_keeps_the_original_fill_observation_clock() {
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let mut input = sample();
|
||||
let fill = &mut input.actions[0].orders[0].fills[0];
|
||||
let original = fill.observed_at;
|
||||
fill.fee_observation_event_id = "fee-receipt-1".into();
|
||||
fill.fee_observation_sequence = 2;
|
||||
fill.fee_observed_at = original + chrono::Duration::hours(1);
|
||||
let fee_time = fill.fee_observed_at;
|
||||
reseal(&mut input);
|
||||
let mut cursor = ManualReplayCursor::new(input).unwrap();
|
||||
assert_eq!(cursor.next_observation_at(), Some(original));
|
||||
let mut portfolio = PortfolioState::new(10_000.);
|
||||
let result = cursor
|
||||
.advance(original, &mut portfolio, &data, false)
|
||||
.unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].observed_at, original);
|
||||
assert_eq!(result[0].fee_observed_at, fee_time);
|
||||
assert_eq!(result[0].source_total_fee, "0.1200001");
|
||||
assert!(
|
||||
cursor
|
||||
.advance(fee_time, &mut portfolio, &data, false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_any_external_price_or_identity_invalidates_the_frozen_trace() {
|
||||
let input = sample();
|
||||
let original = input.content_sha256.clone();
|
||||
let mut changed = input.clone();
|
||||
changed.actions[0].orders[0].fills[0].price += Decimal::ONE;
|
||||
assert_ne!(changed.content_digest().unwrap(), original);
|
||||
assert_eq!(
|
||||
changed.validate().unwrap_err(),
|
||||
"manual replay content digest mismatch"
|
||||
);
|
||||
let mut changed = input;
|
||||
changed.account_id = "another-account".into();
|
||||
assert_ne!(changed.content_digest().unwrap(), original);
|
||||
assert!(changed.validate().is_err());
|
||||
}
|
||||
|
||||
fn identity_data(listed: NaiveDate) -> DataSet {
|
||||
DataSet::from_components(
|
||||
vec![crate::Instrument {
|
||||
symbol: "000001.SZ".into(),
|
||||
name: "test".into(),
|
||||
board: "SZ".into(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(listed),
|
||||
delisted_at: None,
|
||||
status: "active".into(),
|
||||
}],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![crate::BenchmarkSnapshot {
|
||||
date: listed,
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 100.,
|
||||
close: 100.,
|
||||
prev_close: 100.,
|
||||
volume: 0,
|
||||
}],
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_manual_fill_changes_cash_and_lots_but_not_external_cash_flow_units() {
|
||||
let input = sample();
|
||||
let observations = input.observations().unwrap();
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let mut account = PortfolioState::new(10_000.);
|
||||
let applied = observations[0].apply(&mut account, &data, false).unwrap();
|
||||
assert_eq!(
|
||||
applied.gross,
|
||||
FixedMoney::from_decimal_str("1012.345679").unwrap()
|
||||
);
|
||||
assert_eq!(applied.fees, FixedMoney::from_decimal_str("0.12").unwrap());
|
||||
assert_eq!(account.cash(), 8987.534321);
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
|
||||
assert_eq!(
|
||||
account
|
||||
.position("000001.SZ")
|
||||
.unwrap()
|
||||
.sellable_qty(input.actions[0].orders[0].fills[0].trade_date),
|
||||
0
|
||||
);
|
||||
assert_eq!(account.external_cash_flow_total(), 0.);
|
||||
assert_eq!(account.starting_cash(), 10_000.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_mismatches_are_atomic_and_do_not_borrow_shares_cash_or_override_pending_orders() {
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let input = sample();
|
||||
let observations = input.observations().unwrap();
|
||||
let mut poor = PortfolioState::new(10.);
|
||||
assert!(observations[0].apply(&mut poor, &data, false).is_err());
|
||||
assert_eq!(poor.cash(), 10.);
|
||||
assert!(poor.positions().is_empty());
|
||||
let mut account = PortfolioState::new(10_000.);
|
||||
assert!(observations[0].apply(&mut account, &data, true).is_err());
|
||||
assert_eq!(account.cash(), 10_000.);
|
||||
assert!(account.positions().is_empty());
|
||||
observations[0].apply(&mut account, &data, false).unwrap();
|
||||
let before = account.cash();
|
||||
let mut sell = input.clone();
|
||||
sell.actions[0].orders[0].side = OrderSide::Sell;
|
||||
reseal(&mut sell);
|
||||
assert!(
|
||||
sell.observations().unwrap()[0]
|
||||
.apply(&mut account, &data, false)
|
||||
.unwrap_err()
|
||||
.contains("T+1")
|
||||
);
|
||||
assert_eq!(account.cash(), before);
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
|
||||
let unlisted = identity_data(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap());
|
||||
assert!(
|
||||
observations[0]
|
||||
.apply(&mut account, &unlisted, false)
|
||||
.unwrap_err()
|
||||
.contains("lifecycle")
|
||||
);
|
||||
assert_eq!(account.cash(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_next_day_manual_sale_keeps_the_actual_quantity_and_fee_contract() {
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let input = sample();
|
||||
let mut account = PortfolioState::new(10_000.);
|
||||
input.observations().unwrap()[0]
|
||||
.apply(&mut account, &data, false)
|
||||
.unwrap();
|
||||
let mut sell = input.clone();
|
||||
let order = &mut sell.actions[0].orders[0];
|
||||
order.side = OrderSide::Sell;
|
||||
order.order_created_at += chrono::Duration::days(1);
|
||||
order.terminal_observed_at += chrono::Duration::days(1);
|
||||
order.fills[0].trade_date = order.fills[0].trade_date.succ_opt().unwrap();
|
||||
order.fills[0].executed_at += chrono::Duration::days(1);
|
||||
order.fills[0].observed_at += chrono::Duration::days(1);
|
||||
order.fills[0].fee_observed_at += chrono::Duration::days(1);
|
||||
sell.observation_cutoff += chrono::Duration::days(1);
|
||||
reseal(&mut sell);
|
||||
let applied = sell.observations().unwrap()[0]
|
||||
.apply(&mut account, &data, false)
|
||||
.unwrap();
|
||||
assert_eq!(applied.quantity_after, 0);
|
||||
assert_eq!(account.cash(), 9999.76);
|
||||
assert_eq!(account.external_cash_flow_total(), 0.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observations_follow_durable_receipt_order_and_not_input_array_order() {
|
||||
let mut input = sample();
|
||||
let mut second = input.actions[0].orders[0].fills[0].clone();
|
||||
second.trade_id = "trade-2".into();
|
||||
second.observation_event_id = "received-2".into();
|
||||
second.observation_sequence = 2;
|
||||
second.fee_observation_event_id = "received-2".into();
|
||||
second.fee_observation_sequence = 2;
|
||||
input.actions[0].orders[0].quantity = 200;
|
||||
input.actions[0].orders[0].fills.insert(0, second);
|
||||
reseal(&mut input);
|
||||
assert_eq!(
|
||||
input
|
||||
.observations()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|row| row.fill.observation_sequence)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![1, 2]
|
||||
);
|
||||
let mut invalid = input.clone();
|
||||
invalid.actions[0].orders[0].fills[0].observation_sequence = 1;
|
||||
assert!(
|
||||
semantic_result(&invalid)
|
||||
.unwrap_err()
|
||||
.contains("observation")
|
||||
);
|
||||
let mut invalid = input;
|
||||
invalid.actions[0].orders[0].fills[0].observation_event_id = "received-1".into();
|
||||
assert!(
|
||||
semantic_result(&invalid)
|
||||
.unwrap_err()
|
||||
.contains("observation")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_cancel_is_valid_but_full_fill_cannot_be_reported_as_cancelled() {
|
||||
let mut input = sample();
|
||||
input.actions[0].orders[0].quantity = 200;
|
||||
input.actions[0].orders[0].terminal_status = ManualOrderTerminalStatus::Cancelled;
|
||||
semantic_result(&input).unwrap();
|
||||
input.actions[0].orders[0].quantity = 100;
|
||||
assert!(
|
||||
semantic_result(&input)
|
||||
.unwrap_err()
|
||||
.contains("terminal status")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_waits_for_observation_and_never_reapplies_or_rewinds() {
|
||||
let input = sample();
|
||||
let at = input.actions[0].orders[0].fills[0].observed_at;
|
||||
let mut replay = ManualReplayCursor::new(input).unwrap();
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let mut account = PortfolioState::new(10_000.);
|
||||
assert_eq!(replay.next_observation_at(), Some(at));
|
||||
assert!(
|
||||
replay
|
||||
.advance(
|
||||
at - chrono::Duration::milliseconds(1),
|
||||
&mut account,
|
||||
&data,
|
||||
false
|
||||
)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(account.cash(), 10_000.);
|
||||
let records = replay.advance(at, &mut account, &data, false).unwrap();
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].cash_delta, "-1012.465679");
|
||||
assert_eq!(replay.applied_count(), 1);
|
||||
assert_eq!(replay.next_observation_at(), None);
|
||||
let cash = account.cash();
|
||||
assert!(
|
||||
replay
|
||||
.advance(at, &mut account, &data, false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(account.cash(), cash);
|
||||
assert!(
|
||||
replay
|
||||
.advance(
|
||||
at - chrono::Duration::seconds(1),
|
||||
&mut account,
|
||||
&data,
|
||||
false
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("backwards")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_multi_receipt_advance_keeps_both_progress_and_portfolio_unchanged() {
|
||||
let mut input = sample();
|
||||
let mut next = input.actions[0].orders[0].fills[0].clone();
|
||||
next.trade_id = "trade-2".into();
|
||||
next.observation_event_id = "received-2".into();
|
||||
next.observation_sequence = 2;
|
||||
next.fee_observation_event_id = "received-2".into();
|
||||
next.fee_observation_sequence = 2;
|
||||
input.actions[0].orders[0].quantity = 200;
|
||||
input.actions[0].orders[0].fills.push(next);
|
||||
reseal(&mut input);
|
||||
let at = input.actions[0].orders[0].fills[0].observed_at;
|
||||
let mut replay = ManualReplayCursor::new(input).unwrap();
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let mut account = PortfolioState::new(1_500.);
|
||||
assert!(replay.advance(at, &mut account, &data, false).is_err());
|
||||
assert_eq!(account.cash(), 1_500.);
|
||||
assert!(account.positions().is_empty());
|
||||
assert_eq!(replay.applied_count(), 0);
|
||||
assert_eq!(replay.next_observation_at(), Some(at));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_money_decimal_text_preserves_micro_units_without_float_conversion() {
|
||||
for text in [
|
||||
"0",
|
||||
"100",
|
||||
"-100",
|
||||
"0.000001",
|
||||
"-0.000001",
|
||||
"12345678901234567890123456.123456",
|
||||
] {
|
||||
assert_eq!(
|
||||
FixedMoney::from_decimal_str(text)
|
||||
.unwrap()
|
||||
.to_decimal_string(),
|
||||
text
|
||||
);
|
||||
}
|
||||
let min = FixedMoney::from_raw(i128::MIN);
|
||||
assert!(min.to_decimal_string().starts_with('-'));
|
||||
}
|
||||
@@ -93,6 +93,15 @@ pub fn compute_backtest_metrics(
|
||||
account_events: &[AccountEvent],
|
||||
initial_cash: f64,
|
||||
risk_free_contract: Option<&RiskFreeRateContract>,
|
||||
) -> Result<BacktestMetrics, String> {
|
||||
compute_backtest_metrics_with_manual(equity_curve, fills, &[], daily_holdings, account_events, initial_cash, risk_free_contract)
|
||||
}
|
||||
|
||||
pub fn compute_backtest_metrics_with_manual(
|
||||
equity_curve: &[DailyEquityPoint], fills: &[FillEvent],
|
||||
manual_executions: &[crate::manual_execution::ManualReplayApplication],
|
||||
daily_holdings: &[HoldingSummary], account_events: &[AccountEvent], initial_cash: f64,
|
||||
risk_free_contract: Option<&RiskFreeRateContract>,
|
||||
) -> Result<BacktestMetrics, String> {
|
||||
let Some(first_point) = equity_curve.first() else {
|
||||
return Ok(BacktestMetrics {
|
||||
@@ -229,12 +238,20 @@ pub fn compute_backtest_metrics(
|
||||
);
|
||||
let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR);
|
||||
|
||||
let turnover_by_date = fills
|
||||
let mut turnover_by_date = fills
|
||||
.iter()
|
||||
.fold(BTreeMap::<NaiveDate, f64>::new(), |mut acc, fill| {
|
||||
*acc.entry(fill.date).or_default() += fill.gross_amount.abs();
|
||||
acc
|
||||
});
|
||||
for execution in manual_executions {
|
||||
use rust_decimal::prelude::ToPrimitive;
|
||||
let gross = execution.ledger_gross_amount.parse::<rust_decimal::Decimal>()
|
||||
.ok().and_then(|value| value.to_f64()).filter(|value| value.is_finite() && *value >= 0.)
|
||||
.ok_or("manual turnover requires its validated ledger gross amount")?;
|
||||
let date = execution.observed_at.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).date_naive();
|
||||
*turnover_by_date.entry(date).or_default() += gross;
|
||||
}
|
||||
let equity_by_date = equity_curve
|
||||
.iter()
|
||||
.map(|point| (point.date, point.total_equity))
|
||||
|
||||
@@ -653,6 +653,8 @@ pub struct PlatformExprStrategyConfig {
|
||||
pub exposure_expr: String,
|
||||
pub position_exposure_schedule: BTreeMap<NaiveDate, f64>,
|
||||
pub position_exposure_timeline: crate::position_exposure::PositionExposureTimeline,
|
||||
pub runtime_position_exposure_timeline: crate::position_exposure::PositionExposureTimeline,
|
||||
pub runtime_position_exposure_schedule: BTreeMap<NaiveDate, f64>,
|
||||
pub portfolio_drawdown_control: Option<PlatformPortfolioDrawdownControlConfig>,
|
||||
pub portfolio_loss_control: Option<PortfolioLossConfig>,
|
||||
pub stop_loss_expr: String,
|
||||
@@ -742,7 +744,11 @@ impl PlatformExprStrategyConfig {
|
||||
buy_scale_expr: "1.0".to_string(),
|
||||
exposure_expr: "1.0".to_string(),
|
||||
position_exposure_schedule: BTreeMap::new(),
|
||||
position_exposure_timeline: crate::position_exposure::PositionExposureTimeline::default(),
|
||||
position_exposure_timeline: crate::position_exposure::PositionExposureTimeline::default(
|
||||
),
|
||||
runtime_position_exposure_timeline:
|
||||
crate::position_exposure::PositionExposureTimeline::default(),
|
||||
runtime_position_exposure_schedule: BTreeMap::new(),
|
||||
portfolio_drawdown_control: None,
|
||||
portfolio_loss_control: None,
|
||||
stop_loss_expr: String::new(),
|
||||
@@ -8656,13 +8662,28 @@ impl PlatformExprStrategy {
|
||||
let strategy_exposure = self
|
||||
.eval_float(ctx, &self.config.exposure_expr, day, None, None)?
|
||||
.clamp(0.0, 1.0);
|
||||
let risk_on_exposure = self.config.position_exposure_timeline.exposure_at(
|
||||
portfolio_loss_decision_at(ctx), ctx.execution_date, &self.config.position_exposure_schedule,
|
||||
let risk_on_exposure = self
|
||||
.config
|
||||
.position_exposure_timeline
|
||||
.exposure_at(
|
||||
portfolio_loss_decision_at(ctx),
|
||||
ctx.execution_date,
|
||||
&self.config.position_exposure_schedule,
|
||||
strategy_exposure,
|
||||
)
|
||||
.unwrap_or(strategy_exposure)
|
||||
.clamp(0.0, 1.0);
|
||||
let mut exposure = risk_on_exposure;
|
||||
let mut exposure = self
|
||||
.config
|
||||
.runtime_position_exposure_timeline
|
||||
.exposure_at(
|
||||
portfolio_loss_decision_at(ctx),
|
||||
ctx.execution_date,
|
||||
&self.config.runtime_position_exposure_schedule,
|
||||
risk_on_exposure,
|
||||
)
|
||||
.unwrap_or(risk_on_exposure)
|
||||
.clamp(0., 1.);
|
||||
if let Some(controller) = self.portfolio_drawdown_controller.as_mut() {
|
||||
exposure = controller.update(ctx.decision_date, day.total_value, exposure)?.target_exposure;
|
||||
}
|
||||
@@ -9986,10 +10007,28 @@ impl PlatformExprStrategy {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(bps)=self.config.position_exposure_timeline.scale_at(portfolio_loss_decision_at(ctx)) {
|
||||
for bps in [
|
||||
self.config
|
||||
.position_exposure_timeline
|
||||
.scale_at(portfolio_loss_decision_at(ctx)),
|
||||
self.config
|
||||
.runtime_position_exposure_timeline
|
||||
.scale_at(portfolio_loss_decision_at(ctx)),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let before = intents.len();
|
||||
intents=intents.into_iter().map(|intent|crate::position_exposure::scale_explicit_intent(intent,bps,ctx.open_orders))
|
||||
.collect::<Result<Vec<_>,_>>().map_err(BacktestError::Execution)?.into_iter().flatten().collect();
|
||||
intents = intents
|
||||
.into_iter()
|
||||
.map(|intent| {
|
||||
crate::position_exposure::scale_explicit_intent(intent, bps, ctx.open_orders)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(BacktestError::Execution)?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
diagnostics.push(format!("position_override_scale requested_bps={bps} original_intents={before} emitted_intents={}",intents.len()));
|
||||
}
|
||||
Ok((intents, diagnostics))
|
||||
@@ -12396,6 +12435,43 @@ impl PlatformExprStrategy {
|
||||
}
|
||||
|
||||
impl Strategy for PlatformExprStrategy {
|
||||
fn bind_runtime_position_configuration(
|
||||
&mut self,
|
||||
events: &[crate::position_exposure::PositionExposureEvent],
|
||||
legacy: &BTreeMap<NaiveDate, i32>,
|
||||
) -> Result<(), BacktestError> {
|
||||
let timeline = crate::position_exposure::PositionExposureTimeline::from_events(events)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
if legacy.values().any(|value| !(0..=10000).contains(value)) {
|
||||
return Err(BacktestError::Execution(
|
||||
"invalid runtime exposure schedule".into(),
|
||||
));
|
||||
}
|
||||
self.config.runtime_position_exposure_timeline = timeline;
|
||||
self.config.runtime_position_exposure_schedule = legacy
|
||||
.iter()
|
||||
.map(|(date, bps)| (*date, f64::from(*bps) / 10000.))
|
||||
.collect();
|
||||
Ok(())
|
||||
}
|
||||
fn on_observed_manual_execution(
|
||||
&mut self,
|
||||
execution: &crate::manual_execution::ManualReplayApplication,
|
||||
) -> Result<(), BacktestError> {
|
||||
let date = execution
|
||||
.executed_at
|
||||
.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap())
|
||||
.date_naive();
|
||||
let history = match execution.side {
|
||||
OrderSide::Buy => &mut self.protection_last_buys,
|
||||
OrderSide::Sell => &mut self.protection_last_sells,
|
||||
};
|
||||
history
|
||||
.entry(execution.symbol.clone())
|
||||
.and_modify(|previous| *previous = (*previous).max(date))
|
||||
.or_insert(date);
|
||||
Ok(())
|
||||
}
|
||||
fn name(&self) -> &str {
|
||||
self.config.strategy_name.as_str()
|
||||
}
|
||||
@@ -12747,16 +12823,22 @@ impl PlatformExprStrategy {
|
||||
let mut symbols = ctx.portfolio.positions().keys().cloned().collect::<BTreeSet<_>>();
|
||||
symbols.extend(self.protection_last_sells.keys().cloned());
|
||||
symbols.extend(policy.locks.iter().map(|lock| lock.symbol.clone()));
|
||||
symbols.extend(ctx.portfolio.observed_successor_symbols().map(str::to_owned));
|
||||
self.automatic_trade_permissions.clear();
|
||||
self.automatic_holding_days.clear();
|
||||
for symbol in symbols {
|
||||
let position = ctx.portfolio.position(&symbol).filter(|position| position.quantity > 0);
|
||||
let last_observed = |history: &BTreeMap<String, NaiveDate>| std::iter::once(symbol.as_str())
|
||||
.chain(ctx.portfolio.corporate_predecessors(&symbol))
|
||||
.filter_map(|symbol| history.get(symbol).copied()).max();
|
||||
let evidence = HoldingLifecycleEvidence {
|
||||
has_position: position.is_some(), opened_date: position.and_then(|position| position.opened_date()),
|
||||
last_buy_date: self.protection_last_buys.get(&symbol).copied().into_iter().chain(position.and_then(|position|position.last_buy_date())).max(),
|
||||
last_sell_date: self.protection_last_sells.get(&symbol).copied(),
|
||||
last_buy_date: last_observed(&self.protection_last_buys).into_iter()
|
||||
.chain(position.and_then(|position|position.last_buy_date())).max(),
|
||||
last_sell_date: last_observed(&self.protection_last_sells),
|
||||
};
|
||||
let permission = policy.evaluate(&symbol, ctx.execution_date, &evidence, ctx.data.calendar()).map_err(BacktestError::Execution)?;
|
||||
let permission = policy.evaluate_with_predecessors(&symbol, ctx.execution_date, &evidence,
|
||||
ctx.data.calendar(), ctx.portfolio.corporate_predecessors(&symbol)).map_err(BacktestError::Execution)?;
|
||||
if let Some(opened) = evidence.opened_date
|
||||
&& let (Some(start), Some(end)) = (ctx.data.calendar().index_of(opened), ctx.data.calendar().index_of(ctx.execution_date)) {
|
||||
self.automatic_holding_days.insert(symbol.clone(), end.saturating_sub(start) as i64);
|
||||
|
||||
@@ -182,6 +182,15 @@ impl PlatformExprStrategy {
|
||||
scope.push(symbol)
|
||||
}
|
||||
}
|
||||
let allocation_weights = self
|
||||
.config
|
||||
.runtime_position_exposure_timeline
|
||||
.allocation_weights_at(portfolio_loss_decision_at(ctx))
|
||||
.or_else(|| {
|
||||
self.config
|
||||
.position_exposure_timeline
|
||||
.allocation_weights_at(portfolio_loss_decision_at(ctx))
|
||||
});
|
||||
let members = scope
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -198,15 +207,35 @@ impl PlatformExprStrategy {
|
||||
take_profit: constraints.default_take_profit,
|
||||
});
|
||||
member.requested_order = index as i32;
|
||||
if let Some(weights) = allocation_weights {
|
||||
member.target_weight_bps = Some(*weights.get(symbol).unwrap_or(&0));
|
||||
}
|
||||
member
|
||||
})
|
||||
.collect();
|
||||
let (base_ratio, reserve_cash) =
|
||||
pool::stock_pool_funding_from_configuration(&program.allocation_policy)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
let ratio = self.config.position_exposure_timeline
|
||||
.exposure_at(portfolio_loss_decision_at(ctx), ctx.execution_date, &self.config.position_exposure_schedule,
|
||||
f64::from(base_ratio)/10000.)
|
||||
let base_exposure = self
|
||||
.config
|
||||
.position_exposure_timeline
|
||||
.exposure_at(
|
||||
portfolio_loss_decision_at(ctx),
|
||||
ctx.execution_date,
|
||||
&self.config.position_exposure_schedule,
|
||||
f64::from(base_ratio) / 10000.,
|
||||
)
|
||||
.unwrap_or(f64::from(base_ratio) / 10000.);
|
||||
let ratio = self
|
||||
.config
|
||||
.runtime_position_exposure_timeline
|
||||
.exposure_at(
|
||||
portfolio_loss_decision_at(ctx),
|
||||
ctx.execution_date,
|
||||
&self.config.runtime_position_exposure_schedule,
|
||||
base_exposure,
|
||||
)
|
||||
.or(Some(base_exposure))
|
||||
.map(|value| (value * 10000.).round() as i64)
|
||||
.unwrap_or(i64::from(base_ratio));
|
||||
let invest_ratio_bps = i32::try_from(ratio)
|
||||
|
||||
@@ -138,18 +138,30 @@ impl Position {
|
||||
if quantity == 0 {
|
||||
return;
|
||||
}
|
||||
let gross_amount = fixed_money_or_panic(execution_price * quantity as f64, "position buy gross amount");
|
||||
self.buy_with_fixed_gross(date,quantity,execution_price,mark_price,gross_amount);
|
||||
}
|
||||
|
||||
fn buy_with_fixed_gross(
|
||||
&mut self,
|
||||
date: NaiveDate,
|
||||
quantity: u32,
|
||||
execution_price: f64,
|
||||
mark_price: f64,
|
||||
gross_amount: FixedMoney,
|
||||
) {
|
||||
let previous_quantity = self.quantity;
|
||||
self.last_buy_date = Some(self.last_buy_date.map_or(date, |previous| previous.max(date)));
|
||||
self.last_buy_date = Some(
|
||||
self.last_buy_date
|
||||
.map_or(date, |previous| previous.max(date)),
|
||||
);
|
||||
if previous_quantity == 0 {
|
||||
self.opened_date = Some(date);
|
||||
} else if let Some(opened) = self.opened_date {
|
||||
self.opened_date = Some(opened.min(date));
|
||||
}
|
||||
let previous_average_price = self.average_price;
|
||||
let previous_average_cost = self.average_cost;
|
||||
let gross_amount = fixed_money_or_panic(
|
||||
execution_price * quantity as f64,
|
||||
"position buy gross amount",
|
||||
);
|
||||
self.lots.push(PositionLot {
|
||||
acquired_date: date,
|
||||
quantity,
|
||||
@@ -200,6 +212,20 @@ impl Position {
|
||||
quantity: u32,
|
||||
execution_price: f64,
|
||||
mark_price: f64,
|
||||
) -> Result<f64, String> {
|
||||
if quantity > self.quantity {
|
||||
return Err(format!("sell quantity {} exceeds current quantity {} for {}",quantity,self.quantity,self.symbol));
|
||||
}
|
||||
let total_proceeds = fixed_money(execution_price * quantity as f64,"position sell gross amount")?;
|
||||
self.sell_with_fixed_gross(quantity,execution_price,mark_price,total_proceeds)
|
||||
}
|
||||
|
||||
fn sell_with_fixed_gross(
|
||||
&mut self,
|
||||
quantity: u32,
|
||||
execution_price: f64,
|
||||
mark_price: f64,
|
||||
total_proceeds: FixedMoney,
|
||||
) -> Result<f64, String> {
|
||||
if quantity > self.quantity {
|
||||
return Err(format!(
|
||||
@@ -208,10 +234,17 @@ impl Position {
|
||||
));
|
||||
}
|
||||
|
||||
let total_proceeds = fixed_money(
|
||||
execution_price * quantity as f64,
|
||||
"position sell gross amount",
|
||||
)?;
|
||||
// A delayed receipt or a successor conversion can append an older
|
||||
// acquisition after a newer lot. Deplete by actual acquisition date;
|
||||
// stable ordering preserves same-day receipts and their attached fees.
|
||||
if quantity > 0
|
||||
&& self
|
||||
.lots
|
||||
.windows(2)
|
||||
.any(|pair| pair[0].acquired_date > pair[1].acquired_date)
|
||||
{
|
||||
self.lots.sort_by_key(|lot| lot.acquired_date);
|
||||
}
|
||||
let mut remaining = quantity;
|
||||
let mut remaining_proceeds = total_proceeds;
|
||||
let mut realized = FixedMoney::ZERO;
|
||||
@@ -676,6 +709,9 @@ pub struct PortfolioState {
|
||||
cash_receivables: Vec<CashReceivable>,
|
||||
pending_cash_flows: Vec<PendingCashFlow>,
|
||||
day_sold_symbols: BTreeSet<String>,
|
||||
// Observed holding conversions, never a catalog alias or a new target.
|
||||
// Kept after a position becomes flat so an active date lock is not lost.
|
||||
corporate_predecessors: BTreeMap<String, BTreeSet<String>>,
|
||||
stock_pool_states: std::collections::BTreeMap<String,crate::stock_pool_state::StockPoolExecutionState>,
|
||||
}
|
||||
|
||||
@@ -701,6 +737,10 @@ impl PortfolioState {
|
||||
pub fn new(initial_cash: f64) -> Self {
|
||||
let initial_cash = fixed_money(initial_cash, "initial cash")
|
||||
.expect("initial cash must be finite fixed-point money");
|
||||
Self::from_fixed_initial_cash(initial_cash)
|
||||
}
|
||||
|
||||
pub(crate) fn from_fixed_initial_cash(initial_cash: FixedMoney) -> Self {
|
||||
Self {
|
||||
initial_cash,
|
||||
units: initial_cash,
|
||||
@@ -713,6 +753,7 @@ impl PortfolioState {
|
||||
cash_receivables: Vec::new(),
|
||||
pending_cash_flows: Vec::new(),
|
||||
day_sold_symbols: BTreeSet::new(),
|
||||
corporate_predecessors: BTreeMap::new(),
|
||||
stock_pool_states: std::collections::BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -723,6 +764,16 @@ impl PortfolioState {
|
||||
self.initial_cash.to_f64()
|
||||
}
|
||||
|
||||
pub(crate) fn initial_cash_fixed(&self) -> FixedMoney { self.initial_cash }
|
||||
|
||||
pub(crate) fn corporate_predecessors(&self, symbol: &str) -> impl Iterator<Item = &str> {
|
||||
self.corporate_predecessors.get(symbol).into_iter().flatten().map(String::as_str)
|
||||
}
|
||||
|
||||
pub(crate) fn observed_successor_symbols(&self) -> impl Iterator<Item = &str> {
|
||||
self.corporate_predecessors.keys().map(String::as_str)
|
||||
}
|
||||
|
||||
pub(crate) fn stock_pool_execution_state(&self,pool_id:&str)->crate::stock_pool_state::StockPoolExecutionState{
|
||||
self.stock_pool_states.get(pool_id).cloned().unwrap_or_default()
|
||||
}
|
||||
@@ -754,6 +805,53 @@ impl PortfolioState {
|
||||
self.cash.to_f64()
|
||||
}
|
||||
|
||||
pub(crate) fn cash_fixed(&self) -> FixedMoney { self.cash }
|
||||
|
||||
pub(crate) fn financial_replay_identity(&self) -> serde_json::Value {
|
||||
let positions = self.positions.iter().filter(|(_, position)| position.quantity > 0)
|
||||
.map(|(symbol, position)| {
|
||||
let mut lots = position.lots.iter().map(|lot| (lot.acquired_date, lot.quantity,
|
||||
lot.entry_value.to_decimal_string(), lot.cost_basis.to_decimal_string())).collect::<Vec<_>>();
|
||||
lots.sort();
|
||||
(symbol.clone(), serde_json::json!({"quantity":position.quantity,"lots":lots,
|
||||
"openedDate":position.opened_date,"lastBuyDate":position.last_buy_date}))
|
||||
}).collect::<std::collections::BTreeMap<_, _>>();
|
||||
let mut receivables = self.cash_receivables.iter().map(|row| (row.symbol.clone(), row.ex_date,
|
||||
row.payable_date, fixed_money_or_panic(row.amount, "receivable identity").to_decimal_string(), row.reason.clone())).collect::<Vec<_>>();
|
||||
receivables.sort();
|
||||
let mut identity = serde_json::json!({"cash":self.cash.to_decimal_string(),"positions":positions,"receivables":receivables});
|
||||
if !self.corporate_predecessors.is_empty() {
|
||||
identity["corporatePredecessors"] = serde_json::json!(self.corporate_predecessors);
|
||||
}
|
||||
identity
|
||||
}
|
||||
|
||||
pub(crate) fn financial_position_basis(&self, symbol: &str) -> FixedMoney {
|
||||
self.positions.get(symbol).map_or(FixedMoney::ZERO, Position::total_cost_basis)
|
||||
}
|
||||
|
||||
pub(crate) fn replace_replayed_financial_book(&mut self, mut replayed: PortfolioState) -> Result<(), String> {
|
||||
if replayed.cash < FixedMoney::ZERO || replayed.initial_cash != self.initial_cash {
|
||||
return Err("manual corporate replay changed initial capital or borrowed cash".into());
|
||||
}
|
||||
for (symbol, position) in &mut replayed.positions {
|
||||
if let Some(current) = self.positions.get(symbol).filter(|current| current.quantity > 0) {
|
||||
position.last_price = current.last_price;
|
||||
position.refresh_day_pnl();
|
||||
}
|
||||
}
|
||||
self.cash = replayed.cash;
|
||||
self.positions = replayed.positions;
|
||||
self.cash_receivables = replayed.cash_receivables;
|
||||
self.day_sold_symbols = replayed.day_sold_symbols;
|
||||
// Corrected actual receipts can prove a position was fully sold
|
||||
// before conversion. Do not retain a now-disproved financial link.
|
||||
self.corporate_predecessors = replayed.corporate_predecessors;
|
||||
// Existing issued units, explicit cash-flow/financing facts, and task
|
||||
// target state are observed controls, not counterfactual new orders.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn external_cash_flow_total(&self) -> f64 {
|
||||
self.external_cash_flow_total.to_f64()
|
||||
}
|
||||
@@ -789,13 +887,117 @@ impl PortfolioState {
|
||||
}
|
||||
|
||||
pub fn apply_cash_delta(&mut self, delta: f64) -> Result<(), String> {
|
||||
self.apply_cash_delta_fixed(fixed_money(delta, "cash delta")?)
|
||||
}
|
||||
|
||||
pub(crate) fn apply_cash_delta_fixed(&mut self, delta: FixedMoney) -> Result<(), String> {
|
||||
self.cash = self
|
||||
.cash
|
||||
.checked_add(fixed_money(delta, "cash delta")?)
|
||||
.checked_add(delta)
|
||||
.ok_or_else(|| "fixed-point cash overflow".to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply one fully observed external fill atomically. Its money is already
|
||||
/// quantized from the original decimal amounts, not from a float product.
|
||||
pub(crate) fn apply_observed_manual_fill(
|
||||
&mut self,
|
||||
trade_date: NaiveDate,
|
||||
symbol: &str,
|
||||
side: crate::events::OrderSide,
|
||||
quantity: u32,
|
||||
price: f64,
|
||||
mark_price: f64,
|
||||
gross: FixedMoney,
|
||||
fees: FixedMoney,
|
||||
) -> Result<FixedMoney, String> {
|
||||
use crate::events::OrderSide;
|
||||
if symbol.trim().is_empty()
|
||||
|| quantity == 0
|
||||
|| quantity > i32::MAX as u32
|
||||
|| !price.is_finite()
|
||||
|| price <= 0.
|
||||
|| !mark_price.is_finite()
|
||||
|| mark_price <= 0.
|
||||
|| gross <= FixedMoney::ZERO
|
||||
|| fees < FixedMoney::ZERO
|
||||
{
|
||||
return Err("invalid observed manual fill".into());
|
||||
}
|
||||
let mut position = self
|
||||
.positions
|
||||
.get(symbol)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Position::new(symbol));
|
||||
let delta = match side {
|
||||
OrderSide::Buy => gross.checked_add(fees).and_then(FixedMoney::checked_neg),
|
||||
OrderSide::Sell => gross.checked_sub(fees),
|
||||
}
|
||||
.ok_or("manual fill cash delta overflow")?;
|
||||
let next_cash = self
|
||||
.cash
|
||||
.checked_add(delta)
|
||||
.filter(|cash| *cash >= FixedMoney::ZERO)
|
||||
.ok_or("manual fill disagrees with shadow available cash")?;
|
||||
let next_cost = position
|
||||
.day_trade_cost
|
||||
.checked_add(fees)
|
||||
.ok_or("manual trade cost overflow")?;
|
||||
match side {
|
||||
OrderSide::Buy => {
|
||||
let total_quantity = position
|
||||
.quantity
|
||||
.checked_add(quantity)
|
||||
.ok_or("manual position quantity overflow")?;
|
||||
FixedMoney::from_f64(mark_price * f64::from(total_quantity))
|
||||
.ok_or("manual marked position value overflow")?;
|
||||
position
|
||||
.day_buy_quantity
|
||||
.checked_add(quantity)
|
||||
.ok_or("manual daily buy quantity overflow")?;
|
||||
position
|
||||
.day_trade_quantity_delta
|
||||
.checked_add(quantity as i32)
|
||||
.ok_or("manual daily quantity delta overflow")?;
|
||||
position
|
||||
.day_buy_value
|
||||
.checked_add(gross)
|
||||
.ok_or("manual daily buy value overflow")?;
|
||||
let total_basis = gross.checked_add(fees).ok_or("manual lot basis overflow")?;
|
||||
position
|
||||
.total_cost_basis()
|
||||
.checked_add(total_basis)
|
||||
.ok_or("manual aggregate position basis overflow")?;
|
||||
position.buy_with_fixed_gross(trade_date, quantity, price, mark_price, gross);
|
||||
position
|
||||
.lots
|
||||
.last_mut()
|
||||
.ok_or("manual buy produced no lot")?
|
||||
.cost_basis = total_basis;
|
||||
position.average_cost += fees.to_f64() / f64::from(position.quantity);
|
||||
}
|
||||
OrderSide::Sell => {
|
||||
if quantity > position.sellable_qty(trade_date) {
|
||||
return Err("manual fill disagrees with shadow sellable holdings or T+1".into());
|
||||
}
|
||||
position
|
||||
.day_sell_quantity
|
||||
.checked_add(quantity)
|
||||
.ok_or("manual daily sell quantity overflow")?;
|
||||
position
|
||||
.day_trade_quantity_delta
|
||||
.checked_sub(quantity as i32)
|
||||
.ok_or("manual daily quantity delta overflow")?;
|
||||
position.sell_with_fixed_gross(quantity, price, mark_price, gross)?;
|
||||
}
|
||||
}
|
||||
position.day_trade_cost = next_cost;
|
||||
position.refresh_day_pnl();
|
||||
self.positions.insert(symbol.to_string(), position);
|
||||
self.cash = next_cash;
|
||||
Ok(delta)
|
||||
}
|
||||
|
||||
pub fn prune_flat_positions(&mut self) {
|
||||
let mut sold_symbols = Vec::new();
|
||||
self.positions.retain(|symbol, position| {
|
||||
@@ -1374,7 +1576,7 @@ impl PortfolioState {
|
||||
}
|
||||
successor.refresh_day_pnl();
|
||||
|
||||
Some(SuccessorConversionOutcome {
|
||||
let outcome = SuccessorConversionOutcome {
|
||||
old_symbol: old_symbol_owned,
|
||||
new_symbol: new_symbol.to_string(),
|
||||
old_quantity,
|
||||
@@ -1386,7 +1588,14 @@ impl PortfolioState {
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
})
|
||||
};
|
||||
if converted_quantity > 0 {
|
||||
let mut predecessors = self.corporate_predecessors.get(old_symbol).cloned().unwrap_or_default();
|
||||
predecessors.insert(old_symbol.to_owned());
|
||||
predecessors.remove(new_symbol);
|
||||
self.corporate_predecessors.entry(new_symbol.to_owned()).or_default().extend(predecessors);
|
||||
}
|
||||
Some(outcome)
|
||||
}
|
||||
|
||||
fn sum_fixed_money(
|
||||
|
||||
@@ -25,13 +25,19 @@ pub struct PositionExposureEvent {
|
||||
pub sequence: u64,
|
||||
#[serde(alias = "effective_at")]
|
||||
pub effective_at: DateTime<Utc>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
alias = "allocation_weights_bps"
|
||||
)]
|
||||
pub allocation_weights_bps: Option<BTreeMap<String, i32>>,
|
||||
#[serde(flatten)]
|
||||
pub action: PositionExposureAction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PositionExposureTimeline {
|
||||
events: BTreeMap<(DateTime<Utc>, u64), PositionExposureAction>,
|
||||
events: BTreeMap<(DateTime<Utc>, u64), (PositionExposureAction, Option<BTreeMap<String, i32>>)>,
|
||||
}
|
||||
|
||||
impl PositionExposureTimeline {
|
||||
@@ -58,9 +64,24 @@ impl PositionExposureTimeline {
|
||||
{
|
||||
return Err("position exposure target must be between 0 and 10000 bps".into());
|
||||
}
|
||||
result
|
||||
.events
|
||||
.insert((event.effective_at, event.sequence), event.action.clone());
|
||||
if let Some(weights) = &event.allocation_weights_bps {
|
||||
let target = match event.action {
|
||||
PositionExposureAction::Set {
|
||||
target_exposure_bps,
|
||||
} => target_exposure_bps,
|
||||
PositionExposureAction::Scale { requested_bps } => requested_bps,
|
||||
PositionExposureAction::Restore => {
|
||||
return Err(
|
||||
"restoring strategy allocation cannot carry manual weights".into()
|
||||
);
|
||||
}
|
||||
};
|
||||
validate_allocation_weights(weights, target)?;
|
||||
}
|
||||
result.events.insert(
|
||||
(event.effective_at, event.sequence),
|
||||
(event.action.clone(), event.allocation_weights_bps.clone()),
|
||||
);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
@@ -77,7 +98,7 @@ impl PositionExposureTimeline {
|
||||
.events
|
||||
.range(..=(at, u64::MAX))
|
||||
.next_back()
|
||||
.map(|(_, action)| action)
|
||||
.map(|(_, (action, _))| action)
|
||||
{
|
||||
Some(PositionExposureAction::Scale { requested_bps }) => {
|
||||
Some(strategy_exposure * f64::from(*requested_bps) / 10000.)
|
||||
@@ -98,12 +119,47 @@ impl PositionExposureTimeline {
|
||||
.events
|
||||
.range(..=(at, u64::MAX))
|
||||
.next_back()
|
||||
.map(|(_, action)| action)
|
||||
.map(|(_, (action, _))| action)
|
||||
{
|
||||
Some(PositionExposureAction::Scale { requested_bps }) => Some(*requested_bps),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allocation_weights_at(&self, at: DateTime<Utc>) -> Option<&BTreeMap<String, i32>> {
|
||||
self.events
|
||||
.range(..=(at, u64::MAX))
|
||||
.next_back()
|
||||
.and_then(|(_, (_, weights))| weights.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_allocation_weights(
|
||||
weights: &BTreeMap<String, i32>,
|
||||
exposure_bps: i32,
|
||||
) -> Result<(), String> {
|
||||
if !(0..=10000).contains(&exposure_bps) || weights.len() > 10000 {
|
||||
return Err("invalid allocation exposure or weight count".into());
|
||||
}
|
||||
for (symbol, weight) in weights {
|
||||
if !(0..=10000).contains(weight)
|
||||
|| !symbol.rsplit_once('.').is_some_and(|(code, exchange)| {
|
||||
code.len() == 6
|
||||
&& code.bytes().all(|byte| byte.is_ascii_digit())
|
||||
&& matches!(exchange, "SH" | "SZ" | "BJ")
|
||||
})
|
||||
{
|
||||
return Err(
|
||||
"allocation weights require canonical stock/ETF symbols and 0..10000 bps".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (weights.is_empty() && exposure_bps != 0)
|
||||
|| (!weights.is_empty() && weights.values().sum::<i32>() != 10000)
|
||||
{
|
||||
return Err("manual allocation weights must total 10000 bps; only a zero exposure may have no weights".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Scale new buys and desired targets without weakening sell/reduction or
|
||||
@@ -241,6 +297,7 @@ mod tests {
|
||||
event_id: "scale".into(),
|
||||
sequence: 1,
|
||||
effective_at: at,
|
||||
allocation_weights_bps: None,
|
||||
action: PositionExposureAction::Scale {
|
||||
requested_bps: 5000,
|
||||
},
|
||||
@@ -258,6 +315,7 @@ mod tests {
|
||||
event_id: "restore".into(),
|
||||
sequence: 2,
|
||||
effective_at: at,
|
||||
allocation_weights_bps: None,
|
||||
action: PositionExposureAction::Restore,
|
||||
};
|
||||
let timeline = PositionExposureTimeline::from_events(&[event, restored]).unwrap();
|
||||
@@ -274,6 +332,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_is_dated_and_any_later_scalar_or_restore_clears_it() {
|
||||
let at = DateTime::parse_from_rfc3339("2026-09-14T10:00:00+08:00")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc);
|
||||
let weights = BTreeMap::from([("000001.SZ".into(), 3000), ("510300.SH".into(), 7000)]);
|
||||
let event = PositionExposureEvent {
|
||||
event_id: "allocation".into(),
|
||||
sequence: 1,
|
||||
effective_at: at,
|
||||
action: PositionExposureAction::Set {
|
||||
target_exposure_bps: 8000,
|
||||
},
|
||||
allocation_weights_bps: Some(weights.clone()),
|
||||
};
|
||||
let timeline = PositionExposureTimeline::from_events(&[event.clone()]).unwrap();
|
||||
assert!(
|
||||
timeline
|
||||
.allocation_weights_at(at - chrono::Duration::seconds(1))
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(timeline.allocation_weights_at(at), Some(&weights));
|
||||
for action in [
|
||||
PositionExposureAction::Set {
|
||||
target_exposure_bps: 5000,
|
||||
},
|
||||
PositionExposureAction::Restore,
|
||||
] {
|
||||
let next = PositionExposureEvent {
|
||||
event_id: "new".into(),
|
||||
sequence: 2,
|
||||
effective_at: at + chrono::Duration::seconds(1),
|
||||
action,
|
||||
allocation_weights_bps: None,
|
||||
};
|
||||
assert!(
|
||||
PositionExposureTimeline::from_events(&[event.clone(), next])
|
||||
.unwrap()
|
||||
.allocation_weights_at(at + chrono::Duration::seconds(1))
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
validate_allocation_weights(&BTreeMap::from([("000001.SZ".into(), 9000)]), 5000)
|
||||
.is_err()
|
||||
);
|
||||
assert!(validate_allocation_weights(&BTreeMap::new(), 1).is_err());
|
||||
assert!(validate_allocation_weights(&BTreeMap::new(), 0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_equity_targets_and_buys_scale_but_sales_cashflows_and_prices_do_not() {
|
||||
use crate::OrderIntent as I;
|
||||
|
||||
@@ -233,7 +233,7 @@ impl<'a> Scheduler<'a> {
|
||||
pub fn default_stage_time(stage: ScheduleStage) -> Option<NaiveTime> {
|
||||
match stage {
|
||||
ScheduleStage::BeforeTrading => Some(NaiveTime::from_hms_opt(9, 0, 0).expect("valid time")),
|
||||
ScheduleStage::OpenAuction => Some(NaiveTime::from_hms_opt(9, 31, 0).expect("valid time")),
|
||||
ScheduleStage::OpenAuction => Some(NaiveTime::from_hms_opt(9, 25, 0).expect("valid time")),
|
||||
ScheduleStage::Bar => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")),
|
||||
ScheduleStage::Minute => None,
|
||||
ScheduleStage::OnDay => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")),
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
use std::ops::Index;
|
||||
|
||||
use super::prefix_sums;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) enum ReferenceMatchedValues {
|
||||
Identical,
|
||||
Owned(Vec<f64>),
|
||||
}
|
||||
|
||||
impl ReferenceMatchedValues {
|
||||
pub(super) fn push(&mut self, value: f64, reference: &[f64], capacity: usize) {
|
||||
let previous_len = reference.len().checked_sub(1).expect("reference row is missing");
|
||||
match self {
|
||||
Self::Identical if value.to_bits() == reference[previous_len].to_bits() => {}
|
||||
Self::Identical => {
|
||||
let mut values = Vec::with_capacity(capacity);
|
||||
values.extend_from_slice(&reference[..previous_len]);
|
||||
values.push(value);
|
||||
*self = Self::Owned(values);
|
||||
}
|
||||
Self::Owned(values) => {
|
||||
debug_assert_eq!(values.len(), previous_len);
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn values<'a>(&'a self, reference: &'a [f64]) -> &'a [f64] {
|
||||
match self {
|
||||
Self::Identical => reference,
|
||||
Self::Owned(values) => {
|
||||
debug_assert_eq!(values.len(), reference.len());
|
||||
values
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set(&mut self, index: usize, value: f64, reference: &[f64]) {
|
||||
assert!(index < reference.len(), "series index out of bounds");
|
||||
match self {
|
||||
Self::Owned(values) => values[index] = value,
|
||||
Self::Identical if value.to_bits() == reference[index].to_bits() => {}
|
||||
Self::Identical => {
|
||||
let mut values = reference.to_vec();
|
||||
values[index] = value;
|
||||
*self = Self::Owned(values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn prefix(&self) -> Self {
|
||||
match self {
|
||||
Self::Identical => Self::Identical,
|
||||
Self::Owned(values) => Self::Owned(prefix_sums(values)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct RepeatedValues<T> {
|
||||
repeated: T,
|
||||
values: Option<Vec<T>>,
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl<T: Default + Clone + Eq> RepeatedValues<T> {
|
||||
pub(super) fn new() -> Self {
|
||||
Self { repeated: T::default(), values: None, len: 0 }
|
||||
}
|
||||
|
||||
pub(super) fn push(&mut self, value: &T, capacity: usize) {
|
||||
if let Some(values) = &mut self.values {
|
||||
values.push(value.clone());
|
||||
} else if self.len == 0 {
|
||||
self.repeated = value.clone();
|
||||
} else if *value != self.repeated {
|
||||
let mut values = Vec::with_capacity(capacity);
|
||||
values.resize(self.len, std::mem::take(&mut self.repeated));
|
||||
values.push(value.clone());
|
||||
self.values = Some(values);
|
||||
}
|
||||
self.len += 1;
|
||||
}
|
||||
|
||||
pub(super) fn set(&mut self, index: usize, value: T) {
|
||||
assert!(index < self.len, "series index out of bounds");
|
||||
if let Some(values) = &mut self.values {
|
||||
values[index] = value;
|
||||
} else if value != self.repeated {
|
||||
let mut values = vec![std::mem::take(&mut self.repeated); self.len];
|
||||
values[index] = value;
|
||||
self.values = Some(values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Index<usize> for RepeatedValues<T> {
|
||||
type Output = T;
|
||||
|
||||
fn index(&self, index: usize) -> &T {
|
||||
assert!(index < self.len, "series index out of bounds");
|
||||
match &self.values {
|
||||
Some(values) => &values[index],
|
||||
None => &self.repeated,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bits(values: &[f64]) -> Vec<u64> {
|
||||
values.iter().map(|value| value.to_bits()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_prices_share_only_after_exact_bit_comparison() {
|
||||
let reference = [10., -0., f64::from_bits(0x7ff8_0000_0000_0042), f64::INFINITY];
|
||||
let mut column = ReferenceMatchedValues::Identical;
|
||||
for (index, value) in reference.iter().copied().enumerate() {
|
||||
column.push(value, &reference[..=index], reference.len());
|
||||
}
|
||||
assert!(matches!(column, ReferenceMatchedValues::Identical));
|
||||
assert_eq!(column.values(&reference).as_ptr(), reference.as_ptr());
|
||||
let prefix = prefix_sums(&reference);
|
||||
assert_eq!(bits(column.prefix().values(&prefix)), bits(&prefix));
|
||||
|
||||
let original = column.clone();
|
||||
column.set(1, 0., &reference);
|
||||
assert!(matches!(column, ReferenceMatchedValues::Owned(_)));
|
||||
assert_eq!(column.values(&reference)[1].to_bits(), 0_f64.to_bits());
|
||||
assert_eq!(bits(original.values(&reference)), bits(&reference));
|
||||
assert_eq!(bits(column.prefix().values(&prefix)), bits(&prefix_sums(column.values(&reference))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn differing_prices_preserve_zero_nan_payloads_and_prior_rows() {
|
||||
let reference = [10., 11., f64::from_bits(0x7ff8_0000_0000_0042), 13.];
|
||||
for actual in [
|
||||
[10., 0., reference[2], 13.],
|
||||
[10., 11., f64::from_bits(0x7ff8_0000_0000_0043), 13.],
|
||||
] {
|
||||
let mut column = ReferenceMatchedValues::Identical;
|
||||
for (index, value) in actual.iter().copied().enumerate() {
|
||||
column.push(value, &reference[..=index], actual.len());
|
||||
}
|
||||
assert!(matches!(column, ReferenceMatchedValues::Owned(_)));
|
||||
assert_eq!(bits(column.values(&reference)), bits(&actual));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_values_preserve_nonzero_values_and_copy_on_change() {
|
||||
let mut column = RepeatedValues::new();
|
||||
for _ in 0..128 { column.push(&7_u64, 128); }
|
||||
assert!(column.values.is_none());
|
||||
assert_eq!(column[127], 7);
|
||||
column.set(0, 7);
|
||||
assert!(column.values.is_none());
|
||||
let mut changed = column.clone();
|
||||
changed.set(64, 9);
|
||||
assert_eq!(changed[64], 9);
|
||||
assert_eq!(changed[63], 7);
|
||||
assert_eq!(column[64], 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_values_keep_none_distinct_from_empty_and_repeated_text() {
|
||||
for repeated in [None, Some(String::new()), Some("continuous".to_string())] {
|
||||
let mut column = RepeatedValues::new();
|
||||
for _ in 0..12 { column.push(&repeated, 16); }
|
||||
assert!(column.values.is_none());
|
||||
assert_eq!(column[0], repeated);
|
||||
column.push(&Some("closing".to_string()), 16);
|
||||
assert_eq!(column[11], repeated);
|
||||
assert_eq!(column[12].as_deref(), Some("closing"));
|
||||
column.set(5, None);
|
||||
assert_eq!(column[5], None);
|
||||
assert_eq!(column[4], repeated);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "series index out of bounds")]
|
||||
fn repeated_values_reject_out_of_range_access() {
|
||||
let column = RepeatedValues::<u64>::new();
|
||||
let _ = column[0];
|
||||
}
|
||||
}
|
||||
@@ -479,7 +479,7 @@ pub struct StockPoolSelection {
|
||||
pub generation: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct StockPoolDecisionConstraints {
|
||||
pub execution_date: Option<NaiveDate>,
|
||||
pub frozen_positions: BTreeMap<String, FrozenStockPoolPosition>,
|
||||
@@ -545,7 +545,7 @@ pub struct StockPoolPlan {
|
||||
|
||||
/// A signal-time contract. Only the broker/execution adapter supplies later
|
||||
/// prices, actual cash and holdings; strategy code never sees those inputs.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FrozenStockPoolIntent {
|
||||
pub pool_id: String,
|
||||
pub signal_date: NaiveDate,
|
||||
|
||||
@@ -20,6 +20,18 @@ use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSe
|
||||
|
||||
pub trait Strategy {
|
||||
fn name(&self) -> &str;
|
||||
fn bind_runtime_position_configuration(
|
||||
&mut self,
|
||||
events: &[crate::position_exposure::PositionExposureEvent],
|
||||
legacy: &BTreeMap<NaiveDate, i32>,
|
||||
) -> Result<(), BacktestError> {
|
||||
if !events.is_empty() || !legacy.is_empty() {
|
||||
return Err(BacktestError::Execution(
|
||||
"strategy does not implement runtime position configuration".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn initial_subscriptions(&self) -> BTreeSet<String> {
|
||||
BTreeSet::new()
|
||||
}
|
||||
@@ -40,6 +52,12 @@ pub trait Strategy {
|
||||
) -> Result<(), BacktestError> {
|
||||
Ok(())
|
||||
}
|
||||
/// External, already executed manual activity. It is not a new strategy
|
||||
/// order and must not be run through order generation or transaction costs.
|
||||
fn on_observed_manual_execution(
|
||||
&mut self,
|
||||
_execution: &crate::manual_execution::ManualReplayApplication,
|
||||
) -> Result<(), BacktestError> { Ok(()) }
|
||||
fn schedule_rules(&self) -> Vec<ScheduleRule> {
|
||||
Vec::new()
|
||||
}
|
||||
@@ -977,7 +995,7 @@ fn safe_ratio(numerator: f64, denominator: f64) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Debug, Clone, Default, serde::Serialize)]
|
||||
pub struct StrategyDecision {
|
||||
pub buy_denials: BTreeMap<String, String>,
|
||||
pub rebalance: bool,
|
||||
@@ -1095,13 +1113,13 @@ mod decision_merge_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
pub enum AlgoOrderStyle {
|
||||
Vwap,
|
||||
Twap,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
pub enum OrderTimeInForce {
|
||||
Day,
|
||||
Ioc,
|
||||
@@ -1130,7 +1148,7 @@ impl OrderTimeInForce {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub enum TargetPortfolioOrderPricing {
|
||||
LimitPrices(BTreeMap<String, f64>),
|
||||
AlgoOrder {
|
||||
@@ -1140,7 +1158,7 @@ pub enum TargetPortfolioOrderPricing {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub enum OrderIntent {
|
||||
StockPool {
|
||||
contract: Box<crate::stock_pool_execution::FrozenStockPoolIntent>,
|
||||
|
||||
@@ -254,7 +254,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
},
|
||||
ManualSection {
|
||||
title: "corporate_actions.dividend_reinvestment".to_string(),
|
||||
detail: "支持 corporate_actions.dividend_reinvestment(true)。开启后,现金分红到账会优先按 round lot 回补成同一只股票,零头保留为现金。".to_string(),
|
||||
detail: "历史兼容的回测账务再投模型:corporate_actions.dividend_reinvestment(true) 在分红结算时按调整后的参考价分配整手股数,零头留现金,费用为0;来源标记为 dividend_reinvestment,不是交易所委托或真实自动买入。新策略不应手工处理公司行为。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "execution.matching_type / execution.slippage".to_string(),
|
||||
|
||||
@@ -105,6 +105,158 @@ fn action(quantity: &str, when: &str) -> PlatformTradeAction {
|
||||
reason: "configured_strategy_action".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observed_manual_trades_then_split_keep_real_fill_protection_and_lock_dates() {
|
||||
for sell_during_lock in [false, true] {
|
||||
let sale = if sell_during_lock {
|
||||
("manual-sell", "Sell", "2026-09-16T01:31:00Z", "2026-09-16T01:31:01Z", "5", "0.5", 200)
|
||||
} else {
|
||||
("manual-sell", "Sell", "2026-09-14T01:31:00Z", "2026-09-14T01:31:01Z", "10", "0.5", 100)
|
||||
};
|
||||
let actions = [
|
||||
("new-buy", "Buy", "2026-09-14T01:30:00Z", "2026-09-14T01:30:01Z", "10", "0.25", 100),
|
||||
("late-buy", "Buy", "2026-09-11T06:00:00Z", "2026-09-14T01:30:02Z", "10", "0.75", 100),
|
||||
sale,
|
||||
].into_iter().enumerate().map(|(index, (id, side, executed, observed, price, fee, quantity))| {
|
||||
let executed: chrono::DateTime<chrono::Utc> = executed.parse().unwrap();
|
||||
let observed: chrono::DateTime<chrono::Utc> = observed.parse().unwrap();
|
||||
let created = executed - chrono::Duration::seconds(1);
|
||||
serde_json::json!({"actionId":id,"source":"manual_security_trade","auditEventIds":[format!("audit-{id}")],
|
||||
"confirmedAt":created,"confirmationObservedAt":created,"outcome":"orders_terminal","orders":[{
|
||||
"orderId":id,"brokerOrderId":id,"sourceAdapter":"paper","symbol":"000001.SZ","side":side,"quantity":quantity,
|
||||
"orderCreatedAt":created,"terminalObservedAt":observed,"terminalStatus":"filled","fills":[{
|
||||
"tradeId":id,"observationEventId":id,"observationSequence":index+1,
|
||||
"tradeDate":executed.date_naive(),"executedAt":executed,"observedAt":observed,
|
||||
"feeObservationEventId":id,"feeObservationSequence":index+1,"feeObservedAt":observed,
|
||||
"timestampPrecision":"second","quantity":quantity,"price":price,"totalFee":fee
|
||||
}]
|
||||
}]})
|
||||
}).collect::<Vec<_>>();
|
||||
let mut replay: fidc_core::manual_execution::ManualExecutionReplay = serde_json::from_value(serde_json::json!({
|
||||
"schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a",
|
||||
"sourceContractSha256":"a".repeat(64),"contentSha256":"","observationCutoff":"2026-09-18T08:00:00Z","actions":actions,
|
||||
})).unwrap();
|
||||
replay.content_sha256 = replay.content_digest().unwrap();
|
||||
let mut parts = data().snapshot_components();
|
||||
for row in &mut parts.market {
|
||||
if row.date >= d(15) {
|
||||
row.day_open = 5.;
|
||||
row.open = 5.;
|
||||
row.high = 5.;
|
||||
row.low = 5.;
|
||||
row.close = 5.;
|
||||
row.last_price = 5.;
|
||||
row.bid1 = 5.;
|
||||
row.ask1 = 5.;
|
||||
row.prev_close = 5.;
|
||||
row.upper_limit = 5.5;
|
||||
row.lower_limit = 4.5;
|
||||
}
|
||||
}
|
||||
parts.corporate_actions.push(fidc_core::CorporateAction {
|
||||
date: d(15),
|
||||
symbol: "000001.SZ".into(),
|
||||
payable_date: None,
|
||||
share_cash: 0.,
|
||||
share_bonus: 1.,
|
||||
share_gift: 0.,
|
||||
issue_quantity: 0.,
|
||||
issue_price: 0.,
|
||||
reform: false,
|
||||
adjust_factor: None,
|
||||
successor_symbol: None,
|
||||
successor_ratio: None,
|
||||
successor_cash: None,
|
||||
});
|
||||
let data = DataSet::from_components_with_actions(
|
||||
parts.instruments,
|
||||
parts.market,
|
||||
parts.factors,
|
||||
parts.candidates,
|
||||
parts.benchmarks,
|
||||
parts.corporate_actions,
|
||||
)
|
||||
.unwrap();
|
||||
let mut config = PlatformExprStrategyConfig::generic();
|
||||
config.signal_symbol = "000001.SZ".into();
|
||||
config.benchmark_symbol = "000300.SH".into();
|
||||
config.rotation_enabled = false;
|
||||
config.matching_type = MatchingType::CurrentBarClose;
|
||||
config.volume_capacity_mode =
|
||||
fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
|
||||
config.automatic_trade_protection = AutomaticTradeProtection {
|
||||
buy_protection_days: 3,
|
||||
sell_cooldown_days: 3,
|
||||
max_holding_days: 1,
|
||||
locks: vec![AutomaticTradeLock {
|
||||
symbol: "000001.SZ".into(),
|
||||
start_date: d(16),
|
||||
end_date: Some(d(17)),
|
||||
}],
|
||||
};
|
||||
config.explicit_actions = vec![action("-200", "decision_date >= \"2026-09-14\"")];
|
||||
let result = BacktestEngine::new(
|
||||
data,
|
||||
PlatformExprStrategy::new(config),
|
||||
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(MatchingType::CurrentBarClose)
|
||||
.with_volume_capacity_mode(
|
||||
fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit,
|
||||
),
|
||||
BacktestConfig {
|
||||
initial_cash: 10000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(d(11)),
|
||||
end_date: Some(d(18)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Close,
|
||||
},
|
||||
)
|
||||
.with_observed_manual_executions(replay)
|
||||
.unwrap()
|
||||
.run()
|
||||
.unwrap();
|
||||
assert_eq!(result.manual_executions.len(), 3);
|
||||
assert_eq!(result.manual_executions[2].quantity_after, if sell_during_lock { 200 } else { 100 });
|
||||
assert_eq!(result.fills.len(), 1, "{:?}", result.fills);
|
||||
assert_eq!(
|
||||
(
|
||||
result.fills[0].date,
|
||||
result.fills[0].side,
|
||||
result.fills[0].quantity,
|
||||
result.fills[0].price
|
||||
),
|
||||
(d(18), OrderSide::Sell, 200, 5.)
|
||||
);
|
||||
assert!(result.fills[0].reason.contains("max_holding_days_exit"));
|
||||
for day in [14, 15] {
|
||||
for rule in ["buy_fill_protection", "sell_fill_cooldown"] {
|
||||
if rule == "sell_fill_cooldown" && sell_during_lock { continue; }
|
||||
assert!(result.risk_decisions.iter().any(|audit| audit.date == d(day)
|
||||
&& audit.symbol == "000001.SZ" && audit.rule_code == rule && !audit.accepted), "day={day} rule={rule}");
|
||||
}
|
||||
}
|
||||
for day in [16, 17] {
|
||||
assert!(result.risk_decisions.iter().any(|audit| audit.date == d(day)
|
||||
&& audit.symbol == "000001.SZ" && audit.rule_code == "automatic_trade_locked" && !audit.accepted));
|
||||
}
|
||||
assert!(
|
||||
result
|
||||
.daily_holdings
|
||||
.iter()
|
||||
.any(|row| row.date == d(15) && row.quantity == if sell_during_lock { 400 } else { 200 })
|
||||
);
|
||||
assert!(result.holdings_summary.is_empty());
|
||||
assert!(
|
||||
result
|
||||
.equity_curve
|
||||
.iter()
|
||||
.all(|point| point.external_cash_flow == 0.)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult {
|
||||
let mut config = PlatformExprStrategyConfig::generic();
|
||||
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
|
||||
|
||||
@@ -177,6 +177,157 @@ fn benchmark_snapshot(date: NaiveDate) -> BenchmarkSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successor_conversion_depletes_older_source_lots_before_newer_successor_buys() {
|
||||
struct ConvertedSale {
|
||||
dates: [NaiveDate; 3],
|
||||
seen: std::rc::Rc<std::cell::RefCell<Option<(Option<NaiveDate>, Option<NaiveDate>)>>>,
|
||||
}
|
||||
impl Strategy for ConvertedSale {
|
||||
fn name(&self) -> &str {
|
||||
"successor FIFO"
|
||||
}
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
let (symbol, quantity) = if ctx.execution_date == self.dates[0] {
|
||||
("000001.SZ", 100)
|
||||
} else if ctx.execution_date == self.dates[1] {
|
||||
("000002.SZ", 100)
|
||||
} else {
|
||||
let holding = ctx.portfolio.position("000002.SZ").unwrap();
|
||||
*self.seen.borrow_mut() = Some((holding.opened_date(), holding.last_buy_date()));
|
||||
("000002.SZ", -200)
|
||||
};
|
||||
Ok(StrategyDecision {
|
||||
order_intents: vec![fidc_core::OrderIntent::Shares {
|
||||
symbol: symbol.into(),
|
||||
quantity,
|
||||
reason: "dated lot test".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
let dates = [d(2026, 9, 11), d(2026, 9, 14), d(2026, 9, 15)];
|
||||
let symbols = ["000001.SZ", "000002.SZ"];
|
||||
let mut market = Vec::new();
|
||||
let mut factors = Vec::new();
|
||||
let mut candidates = Vec::new();
|
||||
for date in dates {
|
||||
for symbol in symbols {
|
||||
let price = if symbol == symbols[0] {
|
||||
10.
|
||||
} else if date == dates[2] {
|
||||
6.
|
||||
} else {
|
||||
20.
|
||||
};
|
||||
let mut quote = stock_market_snapshot(date);
|
||||
quote.symbol = symbol.into();
|
||||
quote.day_open = price;
|
||||
quote.open = price;
|
||||
quote.high = price;
|
||||
quote.low = price;
|
||||
quote.close = price;
|
||||
quote.last_price = price;
|
||||
quote.bid1 = price;
|
||||
quote.ask1 = price;
|
||||
quote.prev_close = price;
|
||||
quote.upper_limit = price * 1.1;
|
||||
quote.lower_limit = price * 0.9;
|
||||
market.push(quote);
|
||||
let mut factor = stock_factor_snapshot(date);
|
||||
factor.symbol = symbol.into();
|
||||
factors.push(factor);
|
||||
let mut candidate = stock_candidate(date);
|
||||
candidate.symbol = symbol.into();
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
let data = DataSet::from_components_with_actions(
|
||||
symbols
|
||||
.into_iter()
|
||||
.map(|symbol| Instrument {
|
||||
symbol: symbol.into(),
|
||||
name: symbol.into(),
|
||||
board: "SZ".into(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(d(2020, 1, 1)),
|
||||
delisted_at: None,
|
||||
status: "active".into(),
|
||||
})
|
||||
.collect(),
|
||||
market,
|
||||
factors,
|
||||
candidates,
|
||||
dates.map(benchmark_snapshot).into(),
|
||||
vec![CorporateAction {
|
||||
date: dates[2],
|
||||
symbol: symbols[0].into(),
|
||||
payable_date: None,
|
||||
share_cash: 0.,
|
||||
share_bonus: 0.,
|
||||
share_gift: 0.,
|
||||
issue_quantity: 0.,
|
||||
issue_price: 0.,
|
||||
reform: false,
|
||||
adjust_factor: None,
|
||||
successor_symbol: Some(symbols[1].into()),
|
||||
successor_ratio: Some(2.),
|
||||
successor_cash: Some(0.),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
let seen = std::rc::Rc::new(std::cell::RefCell::new(None));
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Open,
|
||||
)
|
||||
.with_matching_type(fidc_core::MatchingType::NextBarOpen)
|
||||
.with_volume_limit(false)
|
||||
.with_liquidity_limit(false);
|
||||
let result = BacktestEngine::new(
|
||||
data,
|
||||
ConvertedSale {
|
||||
dates,
|
||||
seen: seen.clone(),
|
||||
},
|
||||
broker,
|
||||
BacktestConfig {
|
||||
initial_cash: 10000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(dates[0]),
|
||||
end_date: Some(dates[2]),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Open,
|
||||
},
|
||||
)
|
||||
.run()
|
||||
.unwrap();
|
||||
assert_eq!(*seen.borrow(), Some((Some(dates[0]), Some(dates[1]))));
|
||||
assert_eq!(result.fills.len(), 3);
|
||||
assert_eq!(result.fills[2].quantity, 200);
|
||||
assert_eq!(result.fills[2].symbol, symbols[1]);
|
||||
let remaining = result
|
||||
.holdings_summary
|
||||
.iter()
|
||||
.find(|row| row.symbol == symbols[1])
|
||||
.unwrap();
|
||||
assert_eq!(remaining.quantity, 100);
|
||||
assert_eq!(remaining.realized_pnl, 200.);
|
||||
assert!(
|
||||
result
|
||||
.position_events
|
||||
.iter()
|
||||
.any(|event| event.symbol == symbols[0]
|
||||
&& event.quantity_after == 0
|
||||
&& event.reason.starts_with("successor_conversion"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
let buy_date = d(2025, 1, 1);
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
use chrono::{NaiveDate, NaiveTime};
|
||||
use fidc_core::{
|
||||
BacktestConfig, BacktestEngine, BrokerSimulator, ChinaAShareCostModel, ChinaEquityRuleHooks,
|
||||
DataSet, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext, StrategyDecision,
|
||||
};
|
||||
use std::{cell::RefCell, collections::BTreeSet, rc::Rc};
|
||||
|
||||
const SYMBOL: &str = "000001.SZ";
|
||||
fn day(value: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 9, value).unwrap()
|
||||
}
|
||||
|
||||
fn data() -> DataSet {
|
||||
let days = [11, 14, 15].map(day);
|
||||
DataSet::from_components_with_actions_and_quotes(
|
||||
vec![fidc_core::Instrument {
|
||||
symbol: SYMBOL.into(),
|
||||
name: "fixture".into(),
|
||||
board: "SZ".into(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(day(1)),
|
||||
delisted_at: None,
|
||||
status: "active".into(),
|
||||
}],
|
||||
days.iter()
|
||||
.map(|&date| {
|
||||
let price = if date == day(11) { 10. } else { 8.95 };
|
||||
fidc_core::DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: SYMBOL.into(),
|
||||
timestamp: Some(format!("{date} 15:00:00")),
|
||||
day_open: price,
|
||||
open: price,
|
||||
high: price,
|
||||
low: price,
|
||||
close: price,
|
||||
last_price: price,
|
||||
bid1: price,
|
||||
ask1: price,
|
||||
prev_close: price,
|
||||
volume: 100000,
|
||||
minute_volume: 100000,
|
||||
bid1_volume: 100000,
|
||||
ask1_volume: 100000,
|
||||
trading_phase: Some("continuous".into()),
|
||||
paused: false,
|
||||
upper_limit: price * 1.1,
|
||||
lower_limit: price * 0.9,
|
||||
price_tick: 0.01,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
days.iter()
|
||||
.map(|&date| fidc_core::DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: SYMBOL.into(),
|
||||
market_cap_bn: 10.,
|
||||
free_float_cap_bn: 10.,
|
||||
pe_ttm: 10.,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: Some(1.),
|
||||
extra_factors: Default::default(),
|
||||
})
|
||||
.collect(),
|
||||
days.iter()
|
||||
.map(|&date| fidc_core::CandidateEligibility {
|
||||
date,
|
||||
symbol: SYMBOL.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
})
|
||||
.collect(),
|
||||
days.iter()
|
||||
.map(|&date| fidc_core::BenchmarkSnapshot {
|
||||
date,
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 100.,
|
||||
close: 100.,
|
||||
prev_close: 100.,
|
||||
volume: 100000,
|
||||
})
|
||||
.collect(),
|
||||
vec![fidc_core::CorporateAction {
|
||||
date: day(14),
|
||||
symbol: SYMBOL.into(),
|
||||
payable_date: Some(day(14)),
|
||||
share_cash: 1.05,
|
||||
share_bonus: 0.,
|
||||
share_gift: 0.,
|
||||
issue_quantity: 0.,
|
||||
issue_price: 0.,
|
||||
reform: false,
|
||||
adjust_factor: None,
|
||||
successor_symbol: None,
|
||||
successor_ratio: None,
|
||||
successor_cash: None,
|
||||
}],
|
||||
[(9, 15), (9, 31)]
|
||||
.into_iter()
|
||||
.map(|(hour, minute)| fidc_core::IntradayExecutionQuote {
|
||||
observation_kind: fidc_core::data::QuoteObservationKind::MinuteBar,
|
||||
date: day(14),
|
||||
symbol: SYMBOL.into(),
|
||||
timestamp: day(14).and_hms_opt(hour, minute, 0).unwrap(),
|
||||
last_price: 8.95,
|
||||
bid1: 8.95,
|
||||
ask1: 8.95,
|
||||
bid1_volume: 100000,
|
||||
ask1_volume: 100000,
|
||||
volume_delta: 10000,
|
||||
amount_delta: 89500.,
|
||||
trading_phase: Some("continuous".into()),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
struct Hold {
|
||||
seen: Rc<RefCell<Vec<(NaiveTime, u32)>>>,
|
||||
}
|
||||
impl Strategy for Hold {
|
||||
fn name(&self) -> &str {
|
||||
"accounting reinvestment contract"
|
||||
}
|
||||
fn initial_subscriptions(&self) -> BTreeSet<String> {
|
||||
[SYMBOL.into()].into()
|
||||
}
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
Ok(if ctx.execution_date == day(11) {
|
||||
StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: SYMBOL.into(),
|
||||
quantity: 1000,
|
||||
reason: "initial".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
StrategyDecision::default()
|
||||
})
|
||||
}
|
||||
fn on_minute(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
_: &fidc_core::IntradayExecutionQuote,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
self.seen.borrow_mut().push((
|
||||
ctx.current_time().unwrap(),
|
||||
ctx.portfolio
|
||||
.position(SYMBOL)
|
||||
.map_or(0, |position| position.quantity),
|
||||
));
|
||||
Ok(Default::default())
|
||||
}
|
||||
}
|
||||
|
||||
fn engine() -> BacktestEngine<Hold, ChinaAShareCostModel, ChinaEquityRuleHooks> {
|
||||
BacktestEngine::new(
|
||||
data(),
|
||||
Hold {
|
||||
seen: Rc::new(RefCell::new(Vec::new())),
|
||||
},
|
||||
BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default()
|
||||
.with_commission_rate(0.0008)
|
||||
.with_minimum_commission(0.),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Open,
|
||||
)
|
||||
.with_matching_type(MatchingType::NextBarOpen)
|
||||
.with_volume_limit(false)
|
||||
.with_liquidity_limit(false),
|
||||
BacktestConfig {
|
||||
initial_cash: 50000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(day(11)),
|
||||
end_date: Some(day(15)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Open,
|
||||
},
|
||||
)
|
||||
.with_dividend_reinvestment(true)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounting_reinvestment_has_an_explicit_origin_clock_and_progress_delivery() {
|
||||
let mut progress = Vec::new();
|
||||
let result = engine()
|
||||
.run_with_progress(|event| progress.push(event.clone()))
|
||||
.unwrap();
|
||||
let reinvest = result
|
||||
.fills
|
||||
.iter()
|
||||
.find(|fill| fill.reason == "dividend_reinvestment")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
reinvest.quantity,
|
||||
reinvest.price,
|
||||
reinvest.commission,
|
||||
reinvest.order_id
|
||||
),
|
||||
(100, 8.95, 0., None)
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(reinvest).unwrap()["origin"],
|
||||
"dividend_reinvestment"
|
||||
);
|
||||
assert_eq!(reinvest.execution_timestamp, day(14).and_hms_opt(0, 0, 0));
|
||||
let received = progress.iter().find(|event| event.date == day(14)).unwrap();
|
||||
assert!(
|
||||
received
|
||||
.fills
|
||||
.iter()
|
||||
.any(|fill| fill.reason == "dividend_reinvestment")
|
||||
);
|
||||
assert_eq!(
|
||||
progress
|
||||
.iter()
|
||||
.map(|event| event.daily_fill_count)
|
||||
.sum::<usize>(),
|
||||
result.fills.len()
|
||||
);
|
||||
}
|
||||
|
||||
fn manual_source(delayed: bool) -> fidc_core::manual_execution::ManualExecutionReplay {
|
||||
let observed = if delayed {
|
||||
"2026-09-14T01:15:00Z"
|
||||
} else {
|
||||
"2026-09-11T06:00:01Z"
|
||||
};
|
||||
let mut source: fidc_core::manual_execution::ManualExecutionReplay = serde_json::from_value(serde_json::json!({
|
||||
"schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),"contentSha256":"",
|
||||
"observationCutoff":"2026-09-15T08:00:00Z","actions":[{"actionId":"manual","source":"manual_security_trade","auditEventIds":["audit"],
|
||||
"confirmedAt":"2026-09-11T05:59:59Z","confirmationObservedAt":"2026-09-11T05:59:59Z","outcome":"orders_terminal","orders":[{
|
||||
"orderId":"manual-order","sourceAdapter":"paper","symbol":SYMBOL,"side":"Buy","quantity":1000,
|
||||
"orderCreatedAt":"2026-09-11T05:59:59Z","terminalObservedAt":observed,"terminalStatus":"filled","fills":[{
|
||||
"tradeId":"manual-fill","observationEventId":"receipt","observationSequence":1,"feeObservationEventId":"receipt","feeObservationSequence":1,
|
||||
"feeObservedAt":observed,"tradeDate":"2026-09-11","executedAt":"2026-09-11T06:00:00Z","observedAt":observed,
|
||||
"timestampPrecision":"second","quantity":1000,"price":"10","totalFee":"1"
|
||||
}]
|
||||
}]}]
|
||||
})).unwrap();
|
||||
source.content_sha256 = source.content_digest().unwrap();
|
||||
source
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_receipt_before_market_open_reconciles_accounting_not_future_market_fills() {
|
||||
let timely = engine()
|
||||
.with_observed_manual_executions(manual_source(false))
|
||||
.unwrap()
|
||||
.run()
|
||||
.unwrap();
|
||||
let delayed = engine()
|
||||
.with_observed_manual_executions(manual_source(true))
|
||||
.unwrap()
|
||||
.run()
|
||||
.unwrap();
|
||||
assert_eq!(delayed.holdings_summary[0].quantity, 2200);
|
||||
assert_eq!(
|
||||
delayed.holdings_summary[0].quantity,
|
||||
timely.holdings_summary[0].quantity
|
||||
);
|
||||
assert_eq!(
|
||||
delayed.equity_curve.last().unwrap().cash,
|
||||
timely.equity_curve.last().unwrap().cash
|
||||
);
|
||||
assert_eq!(
|
||||
delayed.manual_executions[0]
|
||||
.corporate_adjustment
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.corporate_cash_delta,
|
||||
"155"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekend_receipts_and_morning_allocations_are_in_the_next_progress_batch() {
|
||||
let mut source = manual_source(true);
|
||||
let observed = "2026-09-12T02:00:00Z".parse().unwrap();
|
||||
let order = &mut source.actions[0].orders[0];
|
||||
order.terminal_observed_at = observed;
|
||||
order.fills[0].observed_at = observed;
|
||||
order.fills[0].fee_observed_at = observed;
|
||||
source.content_sha256 = source.content_digest().unwrap();
|
||||
let mut progress = Vec::new();
|
||||
let result = engine()
|
||||
.with_observed_manual_executions(source)
|
||||
.unwrap()
|
||||
.run_with_progress(|event| progress.push(event.clone()))
|
||||
.unwrap();
|
||||
let monday = progress.iter().find(|event| event.date == day(14)).unwrap();
|
||||
assert_eq!(monday.daily_manual_fill_count, 1);
|
||||
assert_eq!(monday.manual_executions[0].observed_at, observed);
|
||||
assert!(
|
||||
monday
|
||||
.fills
|
||||
.iter()
|
||||
.any(|fill| fill.origin == fidc_core::FillOrigin::DividendReinvestment)
|
||||
);
|
||||
assert!(
|
||||
monday
|
||||
.process_events
|
||||
.iter()
|
||||
.any(|event| event.kind == fidc_core::ProcessEventKind::ManualExecutionObserved)
|
||||
);
|
||||
assert_eq!(
|
||||
progress
|
||||
.iter()
|
||||
.map(|event| event.daily_fill_count)
|
||||
.sum::<usize>(),
|
||||
result.fills.len() + result.manual_executions.len()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use fidc_core::manual_execution::{MANUAL_REPLAY_SCHEMA, ManualExecutionReplay};
|
||||
use fidc_core::{
|
||||
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
||||
ChinaAShareCostModel, ChinaEquityRuleHooks, CorporateAction, DailyFactorSnapshot,
|
||||
DailyMarketSnapshot, DataSet, Instrument, MatchingType, PriceField, Strategy,
|
||||
};
|
||||
|
||||
fn date(day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 9, day).unwrap()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Action {
|
||||
Split,
|
||||
Dividend,
|
||||
Successor,
|
||||
}
|
||||
|
||||
fn data(action: Action) -> DataSet {
|
||||
data_with_successor_metadata(action, true)
|
||||
}
|
||||
|
||||
fn data_with_successor_metadata(action: Action, include_successor: bool) -> DataSet {
|
||||
let days = [10, 11, 14, 15].map(date);
|
||||
let mut market = Vec::new();
|
||||
let mut factors = Vec::new();
|
||||
let mut candidates = Vec::new();
|
||||
for day in days {
|
||||
for symbol in ["000001.SZ", "000002.SZ"] {
|
||||
if matches!(action, Action::Successor) && symbol == "000001.SZ" && day >= date(14) {
|
||||
continue;
|
||||
}
|
||||
let price = if day < date(14)
|
||||
|| (symbol == "000002.SZ" && !matches!(action, Action::Successor))
|
||||
{
|
||||
10.
|
||||
} else if matches!(action, Action::Dividend) {
|
||||
9.
|
||||
} else {
|
||||
5.
|
||||
};
|
||||
market.push(DailyMarketSnapshot {
|
||||
date: day,
|
||||
symbol: symbol.into(),
|
||||
timestamp: Some(format!("{day} 15:00:00")),
|
||||
day_open: price,
|
||||
open: price,
|
||||
high: price,
|
||||
low: price,
|
||||
close: price,
|
||||
last_price: price,
|
||||
bid1: price,
|
||||
ask1: price,
|
||||
prev_close: price,
|
||||
volume: 100000,
|
||||
minute_volume: 100000,
|
||||
bid1_volume: 100000,
|
||||
ask1_volume: 100000,
|
||||
trading_phase: Some("continuous".into()),
|
||||
paused: false,
|
||||
upper_limit: price * 1.1,
|
||||
lower_limit: price * 0.9,
|
||||
price_tick: 0.01,
|
||||
});
|
||||
factors.push(DailyFactorSnapshot {
|
||||
date: day,
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn: 10.,
|
||||
free_float_cap_bn: 10.,
|
||||
pe_ttm: 10.,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: Some(1.),
|
||||
extra_factors: Default::default(),
|
||||
});
|
||||
candidates.push(CandidateEligibility {
|
||||
date: day,
|
||||
symbol: symbol.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
DataSet::from_components_with_actions(
|
||||
["000001.SZ", "000002.SZ"]
|
||||
.into_iter()
|
||||
.filter(|symbol| include_successor || *symbol != "000002.SZ")
|
||||
.map(|symbol| Instrument {
|
||||
symbol: symbol.into(),
|
||||
name: symbol.into(),
|
||||
board: "SZ".into(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(date(1)),
|
||||
delisted_at: (matches!(action, Action::Successor) && symbol == "000001.SZ")
|
||||
.then_some(date(14)),
|
||||
status: "active".into(),
|
||||
})
|
||||
.collect(),
|
||||
market,
|
||||
factors,
|
||||
candidates,
|
||||
days.map(|day| BenchmarkSnapshot {
|
||||
date: day,
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 100.,
|
||||
close: 100.,
|
||||
prev_close: 100.,
|
||||
volume: 100000,
|
||||
})
|
||||
.into(),
|
||||
vec![CorporateAction {
|
||||
date: date(14),
|
||||
symbol: "000001.SZ".into(),
|
||||
payable_date: Some(date(14)),
|
||||
share_cash: if matches!(action, Action::Dividend) {
|
||||
1.
|
||||
} else {
|
||||
0.
|
||||
},
|
||||
share_bonus: if matches!(action, Action::Split) {
|
||||
1.
|
||||
} else {
|
||||
0.
|
||||
},
|
||||
share_gift: 0.,
|
||||
issue_quantity: 0.,
|
||||
issue_price: 0.,
|
||||
reform: false,
|
||||
adjust_factor: None,
|
||||
successor_symbol: matches!(action, Action::Successor).then(|| "000002.SZ".into()),
|
||||
successor_ratio: matches!(action, Action::Successor).then_some(2.),
|
||||
successor_cash: matches!(action, Action::Successor).then_some(0.5),
|
||||
}],
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn source(delayed: bool, sell: bool) -> ManualExecutionReplay {
|
||||
let trades = if sell {
|
||||
vec![
|
||||
("initial-buy", "Buy", 10, 200, false),
|
||||
("sale", "Sell", 11, 100, delayed),
|
||||
]
|
||||
} else {
|
||||
vec![("buy", "Buy", 11, 100, delayed)]
|
||||
};
|
||||
let actions = trades.into_iter().enumerate().map(|(index, (id, side, day, quantity, late))| {
|
||||
let executed = format!("2026-09-{day:02}T06:00:00Z").parse::<DateTime<Utc>>().unwrap();
|
||||
let observed = if late { "2026-09-15T05:00:00Z".parse().unwrap() } else { executed + chrono::Duration::seconds(1) };
|
||||
let created = executed - chrono::Duration::seconds(1);
|
||||
serde_json::json!({"actionId":id,"source":"manual_security_trade","auditEventIds":[format!("audit-{id}")],
|
||||
"confirmedAt":created,"confirmationObservedAt":created,"outcome":"orders_terminal","orders":[{
|
||||
"orderId":id,"brokerOrderId":id,"sourceAdapter":"paper","symbol":"000001.SZ","side":side,"quantity":quantity,
|
||||
"orderCreatedAt":created,"terminalObservedAt":observed,"terminalStatus":"filled","fills":[{
|
||||
"tradeId":id,"observationEventId":id,"observationSequence":index+1,"tradeDate":date(day),
|
||||
"executedAt":executed,"observedAt":observed,"feeObservationEventId":id,"feeObservationSequence":index+1,
|
||||
"feeObservedAt":observed,"timestampPrecision":"second","quantity":quantity,"price":"10","totalFee":"1"
|
||||
}]
|
||||
}]})
|
||||
}).collect::<Vec<_>>();
|
||||
let mut source: ManualExecutionReplay = serde_json::from_value(serde_json::json!({
|
||||
"schema":MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),
|
||||
"contentSha256":"","observationCutoff":"2026-09-15T08:00:00Z","actions":actions,
|
||||
})).unwrap();
|
||||
source.content_sha256 = source.content_digest().unwrap();
|
||||
source.validate().unwrap();
|
||||
source
|
||||
}
|
||||
|
||||
struct Hold;
|
||||
impl Strategy for Hold {
|
||||
fn name(&self) -> &str {
|
||||
"manual corporate observation"
|
||||
}
|
||||
fn requires_minute_callbacks(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn run_custom<S: Strategy>(
|
||||
data: DataSet,
|
||||
source: ManualExecutionReplay,
|
||||
strategy: S,
|
||||
cash_dividends: bool,
|
||||
adjust_cost: bool,
|
||||
) -> Result<fidc_core::BacktestResult, fidc_core::BacktestError> {
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(MatchingType::NextBarOpen)
|
||||
.with_volume_limit(false)
|
||||
.with_liquidity_limit(false);
|
||||
BacktestEngine::new(
|
||||
data,
|
||||
strategy,
|
||||
broker,
|
||||
BacktestConfig {
|
||||
initial_cash: 10000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(date(10)),
|
||||
end_date: Some(date(15)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Open,
|
||||
},
|
||||
)
|
||||
.with_cash_dividends(cash_dividends)
|
||||
.with_cash_dividend_cost_basis_adjustment(adjust_cost)
|
||||
.with_observed_manual_executions(source)
|
||||
.unwrap()
|
||||
.run()
|
||||
}
|
||||
|
||||
fn run(
|
||||
action: Action,
|
||||
delayed: bool,
|
||||
sell: bool,
|
||||
) -> Result<fidc_core::BacktestResult, fidc_core::BacktestError> {
|
||||
run_custom(data(action), source(delayed, sell), Hold, true, true)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_buy_does_not_lose_corporate_entitlements() {
|
||||
for action in [Action::Split, Action::Dividend, Action::Successor] {
|
||||
let timely = run(action, false, false).unwrap();
|
||||
let delayed = run(action, true, false).unwrap();
|
||||
let project = |result: &fidc_core::BacktestResult| {
|
||||
(
|
||||
result.equity_curve.last().unwrap().cash,
|
||||
result.equity_curve.last().unwrap().total_equity,
|
||||
result
|
||||
.holdings_summary
|
||||
.iter()
|
||||
.map(|row| (row.symbol.clone(), row.quantity))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
assert_eq!(project(&delayed), project(&timely), "{action:?}");
|
||||
assert_eq!(delayed.manual_executions.len(), 1);
|
||||
assert!(delayed.fills.is_empty());
|
||||
if matches!(action, Action::Successor)
|
||||
&& let Ok(directory) = std::env::var("FIDC_CORPORATE_QA_OUTPUT")
|
||||
{
|
||||
use std::io::Write;
|
||||
let path = std::path::Path::new(&directory).join("corporate-successor-result.json");
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = options.open(path).unwrap();
|
||||
file.write_all(&serde_json::to_vec(&serde_json::json!({
|
||||
"source":delayed.manual_execution_source.as_deref(), "applications":delayed.manual_executions,
|
||||
})).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_sale_does_not_keep_unearned_corporate_entitlements() {
|
||||
for action in [Action::Split, Action::Dividend, Action::Successor] {
|
||||
let timely = run(action, false, true).unwrap();
|
||||
let delayed = run(action, true, true).unwrap();
|
||||
let project = |result: &fidc_core::BacktestResult| {
|
||||
(
|
||||
result.equity_curve.last().unwrap().cash,
|
||||
result.equity_curve.last().unwrap().total_equity,
|
||||
result
|
||||
.holdings_summary
|
||||
.iter()
|
||||
.map(|row| (row.symbol.clone(), row.quantity))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
assert_eq!(project(&delayed), project(&timely), "{action:?}");
|
||||
assert_eq!(delayed.manual_executions.len(), 2);
|
||||
assert!(delayed.fills.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paper_and_broker_observations_require_the_same_frozen_successor_scope() {
|
||||
for adapter in ["paper", "gt", "qmt"] {
|
||||
for delayed in [false, true] {
|
||||
for sell in [false, true] {
|
||||
let mut replay = source(delayed, sell);
|
||||
for action in &mut replay.actions {
|
||||
for order in &mut action.orders { order.source_adapter = Some(adapter.into()); }
|
||||
}
|
||||
replay.content_sha256 = replay.content_digest().unwrap();
|
||||
replay.validate().unwrap();
|
||||
let complete = run_custom(data(Action::Successor), replay.clone(), Hold, true, true).unwrap();
|
||||
assert_eq!(complete.holdings_summary[0].symbol, "000002.SZ");
|
||||
assert_eq!(complete.holdings_summary[0].quantity, 200);
|
||||
assert!(complete.fills.is_empty());
|
||||
assert!(complete.order_events.is_empty());
|
||||
let error = run_custom(data_with_successor_metadata(Action::Successor, false),
|
||||
replay, Hold, true, true).unwrap_err();
|
||||
assert!(error.to_string().contains("successor_instrument_missing"),
|
||||
"{adapter} delayed={delayed} sell={sell}: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn protected_successor_run(delayed: bool, locked: bool, amount: i32)
|
||||
-> fidc_core::BacktestResult {
|
||||
let policy = fidc_core::holding_policy::AutomaticTradeProtection {
|
||||
sell_cooldown_days: if locked { 0 } else { 3 },
|
||||
locks: if locked { vec![fidc_core::holding_policy::AutomaticTradeLock {
|
||||
symbol: "000001.SZ".into(), start_date: date(11), end_date: Some(date(15)),
|
||||
}] } else { vec![] }, ..Default::default()
|
||||
};
|
||||
protected_successor_case(delayed, policy, amount, "partial")
|
||||
}
|
||||
|
||||
fn protected_successor_case(delayed: bool,
|
||||
policy: fidc_core::holding_policy::AutomaticTradeProtection, amount: i32, scenario: &str)
|
||||
-> fidc_core::BacktestResult {
|
||||
let mut config = fidc_core::PlatformExprStrategyConfig::generic();
|
||||
config.signal_symbol = "000002.SZ".into();
|
||||
config.benchmark_symbol = "000300.SH".into();
|
||||
config.rotation_enabled = false;
|
||||
config.matching_type = MatchingType::CurrentBarClose;
|
||||
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
|
||||
config.explicit_action_schedule = Some(fidc_core::PlatformRebalanceSchedule {
|
||||
frequency: fidc_core::PlatformScheduleFrequency::Daily,
|
||||
time_rule: Some(fidc_core::ScheduleTimeRule::physical_time(14, 30)),
|
||||
});
|
||||
config.automatic_trade_protection = policy;
|
||||
config.explicit_actions = vec![fidc_core::PlatformTradeAction::Order {
|
||||
kind: fidc_core::PlatformExplicitOrderKind::Shares, symbol: "000002.SZ".into(),
|
||||
amount_expr: amount.to_string(), when_expr: Some("decision_date == \"2026-09-15\"".into()),
|
||||
limit_price_expr: None, time_in_force: None, start_time_expr: None, end_time_expr: None,
|
||||
reason: "configured_successor_action".into(),
|
||||
}];
|
||||
let data = successor_execution_data();
|
||||
let mut replay = source(delayed, true);
|
||||
if scenario == "sold_before" {
|
||||
let order = &mut replay.actions[1].orders[0];
|
||||
order.quantity = 200; order.fills[0].quantity = 200;
|
||||
} else if scenario == "cleared_after" {
|
||||
let mut row = serde_json::to_value(&replay.actions[1]).unwrap();
|
||||
let at = "2026-09-15T05:30:00Z";
|
||||
let receipt = "2026-09-15T05:30:01Z";
|
||||
row["actionId"] = "clear".into(); row["auditEventIds"] = serde_json::json!(["audit-clear"]);
|
||||
row["confirmedAt"] = at.into(); row["confirmationObservedAt"] = at.into();
|
||||
let order = &mut row["orders"][0];
|
||||
order["orderId"] = "clear-order".into(); order["brokerOrderId"] = "clear-order".into();
|
||||
order["symbol"] = "000002.SZ".into(); order["quantity"] = 200.into();
|
||||
order["orderCreatedAt"] = at.into(); order["terminalObservedAt"] = receipt.into();
|
||||
let fill = &mut order["fills"][0];
|
||||
fill["tradeId"] = "clear-trade".into(); fill["observationEventId"] = "clear-receipt".into();
|
||||
fill["observationSequence"] = 3.into(); fill["tradeDate"] = "2026-09-15".into();
|
||||
fill["executedAt"] = at.into(); fill["observedAt"] = receipt.into();
|
||||
fill["feeObservationEventId"] = "clear-receipt".into(); fill["feeObservationSequence"] = 3.into();
|
||||
fill["feeObservedAt"] = receipt.into(); fill["price"] = "5".into(); fill["quantity"] = 200.into();
|
||||
replay.actions.push(serde_json::from_value(row).unwrap());
|
||||
}
|
||||
replay.content_sha256 = replay.content_digest().unwrap();
|
||||
BacktestEngine::new(data, fidc_core::PlatformExprStrategy::new(config),
|
||||
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(MatchingType::CurrentBarClose)
|
||||
.with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit),
|
||||
BacktestConfig { initial_cash: 10000., benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(date(10)), end_date: Some(date(15)), decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Close,
|
||||
}).with_observed_manual_executions(replay).unwrap().run().unwrap()
|
||||
}
|
||||
|
||||
fn successor_execution_data() -> DataSet {
|
||||
let parts = data(Action::Successor).snapshot_components();
|
||||
DataSet::from_components_with_actions_and_quotes(parts.instruments, parts.market,
|
||||
parts.factors, parts.candidates, parts.benchmarks, parts.corporate_actions,
|
||||
[30, 31].into_iter().map(|minute| fidc_core::IntradayExecutionQuote {
|
||||
observation_kind: fidc_core::data::QuoteObservationKind::MinuteBar,
|
||||
date: date(15), symbol: "000002.SZ".into(), timestamp: date(15).and_hms_opt(14,minute,0).unwrap(),
|
||||
last_price: 5., bid1: 5., ask1: 5., bid1_volume: 100000, ask1_volume: 100000,
|
||||
volume_delta: 100000, amount_delta: 500000., trading_phase: Some("continuous".into()),
|
||||
}).collect()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_pool_rebalance_applies_inherited_protection_without_rewriting_its_target() {
|
||||
use fidc_core::stock_pool_execution as pool;
|
||||
struct NativePool { locked: bool, expires: u32, exposure: i32 }
|
||||
impl Strategy for NativePool {
|
||||
fn name(&self) -> &str { "native pool successor protection" }
|
||||
fn requires_minute_callbacks(&self) -> bool { false }
|
||||
fn schedule_rules(&self) -> Vec<fidc_core::ScheduleRule> {
|
||||
vec![fidc_core::ScheduleRule::daily("pool", fidc_core::ScheduleStage::OnDay)
|
||||
.with_time_rule(fidc_core::ScheduleTimeRule::physical_time(14,30))]
|
||||
}
|
||||
fn on_scheduled(&mut self, ctx: &fidc_core::StrategyContext<'_>, _: &fidc_core::ScheduleRule)
|
||||
-> Result<fidc_core::StrategyDecision, fidc_core::BacktestError> {
|
||||
if ctx.execution_date != date(15) { return Ok(Default::default()); }
|
||||
let symbols = vec!["000002.SZ".to_owned()];
|
||||
let policy = fidc_core::holding_policy::AutomaticTradeProtection {
|
||||
sell_cooldown_days: if self.locked { 0 } else { 3 },
|
||||
locks: if self.locked { vec![fidc_core::holding_policy::AutomaticTradeLock {
|
||||
symbol: "000001.SZ".into(), start_date: date(11), end_date: Some(date(self.expires)),
|
||||
}] } else { vec![] }, ..Default::default()
|
||||
};
|
||||
let contract = pool::FrozenStockPoolIntent {
|
||||
pool_id: "pool".into(), signal_date: date(15), frozen_equity: 10000.into(),
|
||||
selection: pool::StockPoolSelection { trade_date: date(15), requested_symbols: symbols.clone(),
|
||||
normal_trading_symbols: symbols.clone(), risk_eligible_symbols: symbols.clone(), final_symbols: symbols,
|
||||
exclusion_reasons: Default::default(), inherited_from_generation: None, explicit_empty: false,
|
||||
generation: Some("latest".into()),
|
||||
}, members: vec![pool::StockPoolMemberSpec { symbol: "000002.SZ".into(), requested_order: 0,
|
||||
recommendation_reason: String::new(), target_weight_bps: None, stop_loss: None, take_profit: None }],
|
||||
rule: pool::StockPoolExecutionRule { pricing_mode: pool::POOL_PRICE_FIRST_TICK.into(),
|
||||
window_start: "14:30".into(), window_end: "15:00".into(), automatic_trade_protection: policy,
|
||||
..Default::default() }, constraints: pool::StockPoolDecisionConstraints {
|
||||
target_holding_count: Some(1), ..Default::default() },
|
||||
invest_ratio_bps: self.exposure, reserve_cash: 0.into(), out_of_pool_policy: "hold".into(), generation: "latest".into(),
|
||||
};
|
||||
Ok(fidc_core::StrategyDecision { order_intents: vec![fidc_core::OrderIntent::StockPool { contract: Box::new(contract) }], ..Default::default() })
|
||||
}
|
||||
}
|
||||
for delayed in [false, true] {
|
||||
for (locked, expires, exposure) in [(false, 15, 10000), (true, 15, 10000), (true, 15, 0), (true, 14, 10000)] {
|
||||
let result = BacktestEngine::new(successor_execution_data(), NativePool { locked, expires, exposure },
|
||||
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(14,30,0).unwrap())
|
||||
.with_volume_limit(false).with_liquidity_limit(false),
|
||||
BacktestConfig { initial_cash: 10000., benchmark_code: "000300.SH".into(), start_date: Some(date(10)),
|
||||
end_date: Some(date(15)), decision_lag_trading_days: 0, execution_price_field: PriceField::Last })
|
||||
.with_observed_manual_executions(source(delayed, true)).unwrap().run().unwrap();
|
||||
if expires == 14 {
|
||||
assert!(!result.fills.is_empty(), "positive control {:?}", result.order_events);
|
||||
} else {
|
||||
assert!(result.fills.is_empty(), "delayed={delayed} locked={locked} exposure={exposure}: {:?}", result.fills);
|
||||
assert!(result.order_events.is_empty());
|
||||
assert_eq!(result.holdings_summary[0].quantity, 200);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successor_keeps_sell_cooldown_for_timely_and_delayed_receipts() {
|
||||
for delayed in [false, true] {
|
||||
let result = protected_successor_run(delayed, false, 100);
|
||||
assert!(result.fills.is_empty(), "delayed={delayed}: {:?}", result.fills);
|
||||
assert!(result.order_events.is_empty(), "delayed={delayed}: {:?}", result.order_events);
|
||||
assert!(result.risk_decisions.iter().any(|row| row.symbol == "000002.SZ"
|
||||
&& row.date == date(15) && !row.accepted && row.rule_code == "sell_fill_cooldown"),
|
||||
"orders={:?} risk={:?} notes={:?}", result.order_events, result.risk_decisions,
|
||||
result.equity_curve.iter().map(|row| (&row.date, &row.notes)).collect::<Vec<_>>());
|
||||
assert_eq!(result.holdings_summary[0].quantity, 200);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converted_holding_does_not_lose_its_configured_date_lock() {
|
||||
for delayed in [false, true] {
|
||||
for amount in [-100, 100] {
|
||||
let result = protected_successor_run(delayed, true, amount);
|
||||
assert!(result.fills.is_empty(), "delayed={delayed} amount={amount}: {:?}", result.fills);
|
||||
assert!(result.order_events.is_empty(), "delayed={delayed} amount={amount}: {:?}", result.order_events);
|
||||
assert!(result.risk_decisions.iter().any(|row| row.symbol == "000002.SZ"
|
||||
&& row.date == date(15) && !row.accepted && row.rule_code == "automatic_trade_locked"),
|
||||
"orders={:?} risk={:?} notes={:?}", result.order_events, result.risk_decisions,
|
||||
result.equity_curve.iter().map(|row| (&row.date, &row.notes)).collect::<Vec<_>>());
|
||||
assert_eq!(result.holdings_summary[0].quantity, 200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successor_lock_expires_on_the_original_configured_date_not_the_conversion_date() {
|
||||
for delayed in [false, true] {
|
||||
let policy = fidc_core::holding_policy::AutomaticTradeProtection {
|
||||
locks: vec![fidc_core::holding_policy::AutomaticTradeLock {
|
||||
symbol: "000001.SZ".into(), start_date: date(11), end_date: Some(date(14)),
|
||||
}], ..Default::default()
|
||||
};
|
||||
let result = protected_successor_case(delayed, policy, 100, "partial");
|
||||
assert_eq!(result.fills.len(), 1);
|
||||
assert_eq!(result.fills[0].quantity, 100);
|
||||
assert_eq!(result.holdings_summary[0].quantity, 300);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_survives_a_manual_clear_after_conversion_but_not_a_disproved_conversion() {
|
||||
for delayed in [false, true] {
|
||||
let policy = fidc_core::holding_policy::AutomaticTradeProtection {
|
||||
locks: vec![fidc_core::holding_policy::AutomaticTradeLock {
|
||||
symbol: "000001.SZ".into(), start_date: date(11), end_date: Some(date(15)),
|
||||
}], ..Default::default()
|
||||
};
|
||||
let cleared = protected_successor_case(delayed, policy.clone(), 100, "cleared_after");
|
||||
assert!(cleared.fills.is_empty());
|
||||
assert!(cleared.order_events.is_empty());
|
||||
assert!(cleared.holdings_summary.is_empty());
|
||||
assert_eq!(cleared.manual_executions.len(), 3);
|
||||
let unconverted = protected_successor_case(delayed, policy, 100, "sold_before");
|
||||
assert_eq!(unconverted.fills.len(), 1);
|
||||
assert_eq!(unconverted.holdings_summary[0].quantity, 100);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corporate_replay_preserves_issued_orders_cash_flows_financing_and_charged_fees() {
|
||||
struct ExistingActivity {
|
||||
receiving_days: usize,
|
||||
}
|
||||
impl Strategy for ExistingActivity {
|
||||
fn name(&self) -> &str {
|
||||
"corporate replay with original activity"
|
||||
}
|
||||
fn requires_minute_callbacks(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &fidc_core::StrategyContext<'_>,
|
||||
) -> Result<fidc_core::StrategyDecision, fidc_core::BacktestError> {
|
||||
use fidc_core::OrderIntent;
|
||||
let order_intents = if ctx.execution_date == date(10) {
|
||||
vec![
|
||||
OrderIntent::DepositWithdraw {
|
||||
amount: 500.,
|
||||
receiving_days: self.receiving_days,
|
||||
reason: "original deposit".into(),
|
||||
},
|
||||
OrderIntent::FinanceRepay {
|
||||
amount: 200.,
|
||||
reason: "original financing".into(),
|
||||
},
|
||||
OrderIntent::SetManagementFeeRate {
|
||||
rate: 0.001,
|
||||
reason: "original fee policy".into(),
|
||||
},
|
||||
]
|
||||
} else if ctx.execution_date == date(11) {
|
||||
vec![OrderIntent::Shares {
|
||||
symbol: "000002.SZ".into(),
|
||||
quantity: 100,
|
||||
reason: "unrelated stock".into(),
|
||||
}]
|
||||
} else if ctx.execution_date == date(14) {
|
||||
vec![OrderIntent::Shares {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: 100,
|
||||
reason: "already issued after corporate action".into(),
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
Ok(fidc_core::StrategyDecision {
|
||||
order_intents,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
fn management_fee(
|
||||
&mut self,
|
||||
_: &fidc_core::StrategyContext<'_>,
|
||||
_: f64,
|
||||
) -> Result<Option<f64>, fidc_core::BacktestError> {
|
||||
Ok(Some(0.25))
|
||||
}
|
||||
}
|
||||
for receiving_days in [0, 1] {
|
||||
for sell in [false, true] {
|
||||
let timely = run_custom(
|
||||
data(Action::Split),
|
||||
source(false, sell),
|
||||
ExistingActivity { receiving_days },
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let delayed = run_custom(
|
||||
data(Action::Split),
|
||||
source(true, sell),
|
||||
ExistingActivity { receiving_days },
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&timely.fills).unwrap(),
|
||||
serde_json::to_value(&delayed.fills).unwrap()
|
||||
);
|
||||
assert_eq!(delayed.fills.len(), 2);
|
||||
assert_eq!(
|
||||
delayed.equity_curve.last().unwrap().cash,
|
||||
timely.equity_curve.last().unwrap().cash
|
||||
);
|
||||
assert_eq!(
|
||||
delayed.equity_curve.last().unwrap().total_equity,
|
||||
timely.equity_curve.last().unwrap().total_equity
|
||||
);
|
||||
assert_eq!(
|
||||
delayed
|
||||
.equity_curve
|
||||
.iter()
|
||||
.map(|row| row.external_cash_flow)
|
||||
.sum::<f64>(),
|
||||
500.
|
||||
);
|
||||
assert_eq!(delayed.manual_executions.len(), if sell { 2 } else { 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_and_disabled_dividends_keep_the_configured_cash_and_cost_contract() {
|
||||
for paid in [false, true] {
|
||||
for enabled in [false, true] {
|
||||
for adjust_cost in [false, true] {
|
||||
let fixture = || {
|
||||
let mut parts = data(Action::Dividend).snapshot_components();
|
||||
parts.corporate_actions[0].payable_date =
|
||||
Some(date(if paid { 14 } else { 16 }));
|
||||
DataSet::from_components_with_actions(
|
||||
parts.instruments,
|
||||
parts.market,
|
||||
parts.factors,
|
||||
parts.candidates,
|
||||
parts.benchmarks,
|
||||
parts.corporate_actions,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let timely =
|
||||
run_custom(fixture(), source(false, false), Hold, enabled, adjust_cost)
|
||||
.unwrap();
|
||||
let delayed =
|
||||
run_custom(fixture(), source(true, false), Hold, enabled, adjust_cost).unwrap();
|
||||
assert_eq!(
|
||||
delayed.equity_curve.last().unwrap().cash,
|
||||
timely.equity_curve.last().unwrap().cash
|
||||
);
|
||||
let financial = |result: &fidc_core::BacktestResult| {
|
||||
result
|
||||
.holdings_summary
|
||||
.iter()
|
||||
.map(|row| {
|
||||
(
|
||||
row.symbol.clone(),
|
||||
row.quantity,
|
||||
row.average_cost,
|
||||
row.last_price,
|
||||
row.market_value,
|
||||
row.unrealized_pnl,
|
||||
row.realized_pnl,
|
||||
row.pnl,
|
||||
row.dividend_receivable,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
// Receipt-day turnover is deliberately different when the
|
||||
// dividend option is disabled and no adjustment is required.
|
||||
assert_eq!(financial(&delayed), financial(&timely));
|
||||
assert_eq!(
|
||||
delayed.manual_executions[0].corporate_adjustment.is_some(),
|
||||
enabled
|
||||
);
|
||||
if enabled && !paid {
|
||||
assert_eq!(delayed.terminal_audit.cash_receivable_count, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_fill_replays_aggregate_split_rounding_not_an_independent_rounded_fragment() {
|
||||
let fixture = || {
|
||||
let mut parts = data(Action::Split).snapshot_components();
|
||||
parts.corporate_actions[0].share_bonus = 0.125;
|
||||
for row in &mut parts.market {
|
||||
if row.symbol == "000001.SZ" && row.date >= date(14) {
|
||||
row.day_open = 8.89;
|
||||
row.open = 8.89;
|
||||
row.close = 8.89;
|
||||
row.last_price = 8.89;
|
||||
row.high = 8.89;
|
||||
row.low = 8.89;
|
||||
row.prev_close = 8.89;
|
||||
row.bid1 = 8.89;
|
||||
row.ask1 = 8.89;
|
||||
row.upper_limit = 9.78;
|
||||
row.lower_limit = 8.;
|
||||
}
|
||||
}
|
||||
DataSet::from_components_with_actions(
|
||||
parts.instruments,
|
||||
parts.market,
|
||||
parts.factors,
|
||||
parts.candidates,
|
||||
parts.benchmarks,
|
||||
parts.corporate_actions,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let input = |delayed| {
|
||||
let mut value = source(delayed, true);
|
||||
value.actions[0].orders[0].quantity = 100;
|
||||
value.actions[0].orders[0].fills[0].quantity = 100;
|
||||
value.actions[1].orders[0].side = fidc_core::OrderSide::Buy;
|
||||
value.content_sha256 = value.content_digest().unwrap();
|
||||
value
|
||||
};
|
||||
let timely = run_custom(fixture(), input(false), Hold, true, true).unwrap();
|
||||
let delayed = run_custom(fixture(), input(true), Hold, true, true).unwrap();
|
||||
assert_eq!(timely.holdings_summary[0].quantity, 225);
|
||||
assert_eq!(delayed.holdings_summary[0].quantity, 225);
|
||||
assert_eq!(
|
||||
delayed.equity_curve.last().unwrap().total_equity,
|
||||
timely.equity_curve.last().unwrap().total_equity
|
||||
);
|
||||
assert_eq!(
|
||||
delayed.manual_executions[1]
|
||||
.corporate_adjustment
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.positions["000001.SZ"]
|
||||
.quantity_before,
|
||||
113
|
||||
);
|
||||
}
|
||||
@@ -770,6 +770,150 @@ fn pool_position_adjustments_use_execution_clock_and_restore_original_twenty_per
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_allocation_is_separate_from_the_frozen_pool_and_restores_its_weights() {
|
||||
let program = StockPoolProgram {
|
||||
schema_version: 1,
|
||||
pool_id: "manual-allocation".into(),
|
||||
version_id: "v1".into(),
|
||||
members: contract(day(2), 2, false).members,
|
||||
exit_signals: vec![],
|
||||
allocation_policy: serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"follow_candidates","rebalance_weights":true}}),
|
||||
timing_policy: serde_json::json!({"pricing_mode":"first_tick"}),
|
||||
stop_take_policy: serde_json::json!({}),
|
||||
out_of_pool_policy: "hold".into(),
|
||||
};
|
||||
let mut cfg = platform_expr_config_from_value(
|
||||
"manual-allocation",
|
||||
"000300.SH",
|
||||
&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]}}),
|
||||
)
|
||||
.unwrap();
|
||||
cfg.market_cap_field = "close".into();
|
||||
cfg.market_cap_lower_expr = "0".into();
|
||||
cfg.market_cap_upper_expr = "1e12".into();
|
||||
cfg.stock_filter_expr = "true".into();
|
||||
cfg.selection_limit_expr = "2".into();
|
||||
cfg.selection_candidate_limit_expr = "2".into();
|
||||
cfg.rank_expr = "0".into();
|
||||
cfg.matching_type = MatchingType::NextBarOpen;
|
||||
let mut replay:fidc_core::manual_execution::ManualExecutionReplay=serde_json::from_value(serde_json::json!({
|
||||
"schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),"contentSha256":"",
|
||||
"observationCutoff":"2026-01-06T08:00:00Z","actions":[],"positionExposureEvents":[
|
||||
{"eventId":"weights","sequence":1,"effectiveAt":"2026-01-05T09:30:00+08:00","action":"set","targetExposureBps":8000,"allocationWeightsBps":{"000001.SZ":3000,"000002.SZ":7000}},
|
||||
{"eventId":"restore","sequence":2,"effectiveAt":"2026-01-06T09:30:00+08:00","action":"restore"}
|
||||
]})).unwrap();
|
||||
replay.content_sha256 = replay.content_digest().unwrap();
|
||||
let result = BacktestEngine::new(
|
||||
data(false),
|
||||
PlatformExprStrategy::new(cfg),
|
||||
broker(false),
|
||||
BacktestConfig {
|
||||
initial_cash: 30000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(day(2)),
|
||||
end_date: Some(day(6)),
|
||||
decision_lag_trading_days: 1,
|
||||
execution_price_field: PriceField::Open,
|
||||
},
|
||||
)
|
||||
.with_observed_manual_executions(replay)
|
||||
.unwrap()
|
||||
.run()
|
||||
.unwrap();
|
||||
let quantities = |date| {
|
||||
result
|
||||
.daily_holdings
|
||||
.iter()
|
||||
.filter(|row| row.date == date)
|
||||
.map(|row| (row.symbol.clone(), row.quantity))
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
};
|
||||
assert_eq!(
|
||||
quantities(day(5)),
|
||||
BTreeMap::from([(code(1), 300), (code(2), 1600)]),
|
||||
"{:?}",
|
||||
result.fills
|
||||
);
|
||||
assert_eq!(
|
||||
quantities(day(6)),
|
||||
BTreeMap::from([(code(1), 700), (code(2), 1500)]),
|
||||
"{:?}",
|
||||
result.fills
|
||||
);
|
||||
assert!(
|
||||
result.manual_executions.is_empty(),
|
||||
"parameter events are not fabricated fills"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_manual_holding_data_does_not_become_a_pool_candidate() {
|
||||
let program = StockPoolProgram {
|
||||
schema_version: 1,
|
||||
pool_id: "manual-data-scope".into(),
|
||||
version_id: "v1".into(),
|
||||
members: vec![contract(day(2), 1, false).members.remove(0)],
|
||||
exit_signals: vec![],
|
||||
allocation_policy: serde_json::json!({"target_holding_count":1,"invest_ratio_bps":2000}),
|
||||
timing_policy: serde_json::json!({"pricing_mode":"first_tick"}),
|
||||
stop_take_policy: serde_json::json!({}),
|
||||
out_of_pool_policy: "hold".into(),
|
||||
};
|
||||
let mut cfg = platform_expr_config_from_value(
|
||||
"manual-data-scope",
|
||||
"000300.SH",
|
||||
&serde_json::json!({"stockPool":program,"universe":{"include":[code(1)]}}),
|
||||
)
|
||||
.unwrap();
|
||||
cfg.market_cap_field = "close".into();
|
||||
cfg.market_cap_lower_expr = "0".into();
|
||||
cfg.market_cap_upper_expr = "1e12".into();
|
||||
cfg.stock_filter_expr = "true".into();
|
||||
cfg.selection_limit_expr = "1".into();
|
||||
cfg.selection_candidate_limit_expr = "2".into();
|
||||
cfg.rank_expr = "0".into();
|
||||
cfg.matching_type = MatchingType::NextBarOpen;
|
||||
let fill = serde_json::json!({"tradeId":"fill","observationEventId":"receipt","observationSequence":1,"tradeDate":"2026-01-05","executedAt":"2026-01-05T01:31:00Z","observedAt":"2026-01-05T01:31:01Z",
|
||||
"feeObservationEventId":"receipt","feeObservationSequence":1,"feeObservedAt":"2026-01-05T01:31:01Z","timestampPrecision":"second","quantity":100,"price":"10","totalFee":"0"});
|
||||
let order = serde_json::json!({"orderId":"external-order","sourceAdapter":"paper","symbol":code(2),"side":"Buy","quantity":100,"orderCreatedAt":"2026-01-05T01:30:00Z","terminalObservedAt":"2026-01-05T01:31:01Z","terminalStatus":"filled","fills":[fill]});
|
||||
let mut replay:fidc_core::manual_execution::ManualExecutionReplay=serde_json::from_value(serde_json::json!({
|
||||
"schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),"contentSha256":"","observationCutoff":"2026-01-06T08:00:00Z",
|
||||
"actions":[{"actionId":"manual","source":"manual_security_trade","auditEventIds":["audit"],"confirmedAt":"2026-01-05T01:29:59Z","confirmationObservedAt":"2026-01-05T01:29:59Z","outcome":"orders_terminal","orders":[order]}]})).unwrap();
|
||||
replay.content_sha256 = replay.content_digest().unwrap();
|
||||
let result = BacktestEngine::new(
|
||||
data(false),
|
||||
PlatformExprStrategy::new(cfg),
|
||||
broker(false),
|
||||
BacktestConfig {
|
||||
initial_cash: 30000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(day(2)),
|
||||
end_date: Some(day(6)),
|
||||
decision_lag_trading_days: 1,
|
||||
execution_price_field: PriceField::Open,
|
||||
},
|
||||
)
|
||||
.with_observed_manual_executions(replay)
|
||||
.unwrap()
|
||||
.run()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.fills.iter().all(|fill| fill.symbol != code(2)),
|
||||
"extra data cannot authorize an extra candidate"
|
||||
);
|
||||
assert_eq!(result.manual_executions.len(), 1);
|
||||
assert_eq!(
|
||||
result
|
||||
.holdings_summary
|
||||
.iter()
|
||||
.find(|row| row.symbol == code(2))
|
||||
.unwrap()
|
||||
.quantity,
|
||||
100
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsed_typed_exit_program_keeps_ordinary_gates_and_independent_risk_targets_separate() {
|
||||
for (ordinary, risk, quote, sold) in [
|
||||
@@ -1034,6 +1178,375 @@ fn deferred_etf_open_does_not_appear_in_a_pre_open_minute_callback() {
|
||||
assert_eq!(result.fills.iter().filter(|fill|fill.symbol==code(2)).count(),1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_opening_rule_sees_the_etf_open_fill_after_earlier_quote_callbacks() {
|
||||
use fidc_core::scheduler::{ScheduleRule, ScheduleStage, ScheduleTimeRule};
|
||||
use fidc_core::strategy::{Strategy, StrategyContext};
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
struct ObservedPool {
|
||||
inner: EtfPoolSignal,
|
||||
observations: Rc<RefCell<Vec<(String, chrono::NaiveDateTime, u32, usize)>>>,
|
||||
}
|
||||
impl ObservedPool {
|
||||
fn record(&self, label: &str, ctx: &StrategyContext<'_>) {
|
||||
if ctx.execution_date == day(5) {
|
||||
self.observations.borrow_mut().push((
|
||||
label.into(),
|
||||
ctx.current_datetime().unwrap(),
|
||||
ctx.portfolio
|
||||
.position(&code(2))
|
||||
.map_or(0, |position| position.quantity),
|
||||
ctx.fills
|
||||
.iter()
|
||||
.filter(|fill| fill.symbol == code(2))
|
||||
.count(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Strategy for ObservedPool {
|
||||
fn name(&self) -> &str {
|
||||
"late opening with ETF fill"
|
||||
}
|
||||
fn initial_subscriptions(&self) -> BTreeSet<String> {
|
||||
BTreeSet::from([code(1)])
|
||||
}
|
||||
fn decision_quote_times(&self) -> Vec<chrono::NaiveTime> {
|
||||
self.inner.decision_quote_times()
|
||||
}
|
||||
fn decision_quote_symbols(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<BTreeSet<String>, fidc_core::BacktestError> {
|
||||
self.inner.decision_quote_symbols(ctx)
|
||||
}
|
||||
fn schedule_rules(&self) -> Vec<ScheduleRule> {
|
||||
vec![
|
||||
ScheduleRule::daily("open", ScheduleStage::OpenAuction)
|
||||
.with_time_rule(ScheduleTimeRule::market_open(0, 0)),
|
||||
]
|
||||
}
|
||||
fn on_scheduled(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
_: &ScheduleRule,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
self.record("opening", ctx);
|
||||
Ok(Default::default())
|
||||
}
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
self.inner.on_day(ctx)
|
||||
}
|
||||
fn on_minute(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
_: &IntradayExecutionQuote,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
self.record("quote", ctx);
|
||||
Ok(Default::default())
|
||||
}
|
||||
}
|
||||
let time = chrono::NaiveTime::from_hms_opt(13, 0, 0).unwrap();
|
||||
let mut data = etf_fallback_fixture(time);
|
||||
let quote = data.execution_quotes_on(day(5), &code(1))[0].clone();
|
||||
data.add_execution_quotes(
|
||||
[(9, 15), (9, 30), (9, 32)]
|
||||
.into_iter()
|
||||
.map(|(hour, minute)| {
|
||||
let mut row = quote.clone();
|
||||
row.timestamp = day(5).and_hms_opt(hour, minute, 0).unwrap();
|
||||
row
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let observations = Rc::new(RefCell::new(Vec::new()));
|
||||
let broker = broker(false)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_execution_price_field(PriceField::Last)
|
||||
.with_intraday_execution_start_time(time)
|
||||
.with_historical_etf_open_fallback(true);
|
||||
let result = BacktestEngine::new(
|
||||
data,
|
||||
ObservedPool {
|
||||
inner: EtfPoolSignal {
|
||||
at: time,
|
||||
condition: String::new(),
|
||||
},
|
||||
observations: observations.clone(),
|
||||
},
|
||||
broker,
|
||||
BacktestConfig {
|
||||
initial_cash: 30000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(day(2)),
|
||||
end_date: Some(day(5)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Last,
|
||||
},
|
||||
)
|
||||
.with_execution_quote_loader(|_| Ok(vec![]))
|
||||
.run()
|
||||
.unwrap();
|
||||
let seen = observations.borrow();
|
||||
assert_eq!(
|
||||
seen[..4],
|
||||
[
|
||||
("quote".into(), day(5).and_hms_opt(9, 15, 0).unwrap(), 0, 0),
|
||||
(
|
||||
"quote".into(),
|
||||
day(5).and_hms_opt(9, 30, 0).unwrap(),
|
||||
3700,
|
||||
1
|
||||
),
|
||||
(
|
||||
"opening".into(),
|
||||
day(5).and_hms_opt(9, 31, 0).unwrap(),
|
||||
3700,
|
||||
1
|
||||
),
|
||||
(
|
||||
"quote".into(),
|
||||
day(5).and_hms_opt(9, 32, 0).unwrap(),
|
||||
3700,
|
||||
1
|
||||
),
|
||||
]
|
||||
);
|
||||
let etf = result
|
||||
.fills
|
||||
.iter()
|
||||
.filter(|fill| fill.symbol == code(2))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(etf.len(), 1);
|
||||
assert_eq!(
|
||||
(etf[0].quantity, etf[0].price, etf[0].execution_timestamp),
|
||||
(3700, 4., Some(day(5).and_hms_opt(9, 30, 0).unwrap()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_signal_day_executes_the_etf_open_before_later_deferred_stock_orders() {
|
||||
use fidc_core::scheduler::{ScheduleRule, ScheduleStage, ScheduleTimeRule};
|
||||
use fidc_core::strategy::{Strategy, StrategyContext};
|
||||
struct DeferredStockAndEtf {
|
||||
inner: EtfPoolSignal,
|
||||
quantity: i32,
|
||||
}
|
||||
impl Strategy for DeferredStockAndEtf {
|
||||
fn name(&self) -> &str {
|
||||
"no signal ETF and deferred stock"
|
||||
}
|
||||
fn requires_minute_callbacks(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn decision_quote_times(&self) -> Vec<chrono::NaiveTime> {
|
||||
self.inner.decision_quote_times()
|
||||
}
|
||||
fn decision_quote_symbols(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<BTreeSet<String>, fidc_core::BacktestError> {
|
||||
self.inner.decision_quote_symbols(ctx)
|
||||
}
|
||||
fn schedule_rules(&self) -> Vec<ScheduleRule> {
|
||||
vec![
|
||||
ScheduleRule::daily("deferred-stock", ScheduleStage::AfterTrading)
|
||||
.with_time_rule(ScheduleTimeRule::physical_time(16, 0)),
|
||||
]
|
||||
}
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
self.inner.on_day(ctx)
|
||||
}
|
||||
fn on_scheduled(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
_: &ScheduleRule,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
Ok(if ctx.execution_date == day(2) && self.quantity != 0 {
|
||||
StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: code(1),
|
||||
quantity: self.quantity,
|
||||
reason: "after-close stock order".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
StrategyDecision::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
let time = chrono::NaiveTime::from_hms_opt(13, 0, 0).unwrap();
|
||||
let mut parts = etf_fallback_fixture(time).snapshot_components();
|
||||
parts.factors.retain(|row| row.date != day(5));
|
||||
let data = DataSet::from_components_with_actions_and_quotes(
|
||||
parts.instruments,
|
||||
parts.market,
|
||||
parts.factors,
|
||||
parts.candidates,
|
||||
parts.benchmarks,
|
||||
parts.corporate_actions,
|
||||
parts.execution_quotes,
|
||||
)
|
||||
.unwrap();
|
||||
for quantity in [100, -100, 0] {
|
||||
let broker = broker(false)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_execution_price_field(PriceField::Last)
|
||||
.with_intraday_execution_start_time(time)
|
||||
.with_historical_etf_open_fallback(true);
|
||||
let result = BacktestEngine::new(
|
||||
data.clone(),
|
||||
DeferredStockAndEtf {
|
||||
inner: EtfPoolSignal {
|
||||
at: time,
|
||||
condition: String::new(),
|
||||
},
|
||||
quantity,
|
||||
},
|
||||
broker,
|
||||
BacktestConfig {
|
||||
initial_cash: 30000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(day(2)),
|
||||
end_date: Some(day(5)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Last,
|
||||
},
|
||||
)
|
||||
.with_execution_quote_loader(|_| Ok(vec![]))
|
||||
.run()
|
||||
.unwrap();
|
||||
let fills = result
|
||||
.fills
|
||||
.iter()
|
||||
.filter(|fill| fill.date == day(5))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(fills.len(), if quantity < 0 { 2 } else { 1 }, "{fills:?}");
|
||||
assert_eq!(
|
||||
(
|
||||
fills[0].symbol.clone(),
|
||||
fills[0].quantity,
|
||||
fills[0].price,
|
||||
fills[0].execution_timestamp
|
||||
),
|
||||
(code(2), 3700, 4., day(5).and_hms_opt(9, 30, 0))
|
||||
);
|
||||
if quantity < 0 {
|
||||
assert_eq!(
|
||||
(
|
||||
fills[1].symbol.clone(),
|
||||
fills[1].side,
|
||||
fills[1].quantity,
|
||||
fills[1].execution_timestamp
|
||||
),
|
||||
(
|
||||
code(1),
|
||||
fidc_core::OrderSide::Sell,
|
||||
100,
|
||||
Some(day(5).and_time(time))
|
||||
)
|
||||
);
|
||||
} else if quantity > 0 {
|
||||
// The later stock buy cannot spend money that the 09:30 ETF fill
|
||||
// already consumed. It is rejected, not allowed to shrink that fill.
|
||||
assert!(
|
||||
result.order_events.iter().any(|order| order.date == day(5)
|
||||
&& order.symbol == code(1)
|
||||
&& order.status == fidc_core::OrderStatus::Rejected
|
||||
&& order.reason.contains("cash")),
|
||||
"{:?}",
|
||||
result.order_events
|
||||
);
|
||||
}
|
||||
if quantity != 0 {
|
||||
assert!(
|
||||
result.equity_curve.iter().any(
|
||||
|point| point.date == day(5) && point.diagnostics.contains("no_new_signal")
|
||||
)
|
||||
);
|
||||
}
|
||||
assert!(result.terminal_audit.is_clean());
|
||||
}
|
||||
|
||||
for minute in [15, 31] {
|
||||
let observed = format!("2026-01-05T01:{minute}:00Z");
|
||||
let created = format!("2026-01-05T01:{}:00Z", minute - 1);
|
||||
let fill = serde_json::json!({"tradeId":"fill","observationEventId":"receipt","observationSequence":1,"tradeDate":"2026-01-05",
|
||||
"executedAt":observed,"observedAt":observed,"feeObservationEventId":"receipt","feeObservationSequence":1,
|
||||
"feeObservedAt":observed,"timestampPrecision":"second","quantity":100,"price":"10","totalFee":"0"});
|
||||
let order = serde_json::json!({"orderId":"manual-order","sourceAdapter":"paper","symbol":code(1),"side":"Sell","quantity":100,
|
||||
"orderCreatedAt":created,"terminalObservedAt":observed,"terminalStatus":"filled","fills":[fill]});
|
||||
let mut replay: fidc_core::manual_execution::ManualExecutionReplay = serde_json::from_value(serde_json::json!({
|
||||
"schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),"contentSha256":"",
|
||||
"observationCutoff":"2026-01-05T08:00:00Z","actions":[{"actionId":"manual","source":"manual_security_trade","auditEventIds":["audit"],
|
||||
"confirmedAt":created,"confirmationObservedAt":created,"outcome":"orders_terminal","orders":[order]}]})).unwrap();
|
||||
replay.content_sha256 = replay.content_digest().unwrap();
|
||||
let broker = broker(false)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_execution_price_field(PriceField::Last)
|
||||
.with_intraday_execution_start_time(time)
|
||||
.with_historical_etf_open_fallback(true);
|
||||
let result = BacktestEngine::new(
|
||||
data.clone(),
|
||||
DeferredStockAndEtf {
|
||||
inner: EtfPoolSignal {
|
||||
at: time,
|
||||
condition: String::new(),
|
||||
},
|
||||
quantity: 0,
|
||||
},
|
||||
broker,
|
||||
BacktestConfig {
|
||||
initial_cash: 30000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(day(2)),
|
||||
end_date: Some(day(5)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Last,
|
||||
},
|
||||
)
|
||||
.with_execution_quote_loader(|_| Ok(vec![]))
|
||||
.with_observed_manual_executions(replay)
|
||||
.unwrap()
|
||||
.run();
|
||||
if minute < 30 {
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("manual observation conflicts with pending shadow orders")
|
||||
);
|
||||
} else {
|
||||
let result = result.unwrap();
|
||||
assert_eq!(result.manual_executions.len(), 1);
|
||||
assert_eq!(
|
||||
result
|
||||
.fills
|
||||
.iter()
|
||||
.filter(|fill| fill.symbol == code(2))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
result
|
||||
.daily_holdings
|
||||
.iter()
|
||||
.find(|position| position.date == day(5) && position.symbol == code(1))
|
||||
.unwrap()
|
||||
.quantity,
|
||||
1400
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_etf_pending_target_at_end_is_not_a_fake_order_or_fill() {
|
||||
let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(13,0,0).unwrap(),day(2),true,"",false,false).unwrap();
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- 成交日及后续N个完整正式交易日均受保护。例如周五成交、N=3,保护到下周三结束,周四恢复;不按72小时或自然日替代。
|
||||
- 最长持有从连续持仓第一次实际买入开始,跨正式交易日计数;加仓、部分卖出和有证据的证券转换不重置,完全卖出再买入开启新周期。锁定和买入保护优先于最长持有退出。
|
||||
- 日期锁定禁止自动买卖,已接受的挂单不自动撤销;手工路径只绕过自动策略保护,不绕过账户授权、T+1、券商和风控。
|
||||
- 已校验且实际发生的持仓换股继承原保护期限与日期锁,不因改代码解锁或重计时;无实际转换的目录映射不继承。换股后手工清仓仍受原有效日期锁;确认换股前已清仓时不保留推定关系。原生策略、股票池与ETF顺延消费的修复及未发布边界见 `successor-protection-20260914.md`。
|
||||
- 保留的真实持仓继续占用资金与席位,不把未完成卖出当现金。最长持有退出先形成唯一最终目标,不能叠加一笔策略部分卖出和一笔框架全量卖出。
|
||||
- 在线上下文重建必须注入已经校验的真实成交/持仓快照,不能把重建日或旧行情日当建仓日。期货或股票期货混合账户未纳入本合同,显式拒绝。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 回报上下文、盘前意图与尚未提交的目标
|
||||
|
||||
2026-09-14。本轮为v2026.9.14.4之后的候选,当前只有本机验证,尚未发布;完整股票池Goal继续。
|
||||
2026-09-14。本轮已配套发布177,annotated tag `v2026.9.14.5`。Engine81acc54 / Service e81bf47 / Trading94f99d2;完整股票池Goal继续,不据本阶段关闭。
|
||||
|
||||
## 已复现问题
|
||||
|
||||
@@ -28,7 +28,27 @@
|
||||
- 恢复顺序:开启正常旧恢复的单点负向对照确实多买3000股;恢复BeforeStrategy阶段后,只有原股票同一卖单的25+75股成交,无新增买入,最终持仓为空。
|
||||
- 本机Core834项通过(9项原有ignore),Trading613、最新main Runner446/API119通过。外部数据库及平台ignore不当作通过。
|
||||
|
||||
当前代码尚需精确Linux构建、真实历史合同回放和配套发布;不得把本机验证当生产或真实券商成交验收。
|
||||
精确只读快照在Linux通过Core834及Trading613。旧二进制先独立归档,构建保持1GiB磁盘余量;本轮未再次删除缓存或业务文件。
|
||||
|
||||
## 发布与真实历史复验
|
||||
|
||||
已推送annotated tag `v2026.9.14.5`对应Engine `81acc5422878abc855fca72b35766ffad6159200`、Service `e81bf47806f5ac4ae4798bb5f5955a56638f754c`、Trading `94f99d20f49f6cd1810996706cb94f610c302385`。回测API/Runner于06:15:53 CST切换,五交易单元06:21:09切换,06:22实际运行文件和业务事实复核通过。
|
||||
|
||||
三组冻结合同共六次独立原生A/B,完整Canonical及equity/orders/trades/holdings逐行一致;再通过生产HTTP各提交一次,结果分别匹配原生候选,旧记录未改写:
|
||||
|
||||
| 案例 | 生产回测ID | 成交 / 持仓 | 期末权益 |
|
||||
| --- | --- | --- | ---: |
|
||||
| 手选优先四证券 | btr_req_44f1bb067559946ef22941a0c425ed53e47515e04b399e20 | 10 / 4 | 9706248.648662 |
|
||||
| 自动优先四证券 | btr_req_078f129ed46b55ba72b47605983a00ae3eef14b1995e1237 | 10 / 4 | 9706248.648662 |
|
||||
| 许总24只原v3 | btr_req_ef37b8ea403f489e4798d2878e0ef85966ace550f6b4f3c4 | 51 / 21 | 9685563.876924999 |
|
||||
|
||||
重复目标委托0。三条新记录各5个交易日事件落库,持久事件27/18/32条,唯一键计数分别相同;旧流式样本仍27条/5日。仍为原合同下的日终容量审计,不外推实时盘口成交能力;首次Source准备和后续快速返回也不作为性能优化证明。
|
||||
|
||||
API SHA `dea170902d77734d0a77c4da7dad71a70b33f76467e0608675dfbcc9d35d67fc`,Runner SHA `b1d93215deb275fbec6217c6b9afbf717d5649600716bf4f3a1bf5d1cfa69731`,运行实现身份 `fed10e9fa61836aa271921f5d58490054210d83da935cad5de11cfacab45c13e`。API发布目录`/srv/fidc/canonical/run/backtest-api/releases/callback-81acc54-7w1zx9fb`,回退目录`/srv/fidc/canonical/run/build/callback-rollback-j7oje2tz`;交易回退`holding-protection-rollback-czuric4r`。
|
||||
|
||||
六服务实际SHA与manifest吻合,新增ERROR0。3Paper/0Live、配置、旧活动单、3个未确认Paper预览、迁移、影子配置0及disabled未变;发布后Paper/Live新订单0,未发送真实通知、委托或撤单。Source d5/PID1700096与UI6a2/PID3089476未重启,研究/信号暂停不变。177维护中的Engine9a54156完整保留,实际构建使用81acc54/e81bf47及81acc54/94f99d2的只读Git快照。
|
||||
|
||||
原始回放/HTTP证据`/srv/fidc/canonical/run/research/stock-pool-callback-20260914/`;发布和最终审计`/tmp/fidc-callback-{candidate,api-release,trading-release,final-audit}-20260914.json`;非敏感汇总在`docs/evidence/callback-target-20260914/acceptance.json`。
|
||||
|
||||
## 继续范围
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"verified_at": "2026-09-13T22:22:38.597836+00:00",
|
||||
"tag": "v2026.9.14.5",
|
||||
"processes": {
|
||||
"fidc-backtest-service-highmem177.service": {
|
||||
"pid": 3692551,
|
||||
"sha256": "dea170902d77734d0a77c4da7dad71a70b33f76467e0608675dfbcc9d35d67fc",
|
||||
"journal_since": "2026-09-13T22:15:53.225719+00:00",
|
||||
"journal_lines": 54,
|
||||
"error_lines": 0
|
||||
},
|
||||
"fidc-trading-control-highmem177.service": {
|
||||
"pid": 3697497,
|
||||
"sha256": "a8f62ba74caf7ce2f5ba9cc6f67f41c844dee3747852611051c8dfb7b36295a3",
|
||||
"journal_since": "2026-09-13T22:21:09.122548+00:00",
|
||||
"journal_lines": 5,
|
||||
"error_lines": 0
|
||||
},
|
||||
"fidc-market-data-highmem177.service": {
|
||||
"pid": 3697498,
|
||||
"sha256": "89395ab4c9e11274f171f4386f88949ce616fe45d15aae9a829216d53ed11db7",
|
||||
"journal_since": "2026-09-13T22:21:09.122548+00:00",
|
||||
"journal_lines": 5,
|
||||
"error_lines": 0
|
||||
},
|
||||
"fidc-strategy-runtime-highmem177.service": {
|
||||
"pid": 3697688,
|
||||
"sha256": "041a0103d2c46c55221d169965ece9fdacee3905abc0d46edd9c8a2a86f6cd54",
|
||||
"journal_since": "2026-09-13T22:21:09.122548+00:00",
|
||||
"journal_lines": 5,
|
||||
"error_lines": 0
|
||||
},
|
||||
"fidc-paper-trading-highmem177.service": {
|
||||
"pid": 3697792,
|
||||
"sha256": "8460008f712810f7d3876b9f2274aef88f82d46cd33e1593dbd0361f6d158b75",
|
||||
"journal_since": "2026-09-13T22:21:09.122548+00:00",
|
||||
"journal_lines": 6,
|
||||
"error_lines": 0
|
||||
},
|
||||
"fidc-live-trading-highmem177.service": {
|
||||
"pid": 3697762,
|
||||
"sha256": "e89d6ba68655a5d4a164793ad67a9003cf2c49733063018497339930feafaac3",
|
||||
"journal_since": "2026-09-13T22:21:09.122548+00:00",
|
||||
"journal_lines": 6,
|
||||
"error_lines": 0
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"loaded_at": "2026-09-12T03:57:06.665536+00:00",
|
||||
"source_stale": false,
|
||||
"loaded_server_sha256": "ef827ce6b95e0ea63047a0068af2677633716e0a5d63cf350de6c91a3413e352"
|
||||
},
|
||||
"source_checkouts": {
|
||||
"fidc-backtest-engine": {
|
||||
"head": "9a54156df94cfbf11a1e6335ec6ef5449bd6ac17",
|
||||
"runtime_commit": "81acc5422878abc855fca72b35766ffad6159200",
|
||||
"tracked_dirty": false
|
||||
},
|
||||
"fidc-backtest-service": {
|
||||
"head": "5ec8dc86d99736a0c0140440bd039d11e118c1c6",
|
||||
"runtime_commit": "e81bf47806f5ac4ae4798bb5f5955a56638f754c",
|
||||
"tracked_dirty": false
|
||||
},
|
||||
"fidc-trading-platform": {
|
||||
"head": "94f99d20f49f6cd1810996706cb94f610c302385",
|
||||
"runtime_commit": "94f99d20f49f6cd1810996706cb94f610c302385",
|
||||
"tracked_dirty": false
|
||||
},
|
||||
"omniquant": {
|
||||
"head": "6a2b2604b40505fa754453307c517fef60743426",
|
||||
"runtime_commit": "6a2b2604b40505fa754453307c517fef60743426",
|
||||
"tracked_dirty": false
|
||||
}
|
||||
},
|
||||
"ui_unchanged": {
|
||||
"commit": "6a2b2604b40505fa754453307c517fef60743426",
|
||||
"pid": 3089476
|
||||
},
|
||||
"http_cases": [
|
||||
{
|
||||
"name": "manual_first",
|
||||
"run_id": "btr_req_44f1bb067559946ef22941a0c425ed53e47515e04b399e20",
|
||||
"status": "succeeded",
|
||||
"canonical_sha256": "0830216850b64d6e83291e072b31a9989f179915ee3341a75a77c73d1f9081a3",
|
||||
"trade_count": 10,
|
||||
"holding_count": 4,
|
||||
"final_equity": 9706248.648662,
|
||||
"old_result_unchanged": true
|
||||
},
|
||||
{
|
||||
"name": "automatic_first",
|
||||
"run_id": "btr_req_078f129ed46b55ba72b47605983a00ae3eef14b1995e1237",
|
||||
"status": "succeeded",
|
||||
"canonical_sha256": "c75cabcc03760f415bb664d20060e81c620d7a0201dd348ea71f75c932571de7",
|
||||
"trade_count": 10,
|
||||
"holding_count": 4,
|
||||
"final_equity": 9706248.648662,
|
||||
"old_result_unchanged": true
|
||||
},
|
||||
{
|
||||
"name": "stock24",
|
||||
"run_id": "btr_req_ef37b8ea403f489e4798d2878e0ef85966ace550f6b4f3c4",
|
||||
"status": "succeeded",
|
||||
"canonical_sha256": "270b403542ab41290c3d6e027b89cdab24dd41a8e2851d8786b33daa51e0051f",
|
||||
"trade_count": 51,
|
||||
"holding_count": 21,
|
||||
"final_equity": 9685563.876924999,
|
||||
"old_result_unchanged": true
|
||||
}
|
||||
],
|
||||
"durable_events": [
|
||||
{
|
||||
"run_id": "btr_req_44f1bb067559946ef22941a0c425ed53e47515e04b399e20",
|
||||
"count": 27,
|
||||
"unique_keys": 27,
|
||||
"days": 5
|
||||
},
|
||||
{
|
||||
"run_id": "btr_req_078f129ed46b55ba72b47605983a00ae3eef14b1995e1237",
|
||||
"count": 18,
|
||||
"unique_keys": 18,
|
||||
"days": 5
|
||||
},
|
||||
{
|
||||
"run_id": "btr_req_ef37b8ea403f489e4798d2878e0ef85966ace550f6b4f3c4",
|
||||
"count": 32,
|
||||
"unique_keys": 32,
|
||||
"days": 5
|
||||
},
|
||||
{
|
||||
"run_id": "btr_req_a3c3dfe5cd81e27e565064a65665f561c60adebdc6c9c9b4",
|
||||
"count": 27,
|
||||
"unique_keys": 27,
|
||||
"days": 5
|
||||
}
|
||||
],
|
||||
"trading_state": {
|
||||
"paper": {
|
||||
"configuration": {
|
||||
"count": 3,
|
||||
"hash": "93f3224edef59c381164e0236529dacc"
|
||||
},
|
||||
"active": {
|
||||
"claims": 0,
|
||||
"orders": 0
|
||||
}
|
||||
},
|
||||
"live": {
|
||||
"configuration": {
|
||||
"count": 0,
|
||||
"hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"active": {
|
||||
"claims": 0,
|
||||
"orders": 1,
|
||||
"today_orders": 0,
|
||||
"orders_hash": "d4b56fbf3a541a41a383ad4e48891bb8",
|
||||
"route_mode": "disabled"
|
||||
}
|
||||
}
|
||||
},
|
||||
"manual_facts_unchanged": {
|
||||
"paper": {
|
||||
"shadow_configurations": 0,
|
||||
"shadow_runs": 0,
|
||||
"manual_count": 3,
|
||||
"manual_hash": "82572901ac0b5fdb4d8b984f71e1763d",
|
||||
"migrations_hash": "21d711b2ee52d2d66a8be4e99b179190",
|
||||
"new_orders": 0
|
||||
},
|
||||
"live": {
|
||||
"shadow_configurations": 0,
|
||||
"shadow_runs": 0,
|
||||
"manual_count": 0,
|
||||
"manual_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"migrations_hash": "610528d4f350309379c9398c4ea43f66",
|
||||
"new_orders": 0
|
||||
}
|
||||
},
|
||||
"broker_submission": false,
|
||||
"linux_core_tests": {
|
||||
"passed": 834,
|
||||
"failed": 0,
|
||||
"ignored": 9,
|
||||
"log": "/srv/fidc/canonical/run/fidc-private/evidence/callback-candidate-6qumwky7/linux-core-tests.log"
|
||||
},
|
||||
"scope": "Callback and pending-target release verification; historical simulation only. Full manual shadow replay remains incomplete.",
|
||||
"native_replays": 6
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"source": "/srv/fidc/programs/runtime-snapshots/lot-lifecycle-20260914-AEGDYL",
|
||||
"build": "/srv/fidc/canonical/build/lot-lifecycle-20260914-AEGDYL",
|
||||
"commits": {
|
||||
"fidc-backtest-engine": "b4c68be29b98dc814fa75641f629dd18e1dd803b",
|
||||
"fidc-backtest-service": "49f280075e6b9dce2ef149fc190cfe663411b905",
|
||||
"fidc-trading-platform": "ae83fd30f56a5420a235022b0a169aaf0bae55cf"
|
||||
},
|
||||
"source_unchanged": true,
|
||||
"results": [
|
||||
{
|
||||
"repository": "fidc-backtest-engine",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 60.008,
|
||||
"passed": 889,
|
||||
"failed": 0,
|
||||
"ignored": 9,
|
||||
"log_sha256": "7d0760a024a564cd2c18b7bf3b41ab8ba6f95d7e259d49a43191f0ebe666989e"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-trading-platform",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 102.011,
|
||||
"passed": 625,
|
||||
"failed": 0,
|
||||
"ignored": 63,
|
||||
"log_sha256": "ab55da8d47425a0acc6c33dc4bc352f463ad4e1aac887b0169a90f18cb6763cb"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-backtest-service",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 118.011,
|
||||
"passed": 589,
|
||||
"failed": 0,
|
||||
"ignored": 16,
|
||||
"log_sha256": "ff6946b18567dad6dab41b6c36f65ea1181d7ee6b7d53b6ceb568b1dedb7abae"
|
||||
}
|
||||
],
|
||||
"available_bytes": 94029492224
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"observed_date": "2026-09-14",
|
||||
"status": "tested_not_deployed",
|
||||
"source_snapshot_manifest_sha256": "5560614fa2ce2b0c905c44011ff4147df1fe7c04be93cd3c1410cd16fca025de",
|
||||
"immutable_source_file_count": 962,
|
||||
"test_scope": "fidc-lot-lifecycle-check-20260914-AEGDYL.scope",
|
||||
"test_cpu_quota_percent": 400,
|
||||
"test_memory_max_gib": 12,
|
||||
"source_runtime_commit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
|
||||
"source_runtime_pid": 1700096,
|
||||
"source_runtime_changed": false,
|
||||
"runtime_services": [
|
||||
{
|
||||
"service": "omniquant-highmem177.service",
|
||||
"pid": 987464,
|
||||
"state": "active",
|
||||
"version": "v2026.9.14.8"
|
||||
},
|
||||
{
|
||||
"service": "fidc-backtest-service-highmem177.service",
|
||||
"pid": 4108679,
|
||||
"state": "active",
|
||||
"exe_sha256": "5933b7423c6722e5fe644a604c915a27ceb89900f690b092d1689bfe0fe389b7"
|
||||
},
|
||||
{
|
||||
"service": "fidc-paper-trading-highmem177.service",
|
||||
"pid": 3765829,
|
||||
"state": "active",
|
||||
"exe_sha256": "3ebd7ce815e325ff0bb38632ef5b5e5cd5aff7a4e0601ba0229feb3201cdefe3"
|
||||
},
|
||||
{
|
||||
"service": "fidc-live-trading-highmem177.service",
|
||||
"pid": 3765783,
|
||||
"state": "active",
|
||||
"exe_sha256": "1f4b2d697accdbb76d89cd7cb9c3ae4dad942d86e3dde2e7d211eee5879b9144"
|
||||
},
|
||||
{
|
||||
"service": "fidc-strategy-runtime-highmem177.service",
|
||||
"pid": 3765705,
|
||||
"state": "active",
|
||||
"exe_sha256": "5a160ba9588f0ba9275f7d33152667be176cece310fc3026d4c043d897d44b4c"
|
||||
},
|
||||
{
|
||||
"service": "fidc-trading-control-highmem177.service",
|
||||
"pid": 3765532,
|
||||
"state": "active",
|
||||
"exe_sha256": "9feade5eed04007595f6ef88e076a3f501631b06cb6dc9d9b6d7f5be379b0a20"
|
||||
}
|
||||
],
|
||||
"engine_development_checkout": "b4c68be29b98dc814fa75641f629dd18e1dd803b",
|
||||
"preserved_service_development_checkout": "5991e7733d9e2a770f740e971924b12cc5f7d29d",
|
||||
"production_order_or_cancel_requests_by_this_verification": 0,
|
||||
"production_task_configuration_writes_by_this_verification": 0,
|
||||
"remaining": [
|
||||
"Source freeze authorization and physical partition validation",
|
||||
"Actual Source and Runner shadow joint acceptance",
|
||||
"Unaccepted Arrow performance candidate gate",
|
||||
"Delayed observations spanning already applied corporate actions and remaining cross-mode matrix"
|
||||
],
|
||||
"source_runtime_tracked_clean": true
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"source": "/srv/fidc/programs/runtime-snapshots/corporate-replay-20260914-ZAnvlO",
|
||||
"build": "/srv/fidc/canonical/build/corporate-replay-20260914-ZAnvlO",
|
||||
"commits": {
|
||||
"fidc-backtest-engine": "05f1cbbe00ffe75b72de8a24cea6a4e40e8142b0",
|
||||
"fidc-backtest-service": "879a5a7fe07620415b00023f4628cc81e45903fb",
|
||||
"fidc-trading-platform": "559f5b1d24d98a67606e859a1f2105bee4c0bc01"
|
||||
},
|
||||
"source_unchanged": true,
|
||||
"results": [
|
||||
{
|
||||
"repository": "fidc-backtest-engine",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 58.007,
|
||||
"passed": 895,
|
||||
"failed": 0,
|
||||
"ignored": 9,
|
||||
"log_sha256": "d50fc04699eaa39399016e3b9f470e9adf3e8da0d968ada2e212ef606bdfb37a"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-trading-platform",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 94.009,
|
||||
"passed": 625,
|
||||
"failed": 0,
|
||||
"ignored": 63,
|
||||
"log_sha256": "eafd3d792dc54ff90efaae9aa1e9e0c15bb191f8b679585c982b8c1bab3211ad"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-backtest-service",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 148.015,
|
||||
"passed": 591,
|
||||
"failed": 0,
|
||||
"ignored": 16,
|
||||
"log_sha256": "2ae31905c43697177af50825270d4f7830361d6e1d5e3d22a26179dd726abe36"
|
||||
}
|
||||
],
|
||||
"available_bytes": 90635833344
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"observed_date": "2026-09-14",
|
||||
"status": "tested_not_deployed",
|
||||
"source_snapshot_manifest_sha256": "9b61a3586248c80bac30ce2107c9323a4d9a1ac7bdf29a42d3c423bd5c615ec4",
|
||||
"immutable_source_file_count": 972,
|
||||
"test_scope": "fidc-corporate-replay-check-20260914-ZAnvlO.scope",
|
||||
"test_cpu_quota_percent": 400,
|
||||
"test_memory_max_gib": 12,
|
||||
"source_runtime_commit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
|
||||
"source_runtime_pid": 1700096,
|
||||
"source_runtime_changed": false,
|
||||
"runtime_services": [
|
||||
{
|
||||
"service": "omniquant-highmem177.service",
|
||||
"pid": 987464,
|
||||
"state": "active",
|
||||
"version": "v2026.9.14.8"
|
||||
},
|
||||
{
|
||||
"service": "fidc-backtest-service-highmem177.service",
|
||||
"pid": 4108679,
|
||||
"state": "active",
|
||||
"exe_sha256": "5933b7423c6722e5fe644a604c915a27ceb89900f690b092d1689bfe0fe389b7"
|
||||
},
|
||||
{
|
||||
"service": "fidc-paper-trading-highmem177.service",
|
||||
"pid": 3765829,
|
||||
"state": "active",
|
||||
"exe_sha256": "3ebd7ce815e325ff0bb38632ef5b5e5cd5aff7a4e0601ba0229feb3201cdefe3"
|
||||
},
|
||||
{
|
||||
"service": "fidc-live-trading-highmem177.service",
|
||||
"pid": 3765783,
|
||||
"state": "active",
|
||||
"exe_sha256": "1f4b2d697accdbb76d89cd7cb9c3ae4dad942d86e3dde2e7d211eee5879b9144"
|
||||
},
|
||||
{
|
||||
"service": "fidc-strategy-runtime-highmem177.service",
|
||||
"pid": 3765705,
|
||||
"state": "active",
|
||||
"exe_sha256": "5a160ba9588f0ba9275f7d33152667be176cece310fc3026d4c043d897d44b4c"
|
||||
},
|
||||
{
|
||||
"service": "fidc-trading-control-highmem177.service",
|
||||
"pid": 3765532,
|
||||
"state": "active",
|
||||
"exe_sha256": "9feade5eed04007595f6ef88e076a3f501631b06cb6dc9d9b6d7f5be379b0a20"
|
||||
}
|
||||
],
|
||||
"engine_development_checkout": "05f1cbbe00ffe75b72de8a24cea6a4e40e8142b0",
|
||||
"preserved_service_development_checkout": "5991e7733d9e2a770f740e971924b12cc5f7d29d",
|
||||
"production_order_or_cancel_requests_by_this_verification": 0,
|
||||
"production_task_configuration_writes_by_this_verification": 0,
|
||||
"remaining": [
|
||||
"Source freeze authorization and physical partition validation",
|
||||
"Actual Source and Runner shadow joint acceptance",
|
||||
"Unaccepted Arrow performance candidate gate",
|
||||
"Dividend reinvestment execution clocks, successor data scope, target state and cross-mode ETF matrix"
|
||||
],
|
||||
"source_runtime_tracked_clean": true
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"source": "/srv/fidc/programs/runtime-snapshots/opening-clock-20260914-UUx5ru",
|
||||
"build": "/srv/fidc/canonical/build/opening-clock-20260914-UUx5ru",
|
||||
"commits": {
|
||||
"fidc-backtest-engine": "13c89e8d59f21df6280d722f369d0ec8d9a6457e",
|
||||
"fidc-backtest-service": "49f280075e6b9dce2ef149fc190cfe663411b905",
|
||||
"fidc-trading-platform": "ae83fd30f56a5420a235022b0a169aaf0bae55cf"
|
||||
},
|
||||
"source_unchanged": true,
|
||||
"results": [
|
||||
{
|
||||
"repository": "fidc-backtest-engine",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 56.007,
|
||||
"passed": 885,
|
||||
"failed": 0,
|
||||
"ignored": 9,
|
||||
"log_sha256": "9a887a23fc656d0f448502ecb2fca5fcfd82db15346f567e6a8b0bcaa88180d7"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-trading-platform",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 90.054,
|
||||
"passed": 625,
|
||||
"failed": 0,
|
||||
"ignored": 63,
|
||||
"log_sha256": "048a7690ce874bccc58e3111decd183c3f88f649397c59cdfbaa3e4533662589"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-backtest-service",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 118.011,
|
||||
"passed": 589,
|
||||
"failed": 0,
|
||||
"ignored": 16,
|
||||
"log_sha256": "ec4f51b30dd69d9c86812cbb1b1e310c277fdfcf29465978b8d87eb83f40204c"
|
||||
}
|
||||
],
|
||||
"available_bytes": 97373085696
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"observed_date": "2026-09-14",
|
||||
"status": "tested_not_deployed",
|
||||
"source_snapshot_manifest_sha256": "bf01cd040bdcbeaa0c81a93153eb8e505dd7dcb8c761731f7f3ed81b1b2b6b67",
|
||||
"immutable_source_file_count": 959,
|
||||
"test_scope": "fidc-opening-clock-check-20260914-UUx5ru.scope",
|
||||
"test_cpu_quota_percent": 400,
|
||||
"test_memory_max_gib": 12,
|
||||
"source_runtime_commit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
|
||||
"source_runtime_pid": 1700096,
|
||||
"source_runtime_changed": false,
|
||||
"runtime_services": [
|
||||
{
|
||||
"service": "omniquant-highmem177.service",
|
||||
"pid": 987464,
|
||||
"state": "active",
|
||||
"version": "v2026.9.14.8"
|
||||
},
|
||||
{
|
||||
"service": "fidc-backtest-service-highmem177.service",
|
||||
"pid": 4108679,
|
||||
"state": "active",
|
||||
"exe_sha256": "5933b7423c6722e5fe644a604c915a27ceb89900f690b092d1689bfe0fe389b7"
|
||||
},
|
||||
{
|
||||
"service": "fidc-paper-trading-highmem177.service",
|
||||
"pid": 3765829,
|
||||
"state": "active",
|
||||
"exe_sha256": "3ebd7ce815e325ff0bb38632ef5b5e5cd5aff7a4e0601ba0229feb3201cdefe3"
|
||||
},
|
||||
{
|
||||
"service": "fidc-live-trading-highmem177.service",
|
||||
"pid": 3765783,
|
||||
"state": "active",
|
||||
"exe_sha256": "1f4b2d697accdbb76d89cd7cb9c3ae4dad942d86e3dde2e7d211eee5879b9144"
|
||||
},
|
||||
{
|
||||
"service": "fidc-strategy-runtime-highmem177.service",
|
||||
"pid": 3765705,
|
||||
"state": "active",
|
||||
"exe_sha256": "5a160ba9588f0ba9275f7d33152667be176cece310fc3026d4c043d897d44b4c"
|
||||
},
|
||||
{
|
||||
"service": "fidc-trading-control-highmem177.service",
|
||||
"pid": 3765532,
|
||||
"state": "active",
|
||||
"exe_sha256": "9feade5eed04007595f6ef88e076a3f501631b06cb6dc9d9b6d7f5be379b0a20"
|
||||
}
|
||||
],
|
||||
"engine_development_checkout": "13c89e8d59f21df6280d722f369d0ec8d9a6457e",
|
||||
"preserved_service_development_checkout": "5991e7733d9e2a770f740e971924b12cc5f7d29d",
|
||||
"production_order_or_cancel_requests_by_this_verification": 0,
|
||||
"production_task_configuration_writes_by_this_verification": 0,
|
||||
"remaining": [
|
||||
"Source freeze authorization and physical partition validation",
|
||||
"Actual Source and Runner shadow joint acceptance",
|
||||
"Unaccepted Arrow performance candidate gate",
|
||||
"Remaining corporate-action, holding-protection and full parameter matrix"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"source": "/srv/fidc/programs/runtime-snapshots/reinvestment-20260914-dBw0O5",
|
||||
"build": "/srv/fidc/canonical/build/reinvestment-20260914-dBw0O5",
|
||||
"commits": {
|
||||
"fidc-backtest-engine": "984f9d308dbddec8134b8aed249f82b962839505",
|
||||
"fidc-backtest-service": "234d85bdb8ed12b552ba8e9eff47fddc5e683acc",
|
||||
"fidc-trading-platform": "d7d492541e60eb16e58dbff74b664b8ac85f6ccf"
|
||||
},
|
||||
"source_unchanged": true,
|
||||
"results": [
|
||||
{
|
||||
"repository": "fidc-backtest-engine",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 54.005,
|
||||
"passed": 901,
|
||||
"failed": 0,
|
||||
"ignored": 9,
|
||||
"log_sha256": "ed7d00793dbbb037b9c806687841de00507ef8f59a174ab4c6ab38cbd419fe35"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-trading-platform",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 88.008,
|
||||
"passed": 625,
|
||||
"failed": 0,
|
||||
"ignored": 63,
|
||||
"log_sha256": "d4c6e129353c9cb738bb277c68bd390ac8b1e8df4db44340bd63e71fd60fa9da"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-backtest-service",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 110.01,
|
||||
"passed": 594,
|
||||
"failed": 0,
|
||||
"ignored": 16,
|
||||
"log_sha256": "57cd4b3f900f01f74864a1bb07a726b5d350b42384153f9094d9f316ddb7063c"
|
||||
}
|
||||
],
|
||||
"available_bytes": 87221923840
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"observed_date": "2026-09-14",
|
||||
"status": "tested_not_deployed",
|
||||
"source_snapshot_manifest_sha256": "8452dc9dedb6b3b3a48f37a572f4fe653728c856eda9aedd2644079448a55d65",
|
||||
"immutable_source_file_count": 978,
|
||||
"test_scope": "fidc-reinvestment-check-20260914-dBw0O5.scope",
|
||||
"test_cpu_quota_percent": 400,
|
||||
"test_memory_max_gib": 12,
|
||||
"source_runtime_commit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
|
||||
"source_runtime_pid": 1700096,
|
||||
"source_runtime_changed": false,
|
||||
"runtime_services": [
|
||||
{
|
||||
"service": "omniquant-highmem177.service",
|
||||
"pid": 987464,
|
||||
"state": "active",
|
||||
"version": "v2026.9.14.8"
|
||||
},
|
||||
{
|
||||
"service": "fidc-backtest-service-highmem177.service",
|
||||
"pid": 4108679,
|
||||
"state": "active",
|
||||
"exe_sha256": "5933b7423c6722e5fe644a604c915a27ceb89900f690b092d1689bfe0fe389b7"
|
||||
},
|
||||
{
|
||||
"service": "fidc-paper-trading-highmem177.service",
|
||||
"pid": 3765829,
|
||||
"state": "active",
|
||||
"exe_sha256": "3ebd7ce815e325ff0bb38632ef5b5e5cd5aff7a4e0601ba0229feb3201cdefe3"
|
||||
},
|
||||
{
|
||||
"service": "fidc-live-trading-highmem177.service",
|
||||
"pid": 3765783,
|
||||
"state": "active",
|
||||
"exe_sha256": "1f4b2d697accdbb76d89cd7cb9c3ae4dad942d86e3dde2e7d211eee5879b9144"
|
||||
},
|
||||
{
|
||||
"service": "fidc-strategy-runtime-highmem177.service",
|
||||
"pid": 3765705,
|
||||
"state": "active",
|
||||
"exe_sha256": "5a160ba9588f0ba9275f7d33152667be176cece310fc3026d4c043d897d44b4c"
|
||||
},
|
||||
{
|
||||
"service": "fidc-trading-control-highmem177.service",
|
||||
"pid": 3765532,
|
||||
"state": "active",
|
||||
"exe_sha256": "9feade5eed04007595f6ef88e076a3f501631b06cb6dc9d9b6d7f5be379b0a20"
|
||||
}
|
||||
],
|
||||
"engine_development_checkout": "984f9d308dbddec8134b8aed249f82b962839505",
|
||||
"preserved_service_development_checkout": "5991e7733d9e2a770f740e971924b12cc5f7d29d",
|
||||
"production_order_or_cancel_requests_by_this_verification": 0,
|
||||
"production_task_configuration_writes_by_this_verification": 0,
|
||||
"remaining": [
|
||||
"Source freeze authorization and physical partition validation",
|
||||
"Actual Source and Runner shadow joint acceptance",
|
||||
"Unaccepted Arrow performance candidate gate",
|
||||
"Successor data scope, target state, cleared-position references and cross-mode ETF matrix"
|
||||
],
|
||||
"source_runtime_tracked_clean": true
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
{
|
||||
"schema": "fidc.series-column-storage-acceptance/v1",
|
||||
"rows": [
|
||||
{
|
||||
"name": "control-1",
|
||||
"receiptSha256": "fa8f2265f1d3ed2cdeef02f62840fec00a266ace3cf9399cb81d33110f3b115c",
|
||||
"wallSeconds": 23.878584733000025,
|
||||
"engineSeconds": 6.612,
|
||||
"dataSeconds": 5.213,
|
||||
"datasetConstructSeconds": 1.901,
|
||||
"loopSeconds": 1.734,
|
||||
"validationSeconds": 10.871,
|
||||
"resultSeconds": 1.007,
|
||||
"maxRssKiB": 7137676,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"source_stale": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "candidate-1",
|
||||
"receiptSha256": "edd7e575013932ae58603e9fa810626573e1b0eaa00d01426d736cb4740aaf86",
|
||||
"wallSeconds": 24.12557172914967,
|
||||
"engineSeconds": 6.674,
|
||||
"dataSeconds": 4.818,
|
||||
"datasetConstructSeconds": 1.589,
|
||||
"loopSeconds": 1.646,
|
||||
"validationSeconds": 11.47,
|
||||
"resultSeconds": 1.011,
|
||||
"maxRssKiB": 6463660,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"source_stale": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "rotation-control-2",
|
||||
"receiptSha256": "6a5b37c2881c7a4177ce69cef2440c3dd7a0846c6ca9a4811c68f173b16f7183",
|
||||
"wallSeconds": 18.18023332185112,
|
||||
"engineSeconds": 9.509,
|
||||
"dataSeconds": 7.272,
|
||||
"datasetConstructSeconds": 2.824,
|
||||
"loopSeconds": 2.822,
|
||||
"validationSeconds": 0.005,
|
||||
"resultSeconds": 1.206,
|
||||
"maxRssKiB": 7138628,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"source_stale": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "rotation-candidate-2",
|
||||
"receiptSha256": "f50ca6af6496213d558af646862e5a86334077bb9080e4e7c6c9602a0cabd789",
|
||||
"wallSeconds": 15.078223099000752,
|
||||
"engineSeconds": 8.171,
|
||||
"dataSeconds": 5.543,
|
||||
"datasetConstructSeconds": 2.094,
|
||||
"loopSeconds": 2.066,
|
||||
"validationSeconds": 0.004,
|
||||
"resultSeconds": 1.177,
|
||||
"maxRssKiB": 6454624,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"source_stale": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "candidate-3",
|
||||
"receiptSha256": "c8bce9a8eeda769f00e72118c9ea955d323fdad902dc271590d6e377b5a99196",
|
||||
"wallSeconds": 13.124768079956993,
|
||||
"engineSeconds": 6.649,
|
||||
"dataSeconds": 5.103,
|
||||
"datasetConstructSeconds": 1.853,
|
||||
"loopSeconds": 1.676,
|
||||
"validationSeconds": 0.208,
|
||||
"resultSeconds": 1.017,
|
||||
"maxRssKiB": 6457728,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"source_stale": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "control-3",
|
||||
"receiptSha256": "1f999a89aadab1baf4a70cda96a314ab237302722cdce895bb5fe26dea4306ec",
|
||||
"wallSeconds": 12.725315875839442,
|
||||
"engineSeconds": 6.583,
|
||||
"dataSeconds": 4.955,
|
||||
"datasetConstructSeconds": 1.713,
|
||||
"loopSeconds": 1.675,
|
||||
"validationSeconds": 0.004,
|
||||
"resultSeconds": 1.013,
|
||||
"maxRssKiB": 7140772,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"source_stale": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "trend-40-control",
|
||||
"receiptSha256": "55b1976d15ba531477ff92f07f953a0a8a9b7cab96f01fb0ce5546f9aeda7406",
|
||||
"wallSeconds": 14.779385674046353,
|
||||
"engineSeconds": 7.867,
|
||||
"dataSeconds": 5.418,
|
||||
"datasetConstructSeconds": 1.921,
|
||||
"loopSeconds": 1.952,
|
||||
"validationSeconds": 0.005,
|
||||
"resultSeconds": 1.35,
|
||||
"maxRssKiB": 7157844,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 128192,
|
||||
"sha256": "41209fed4c7a5e385a12e170afef685efae7a3b00137365fe2b9aea015dd7948",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 29968,
|
||||
"sha256": "fa578b86d94a5be9ad192258eb87c9be6a1b356d6713e70ae475b95fd130f61d"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "90f63f3c51c62f0fc0abb317a29ac48aa0ead6035d5f75d650a948c33ae1e9b3"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 29776,
|
||||
"sha256": "6626ea78cfaa5d88040496c575be63cd8b573d48d1afe6812579762bf7e2abd0"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 37367,
|
||||
"sha256": "491b32468148cc62779f4c01cee96e4dcf18ce1a6115ccecc437b468e49b2ceb"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 29932,
|
||||
"sha256": "565f5a6271a9e1f0fa31f152141c75db0e5f84784e4d4a83b2277b0fd14c45fc"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 124,
|
||||
"sha256": "d170e1704ee5f64a93b71cecf2178b062d30368df8f5da53c07416b8e8a9c5e5"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "ab29a9f999a6b41330255ba82081696a9826825fa6d34941247332adac65249e",
|
||||
"verifiedFactBlocks": 293,
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"source_stale": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "trend-40-candidate",
|
||||
"receiptSha256": "86a3f452168d059f034e91f7317890af49d9763b75745dd3d39a948b00c1d72d",
|
||||
"wallSeconds": 15.011793529149145,
|
||||
"engineSeconds": 8.011,
|
||||
"dataSeconds": 5.168,
|
||||
"datasetConstructSeconds": 1.851,
|
||||
"loopSeconds": 1.739,
|
||||
"validationSeconds": 0.376,
|
||||
"resultSeconds": 1.315,
|
||||
"maxRssKiB": 6470768,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 128192,
|
||||
"sha256": "41209fed4c7a5e385a12e170afef685efae7a3b00137365fe2b9aea015dd7948",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 29968,
|
||||
"sha256": "fa578b86d94a5be9ad192258eb87c9be6a1b356d6713e70ae475b95fd130f61d"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "90f63f3c51c62f0fc0abb317a29ac48aa0ead6035d5f75d650a948c33ae1e9b3"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 29776,
|
||||
"sha256": "6626ea78cfaa5d88040496c575be63cd8b573d48d1afe6812579762bf7e2abd0"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 37367,
|
||||
"sha256": "491b32468148cc62779f4c01cee96e4dcf18ce1a6115ccecc437b468e49b2ceb"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 29932,
|
||||
"sha256": "565f5a6271a9e1f0fa31f152141c75db0e5f84784e4d4a83b2277b0fd14c45fc"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 124,
|
||||
"sha256": "d170e1704ee5f64a93b71cecf2178b062d30368df8f5da53c07416b8e8a9c5e5"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "ab29a9f999a6b41330255ba82081696a9826825fa6d34941247332adac65249e",
|
||||
"verifiedFactBlocks": 293,
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"source_stale": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pullback-40-control",
|
||||
"receiptSha256": "f5e6c9cb90db0dc7095e388b4ee939d4c6609824f3c065e0a08144a80f3d9395",
|
||||
"wallSeconds": 14.010676869889721,
|
||||
"engineSeconds": 7.298,
|
||||
"dataSeconds": 5.172,
|
||||
"datasetConstructSeconds": 1.893,
|
||||
"loopSeconds": 1.7,
|
||||
"validationSeconds": 0.005,
|
||||
"resultSeconds": 1.388,
|
||||
"maxRssKiB": 7167408,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 135630,
|
||||
"sha256": "49fdbd74245d7aa678f1a4851add29f7b12dde71fd50c93c070fd2ee57f35285",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 32010,
|
||||
"sha256": "9be3914d28d7766f12bac45227d2c2da47d61a921f9b918a4b11447da4b78baa"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "2044627b1152c4a2ad8ea92dca3351322d41cfc18c002cda93f274bd810efdad"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 31862,
|
||||
"sha256": "b33d1b4b0f7f86c96c082c217c9a27d86830ccac0de5a33714104ac4756df8d7"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 38679,
|
||||
"sha256": "a45b516a926c57b6c7827f8f1684b6e749d26118b8c5d8e8614afbb23cd87559"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 31966,
|
||||
"sha256": "73704496ab17d10d9a602429d97b728cf22d0896f2d467b0f2064f2f48bf04fb"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 88,
|
||||
"sha256": "2c8de7dbb11c7ca5ac678c17feabec0bd98aac0a031bbb4780eddaa144933293"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "ff32f177e5e0ec4b4f3f0597b61174efdafac77b4f36e6065448b6e9c414f07e",
|
||||
"verifiedFactBlocks": 281,
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"source_stale": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pullback-40-candidate",
|
||||
"receiptSha256": "f8caf67a6010d882a064678cf5c57f48c53c85b7c82dde708c029ee52e9e56b2",
|
||||
"wallSeconds": 13.87669027899392,
|
||||
"engineSeconds": 7.219,
|
||||
"dataSeconds": 5.117,
|
||||
"datasetConstructSeconds": 1.837,
|
||||
"loopSeconds": 1.691,
|
||||
"validationSeconds": 0.005,
|
||||
"resultSeconds": 1.362,
|
||||
"maxRssKiB": 6478492,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 135630,
|
||||
"sha256": "49fdbd74245d7aa678f1a4851add29f7b12dde71fd50c93c070fd2ee57f35285",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 32010,
|
||||
"sha256": "9be3914d28d7766f12bac45227d2c2da47d61a921f9b918a4b11447da4b78baa"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "2044627b1152c4a2ad8ea92dca3351322d41cfc18c002cda93f274bd810efdad"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 31862,
|
||||
"sha256": "b33d1b4b0f7f86c96c082c217c9a27d86830ccac0de5a33714104ac4756df8d7"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 38679,
|
||||
"sha256": "a45b516a926c57b6c7827f8f1684b6e749d26118b8c5d8e8614afbb23cd87559"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 31966,
|
||||
"sha256": "73704496ab17d10d9a602429d97b728cf22d0896f2d467b0f2064f2f48bf04fb"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 88,
|
||||
"sha256": "2c8de7dbb11c7ca5ac678c17feabec0bd98aac0a031bbb4780eddaa144933293"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "ff32f177e5e0ec4b4f3f0597b61174efdafac77b4f36e6065448b6e9c414f07e",
|
||||
"verifiedFactBlocks": 281,
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"source_stale": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "volume-momentum-80-control",
|
||||
"receiptSha256": "0f589d11fec4a37f80635446fa445b7c7a9ae58e202e5b0c2533aa257fd94c81",
|
||||
"wallSeconds": 18.577259425073862,
|
||||
"engineSeconds": 10.96,
|
||||
"dataSeconds": 5.163,
|
||||
"datasetConstructSeconds": 1.891,
|
||||
"loopSeconds": 1.698,
|
||||
"validationSeconds": 0.004,
|
||||
"resultSeconds": 2.282,
|
||||
"maxRssKiB": 7210220,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 234267,
|
||||
"sha256": "4359817bb1fbd73b02c2c3456e1f0b7ee7428b090529393bd203e8212979a1dc",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 51696,
|
||||
"sha256": "ca4d333cc4768ec4b528475d1833809c7e76a80de937c635e7e933a0f18264c6"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "31865ccfeb71d260757979524a679880070d691c09083c82865c3de6dd47e440"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 51300,
|
||||
"sha256": "4f45cc0906b6cd02af9ce12450a52f509f6e80e9b26157695ae04e90ce7ca9ac"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 78078,
|
||||
"sha256": "79098ebfa48dcd24b00ba4d19917c291adb80a129b4d90ee1087b5f2210c43aa"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 51783,
|
||||
"sha256": "340699c5d04407c5c6f71dc39c41ac2baedc096bb11185f1ccb99ab7100ba8f8"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 385,
|
||||
"sha256": "2aa0a4d6e6078dbea815b536ce0ee8f6ca03f9c44f63cdfecf4e37a0ff9cf561"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "5748fe4db08f8d81607622b302cc43c1d2e4ef50c3b87739fb6c20cd40de79c0",
|
||||
"verifiedFactBlocks": 309,
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"source_stale": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "volume-momentum-80-candidate",
|
||||
"receiptSha256": "5ae74a1b311479a39c9863c2fba487951631353f9ae2a523d919f2d0a8f592e7",
|
||||
"wallSeconds": 18.476340716006234,
|
||||
"engineSeconds": 10.989,
|
||||
"dataSeconds": 5.051,
|
||||
"datasetConstructSeconds": 1.844,
|
||||
"loopSeconds": 1.645,
|
||||
"validationSeconds": 0.005,
|
||||
"resultSeconds": 2.284,
|
||||
"maxRssKiB": 6527236,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 234267,
|
||||
"sha256": "4359817bb1fbd73b02c2c3456e1f0b7ee7428b090529393bd203e8212979a1dc",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 51696,
|
||||
"sha256": "ca4d333cc4768ec4b528475d1833809c7e76a80de937c635e7e933a0f18264c6"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "31865ccfeb71d260757979524a679880070d691c09083c82865c3de6dd47e440"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 51300,
|
||||
"sha256": "4f45cc0906b6cd02af9ce12450a52f509f6e80e9b26157695ae04e90ce7ca9ac"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 78078,
|
||||
"sha256": "79098ebfa48dcd24b00ba4d19917c291adb80a129b4d90ee1087b5f2210c43aa"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 51783,
|
||||
"sha256": "340699c5d04407c5c6f71dc39c41ac2baedc096bb11185f1ccb99ab7100ba8f8"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 385,
|
||||
"sha256": "2aa0a4d6e6078dbea815b536ce0ee8f6ca03f9c44f63cdfecf4e37a0ff9cf561"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "5748fe4db08f8d81607622b302cc43c1d2e4ef50c3b87739fb6c20cd40de79c0",
|
||||
"verifiedFactBlocks": 309,
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"source_stale": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"sharedInputFiles": 9257,
|
||||
"sharedInputBytes": 12596608049,
|
||||
"sharedInputInventorySha256": "1a4818aaab906e77b750e28601d3d405ad9e14e0553f7937cc60b68be0c9b71d",
|
||||
"verifiedFactBlocks": 3506,
|
||||
"status": "candidate-not-deployed",
|
||||
"scope": "exact in-memory column reuse; separate DayOpen correctness fix included in both control and candidate",
|
||||
"controlRunnerSha256": "8859459f54389f12af1ab7d4e36802c01aff63fb10fbb679243ccdd54d013e2d",
|
||||
"candidateRunnerSha256": "40bcf65c1977dbd93ab8bc80e3ff04d0db5e27b61fce1afdce99cf1b5e58eb43",
|
||||
"candidateApiSha256": "c54be3a8196c32051520c709f793bcb974d869467bb12700d846efaad8c2180e",
|
||||
"engineCommit": "996b909608589fb1987f33c0cfb4c62099f69617",
|
||||
"serviceCommit": "443ed421c2c9c854a01fab69ce58957690504570",
|
||||
"boundaries": [
|
||||
"The DayOpen prefix correction is present in both storage A/B binaries.",
|
||||
"No file format, cache schema, input values or execution policy changed for the storage comparison.",
|
||||
"Original shared inputs were hashed and remained unchanged; results were recalculated into private artifacts.",
|
||||
"The last pair ran candidate before control. It did not establish a general latency improvement.",
|
||||
"Source remains frozen and paused research/signal tasks were not resumed.",
|
||||
"The independent same-day intraday clock counterexample remains unresolved."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"source": "/srv/fidc/programs/runtime-snapshots/successor-20260914-Sx15GO",
|
||||
"build": "/srv/fidc/canonical/build/successor-20260914-Sx15GO",
|
||||
"commits": {
|
||||
"fidc-backtest-engine": "59a0c95aaec9c14ddd7384bb451d6d90eb3abec7",
|
||||
"fidc-backtest-service": "234d85bdb8ed12b552ba8e9eff47fddc5e683acc",
|
||||
"fidc-trading-platform": "d7d492541e60eb16e58dbff74b664b8ac85f6ccf"
|
||||
},
|
||||
"source_unchanged": true,
|
||||
"results": [
|
||||
{
|
||||
"repository": "fidc-backtest-engine",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 58.008,
|
||||
"passed": 907,
|
||||
"failed": 0,
|
||||
"ignored": 9,
|
||||
"log_sha256": "9fa0e1cfcc55722beaacf22e743f4a4e097df8c967894b13b4d7dfd7e18540bc"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-trading-platform",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 88.009,
|
||||
"passed": 625,
|
||||
"failed": 0,
|
||||
"ignored": 63,
|
||||
"log_sha256": "50c659d618fccf51ce33e15a879d086934efb4a537f82dafb5f4c133d46eecce"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-backtest-service",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 110.011,
|
||||
"passed": 594,
|
||||
"failed": 0,
|
||||
"ignored": 16,
|
||||
"log_sha256": "7a2676f29c4d5867b6df64779d356c33217004ed0ddad73af75255eb0143d57a"
|
||||
}
|
||||
],
|
||||
"available_bytes": 83883585536
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"observed_date": "2026-09-14",
|
||||
"baseline_engine": "d4e7cdd5b53e97666afb40c512223def0401539c",
|
||||
"candidate_engine": "59a0c95aaec9c14ddd7384bb451d6d90eb3abec7",
|
||||
"scope": "isolated core regression; not Source or broker acceptance",
|
||||
"reproduced": [
|
||||
{
|
||||
"test": "successor_without_frozen_instrument_metadata_is_not_an_implicit_new_security",
|
||||
"before": "returned success and created 300 shares of an instrument absent from the frozen metadata",
|
||||
"after": "successor_instrument_missing, original ledger and notes unchanged"
|
||||
},
|
||||
{
|
||||
"test": "invalid_successor_terms_cannot_be_replaced_with_one_share_or_zero_cash",
|
||||
"before": "missing ratio accepted as 1.0",
|
||||
"after": "all 13 malformed term cases rejected without mutation"
|
||||
},
|
||||
{
|
||||
"test": "a_late_corporate_batch_failure_keeps_prior_cash_positions_and_notes",
|
||||
"before": "an overflowing cash leg failed after deleting the old position and retaining a successor plus a cash receivable",
|
||||
"after": "whole batch rejected, including original target quantity and weight state, notes unchanged"
|
||||
}
|
||||
],
|
||||
"local_tests": {
|
||||
"core": {
|
||||
"passed": 907,
|
||||
"ignored": 9
|
||||
},
|
||||
"trading": {
|
||||
"passed": 625,
|
||||
"ignored": 63
|
||||
},
|
||||
"runner": {
|
||||
"passed": 463,
|
||||
"ignored": 9
|
||||
},
|
||||
"api": {
|
||||
"passed": 129,
|
||||
"ignored": 7
|
||||
}
|
||||
},
|
||||
"completed_engine_replay_cases": 12,
|
||||
"engine_replay_cases": "paper/gt/qmt observation origins x timely/late x buy/sell; each paired with complete/missing successor metadata",
|
||||
"live_order_or_cancel_requests": 0,
|
||||
"source_runtime_modified": false,
|
||||
"ui_modified": false
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"observed_at": "2026-09-14T15:05:40.175433+00:00",
|
||||
"status": "tested_not_deployed",
|
||||
"runtime_services": [
|
||||
{
|
||||
"service": "omniquant-highmem177.service",
|
||||
"pid": 987464,
|
||||
"state": "active"
|
||||
},
|
||||
{
|
||||
"service": "fidc-backtest-service-highmem177.service",
|
||||
"pid": 4108679,
|
||||
"state": "active",
|
||||
"exe_sha256": "5933b7423c6722e5fe644a604c915a27ceb89900f690b092d1689bfe0fe389b7"
|
||||
},
|
||||
{
|
||||
"service": "fidc-paper-trading-highmem177.service",
|
||||
"pid": 3765829,
|
||||
"state": "active",
|
||||
"exe_sha256": "3ebd7ce815e325ff0bb38632ef5b5e5cd5aff7a4e0601ba0229feb3201cdefe3"
|
||||
},
|
||||
{
|
||||
"service": "fidc-live-trading-highmem177.service",
|
||||
"pid": 3765783,
|
||||
"state": "active",
|
||||
"exe_sha256": "1f4b2d697accdbb76d89cd7cb9c3ae4dad942d86e3dde2e7d211eee5879b9144"
|
||||
},
|
||||
{
|
||||
"service": "fidc-strategy-runtime-highmem177.service",
|
||||
"pid": 3765705,
|
||||
"state": "active",
|
||||
"exe_sha256": "5a160ba9588f0ba9275f7d33152667be176cece310fc3026d4c043d897d44b4c"
|
||||
},
|
||||
{
|
||||
"service": "fidc-trading-control-highmem177.service",
|
||||
"pid": 3765532,
|
||||
"state": "active",
|
||||
"exe_sha256": "9feade5eed04007595f6ef88e076a3f501631b06cb6dc9d9b6d7f5be379b0a20"
|
||||
}
|
||||
],
|
||||
"source_runtime_commit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
|
||||
"source_runtime_tracked_clean": true,
|
||||
"engine_development_checkout": "59a0c95aaec9c14ddd7384bb451d6d90eb3abec7",
|
||||
"preserved_service_development_checkout": "5991e7733d9e2a770f740e971924b12cc5f7d29d",
|
||||
"immutable_source_file_count": 981,
|
||||
"source_snapshot_manifest_sha256": "a8da0ee559821758d98eb7e4ae27beaa683ce32b708dc29f90bc751808382ff3",
|
||||
"test_scope": "fidc-successor-check-20260914-Sx15GO.scope",
|
||||
"test_cpu_quota_percent": 400,
|
||||
"test_memory_max_gib": 12,
|
||||
"source_runtime_modified": false,
|
||||
"production_order_or_cancel_requests_by_this_verification": 0,
|
||||
"production_task_configuration_writes_by_this_verification": 0
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"source": "/srv/fidc/programs/runtime-snapshots/conversion-protection-20260914-51wBBB",
|
||||
"build": "/srv/fidc/canonical/build/conversion-protection-20260914-51wBBB",
|
||||
"commits": {
|
||||
"fidc-backtest-engine": "ba4b77fd746963687b8029b47806f943ed6ce03f",
|
||||
"fidc-backtest-service": "234d85bdb8ed12b552ba8e9eff47fddc5e683acc",
|
||||
"fidc-trading-platform": "d7d492541e60eb16e58dbff74b664b8ac85f6ccf"
|
||||
},
|
||||
"source_unchanged": true,
|
||||
"results": [
|
||||
{
|
||||
"repository": "fidc-backtest-engine",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 56.007,
|
||||
"passed": 913,
|
||||
"failed": 0,
|
||||
"ignored": 9,
|
||||
"log_sha256": "07ee830295ac22961dc877f75d0016e3755917037b167ac65f9e31d449c68991"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-trading-platform",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 90.009,
|
||||
"passed": 625,
|
||||
"failed": 0,
|
||||
"ignored": 63,
|
||||
"log_sha256": "e88a14c056fa69f0186eb681cd16ea1ce254b57e8cc48e34f0231bce7901588a"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-backtest-service",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 110.011,
|
||||
"passed": 594,
|
||||
"failed": 0,
|
||||
"ignored": 16,
|
||||
"log_sha256": "0ea63308d32bfc8ab0da4d71f73e770e7a6f273117e714dfcccb50e26ef70b54"
|
||||
}
|
||||
],
|
||||
"available_bytes": 80519344128
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"date": "2026-09-14",
|
||||
"baseline_engine": "ad76bdb6ae13f8ccd963e8fed4badff72a3846c3",
|
||||
"candidate_engine": "ba4b77fd746963687b8029b47806f943ed6ce03f",
|
||||
"reproduced_before_fix": [
|
||||
{
|
||||
"case": "timely sale then conversion inside sell cooldown",
|
||||
"unexpected_market_fill": {
|
||||
"symbol": "000002.SZ",
|
||||
"side": "Buy",
|
||||
"quantity": 100,
|
||||
"price": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "original security lock still active after conversion",
|
||||
"unexpected_market_fill": {
|
||||
"symbol": "000002.SZ",
|
||||
"side": "Sell",
|
||||
"quantity": 100,
|
||||
"price": 5
|
||||
}
|
||||
}
|
||||
],
|
||||
"verified": {
|
||||
"configured_dates_preserved": true,
|
||||
"no_new_orders_when_protected": true,
|
||||
"expiration_positive_control_fills": true,
|
||||
"clear_after_conversion_keeps_active_lock": true,
|
||||
"full_sale_before_conversion_removes_disproved_link": true,
|
||||
"original_strategy_members_and_weights_not_rewritten": true,
|
||||
"entry_points": [
|
||||
"platform explicit strategy",
|
||||
"native stock pool",
|
||||
"deferred ETF open consumer"
|
||||
],
|
||||
"new_test_functions": 6,
|
||||
"isolated_configuration_cases": 23
|
||||
},
|
||||
"local_tests": {
|
||||
"core": {
|
||||
"passed": 913,
|
||||
"ignored": 9
|
||||
},
|
||||
"trading": {
|
||||
"passed": 625,
|
||||
"ignored": 63
|
||||
},
|
||||
"runner": {
|
||||
"passed": 463,
|
||||
"ignored": 9
|
||||
},
|
||||
"api": {
|
||||
"passed": 129,
|
||||
"ignored": 7
|
||||
}
|
||||
},
|
||||
"ui_modified": false,
|
||||
"production_runtime_modified": false,
|
||||
"production_order_or_cancel_requests": 0,
|
||||
"limits": [
|
||||
"not production Source/Runner acceptance",
|
||||
"not a broker connection or corporate action acceptance",
|
||||
"online conversion lineage facts still required",
|
||||
"Source runtime freeze unchanged"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"observed_at": "2026-09-14T15:47:13.093567+00:00",
|
||||
"status": "tested_not_deployed",
|
||||
"runtime_services": [
|
||||
{
|
||||
"service": "omniquant-highmem177.service",
|
||||
"pid": 987464,
|
||||
"state": "active"
|
||||
},
|
||||
{
|
||||
"service": "fidc-backtest-service-highmem177.service",
|
||||
"pid": 4108679,
|
||||
"state": "active",
|
||||
"exe_sha256": "5933b7423c6722e5fe644a604c915a27ceb89900f690b092d1689bfe0fe389b7"
|
||||
},
|
||||
{
|
||||
"service": "fidc-paper-trading-highmem177.service",
|
||||
"pid": 3765829,
|
||||
"state": "active",
|
||||
"exe_sha256": "3ebd7ce815e325ff0bb38632ef5b5e5cd5aff7a4e0601ba0229feb3201cdefe3"
|
||||
},
|
||||
{
|
||||
"service": "fidc-live-trading-highmem177.service",
|
||||
"pid": 3765783,
|
||||
"state": "active",
|
||||
"exe_sha256": "1f4b2d697accdbb76d89cd7cb9c3ae4dad942d86e3dde2e7d211eee5879b9144"
|
||||
},
|
||||
{
|
||||
"service": "fidc-strategy-runtime-highmem177.service",
|
||||
"pid": 3765705,
|
||||
"state": "active",
|
||||
"exe_sha256": "5a160ba9588f0ba9275f7d33152667be176cece310fc3026d4c043d897d44b4c"
|
||||
},
|
||||
{
|
||||
"service": "fidc-trading-control-highmem177.service",
|
||||
"pid": 3765532,
|
||||
"state": "active",
|
||||
"exe_sha256": "9feade5eed04007595f6ef88e076a3f501631b06cb6dc9d9b6d7f5be379b0a20"
|
||||
}
|
||||
],
|
||||
"source_runtime_commit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
|
||||
"source_runtime_tracked_clean": true,
|
||||
"engine_development_checkout": "ba4b77fd746963687b8029b47806f943ed6ce03f",
|
||||
"preserved_service_development_checkout": "5991e7733d9e2a770f740e971924b12cc5f7d29d",
|
||||
"immutable_source_file_count": 985,
|
||||
"source_snapshot_manifest_sha256": "6ae0cb8f0e571b4848e2cba6b39368a19434cc43729cfe07aad6fdce531d2814",
|
||||
"test_scope": "fidc-conversion-protection-check-20260914-51wBBB.scope",
|
||||
"test_cpu_quota_percent": 400,
|
||||
"test_memory_max_gib": 12,
|
||||
"source_runtime_modified": false,
|
||||
"production_order_or_cancel_requests_by_this_verification": 0,
|
||||
"production_task_configuration_writes_by_this_verification": 0
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# 迟到成交、批次成本与持有保护
|
||||
|
||||
2026-09-14。阶段修复;完整股票池目标仍未完成,未据此发布生产。
|
||||
|
||||
## 根因与修复
|
||||
|
||||
旧持仓账本按收到买入回报的顺序追加批次,卖出直接扣列表首批。当较早成交的回报晚到,或旧证券换股并入已有新证券持仓时,列表先后不再等于取得日期。T+1 校验计算了合法老批次数量,却实际扣掉新批次;剩余旧批次可能再次被当作可卖。FIFO 成本与已实现/未实现盈亏随之错配。
|
||||
|
||||
另一问题是已有持仓收到更早买入事实时,`opened_date` 没有更新,最长持有期从较晚日期起算。
|
||||
|
||||
修复在账本扣减入口按真实取得日期稳定执行 FIFO;同日回报及其费用保持原关联,不重排收到的外部事件,不补单、不回写历史结果。正常日期顺序不排序,零股操作不排序。连续持仓的已知开仓日取较早日期,最近买入日仍取较晚日期;缺失的原始建仓日期不靠新买入猜测填充。移动均价展示合同与固定精度现金/费用不变。
|
||||
|
||||
## 负向证据
|
||||
|
||||
基线 `b2eaaa0d269d4aee5e2e500cd0f2b2edbda648b8` 上新增两个测试实际失败:
|
||||
|
||||
- 9月14日新买100股先被观察,9月11日老买100股随后才被观察。旧 `opened_date` 仍为9月14日,期望9月11日。
|
||||
- 随后卖出100股时,旧代码扣了新批次,剩余未实现盈亏为 -0.75,而按老批次先卖应为 -1000.25。该样例分别使用20元/10元买入、0.25/0.75元买入费用和0.5元卖出费用;只有证券身份数据,不冒充真实市场行情。
|
||||
|
||||
## 回归覆盖
|
||||
|
||||
- 回报仍按原观察序号应用;老买入不得在收到之前进入持仓。
|
||||
- 合法卖出老100股后,新100股仍不可在9月14日卖出。第二次冲突卖出拒绝且现金、股数、游标原子保留;现金7998.5、出入金0、剩余FIFO成本2000.25,费用没有串到另一批次。
|
||||
- 最长持有期使用9月11日,买后3个交易日保护使用最新买入日9月14日,保护优先于最长持有退出。
|
||||
- 整段引擎换股:旧股较早买入100股、已有新股较晚买入100股,旧股按2倍换成新股200股。随后卖200股先扣旧来源,留下新买100股;不重置开仓/最近买入日,成交来源及换股事件保留,已实现不含费用盈亏200。
|
||||
- 整段平台表达式:真实手工两买一卖、次日送转、3日买后保护/卖后禁买、16日至17日显式锁定、最长持有退出同时配置。14日至15日审计分别记录保护和禁买,16日至17日记录锁定;18日只生成一笔卖200股、5元的最长持有退出,不重复附加显式卖单。三笔手工来源保留、不计出入金,旧股转成200股后计时不重置。
|
||||
- 同一整段测试另验16日锁定期间的已确认手工卖出200股:与14日保护期间卖100股两个变体分别验证。前者15日送转后400股,手工卖出后200股;两个变体最终都在18日仅自动退出剩余200股。锁定仍阻止自动交易,不阻止已授权手工事实入账;T+1未绕过,买后保护和卖后禁买分别沿实际日期计算。
|
||||
|
||||
本机Core889、Trading625、Runner460/API127通过,ignore另计;针对性完整审计断言另行通过。两次测试编写阶段的私有方法/辅助函数名编译错误已修正,不计作框架失败或通过证据。不是实际Source或GT交易验收。
|
||||
|
||||
## 仍须继续
|
||||
|
||||
实际 Source/Runner 联合回放和未准入 Arrow 性能门禁尚未通过;Source明确冻结仍待独立解除授权。另需继续验证迟到回报跨越已经执行过的除权/派息/换股事件、跨模式历史持有事实及其余参数矩阵。本节只证明列出的组合,不能外推全部公司行为或关闭完整目标。
|
||||
|
||||
旧opening-clock-UUx5ru与FewUWP收据均不包含本次账本修复,不得覆盖。后续新的Linux/发布证据另附,本轮不修改原池、任务、历史或交易开关。
|
||||
|
||||
## Linux复验与当前状态
|
||||
|
||||
代码修复e9c9ecb、两组锁定/保护变体b4c68be均已提交推送;177引擎开发树已更新到b4c68be、tracked clean。新的`lot-lifecycle-20260914-AEGDYL`只读快照绑定Engineb4c68be/Service49f2800/Tradingae83fd3,962个文件、独立build根、4CPU/12GiB及1GiB容量线。
|
||||
|
||||
Core889、Trading625、Runner/API589(含Linux额外2项平台测试)通过,源码前后不变,测试已结束。收据[linux-tests.json](evidence/late-fill-lifecycle-20260914/linux-tests.json)及[runtime.json](evidence/late-fill-lifecycle-20260914/runtime.json)。本轮只是新快照测试,没有再构建release或创建发布tag,不覆盖已完成批次。
|
||||
|
||||
Source仍d5/PID1700096、tracked clean,生产UI及五后端PID/实际二进制SHA与上一轮相同;保留Service并行开发5991e77。没有委托/撤单/任务配置写入,没有解冻Source或重启生产。完整Goal继续上述剩余矩阵及正式Source/Runner验收,不把这些确定性测试外推为真实交易通过。
|
||||
@@ -0,0 +1,52 @@
|
||||
# 迟到成交跨公司行为:校正候选
|
||||
|
||||
2026-09-14。仅开发验收,未发布生产;完整股票池目标保持进行中。
|
||||
|
||||
## 已复现
|
||||
|
||||
相同成交与同一数据/配置,仅推迟回报收到时间,原实现会丢失或多留公司行为权益:
|
||||
|
||||
- 买100股后送转2倍:及时回报为200股、权益9999;迟到回报只有100股、权益9499。
|
||||
- 原持有200股、送转前卖100股:及时回报最后200股、权益9998;迟到回报最后300股、权益10498。
|
||||
- 派息会漏记或多记应收/现金;换股后的迟到旧代码成交不能直接写回旧证券持仓。
|
||||
|
||||
这些是隔离的确定性引擎样例,不是实际GT账户金额。
|
||||
|
||||
## 实现
|
||||
|
||||
正常公司行为与校正共用 `corporate_book` 的原始计算逻辑。迟到回报跨过已处理的有效公司行为时,先复算当前已观察前缀,核对现金、批次取得日/成本及应收身份;覆盖不完整就失败,不以账户最终快照覆盖结果。
|
||||
|
||||
随后在私有经济账本按实际成交时间放入已经确认的手工事实,重放已发出的模拟成交、原现金变动与已执行的公司行为。原始策略不重跑,委托不补造,旧成交/历史日终行不重写;不存在的税费或现金对价不推算。普通日线成交的内部顺序使用冻结撮合模型,不把模型时刻写成实际券商时间。
|
||||
|
||||
现金与持仓在真实回报收到时原子替换;失败不推进游标。已发行单位、融资/资金流控制和任务目标状态保留,不重新发行单位或改用户参数。已收管理费和既有委托价格/费用保持原事实。现金计算直接使用固定金额,避免大额资金再经浮点转换。
|
||||
|
||||
校正新增独立审计:公司行为日期、冻结参数和SHA;现金前后与权益现金变化;逐代码股数及成本前后;前后账本摘要。原始成交股数、价格、费用和资金变动仍分开保存。换股后的卖后禁买事实沿已证实的后继代码传递,不冒充新增交易。
|
||||
|
||||
这是冻结回测合同内的经济账本校正,不是对实际GT税后派息或权益到账的独立核验。
|
||||
|
||||
## 验证范围
|
||||
|
||||
- 买/卖两方向 × 送转、派息、换股的最终股数/现金/权益对照通过。
|
||||
- 期间存在已执行的同股/其他股票买入、即期或延期入金、显式融资及固定已收管理费,对照通过;原模拟成交列表逐字段不变。
|
||||
- 派息启用/禁用、已付/待付、成本调整开/关八组合通过。禁用时不改变原观察日成交统计,仅比较最终经济事实,不将其误称同日成交。
|
||||
- 两个100股批次、1.125倍送转,合计225股;不是把两段分别取整得到226股。
|
||||
- 原子投影失败测试、来源绑定/金额/日期/后继链篡改拒绝测试通过。
|
||||
- 本机Core895、交易625、Runner462/API127、前端2277通过;原ignore/skip不计通过。三项私有PG提交/权限/租约用例实际执行通过,最初错误筛选匹配0项的命令不计通过。
|
||||
|
||||
Runner正常构建保留现有Mac专属dead-code提示;格式化辅助脚本在lib.rs的模块排序比较处主动停止,没有强行覆盖模块顺序。最终源码检查与测试仍单独执行。
|
||||
|
||||
## 贯通与剩余门禁
|
||||
|
||||
提交能力增加 `corporate_adjustments: v1`,旧消费者不接受本轮手工输入。共享最终/流式成交投影保留校正结构并核对固定金额;页面保留原始交易,额外证据无效时单独报错而不藏掉原交易。
|
||||
|
||||
真实Rust样例经共享投影导出后,通过本机HTTP加载实际审计弹窗,1440/390、短屏及明暗主题共六组通过;无横向溢出、页面错误或交易写请求。原生Tab曾短暂离开弹窗,已补循环焦点;Esc回到原按钮。详见OmniQuant同名文档及 `docs/evidence/manual-corporate-replay-20260914/browser.json`。
|
||||
|
||||
Source d5明确冻结尚未获准解除,实际Source/Runner/生产联合验收仍未完成。新Linux配套验收与生产发布另列,不用旧AEGDYL/UUx5ru/FewUWP收据冒充本轮。继续核对红利再投入场时钟、后继证券数据范围、目标状态及跨模式ETF等剩余矩阵;本轮通过不代表完整Goal完成。
|
||||
|
||||
## Linux配套补验
|
||||
|
||||
已推送功能提交:Engine05f1cbb、Service879a5a7、Trading559f5b1、UIf3c5470b(双远端已核对)。177引擎开发源码已ff到05,保留Service并行5991e77。独立只读`corporate-replay-20260914-ZAnvlO`快照972文件,4CPU/12GiB、boris运行、1GiB容量线保持。
|
||||
|
||||
Linux Core895、Trading625、Runner/API591全部通过(原9/63/16项ignore不计),源码前后未变。收据[linux-tests.json](evidence/manual-corporate-replay-20260914/linux-tests.json)及[runtime.json](evidence/manual-corporate-replay-20260914/runtime.json)。测试已结束;没有新的release构建、tag或生产重启,不覆盖旧完成快照。
|
||||
|
||||
生产UI/Paper/Live/Backtest等PID/实际SHA与前阶段相同,Source d5/PID1700096且tracked clean。没有真实委托、撤单或配置/通知写入。下一步直接处理上述剩余边界及正式Source/Runner准入,不重复本轮确定性测试或据此关闭完整Goal。
|
||||
@@ -0,0 +1,53 @@
|
||||
# 手工观察主时钟接入候选
|
||||
|
||||
最新显式开盘/跨日 ETF 修复与验收见 [opening-and-deferred-clock-20260914.md](opening-and-deferred-clock-20260914.md)。下面保留早期阶段证据;原“开盘晚于配置窗口一律拒绝”已改为共享时钟与真实收盘边界,不再依赖订阅是否启用。
|
||||
|
||||
2026-09-14,未发布,完整Goal不关闭。不是生产手工影子回放验收。
|
||||
|
||||
## 本阶段已实现
|
||||
|
||||
- BacktestEngine可显式绑定严格v2手工观察输入。原始回报时刻驱动账本;同一时刻按真实观察序号逐笔原子应用,回调能看到100、200而不是第一笔就看到两笔总量。
|
||||
- 默认盘前、开盘、盘中、收盘/结算及当日晚到回报纳入处理;跨会话观察先于下一会话公司行为,不生成行情行;结束后仍未覆盖的观察明确失败,不截断成成功。
|
||||
- 手工成交写独立来源及应用明细,不冒充模拟策略FillEvent。账户变化不计作出入金;手续费只扣一次,最终费用来源/时间仍单独保留。
|
||||
- 股数及现金改变后通知策略,真实买卖日期更新持有保护和卖后禁买证据;券商模拟器的当日卖后禁买规则同样接收手工卖出,不把手工绕过自动条件理解为抹掉真实成交历史。
|
||||
- 分钟时钟不必依赖策略订阅或同一时刻市场报价,手工价格也不会伪造为市场行情。已有挂单/待执行目标冲突仍明确拒绝,不替用户撤单或重建目标。
|
||||
- 流式数量、原始观察明细及换手率纳入手工应用;纯无成交的来源不会改变自然策略时钟。
|
||||
|
||||
## 已复现并修正的问题
|
||||
|
||||
旧默认OpenAuction回调在09:31,接着却可能执行09:30日内步骤。手工09:27观察会由此先进入09:31再倒退到09:30。已把默认开盘阶段放在09:25,并保留显式调度时间。
|
||||
|
||||
盘前08:50/09:10规则原来在同一状态上顺序计算,不能正确看到夹在两者之间的08:55回报。现按实际到期时间交错处理回报、调度、资金等指令和撤改控制;盘前阶段若跨越开盘阶段,明确报告冲突,不把晚时点状态带回早时点。
|
||||
|
||||
## 当前验证
|
||||
|
||||
Core872通过(9项原ignore不计通过),交易工作区619普通测试通过;不是实际券商行情验收。此前默认阶段样例最终600股、现金3991、权益9991、出入金0,原四个基础用例保留。
|
||||
|
||||
本轮新增8项回归,不重复把基础样例当新验收:
|
||||
|
||||
- OpenAuction 09:20/09:26、AfterTrading 15:15/16:00、Settlement 16:10与09:22/15:30/16:05手工观察交错。原候选09:20提前读到100股,16:00/16:10仍只读到100股;修复后依次为0/100/100/200/300股。
|
||||
- 盘后16:00的100股与结算16:10的200股显式指令,下一交易日各执行一次、共300股,信号日价格10不冒充执行日价格12。lag0/lag1保留原信号日、意图创建日和实际成交日;测试还抓到立即成交记录曾被统一注释为新信号日,已按批次原始日期记录。
|
||||
- 多个完整目标在进入待执行队列时就只保留最新一份;次日新的0%完整目标不会先执行旧买入。显式股数指令不作为完整目标覆盖。
|
||||
- 结束日期的两笔显式意图没有生成委托/成交,完整原指令留在terminalAudit;完整目标只留最新一份。NaN/Inf不能在JSON中被悄悄变成null。
|
||||
- 存在真实行情/风控但没有新因子选股快照的下一交易日,仍执行已有指令,不等到后日再运行。声明盘后阶段的策略使用完整市场日历,外部指标可用同一`backtest_execution_dates_with_rules`对齐;当前Runner的Platform策略只暴露OpenAuction/OnDay/Minute,不宣称已支持配置盘后阶段。
|
||||
- 显式开盘调度越过已配置执行窗口被拒绝;盘后GTC撤单立即作为控制执行,不变成次日新委托。
|
||||
|
||||
盘后处理使用正常账本/报价/风控入口,不创建模拟外部Fill,不越过结束日期。旧DAY订单仍按到期失效,下一日处理的是尚未提交的策略意图,并非延长旧订单有效期。无新信号的报价时钟复用有序迭代器,不复制整日Tick列表。
|
||||
|
||||
结果协议和API/Runner的候选接入见fidc-backtest-service/docs/manual-execution-run-contract-20260914.md。当前正常记录/费用原始精度不改;所有新代码尚未发布,影子调用仍没有解除四类纯比例拒绝门禁。
|
||||
|
||||
## 必须继续
|
||||
|
||||
1. 本轮已覆盖上述显式阶段与跨日用例;仍需补完整混合时钟矩阵,特别是显式开盘晚于盘中报价/ETF开盘、无新信号日同时有ETF待执行目标、公司行为和跨日保护组合。不得修改market_open已有09:31语义或把这些未验组合静默跳过以让测试通过。
|
||||
2. 完成影子调度调用、所需历史证券范围、来源权限/归属、实际HTTP和Linux验收;不以独立输入/结果单测冒充端到端。
|
||||
3. 结果委托/成交分页接口与统一UI仍须合并展示外部手工来源,保留未知组件和完整原始ID,不把仅落库视为呈现已完成。
|
||||
4. 核对GT正式总费用来源、整仓关键日志严格持久化及完整参数矩阵后再配套发布。
|
||||
|
||||
本轮未重启生产或发送委托。同期其他维护已将Backtest发布为Engine665653c/Service501f6d0;这不包含本文件所述主时钟候选。交易仍166998d/v2026.9.14.6,Source d5/PID1700096冻结与研究暂停不改。
|
||||
|
||||
## 2026-09-14 运行级仓位配置补充
|
||||
|
||||
v3 手工输入独立携带审计仓位/权重时间线与旧日级前缀,不覆盖原策略或股票池。仅已成交证券产生独立行情需求;补充范围不会成为选股候选。恢复跟随回到原规则,未来事件不能被伪称为截止时刻前已观察事实。Core 878 项本机通过,尚未部署;PG、期间隔离、权限与剩余联合验收见 `../../fidc-trading-platform/docs/shadow-manual-input-20260914.md`。本节不替代前述时钟证据,也不宣称全部矩阵完成。
|
||||
## 逐日手工交付补充
|
||||
|
||||
手工观察输入可通过Arc与进度投影共享;默认紧凑进度保留当日手工应用及独立累计计数,原生明细开关不改。新增可失败进度回调,投影来源/计数错误会终止本次回测,不忽略错误后返回成功。Core879本机通过,当前完整版本Linux及发布验收未完成;共享最终/逐日投影与真实本机WebSocket证据见Service `docs/manual-stream-projection-20260914.md`。
|
||||
@@ -0,0 +1,45 @@
|
||||
# 手工成交观察回放:基础合同与当前断点
|
||||
|
||||
2026-09-14。当前候选已升级v2并与交易端权威读取配套,仍未接入Runner/API或引擎主时钟、未发布。交易最近发布是166998d/v2026.9.14.6,回测仍81acc54/e81;完整Goal和手工影子回放均未完成。
|
||||
|
||||
## v2读取合同补充
|
||||
|
||||
默认主时钟、盘前交错与独立结果来源已开始配套接入,当前阶段/真实缺口改由docs/manual-execution-clock-20260914.md维护。本基础模块通过不等于完整阶段日历或生产影子已启用。
|
||||
|
||||
总费用必须来自权威事实,佣金/印花税/过户费等组件可以未知,不能反过来用已知组件推定费用完整。保留组件原精度、总费用和微元账本费用;未知组件不写成0。新增费用来源事件/序号/可见时刻,原FillReceived继续决定股数变化时刻,后补费用不推迟成交、也不重复入账。历史采用最终费用回放口径,不能声称费用明细当时已经可见。
|
||||
|
||||
分别表达订单创建、确认登记、成交、原始观察、费用观察与终态核对,不伪装GT实际发送时间。无订单区分NoOrdersNeeded与NotExecuted;无成交且无券商身份时允许适配器未知,不造名称。确认登记之前的成交、证据跨交易复用、费用少于已知组件及越截止点均拒绝。
|
||||
|
||||
最新main a29c434的DayOpen和列存变更已按ff-only保留合入;组合Core860通过,其中本模块18项。交易端读取四类来源及验证范围见fidc-trading-platform/docs/manual-replay-capture-20260914.md。未将整仓无订单、Paper一例与Live一例外推完整参数/时钟/券商验收,不据此解除门禁。
|
||||
|
||||
## 已实现
|
||||
|
||||
`manual_execution`提供`fidc.observed-manual-executions/v2`严格合同及`ManualReplayCursor`。这是将已确认的手工成交事实作为外部输入,不是让回测券商独立重演其真实成交。下面保留初版阶段的实现说明,费用和时间字段以本节v2补充为准。
|
||||
|
||||
- 保留确认、提交、成交、观察和终态时间,声明秒/毫秒/微秒/纳秒精度;同秒报告只允许在其真实精度区间内与提交时间对应,不伪造纳秒。
|
||||
- 手工动作、审计事件、订单、券商订单、成交和`FillReceived`观察事件/序号均有唯一性与完整性校验。账户/运行身份及源合同摘要进入完整内容SHA;改价格、费用、身份或时间会使旧摘要失效。
|
||||
- 明确区分无须生成订单与有终态订单,拒绝不完整、未知、超量、状态不一致、超截止日期的数据。不将空订单列表直接当成功。
|
||||
- 金额输入使用十进制字符串,不先经过JSON浮点数。保留原价、原费用、原成交额;账本沿用既有微元精度,真实十进制金额在入口统一量化,并分开返回原值和账本值。
|
||||
- 游标按真实观察时间和已持久化事件序号前进,重入同一时点不会重复入账,时间倒退或越过证据截止时间会失败。
|
||||
- 资金、持仓及游标在一次advance中原子变更。资金不足、T+1、生命周期冲突或活动影子订单冲突不借股、不借款、不取消原订单,也不留下半笔状态。
|
||||
- 人工交易不是出入金,不更改现金流中性单位或初始资金;原始买卖账本入口继续使用原有计算,仅抽出可传固定金额的内部函数。
|
||||
|
||||
本机Core849项通过(9项原有ignore),其中15项新专项覆盖精度/摘要/关联/时间/顺序/无订单/部分撤单/原子失败/不重复和跨日出售。此结果不代表服务、完整影子请求或生产成交验收。
|
||||
|
||||
## 已核对的持久化入口
|
||||
|
||||
Paper `paper_manual_position_actions`保存确认、执行合同SHA、计划与order_ids;`paper_fills`及`paper_event_log.FillReceived`可以提供真实成交及观察事件序号。Live单证券动作在`live_manual_trade_intents`,逐笔事实在`live_broker_trade_facts`,对应`live_event_log.FillReceived`提供recorded_at和序号。事件序号表示持久化观察顺序,不冒充交易所执行顺序。
|
||||
|
||||
Live整仓的历史审计原来只有confirmation_hash,执行ID在另一个开始事件中;当前候选已将服务端生成的execution_id和所选account_id写入同一仓位审计详情,并校验非空ID和账户范围。旧历史仍只能依据原始审计/事件做唯一关联,不能猜测或重写。
|
||||
|
||||
费用仍需在读取层核对实际适配器合同:当前Paper账本收取commission+stamp_tax;Live事实的complete也按这两个已声明字段判定。不能仅凭complete名字断言其他费用不存在,不能以默认0补缺失。
|
||||
|
||||
## 必须继续,不能把本阶段当完成
|
||||
|
||||
1. 实现全部四类来源的权威PG读取、审计/动作/订单/成交/事件绑定与一致快照;未知/活动状态等待,不能变成空成功。
|
||||
2. 在API/Runner传递完整受控合同和源范围,补齐手工证券的历史资料/行情需求。当前没有任何运行入口调用此游标。
|
||||
3. 把观察事件与盘前、集合竞价、日度、分钟、收盘/结算阶段按完整时钟合并;跨交易日/会话外观察不可简单塞进on_minute或提前应用。
|
||||
4. 输出须区分外部人工成交与策略模拟成交,保留原始执行时间、观察时间、费用和实际投影时间线,不能宣称人工成交被独立验证。
|
||||
5. 完成两套隔离PG、真实引擎、完整HTTP和发布验证后,才可解除四类手工来源的纯比例影子拒绝门禁。
|
||||
|
||||
下一轮直接进行上述读取/引擎/结果链,不能重复15项基础用例或v2026.9.14.5固定三组回放替代集成。Source冻结、研究/信号暂停、现有3Paper/0Live与disabled不变;本轮无生产写入、真实订单或通知。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 显式开盘、跨日 ETF 与委托时钟修复
|
||||
|
||||
2026-09-14。候选本机及独立 Linux 已验证,尚未发布;完整股票池工作不以本阶段关闭。
|
||||
|
||||
## 已复现的错误
|
||||
|
||||
1. `market_open(0, 0)` 的既有语义为 09:31。旧引擎先执行该开盘回调,再执行 09:30 行情回调。09:31 的手工买入 100 股提前出现在 09:30 上下文,造成未来状态可见。
|
||||
2. 开盘回调顺序修正后,`MinuteLast` 撮合仍把配置的 09:30 窗口起点当作实际执行时刻,将 09:31 新订单记成 09:30 成交;新挂单也可能记成较早起点。
|
||||
3. 没有新因子/选股快照的下一交易日,原实现先执行 13:00 的普通待执行指令,再执行 ETF 的 09:30 开盘目标。负向测试中第一笔是股票 100 股、20 元、13:00,后面才出现较早 ETF 成交。此顺序会影响实际现金分配,不能只排序最终表格。
|
||||
4. 行情可用但剩余现金不足一手时,报价撮合丢失预算阻断原因,最终错误显示“intraday quote liquidity exhausted”。
|
||||
|
||||
## 修复合同
|
||||
|
||||
- 开盘调度、已订阅行情、ETF 开盘、实际手工观察、委托窗口与到期时刻进入同一时间序列。保留 09:31 的已配置含义,不改成 09:25 避开反例。开盘阶段不允许越过收盘阶段;其可用性不再依赖是否订阅分钟回调。
|
||||
- 新订阅从实际启用时刻开始接收后续行情,不重放较早缓存报价;仍订阅中的证券不丢失其较早合法行情。
|
||||
- 新委托和续撮使用当前执行时刻,原委托创建时刻在后续重试中保留。行情来源时刻与成交时刻分开,日线/分钟/ETF 既有定价合同、价格精度、费用和证券规则不改。
|
||||
- 无新信号日不重新生成策略目标;只执行已存在意图、ETF 目标及挂单。相同时间先处理到期 ETF,再执行普通批次,后续按真实报价和到期时刻推进。没有可用信号上下文时只发布原始事实,不伪造策略回调。
|
||||
- 手工观察遇到尚未结束的影子订单/ETF 目标仍明确拒绝。不能把 09:30 的成交提前应用以让 09:15 的手工冲突消失。
|
||||
- 零成交预算阻断保留资金不足/金额预算/非法价原因,不伪装成流动性不足。真实无行情或容量不足的规则保持。
|
||||
|
||||
## 本机验证
|
||||
|
||||
源基线 Engine `232e9ae1546842224d7a21aa07d3c0696ece4b11`;Service `49f280075e6b9dce2ef149fc190cfe663411b905`、Trading `ae83fd30f56a5420a235022b0a169aaf0bae55cf`。
|
||||
|
||||
- Core 885 项通过,9 项原 ignore 不计通过。
|
||||
- Trading 工作区 625 项通过,63 项私有依赖 ignore 不计通过;没有重复运行已有数据库夹具。
|
||||
- Runner 460、API 127 项通过,16 项 ignore 不计通过。Mac 原 `is_source_row_file` dead-code 警告仍存在。
|
||||
- 手工 09:31 买入:09:30 回调 0 股,09:31 开盘回调 100 股;后续报价只处理一次。
|
||||
- NextBarOpen/MinuteLast × 订阅/未订阅四种组合:09:31 的 100 股新单恰好成交一次,时间均为 09:31;后续重试不改原创建时刻。
|
||||
- ETF 和晚开盘:09:15 回调未持有;09:30 成交 3,700 股、4 元;09:31 开盘及 09:32 行情各看见该唯一成交。
|
||||
- 无新信号日:ETF 09:30 先成交 3,700 股。13:00 股票卖出 100 股随后成交;股票买入 100 股的对照因剩余现金不足而拒绝,不能抢先花费 ETF 应使用的现金。无普通待执行意图的 ETF 单独分支也通过。
|
||||
- 同一无信号日的手工卖出:09:15 与未结束 ETF 目标冲突时拒绝;09:31、ETF 完成后的真实手工卖出应用一次,原股票持仓从 1,500 到 1,400 股。
|
||||
- 定位过程的失败、类型修正和资金不足断言修正不计通过;没有改动原池或补造行情。
|
||||
|
||||
## 发布与剩余工作
|
||||
|
||||
功能提交 `13c89e8d59f21df6280d722f369d0ec8d9a6457e` 已推送并核对 main,177 引擎开发源码已快进到同一 SHA,tracked clean。保留 Service 并行开发提交 `5991e7733d9e2a770f740e971924b12cc5f7d29d`;联合测试使用已推送 Service49f2800/Tradingae83fd3,不覆盖该工作树。
|
||||
|
||||
新的只读 `opening-clock-20260914-UUx5ru` 快照 959 个文件,4CPU/12GiB独立 scope、boris 执行、1GiB磁盘线不变。Linux Core885、Trading625、Runner/API589(Linux额外2项平台测试)全部通过,源码前后不变。收据 [linux-tests.json](evidence/opening-clock-20260914/linux-tests.json),只读生产状态 [runtime.json](evidence/opening-clock-20260914/runtime.json)。这是独立程序测试,不是 Source 正式数据回放或生产交易验收。
|
||||
|
||||
本修复不在已构建的 `manual-stream-20260914-FewUWP` 二进制中。该目录及既有 Linux 收据继续保留,不能覆盖或改写成包含本修复。本轮没有新 release 构建/发布 tag;仍须真实 Source/Runner 联合验收及未准入 Arrow 性能门禁。
|
||||
|
||||
Source `d5` 版本冻结、研究/信号暂停、Live disabled 与旧任务/历史不变。Source 清单权威修复和新验证合同仍待明确解冻授权;本阶段没有发单、撤单或生产重启。后续继续公司行为、跨日保护/禁买及完整参数/适配器矩阵,不把以上确定性例子外推为全量生产完成。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 红利再投来源、入账时钟及流式完整性
|
||||
|
||||
2026-09-14。候选未发布;完整Goal继续。
|
||||
|
||||
## 先核对语义
|
||||
|
||||
读取AiQuant `alv/portfolio/accounts/position_model.py::_handle_dividend_payable`与FIDC原实现后确认:现有开关是历史兼容的账务再投模型,按调整后的参考价分配整手股数、零费用,不是交易所委托。不能把盘前分配直接解释成市场成交提前发生,也不能为了修时钟擅自改为开盘价、收费市价单或调用GT。
|
||||
|
||||
保留此价格/数量/费用合同,新增明确`FillOrigin::DividendReinvestment`及结算入账时刻。普通市场成交默认来源不序列化,旧普通字段和原历史不改写。账务来源不得携带委托编号、市场时钟或手续费;规则由Core验证。
|
||||
|
||||
## 实际错误与修复
|
||||
|
||||
- 红利再投缺来源/时间,被手工公司行为复算当作09:30市场成交,09:15的回报因此报“future financial fact”。现在正常与复算使用同一结算模型,账务分配按结算阶段处理,不伪造或重跑市场订单。
|
||||
- 已观察时点的原账务记录保留。迟到成交改变已知权益后,重新计算账务分配并记录校正;它不是不能改变的交易所成交。1000股额外买入的例子,最终2200股,与及时观察对照一致,权益现金调整155。
|
||||
- 每日计数/流式切片原来在盘前结算之后才开始,漏掉晨间再投和周末回报。现从会话处理前开始,下一代表交易日完整交付期间观察,原实际观察时间不改,不补造非交易日行情。
|
||||
- 活跃证券缺参考价/元信息明确失败,结算批次原子回滚;终止上市等已证明生命周期不允许分配时保留现金并记录原因,不重建旧证券。未改变ST/停牌等市场委托风控:账务模型不代表市场买入获准。
|
||||
|
||||
## 交付
|
||||
|
||||
Runner对明确账务来源生成独立记录,不要求或伪造委托。API分开市场成交与账务分配的件数/金额;合入手工成交时也不把账务金额加回市场成交额。兼容总事件数仍保留,新增市场件数和账务件数。账务时钟在传输时显式带上海偏移;不猜测旧普通记录的时区。
|
||||
|
||||
提交能力增加`accounting_allocations:v1`,旧消费者不能处理本轮完整手工合同。Core能力说明和FIDC策略Agent注册表同步澄清其历史账务性质,新策略仍不得自行编写公司行为处理。
|
||||
|
||||
## 验证
|
||||
|
||||
本机Core901、Trading625、Runner463/API129及三项私有PG通过;原ignore单列。前端2278测试、类型/定向lint/主题检查与独立构建通过。共享Rust投影的实际表格在1440/390明暗四组无文档溢出、错误或交易写入;窄屏表格保留横向滚动。初验发现无时区标记及独立挂载缺少工作台令牌导致浅色选中项不清晰,已修正生产投影和共享组件令牌回退,复验通过。
|
||||
|
||||
旧价8.95、100股、零费用仍相同;无委托来源及进度覆盖、周末观察、迟到权益再投、非法来源拒绝、缺参考原子失败和退市现金保留均有专项。机械字段迁移时重复插入和测试导入问题在编译阶段纠正,不计作业务通过。
|
||||
|
||||
本次只证明这些边界,不等于真实Source/GT权益到账或全参数完成。仍须做后继证券范围、目标状态、持仓清空后参考与跨模式ETF的完整核对;实际Source/Runner及Arrow候选准入仍受Source冻结约束。Linux和发布证据另列,不能复用旧ZAnvlO等收据。
|
||||
|
||||
## Linux配套验收
|
||||
|
||||
已推送:Engine984f9d3、Service234d85b、Tradingd7d4925、UId9f7907d(双远端)、Agentc739854。177引擎开发树已ff到984,保留Service并行5991e77。978文件只读`reinvestment-20260914-dBw0O5`快照与独立build根,4CPU/12GiB、boris执行、1GiB空间线保持。
|
||||
|
||||
Linux Core901、Trading625、Runner/API594全部通过,源码前后不变;原9/63/16项ignore不计。新收据[linux-tests.json](evidence/reinvestment-origin-20260914/linux-tests.json)及[runtime.json](evidence/reinvestment-origin-20260914/runtime.json)。本批测试已结束,不重复启动或覆盖历史收据;无新的release构建和发布tag。
|
||||
|
||||
生产UI及五后端PID/实际SHA保持前阶段值,Source d5/PID1700096、tracked clean。没有生产重启、配置/账户/通知写入或实际券商请求;完整Goal继续剩余矩阵与正式数据准入。
|
||||
@@ -2,16 +2,19 @@
|
||||
|
||||
## Status
|
||||
|
||||
Candidate tested, not deployed. The change removes selection calls that have
|
||||
Published to Backtest in the combined 665653c/501f6d0 release described below.
|
||||
The change removes selection calls that have
|
||||
no possible effect under the current frozen policy. It does not disable any
|
||||
configured rule, execution-day check or strategy expression. Engine time falls
|
||||
slightly in the measured cases; this is not the solution to the main remaining
|
||||
data construction cost and is not a general whole-backtest speedup claim.
|
||||
|
||||
The independent [intraday-clock counterexample](intraday-clock-causality-20260914.md)
|
||||
remains open. This work does not remove that test or its evidence, change the
|
||||
execution clock, or turn day-level parity into full framework acceptance.
|
||||
The published service stays at e81bf47/c98bcc3. Source d5b682c6 remains frozen;
|
||||
was subsequently resolved by business-main work and published in the clock and
|
||||
81acc54 callback releases. That correction is not attributed to this candidate.
|
||||
Current published Backtest uses 501f6d0/665653c; the measurements below retain
|
||||
their earlier c98 baseline. Complete manual-replay integration remains open.
|
||||
Source d5b682c6 remains frozen;
|
||||
research and signal work stay paused. No trading operation was submitted.
|
||||
|
||||
## Evidence Leading to the Change
|
||||
@@ -110,9 +113,10 @@ Prioritize direct typed-column reuse during daily snapshot and DataSet
|
||||
construction; approximately five seconds of preparation remain in these warm
|
||||
cases. Do not skip normalization, NULL, adjustment or date/uniqueness checks.
|
||||
Source cold-query and contract-validation latency remain separate tasks under
|
||||
the Source freeze. The earlier cache-boundary candidate still needs its missing
|
||||
cold/same-window acceptance, and this combined candidate has no HTTP publication
|
||||
gate yet. Financial PIT, minute-clock behavior, signal lifecycle and UI factor
|
||||
the Source freeze. The cache-boundary candidate later passed its conditional
|
||||
cross-window/full-input gate and the combination passed daily HTTP publication;
|
||||
neither establishes cold or universal performance. Financial PIT, broader minute
|
||||
behavior, signal lifecycle and UI factor
|
||||
condition acceptance are not claimed complete.
|
||||
|
||||
- Candidate engine: d2aa16a2f0064297d0d8c931060646d66422e9d4.
|
||||
@@ -124,3 +128,19 @@ condition acceptance are not claimed complete.
|
||||
- HTTP diagnostic: /srv/fidc/canonical/run/research/http-phase-profile-20260914.
|
||||
|
||||
[Verified acceptance summary](evidence/selection-risk-plan-20260914/acceptance.json).
|
||||
|
||||
## Combined Release
|
||||
|
||||
After merging engine 665653c, 860 core / 448 runner / 119 API tests and six
|
||||
additional new-process replays passed. The guarded official workflow deployed
|
||||
Backtest only, then nine HTTP runs matched their respective canonical/store
|
||||
baselines. A multi-strategy sequence proved actual immutable DataSet hit counts
|
||||
0/1/2/3/4 with distinct strategy results and repeatable trend results. Default
|
||||
90-day cleared-DataSet HTTP mean 13.646 before versus 13.740 seconds after does
|
||||
not demonstrate a general latency gain.
|
||||
|
||||
The active root /srv/fidc/canonical/build/factor-reserve-20260913 is protected
|
||||
from reuse/cleanup. Source, paused research, trading services and all execution
|
||||
permissions remain unchanged. This does not activate the manual-replay module.
|
||||
Actual identities, timings and evidence are maintained in
|
||||
`/Users/boris/WorkSpace/fidc-backtest-service/docs/cache-boundary-planning-performance-20260914.md`.
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# Exact Series Column Storage
|
||||
|
||||
## Status
|
||||
|
||||
The subsequent business-main merge includes the separately published 81acc54
|
||||
clock/callback fixes and 5e11f3d manual-replay foundation. The combined version
|
||||
passed 857 core, 448 runner and 119 API tests, six long reference replays and
|
||||
three additional strategy replays;
|
||||
see `/Users/boris/WorkSpace/fidc-backtest-service/docs/arrow-factor-scratch-rejection-20260914.md`.
|
||||
The scratch candidate from that experiment was removed. Series storage is
|
||||
now published to Backtest only in the combined 665653c/501f6d0 release below;
|
||||
existing measurements retain their original versions.
|
||||
|
||||
The original twelve real long replays preserve their independent
|
||||
business baselines and reduce peak RSS by about 9.5%. Construction latency is
|
||||
mixed, including a reversed pair where the control is faster. This is accepted
|
||||
as evidence of a smaller working set, not as a proved general speedup or closure
|
||||
of the main performance objective. Original results are retained unchanged.
|
||||
|
||||
The Source implementation remains d5b682c6d09704ff23d725a8dd8b155db3eb6967.
|
||||
Research/signal work remains paused. The initial experiments ran while e81bf47/c98
|
||||
was published; later business work published e81bf47/81acc54. This performance
|
||||
task did not restart Source, trading or another user's process. The original
|
||||
clock counterexample was resolved by that business work; complete manual-replay
|
||||
integration remains open and is not proved by these performance tests.
|
||||
|
||||
## Separate DayOpen Correction
|
||||
|
||||
Code inspection found that PriceField::DayOpen selected the Open prefix sums,
|
||||
although direct history access returned day_open. For day_open values 10/12
|
||||
and open values 20/24, that path computes 22 instead of the expected 11.
|
||||
The correction adds its own day-open prefix and a regression checking both
|
||||
fields plus empty/insufficient windows. No price field is substituted.
|
||||
|
||||
This correction was built and tested independently before the storage change:
|
||||
806 core unit/integration tests, 448 runner tests and 119 API tests passed.
|
||||
The resulting control runner is
|
||||
8859459f54389f12af1ab7d4e36802c01aff63fb10fbb679243ccdd54d013e2d.
|
||||
It also preserves the real rotation baseline. Both subsequent A/B variants
|
||||
include the fix, so corrected calculation semantics are not counted as speedup.
|
||||
|
||||
## Storage Design
|
||||
|
||||
SymbolPriceSeries previously allocated separate vectors for last/bid/ask,
|
||||
their prefix, timestamps, trading phases and three quote-volume fields, even
|
||||
when actual data repeated or exactly matched the existing close series.
|
||||
|
||||
- ReferenceMatchedValues aliases the existing column only after every consumed
|
||||
value matches by f64::to_bits. A mismatch materializes the exact preceding
|
||||
values and continues as an owned vector. No missing/invalid price is replaced
|
||||
by close; signed zero and NaN payload differences prevent sharing.
|
||||
- RepeatedValues retains the actual first value and logical length. It avoids
|
||||
expanding equal values, including nonzero volumes and Some strings. The
|
||||
first difference materializes the exact prior values. None is distinct from
|
||||
an empty string; no value is inferred from the backtest frequency.
|
||||
- Intraday updates materialize only changed columns. Cloned views retain their
|
||||
original values and immutable daily base. Last-price prefix sums use the same
|
||||
accumulation order and actual values as before. History cutoffs are unchanged.
|
||||
|
||||
There is no new dependency, unsafe code, strategy-specific branch, disk schema,
|
||||
source-data rewrite or account/result sharing. Construction and data validation
|
||||
remain in the existing paths. The overlay comment now accurately states that
|
||||
quote fields affect Last history while daily OHLC remains unchanged.
|
||||
|
||||
The full candidate passes 813 core unit/integration tests (9 ignored), 448 runner
|
||||
tests (9 ignored) and 119 API tests (5 ignored). New tests cover exact bit
|
||||
identity, distinct zero/NaN values, repeated nonzero/string values, mutation
|
||||
isolation, unknown dates, full snapshot equality and history-date cutoffs.
|
||||
These tests do not prove the separately known same-day execution-clock issue.
|
||||
|
||||
## Real A/B
|
||||
|
||||
All cases execute 2021-08-23 through 2025-11-17 with their unchanged frozen
|
||||
strategy/runtime/bundle and 10,000,000 initial cash. This is not five complete
|
||||
execution years. Every run is a new process with private result artifacts and
|
||||
the same verified shared inputs: 9,257 files / 12,596,608,049 bytes. No original
|
||||
input changed and no Arrow/bin input was newly created. Hashing is outside the
|
||||
runner timer; no result is reused. Source/OS caches are not cold.
|
||||
|
||||
| Case | Wall s | Data s | DataSet construction s | Engine s | RSS KiB |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| Rotation control 1 | 23.879 | 5.213 | 1.901 | 6.612 | 7,137,676 |
|
||||
| Rotation candidate 1 | 24.126 | 4.818 | 1.589 | 6.674 | 6,463,660 |
|
||||
| Rotation control 2 | 18.180 | 7.272 | 2.824 | 9.509 | 7,138,628 |
|
||||
| Rotation candidate 2 | 15.078 | 5.543 | 2.094 | 8.171 | 6,454,624 |
|
||||
| Rotation candidate 3 | 13.125 | 5.103 | 1.853 | 6.649 | 6,457,728 |
|
||||
| Rotation control 3 | 12.725 | 4.955 | 1.713 | 6.583 | 7,140,772 |
|
||||
| Trend 40 control | 14.779 | 5.418 | 1.921 | 7.867 | 7,157,844 |
|
||||
| Trend 40 candidate | 15.012 | 5.168 | 1.851 | 8.011 | 6,470,768 |
|
||||
| Pullback 40 control | 14.011 | 5.172 | 1.893 | 7.298 | 7,167,408 |
|
||||
| Pullback 40 candidate | 13.877 | 5.117 | 1.837 | 7.219 | 6,478,492 |
|
||||
| Volume 80 control | 18.577 | 5.163 | 1.891 | 10.960 | 7,210,220 |
|
||||
| Volume 80 candidate | 18.476 | 5.051 | 1.844 | 10.989 | 6,527,236 |
|
||||
|
||||
The final rotation pair deliberately ran candidate before control. Rotation
|
||||
RSS medians are 7,138,628 versus 6,457,728 KiB, about 665 MiB / 9.5% lower.
|
||||
Other strategy pairs save about 670-673 MiB. These are measured process peaks,
|
||||
not estimates obtained by adding cgroup limits or counting mmap as private RAM.
|
||||
|
||||
Construction medians are 1.901 versus 1.853 seconds for rotation. The first pair
|
||||
has a larger reduction, but other samples and the reversed pair do not support
|
||||
a universal 16% construction or total-latency claim. Source validation waits and
|
||||
independent phase variation remain in the full evidence. A read-only host sample
|
||||
showed load near 49 and thermal readings 53/58/69 C; it does not prove the cause
|
||||
of timing variation. No host policy or another user's workload was changed.
|
||||
|
||||
All six canonical sections and result-store SHA match the appropriate existing
|
||||
baselines: 21,393 / 29,776 / 31,862 / 51,300 fills. Result and request evidence,
|
||||
physical manifests and all 3,506 fact blocks were verified. No earlier failed
|
||||
or successful receipt was rewritten. Complete receipts remain on 177; only the
|
||||
compact verified summary is stored here to avoid duplicating input inventories.
|
||||
|
||||
## Remaining Work
|
||||
|
||||
Do not publish this as the main performance fix. Next, target the remaining
|
||||
daily snapshot/factor construction and direct typed-column reuse, avoiding
|
||||
new per-access branches or post-hoc compression passes. Cold-query acceptance,
|
||||
real minute-mode acceptance remain outstanding for this storage change. The
|
||||
combined version subsequently passed daily HTTP publication below. The original
|
||||
clock issue was fixed by subsequent business
|
||||
work, not this experiment. Signal lifecycle, financial PIT and UI factor conditions
|
||||
remain outside this completed storage experiment.
|
||||
|
||||
- Engine candidate: 996b909608589fb1987f33c0cfb4c62099f69617.
|
||||
- Service source: 443ed421c2c9c854a01fab69ce58957690504570.
|
||||
- Candidate runner: 40bcf65c1977dbd93ab8bc80e3ff04d0db5e27b61fce1afdce99cf1b5e58eb43.
|
||||
- Candidate API: c54be3a8196c32051520c709f793bcb974d869467bb12700d846efaad8c2180e.
|
||||
- Evidence: /srv/fidc/canonical/run/research/series-column-storage-20260914.
|
||||
|
||||
[Verified summary](evidence/series-column-storage-20260914/acceptance.json).
|
||||
|
||||
## Combined Release
|
||||
|
||||
Engine 665653c / service 501f6d0 passed 860 core, 448 runner and 119 API tests,
|
||||
six additional independent-process replays and nine post-publication HTTP runs.
|
||||
Complete canonical/store results remain equal to each strategy's own baseline.
|
||||
An adjacent original/new rotation pair measures 15.279/14.579 seconds and
|
||||
6,923,640/6,369,900 KiB peak RSS, but the default-window HTTP means are essentially
|
||||
unchanged (13.646/13.740 seconds). Reduced memory and conditional cross-window
|
||||
reuse are not promoted to a universal latency improvement.
|
||||
|
||||
The official Backtest-only publication preserves Source d5, paused research,
|
||||
trading services and execution permissions. Its active build root
|
||||
/srv/fidc/canonical/build/factor-reserve-20260913 must not be overwritten or
|
||||
reused. Shared DataSet acceptance proves input reuse while distinct strategies
|
||||
execute independently; no results are cached. Identities and original receipts:
|
||||
`/Users/boris/WorkSpace/fidc-backtest-service/docs/cache-boundary-planning-performance-20260914.md`.
|
||||
@@ -0,0 +1,35 @@
|
||||
# 换股证据与公司行为批次原子性
|
||||
|
||||
2026-09-14。开发候选,未发布生产;完整目标保持未完成。
|
||||
|
||||
## 已复现的错误
|
||||
|
||||
1. 正常公司行为入口未核对后继证券是否存在于冻结数据:源持仓100股即使缺后继资料,仍可先派息、送转并生成300股后继持仓。手工校正入口此前虽有单独检查,但正常入口没有,两个路径口径不同。
|
||||
2. `successor_ratio_value` 将缺失、零、负数和非有限比例静默替换为1;非法现金字段也可能替换为0,错误输入因此成为看似成功的换股。
|
||||
3. 批次先更新派息/送转、后处理换股现金。当后续现金超出金额合同而失败时,旧证券已经消失,新证券和应收款仍留在账本;目标股数调整及日志也可能部分提交。
|
||||
|
||||
负向测试在修复前实际失败。测试代码初次访问私有日期字段的编译错误先改为正式访问方法;这不是业务反例。用于金额超界的首个有限大数仍在金额合同范围内,已改用确实超界的有限值后复现部分更新,不将未超界样例称为错误。
|
||||
|
||||
## 处理合同
|
||||
|
||||
- 正常和手工权益校正使用同一换股条款/冻结证券资料校验。必须提供明确、有限、正的换股比例;代码不能缺失、含首尾空格或指向自身。孤立的比例/现金字段也拒绝。没有声明现金组成时仍代表没有现金腿,但明确提供的非法数字不得补0。
|
||||
- 缺少源证券或后继证券资料时明确报告代码及公司行为日期,不生成隐式证券,不把价格行当成证券资料,更不把后继代码加入策略候选。
|
||||
- 有经济影响的公司行为在独立账本副本中完成整个批次,成功后一次更新资金、持仓、应收、任务目标单位及说明。失败原状态不变;没有涉及实际持仓的正常日不克隆整个账本。
|
||||
- 送转/换股股数超过事件和持仓的表示范围时明确失败,不能靠浮点转整数的饱和转换或负数delta继续运行。
|
||||
- 保留已声明条款、原取得日期及最近买入日期、成本和原策略配置;不伪造委托/市场成交,不把换股当作重新选股。没有重写已提交订单或猜测新目标权重。
|
||||
|
||||
## 验证及剩余工作
|
||||
|
||||
本机Core907、Trading625、Runner463/API129通过;原9/63/16项ignore分别保留,不计通过。本次未改UI,也没有重复上一轮UI测试。
|
||||
|
||||
新增缺后继资料、13类坏条款、金额失败全批回滚(含原目标单位/权重)、送转/换股数量溢出、有效换股保留两类取得日期等专项。整段引擎覆盖Paper/GT/QMT三种已审计来源、及时/迟到及买/卖12组合:完整资料均保留200股后继持仓且没有新增市场订单/成交;缺资料均明确失败。这是隔离回放,不是券商连接/真实委托验收。
|
||||
|
||||
Source当前公司行为Arrow字段及Runner `CorporateActionRowRecord`仍未提供换股条款,日快照仍显式为无换股字段。这是独立的正式数据能力缺口,不能以本轮核心校验冒充已完成后继证券自动取数。完整范围闭包、目标状态跨公司行为语义、清空后参考、ETF跨模式及实际Source/Runner仍需继续验证。Source运行版本冻结保持,不改旧合同SHA、不注册替代合同或启用交易;后端发布还未准入。
|
||||
|
||||
## Linux配套验收及运行边界
|
||||
|
||||
功能提交 `59a0c95aaec9c14ddd7384bb451d6d90eb3abec7` 已推送并核对远端;177引擎开发树已快进到此提交,Service并行开发树5991e77保持。新只读快照 `/srv/fidc/programs/runtime-snapshots/successor-20260914-Sx15GO` 含981个文件,独立build根同名;4CPU/12GiB、boris执行,1GiB磁盘线保持。源码manifest SHA `a8da0ee559821758d98eb7e4ae27beaa683ce32b708dc29f90bc751808382ff3`。
|
||||
|
||||
Linux Core907、Trading625、Runner/API594共2126项全部通过,原9/63/16项ignore不计;源码前后不变。结束时可用83883585536字节,scope已inactive,不重复启动同批。收据见 `docs/evidence/successor-contract-20260914/{local-tests,linux-tests,runtime}.json`;没有新的release构建或tag,不复用旧reinvestment/AEGDYL/UUx5ru/FewUWP结果。
|
||||
|
||||
23:05 CST逐服务比较,现有UI与五后端PID、后端实际二进制SHA均与上一轮相同,Source d5且tracked clean。没有生产服务重启、任务/账户/通知写入或实际委托/撤单。只同步开发源码不是发布验收;未对当前生产进行新UI或GT测试。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 换股后的策略保护继承
|
||||
|
||||
2026-09-14。开发候选;生产未发布,完整目标仍未完成。
|
||||
|
||||
## 反例与根因
|
||||
|
||||
原代码只给跨公司行为的迟到成交附带后继代码保护;及时回报之后正常换股则没有相同处理。日期锁也只比较当前代码,原持仓换股后会失去锁定。完整引擎反例已复现:及时卖出旧证券后的禁买期内,新证券又买入100股;旧证券仍处日期锁定内,新证券却卖出100股。
|
||||
|
||||
初始15:00样例虽产生了不该生成的委托,但因没有之后的报价而到期,不能把无成交视为保护通过。改用14:30调度和14:30/14:31报价后,实际回放成交证明了上述错误。ETF正向对照最初缺候选资格行,补齐隔离输入后正常成交,未放宽生产数据或风控校验。
|
||||
|
||||
## 统一语义
|
||||
|
||||
- 仅记录已真实影响持仓的、条款与证券身份已经校验的转换关系。目录别名、请求中的代码或尚未发生的公司行为不能使另一证券受锁定;不向候选池或策略目标添加证券。
|
||||
- 连续转换保留已证明的前身关系,保护读取原实际买卖日期;日期锁沿关系生效,但到期日仍为原配置,不从换股日重新计时。买后保护和最长持有继续使用原取得/买入日期。
|
||||
- 换股后手工清仓不删除仍有效的日期锁关系,防止自动重新买回绕过锁定。若迟到回报证明在换股前已经全部卖出,则原本推定的持仓转换关系应被校正掉,不将旧锁误加给新证券。
|
||||
- 关系进入经济账本的重放校验;无换股时原账本摘要形状不变。权益校正整体替换已验证关系,不并入已被新事实推翻的旧关系;原参数、目标权重、未提交目标及已发订单不擅自换成新代码。
|
||||
- 普通表达式策略、股票池普通调仓、ETF顺延开盘消费三个入口均使用同一保护内核。股票池与ETF共用经实际持仓关系解析的保护证据,不只修表面策略层。
|
||||
- 移除按公司行为引用列表直接扩展卖出代码的两个旁路,避免无实际持仓转换也被误认为曾卖出新证券。原股票的真实卖出记录和已发订单仍保留。
|
||||
|
||||
## 验证
|
||||
|
||||
6项新增专项覆盖23个隔离配置场景:及时/迟到回报、买卖两方向、禁买期、日期锁、原日期到期、转换后手工清仓、转换前已清仓,股票池0%/100%目标以及ETF顺延开盘。已知保护有效时验证零委托/零成交及200股原持仓;到期对照必须能够真实回放成交,不能靠缺报价或被其他风险拒绝冒充保护正确。
|
||||
|
||||
本机Core913、Trading625、Runner463/API129全量通过;原9/63/16项ignore不计。没有新增私有数据库、生产页面或券商实测;UI和其他业务仓代码未改。此次修复不改变配置为0/null/空的保护规则,也不是全局共享风控配置。
|
||||
|
||||
## 发布边界与后续
|
||||
|
||||
Source公司行为接口仍缺正式换股条款及所需数据范围闭包,在线重建也仍需要权威转换持仓事实,不能仅凭最终股数或证券名称猜关系。本轮原生回放与Linux测试不是GT/QMT公司行为实盘验收。Source d5冻结保持,不改旧合同、不注册替代合同或恢复交易。
|
||||
|
||||
继续正式Source/Runner联合验收、在线转换事实持久化/重建、旧目标及活动单边界、清空/0%后再投参考及其余ETF跨模式矩阵。需先核对原账务模型与仓位参数的关系,不能为了通过保护样例擅自改变再投定价、费用或策略意图。
|
||||
|
||||
## Linux配套验收
|
||||
|
||||
功能提交 `ba4b77fd746963687b8029b47806f943ed6ce03f` 已推送并核对,177引擎开发树已快进至此,Service并行5991e77保留。新只读快照 `/srv/fidc/programs/runtime-snapshots/conversion-protection-20260914-51wBBB` 含985文件,独立同名build根;4CPU/12GiB、boris运行、1GiB保护线不改。源码manifest SHA `6ae0cb8f0e571b4848e2cba6b39368a19434cc43729cfe07aad6fdce531d2814`。
|
||||
|
||||
Linux Core913、Trading625、Runner/API594共2132项全过,原9/63/16项ignore单列,源码前后不变;结束时可用80519344128字节。scope已inactive,不再重复本批测试或覆盖前阶段收据。证据为 `docs/evidence/successor-protection-20260914/{local-tests,linux-tests,runtime}.json`。
|
||||
|
||||
23:47 CST逐服务比对,现有UI/五后端PID及后端实际SHA与上一轮完全相同,Source d5且tracked clean。没有新release构建、发布tag、生产重启、账户/任务/路由/通知写入或券商委托;开发树同步不等于运行版本发布,完整目标仍需上述联合验收。
|
||||
Reference in New Issue
Block a user