From 669788bd81df3134f6e9f8f73196f20d1478a6c4 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 11 Aug 2026 17:10:37 -0400 Subject: [PATCH] Improve ObjectStore ergonomics for end-of-run persistence - Key rules (charset/extension) move to static ObjectStore.IsSupportedKey; unsupported-key errors now state the rules and point to the new ObjectStore.SanitizeKey helper for programmatically-built keys - Reading a missing key lists the available keys in the error - New Python-facing helpers: save_json/read_json with a tolerant encoder (datetime/date/time, Decimal, numpy scalars, Symbol, non-string dict keys) and read_json default for missing keys, save_dataframe (CSV, or JSON for .json keys) and save_text as an alias of save - Unit tests plus ObjectStoreErgonomicsRegressionAlgorithm (C# + Python) --- ...bjectStoreErgonomicsRegressionAlgorithm.cs | 162 ++++++++++++++ ...bjectStoreErgonomicsRegressionAlgorithm.py | 104 +++++++++ Common/Storage/ObjectStore.cs | 201 ++++++++++++++++++ Engine/Storage/LocalObjectStore.cs | 23 +- Tests/Common/Storage/LocalObjectStoreTests.cs | 156 ++++++++++++++ 5 files changed, 636 insertions(+), 10 deletions(-) create mode 100644 Algorithm.CSharp/ObjectStoreErgonomicsRegressionAlgorithm.cs create mode 100644 Algorithm.Python/ObjectStoreErgonomicsRegressionAlgorithm.py diff --git a/Algorithm.CSharp/ObjectStoreErgonomicsRegressionAlgorithm.cs b/Algorithm.CSharp/ObjectStoreErgonomicsRegressionAlgorithm.cs new file mode 100644 index 000000000000..05a86aed9a37 --- /dev/null +++ b/Algorithm.CSharp/ObjectStoreErgonomicsRegressionAlgorithm.cs @@ -0,0 +1,162 @@ +/* + * 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 QuantConnect.Interfaces; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm asserting the ObjectStore ergonomics: unsupported keys are rejected with the + /// key rules stated in the error, converts arbitrary names + /// into supported keys, is an alias of Save and reading a + /// missing key lists the available keys in the error. The Python version of this algorithm additionally + /// covers the tolerant save_json/read_json and save_dataframe helpers + /// + public class ObjectStoreErgonomicsRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + /// + /// Initialize 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, 7); + SetEndDate(2013, 10, 11); + + SetBenchmark(x => 0); + + AddEquity("SPY", Resolution.Daily); + } + + /// + /// End of algorithm run event handler. This method is called at the end of a backtest or live trading operation. + /// + public override void OnEndOfAlgorithm() + { + // an unsupported key, e.g. built from a user-facing name, is rejected stating the rules and the fix + const string invalidKey = "trade_log_ai_hardware_&_cloud.csv"; + var errorMessage = string.Empty; + try + { + ObjectStore.Save(invalidKey, "a,b\n1,2"); + } + catch (ArgumentException exception) + { + errorMessage = exception.Message; + } + if (!errorMessage.Contains(Storage.ObjectStore.SupportedKeyRules) || !errorMessage.Contains("SanitizeKey")) + { + throw new RegressionTestException($"Expected the key rules in the unsupported key error, got: '{errorMessage}'"); + } + + // SanitizeKey converts the arbitrary name into a supported key + var sanitized = Storage.ObjectStore.SanitizeKey(invalidKey); + if (sanitized != "trade_log_ai_hardware___cloud.csv") + { + throw new RegressionTestException($"Unexpected sanitized key: '{sanitized}'"); + } + if (!Storage.ObjectStore.IsSupportedKey(sanitized) || !ObjectStore.Save(sanitized, "a,b\n1,2")) + { + throw new RegressionTestException("Expected the sanitized key to be storable"); + } + + // SaveText is an alias of Save + if (!ObjectStore.SaveText("ergonomics_report.txt", "The strategy went up") || + ObjectStore.Read("ergonomics_report.txt") != "The strategy went up") + { + throw new RegressionTestException("Expected the SaveText/Read round trip to succeed"); + } + + // reading a missing key lists the available keys in the error + errorMessage = string.Empty; + try + { + ObjectStore.ReadBytes("ergonomics_missing.json"); + } + catch (KeyNotFoundException exception) + { + errorMessage = exception.Message; + } + if (!errorMessage.Contains("Keys: [") || !errorMessage.Contains($"'{sanitized}'")) + { + throw new RegressionTestException($"Expected the available keys in the missing key error, got: '{errorMessage}'"); + } + + ObjectStore.Delete(sanitized); + ObjectStore.Delete("ergonomics_report.txt"); + } + + /// + /// 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 => 6; + + /// + /// 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", "0"}, + {"Tracking Error", "0"}, + {"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/ObjectStoreErgonomicsRegressionAlgorithm.py b/Algorithm.Python/ObjectStoreErgonomicsRegressionAlgorithm.py new file mode 100644 index 000000000000..fb0eaf184af2 --- /dev/null +++ b/Algorithm.Python/ObjectStoreErgonomicsRegressionAlgorithm.py @@ -0,0 +1,104 @@ +# 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 * +from decimal import Decimal + +### +### Regression algorithm asserting the ObjectStore ergonomics: unsupported keys are rejected with the +### key rules stated in the error, sanitize_key converts arbitrary names into supported keys, save_text +### is an alias of save, reading a missing key lists the available keys in the error, and the tolerant +### save_json/read_json and save_dataframe helpers handle datetime/date/Decimal/Symbol and data frames +### out of the box. +### +class ObjectStoreErgonomicsRegressionAlgorithm(QCAlgorithm): + + def initialize(self): + self.set_start_date(2013, 10, 7) + self.set_end_date(2013, 10, 11) + + self.set_benchmark(lambda x: 0) + + self.spy = self.add_equity("SPY", Resolution.DAILY).symbol + + def on_end_of_algorithm(self): + # an unsupported key, e.g. built from a user-facing name, is rejected stating the rules and the fix. + # note: BaseException because AlgorithmImports shadows the builtin Exception with System.Exception + invalid_key = "trade_log_ai_hardware_&_cloud.csv" + error = None + try: + self.object_store.save(invalid_key, "a,b\n1,2") + except BaseException as e: + error = str(e) + if error is None or "keys may only contain" not in error or "SanitizeKey" not in error: + raise AssertionError(f"Expected the key rules in the unsupported key error, got: '{error}'") + + # sanitize_key converts the arbitrary name into a supported key + sanitized = self.object_store.sanitize_key(invalid_key) + if sanitized != "trade_log_ai_hardware___cloud.csv": + raise AssertionError(f"Unexpected sanitized key: '{sanitized}'") + if not self.object_store.save(sanitized, "a,b\n1,2"): + raise AssertionError("Expected the sanitized key to be storable") + + # save_json tolerates datetime/date/Decimal/Symbol values and non-string dictionary keys + trade_log = { + "time": datetime(2013, 10, 11, 16, 0, 0), + "date": date(2013, 10, 11), + "qty": Decimal("1.5"), + "symbol": self.spy, + "fills": [{"price": Decimal("167.42"), self.spy: 10}] + } + if not self.object_store.save_json("ergonomics/trade_log.json", trade_log): + raise AssertionError("Expected save_json to succeed") + data = self.object_store.read_json("ergonomics/trade_log.json") + expected = { + "time": "2013-10-11T16:00:00", + "date": "2013-10-11", + "qty": 1.5, + "symbol": "SPY", + "fills": [{"price": 167.42, "SPY": 10}] + } + if data != expected: + raise AssertionError(f"Unexpected read_json round trip result: {data}") + + # read_json returns the given default when the key is missing + if self.object_store.read_json("ergonomics/missing.json") is not None: + raise AssertionError("Expected read_json of a missing key to return None") + if self.object_store.read_json("ergonomics/missing.json", {"warmed_up": False}) != {"warmed_up": False}: + raise AssertionError("Expected read_json of a missing key to return the given default") + + # save_dataframe stores a pandas DataFrame as CSV + frame = pd.DataFrame({"close": [167.42, 168.0]}, index=pd.to_datetime(["2013-10-10", "2013-10-11"])) + if not self.object_store.save_dataframe("ergonomics/history.csv", frame): + raise AssertionError("Expected save_dataframe to succeed") + csv = self.object_store.read("ergonomics/history.csv") + if "close" not in csv or "2013-10-10" not in csv: + raise AssertionError(f"Unexpected save_dataframe content: {csv}") + + # save_text is an alias of save + if not self.object_store.save_text("ergonomics/report.txt", "The strategy went up"): + raise AssertionError("Expected save_text to succeed") + if self.object_store.read("ergonomics/report.txt") != "The strategy went up": + raise AssertionError("Expected the save_text/read round trip to succeed") + + # reading a missing key lists the available keys in the error + error = None + try: + self.object_store.read_bytes("ergonomics/missing.json") + except BaseException as e: + error = str(e) + if error is None or "Keys: [" not in error or f"'{sanitized}'" not in error: + raise AssertionError(f"Expected the available keys in the missing key error, got: '{error}'") + + for key in [sanitized, "ergonomics/trade_log.json", "ergonomics/history.csv", "ergonomics/report.txt"]: + self.object_store.delete(key) diff --git a/Common/Storage/ObjectStore.cs b/Common/Storage/ObjectStore.cs index bb4795c00d60..d2b73bb7766a 100644 --- a/Common/Storage/ObjectStore.cs +++ b/Common/Storage/ObjectStore.cs @@ -17,9 +17,12 @@ using System.Collections; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; +using System.Text.RegularExpressions; using System.Xml.Serialization; using Newtonsoft.Json; +using Python.Runtime; using QuantConnect.Interfaces; using QuantConnect.Packets; @@ -30,6 +33,23 @@ namespace QuantConnect.Storage /// public class ObjectStore : IObjectStore { + // The single source of the key charset/extension rules, also enforced by the LocalObjectStore implementation + private static readonly Regex SupportedKeyRegex = new(@"^\.?[a-zA-Z0-9\\/_#\-\$= ]+\.?[a-zA-Z0-9]*$", RegexOptions.Compiled); + private static readonly Regex UnsupportedKeyCharactersRegex = new(@"[^a-zA-Z0-9\\/_#\-\$= .]", RegexOptions.Compiled); + private static readonly Regex KeyExtensionRegex = new(@"^[a-zA-Z0-9]+$", RegexOptions.Compiled); + + // Python helpers for the PyObject APIs, lazily created under the GIL + private static PyObject _jsonSerializeMethod; + private static PyObject _jsonDeserializeMethod; + private static PyObject _dataFrameSerializeMethod; + + /// + /// Human readable description of the format an object store key must follow, stated in key validation errors + /// + public static string SupportedKeyRules { get; } = "keys may only contain english letters, numbers, spaces and the characters" + + " '/', '\\', '_', '#', '-', '$' and '=', plus at most one '.' followed by a letters-and-numbers-only extension," + + " e.g. 'folder/trade_log-2024.csv'"; + /// /// Gets the maximum storage limit in bytes /// @@ -159,6 +179,57 @@ public string ReadString(string path, Encoding encoding = null) return Read(path, encoding); } + /// + /// Determines whether the given key follows the object store key format, see + /// + /// The object store key to validate + /// True if the key is supported + public static bool IsSupportedKey(string key) + { + return !string.IsNullOrEmpty(key) && SupportedKeyRegex.IsMatch(key) + // just in case + && key.Count(c => c == '/') <= 100 && key.Count(c => c == '\\') <= 100; + } + + /// + /// Converts an arbitrary name into a supported object store key by replacing every unsupported character + /// with '_', keeping at most the final '.extension'. Useful for programmatically-built keys, for instance + /// from user-facing names: 'trade log: AI & Cloud.csv' becomes 'trade log_ AI _ Cloud.csv' + /// + /// The arbitrary name to sanitize + /// A key following + public static string SanitizeKey(string key) + { + if (string.IsNullOrEmpty(key)) + { + throw new ArgumentException("ObjectStore.SanitizeKey(): key cannot be null or empty", nameof(key)); + } + if (IsSupportedKey(key)) + { + return key; + } + + var sanitized = UnsupportedKeyCharactersRegex.Replace(key, "_"); + + // a single trailing '.extension' of letters and numbers is allowed: keep the last dot if it + // introduces a valid extension and replace every other dot + var lastDot = sanitized.LastIndexOf('.'); + if (lastDot > 0 && KeyExtensionRegex.IsMatch(sanitized.Substring(lastDot + 1))) + { + sanitized = sanitized.Substring(0, lastDot).Replace('.', '_') + sanitized.Substring(lastDot); + } + else if (lastDot >= 0) + { + sanitized = sanitized.Replace('.', '_'); + } + + if (!IsSupportedKey(sanitized)) + { + throw new ArgumentException($"ObjectStore.SanitizeKey(): unable to sanitize key '{key}': object store {SupportedKeyRules}"); + } + return sanitized; + } + /// /// Returns the JSON deserialized object data for the specified path /// @@ -174,6 +245,28 @@ public T ReadJson(string path, Encoding encoding = null, JsonSerializerSettin return JsonConvert.DeserializeObject(json, settings); } + /// + /// Returns the JSON deserialized object data for the specified path as Python objects, + /// or the given default value if the key is not present + /// + /// The object path + /// Value to return when the key is not present. Defaults to None + /// The deserialized Python object, or if the key is not present + public PyObject ReadJson(string path, PyObject defaultValue = null) + { + if (!ContainsKey(path)) + { + return defaultValue; + } + var json = Read(path); + using (Py.GIL()) + { + EnsurePythonHelpers(); + using var pyJson = json.ToPython(); + return _jsonDeserializeMethod.Invoke(pyJson); + } + } + /// /// Returns the XML deserialized object data for the specified path /// @@ -239,6 +332,19 @@ public bool SaveString(string path, string text, Encoding encoding = null) return _store.SaveBytes(path, encoding.GetBytes(text)); } + /// + /// Saves the object data in text format for the specified path. + /// Alias of + /// + /// The object path + /// The string object to be saved + /// The string encoding used, by default + /// True if the object was saved successfully + public bool SaveText(string path, string text, Encoding encoding = null) + { + return Save(path, text, encoding); + } + /// /// Saves the object data in JSON format for the specified path /// @@ -255,6 +361,47 @@ public bool SaveJson(string path, T obj, Encoding encoding = null, JsonSerial return SaveString(path, json, encoding); } + /// + /// Saves the given Python object in JSON format for the specified path, tolerating types the standard + /// json module rejects: datetime/date/time are stored in ISO-8601 format, Decimal and numpy scalars as + /// numbers and any other unsupported type (e.g. Symbol) as its string representation. Non-string + /// dictionary keys are stringified + /// + /// The object path + /// The Python object to be saved + /// True if the object was saved successfully + public bool SaveJson(string path, PyObject obj) + { + string json; + using (Py.GIL()) + { + EnsurePythonHelpers(); + using var result = _jsonSerializeMethod.Invoke(obj); + json = result.As(); + } + return Save(path, json); + } + + /// + /// Saves the given pandas DataFrame (or Series) for the specified path, in JSON format if the + /// path has a '.json' extension and as CSV otherwise + /// + /// The object path + /// The pandas DataFrame or Series to be saved + /// True if the object was saved successfully + public bool SaveDataframe(string path, PyObject dataFrame) + { + string serialized; + using (Py.GIL()) + { + EnsurePythonHelpers(); + using var pyPath = (path ?? string.Empty).ToPython(); + using var result = _dataFrameSerializeMethod.Invoke(dataFrame, pyPath); + serialized = result.As(); + } + return Save(path, serialized); + } + /// /// Saves the object data in XML format for the specified path /// @@ -298,5 +445,59 @@ public void Dispose() { _store.Dispose(); } + + /// + /// Lazily creates the Python helper methods backing the PyObject APIs. Must be called under the GIL + /// + private static void EnsurePythonHelpers() + { + if (_jsonSerializeMethod == null) + { + var module = PyModule.FromString("object_store_helpers", @"from json import dumps, loads +from datetime import datetime, date, time +from decimal import Decimal +try: + import numpy +except ImportError: + numpy = None + +def _default(value): + if isinstance(value, (datetime, date, time)): + return value.isoformat() + if isinstance(value, Decimal): + return float(value) + if numpy is not None and isinstance(value, numpy.generic): + return value.item() + # Symbol and any other type json does not handle + return str(value) + +def _normalize(value): + if isinstance(value, dict): + # json requires str/int/float/bool/None keys: stringify anything else (Symbol, datetime, ...) + return { (k if isinstance(k, (str, int, float, bool)) or k is None else str(_default(k))): _normalize(v) + for k, v in value.items() } + if isinstance(value, (list, tuple, set)): + return [_normalize(v) for v in value] + return value + +def serialize(value): + return dumps(_normalize(value), default=_default) + +def deserialize(json_string): + return loads(json_string) + +def serialize_dataframe(value, key): + if not hasattr(value, 'to_csv'): + raise TypeError(f'save_dataframe() expects a pandas DataFrame or Series but received {type(value).__name__}') + if key.lower().endswith('.json'): + return value.to_json() + return value.to_csv() +"); + _jsonDeserializeMethod = module.GetAttr("deserialize"); + _dataFrameSerializeMethod = module.GetAttr("serialize_dataframe"); + // last so partial initialization is never observed + _jsonSerializeMethod = module.GetAttr("serialize"); + } + } } } \ No newline at end of file diff --git a/Engine/Storage/LocalObjectStore.cs b/Engine/Storage/LocalObjectStore.cs index 80690b8a7531..600667fcd10a 100644 --- a/Engine/Storage/LocalObjectStore.cs +++ b/Engine/Storage/LocalObjectStore.cs @@ -19,7 +19,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text.RegularExpressions; using System.Threading; using QuantConnect.Configuration; using QuantConnect.Interfaces; @@ -112,7 +111,6 @@ private bool IsDirty } private Timer _persistenceTimer; - private Regex _pathRegex = new(@"^\.?[a-zA-Z0-9\\/_#\-\$= ]+\.?[a-zA-Z0-9]*$", RegexOptions.Compiled); private readonly ConcurrentDictionary _storage = new(); private readonly object _persistLock = new object(); @@ -292,7 +290,14 @@ public byte[] ReadBytes(string path) // Ensure we have the key, also takes care of null or improper access if (!ContainsKey(path)) { - throw new KeyNotFoundException($"Object with path '{path}' was not found in the current project. " + + // list the available keys so a near-miss (typo, wrong extension) is visible in the error itself + var keys = Keys; + var sample = string.Join(", ", keys.Take(10).Select(key => $"'{key}'")); + if (keys.Count > 10) + { + sample += ", ..."; + } + throw new KeyNotFoundException($"Object with path '{path}' was not found in the current project. Keys: [{sample}]. " + "Please use ObjectStore.ContainsKey(key) to check if an object exists before attempting to read." ); } @@ -326,14 +331,12 @@ public bool SaveBytes(string path, byte[] contents) { throw new InvalidOperationException($"LocalObjectStore.SaveBytes(): {NoWritePermissionsError}"); } - else if (!_pathRegex.IsMatch(path)) - { - throw new ArgumentException($"LocalObjectStore: path is not supported: '{path}'"); - } - else if (path.Count(c => c == '/') > 100 || path.Count(c => c == '\\') > 100) + else if (!ObjectStore.IsSupportedKey(path)) { - // just in case - throw new ArgumentException($"LocalObjectStore: path is not supported: '{path}'"); + // state the rules in the error: keys are often built from user-facing names and only fail here, + // frequently in OnEndOfAlgorithm after an otherwise green run + throw new ArgumentException($"LocalObjectStore: path is not supported: '{path}'. Object store {ObjectStore.SupportedKeyRules}." + + $" Use ObjectStore.{nameof(ObjectStore.SanitizeKey)}(name) to convert an arbitrary name into a supported path."); } // after we check the regex diff --git a/Tests/Common/Storage/LocalObjectStoreTests.cs b/Tests/Common/Storage/LocalObjectStoreTests.cs index 6451c116bda3..624ce2a8dad2 100644 --- a/Tests/Common/Storage/LocalObjectStoreTests.cs +++ b/Tests/Common/Storage/LocalObjectStoreTests.cs @@ -541,6 +541,104 @@ public void ThrowsKeyNotFoundException_WhenObjectStoreDoesNotContainKey() Assert.IsTrue(error.Message.Contains("Please use ObjectStore.ContainsKey(key)")); } + [Test] + public void KeyNotFoundErrorListsTheAvailableKeys() + { + // dedicated store instance: its in-memory keys are listed first in the error + using var store = new ObjectStore(new TestLocalObjectStore()); + store.Initialize(0, 0, "", new Controls() { PersistenceIntervalSeconds = -1 }, AlgorithmMode.Backtesting); + Assert.IsTrue(store.SaveBytes("available_key_a.txt", new byte[] { 1 })); + Assert.IsTrue(store.SaveBytes("available_key_b.txt", new byte[] { 1 })); + + var error = Assert.Throws(() => store.ReadBytes("missing.missing")); + + Assert.IsTrue(error.Message.Contains("Keys: ["), error.Message); + Assert.IsTrue(error.Message.Contains("'available_key_a.txt'"), error.Message); + Assert.IsTrue(error.Message.Contains("'available_key_b.txt'"), error.Message); + + store.Delete("available_key_a.txt"); + store.Delete("available_key_b.txt"); + } + + [Test] + public void KeyNotFoundErrorTruncatesTheAvailableKeysListAtTen() + { + using var store = new ObjectStore(new TestLocalObjectStore()); + store.Initialize(0, 0, "", new Controls() { PersistenceIntervalSeconds = -1 }, AlgorithmMode.Backtesting); + for (var i = 0; i < 11; i++) + { + Assert.IsTrue(store.SaveBytes($"truncation_key_{i}.txt", new byte[] { 1 })); + } + + var error = Assert.Throws(() => store.ReadBytes("missing.missing")); + + Assert.IsTrue(error.Message.Contains(", ...]"), error.Message); + + for (var i = 0; i < 11; i++) + { + store.Delete($"truncation_key_{i}.txt"); + } + } + + [TestCase("my_key", true)] + [TestCase("./my_key/nested\\file.csv", true)] + [TestCase("file with spaces #1-$=.txt", true)] + [TestCase("trade_log_ai_hardware_&_cloud.csv", false)] + [TestCase("file.tar.gz", false)] + [TestCase("file.name_v2", false)] + [TestCase("**abc**", false)] + [TestCase("", false)] + [TestCase(null, false)] + public void ValidatesKeys(string key, bool isSupported) + { + Assert.AreEqual(isSupported, ObjectStore.IsSupportedKey(key)); + } + + [Test] + public void UnsupportedKeyErrorStatesTheKeyRules() + { + var error = Assert.Throws( + () => _store.SaveBytes("trade_log_ai_hardware_&_cloud.csv", new byte[] { 1 })); + + Assert.IsTrue(error.Message.Contains("path is not supported: 'trade_log_ai_hardware_&_cloud.csv'"), error.Message); + Assert.IsTrue(error.Message.Contains(ObjectStore.SupportedKeyRules), error.Message); + Assert.IsTrue(error.Message.Contains("SanitizeKey"), error.Message); + } + + [TestCase("my_key/file.csv", "my_key/file.csv")] + [TestCase("trade_log_ai_hardware_&_cloud.csv", "trade_log_ai_hardware___cloud.csv")] + [TestCase("trade log: AI & Cloud.csv", "trade log_ AI _ Cloud.csv")] + [TestCase("file.tar.gz", "file_tar.gz")] + [TestCase("résumé.pdf", "r_sum_.pdf")] + [TestCase("a..", "a__")] + [TestCase("100%.json", "100_.json")] + public void SanitizeKeyReturnsAStorableKey(string key, string expectedSanitizedKey) + { + var sanitized = ObjectStore.SanitizeKey(key); + + Assert.AreEqual(expectedSanitizedKey, sanitized); + Assert.IsTrue(ObjectStore.IsSupportedKey(sanitized)); + Assert.IsTrue(_store.SaveBytes(sanitized, new byte[] { 1 })); + + _store.Delete(sanitized); + } + + [TestCase(null)] + [TestCase("")] + public void SanitizeKeyThrowsOnNullOrEmptyKey(string key) + { + Assert.Throws(() => ObjectStore.SanitizeKey(key)); + } + + [Test] + public void SaveTextIsAnAliasOfSave() + { + Assert.IsTrue(_store.SaveText("save_text_alias.txt", "abc")); + Assert.AreEqual("abc", _store.Read("save_text_alias.txt")); + + _store.Delete("save_text_alias.txt"); + } + [TestCase("my_key", "./LocalObjectStoreTests/my_key")] [TestCase("test/123", "./LocalObjectStoreTests/test/123")] [TestCase("**abc**", null)] @@ -1016,6 +1114,64 @@ def add_data(object_store): } } + [Test] + public void PythonSaveJsonReadJsonSaveDataframeAndSanitizeKey() + { + using (Py.GIL()) + { + var testModule = PyModule.FromString("TestObjectStoreErgonomics", + @" +from AlgorithmImports import * +from datetime import datetime +from decimal import Decimal + +def test(object_store): + symbol = Symbol.create('SPY', SecurityType.EQUITY, Market.USA) + + # save_json tolerates datetime/date/Decimal/Symbol values and non-string dictionary keys + object_store.save_json('ergonomics/log.json', { + 'time': datetime(2024, 1, 2, 3, 4, 5), + 'date': datetime(2024, 1, 1).date(), + 'qty': Decimal('1.5'), + 'symbol': symbol, + 'fills': [{symbol: 10}] + }) + data = object_store.read_json('ergonomics/log.json') + # symbols are stored as their string representation, which depends on the symbol cache state + assert data == { + 'time': '2024-01-02T03:04:05', + 'date': '2024-01-01', + 'qty': 1.5, + 'symbol': str(symbol), + 'fills': [{str(symbol): 10}] + }, data + + # read_json returns the given default when the key is missing + assert object_store.read_json('ergonomics/missing.json') is None + assert object_store.read_json('ergonomics/missing.json', {'a': 1}) == {'a': 1} + + # save_text is an alias of save + object_store.save_text('ergonomics/report.txt', 'hello') + assert object_store.read('ergonomics/report.txt') == 'hello' + + # save_dataframe stores a DataFrame as CSV + import pandas as pd + frame = pd.DataFrame({'a': [1, 2], 'b': [3.5, 4.5]}) + object_store.save_dataframe('ergonomics/frame.csv', frame) + assert 'a,b' in object_store.read('ergonomics/frame.csv') + + # sanitize_key is reachable from the instance + assert object_store.sanitize_key('trade_log_ai_hardware_&_cloud.csv') == 'trade_log_ai_hardware___cloud.csv' + + for key in ['ergonomics/log.json', 'ergonomics/report.txt', 'ergonomics/frame.csv']: + object_store.delete(key) +"); + + dynamic test = testModule.GetAttr("test"); + Assert.DoesNotThrow(() => test(_store)); + } + } + private static void DummyMachineLearning(string outputFile, string content) { try