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

Commit b721e84

Browse files
feat: add utc time and session helpers
1 parent 0d235d9 commit b721e84

15 files changed

Lines changed: 584 additions & 14 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ The language keeps market-data inputs and execution venues explicit:
3333
- `source` declares market data feeds
3434
- `execution` declares order-routing venues
3535
- `order ...` declares how entries and exits are placed
36+
- `hour_utc(<alias>.time)`, `weekday_utc(<alias>.time)`, and `session_utc(<alias>.time, start_hour, end_hour)` provide deterministic UTC time/session gating without hand-rolled timestamp math
3637

3738
Documentation and tooling:
3839

crates/palmscript/examples/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ For runnable public examples and workflow guidance, use the linked docs pages ab
6060

6161
When you inspect these strategies from the CLI, `run backtest`, `run walk-forward`, and `run optimize` now support `--diagnostics summary|full-trace`. Use `summary` for the normal compact diagnostics payload and `full-trace` when you want one typed per-bar decision trace record per execution bar.
6262

63+
Use `hour_utc(<alias>.time)`, `weekday_utc(<alias>.time)`, and
64+
`session_utc(<alias>.time, start_hour, end_hour)` when you want deterministic
65+
UTC session filters without manual millisecond arithmetic. `session_utc` treats
66+
its window as half-open `[start_hour, end_hour)` and supports overnight wraps
67+
such as `session_utc(spot.time, 22, 2)`.
68+
6369
Execution-oriented commands now require at least one declared `execution`
6470
target in the script. The checked-in backtest and paper examples already
6571
declare those execution aliases explicitly. If a script declares exactly one

crates/palmscript/src/backtest/diagnostics/analysis.rs

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use crate::backtest::{
1111
TradeExitClassification, TransferDiagnosticsSummary, TransferRouteDiagnosticSummary,
1212
WeekdayDiagnosticSummary,
1313
};
14+
use crate::interval::{hour_utc, weekday_utc};
1415
use crate::position::PositionSide;
1516

1617
const TIME_BUCKET_HOURS_UTC: u8 = 4;
@@ -821,12 +822,3 @@ fn holding_time_bucket(bars_held: usize) -> HoldingTimeBucket {
821822
_ => HoldingTimeBucket::Bars32Plus,
822823
}
823824
}
824-
825-
fn weekday_utc(time_ms: i64) -> u8 {
826-
let days = time_ms.div_euclid(86_400_000);
827-
((days + 3).rem_euclid(7)) as u8
828-
}
829-
830-
fn hour_utc(time_ms: i64) -> u8 {
831-
time_ms.rem_euclid(86_400_000).div_euclid(3_600_000) as u8
832-
}

crates/palmscript/src/builtins.rs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ pub enum BuiltinKind {
8484
ParabolicSarExt,
8585
RollingHighLowCloseBands,
8686
AdaptiveCycleTuple,
87+
TimeTransform,
88+
TimeSession,
8789
VenueAliasSelector,
8890
VenueRank,
8991
VenueSpread,
@@ -238,10 +240,13 @@ pub enum BuiltinId {
238240
SpreadBps = 143,
239241
RankAsc = 144,
240242
RankDesc = 145,
243+
HourUtc = 146,
244+
WeekdayUtc = 147,
245+
SessionUtc = 148,
241246
}
242247

243248
impl BuiltinId {
244-
pub const RESERVED: [Self; 146] = [
249+
pub const RESERVED: [Self; 149] = [
245250
Self::Open,
246251
Self::High,
247252
Self::Low,
@@ -388,9 +393,12 @@ impl BuiltinId {
388393
Self::SpreadBps,
389394
Self::RankAsc,
390395
Self::RankDesc,
396+
Self::HourUtc,
397+
Self::WeekdayUtc,
398+
Self::SessionUtc,
391399
];
392400

393-
pub const CALLABLE: [Self; 133] = [
401+
pub const CALLABLE: [Self; 136] = [
394402
Self::Sma,
395403
Self::Ema,
396404
Self::Rsi,
@@ -524,6 +532,9 @@ impl BuiltinId {
524532
Self::SpreadBps,
525533
Self::RankAsc,
526534
Self::RankDesc,
535+
Self::HourUtc,
536+
Self::WeekdayUtc,
537+
Self::SessionUtc,
527538
];
528539

529540
pub fn from_name(name: &str) -> Option<Self> {
@@ -674,6 +685,9 @@ impl BuiltinId {
674685
"spread_bps" => Some(Self::SpreadBps),
675686
"rank_asc" => Some(Self::RankAsc),
676687
"rank_desc" => Some(Self::RankDesc),
688+
"hour_utc" => Some(Self::HourUtc),
689+
"weekday_utc" => Some(Self::WeekdayUtc),
690+
"session_utc" => Some(Self::SessionUtc),
677691
_ => None,
678692
}
679693
}
@@ -826,6 +840,9 @@ impl BuiltinId {
826840
143 => Some(Self::SpreadBps),
827841
144 => Some(Self::RankAsc),
828842
145 => Some(Self::RankDesc),
843+
146 => Some(Self::HourUtc),
844+
147 => Some(Self::WeekdayUtc),
845+
148 => Some(Self::SessionUtc),
829846
_ => None,
830847
}
831848
}
@@ -978,6 +995,9 @@ impl BuiltinId {
978995
Self::SpreadBps => "spread_bps",
979996
Self::RankAsc => "rank_asc",
980997
Self::RankDesc => "rank_desc",
998+
Self::HourUtc => "hour_utc",
999+
Self::WeekdayUtc => "weekday_utc",
1000+
Self::SessionUtc => "session_utc",
9811001
}
9821002
}
9831003

@@ -1090,6 +1110,8 @@ impl BuiltinId {
10901110
}
10911111
Self::HtPhasor | Self::HtSine => BuiltinKind::RollingSingleInputTuple,
10921112
Self::Mama => BuiltinKind::AdaptiveCycleTuple,
1113+
Self::HourUtc | Self::WeekdayUtc => BuiltinKind::TimeTransform,
1114+
Self::SessionUtc => BuiltinKind::TimeSession,
10931115
Self::Cheapest | Self::Richest => BuiltinKind::VenueAliasSelector,
10941116
Self::RankAsc | Self::RankDesc => BuiltinKind::VenueRank,
10951117
Self::SpreadBps => BuiltinKind::VenueSpread,
@@ -1237,6 +1259,8 @@ impl BuiltinId {
12371259
Self::Cheapest | Self::Richest => BuiltinArity::AtLeast(2),
12381260
Self::RankAsc | Self::RankDesc => BuiltinArity::AtLeast(3),
12391261
Self::SpreadBps => BuiltinArity::Exact(2),
1262+
Self::HourUtc | Self::WeekdayUtc => BuiltinArity::Exact(1),
1263+
Self::SessionUtc => BuiltinArity::Exact(3),
12401264
}
12411265
}
12421266

@@ -1333,6 +1357,9 @@ impl BuiltinId {
13331357
Self::SpreadBps => "spread_bps(buy_exec, sell_exec)",
13341358
Self::RankAsc => "rank_asc(target_exec, exec0, exec1[, execN...])",
13351359
Self::RankDesc => "rank_desc(target_exec, exec0, exec1[, execN...])",
1360+
Self::HourUtc => "hour_utc(time_value)",
1361+
Self::WeekdayUtc => "weekday_utc(time_value)",
1362+
Self::SessionUtc => "session_utc(time_value, start_hour, end_hour)",
13361363
Self::Ma => "ma(series, length, ma_type)",
13371364
Self::Apo => "apo(series[, fast_length=12[, slow_length=26[, ma_type=ma_type.sma]]])",
13381365
Self::Ppo => "ppo(series[, fast_length=12[, slow_length=26[, ma_type=ma_type.sma]]])",
@@ -1545,6 +1572,9 @@ impl BuiltinId {
15451572
Self::SpreadBps => "Return the current cross-venue spread in basis points from buy execution close to sell execution close.",
15461573
Self::RankAsc => "Return the 1-based ascending price rank of a declared execution alias within the provided venue set.",
15471574
Self::RankDesc => "Return the 1-based descending price rank of a declared execution alias within the provided venue set.",
1575+
Self::HourUtc => "Return the UTC hour-of-day (0..23) for the provided timestamp input.",
1576+
Self::WeekdayUtc => "Return the UTC weekday (Monday=0 .. Sunday=6) for the provided timestamp input.",
1577+
Self::SessionUtc => "True when the provided UTC timestamp falls inside the half-open session window [start_hour, end_hour), with overnight wrap support.",
15481578
}
15491579
}
15501580
}

crates/palmscript/src/compiler.rs

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5729,6 +5729,63 @@ fn analyze_helper_builtin(
57295729
.fold(0, |mask, info| mask | info.update_mask),
57305730
}
57315731
}
5732+
BuiltinKind::TimeTransform => {
5733+
let time_info = arg_info[0];
5734+
if !time_info.ty.is_numeric_like() {
5735+
diagnostics.push(Diagnostic::new(
5736+
DiagnosticKind::Type,
5737+
format!("{callee} requires a numeric or series numeric timestamp input"),
5738+
args[0].span,
5739+
));
5740+
}
5741+
numeric_result(arg_info)
5742+
}
5743+
BuiltinKind::TimeSession => {
5744+
if !arg_info[0].ty.is_numeric_like() {
5745+
diagnostics.push(Diagnostic::new(
5746+
DiagnosticKind::Type,
5747+
format!(
5748+
"{callee} requires a numeric or series numeric timestamp input as the first argument"
5749+
),
5750+
args[0].span,
5751+
));
5752+
}
5753+
for (index, (arg, info)) in args.iter().zip(arg_info.iter()).enumerate().skip(1) {
5754+
if !matches!(
5755+
info.ty,
5756+
InferredType::Concrete(Type::F64) | InferredType::Na
5757+
) {
5758+
diagnostics.push(Diagnostic::new(
5759+
DiagnosticKind::Type,
5760+
if index == 1 {
5761+
format!(
5762+
"{callee} requires a numeric UTC start hour as the second argument"
5763+
)
5764+
} else {
5765+
format!(
5766+
"{callee} requires a numeric UTC end hour as the third argument"
5767+
)
5768+
},
5769+
arg.span,
5770+
));
5771+
}
5772+
}
5773+
validate_session_hour_literal(
5774+
callee,
5775+
"start hour",
5776+
&args[1],
5777+
immutable_values,
5778+
diagnostics,
5779+
);
5780+
validate_session_hour_literal(
5781+
callee,
5782+
"end hour",
5783+
&args[2],
5784+
immutable_values,
5785+
diagnostics,
5786+
);
5787+
bool_result(arg_info)
5788+
}
57325789
BuiltinKind::Plot | BuiltinKind::MarketSeries => unreachable!(),
57335790
}
57345791
}
@@ -5818,6 +5875,8 @@ fn fallback_expr_info_for_builtin(builtin: BuiltinId, arg_info: &[ExprInfo]) ->
58185875
| BuiltinKind::VolumeIndicator
58195876
| BuiltinKind::AnchoredPriceVolume
58205877
| BuiltinKind::VolatilityIndicator => ExprInfo::series(0),
5878+
BuiltinKind::TimeTransform => numeric_result(arg_info),
5879+
BuiltinKind::TimeSession => bool_result(arg_info),
58215880
BuiltinKind::VenueAliasSelector => ExprInfo::scalar(Type::ExecutionAlias),
58225881
BuiltinKind::VenueRank => ExprInfo::scalar(Type::F64),
58235882
BuiltinKind::VenueSpread => ExprInfo::scalar(Type::F64),
@@ -6351,6 +6410,25 @@ fn validate_non_negative_literal(
63516410
}
63526411
}
63536412

6413+
fn validate_session_hour_literal(
6414+
callee: &str,
6415+
noun: &str,
6416+
expr: &Expr,
6417+
immutable_values: &HashMap<String, Value>,
6418+
diagnostics: &mut Vec<Diagnostic>,
6419+
) {
6420+
let Some(value) = literal_time_component(expr, immutable_values) else {
6421+
return;
6422+
};
6423+
if !(0.0..=24.0).contains(&value) || value.fract() != 0.0 {
6424+
diagnostics.push(Diagnostic::new(
6425+
DiagnosticKind::Type,
6426+
format!("{callee} {noun} must be an integer literal in the range 0..24"),
6427+
expr.span,
6428+
));
6429+
}
6430+
}
6431+
63546432
fn expected_arity_message(callee: &str, arity: BuiltinArity) -> String {
63556433
match arity {
63566434
BuiltinArity::NonCallable => format!("{callee} is not callable"),
@@ -6402,6 +6480,17 @@ fn literal_window(expr: &Expr, immutable_values: &HashMap<String, Value>) -> Opt
64026480
}
64036481
}
64046482

6483+
fn literal_time_component(expr: &Expr, immutable_values: &HashMap<String, Value>) -> Option<f64> {
6484+
match &expr.kind {
6485+
ExprKind::Number(value) => Some(*value),
6486+
ExprKind::Ident(name) => match immutable_values.get(name) {
6487+
Some(Value::F64(value)) => Some(*value),
6488+
_ => None,
6489+
},
6490+
_ => None,
6491+
}
6492+
}
6493+
64056494
fn literal_ma_type(expr: &Expr, immutable_values: &HashMap<String, Value>) -> Option<MaType> {
64066495
match &expr.kind {
64076496
ExprKind::Ident(name) => match immutable_values.get(name) {
@@ -7550,7 +7639,10 @@ impl<'a> Compiler<'a> {
75507639
| BuiltinId::HtSine
75517640
| BuiltinId::HtTrendline
75527641
| BuiltinId::HtTrendmode
7553-
| BuiltinId::Mama => {
7642+
| BuiltinId::Mama
7643+
| BuiltinId::HourUtc
7644+
| BuiltinId::WeekdayUtc
7645+
| BuiltinId::SessionUtc => {
75547646
self.emit_runtime_builtin_call(
75557647
builtin, expr, args, expr_info, user_calls, callsite,
75567648
);
@@ -8778,7 +8870,11 @@ impl<'a> Compiler<'a> {
87788870
.with_span(expr.span),
87798871
);
87808872
}
8781-
BuiltinKind::VenueAliasSelector | BuiltinKind::VenueRank | BuiltinKind::VenueSpread => {
8873+
BuiltinKind::VenueAliasSelector
8874+
| BuiltinKind::VenueRank
8875+
| BuiltinKind::VenueSpread
8876+
| BuiltinKind::TimeTransform
8877+
| BuiltinKind::TimeSession => {
87828878
for arg in args {
87838879
self.emit_expr(arg, expr_info, user_calls);
87848880
}

crates/palmscript/src/interval.rs

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,28 @@ fn weekday_monday_based(days_since_epoch: i64) -> i64 {
492492
(days_since_epoch + 3).rem_euclid(7)
493493
}
494494

495+
pub(crate) fn weekday_utc(time_ms: i64) -> u8 {
496+
let (days_since_epoch, _) = split_days(time_ms);
497+
weekday_monday_based(days_since_epoch) as u8
498+
}
499+
500+
pub(crate) fn hour_utc(time_ms: i64) -> u8 {
501+
let (_, remainder) = split_days(time_ms);
502+
remainder.div_euclid(3_600_000) as u8
503+
}
504+
505+
pub(crate) fn session_utc(time_ms: i64, start_hour: u8, end_hour: u8) -> bool {
506+
if start_hour == end_hour {
507+
return true;
508+
}
509+
let hour = hour_utc(time_ms);
510+
if start_hour < end_hour {
511+
hour >= start_hour && hour < end_hour
512+
} else {
513+
hour >= start_hour || hour < end_hour
514+
}
515+
}
516+
495517
fn civil_from_days(days_since_epoch: i64) -> (i32, u8, u8) {
496518
let z = days_since_epoch + 719_468;
497519
let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
@@ -519,7 +541,11 @@ fn days_from_civil(year: i32, month: u8, day: u8) -> i64 {
519541

520542
#[cfg(test)]
521543
mod tests {
522-
use super::{Interval, MarketField, SourceTemplate};
544+
use super::{hour_utc, session_utc, weekday_utc, Interval, MarketField, SourceTemplate};
545+
546+
const JAN_1_2024_UTC_MS: i64 = 1_704_067_200_000;
547+
const HOUR_MS: i64 = 3_600_000;
548+
const DAY_MS: i64 = 24 * HOUR_MS;
523549

524550
#[test]
525551
fn parses_all_supported_intervals() {
@@ -640,4 +666,22 @@ mod tests {
640666
Some(1_706_745_600_000)
641667
);
642668
}
669+
670+
#[test]
671+
fn utc_time_helpers_extract_expected_components() {
672+
assert_eq!(weekday_utc(JAN_1_2024_UTC_MS), 0);
673+
assert_eq!(weekday_utc(JAN_1_2024_UTC_MS + DAY_MS), 1);
674+
assert_eq!(hour_utc(JAN_1_2024_UTC_MS), 0);
675+
assert_eq!(hour_utc(JAN_1_2024_UTC_MS + 23 * HOUR_MS), 23);
676+
}
677+
678+
#[test]
679+
fn utc_session_helper_supports_wrapped_windows() {
680+
assert!(session_utc(JAN_1_2024_UTC_MS + 4 * HOUR_MS, 4, 8));
681+
assert!(!session_utc(JAN_1_2024_UTC_MS + 8 * HOUR_MS, 4, 8));
682+
assert!(session_utc(JAN_1_2024_UTC_MS + 23 * HOUR_MS, 22, 2));
683+
assert!(session_utc(JAN_1_2024_UTC_MS + DAY_MS, 22, 2));
684+
assert!(!session_utc(JAN_1_2024_UTC_MS + 21 * HOUR_MS, 22, 2));
685+
assert!(session_utc(JAN_1_2024_UTC_MS, 0, 0));
686+
}
643687
}

0 commit comments

Comments
 (0)