Skip to content
This repository was archived by the owner on Mar 27, 2026. It is now read-only.

Commit 70854ee

Browse files
fix: wait for aligned perp mark bars in paper sessions
1 parent fd2b06b commit 70854ee

9 files changed

Lines changed: 173 additions & 12 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,9 @@ when the session never produced a first snapshot. The bundled
150150
the canonical multi-source strategy and the trigger-happy smoke test run side
151151
by side. The paper daemon keeps the last closed candle armed when an exchange
152152
temporarily returns no fresh bar for the current live append window, then
153-
resumes once the next closed candle appears.
153+
resumes once the next closed candle appears. Perp sessions now also wait for
154+
aligned mark-price candles instead of failing immediately when the venue lags
155+
the execution feed by one runtime window.
154156

155157
The CLI, IDE server, and LSP now also emit structured JSON logs on `stderr`.
156158
Set `PALMSCRIPT_LOG_LEVEL=debug` or `trace` when you need more detail, and set

crates/palmscript/src/execution/engine.rs

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
use crate::backtest::{BacktestConfig, PerpBacktestConfig, PerpBacktestContext, PerpMarginMode};
1+
use crate::backtest::{
2+
BacktestConfig, BacktestError, PerpBacktestConfig, PerpBacktestContext, PerpMarginMode,
3+
};
24
use crate::compiler::compile;
35
use crate::run_backtest_with_sources;
46
use crate::runtime::VmLimits;
@@ -264,8 +266,7 @@ pub(crate) async fn process_paper_session(
264266
perp_context: session.perp_context.clone(),
265267
portfolio_perp_contexts: session.portfolio_perp_contexts.clone(),
266268
},
267-
)
268-
.map_err(|err| ExecutionError::Runtime(err.to_string()));
269+
);
269270

270271
match result {
271272
Ok(result) => {
@@ -323,7 +324,11 @@ pub(crate) async fn process_paper_session(
323324
);
324325
Ok(manifest)
325326
}
327+
Err(BacktestError::MissingPerpMarkFeed { alias }) => {
328+
defer_perp_mark_alignment(session, &mut manifest, runtime_to_ms, &alias)
329+
}
326330
Err(err) => {
331+
let err = ExecutionError::Runtime(err.to_string());
327332
manifest.status = ExecutionSessionStatus::Failed;
328333
manifest.health = ExecutionSessionHealth::Failed;
329334
manifest.updated_at_ms = now_ms;
@@ -361,6 +366,36 @@ pub(crate) async fn process_paper_session(
361366
}
362367
}
363368

369+
fn defer_perp_mark_alignment(
370+
session: &mut LoadedPaperSession,
371+
manifest: &mut PaperSessionManifest,
372+
runtime_to_ms: i64,
373+
alias: &str,
374+
) -> Result<PaperSessionManifest, ExecutionError> {
375+
session.perp_context_to_ms = session
376+
.perp_context_to_ms
377+
.min(runtime_to_ms.saturating_sub(1));
378+
let message =
379+
format!("paper session waiting for aligned perp mark bars for execution `{alias}`");
380+
update_manifest_status(
381+
manifest,
382+
ExecutionSessionStatus::ArmingLive,
383+
ExecutionSessionHealth::WarmingUp,
384+
None,
385+
&message,
386+
)?;
387+
warn_fields(
388+
"paper.session.waiting_perp_mark_bars",
389+
"Paper session waiting for aligned perp mark bars",
390+
vec![
391+
LogField::string("session_id", manifest.session_id.clone()),
392+
LogField::string("execution_alias", alias.to_string()),
393+
LogField::i64("runtime_to_ms", runtime_to_ms),
394+
],
395+
);
396+
Ok(manifest.clone())
397+
}
398+
364399
fn infer_health(feed_snapshots: &[super::PaperFeedSnapshot]) -> ExecutionSessionHealth {
365400
if feed_snapshots.is_empty() {
366401
return ExecutionSessionHealth::Live;
@@ -445,7 +480,7 @@ fn update_manifest_status(
445480

446481
#[cfg(test)]
447482
mod tests {
448-
use super::LoadedPaperSession;
483+
use super::{defer_perp_mark_alignment, LoadedPaperSession};
449484
use crate::backtest::{DiagnosticsDetailMode, PerpMarginMode};
450485
use crate::compile;
451486
use crate::exchange::ExchangeEndpoints;
@@ -458,6 +493,8 @@ mod tests {
458493
use crate::runtime::VmLimits;
459494
use mockito::{Matcher, Server};
460495
use serde_json::json;
496+
use std::collections::BTreeMap;
497+
use std::path::PathBuf;
461498

462499
fn sample_manifest(endpoints: ExchangeEndpoints) -> PaperSessionManifest {
463500
PaperSessionManifest {
@@ -611,4 +648,50 @@ plot(perp.close)";
611648
assert_eq!(refreshed.mark_bars[2].time as i64, 1_704_074_400_000);
612649
assert_eq!(session.perp_context_to_ms, 1_704_078_000_000);
613650
}
651+
652+
#[test]
653+
fn defer_perp_mark_alignment_keeps_session_warming_and_retries_window() {
654+
let state_dir = tempfile::tempdir().expect("tempdir");
655+
std::env::set_var("PALMSCRIPT_EXECUTION_STATE_DIR", state_dir.path());
656+
657+
let compiled = compile(
658+
"interval 1m
659+
source perp = binance.usdm(\"BTCUSDT\")
660+
execution exec = binance.usdm(\"BTCUSDT\")
661+
plot(perp.close)",
662+
)
663+
.expect("compile");
664+
let manifest = sample_manifest(ExchangeEndpoints::default());
665+
let feed_plan = build_session_feed_plan(
666+
&compiled,
667+
&manifest.config.execution_source_aliases,
668+
manifest.start_time_ms,
669+
&manifest.endpoints,
670+
)
671+
.expect("feed plan");
672+
let mut session = LoadedPaperSession {
673+
compiled,
674+
feed_plan,
675+
perp: None,
676+
perp_context: None,
677+
portfolio_perp_contexts: BTreeMap::new(),
678+
perp_context_to_ms: 1_704_067_500_000,
679+
};
680+
let mut manifest = PaperSessionManifest {
681+
session_id: "paper-wait".to_string(),
682+
script_path: Some(PathBuf::from("strategy.ps").display().to_string()),
683+
..manifest
684+
};
685+
686+
let deferred =
687+
defer_perp_mark_alignment(&mut session, &mut manifest, 1_704_067_500_000, "exec")
688+
.expect("defer should succeed");
689+
690+
assert_eq!(deferred.status, ExecutionSessionStatus::ArmingLive);
691+
assert_eq!(deferred.health, ExecutionSessionHealth::WarmingUp);
692+
assert_eq!(deferred.failure_message, None);
693+
assert_eq!(session.perp_context_to_ms, 1_704_067_499_999);
694+
695+
std::env::remove_var("PALMSCRIPT_EXECUTION_STATE_DIR");
696+
}
614697
}

crates/palmscript/tests/execution.rs

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ use mockito::{Matcher, Server};
55
use palmscript::{
66
load_paper_session_export, load_paper_session_logs, serve_execution_daemon, stop_paper_session,
77
submit_paper_session, DiagnosticsDetailMode, ExchangeEndpoints, ExecutionDaemonConfig,
8-
ExecutionSessionStatus, PaperSessionConfig, SubmitPaperSession, VmLimits,
8+
ExecutionSessionHealth, ExecutionSessionStatus, PaperSessionConfig, SubmitPaperSession,
9+
VmLimits,
910
};
1011

1112
static ENV_LOCK: Mutex<()> = Mutex::new(());
@@ -413,6 +414,81 @@ fn paper_daemon_processes_a_perp_session_without_async_blocking_panics() {
413414
std::env::remove_var("PALMSCRIPT_BINANCE_USDM_BASE_URL");
414415
}
415416

417+
#[test]
418+
fn paper_daemon_waits_for_aligned_perp_mark_bars_instead_of_failing_session() {
419+
let _guard = ENV_LOCK.lock().expect("lock env");
420+
let state_dir = tempfile::tempdir().expect("tempdir");
421+
let mut server = Server::new();
422+
mock_binance_usdm_interval(
423+
&mut server,
424+
"1m",
425+
&[
426+
serde_json::json!([1704067200000_i64, "10", "10", "10", "10", "1000"]),
427+
serde_json::json!([1704067260000_i64, "11", "11", "11", "11", "1000"]),
428+
serde_json::json!([1704067320000_i64, "12", "12", "12", "12", "1000"]),
429+
serde_json::json!([1704067380000_i64, "13", "13", "13", "13", "1000"]),
430+
],
431+
);
432+
mock_binance_usdm_mark_interval(
433+
&mut server,
434+
"1m",
435+
&[
436+
serde_json::json!([1704067200000_i64, "10", "10", "10", "10", "0"]),
437+
serde_json::json!([1704067260000_i64, "11", "11", "11", "11", "0"]),
438+
serde_json::json!([1704067320000_i64, "12", "12", "12", "12", "0"]),
439+
],
440+
);
441+
mock_binance_usdm_exchange_info(&mut server);
442+
mock_binance_usdm_book_ticker(&mut server);
443+
mock_binance_usdm_last_price(&mut server);
444+
mock_binance_usdm_premium_index(&mut server);
445+
446+
std::env::set_var("PALMSCRIPT_EXECUTION_STATE_DIR", state_dir.path());
447+
std::env::set_var("PALMSCRIPT_BINANCE_USDM_BASE_URL", server.url());
448+
449+
let manifest = submit_paper_session(SubmitPaperSession {
450+
source: perp_source().to_string(),
451+
script_path: Some(PathBuf::from("perp_strategy.ps")),
452+
config: PaperSessionConfig {
453+
execution_source_aliases: vec!["exec".to_string()],
454+
initial_capital: 1_000.0,
455+
maker_fee_bps: 0.0,
456+
taker_fee_bps: 0.0,
457+
execution_fee_schedules: std::collections::BTreeMap::new(),
458+
slippage_bps: 0.0,
459+
diagnostics_detail: DiagnosticsDetailMode::SummaryOnly,
460+
leverage: Some(5.0),
461+
margin_mode: Some(palmscript::PerpMarginMode::Isolated),
462+
vm_limits: VmLimits::default(),
463+
},
464+
start_time_ms: 1704067320000_i64,
465+
endpoints: ExchangeEndpoints::from_env(),
466+
})
467+
.expect("perp paper session submission should succeed");
468+
469+
let status = serve_execution_daemon(ExecutionDaemonConfig {
470+
poll_interval_ms: 1,
471+
once: true,
472+
})
473+
.expect("daemon should wait for aligned perp mark bars");
474+
assert!(!status.running);
475+
476+
let export = load_paper_session_export(&manifest.session_id).expect("perp paper export");
477+
let logs = load_paper_session_logs(&manifest.session_id).expect("paper logs should load");
478+
assert_eq!(export.manifest.status, ExecutionSessionStatus::ArmingLive);
479+
assert_eq!(export.manifest.health, ExecutionSessionHealth::WarmingUp);
480+
assert_eq!(export.manifest.failure_message, None);
481+
assert!(export.latest_result.is_none());
482+
assert!(
483+
logs.iter()
484+
.any(|event| event.message.contains("waiting for aligned perp mark bars")),
485+
"{logs:#?}"
486+
);
487+
488+
std::env::remove_var("PALMSCRIPT_EXECUTION_STATE_DIR");
489+
std::env::remove_var("PALMSCRIPT_BINANCE_USDM_BASE_URL");
490+
}
491+
416492
#[test]
417493
fn paper_daemon_keeps_binance_usdm_session_live_when_funding_feed_is_empty() {
418494
let _guard = ENV_LOCK.lock().expect("lock env");

web/docs/docs/tooling/cli.de.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ The v1 execution layer is intentionally conservative:
162162
- one persistent local ledger per paper session
163163
- the same strategy semantics, portfolio caps, cooldowns, and max-bars exits as backtest mode
164164

165-
When you submit a paper session, PalmScript snapshots the script and queues a persistent session locally. `execution serve` warms the VM with compiler-derived pre-session history, keeps one shared armed feed cache for the active paper sessions, and updates the strategy only when a new execution candle closes. Sessions remain in explicit `arming_history` and `arming_live` states until the required feed inventory is ready. If an exchange temporarily returns no fresh bar for the current live append window, the daemon keeps the last closed candle armed and resumes on the next closed candle instead of failing the session.
165+
When you submit a paper session, PalmScript snapshots the script and queues a persistent session locally. `execution serve` warms the VM with compiler-derived pre-session history, keeps one shared armed feed cache for the active paper sessions, and updates the strategy only when a new execution candle closes. Sessions remain in explicit `arming_history` and `arming_live` states until the required feed inventory is ready. If an exchange temporarily returns no fresh bar for the current live append window, the daemon keeps the last closed candle armed and resumes on the next closed candle instead of failing the session. Perp sessions also stay in `arming_live` and retry when mark-price candles have not caught up to the execution window yet.
166166

167167
The shared quote layer currently provides, per execution alias:
168168

web/docs/docs/tooling/cli.es.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ The v1 execution layer is intentionally conservative:
162162
- one persistent local ledger per paper session
163163
- the same strategy semantics, portfolio caps, cooldowns, and max-bars exits as backtest mode
164164

165-
When you submit a paper session, PalmScript snapshots the script and queues a persistent session locally. `execution serve` warms the VM with compiler-derived pre-session history, keeps one shared armed feed cache for the active paper sessions, and updates the strategy only when a new execution candle closes. Sessions remain in explicit `arming_history` and `arming_live` states until the required feed inventory is ready. If an exchange temporarily returns no fresh bar for the current live append window, the daemon keeps the last closed candle armed and resumes on the next closed candle instead of failing the session.
165+
When you submit a paper session, PalmScript snapshots the script and queues a persistent session locally. `execution serve` warms the VM with compiler-derived pre-session history, keeps one shared armed feed cache for the active paper sessions, and updates the strategy only when a new execution candle closes. Sessions remain in explicit `arming_history` and `arming_live` states until the required feed inventory is ready. If an exchange temporarily returns no fresh bar for the current live append window, the daemon keeps the last closed candle armed and resumes on the next closed candle instead of failing the session. Perp sessions also stay in `arming_live` and retry when mark-price candles have not caught up to the execution window yet.
166166

167167
The shared quote layer currently provides, per execution alias:
168168

web/docs/docs/tooling/cli.fr.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ The v1 execution layer is intentionally conservative:
162162
- one persistent local ledger per paper session
163163
- the same strategy semantics, portfolio caps, cooldowns, and max-bars exits as backtest mode
164164

165-
When you submit a paper session, PalmScript snapshots the script and queues a persistent session locally. `execution serve` warms the VM with compiler-derived pre-session history, keeps one shared armed feed cache for the active paper sessions, and updates the strategy only when a new execution candle closes. Sessions remain in explicit `arming_history` and `arming_live` states until the required feed inventory is ready. If an exchange temporarily returns no fresh bar for the current live append window, the daemon keeps the last closed candle armed and resumes on the next closed candle instead of failing the session.
165+
When you submit a paper session, PalmScript snapshots the script and queues a persistent session locally. `execution serve` warms the VM with compiler-derived pre-session history, keeps one shared armed feed cache for the active paper sessions, and updates the strategy only when a new execution candle closes. Sessions remain in explicit `arming_history` and `arming_live` states until the required feed inventory is ready. If an exchange temporarily returns no fresh bar for the current live append window, the daemon keeps the last closed candle armed and resumes on the next closed candle instead of failing the session. Perp sessions also stay in `arming_live` and retry when mark-price candles have not caught up to the execution window yet.
166166

167167
The shared quote layer currently provides, per execution alias:
168168

web/docs/docs/tooling/cli.ja.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ The v1 execution layer is intentionally conservative:
162162
- one persistent local ledger per paper session
163163
- the same strategy semantics, portfolio caps, cooldowns, and max-bars exits as backtest mode
164164

165-
When you submit a paper session, PalmScript snapshots the script and queues a persistent session locally. `execution serve` warms the VM with compiler-derived pre-session history, keeps one shared armed feed cache for the active paper sessions, and updates the strategy only when a new execution candle closes. Sessions remain in explicit `arming_history` and `arming_live` states until the required feed inventory is ready. If an exchange temporarily returns no fresh bar for the current live append window, the daemon keeps the last closed candle armed and resumes on the next closed candle instead of failing the session.
165+
When you submit a paper session, PalmScript snapshots the script and queues a persistent session locally. `execution serve` warms the VM with compiler-derived pre-session history, keeps one shared armed feed cache for the active paper sessions, and updates the strategy only when a new execution candle closes. Sessions remain in explicit `arming_history` and `arming_live` states until the required feed inventory is ready. If an exchange temporarily returns no fresh bar for the current live append window, the daemon keeps the last closed candle armed and resumes on the next closed candle instead of failing the session. Perp sessions also stay in `arming_live` and retry when mark-price candles have not caught up to the execution window yet.
166166

167167
The shared quote layer currently provides, per execution alias:
168168

web/docs/docs/tooling/cli.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ The v1 execution layer is intentionally conservative:
156156
- one persistent local ledger per paper session
157157
- the same strategy semantics, portfolio caps, cooldowns, and max-bars exits as backtest mode
158158

159-
When you submit a paper session, PalmScript snapshots the script and queues a persistent session locally. `execution serve` warms the VM with compiler-derived pre-session history, keeps one shared armed feed cache for the active paper sessions, and updates the strategy only when a new execution candle closes. Sessions remain in explicit `arming_history` and `arming_live` states until the required feed inventory is ready. If an exchange temporarily returns no fresh bar for the current live append window, the daemon keeps the last closed candle armed and resumes on the next closed candle instead of failing the session.
159+
When you submit a paper session, PalmScript snapshots the script and queues a persistent session locally. `execution serve` warms the VM with compiler-derived pre-session history, keeps one shared armed feed cache for the active paper sessions, and updates the strategy only when a new execution candle closes. Sessions remain in explicit `arming_history` and `arming_live` states until the required feed inventory is ready. If an exchange temporarily returns no fresh bar for the current live append window, the daemon keeps the last closed candle armed and resumes on the next closed candle instead of failing the session. Perp sessions also stay in `arming_live` and retry when mark-price candles have not caught up to the execution window yet.
160160

161161
The shared quote layer currently provides, per execution alias:
162162

web/docs/docs/tooling/cli.pt-BR.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ The v1 execution layer is intentionally conservative:
162162
- one persistent local ledger per paper session
163163
- the same strategy semantics, portfolio caps, cooldowns, and max-bars exits as backtest mode
164164

165-
When you submit a paper session, PalmScript snapshots the script and queues a persistent session locally. `execution serve` warms the VM with compiler-derived pre-session history, keeps one shared armed feed cache for the active paper sessions, and updates the strategy only when a new execution candle closes. Sessions remain in explicit `arming_history` and `arming_live` states until the required feed inventory is ready. If an exchange temporarily returns no fresh bar for the current live append window, the daemon keeps the last closed candle armed and resumes on the next closed candle instead of failing the session.
165+
When you submit a paper session, PalmScript snapshots the script and queues a persistent session locally. `execution serve` warms the VM with compiler-derived pre-session history, keeps one shared armed feed cache for the active paper sessions, and updates the strategy only when a new execution candle closes. Sessions remain in explicit `arming_history` and `arming_live` states until the required feed inventory is ready. If an exchange temporarily returns no fresh bar for the current live append window, the daemon keeps the last closed candle armed and resumes on the next closed candle instead of failing the session. Perp sessions also stay in `arming_live` and retry when mark-price candles have not caught up to the execution window yet.
166166

167167
The shared quote layer currently provides, per execution alias:
168168

0 commit comments

Comments
 (0)