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()
{