修复开盘与跨日ETF执行时钟及资金阻断原因
This commit is contained in:
@@ -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 {
|
||||
@@ -8025,22 +8033,31 @@ 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(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(|| {
|
||||
algo_request
|
||||
.and_then(|request| request.end_time)
|
||||
.or(runtime_end_time)
|
||||
.map(|end_time| date.and_time(end_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)),
|
||||
(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)
|
||||
.map(|end_time| date.and_time(end_time))
|
||||
});
|
||||
let end_cursor = if end_cursor.is_none()
|
||||
&& matching_type == MatchingType::CurrentBarClose
|
||||
&& self.matching_type_uses_intraday_quotes()
|
||||
@@ -8253,6 +8270,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;
|
||||
@@ -8393,6 +8411,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;
|
||||
}
|
||||
@@ -8411,6 +8430,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,
|
||||
@@ -8436,6 +8456,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,
|
||||
@@ -8516,6 +8537,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);
|
||||
}
|
||||
|
||||
@@ -8717,6 +8748,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,
|
||||
|
||||
+1199
-349
File diff suppressed because it is too large
Load Diff
@@ -1178,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();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 手工观察主时钟接入候选
|
||||
|
||||
最新显式开盘/跨日 ETF 修复与验收见 [opening-and-deferred-clock-20260914.md](opening-and-deferred-clock-20260914.md)。下面保留早期阶段证据;原“开盘晚于配置窗口一律拒绝”已改为共享时钟与真实收盘边界,不再依赖订阅是否启用。
|
||||
|
||||
2026-09-14,未发布,完整Goal不关闭。不是生产手工影子回放验收。
|
||||
|
||||
## 本阶段已实现
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# 显式开盘、跨日 ETF 与委托时钟修复
|
||||
|
||||
2026-09-14。本机候选已验证,尚未发布;完整股票池工作不以本阶段关闭。
|
||||
|
||||
## 已复现的错误
|
||||
|
||||
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 股。
|
||||
- 定位过程的失败、类型修正和资金不足断言修正不计通过;没有改动原池或补造行情。
|
||||
|
||||
## 发布与剩余工作
|
||||
|
||||
本修复不在已构建的 `manual-stream-20260914-FewUWP` 二进制中。该目录及既有 Linux 收据继续保留,不能覆盖或改写成包含本修复。新提交还须独立 Linux 和真实 Source/Runner 联合验收。
|
||||
|
||||
Source `d5` 版本冻结、研究/信号暂停、Live disabled 与旧任务/历史不变。Source 清单权威修复和新验证合同仍待明确解冻授权;本阶段没有发单、撤单或生产重启。后续继续公司行为、跨日保护/禁买及完整参数/适配器矩阵,不把以上确定性例子外推为全量生产完成。
|
||||
Reference in New Issue
Block a user