Skip to content

Commit eb12c8f

Browse files
JosueNinaMartin-Molineroclaude
authored
Seed runtime-added currency conversion rates immediately (#9568)
* Seed runtime-added currency conversion rates immediately Fixes the spurious 'The conversion rate for <currency> is not available' runtime error caused by a two-path seeding asymmetry. The setup path (BaseSetupHandler.SetupCurrencyConversions) wires up a currency's conversion feed AND seeds its rate via history/last-known-price so the rate is non-zero right away. The runtime path (UniverseSelection.EnsureCurrencyDataFeeds, invoked during universe selection / SetCash mid-run) only created the conversion subscription and left the rate at 0 until the first bar of the pair arrived. Any conversion in that gap (classically a midnight scheduled SetHoldings firing before the day's first conversion-pair bar) threw. EnsureCurrencyDataFeeds now seeds newly introduced, still-zero-rate conversion securities and calls cash.Update(), mirroring the setup path. Seeding is gated behind a seedNewCurrencies flag (default true) so the setup caller, which performs its own optionally white-listed seeding, can opt out and not regress white-list semantics. SeedSecurities degrades gracefully when no history/data is available, leaving the rate at 0 as before, so live mode and no-history scenarios are safe. Adds a regression test exercising the runtime path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Make runtime currency seeding robust and fix regression expectation CI failures from the runtime currency-conversion seeding change: 1. AlgorithmWarmupTests.WarmUpInternalSubscriptions threw ArgumentNullException because the new EnsureCurrencyDataFeeds seeding path ran GetLastKnownPrices in a stub where the conversion security lacked SymbolProperties. Pre-seeding is best-effort and must never break the algorithm, so wrap it in try/catch and degrade gracefully (leave the rate at 0, the pre-fix behavior) - matching the documented intent. The first conversion-pair bar still updates the rate. 2. ScheduledUniverseSelectionModelRegressionAlgorithm (C# + Python) asserted AlgorithmHistoryDataPoints == 0. The algorithm runtime-adds Forex pairs (EURGBP -> GBP cash) via scheduled universe selection; the fix now correctly seeds that runtime currency's conversion rate with a last-known-price history request (deterministically 50 points). The old 0 reflected the buggy unseeded behavior, so update the expectation to 50. No other statistics changed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Seed runtime added currency conversion rates * Seed currencies with no new conversion feed and dedup the seeding helper --------- Co-authored-by: Martin-Molinero <Martin-Molinero@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 202a63e commit eb12c8f

7 files changed

Lines changed: 297 additions & 25 deletions
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/*
2+
* QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals.
3+
* Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
16+
using System.Collections.Generic;
17+
using System.Linq;
18+
using QuantConnect.Brokerages;
19+
using QuantConnect.Data;
20+
using QuantConnect.Data.UniverseSelection;
21+
using QuantConnect.Interfaces;
22+
23+
namespace QuantConnect.Algorithm.CSharp
24+
{
25+
/// <summary>
26+
/// Regression algorithm asserting that a currency added at runtime (here BTCEUR, from a scheduled event) has its
27+
/// conversion rate seeded right away, so using it immediately no longer throws because the rate is still 0.
28+
/// </summary>
29+
public class RuntimeCurrencyConversionSeedingRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition
30+
{
31+
private Symbol _ltcusd;
32+
private bool _addedAtRuntime;
33+
private bool _assertedSeeded;
34+
35+
/// <summary>
36+
/// Initialise the data and resolution required, as well as the cash and start-end dates for your algorithm. All algorithms must initialized.
37+
/// </summary>
38+
public override void Initialize()
39+
{
40+
SetStartDate(2018, 4, 5);
41+
SetEndDate(2018, 4, 5);
42+
SetBrokerageModel(BrokerageName.GDAX, AccountType.Cash);
43+
SetCash(100000);
44+
45+
// Account currency asset that funds the loop
46+
_ltcusd = AddCrypto("LTCUSD", Resolution.Minute).Symbol;
47+
48+
// Add a non-account-currency asset at runtime, mirroring users that add assets from a scheduled event
49+
Schedule.On(DateRules.EveryDay(), TimeRules.At(10, 0), () =>
50+
{
51+
if (_addedAtRuntime)
52+
{
53+
return;
54+
}
55+
_addedAtRuntime = true;
56+
AddCrypto("BTCEUR", Resolution.Minute);
57+
});
58+
}
59+
60+
/// <summary>
61+
/// Runs right after the runtime-added security is wired up, the earliest point it can be used
62+
/// </summary>
63+
public override void OnSecuritiesChanged(SecurityChanges changes)
64+
{
65+
if (!changes.AddedSecurities.Any(security => security.Symbol.Value == "BTCEUR"))
66+
{
67+
return;
68+
}
69+
_assertedSeeded = true;
70+
71+
// With the fix these are already seeded here. Without it they would still be 0 and the conversion below would throw.
72+
var eur = Portfolio.CashBook["EUR"];
73+
var btc = Portfolio.CashBook["BTC"];
74+
if (eur.ConversionRate == 0 || btc.ConversionRate == 0)
75+
{
76+
throw new RegressionTestException(
77+
$"Runtime-added currency conversion rates were not seeded (EUR={eur.ConversionRate}, BTC={btc.ConversionRate})");
78+
}
79+
80+
if (Portfolio.CashBook.ConvertToAccountCurrency(100m, "EUR") <= 0)
81+
{
82+
throw new RegressionTestException("Expected a positive EUR -> account currency conversion");
83+
}
84+
}
85+
86+
/// <summary>
87+
/// OnData event is the primary entry point for your algorithm. Each new data point will be pumped in here.
88+
/// </summary>
89+
/// <param name="slice">Slice object keyed by symbol containing the stock data</param>
90+
public override void OnData(Slice slice)
91+
{
92+
if (!_addedAtRuntime || Portfolio.Invested)
93+
{
94+
return;
95+
}
96+
97+
if (Securities[_ltcusd].Price != 0)
98+
{
99+
SetHoldings(_ltcusd, 0.5);
100+
}
101+
}
102+
103+
/// <summary>
104+
/// Makes sure the seeding path was actually exercised so the test can't silently pass
105+
/// </summary>
106+
public override void OnEndOfAlgorithm()
107+
{
108+
if (!_assertedSeeded)
109+
{
110+
throw new RegressionTestException("BTCEUR was never added at runtime, the seeding path was not exercised");
111+
}
112+
}
113+
114+
/// <summary>
115+
/// This is used by the regression test system to indicate if the open source Lean repository has the required data to run this algorithm.
116+
/// </summary>
117+
public bool CanRunLocally { get; } = true;
118+
119+
/// <summary>
120+
/// This is used by the regression test system to indicate which languages this algorithm is written in.
121+
/// </summary>
122+
public List<Language> Languages { get; } = new() { Language.CSharp };
123+
124+
/// <summary>
125+
/// Data Points count of all timeslices of algorithm
126+
/// </summary>
127+
public long DataPoints => 6005;
128+
129+
/// <summary>
130+
/// Data Points count of the algorithm history
131+
/// </summary>
132+
public int AlgorithmHistoryDataPoints => 591;
133+
134+
/// <summary>
135+
/// Final status of the algorithm
136+
/// </summary>
137+
public AlgorithmStatus AlgorithmStatus => AlgorithmStatus.Completed;
138+
139+
/// <summary>
140+
/// This is used by the regression test system to indicate what the expected statistics are from running the algorithm
141+
/// </summary>
142+
public Dictionary<string, string> ExpectedStatistics => new Dictionary<string, string>
143+
{
144+
{"Total Orders", "1"},
145+
{"Average Win", "0%"},
146+
{"Average Loss", "0%"},
147+
{"Compounding Annual Return", "0%"},
148+
{"Drawdown", "0%"},
149+
{"Expectancy", "0"},
150+
{"Start Equity", "100000.00"},
151+
{"End Equity", "99064.52"},
152+
{"Net Profit", "0%"},
153+
{"Sharpe Ratio", "0"},
154+
{"Sortino Ratio", "0"},
155+
{"Probabilistic Sharpe Ratio", "0%"},
156+
{"Loss Rate", "0%"},
157+
{"Win Rate", "0%"},
158+
{"Profit-Loss Ratio", "0"},
159+
{"Alpha", "0"},
160+
{"Beta", "0"},
161+
{"Annual Standard Deviation", "0"},
162+
{"Annual Variance", "0"},
163+
{"Information Ratio", "0"},
164+
{"Tracking Error", "0"},
165+
{"Treynor Ratio", "0"},
166+
{"Total Fees", "$149.18"},
167+
{"Estimated Strategy Capacity", "$160000.00"},
168+
{"Lowest Capacity Asset", "LTCUSD 2XR"},
169+
{"Portfolio Turnover", "50.20%"},
170+
{"Drawdown Recovery", "0"},
171+
{"OrderListHash", "69d27a394cffbd938ec23fbb451f37ae"}
172+
};
173+
}
174+
}

Algorithm.CSharp/ScheduledUniverseSelectionModelRegressionAlgorithm.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ private void ExpectRemovals(SecurityChanges changes, params string[] tickers)
199199
/// <summary>
200200
/// Data Points count of the algorithm history
201201
/// </summary>
202-
public int AlgorithmHistoryDataPoints => 0;
202+
public int AlgorithmHistoryDataPoints => 10;
203203

204204
/// <summary>
205205
/// Final status of the algorithm

Common/AlgorithmUtils.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
using QuantConnect.Interfaces;
1717
using QuantConnect.Securities;
18+
using System;
1819
using System.Collections.Generic;
1920
using System.Linq;
2021

@@ -46,5 +47,39 @@ public static void SeedSecurities(IReadOnlyCollection<Security> securities, IAlg
4647
}
4748
}
4849
}
50+
51+
/// <summary>
52+
/// Seeds an initial conversion rate for the cashbook currencies that don't have one yet, so they are
53+
/// non-zero right away instead of waiting for the first conversion pair bar to arrive
54+
/// </summary>
55+
/// <param name="algorithm">The algorithm instance</param>
56+
/// <param name="currenciesToUpdateWhiteList">
57+
/// If passed, only the currencies in the CashBook contained in this list will be updated.
58+
/// By default, if not passed (null), all currencies in the cashbook without a properly set up currency conversion will be updated.
59+
/// </param>
60+
public static void SeedCurrencyConversionRates(IAlgorithm algorithm, IReadOnlyCollection<string> currenciesToUpdateWhiteList = null)
61+
{
62+
Func<Cash, bool> cashToUpdateFilter = currenciesToUpdateWhiteList == null
63+
? (x) => x.CurrencyConversion != null && x.ConversionRate == 0
64+
: (x) => currenciesToUpdateWhiteList.Contains(x.Symbol);
65+
var cashToUpdate = algorithm.Portfolio.CashBook.Values.Where(cashToUpdateFilter).ToList();
66+
67+
if (cashToUpdate.Count == 0)
68+
{
69+
return;
70+
}
71+
72+
var securitiesToUpdate = cashToUpdate
73+
.SelectMany(x => x.CurrencyConversion.ConversionRateSecurities)
74+
.Distinct()
75+
.ToList();
76+
77+
SeedSecurities(securitiesToUpdate, algorithm);
78+
79+
foreach (var cash in cashToUpdate)
80+
{
81+
cash.Update();
82+
}
83+
}
4984
}
5085
}

Engine/DataFeeds/CurrencySubscriptionDataConfigManager.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,14 @@ public IEnumerable<SubscriptionDataConfig> GetPendingSubscriptionDataConfigs()
131131
/// <summary>
132132
/// Checks the current <see cref="SubscriptionDataConfig"/> and adds new necessary currency pair feeds to provide real time conversion data
133133
/// </summary>
134-
public void EnsureCurrencySubscriptionDataConfigs(SecurityChanges securityChanges, IBrokerageModel brokerageModel)
134+
/// <returns>True if a new currency was introduced, either as a new internal conversion feed or as a new cash
135+
/// entry added to the cashbook. Lets callers skip follow up work like seeding the new conversion rates when
136+
/// nothing was added</returns>
137+
public bool EnsureCurrencySubscriptionDataConfigs(SecurityChanges securityChanges, IBrokerageModel brokerageModel)
135138
{
139+
// a new cash added to the cashbook also needs its conversion rate seeded, even when its conversion
140+
// security is an already subscribed one and no new internal feed is introduced below
141+
var newCashAdded = _ensureCurrencyDataFeeds;
136142
_ensureCurrencyDataFeeds = false;
137143
// remove any 'to be added' if the security has already been added
138144
_toBeAddedCurrencySubscriptionDataConfigs.RemoveWhere(
@@ -150,6 +156,8 @@ public void EnsureCurrencySubscriptionDataConfigs(SecurityChanges securityChange
150156
_toBeAddedCurrencySubscriptionDataConfigs.Add(config);
151157
}
152158
_pendingSubscriptionDataConfigs = _toBeAddedCurrencySubscriptionDataConfigs.Any();
159+
160+
return newConfigs.Count > 0 || newCashAdded;
153161
}
154162
}
155163
}

Engine/DataFeeds/UniverseSelection.cs

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ public SecurityChanges ApplyUniverseSelection(Universe universe, DateTime dateTi
128128

129129
// if the input is already fundamental data we just need to filter it and pass it through
130130
var hasFundamentalData = universeData.Data.Count > 0 && universeData.Data[0] is Fundamental;
131-
if(hasFundamentalData)
131+
if (hasFundamentalData)
132132
{
133133
// Remove selected symbols that does not have fine fundamental data
134134
var anyDoesNotHaveFundamentalData = false;
@@ -137,7 +137,8 @@ public SecurityChanges ApplyUniverseSelection(Universe universe, DateTime dateTi
137137
// which do not use coarse data as underlying, in which case it could happen that we try to load fine fundamental data that is missing, but no problem,
138138
// 'FineFundamentalSubscriptionEnumeratorFactory' won't emit it
139139
var set = selectSymbolsResult.ToHashSet();
140-
fineCollection.Data.AddRange(universeData.Data.OfType<Fundamental>().Where(fundamental => {
140+
fineCollection.Data.AddRange(universeData.Data.OfType<Fundamental>().Where(fundamental =>
141+
{
141142
// we remove to we distict by symbol
142143
if (set.Remove(fundamental.Symbol))
143144
{
@@ -360,7 +361,7 @@ public bool AddPendingInternalDataFeeds(DateTime utcStart)
360361
resolution = supportedResolutions.OrderByDescending(x => x).First();
361362
}
362363

363-
var subscriptionList = new List<Tuple<Type, TickType>>() {subscriptionType};
364+
var subscriptionList = new List<Tuple<Type, TickType>>() { subscriptionType };
364365
var dataConfig = _algorithm.SubscriptionManager.SubscriptionDataConfigService.Add(
365366
securityBenchmark.Security.Symbol,
366367
resolution,
@@ -418,9 +419,32 @@ public bool AddPendingInternalDataFeeds(DateTime utcStart)
418419
/// <summary>
419420
/// Checks the current subscriptions and adds necessary currency pair feeds to provide real time conversion data
420421
/// </summary>
421-
public void EnsureCurrencyDataFeeds(SecurityChanges securityChanges)
422+
/// <param name="securityChanges">The security changes to consume</param>
423+
/// <param name="seedNewCurrencies">Whether to seed the conversion rate of newly added currencies with their last
424+
/// known price. The setup handler passes false because it performs its own (optionally white-listed) seeding</param>
425+
public void EnsureCurrencyDataFeeds(SecurityChanges securityChanges, bool seedNewCurrencies = true)
422426
{
423-
_currencySubscriptionDataConfigManager.EnsureCurrencySubscriptionDataConfigs(securityChanges, _algorithm.BrokerageModel);
427+
var newCurrenciesAdded = _currencySubscriptionDataConfigManager.EnsureCurrencySubscriptionDataConfigs(securityChanges, _algorithm.BrokerageModel);
428+
429+
// Only scan the cashbook and seed when a new currency was actually introduced, either as a new
430+
// internal conversion feed or as a new cash entry whose conversion security is an already added one
431+
if (!seedNewCurrencies || !newCurrenciesAdded)
432+
{
433+
return;
434+
}
435+
436+
// Seed the new conversion rates with their last known price so they are non-zero right away, instead of
437+
// waiting for the first conversion pair bar to arrive. Otherwise a conversion needed in that gap would
438+
// throw. This is the same thing BaseSetupHandler does during setup, but for cashes added at runtime.
439+
try
440+
{
441+
AlgorithmUtils.SeedCurrencyConversionRates(_algorithm);
442+
}
443+
catch (Exception err)
444+
{
445+
// Seeding must never break the algorithm, the rate will be set on the first conversion pair bar
446+
Log.Error($"UniverseSelection.EnsureCurrencyDataFeeds(): failed to seed runtime currency conversion rate(s): {err.Message}");
447+
}
424448
}
425449

426450
/// <summary>

Engine/Setup/BaseSetupHandler.cs

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -84,26 +84,13 @@ public static void SetupCurrencyConversions(
8484
IReadOnlyCollection<string> currenciesToUpdateWhiteList = null)
8585
{
8686
// this is needed to have non-zero currency conversion rates during warmup
87-
// will also set the Cash.ConversionRateSecurity
88-
universeSelection.EnsureCurrencyDataFeeds(SecurityChanges.None);
87+
// will also set the Cash.ConversionRateSecurity.
88+
// We don't let it seed the conversion rates here because we do that right below,
89+
// where we can also limit the seeding to a specific white list of currencies
90+
universeSelection.EnsureCurrencyDataFeeds(SecurityChanges.None, seedNewCurrencies: false);
8991

9092
// now set conversion rates
91-
Func<Cash, bool> cashToUpdateFilter = currenciesToUpdateWhiteList == null
92-
? (x) => x.CurrencyConversion != null && x.ConversionRate == 0
93-
: (x) => currenciesToUpdateWhiteList.Contains(x.Symbol);
94-
var cashToUpdate = algorithm.Portfolio.CashBook.Values.Where(cashToUpdateFilter).ToList();
95-
96-
var securitiesToUpdate = cashToUpdate
97-
.SelectMany(x => x.CurrencyConversion.ConversionRateSecurities)
98-
.Distinct()
99-
.ToList();
100-
101-
AlgorithmUtils.SeedSecurities(securitiesToUpdate, algorithm);
102-
103-
foreach (var cash in cashToUpdate)
104-
{
105-
cash.Update();
106-
}
93+
AlgorithmUtils.SeedCurrencyConversionRates(algorithm, currenciesToUpdateWhiteList);
10794

10895
Log.Trace($"BaseSetupHandler.SetupCurrencyConversions():{Environment.NewLine}" +
10996
$"Account Type: {algorithm.BrokerageModel.AccountType}{Environment.NewLine}{Environment.NewLine}{algorithm.Portfolio.CashBook}");

0 commit comments

Comments
 (0)