From 0b42216f1f7fed3aaece42067cdb7d288a0c8e58 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 11 Aug 2026 18:35:18 -0400 Subject: [PATCH] Add scheduling API conveniences - FuncTimeRule/FuncDateRule define __call__ returning the rule itself, so Python calls like self.time_rules.midnight() or self.date_rules.today() no longer fail with "'FuncTimeRule' object is not callable" - DateRules.Every(TimeSpan): every-N-days date rule anchored at the start of the schedule, accepting a timedelta in Python - TimeRules.At overloads accepting an IANA time zone id string, e.g. at(8, 0, 0, "Europe/London") - ScheduleManager.On(dateRule, callback): defaults the time rule to midnight in the algorithm time zone, mirroring universe selection schedules; the PyObject overload rejects a time rule or non-callable in the callback slot with an explicit error --- ...chedulingConvenienceRegressionAlgorithm.cs | 134 ++++++++++++++++++ ...chedulingConvenienceRegressionAlgorithm.py | 62 ++++++++ Common/Scheduling/DateRules.cs | 18 +++ Common/Scheduling/FuncDateRule.cs | 13 ++ Common/Scheduling/FuncTimeRule.cs | 13 ++ Common/Scheduling/ScheduleManager.cs | 50 +++++++ Common/Scheduling/TimeRules.cs | 55 +++++++ Tests/Common/Scheduling/DateRulesTests.cs | 72 ++++++++++ .../Common/Scheduling/ScheduleManagerTests.cs | 96 +++++++++++++ Tests/Common/Scheduling/TimeRulesTests.cs | 45 ++++++ 10 files changed, 558 insertions(+) create mode 100644 Algorithm.CSharp/SchedulingConvenienceRegressionAlgorithm.cs create mode 100644 Algorithm.Python/SchedulingConvenienceRegressionAlgorithm.py diff --git a/Algorithm.CSharp/SchedulingConvenienceRegressionAlgorithm.cs b/Algorithm.CSharp/SchedulingConvenienceRegressionAlgorithm.cs new file mode 100644 index 000000000000..6acb5e4230b9 --- /dev/null +++ b/Algorithm.CSharp/SchedulingConvenienceRegressionAlgorithm.cs @@ -0,0 +1,134 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Linq; +using QuantConnect.Interfaces; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm asserting the scheduling convenience APIs: an every-N-days date rule, + /// a time rule with an IANA time zone id and scheduling with a default midnight time rule + /// + public class SchedulingConvenienceRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + private readonly List _everyThreeDaysTimes = new(); + private readonly List _defaultTimeRuleTimes = new(); + private readonly List _londonTimes = new(); + + /// + /// Initialise the data and resolution required, as well as the cash and start-end dates for your algorithm. All algorithms must initialized. + /// + public override void Initialize() + { + SetStartDate(2013, 10, 07); + SetEndDate(2013, 10, 11); + + // every 3 days, anchored at the start of the schedule + Schedule.On(DateRules.Every(TimeSpan.FromDays(3)), TimeRules.At(12, 0), () => _everyThreeDaysTimes.Add(Time)); + + // no time rule: defaults to midnight in the algorithm time zone + Schedule.On(DateRules.EveryDay(), () => _defaultTimeRuleTimes.Add(Time)); + + // IANA time zone id: 15:00 London (BST in October 2013) is 10:00 New York (EDT) + Schedule.On(DateRules.EveryDay(), TimeRules.At(15, 0, "Europe/London"), () => _londonTimes.Add(Time)); + } + + public override void OnEndOfAlgorithm() + { + // the schedule starts the day before the algorithm start, so the rule anchors at Oct 6 and + // fires Oct 6 (before the start, skipped), Oct 9 and Oct 12 (after the end) + AssertScheduledTimes(_everyThreeDaysTimes, new List { new(2013, 10, 09, 12, 0, 0) }, "every 3 days"); + + // fires at midnight every day, including the algorithm start time itself + AssertScheduledTimes(_defaultTimeRuleTimes, + Enumerable.Range(7, 5).Select(day => new DateTime(2013, 10, day)).ToList(), "default midnight"); + + AssertScheduledTimes(_londonTimes, + Enumerable.Range(7, 5).Select(day => new DateTime(2013, 10, day, 10, 0, 0)).ToList(), "London 15:00"); + } + + private static void AssertScheduledTimes(List actual, List expected, string name) + { + if (!actual.SequenceEqual(expected)) + { + throw new RegressionTestException($"Unexpected '{name}' event times: expected [{string.Join(", ", expected)}] " + + $"but got [{string.Join(", ", actual)}]"); + } + } + + /// + /// This is used by the regression test system to indicate if the open source Lean repository has the required data to run this algorithm. + /// + public bool CanRunLocally { get; } = true; + + /// + /// This is used by the regression test system to indicate which languages this algorithm is written in. + /// + public List Languages { get; } = new() { Language.CSharp, Language.Python }; + + /// + /// Data Points count of all timeslices of algorithm + /// + public long DataPoints => 42; + + /// + /// Data Points count of the algorithm history + /// + public int AlgorithmHistoryDataPoints => 0; + + /// + /// Final status of the algorithm + /// + public AlgorithmStatus AlgorithmStatus => AlgorithmStatus.Completed; + + /// + /// This is used by the regression test system to indicate what the expected statistics are from running the algorithm + /// + public Dictionary ExpectedStatistics => new Dictionary + { + {"Total Orders", "0"}, + {"Average Win", "0%"}, + {"Average Loss", "0%"}, + {"Compounding Annual Return", "0%"}, + {"Drawdown", "0%"}, + {"Expectancy", "0"}, + {"Start Equity", "100000"}, + {"End Equity", "100000"}, + {"Net Profit", "0%"}, + {"Sharpe Ratio", "0"}, + {"Sortino Ratio", "0"}, + {"Probabilistic Sharpe Ratio", "0%"}, + {"Loss Rate", "0%"}, + {"Win Rate", "0%"}, + {"Profit-Loss Ratio", "0"}, + {"Alpha", "0"}, + {"Beta", "0"}, + {"Annual Standard Deviation", "0"}, + {"Annual Variance", "0"}, + {"Information Ratio", "-8.91"}, + {"Tracking Error", "0.223"}, + {"Treynor Ratio", "0"}, + {"Total Fees", "$0.00"}, + {"Estimated Strategy Capacity", "$0"}, + {"Lowest Capacity Asset", ""}, + {"Portfolio Turnover", "0%"}, + {"Drawdown Recovery", "0"}, + {"OrderListHash", "d41d8cd98f00b204e9800998ecf8427e"} + }; + } +} diff --git a/Algorithm.Python/SchedulingConvenienceRegressionAlgorithm.py b/Algorithm.Python/SchedulingConvenienceRegressionAlgorithm.py new file mode 100644 index 000000000000..48b8b0f22e6e --- /dev/null +++ b/Algorithm.Python/SchedulingConvenienceRegressionAlgorithm.py @@ -0,0 +1,62 @@ +# QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. +# Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from AlgorithmImports import * + +### +### Regression algorithm asserting the scheduling convenience APIs: an every-N-days date rule, +### a time rule with an IANA time zone id and scheduling with a default midnight time rule +### +class SchedulingConvenienceRegressionAlgorithm(QCAlgorithm): + def initialize(self): + '''Initialise the data and resolution required, as well as the cash and start-end dates for your algorithm. All algorithms must initialized.''' + self.set_start_date(2013, 10, 7) + self.set_end_date(2013, 10, 11) + + self._every_three_days_times = [] + self._default_time_rule_times = [] + self._london_times = [] + + # every 3 days, anchored at the start of the schedule + self.schedule.on(self.date_rules.every(timedelta(days=3)), self.time_rules.at(12, 0), + lambda: self._every_three_days_times.append(self.time)) + + # no time rule: defaults to midnight in the algorithm time zone + self.schedule.on(self.date_rules.every_day(), lambda: self._default_time_rule_times.append(self.time)) + + # IANA time zone id: 15:00 London (BST in October 2013) is 10:00 New York (EDT) + self.schedule.on(self.date_rules.every_day(), self.time_rules.at(15, 0, "Europe/London"), + lambda: self._london_times.append(self.time)) + + # date/time rule properties are tolerant to being called as if they were methods, returning the rule itself + for rule, expected_name in [(self.time_rules.midnight(), "Midnight"), (self.time_rules.noon(), "Noon"), + (self.time_rules.now(), "Now"), (self.date_rules.today(), "TodayOnly"), + (self.date_rules.tomorrow(), "TomorrowOnly")]: + if rule.name != expected_name: + raise RegressionTestException(f"Unexpected rule name: expected {expected_name} but got {rule.name}") + + def on_end_of_algorithm(self): + # the schedule starts the day before the algorithm start, so the rule anchors at Oct 6 and + # fires Oct 6 (before the start, skipped), Oct 9 and Oct 12 (after the end) + self._assert_scheduled_times(self._every_three_days_times, [datetime(2013, 10, 9, 12, 0, 0)], "every 3 days") + + # fires at midnight every day, including the algorithm start time itself + self._assert_scheduled_times(self._default_time_rule_times, [datetime(2013, 10, day) for day in range(7, 12)], + "default midnight") + + self._assert_scheduled_times(self._london_times, [datetime(2013, 10, day, 10, 0, 0) for day in range(7, 12)], + "London 15:00") + + def _assert_scheduled_times(self, actual, expected, name): + if actual != expected: + raise RegressionTestException(f"Unexpected '{name}' event times: expected {expected} but got {actual}") diff --git a/Common/Scheduling/DateRules.cs b/Common/Scheduling/DateRules.cs index 799b972c05fc..47514269ae52 100644 --- a/Common/Scheduling/DateRules.cs +++ b/Common/Scheduling/DateRules.cs @@ -111,6 +111,24 @@ public IDateRule Every(params DayOfWeek[] days) return new FuncDateRule(string.Join(",", days), (start, end) => Time.EachDay(start, end).Where(date => hash.Contains(date.DayOfWeek))); } + /// + /// Specifies an event should fire every days, anchored at the start of the schedule. + /// In Python, accepts a timedelta, e.g. 'self.date_rules.every(timedelta(days=5))' + /// + /// The interval between event dates; must be a whole number of days of at least 1 + /// A date rule that fires every N days + public IDateRule Every(TimeSpan interval) + { + var days = (int)interval.TotalDays; + if (days < 1 || interval != TimeSpan.FromDays(days)) + { + throw new ArgumentException("DateRules.Every(): interval must be a whole number of days of at least 1." + + $" For intraday schedules use TimeRules.Every() instead. Value provided: {interval}"); + } + return new FuncDateRule($"Every {days.ToStringInvariant()} days", + (start, end) => Time.EachDay(start, end).Where((date, index) => index % days == 0)); + } + /// /// Specifies an event should fire every day /// diff --git a/Common/Scheduling/FuncDateRule.cs b/Common/Scheduling/FuncDateRule.cs index 26802bf162ed..a9ee4dc39569 100644 --- a/Common/Scheduling/FuncDateRule.cs +++ b/Common/Scheduling/FuncDateRule.cs @@ -70,5 +70,18 @@ public IEnumerable GetDates(DateTime start, DateTime end) { return _getDatesFunction(start, end); } + + /// + /// Returns this same rule instance. Makes rule instances tolerant to being called in Python: + /// convenience properties like are indistinguishable from sibling + /// methods like there, so algorithms frequently write + /// 'self.date_rules.today()', which used to fail with "'FuncDateRule' object is not callable" + /// + /// Python.NET wires any public '__call__' method to the Python call operator + /// This same date rule instance + public IDateRule __call__() + { + return this; + } } } diff --git a/Common/Scheduling/FuncTimeRule.cs b/Common/Scheduling/FuncTimeRule.cs index 3388ab752ee3..ed9cca11a273 100644 --- a/Common/Scheduling/FuncTimeRule.cs +++ b/Common/Scheduling/FuncTimeRule.cs @@ -70,5 +70,18 @@ public IEnumerable CreateUtcEventTimes(IEnumerable dates) { return _createUtcEventTimesFunction(dates); } + + /// + /// Returns this same rule instance. Makes rule instances tolerant to being called in Python: + /// convenience properties like are indistinguishable from sibling + /// methods like there, so algorithms frequently write + /// 'self.time_rules.midnight()', which used to fail with "'FuncTimeRule' object is not callable" + /// + /// Python.NET wires any public '__call__' method to the Python call operator + /// This same time rule instance + public ITimeRule __call__() + { + return this; + } } } diff --git a/Common/Scheduling/ScheduleManager.cs b/Common/Scheduling/ScheduleManager.cs index 6bcf46f00554..3cd3ca0512dc 100644 --- a/Common/Scheduling/ScheduleManager.cs +++ b/Common/Scheduling/ScheduleManager.cs @@ -123,6 +123,56 @@ public void Remove(ScheduledEvent scheduledEvent) } } + /// + /// Schedules the callback to run on the dates given by the date rule, at midnight in the algorithm's + /// time zone (), mirroring universe selection schedules that + /// only take a date rule + /// + /// Specifies what dates the event should run + /// The callback to be invoked + public ScheduledEvent On(IDateRule dateRule, Action callback) + { + return On(dateRule, TimeRules.Midnight, callback); + } + + /// + /// Schedules the callback to run on the dates given by the date rule, at midnight in the algorithm's + /// time zone (), mirroring universe selection schedules that + /// only take a date rule + /// + /// Specifies what dates the event should run + /// The callback to be invoked + public ScheduledEvent On(IDateRule dateRule, PyObject callback) + { + // Guard the missing-callback mistake: On(dateRule, timeRule) would otherwise schedule the time + // rule itself as the callback, silently doing nothing now that time rules define __call__ + if (callback.TryConvert(out ITimeRule _)) + { + throw new ArgumentException("ScheduleManager.On(): missing callback. Use On(dateRule, timeRule, callback), " + + "or On(dateRule, callback) to default the time rule to midnight."); + } + using (Py.GIL()) + { + if (!callback.IsCallable()) + { + throw new ArgumentException($"ScheduleManager.On(): the provided callback is not callable: {callback.Repr()}"); + } + } + return On(dateRule, TimeRules.Midnight, callback); + } + + /// + /// Schedules the callback to run on the dates given by the date rule, at midnight in the algorithm's + /// time zone (), mirroring universe selection schedules that + /// only take a date rule + /// + /// Specifies what dates the event should run + /// The callback to be invoked + public ScheduledEvent On(IDateRule dateRule, Action callback) + { + return On(dateRule, TimeRules.Midnight, callback); + } + /// /// Schedules the callback to run using the specified date and time rules /// diff --git a/Common/Scheduling/TimeRules.cs b/Common/Scheduling/TimeRules.cs index 767150da2a8f..7e4248ffe380 100644 --- a/Common/Scheduling/TimeRules.cs +++ b/Common/Scheduling/TimeRules.cs @@ -16,6 +16,7 @@ using System; using NodaTime; +using NodaTime.TimeZones; using System.Linq; using QuantConnect.Interfaces; using QuantConnect.Securities; @@ -121,6 +122,42 @@ public ITimeRule At(int hour, int minute, int second, DateTimeZone timeZone) return At(new TimeSpan(hour, minute, second), timeZone); } + /// + /// Specifies an event should fire at the specified time of day in the specified time zone + /// + /// The hour + /// The minute + /// The time zone the event time is represented in, as an IANA time zone id, e.g. "Europe/London" + /// A time rule that fires at the specified time in the algorithm's time zone + public ITimeRule At(int hour, int minute, string timeZone) + { + return At(new TimeSpan(hour, minute, 0), ParseTimeZone(timeZone)); + } + + /// + /// Specifies an event should fire at the specified time of day in the specified time zone + /// + /// The hour + /// The minute + /// The second + /// The time zone the event time is represented in, as an IANA time zone id, e.g. "Europe/London" + /// A time rule that fires at the specified time in the algorithm's time zone + public ITimeRule At(int hour, int minute, int second, string timeZone) + { + return At(new TimeSpan(hour, minute, second), ParseTimeZone(timeZone)); + } + + /// + /// Specifies an event should fire at the specified time of day in the specified time zone + /// + /// The time of day in the algorithm's time zone the event should fire + /// The time zone the event time is represented in, as an IANA time zone id, e.g. "Europe/London" + /// A time rule that fires at the specified time in the algorithm's time zone + public ITimeRule At(TimeSpan timeOfDay, string timeZone) + { + return At(timeOfDay, ParseTimeZone(timeZone)); + } + /// /// Specifies an event should fire at the specified time of day in the specified time zone /// @@ -387,6 +424,24 @@ where exchangeHours.IsDateOpen(date, extendedMarketClose) return new FuncTimeRule(name, applicator); } + /// + /// Helper method to resolve an IANA/tzdb time zone id, e.g. "Europe/London", into a + /// + /// The IANA time zone id + /// The instance for the given id + private static DateTimeZone ParseTimeZone(string timeZone) + { + try + { + return DateTimeZoneProviders.Tzdb[timeZone]; + } + catch (DateTimeZoneNotFoundException) + { + throw new ArgumentException($"TimeRules.At(): TimeZone with id '{timeZone}' was not found. " + + "For a complete list of time zones please visit: http://en.wikipedia.org/wiki/List_of_tz_database_time_zones"); + } + } + /// /// For each provided date will yield all the time intervals based on the supplied time span /// diff --git a/Tests/Common/Scheduling/DateRulesTests.cs b/Tests/Common/Scheduling/DateRulesTests.cs index 3041f178e92d..a600655b38c6 100644 --- a/Tests/Common/Scheduling/DateRulesTests.cs +++ b/Tests/Common/Scheduling/DateRulesTests.cs @@ -1094,6 +1094,78 @@ from AlgorithmImports import * } } + [Test] + public void EveryIntervalDaysEmitsEveryNDays() + { + var rules = GetDateRules(); + var rule = rules.Every(TimeSpan.FromDays(3)); + Assert.AreEqual("Every 3 days", rule.Name); + + var dates = rule.GetDates(new DateTime(2000, 01, 01), new DateTime(2000, 01, 31)).ToList(); + + // anchored at the start date, every 3rd day: Jan 1, 4, 7, ..., 31 + Assert.AreEqual(11, dates.Count); + Assert.AreEqual(new DateTime(2000, 01, 01), dates[0]); + for (var i = 1; i < dates.Count; i++) + { + Assert.AreEqual(TimeSpan.FromDays(3), dates[i] - dates[i - 1]); + } + } + + [TestCase(0)] + [TestCase(-24)] + [TestCase(12)] + [TestCase(36)] + public void EveryIntervalValidatesWholeDayInterval(int hours) + { + var rules = GetDateRules(); + Assert.Throws(() => rules.Every(TimeSpan.FromHours(hours))); + } + + [Test] + public void EveryWithTimeDeltaInPython() + { + var rules = GetDateRules(); + using (Py.GIL()) + { + using var module = PyModule.FromString("testModule", @" +from datetime import timedelta + +def create_rule(rules): + return rules.Every(timedelta(days=2)) +"); + dynamic createRule = module.GetAttr("create_rule"); + var rule = (createRule(rules) as PyObject).GetAndDispose(); + Assert.AreEqual("Every 2 days", rule.Name); + + var dates = rule.GetDates(new DateTime(2000, 01, 01), new DateTime(2000, 01, 07)).ToList(); + CollectionAssert.AreEqual(new[] + { + new DateTime(2000, 01, 01), new DateTime(2000, 01, 03), new DateTime(2000, 01, 05), new DateTime(2000, 01, 07) + }, dates); + } + } + + [Test] + public void DateRulePropertiesAreCallableTolerantInPython() + { + var rules = GetDateRules(); + using (Py.GIL()) + { + using var module = PyModule.FromString("testModule", @" +def call_rule(rule): + return rule() +"); + dynamic callRule = module.GetAttr("call_rule"); + foreach (var rule in new[] { rules.Today, rules.Tomorrow }) + { + // calling the rule as if it were a method returns the rule itself + var result = (callRule(rule) as PyObject).GetAndDispose(); + Assert.AreEqual(rule.Name, result.Name); + } + } + } + [Test] public void DateRuleDoesNotConflictWithTimeRuleDueToExtendedMarketHours() { diff --git a/Tests/Common/Scheduling/ScheduleManagerTests.cs b/Tests/Common/Scheduling/ScheduleManagerTests.cs index f1bed8425f7e..e5b08c28c408 100644 --- a/Tests/Common/Scheduling/ScheduleManagerTests.cs +++ b/Tests/Common/Scheduling/ScheduleManagerTests.cs @@ -18,6 +18,7 @@ using System.Collections.Generic; using System.Threading; using NUnit.Framework; +using Python.Runtime; using QuantConnect.Algorithm; using QuantConnect.Lean.Engine; using QuantConnect.Lean.Engine.DataFeeds; @@ -64,6 +65,101 @@ public void DuplicateScheduledEventsAreBothFired() Assert.AreEqual(timeSteps, count2); } + [Test] + public void OnWithoutTimeRuleDefaultsToMidnight() + { + var algorithm = new QCAlgorithm(); + + var handler = new BacktestingRealTimeHandler(); + var timeLimitManager = new AlgorithmTimeLimitManager(TokenBucket.Null, TimeSpan.MaxValue); + handler.Setup(algorithm, new AlgorithmNodePacket(PacketType.BacktestNode), null, null, timeLimitManager); + + algorithm.Schedule.SetEventSchedule(handler); + + var time = new DateTime(2018, 1, 1); + algorithm.SetDateTime(time); + + var eventTimes = new List(); + var scheduledEvent = algorithm.Schedule.On(algorithm.Schedule.DateRules.EveryDay(), (name, triggerTime) => + { + eventTimes.Add(triggerTime); + }); + + Assert.AreEqual("EveryDay: Midnight", scheduledEvent.Name); + + for (var i = 0; i < 48; i++) + { + handler.SetTime(time); + time = time.AddHours(1); + } + + handler.Exit(); + + // the default algorithm time zone is New York, so midnight local is 5am UTC + CollectionAssert.AreEqual(new[] + { + new DateTime(2018, 1, 1, 5, 0, 0), + new DateTime(2018, 1, 2, 5, 0, 0) + }, eventTimes); + } + + [Test] + public void OnWithoutTimeRuleWithPythonCallbackDefaultsToMidnight() + { + var algorithm = new QCAlgorithm(); + + var handler = new BacktestingRealTimeHandler(); + var timeLimitManager = new AlgorithmTimeLimitManager(TokenBucket.Null, TimeSpan.MaxValue); + handler.Setup(algorithm, new AlgorithmNodePacket(PacketType.BacktestNode), null, null, timeLimitManager); + + algorithm.Schedule.SetEventSchedule(handler); + + var time = new DateTime(2018, 1, 1); + algorithm.SetDateTime(time); + + using (Py.GIL()) + { + using var module = PyModule.FromString("testModule", @" +count = 0 +def callback(): + global count + count += 1 +"); + using var callback = module.GetAttr("callback"); + var scheduledEvent = algorithm.Schedule.On(algorithm.Schedule.DateRules.EveryDay(), callback); + Assert.AreEqual("EveryDay: Midnight", scheduledEvent.Name); + + for (var i = 0; i < 48; i++) + { + handler.SetTime(time); + time = time.AddHours(1); + } + + handler.Exit(); + + Assert.AreEqual(2, module.GetAttr("count").GetAndDispose()); + } + } + + [Test] + public void OnWithTimeRuleButMissingCallbackThrows() + { + var algorithm = new QCAlgorithm(); + + using (Py.GIL()) + { + // a time rule passed where the callback belongs must not be silently scheduled as the callback + using var timeRule = algorithm.Schedule.TimeRules.Midnight.ToPython(); + var exception = Assert.Throws( + () => algorithm.Schedule.On(algorithm.Schedule.DateRules.EveryDay(), timeRule)); + Assert.That(exception.Message, Does.Contain("missing callback")); + + using var notCallable = "hello".ToPython(); + Assert.Throws( + () => algorithm.Schedule.On(algorithm.Schedule.DateRules.EveryDay(), notCallable)); + } + } + [Test] public void TriggersWeeklyScheduledEventsEachWeekBacktesting() { diff --git a/Tests/Common/Scheduling/TimeRulesTests.cs b/Tests/Common/Scheduling/TimeRulesTests.cs index 101fe8046e95..2f60fb848268 100644 --- a/Tests/Common/Scheduling/TimeRulesTests.cs +++ b/Tests/Common/Scheduling/TimeRulesTests.cs @@ -671,6 +671,51 @@ public void SetTimeZone() Assert.AreEqual(nowUtc, nowNewYork); } + [Test] + public void AtSpecificTimeWithStringTimeZone() + { + var rules = GetTimeRules(TimeZones.Utc); + var date = new DateTime(2021, 1, 4); + // London is on GMT (UTC+0) in January + var expected = new DateTime(2021, 1, 4, 8, 30, 0); + + Assert.AreEqual(expected, rules.At(8, 30, "Europe/London").CreateUtcEventTimes(new[] { date }).Single()); + Assert.AreEqual(expected.AddSeconds(15), rules.At(8, 30, 15, "Europe/London").CreateUtcEventTimes(new[] { date }).Single()); + Assert.AreEqual(expected, rules.At(new TimeSpan(8, 30, 0), "Europe/London").CreateUtcEventTimes(new[] { date }).Single()); + + // matches the DateTimeZone overload + Assert.AreEqual(rules.At(8, 30, TimeZones.London).CreateUtcEventTimes(new[] { date }).Single(), + rules.At(8, 30, "Europe/London").CreateUtcEventTimes(new[] { date }).Single()); + } + + [Test] + public void AtWithInvalidStringTimeZoneThrows() + { + var rules = GetTimeRules(TimeZones.Utc); + var exception = Assert.Throws(() => rules.At(8, 30, "Europe/NotATimeZone")); + Assert.That(exception.Message, Does.Contain("Europe/NotATimeZone")); + } + + [Test] + public void TimeRulePropertiesAreCallableTolerantInPython() + { + var rules = GetTimeRules(TimeZones.Utc); + using (Py.GIL()) + { + using var module = PyModule.FromString("testModule", @" +def call_rule(rule): + return rule() +"); + dynamic callRule = module.GetAttr("call_rule"); + foreach (var rule in new[] { rules.Now, rules.Midnight, rules.Noon }) + { + // calling the rule as if it were a method returns the rule itself + var result = (callRule(rule) as PyObject).GetAndDispose(); + Assert.AreEqual(rule.Name, result.Name); + } + } + } + [Test] public void SetFuncTimeRuleInPythonWorksAsExpected() {