diff --git a/CMakeLists.txt b/CMakeLists.txt index 0a3b54f..5c0e353 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1112,6 +1112,7 @@ if(OPTIONX_BUILD_TESTS) market_data_tick_history_contract_test market_data_tick_continuity_test intrade_observed_tick_history_test + trading_system_batch1_test ) if(OPTIONX_LIGHTWEIGHT_BRIDGE_SMOKE_TESTS) list(APPEND OPTIONX_LIGHTWEIGHT_TESTS diff --git a/include/optionx_cpp/optionx.hpp b/include/optionx_cpp/optionx.hpp index 6eef746..26aa682 100644 --- a/include/optionx_cpp/optionx.hpp +++ b/include/optionx_cpp/optionx.hpp @@ -8,6 +8,7 @@ #include "utils.hpp" #include "lifecycle.hpp" #include "data.hpp" +#include "trading_system.hpp" #include "market_data.hpp" #include "storages.hpp" #include "components.hpp" diff --git a/include/optionx_cpp/trading_system.hpp b/include/optionx_cpp/trading_system.hpp new file mode 100644 index 0000000..8625c7a --- /dev/null +++ b/include/optionx_cpp/trading_system.hpp @@ -0,0 +1,14 @@ +#pragma once +#ifndef OPTIONX_HEADER_TRADING_SYSTEM_HPP_INCLUDED +#define OPTIONX_HEADER_TRADING_SYSTEM_HPP_INCLUDED + +/// \file trading_system.hpp +/// \brief Public aggregate for pure trading-system identity contracts. + +#include + +#include "trading_system/Identity.hpp" +#include "trading_system/TradeSeries.hpp" +#include "trading_system/TradeIntent.hpp" + +#endif // OPTIONX_HEADER_TRADING_SYSTEM_HPP_INCLUDED diff --git a/include/optionx_cpp/trading_system/Identity.hpp b/include/optionx_cpp/trading_system/Identity.hpp new file mode 100644 index 0000000..cfc55a7 --- /dev/null +++ b/include/optionx_cpp/trading_system/Identity.hpp @@ -0,0 +1,133 @@ +#pragma once +#ifndef OPTIONX_HEADER_TRADING_SYSTEM_IDENTITY_HPP_INCLUDED +#define OPTIONX_HEADER_TRADING_SYSTEM_IDENTITY_HPP_INCLUDED + +/// \file Identity.hpp +/// \brief Defines stable identities and cardinality policies for trading-system contracts. + +#include +#include +#include +#include + +namespace optionx::trading_system { + +using IntentId = std::uint64_t; +using SeriesId = std::uint64_t; +using ExecutionGroupId = std::uint64_t; +using SettlementId = std::uint64_t; + +enum class IntentStatus { + PROPOSED, + REJECTED, + ADMITTED, + SUBMITTING, + EXECUTION_UNKNOWN, + EXECUTED, + SETTLED, + CANCELLED +}; + +enum class ExecutionCardinality { + ZERO_OR_ONE, + EXACTLY_ONE, + MULTIPLE, + SPLIT_GROUP +}; + +inline const char* to_string(IntentStatus value) noexcept { + switch (value) { + case IntentStatus::PROPOSED: return "PROPOSED"; + case IntentStatus::REJECTED: return "REJECTED"; + case IntentStatus::ADMITTED: return "ADMITTED"; + case IntentStatus::SUBMITTING: return "SUBMITTING"; + case IntentStatus::EXECUTION_UNKNOWN: return "EXECUTION_UNKNOWN"; + case IntentStatus::EXECUTED: return "EXECUTED"; + case IntentStatus::SETTLED: return "SETTLED"; + case IntentStatus::CANCELLED: return "CANCELLED"; + } + return "UNKNOWN"; +} + +inline const char* to_string(ExecutionCardinality value) noexcept { + switch (value) { + case ExecutionCardinality::ZERO_OR_ONE: return "ZERO_OR_ONE"; + case ExecutionCardinality::EXACTLY_ONE: return "EXACTLY_ONE"; + case ExecutionCardinality::MULTIPLE: return "MULTIPLE"; + case ExecutionCardinality::SPLIT_GROUP: return "SPLIT_GROUP"; + } + return "UNKNOWN"; +} + +inline IntentStatus intent_status_from_string(const std::string& value) { + if (value == "PROPOSED") return IntentStatus::PROPOSED; + if (value == "REJECTED") return IntentStatus::REJECTED; + if (value == "ADMITTED") return IntentStatus::ADMITTED; + if (value == "SUBMITTING") return IntentStatus::SUBMITTING; + if (value == "EXECUTION_UNKNOWN") return IntentStatus::EXECUTION_UNKNOWN; + if (value == "EXECUTED") return IntentStatus::EXECUTED; + if (value == "SETTLED") return IntentStatus::SETTLED; + if (value == "CANCELLED") return IntentStatus::CANCELLED; + throw std::invalid_argument("Unknown trading-system intent status: " + value); +} + +inline ExecutionCardinality execution_cardinality_from_string(const std::string& value) { + if (value == "ZERO_OR_ONE") return ExecutionCardinality::ZERO_OR_ONE; + if (value == "EXACTLY_ONE") return ExecutionCardinality::EXACTLY_ONE; + if (value == "MULTIPLE") return ExecutionCardinality::MULTIPLE; + if (value == "SPLIT_GROUP") return ExecutionCardinality::SPLIT_GROUP; + throw std::invalid_argument("Unknown trading-system execution cardinality: " + value); +} + +inline void to_json(nlohmann::json& json, IntentStatus value) { + json = to_string(value); +} + +inline void from_json(const nlohmann::json& json, IntentStatus& value) { + value = intent_status_from_string(json.get()); +} + +inline void to_json(nlohmann::json& json, ExecutionCardinality value) { + json = to_string(value); +} + +inline void from_json(const nlohmann::json& json, ExecutionCardinality& value) { + value = execution_cardinality_from_string(json.get()); +} + +/// \brief Identifies a physical execution group without owning execution state. +struct ExecutionGroupIdentity { + ExecutionGroupId group_id = 0; + std::uint32_t expected_members = 0; + + /// \brief Checks whether this identity is compatible with a cardinality policy. + bool valid_for(ExecutionCardinality cardinality) const noexcept { + switch (cardinality) { + case ExecutionCardinality::ZERO_OR_ONE: + return group_id == 0 && expected_members <= 1; + case ExecutionCardinality::EXACTLY_ONE: + return expected_members == 1; + case ExecutionCardinality::MULTIPLE: + return group_id != 0 && expected_members >= 2; + case ExecutionCardinality::SPLIT_GROUP: + return group_id != 0 && expected_members >= 2; + } + return false; + } +}; + +inline void to_json(nlohmann::json& json, const ExecutionGroupIdentity& value) { + json = nlohmann::json{ + {"group_id", value.group_id}, + {"expected_members", value.expected_members} + }; +} + +inline void from_json(const nlohmann::json& json, ExecutionGroupIdentity& value) { + value.group_id = json.value("group_id", ExecutionGroupId{0}); + value.expected_members = json.value("expected_members", std::uint32_t{0}); +} + +} // namespace optionx::trading_system + +#endif // OPTIONX_HEADER_TRADING_SYSTEM_IDENTITY_HPP_INCLUDED diff --git a/include/optionx_cpp/trading_system/TradeIntent.hpp b/include/optionx_cpp/trading_system/TradeIntent.hpp new file mode 100644 index 0000000..7b72622 --- /dev/null +++ b/include/optionx_cpp/trading_system/TradeIntent.hpp @@ -0,0 +1,92 @@ +#pragma once +#ifndef OPTIONX_HEADER_TRADING_SYSTEM_TRADE_INTENT_HPP_INCLUDED +#define OPTIONX_HEADER_TRADING_SYSTEM_TRADE_INTENT_HPP_INCLUDED + +/// \file TradeIntent.hpp +/// \brief Defines the pure intent envelope used before broker submission. + +#include "Identity.hpp" + +#include +#include +#include + +namespace optionx::trading_system { + +/// \class TradeIntent +/// \brief Identifies one proposed logical trade without executing it. +/// +/// The embedded TradeRequest remains the existing broker-compatible request +/// DTO. This envelope adds identity, cardinality, and revision data without +/// changing TradeRequest serialization or runtime callback ownership. +struct TradeIntent { + IntentId intent_id = 0; + SignalId origin_signal_id = 0; + SeriesId series_id = 0; + std::int32_t series_step = -1; + ExecutionGroupIdentity execution_group; + ExecutionCardinality cardinality = ExecutionCardinality::ZERO_OR_ONE; + IntentStatus status = IntentStatus::PROPOSED; + TradeRequest request; + std::uint64_t state_revision = 0; + std::string idempotency_key; + + /// \brief Validates identity and cardinality invariants only. + bool valid() const noexcept { + const bool standalone = series_id == 0 && series_step == -1; + const bool series_bound = series_id != 0 && series_step >= 0; + return intent_id != 0 && + (standalone || series_bound) && + !idempotency_key.empty() && + execution_group.valid_for(cardinality); + } + + bool revision_matches(std::uint64_t expected_revision) const noexcept { + return state_revision == expected_revision; + } + + bool advance_revision(std::uint64_t expected_revision) noexcept { + if (!revision_matches(expected_revision)) return false; + ++state_revision; + return true; + } + + TradeIntent clone() const { + return *this; + } +}; + +inline void to_json(nlohmann::json& json, const TradeIntent& value) { + json = nlohmann::json{ + {"intent_id", value.intent_id}, + {"origin_signal_id", value.origin_signal_id}, + {"series_id", value.series_id}, + {"series_step", value.series_step}, + {"execution_group", value.execution_group}, + {"cardinality", value.cardinality}, + {"status", value.status}, + {"request", value.request}, + {"state_revision", value.state_revision}, + {"idempotency_key", value.idempotency_key} + }; +} + +inline void from_json(const nlohmann::json& json, TradeIntent& value) { + value.intent_id = json.value("intent_id", IntentId{0}); + value.origin_signal_id = json.value("origin_signal_id", SignalId{0}); + value.series_id = json.value("series_id", SeriesId{0}); + value.series_step = json.value("series_step", std::int32_t{-1}); + value.execution_group = json.value("execution_group", ExecutionGroupIdentity{}); + value.cardinality = json.value("cardinality", ExecutionCardinality::ZERO_OR_ONE); + value.status = json.value("status", IntentStatus::PROPOSED); + value.request = TradeRequest{}; + if (json.contains("request")) { + json.at("request").get_to(value.request); + } + value.state_revision = json.value("state_revision", std::uint64_t{0}); + value.idempotency_key = json.value("idempotency_key", std::string{}); +} + +} // namespace optionx::trading_system + +#endif // OPTIONX_HEADER_TRADING_SYSTEM_TRADE_INTENT_HPP_INCLUDED diff --git a/include/optionx_cpp/trading_system/TradeSeries.hpp b/include/optionx_cpp/trading_system/TradeSeries.hpp new file mode 100644 index 0000000..9078522 --- /dev/null +++ b/include/optionx_cpp/trading_system/TradeSeries.hpp @@ -0,0 +1,63 @@ +#pragma once +#ifndef OPTIONX_HEADER_TRADING_SYSTEM_TRADE_SERIES_HPP_INCLUDED +#define OPTIONX_HEADER_TRADING_SYSTEM_TRADE_SERIES_HPP_INCLUDED + +/// \file TradeSeries.hpp +/// \brief Defines the identity and revision envelope for a trade series. + +#include "Identity.hpp" + +#include +#include + +namespace optionx::trading_system { + +/// \class TradeSeries +/// \brief Pure identity state for a future series progression component. +/// +/// This type deliberately does not implement progression, settlement, risk, +/// or broker calls. Those concerns belong to later trading-system batches. +struct TradeSeries { + SeriesId series_id = 0; + SignalId origin_signal_id = 0; + std::uint32_t next_step = 0; + std::uint64_t state_revision = 0; + + bool valid() const noexcept { + return series_id != 0; + } + + bool revision_matches(std::uint64_t expected_revision) const noexcept { + return state_revision == expected_revision; + } + + bool advance_revision(std::uint64_t expected_revision) noexcept { + if (!revision_matches(expected_revision)) return false; + ++state_revision; + return true; + } + + TradeSeries clone() const { + return *this; + } +}; + +inline void to_json(nlohmann::json& json, const TradeSeries& value) { + json = nlohmann::json{ + {"series_id", value.series_id}, + {"origin_signal_id", value.origin_signal_id}, + {"next_step", value.next_step}, + {"state_revision", value.state_revision} + }; +} + +inline void from_json(const nlohmann::json& json, TradeSeries& value) { + value.series_id = json.value("series_id", SeriesId{0}); + value.origin_signal_id = json.value("origin_signal_id", SignalId{0}); + value.next_step = json.value("next_step", std::uint32_t{0}); + value.state_revision = json.value("state_revision", std::uint64_t{0}); +} + +} // namespace optionx::trading_system + +#endif // OPTIONX_HEADER_TRADING_SYSTEM_TRADE_SERIES_HPP_INCLUDED diff --git a/tests/trading_system_batch1_test.cpp b/tests/trading_system_batch1_test.cpp new file mode 100644 index 0000000..65c6341 --- /dev/null +++ b/tests/trading_system_batch1_test.cpp @@ -0,0 +1,135 @@ +#include + +#include + +namespace { + +optionx::trading_system::TradeIntent make_intent() { + optionx::trading_system::TradeIntent intent; + intent.intent_id = 41; + intent.origin_signal_id = 7; + intent.execution_group.expected_members = 1; + intent.cardinality = optionx::trading_system::ExecutionCardinality::EXACTLY_ONE; + intent.idempotency_key = "intent-41"; + intent.request.symbol = "EURUSD"; + intent.request.signal_id = 7; + intent.request.amount = 10.0; + return intent; +} + +TEST(TradingSystemBatch1, IntentCloneCopiesIdentityAndRequest) { + const auto original = make_intent(); + auto clone = original.clone(); + + EXPECT_EQ(clone.intent_id, original.intent_id); + EXPECT_EQ(clone.idempotency_key, original.idempotency_key); + EXPECT_EQ(clone.request.symbol, original.request.symbol); + EXPECT_DOUBLE_EQ(clone.request.amount, original.request.amount); +} + +TEST(TradingSystemBatch1, IntentRoundTripsThroughJson) { + const auto original = make_intent(); + const auto encoded = nlohmann::json(original); + const auto restored = encoded.get(); + + EXPECT_TRUE(restored.valid()); + EXPECT_EQ(restored.intent_id, original.intent_id); + EXPECT_EQ(restored.cardinality, original.cardinality); + EXPECT_EQ(restored.request.signal_id, original.request.signal_id); + EXPECT_EQ(restored.request.symbol, original.request.symbol); +} + +TEST(TradingSystemBatch1, MissingNewFieldsUseStableDefaults) { + const nlohmann::json legacy = { + {"intent_id", 9}, + {"idempotency_key", "legacy-9"} + }; + + const auto restored = legacy.get(); + + EXPECT_TRUE(restored.valid()); + EXPECT_EQ(restored.series_step, -1); + EXPECT_EQ(restored.status, optionx::trading_system::IntentStatus::PROPOSED); +} + +TEST(TradingSystemBatch1, InvalidIdentityIsRejected) { + auto intent = make_intent(); + intent.intent_id = 0; + EXPECT_FALSE(intent.valid()); + + intent = make_intent(); + intent.idempotency_key.clear(); + EXPECT_FALSE(intent.valid()); + + optionx::trading_system::TradeSeries series; + EXPECT_FALSE(series.valid()); +} + +TEST(TradingSystemBatch1, SeriesBindingRequiresMatchingStep) { + auto intent = make_intent(); + intent.series_id = 12; + intent.series_step = -1; + EXPECT_FALSE(intent.valid()); + + intent.series_step = 0; + EXPECT_TRUE(intent.valid()); +} + +TEST(TradingSystemBatch1, RevisionMustMatchBeforeAdvance) { + auto intent = make_intent(); + EXPECT_TRUE(intent.revision_matches(0)); + EXPECT_TRUE(intent.advance_revision(0)); + EXPECT_EQ(intent.state_revision, 1U); + EXPECT_FALSE(intent.advance_revision(0)); + + optionx::trading_system::TradeSeries series; + series.series_id = 3; + EXPECT_TRUE(series.advance_revision(0)); + EXPECT_FALSE(series.advance_revision(0)); +} + +TEST(TradingSystemBatch1, CardinalityPoliciesValidateIdentityInvariants) { + using optionx::trading_system::ExecutionCardinality; + using optionx::trading_system::ExecutionGroupIdentity; + + ExecutionGroupIdentity zero_or_one; + EXPECT_TRUE(zero_or_one.valid_for(ExecutionCardinality::ZERO_OR_ONE)); + + ExecutionGroupIdentity exactly_one; + exactly_one.expected_members = 1; + EXPECT_TRUE(exactly_one.valid_for(ExecutionCardinality::EXACTLY_ONE)); + + ExecutionGroupIdentity multiple; + multiple.group_id = 100; + multiple.expected_members = 2; + EXPECT_TRUE(multiple.valid_for(ExecutionCardinality::MULTIPLE)); + + ExecutionGroupIdentity split; + split.group_id = 101; + split.expected_members = 3; + EXPECT_TRUE(split.valid_for(ExecutionCardinality::SPLIT_GROUP)); + + EXPECT_FALSE(zero_or_one.valid_for(ExecutionCardinality::EXACTLY_ONE)); + EXPECT_FALSE(exactly_one.valid_for(ExecutionCardinality::MULTIPLE)); + split.expected_members = 1; + EXPECT_FALSE(split.valid_for(ExecutionCardinality::SPLIT_GROUP)); +} + +TEST(TradingSystemBatch1, ExistingRequestJsonContractRemainsAvailable) { + optionx::TradeRequest request; + request.symbol = "EURUSD"; + request.signal_id = 7; + request.amount = 10.0; + + const auto encoded = nlohmann::json(request); + EXPECT_EQ(encoded.at("symbol"), "EURUSD"); + EXPECT_EQ(encoded.at("signal_id"), 7); + EXPECT_DOUBLE_EQ(encoded.at("amount").get(), 10.0); +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}