diff --git a/.github/workflows/ubuntu-smoke.yml b/.github/workflows/ubuntu-smoke.yml index 638e3cf..0479523 100644 --- a/.github/workflows/ubuntu-smoke.yml +++ b/.github/workflows/ubuntu-smoke.yml @@ -217,11 +217,21 @@ jobs: cmake --build build-linux --target market_data_tick_continuity_test intrade_observed_tick_history_test -j + - name: Build trading system batch tests + run: > + cmake --build build-linux --target + trading_system_batch1_test trading_system_batch2_test -j + - name: Run market data tick continuity and observed history tests run: | ./build-linux/market_data_tick_continuity_test --gtest_brief=1 ./build-linux/intrade_observed_tick_history_test --gtest_brief=1 + - name: Run trading system batch tests + run: | + ./build-linux/trading_system_batch1_test --gtest_brief=1 + ./build-linux/trading_system_batch2_test --gtest_brief=1 + - name: Build market_data_subscriber_base_test and example run: cmake --build build-linux --target market_data_subscriber_base_test market_data_subscriber_base_example -j diff --git a/.github/workflows/windows-smoke.yml b/.github/workflows/windows-smoke.yml index f2f3546..4291703 100644 --- a/.github/workflows/windows-smoke.yml +++ b/.github/workflows/windows-smoke.yml @@ -108,6 +108,11 @@ jobs: cmake --build build-windows --config Debug --target market_data_tick_continuity_test intrade_observed_tick_history_test + - name: Build trading system batch tests + run: > + cmake --build build-windows --config Debug --target + trading_system_batch1_test trading_system_batch2_test + - name: Build TradingView extension bridge smoke example run: cmake --build build-windows --config Debug --target tradingview_extension_bridge_smoke @@ -133,6 +138,8 @@ jobs: .\build-windows\Debug\market_data_tick_history_contract_test.exe --gtest_brief=1 .\build-windows\Debug\market_data_tick_continuity_test.exe --gtest_brief=1 .\build-windows\Debug\intrade_observed_tick_history_test.exe --gtest_brief=1 + .\build-windows\Debug\trading_system_batch1_test.exe --gtest_brief=1 + .\build-windows\Debug\trading_system_batch2_test.exe --gtest_brief=1 .\build-windows\Debug\metatrader_file_bridge_smoke.exe --self-test .\build-windows\Debug\metatrader_file_command_writer_smoke.exe --self-test .\build-windows\Debug\metatrader_file_end_to_end_smoke.exe --self-test diff --git a/AGENTS.md b/AGENTS.md index 6c64b02..ec946bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,8 @@ локальные проверки, примеры и generated output. - [Implementation notes](guides/implementation-notes.md) - lifecycle, pub-sub, task scheduling, HTTP/WebSocket, trade queue, session DB и ограничения. +- [Trading system Batch 2](guides/trading-system-batch2.md) - pure filters, + decision snapshots and fixed-point sizing policies. - [Trade check API research](guides/trade-check2-research.md) - границы доказанного контракта `trade_check2.php` и решение не использовать его как authoritative tick-history API. diff --git a/CMakeLists.txt b/CMakeLists.txt index 5c0e353..dab1dbc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1113,6 +1113,7 @@ if(OPTIONX_BUILD_TESTS) market_data_tick_continuity_test intrade_observed_tick_history_test trading_system_batch1_test + trading_system_batch2_test ) if(OPTIONX_LIGHTWEIGHT_BRIDGE_SMOKE_TESTS) list(APPEND OPTIONX_LIGHTWEIGHT_TESTS diff --git a/guides/api-and-header-contracts.md b/guides/api-and-header-contracts.md index cda8c39..d85d2a6 100644 --- a/guides/api-and-header-contracts.md +++ b/guides/api-and-header-contracts.md @@ -15,6 +15,7 @@ ```cpp #include #include +#include #include #include #include @@ -41,9 +42,9 @@ angle brackets и установленный префикс ``. - Не полагайся на `include/optionx_cpp` как на дополнительный include-root: consumer contract должен работать только с корнем `include`. -- Public aggregate headers (`optionx.hpp`, `data.hpp`, `market_data.hpp`, - `platforms.hpp`, `storages.hpp`, `components.hpp`, `utils.hpp`, - `bridges.hpp`) задают публичные точки подключения. +- Public aggregate headers (`optionx.hpp`, `data.hpp`, `trading_system.hpp`, + `market_data.hpp`, `platforms.hpp`, `storages.hpp`, `components.hpp`, + `utils.hpp`, `bridges.hpp`) задают публичные точки подключения. - Bridge families are public only through `bridges.hpp` or the family umbrella headers: `bridges/metatrader_file.hpp`, `bridges/legacy_trading.hpp`, `bridges/named_pipe.hpp` and `bridges/trading_view.hpp`. diff --git a/guides/codebase-orientation.md b/guides/codebase-orientation.md index 6357404..e064274 100644 --- a/guides/codebase-orientation.md +++ b/guides/codebase-orientation.md @@ -24,8 +24,8 @@ ## Public Include Points Главная include-точка - `include/optionx_cpp/optionx.hpp`. Она включает -`utils.hpp`, `lifecycle.hpp`, `data.hpp`, `storages.hpp`, `components.hpp`, -`platforms.hpp`, `bridges.hpp`. +`utils.hpp`, `lifecycle.hpp`, `data.hpp`, `trading_system.hpp`, `storages.hpp`, +`components.hpp`, `platforms.hpp`, `bridges.hpp`. Aggregate headers в корне `include/optionx_cpp` - часть публичной поверхности. Если добавляешь новый публичный DTO/component/platform, проверь соответствующий diff --git a/guides/project-overview.md b/guides/project-overview.md index a7b2ab8..d995ed0 100644 --- a/guides/project-overview.md +++ b/guides/project-overview.md @@ -35,6 +35,7 @@ node lifecycles. See [English](lifecycle-stack.md) or | `optionx_cpp/utils.hpp` | Pub-sub, tasks, crypto, strings, time, ids | Для инфраструктурного кода и новых components | | `optionx_cpp/lifecycle.hpp` | `ILifecycleModule`, `LifecycleStack` | Для необязательной общей обработки и staged shutdown модулей | | `optionx_cpp/data.hpp` | DTO, events, enums, account/symbol/tick/bar/trading data | Для API boundary и сообщений | +| `optionx_cpp/trading_system.hpp` | Trading-system identity, decision context, filters and sizing policies | Для broker-independent admission decisions before execution | | `optionx_cpp/components.hpp` | `BaseComponent`, HTTP и trade execution base classes | Для нового manager/component | | `optionx_cpp/market_data.hpp` | Provider role, subscription DTOs, Router, subscriber base and continuity | Для live tick/bar subscriptions, history prefill and routed delivery | | `optionx_cpp/platforms.hpp` | Base platform и Intrade Bar platform | Для клиентского кода платформ | @@ -51,6 +52,7 @@ node lifecycles. See [English](lifecycle-stack.md) or | Platform facade | `optionx::platforms`, `include/optionx_cpp/platforms` | Публичный API платформы: connect, auth, trades, account info | | Components/managers | `optionx::components`, platform subnamespaces | Lifecycle-компоненты, которые получают events и выполняют работу | | Trading data | `optionx`, `include/optionx_cpp/data/trading` | `TradeRequest`, `TradeResult`, enums, signals | +| Trading system | `optionx::trading_system`, `include/optionx_cpp/trading_system` | Intent/series identity, pure policy decisions and fixed-point sizing | | Market data | `optionx::market_data`, `include/optionx_cpp/market_data`, `data/bars`, `data/ticks`, `data/symbol` | Provider subscriptions, routed delivery, history prefill/recovery, ticks, bars and symbols | | Events | `optionx::events`, `include/optionx_cpp/data/events` | Pub-sub контракты между components | | Infrastructure | `optionx::utils` | EventBus, tasks, crypto, ids, HTTP helpers | diff --git a/guides/trading-system-batch2.md b/guides/trading-system-batch2.md new file mode 100644 index 0000000..fc2b687 --- /dev/null +++ b/guides/trading-system-batch2.md @@ -0,0 +1,101 @@ +# Trading System Batch 2 + +Batch 2 adds deterministic, broker-independent policies to the +`optionx::trading_system` aggregate. It does not submit trades, read a live +account, persist state, or own a scheduler. + +## Decision Context + +`TradingDecisionContext` is a value snapshot. It copies the symbol and source +identity needed by filters, the explicit evaluation timestamp and +`TradeTimeZone`, and a `BalanceSnapshot` for sizing. Policy calls do not read +the system clock. Build the context once and pass it as a `const` value. + +`TradeTimeZone` delegates named-zone and DST behavior to `time-shield`. Fixed +offset mode is available when the caller has an explicit broker offset. The +general `TradeTimeZone` conversion helpers retain their UTC/identity fallback +for compatibility. `TradingWindowFilter` uses the checked conversion API and +returns `UNAVAILABLE / INVALID_CONFIGURATION` when a requested named zone +cannot be resolved; it never evaluates a trading window against a silent UTC +fallback. + +## Filter Decisions + +`SymbolFilter`, `SourceFilter` and `TradingWindowFilter` return a +`PolicyDecision` with one of: + +- `ALLOW` when the input matches; +- `REJECT` when a configured policy denies it; +- `UNAVAILABLE` when required context is missing or a symbol/time configuration + cannot be evaluated. + +`SymbolFilter` uses generic separator/case normalization by default, so +`EUR/USD` and `eurusd` compare as `EURUSD`. A caller may inject an existing +broker normalizer, such as the Intrade Bar alias helper, and provide a known +symbol set. Unknown symbols in that explicit set are `UNAVAILABLE`, while an +explicitly disallowed known symbol is `REJECT`. + +`TradingTimeWindow` uses half-open intervals `[start, end)`. An interval may +cross midnight, for example `{22 * 3600, 2 * 3600}`. Midnight is a valid end +point, so `{22 * 3600, 0}` means 22:00 through 00:00 while `{0, 0}` remains +invalid. Weekday restrictions identify the day on which an allowed window +starts: the after-midnight part of a Monday 22:00-02:00 window still belongs to +Monday. With no allowed windows, weekdays retain calendar-day semantics. The +filter evaluates local time after applying the timezone from the context, and +denied windows always take precedence over an allowed match. + +## Fixed-Point Sizing + +`MoneyAmount` stores integer minor units plus an explicit decimal precision. +`MoneyAmount::from_decimal()` rejects significant digits beyond the declared +precision instead of silently rounding them. + +`FixedStake` returns its configured positive amount. `PercentOfBalance` accepts +a percentage in the range `(0, 100]`; the `double` constructor explicitly +converts it to basis points, while `from_basis_points()` is available when the +caller already has an exact value. The calculated amount uses integer +arithmetic. `MoneyRounding` explicitly selects `DOWN`, `HALF_UP`, or `UP` for +the percentage and output-precision boundaries. + +The output precision may be equal to or finer than the balance precision. A +coarser output precision is rejected as `PRECISION_MISMATCH`; the policy does +not silently discard balance digits before calculating the percentage. + +Stale, unavailable, unknown, or missing balances produce an `UNAVAILABLE` +sizing result. Zero and negative balances, invalid amounts, currency +mismatches, and zero calculated stakes are rejected. The policy only proposes +an amount; broker limits, payout, reservations, and final admission remain +later responsibilities. + +## Sequential State Guards + +`TradeIntent::advance_revision()` and `TradeSeries::advance_revision()` remain +local sequential guards for one state owner. They are not thread-safe CAS +operations. Their callers must serialize DTO access; persistent/runtime state +coordination belongs to a single owner or a storage-layer compare-and-swap. +Revision overflow is rejected rather than wrapped to zero. + +## Example + +```cpp +auto context = TradingDecisionContext::from_intent( + intent, evaluation_time_ms, optionx::TradeTimeZone::utc()); +context.account.availability = DataAvailability::SUPPORTED; +context.account.currency = optionx::CurrencyType::USD; +context.account.balance = MoneyAmount::from_decimal( + "100.00", optionx::CurrencyType::USD, 2); + +const SymbolFilter symbols({"EUR/USD"}); +const SourceFilter source({7}, {}, {"alpha"}); +const TradingWindowFilter window({{9 * 3600, 10 * 3600}}); +const FixedStake stake(*MoneyAmount::from_decimal( + "5.00", optionx::CurrencyType::USD, 2)); + +const auto symbol_result = symbols.evaluate(context); +const auto source_result = source.evaluate(context); +const auto window_result = window.evaluate(context); +const auto size_result = stake.evaluate(context); +``` + +The example is intentionally a pure decision chain. It does not imply that a +successful policy result is permission to submit a broker request. diff --git a/include/optionx_cpp/data/trading/TradeTimeZone.hpp b/include/optionx_cpp/data/trading/TradeTimeZone.hpp index 97b3b35..30ec2d1 100644 --- a/include/optionx_cpp/data/trading/TradeTimeZone.hpp +++ b/include/optionx_cpp/data/trading/TradeTimeZone.hpp @@ -51,10 +51,13 @@ namespace optionx { } /// \brief Creates named-zone context. - /// \param value time-shield zone value. UNKNOWN falls back to UTC. + /// \param value time-shield zone value. UNKNOWN keeps UTC conversion + /// fallback but remains detectable as an unsupported configuration. static TradeTimeZone named(time_shield::TimeZone value) noexcept { if (value == time_shield::UNKNOWN) { - return utc(); + auto result = utc(); + result.m_named_zone_supported = false; + return result; } TradeTimeZone result; @@ -103,9 +106,37 @@ namespace optionx { : 0; } + /// \brief Tries to convert UTC milliseconds to local civil milliseconds. + /// \param utc_ms UTC timestamp in milliseconds. + /// \param local_ms Receives the converted local timestamp on success. + /// \return False when a requested named zone cannot be resolved. + bool try_to_local_ms( + std::int64_t utc_ms, + std::int64_t& local_ms) const noexcept { + if (!m_named_zone_supported) { + return false; + } + if (!is_named_zone()) { + local_ms = utc_ms + m_offset_sec * time_shield::MS_PER_SEC; + return true; + } + + time_shield::tz_t resolved = 0; + if (!time_shield::zone_offset_at_utc_ms( + static_cast(utc_ms), + m_zone, + resolved)) { + return false; + } + local_ms = utc_ms + + static_cast(resolved) * time_shield::MS_PER_SEC; + return true; + } + /// \brief Converts UTC milliseconds to this context's local civil milliseconds. std::int64_t to_local_ms(std::int64_t utc_ms) const noexcept { - return utc_ms + offset_at_utc_ms(utc_ms) * time_shield::MS_PER_SEC; + std::int64_t local_ms = utc_ms; + return try_to_local_ms(utc_ms, local_ms) ? local_ms : utc_ms; } /// \brief Converts local civil milliseconds in this context back to UTC. @@ -151,6 +182,7 @@ namespace optionx { TradeTimeZoneMode m_mode = TradeTimeZoneMode::FIXED_OFFSET; std::int64_t m_offset_sec = 0; time_shield::TimeZone m_zone = time_shield::UTC; + bool m_named_zone_supported = true; }; } // namespace optionx diff --git a/include/optionx_cpp/trading_system.hpp b/include/optionx_cpp/trading_system.hpp index 8625c7a..bf537e5 100644 --- a/include/optionx_cpp/trading_system.hpp +++ b/include/optionx_cpp/trading_system.hpp @@ -10,5 +10,9 @@ #include "trading_system/Identity.hpp" #include "trading_system/TradeSeries.hpp" #include "trading_system/TradeIntent.hpp" +#include "trading_system/PolicyTypes.hpp" +#include "trading_system/DecisionContext.hpp" +#include "trading_system/Filters.hpp" +#include "trading_system/Sizing.hpp" #endif // OPTIONX_HEADER_TRADING_SYSTEM_HPP_INCLUDED diff --git a/include/optionx_cpp/trading_system/DecisionContext.hpp b/include/optionx_cpp/trading_system/DecisionContext.hpp new file mode 100644 index 0000000..4c86fca --- /dev/null +++ b/include/optionx_cpp/trading_system/DecisionContext.hpp @@ -0,0 +1,79 @@ +#pragma once +#ifndef OPTIONX_HEADER_TRADING_SYSTEM_DECISION_CONTEXT_HPP_INCLUDED +#define OPTIONX_HEADER_TRADING_SYSTEM_DECISION_CONTEXT_HPP_INCLUDED + +/// \file DecisionContext.hpp +/// \brief Defines the small value snapshot consumed by Batch 2 policies. + +#include "Identity.hpp" +#include "PolicyTypes.hpp" +#include "TradeIntent.hpp" + +#include +#include +#include + +namespace optionx::trading_system { + +/// \brief Account balance snapshot supplied to pure sizing policies. +struct BalanceSnapshot { + DataAvailability availability = DataAvailability::UNKNOWN; + CurrencyType currency = CurrencyType::UNKNOWN; + std::optional balance; + std::int64_t captured_at_ms = 0; + std::uint64_t revision = 0; +}; + +/// \brief Immutable-by-convention input snapshot for stateless policies. +/// +/// The snapshot owns all strings and scalar values needed by Batch 2. It does +/// not retain broker/platform pointers, and policy evaluation never reads the +/// system clock or calls a live provider. Construct it once and pass it as a +/// const value to policy objects. +struct TradingDecisionContext { + IntentId intent_id = 0; + SignalId signal_id = 0; + std::string symbol; + BridgeId bridge_id = 0; + std::string signal_name; + std::optional evaluation_time_ms; + TradeTimeZone time_zone = TradeTimeZone::utc(); + BalanceSnapshot account; + + static TradingDecisionContext from_signal( + const TradeSignal& signal, + std::int64_t evaluation_time_ms, + TradeTimeZone time_zone, + BalanceSnapshot account = {}) { + TradingDecisionContext result; + result.signal_id = signal.signal_id; + result.symbol = signal.symbol; + result.bridge_id = signal.bridge_id; + result.signal_name = signal.signal_name; + result.evaluation_time_ms = evaluation_time_ms; + result.time_zone = time_zone; + result.account = std::move(account); + return result; + } + + static TradingDecisionContext from_intent( + const TradeIntent& intent, + std::int64_t evaluation_time_ms, + TradeTimeZone time_zone, + BalanceSnapshot account = {}) { + TradingDecisionContext result; + result.intent_id = intent.intent_id; + result.signal_id = intent.origin_signal_id; + result.symbol = intent.request.symbol; + result.bridge_id = intent.request.bridge_id; + result.signal_name = intent.request.signal_name; + result.evaluation_time_ms = evaluation_time_ms; + result.time_zone = time_zone; + result.account = std::move(account); + return result; + } +}; + +} // namespace optionx::trading_system + +#endif // OPTIONX_HEADER_TRADING_SYSTEM_DECISION_CONTEXT_HPP_INCLUDED diff --git a/include/optionx_cpp/trading_system/Filters.hpp b/include/optionx_cpp/trading_system/Filters.hpp new file mode 100644 index 0000000..b49902b --- /dev/null +++ b/include/optionx_cpp/trading_system/Filters.hpp @@ -0,0 +1,281 @@ +#pragma once +#ifndef OPTIONX_HEADER_TRADING_SYSTEM_FILTERS_HPP_INCLUDED +#define OPTIONX_HEADER_TRADING_SYSTEM_FILTERS_HPP_INCLUDED + +/// \file Filters.hpp +/// \brief Defines pure symbol, source and trading-window filters. + +#include "DecisionContext.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace optionx::trading_system { + +/// \brief Half-open local-time interval in seconds from midnight. +/// +/// `start_second` is inclusive and `end_second` is exclusive. An interval may +/// cross midnight when start is greater than end. `{0, 86400}` represents a +/// full day, and a nonzero start may use `0` as its midnight endpoint. Equal +/// endpoints are rejected to avoid an ambiguous empty/full meaning. +struct TradingTimeWindow { + std::uint32_t start_second = 0; + std::uint32_t end_second = 0; + + bool valid() const noexcept { + return start_second < 86400 && + end_second <= 86400 && + start_second != end_second; + } + + bool contains(std::uint32_t second_of_day) const noexcept { + if (!valid() || second_of_day >= 86400) return false; + if (start_second < end_second) { + return second_of_day >= start_second && second_of_day < end_second; + } + return second_of_day >= start_second || second_of_day < end_second; + } +}; + +/// \brief Normalizes a generic symbol by removing separators and uppercasing it. +inline std::string normalize_symbol(std::string symbol) { + symbol.erase(std::remove_if( + symbol.begin(), + symbol.end(), + [](unsigned char ch) { + return ch == '/' || std::isspace(ch) != 0; + }), symbol.end()); + for (auto& ch : symbol) { + ch = static_cast(std::toupper(static_cast(ch))); + } + return symbol; +} + +using SymbolNormalizer = std::function; + +/// \brief Allows or rejects normalized symbols without owning broker knowledge. +/// +/// A caller may provide a broker-specific normalizer, such as the existing +/// Intrade Bar alias helper, and an explicit known-symbol set. The default +/// policy only performs generic separator/case normalization. +class SymbolFilter { +public: + SymbolFilter( + std::vector allowed_symbols = {}, + std::vector denied_symbols = {}, + std::vector known_symbols = {}, + SymbolNormalizer normalizer = {}) + : m_normalizer(std::move(normalizer)) { + if (!m_normalizer) { + m_normalizer = normalize_symbol; + } + for (auto& symbol : allowed_symbols) { + m_allowed_symbols.push_back(m_normalizer(std::move(symbol))); + } + for (auto& symbol : denied_symbols) { + m_denied_symbols.push_back(m_normalizer(std::move(symbol))); + } + for (auto& symbol : known_symbols) { + m_known_symbols.push_back(m_normalizer(std::move(symbol))); + } + } + + PolicyDecision evaluate(const TradingDecisionContext& context) const { + if (context.symbol.empty()) { + return PolicyDecision::unavailable(PolicyReason::MISSING_SYMBOL); + } + + const auto normalized = m_normalizer(context.symbol); + if (normalized.empty()) { + return PolicyDecision::unavailable(PolicyReason::MISSING_SYMBOL); + } + if (!m_known_symbols.empty() && !contains(m_known_symbols, normalized)) { + return PolicyDecision::unavailable(PolicyReason::UNKNOWN_SYMBOL); + } + if (!m_allowed_symbols.empty() && !contains(m_allowed_symbols, normalized)) { + return PolicyDecision::reject(PolicyReason::SYMBOL_NOT_ALLOWED); + } + if (contains(m_denied_symbols, normalized)) { + return PolicyDecision::reject(PolicyReason::SYMBOL_DENIED); + } + return PolicyDecision::allow(); + } + +private: + static bool contains(const std::vector& values, const std::string& value) { + return std::find(values.begin(), values.end(), value) != values.end(); + } + + std::vector m_allowed_symbols; + std::vector m_denied_symbols; + std::vector m_known_symbols; + SymbolNormalizer m_normalizer; +}; + +/// \brief Filters signals by bridge identity and/or exact signal name. +class SourceFilter { +public: + SourceFilter( + std::vector allowed_bridge_ids = {}, + std::vector denied_bridge_ids = {}, + std::vector allowed_signal_names = {}, + std::vector denied_signal_names = {}) + : m_allowed_bridge_ids(std::move(allowed_bridge_ids)), + m_denied_bridge_ids(std::move(denied_bridge_ids)), + m_allowed_signal_names(std::move(allowed_signal_names)), + m_denied_signal_names(std::move(denied_signal_names)) {} + + PolicyDecision evaluate(const TradingDecisionContext& context) const { + if (!m_allowed_bridge_ids.empty() && context.bridge_id == 0) { + return PolicyDecision::unavailable(PolicyReason::MISSING_SOURCE_ID); + } + if (!m_allowed_signal_names.empty() && context.signal_name.empty()) { + return PolicyDecision::unavailable(PolicyReason::MISSING_SIGNAL_NAME); + } + if (contains(m_denied_bridge_ids, context.bridge_id)) { + return PolicyDecision::reject(PolicyReason::SOURCE_DENIED); + } + if (!m_allowed_bridge_ids.empty() && + !contains(m_allowed_bridge_ids, context.bridge_id)) { + return PolicyDecision::reject(PolicyReason::SOURCE_NOT_ALLOWED); + } + if (contains(m_denied_signal_names, context.signal_name)) { + return PolicyDecision::reject(PolicyReason::SIGNAL_NAME_DENIED); + } + if (!m_allowed_signal_names.empty() && + !contains(m_allowed_signal_names, context.signal_name)) { + return PolicyDecision::reject(PolicyReason::SIGNAL_NAME_NOT_ALLOWED); + } + return PolicyDecision::allow(); + } + +private: + static bool contains(const std::vector& values, BridgeId value) { + return std::find(values.begin(), values.end(), value) != values.end(); + } + + static bool contains(const std::vector& values, const std::string& value) { + return std::find(values.begin(), values.end(), value) != values.end(); + } + + std::vector m_allowed_bridge_ids; + std::vector m_denied_bridge_ids; + std::vector m_allowed_signal_names; + std::vector m_denied_signal_names; +}; + +/// \brief Filters local time and weekday using the timezone in the context. +/// +/// Allowed weekdays belong to the day on which an allowed window starts, so +/// the after-midnight tail of an overnight window keeps the previous weekday. +/// Without allowed windows, weekday checks use the current local calendar day. +/// Denied windows take precedence over allowed windows and weekdays. +class TradingWindowFilter { +public: + TradingWindowFilter( + std::vector allowed_windows = {}, + std::vector denied_windows = {}, + std::vector allowed_weekdays = {}) + : m_allowed_windows(std::move(allowed_windows)), + m_denied_windows(std::move(denied_windows)), + m_allowed_weekdays(std::move(allowed_weekdays)) { + for (const auto& window : m_allowed_windows) m_valid &= window.valid(); + for (const auto& window : m_denied_windows) m_valid &= window.valid(); + for (const auto weekday : m_allowed_weekdays) { + if (weekday < time_shield::SUN || weekday > time_shield::SAT) { + m_valid = false; + } + } + } + + PolicyDecision evaluate(const TradingDecisionContext& context) const { + if (!m_valid) { + return PolicyDecision::unavailable(PolicyReason::INVALID_CONFIGURATION); + } + if (!context.evaluation_time_ms.has_value()) { + return PolicyDecision::unavailable(PolicyReason::MISSING_EVALUATION_TIME); + } + + std::int64_t local_ms = 0; + if (!context.time_zone.try_to_local_ms( + *context.evaluation_time_ms, + local_ms)) { + return PolicyDecision::unavailable(PolicyReason::INVALID_CONFIGURATION); + } + const auto local_sec = time_shield::ms_to_sec(local_ms); + const auto second_of_day = static_cast( + time_shield::sec_of_day(local_sec)); + const auto weekday = time_shield::weekday_of_ts(local_sec); + + if (contains(m_denied_windows, second_of_day)) { + return PolicyDecision::reject(PolicyReason::TIME_DENIED); + } + + if (m_allowed_windows.empty()) { + if (!m_allowed_weekdays.empty() && + !contains(m_allowed_weekdays, weekday)) { + return PolicyDecision::reject(PolicyReason::WEEKDAY_NOT_ALLOWED); + } + return PolicyDecision::allow(); + } + + bool matches_time = false; + for (const auto& window : m_allowed_windows) { + if (!window.contains(second_of_day)) continue; + matches_time = true; + + auto window_weekday = weekday; + if (window.start_second > window.end_second && + second_of_day < window.end_second) { + window_weekday = previous_weekday(weekday); + } + if (m_allowed_weekdays.empty() || + contains(m_allowed_weekdays, window_weekday)) { + return PolicyDecision::allow(); + } + } + return PolicyDecision::reject( + matches_time + ? PolicyReason::WEEKDAY_NOT_ALLOWED + : PolicyReason::TIME_NOT_ALLOWED); + } + +private: + static bool contains( + const std::vector& windows, + std::uint32_t second_of_day) noexcept { + return std::any_of( + windows.begin(), windows.end(), + [second_of_day](const auto& window) { + return window.contains(second_of_day); + }); + } + + static bool contains( + const std::vector& weekdays, + time_shield::Weekday weekday) noexcept { + return std::find(weekdays.begin(), weekdays.end(), weekday) != weekdays.end(); + } + + static time_shield::Weekday previous_weekday( + time_shield::Weekday weekday) noexcept { + return weekday == time_shield::SUN + ? time_shield::SAT + : static_cast( + static_cast(weekday) - 1); + } + + std::vector m_allowed_windows; + std::vector m_denied_windows; + std::vector m_allowed_weekdays; + bool m_valid = true; +}; + +} // namespace optionx::trading_system + +#endif // OPTIONX_HEADER_TRADING_SYSTEM_FILTERS_HPP_INCLUDED diff --git a/include/optionx_cpp/trading_system/Identity.hpp b/include/optionx_cpp/trading_system/Identity.hpp index cfc55a7..98f18c1 100644 --- a/include/optionx_cpp/trading_system/Identity.hpp +++ b/include/optionx_cpp/trading_system/Identity.hpp @@ -5,10 +5,13 @@ /// \file Identity.hpp /// \brief Defines stable identities and cardinality policies for trading-system contracts. +#include #include #include #include #include +#include +#include namespace optionx::trading_system { @@ -35,47 +38,79 @@ enum class ExecutionCardinality { 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"; +namespace detail { + +template +using EnumTable = std::array, N>; + +inline constexpr EnumTable intent_status_table{{ + {IntentStatus::PROPOSED, "PROPOSED"}, + {IntentStatus::REJECTED, "REJECTED"}, + {IntentStatus::ADMITTED, "ADMITTED"}, + {IntentStatus::SUBMITTING, "SUBMITTING"}, + {IntentStatus::EXECUTION_UNKNOWN, "EXECUTION_UNKNOWN"}, + {IntentStatus::EXECUTED, "EXECUTED"}, + {IntentStatus::SETTLED, "SETTLED"}, + {IntentStatus::CANCELLED, "CANCELLED"} +}}; + +inline constexpr EnumTable execution_cardinality_table{{ + {ExecutionCardinality::ZERO_OR_ONE, "ZERO_OR_ONE"}, + {ExecutionCardinality::EXACTLY_ONE, "EXACTLY_ONE"}, + {ExecutionCardinality::MULTIPLE, "MULTIPLE"}, + {ExecutionCardinality::SPLIT_GROUP, "SPLIT_GROUP"} +}}; + +template +constexpr std::string_view enum_to_string( + E value, + const EnumTable& table) noexcept { + for (const auto& entry : table) { + if (entry.first == value) return entry.second; } 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"; +template +bool enum_from_string( + std::string_view text, + const EnumTable& table, + E& result) noexcept { + for (const auto& entry : table) { + if (entry.second == text) { + result = entry.first; + return true; + } } - return "UNKNOWN"; + return false; +} + +} // namespace detail + +inline const char* to_string(IntentStatus value) noexcept { + return detail::enum_to_string(value, detail::intent_status_table).data(); +} + +inline const char* to_string(ExecutionCardinality value) noexcept { + return detail::enum_to_string(value, detail::execution_cardinality_table).data(); } 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; + IntentStatus result{}; + if (detail::enum_from_string( + std::string_view(value), detail::intent_status_table, result)) { + return result; + } 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; +inline ExecutionCardinality execution_cardinality_from_string( + const std::string& value) { + ExecutionCardinality result{}; + if (detail::enum_from_string( + std::string_view(value), detail::execution_cardinality_table, result)) { + return result; + } throw std::invalid_argument("Unknown trading-system execution cardinality: " + value); } @@ -101,6 +136,13 @@ struct ExecutionGroupIdentity { std::uint32_t expected_members = 0; /// \brief Checks whether this identity is compatible with a cardinality policy. + /// + /// Batch 1 only validates the fixed identity shape. `MULTIPLE` means that + /// several independent physical executions are allowed, while + /// `SPLIT_GROUP` means that several physical executions share one logical + /// group and remain pending until the group policy settles them. The + /// execution-group runtime must define dynamic fan-out and final member + /// accounting before either policy is used for admission. bool valid_for(ExecutionCardinality cardinality) const noexcept { switch (cardinality) { case ExecutionCardinality::ZERO_OR_ONE: diff --git a/include/optionx_cpp/trading_system/PolicyTypes.hpp b/include/optionx_cpp/trading_system/PolicyTypes.hpp new file mode 100644 index 0000000..7cb93d9 --- /dev/null +++ b/include/optionx_cpp/trading_system/PolicyTypes.hpp @@ -0,0 +1,320 @@ +#pragma once +#ifndef OPTIONX_HEADER_TRADING_SYSTEM_POLICY_TYPES_HPP_INCLUDED +#define OPTIONX_HEADER_TRADING_SYSTEM_POLICY_TYPES_HPP_INCLUDED + +/// \file PolicyTypes.hpp +/// \brief Defines deterministic policy decisions and fixed-point money values. + +#include +#include +#include +#include +#include + +namespace optionx::trading_system { + +enum class DataAvailability { + SUPPORTED, + UNAVAILABLE, + STALE, + UNKNOWN +}; + +enum class PolicyDecisionKind { + ALLOW, + REJECT, + UNAVAILABLE +}; + +enum class PolicyReason { + NONE, + MATCHED, + INVALID_CONTEXT, + INVALID_CONFIGURATION, + MISSING_SYMBOL, + UNKNOWN_SYMBOL, + SYMBOL_NOT_ALLOWED, + SYMBOL_DENIED, + MISSING_SOURCE_ID, + SOURCE_NOT_ALLOWED, + SOURCE_DENIED, + MISSING_SIGNAL_NAME, + SIGNAL_NAME_NOT_ALLOWED, + SIGNAL_NAME_DENIED, + MISSING_EVALUATION_TIME, + WEEKDAY_NOT_ALLOWED, + TIME_NOT_ALLOWED, + TIME_DENIED +}; + +inline const char* to_string(DataAvailability value) noexcept { + switch (value) { + case DataAvailability::SUPPORTED: return "SUPPORTED"; + case DataAvailability::UNAVAILABLE: return "UNAVAILABLE"; + case DataAvailability::STALE: return "STALE"; + case DataAvailability::UNKNOWN: return "UNKNOWN"; + } + return "UNKNOWN"; +} + +inline const char* to_string(PolicyDecisionKind value) noexcept { + switch (value) { + case PolicyDecisionKind::ALLOW: return "ALLOW"; + case PolicyDecisionKind::REJECT: return "REJECT"; + case PolicyDecisionKind::UNAVAILABLE: return "UNAVAILABLE"; + } + return "UNAVAILABLE"; +} + +inline const char* to_string(PolicyReason value) noexcept { + switch (value) { + case PolicyReason::NONE: return "NONE"; + case PolicyReason::MATCHED: return "MATCHED"; + case PolicyReason::INVALID_CONTEXT: return "INVALID_CONTEXT"; + case PolicyReason::INVALID_CONFIGURATION: return "INVALID_CONFIGURATION"; + case PolicyReason::MISSING_SYMBOL: return "MISSING_SYMBOL"; + case PolicyReason::UNKNOWN_SYMBOL: return "UNKNOWN_SYMBOL"; + case PolicyReason::SYMBOL_NOT_ALLOWED: return "SYMBOL_NOT_ALLOWED"; + case PolicyReason::SYMBOL_DENIED: return "SYMBOL_DENIED"; + case PolicyReason::MISSING_SOURCE_ID: return "MISSING_SOURCE_ID"; + case PolicyReason::SOURCE_NOT_ALLOWED: return "SOURCE_NOT_ALLOWED"; + case PolicyReason::SOURCE_DENIED: return "SOURCE_DENIED"; + case PolicyReason::MISSING_SIGNAL_NAME: return "MISSING_SIGNAL_NAME"; + case PolicyReason::SIGNAL_NAME_NOT_ALLOWED: return "SIGNAL_NAME_NOT_ALLOWED"; + case PolicyReason::SIGNAL_NAME_DENIED: return "SIGNAL_NAME_DENIED"; + case PolicyReason::MISSING_EVALUATION_TIME: return "MISSING_EVALUATION_TIME"; + case PolicyReason::WEEKDAY_NOT_ALLOWED: return "WEEKDAY_NOT_ALLOWED"; + case PolicyReason::TIME_NOT_ALLOWED: return "TIME_NOT_ALLOWED"; + case PolicyReason::TIME_DENIED: return "TIME_DENIED"; + } + return "INVALID_CONTEXT"; +} + +struct PolicyDecision { + PolicyDecisionKind kind = PolicyDecisionKind::UNAVAILABLE; + PolicyReason reason = PolicyReason::INVALID_CONTEXT; + + static PolicyDecision allow(PolicyReason reason = PolicyReason::MATCHED) noexcept { + return {PolicyDecisionKind::ALLOW, reason}; + } + + static PolicyDecision reject(PolicyReason reason) noexcept { + return {PolicyDecisionKind::REJECT, reason}; + } + + static PolicyDecision unavailable(PolicyReason reason) noexcept { + return {PolicyDecisionKind::UNAVAILABLE, reason}; + } + + bool is_allowed() const noexcept { + return kind == PolicyDecisionKind::ALLOW; + } +}; + +enum class SizingDecisionKind { + SIZED, + REJECTED, + UNAVAILABLE +}; + +enum class SizingReason { + NONE, + SIZED, + INVALID_CONFIGURATION, + INVALID_AMOUNT, + INVALID_PERCENT, + UNKNOWN_CURRENCY, + CURRENCY_MISMATCH, + BALANCE_UNAVAILABLE, + BALANCE_STALE, + BALANCE_UNKNOWN, + MISSING_BALANCE, + ZERO_BALANCE, + NEGATIVE_BALANCE, + ZERO_RESULT, + PRECISION_MISMATCH, + NUMERIC_OVERFLOW +}; + +inline const char* to_string(SizingDecisionKind value) noexcept { + switch (value) { + case SizingDecisionKind::SIZED: return "SIZED"; + case SizingDecisionKind::REJECTED: return "REJECTED"; + case SizingDecisionKind::UNAVAILABLE: return "UNAVAILABLE"; + } + return "UNAVAILABLE"; +} + +inline const char* to_string(SizingReason value) noexcept { + switch (value) { + case SizingReason::NONE: return "NONE"; + case SizingReason::SIZED: return "SIZED"; + case SizingReason::INVALID_CONFIGURATION: return "INVALID_CONFIGURATION"; + case SizingReason::INVALID_AMOUNT: return "INVALID_AMOUNT"; + case SizingReason::INVALID_PERCENT: return "INVALID_PERCENT"; + case SizingReason::UNKNOWN_CURRENCY: return "UNKNOWN_CURRENCY"; + case SizingReason::CURRENCY_MISMATCH: return "CURRENCY_MISMATCH"; + case SizingReason::BALANCE_UNAVAILABLE: return "BALANCE_UNAVAILABLE"; + case SizingReason::BALANCE_STALE: return "BALANCE_STALE"; + case SizingReason::BALANCE_UNKNOWN: return "BALANCE_UNKNOWN"; + case SizingReason::MISSING_BALANCE: return "MISSING_BALANCE"; + case SizingReason::ZERO_BALANCE: return "ZERO_BALANCE"; + case SizingReason::NEGATIVE_BALANCE: return "NEGATIVE_BALANCE"; + case SizingReason::ZERO_RESULT: return "ZERO_RESULT"; + case SizingReason::PRECISION_MISMATCH: return "PRECISION_MISMATCH"; + case SizingReason::NUMERIC_OVERFLOW: return "NUMERIC_OVERFLOW"; + } + return "INVALID_CONFIGURATION"; +} + +namespace detail { + +inline constexpr std::uint8_t max_money_precision = 18; + +inline constexpr std::uint64_t power_of_ten(std::uint8_t precision) noexcept { + std::uint64_t result = 1; + for (std::uint8_t i = 0; i < precision; ++i) result *= 10; + return result; +} + +} // namespace detail + +/// \brief A non-negative or signed fixed-point monetary value. +/// +/// `units` stores the amount multiplied by 10^`precision`. Policies operate on +/// this representation and never round a broker amount implicitly from double. +struct MoneyAmount { + std::int64_t units = 0; + std::uint8_t precision = 2; + CurrencyType currency = CurrencyType::UNKNOWN; + + bool valid_metadata() const noexcept { + return precision <= detail::max_money_precision && + currency != CurrencyType::UNKNOWN; + } + + /// \brief Parses a decimal amount without discarding significant digits. + /// + /// Extra fractional digits are accepted only when they are zero. This + /// makes conversion to fixed precision explicit instead of silently + /// applying a rounding rule. + static std::optional from_decimal( + const std::string& text, + CurrencyType currency, + std::uint8_t precision = 2) { + if (text.empty() || precision > detail::max_money_precision || + currency == CurrencyType::UNKNOWN) { + return std::nullopt; + } + + std::size_t position = 0; + bool negative = false; + if (text[position] == '+' || text[position] == '-') { + negative = text[position] == '-'; + ++position; + if (position == text.size()) return std::nullopt; + } + + const auto dot = text.find('.', position); + if (dot != std::string::npos && text.find('.', dot + 1) != std::string::npos) { + return std::nullopt; + } + + const auto integer_end = dot == std::string::npos ? text.size() : dot; + const auto fraction_begin = dot == std::string::npos ? text.size() : dot + 1; + const auto fraction_size = text.size() - fraction_begin; + if (integer_end == position && fraction_size == 0) return std::nullopt; + + std::uint64_t whole = 0; + for (std::size_t i = position; i < integer_end; ++i) { + const auto ch = static_cast(text[i]); + if (ch < '0' || ch > '9') return std::nullopt; + const auto digit = static_cast(ch - '0'); + if (whole > (std::numeric_limits::max() - digit) / 10) { + return std::nullopt; + } + whole = whole * 10 + digit; + } + + const auto stored_fraction_size = std::min(fraction_size, precision); + std::uint64_t fraction = 0; + for (std::size_t i = 0; i < fraction_size; ++i) { + const auto ch = static_cast(text[fraction_begin + i]); + if (ch < '0' || ch > '9') return std::nullopt; + if (i < stored_fraction_size) { + fraction = fraction * 10 + static_cast(ch - '0'); + } else if (ch != '0') { + return std::nullopt; + } + } + for (std::size_t i = stored_fraction_size; i < precision; ++i) { + fraction *= 10; + } + + const auto factor = detail::power_of_ten(precision); + const auto max_positive = static_cast( + (std::numeric_limits::max)()); + const auto max_magnitude = negative ? max_positive + 1 : max_positive; + if (whole > (max_magnitude - fraction) / factor) return std::nullopt; + const auto magnitude = whole * factor + fraction; + + MoneyAmount result; + result.precision = precision; + result.currency = currency; + if (!negative) { + result.units = static_cast(magnitude); + } else if (magnitude == max_positive + 1) { + result.units = (std::numeric_limits::min)(); + } else { + result.units = -static_cast(magnitude); + } + return result; + } + + /// \brief Formats the fixed-point value without scientific notation. + std::string to_decimal() const { + if (!valid_metadata()) return {}; + + const bool negative = units < 0; + const auto magnitude = negative + ? static_cast(-(units + 1)) + 1 + : static_cast(units); + const auto factor = detail::power_of_ten(precision); + const auto whole = magnitude / factor; + const auto fraction = magnitude % factor; + std::string result = negative ? "-" : ""; + result += std::to_string(whole); + if (precision == 0) return result; + result += "."; + auto fraction_text = std::to_string(fraction); + result.append(precision - fraction_text.size(), '0'); + result += fraction_text; + return result; + } +}; + +struct SizingDecision { + SizingDecisionKind kind = SizingDecisionKind::UNAVAILABLE; + SizingReason reason = SizingReason::INVALID_CONFIGURATION; + std::optional amount; + + static SizingDecision sized(const MoneyAmount& amount) { + return {SizingDecisionKind::SIZED, SizingReason::SIZED, amount}; + } + + static SizingDecision rejected(SizingReason reason) noexcept { + return {SizingDecisionKind::REJECTED, reason, std::nullopt}; + } + + static SizingDecision unavailable(SizingReason reason) noexcept { + return {SizingDecisionKind::UNAVAILABLE, reason, std::nullopt}; + } + + bool is_sized() const noexcept { + return kind == SizingDecisionKind::SIZED && amount.has_value(); + } +}; + +} // namespace optionx::trading_system + +#endif // OPTIONX_HEADER_TRADING_SYSTEM_POLICY_TYPES_HPP_INCLUDED diff --git a/include/optionx_cpp/trading_system/Sizing.hpp b/include/optionx_cpp/trading_system/Sizing.hpp new file mode 100644 index 0000000..c4fcf67 --- /dev/null +++ b/include/optionx_cpp/trading_system/Sizing.hpp @@ -0,0 +1,205 @@ +#pragma once +#ifndef OPTIONX_HEADER_TRADING_SYSTEM_SIZING_HPP_INCLUDED +#define OPTIONX_HEADER_TRADING_SYSTEM_SIZING_HPP_INCLUDED + +/// \file Sizing.hpp +/// \brief Defines pure fixed-stake and percentage sizing policies. + +#include "DecisionContext.hpp" + +#include +#include +#include +#include +#include + +namespace optionx::trading_system { + +enum class MoneyRounding { + DOWN, + HALF_UP, + UP +}; + +namespace detail { + +inline std::optional percentage_units( + std::int64_t balance_units, + std::uint8_t balance_precision, + std::uint8_t output_precision, + std::int64_t basis_points, + MoneyRounding rounding) noexcept { + if (balance_units < 0 || balance_precision > max_money_precision || + output_precision < balance_precision || + output_precision > max_money_precision || + basis_points <= 0 || basis_points > 10000) { + return std::nullopt; + } + + const auto precision_delta = static_cast( + output_precision - balance_precision); + const auto max_value = static_cast( + (std::numeric_limits::max)()); + + // Four decimal percentage places are represented by basis points. When + // the output has at least four extra decimal places, the denominator is + // cancelled before multiplication and the result is exact. + if (precision_delta >= 4) { + const auto factor = power_of_ten( + static_cast(precision_delta - 4)); + const auto multiplier = static_cast(basis_points) * factor; + if (static_cast(balance_units) > max_value / multiplier) { + return std::nullopt; + } + return static_cast( + static_cast(balance_units) * multiplier); + } + + const auto denominator = power_of_ten( + static_cast(4 - precision_delta)); + const auto value = static_cast(balance_units); + const auto whole = value / denominator; + const auto remainder = value % denominator; + if (whole > max_value / static_cast(basis_points)) { + return std::nullopt; + } + + auto result = whole * static_cast(basis_points); + const auto remainder_product = remainder * static_cast(basis_points); + const auto integral_remainder = remainder_product / denominator; + if (result > max_value - integral_remainder) return std::nullopt; + result += integral_remainder; + const auto fractional = remainder_product % denominator; + if (rounding == MoneyRounding::UP && fractional != 0) { + if (result == max_value) return std::nullopt; + ++result; + } else if (rounding == MoneyRounding::HALF_UP && + fractional >= denominator / 2) { + if (result == max_value) return std::nullopt; + ++result; + } + return static_cast(result); +} + +} // namespace detail + +/// \brief Returns a configured monetary amount without reading account state. +class FixedStake { +public: + explicit FixedStake(MoneyAmount amount) : m_amount(std::move(amount)) {} + + SizingDecision evaluate(const TradingDecisionContext&) const { + if (!m_amount.valid_metadata() || m_amount.units <= 0) { + return SizingDecision::rejected(SizingReason::INVALID_AMOUNT); + } + return SizingDecision::sized(m_amount); + } + +private: + MoneyAmount m_amount; +}; + +/// \brief Calculates a fixed percentage of a supported balance snapshot. +class PercentOfBalance { +public: + explicit PercentOfBalance( + double percent, + std::uint8_t output_precision = 2, + MoneyRounding rounding = MoneyRounding::DOWN) + : m_output_precision(output_precision), m_rounding(rounding) { + if (!std::isfinite(percent) || percent <= 0.0 || percent > 100.0) return; + const auto basis_points = std::round(percent * 100.0); + if (!std::isfinite(basis_points) || basis_points < 1.0 || basis_points > 10000.0) { + return; + } + m_basis_points = static_cast(basis_points); + m_valid = true; + } + + static PercentOfBalance from_basis_points( + std::int64_t basis_points, + std::uint8_t output_precision = 2, + MoneyRounding rounding = MoneyRounding::DOWN) noexcept { + PercentOfBalance result; + result.m_basis_points = basis_points; + result.m_output_precision = output_precision; + result.m_rounding = rounding; + result.m_valid = basis_points > 0 && basis_points <= 10000; + return result; + } + + bool valid() const noexcept { + return m_valid && m_output_precision <= detail::max_money_precision; + } + + SizingDecision evaluate(const TradingDecisionContext& context) const { + if (!valid()) { + return SizingDecision::rejected(SizingReason::INVALID_PERCENT); + } + switch (context.account.availability) { + case DataAvailability::SUPPORTED: break; + case DataAvailability::UNAVAILABLE: + return SizingDecision::unavailable(SizingReason::BALANCE_UNAVAILABLE); + case DataAvailability::STALE: + return SizingDecision::unavailable(SizingReason::BALANCE_STALE); + case DataAvailability::UNKNOWN: + return SizingDecision::unavailable(SizingReason::BALANCE_UNKNOWN); + } + if (!context.account.balance.has_value()) { + return SizingDecision::unavailable(SizingReason::MISSING_BALANCE); + } + + const auto& balance = *context.account.balance; + if (balance.precision > detail::max_money_precision) { + return SizingDecision::rejected(SizingReason::INVALID_AMOUNT); + } + if (balance.currency == CurrencyType::UNKNOWN) { + return SizingDecision::rejected(SizingReason::UNKNOWN_CURRENCY); + } + if (!balance.valid_metadata()) { + return SizingDecision::rejected(SizingReason::INVALID_AMOUNT); + } + if (context.account.currency == CurrencyType::UNKNOWN) { + return SizingDecision::unavailable(SizingReason::UNKNOWN_CURRENCY); + } + if (context.account.currency != balance.currency) { + return SizingDecision::rejected(SizingReason::CURRENCY_MISMATCH); + } + if (balance.units < 0) { + return SizingDecision::rejected(SizingReason::NEGATIVE_BALANCE); + } + if (balance.units == 0) { + return SizingDecision::rejected(SizingReason::ZERO_BALANCE); + } + + if (m_output_precision < balance.precision) { + return SizingDecision::rejected(SizingReason::PRECISION_MISMATCH); + } + const auto calculated = detail::percentage_units( + balance.units, + balance.precision, + m_output_precision, + m_basis_points, + m_rounding); + if (!calculated.has_value()) { + return SizingDecision::rejected(SizingReason::NUMERIC_OVERFLOW); + } + if (*calculated <= 0) { + return SizingDecision::rejected(SizingReason::ZERO_RESULT); + } + return SizingDecision::sized( + MoneyAmount{*calculated, m_output_precision, balance.currency}); + } + +private: + PercentOfBalance() = default; + + std::int64_t m_basis_points = 0; + std::uint8_t m_output_precision = 2; + MoneyRounding m_rounding = MoneyRounding::DOWN; + bool m_valid = false; +}; + +} // namespace optionx::trading_system + +#endif // OPTIONX_HEADER_TRADING_SYSTEM_SIZING_HPP_INCLUDED diff --git a/include/optionx_cpp/trading_system/TradeIntent.hpp b/include/optionx_cpp/trading_system/TradeIntent.hpp index 7b72622..3e2fc0b 100644 --- a/include/optionx_cpp/trading_system/TradeIntent.hpp +++ b/include/optionx_cpp/trading_system/TradeIntent.hpp @@ -8,6 +8,7 @@ #include "Identity.hpp" #include +#include #include #include @@ -45,8 +46,15 @@ struct TradeIntent { return state_revision == expected_revision; } + /// \brief Advances the local revision after an expected revision check. + /// + /// This is a sequential guard for one state owner. It is not thread-safe + /// and does not provide a persistent compare-and-swap operation. Callers + /// must serialize access to this DTO; durable state transitions belong to + /// the owning runtime or persistence adapter. bool advance_revision(std::uint64_t expected_revision) noexcept { if (!revision_matches(expected_revision)) return false; + if (state_revision == (std::numeric_limits::max)()) return false; ++state_revision; return true; } diff --git a/include/optionx_cpp/trading_system/TradeSeries.hpp b/include/optionx_cpp/trading_system/TradeSeries.hpp index 9078522..d153732 100644 --- a/include/optionx_cpp/trading_system/TradeSeries.hpp +++ b/include/optionx_cpp/trading_system/TradeSeries.hpp @@ -8,6 +8,7 @@ #include "Identity.hpp" #include +#include #include namespace optionx::trading_system { @@ -31,8 +32,15 @@ struct TradeSeries { return state_revision == expected_revision; } + /// \brief Advances the local revision after an expected revision check. + /// + /// This is a sequential guard for one state owner. It is not thread-safe + /// and does not provide a persistent compare-and-swap operation. Callers + /// must serialize access to this DTO; durable state transitions belong to + /// the owning runtime or persistence adapter. bool advance_revision(std::uint64_t expected_revision) noexcept { if (!revision_matches(expected_revision)) return false; + if (state_revision == (std::numeric_limits::max)()) return false; ++state_revision; return true; } diff --git a/tests/trading_system_batch1_test.cpp b/tests/trading_system_batch1_test.cpp index 65c6341..1b073a1 100644 --- a/tests/trading_system_batch1_test.cpp +++ b/tests/trading_system_batch1_test.cpp @@ -2,6 +2,11 @@ #include +#include +#include +#include +#include + namespace { optionx::trading_system::TradeIntent make_intent() { @@ -88,6 +93,21 @@ TEST(TradingSystemBatch1, RevisionMustMatchBeforeAdvance) { EXPECT_FALSE(series.advance_revision(0)); } +TEST(TradingSystemBatch1, RevisionOverflowIsRejected) { + auto intent = make_intent(); + intent.state_revision = (std::numeric_limits::max)(); + + EXPECT_TRUE(intent.revision_matches(intent.state_revision)); + EXPECT_FALSE(intent.advance_revision(intent.state_revision)); + EXPECT_EQ(intent.state_revision, (std::numeric_limits::max)()); + + optionx::trading_system::TradeSeries series; + series.series_id = 3; + series.state_revision = (std::numeric_limits::max)(); + EXPECT_FALSE(series.advance_revision(series.state_revision)); + EXPECT_EQ(series.state_revision, (std::numeric_limits::max)()); +} + TEST(TradingSystemBatch1, CardinalityPoliciesValidateIdentityInvariants) { using optionx::trading_system::ExecutionCardinality; using optionx::trading_system::ExecutionGroupIdentity; @@ -127,6 +147,60 @@ TEST(TradingSystemBatch1, ExistingRequestJsonContractRemainsAvailable) { EXPECT_DOUBLE_EQ(encoded.at("amount").get(), 10.0); } +TEST(TradingSystemBatch1, EnumConversionsRoundTripThroughStableNames) { + using optionx::trading_system::ExecutionCardinality; + using optionx::trading_system::IntentStatus; + + const std::array, 8> statuses{{ + {IntentStatus::PROPOSED, "PROPOSED"}, + {IntentStatus::REJECTED, "REJECTED"}, + {IntentStatus::ADMITTED, "ADMITTED"}, + {IntentStatus::SUBMITTING, "SUBMITTING"}, + {IntentStatus::EXECUTION_UNKNOWN, "EXECUTION_UNKNOWN"}, + {IntentStatus::EXECUTED, "EXECUTED"}, + {IntentStatus::SETTLED, "SETTLED"}, + {IntentStatus::CANCELLED, "CANCELLED"} + }}; + for (const auto& entry : statuses) { + EXPECT_STREQ(optionx::trading_system::to_string(entry.first), entry.second); + EXPECT_EQ( + optionx::trading_system::intent_status_from_string(std::string(entry.second)), + entry.first); + } + + const std::array, 4> cardinalities{{ + {ExecutionCardinality::ZERO_OR_ONE, "ZERO_OR_ONE"}, + {ExecutionCardinality::EXACTLY_ONE, "EXACTLY_ONE"}, + {ExecutionCardinality::MULTIPLE, "MULTIPLE"}, + {ExecutionCardinality::SPLIT_GROUP, "SPLIT_GROUP"} + }}; + for (const auto& entry : cardinalities) { + EXPECT_STREQ(optionx::trading_system::to_string(entry.first), entry.second); + EXPECT_EQ( + optionx::trading_system::execution_cardinality_from_string( + std::string(entry.second)), + entry.first); + } +} + +TEST(TradingSystemBatch1, EnumConversionsPreserveUnknownValueBehavior) { + using optionx::trading_system::ExecutionCardinality; + using optionx::trading_system::IntentStatus; + + EXPECT_STREQ( + optionx::trading_system::to_string(static_cast(255)), + "UNKNOWN"); + EXPECT_STREQ( + optionx::trading_system::to_string(static_cast(255)), + "UNKNOWN"); + EXPECT_THROW( + optionx::trading_system::intent_status_from_string("NOT_A_STATUS"), + std::invalid_argument); + EXPECT_THROW( + optionx::trading_system::execution_cardinality_from_string("NOT_A_POLICY"), + std::invalid_argument); +} + } // namespace int main(int argc, char** argv) { diff --git a/tests/trading_system_batch2_test.cpp b/tests/trading_system_batch2_test.cpp new file mode 100644 index 0000000..13d99c0 --- /dev/null +++ b/tests/trading_system_batch2_test.cpp @@ -0,0 +1,473 @@ +#include + +#include + +#include +#include + +namespace { + +using optionx::CurrencyType; +using optionx::trading_system::BalanceSnapshot; +using optionx::trading_system::DataAvailability; +using optionx::trading_system::MoneyAmount; +using optionx::trading_system::MoneyRounding; +using optionx::trading_system::PolicyDecisionKind; +using optionx::trading_system::PolicyReason; +using optionx::trading_system::SizingDecisionKind; +using optionx::trading_system::SizingReason; +using optionx::trading_system::TradeIntent; +using optionx::trading_system::TradingDecisionContext; +using optionx::trading_system::TradingTimeWindow; + +MoneyAmount money(const char* value, CurrencyType currency = CurrencyType::USD) { + const auto parsed = MoneyAmount::from_decimal(value, currency, 2); + EXPECT_TRUE(parsed.has_value()); + return parsed.value_or(MoneyAmount{}); +} + +TradingDecisionContext make_context() { + TradingDecisionContext context; + context.symbol = "EUR/USD"; + context.bridge_id = 7; + context.signal_name = "alpha"; + context.evaluation_time_ms = + time_shield::to_timestamp(2026, 10, 28, 9, 30, 0) * 1000; + context.time_zone = optionx::TradeTimeZone::utc(); + context.account.availability = DataAvailability::SUPPORTED; + context.account.currency = CurrencyType::USD; + context.account.balance = money("100.00"); + return context; +} + +TEST(TradingSystemBatch2, SymbolFilterNormalizesAliasesAndRejectsUnknowns) { + const optionx::trading_system::SymbolFilter filter( + {"eurusd"}, {}, {"EURUSD", "EURGBP"}); + const auto context = make_context(); + + const auto allowed = filter.evaluate(context); + EXPECT_EQ(allowed.kind, PolicyDecisionKind::ALLOW); + EXPECT_EQ(allowed.reason, PolicyReason::MATCHED); + + auto denied_context = context; + denied_context.symbol = "EUR/GBP"; + EXPECT_EQ(filter.evaluate(denied_context).kind, PolicyDecisionKind::REJECT); + EXPECT_EQ(filter.evaluate(denied_context).reason, PolicyReason::SYMBOL_NOT_ALLOWED); + + denied_context.symbol = "UNKNOWN/PAIR"; + EXPECT_EQ(filter.evaluate(denied_context).kind, PolicyDecisionKind::UNAVAILABLE); + EXPECT_EQ(filter.evaluate(denied_context).reason, PolicyReason::UNKNOWN_SYMBOL); +} + +TEST(TradingSystemBatch2, SourceFilterCombinesBridgeAndSignalNameRules) { + const optionx::trading_system::SourceFilter filter( + {7}, {}, {"alpha"}, {"blocked"}); + const auto context = make_context(); + EXPECT_EQ(filter.evaluate(context).kind, PolicyDecisionKind::ALLOW); + + auto source = context; + source.bridge_id = 8; + EXPECT_EQ(filter.evaluate(source).reason, PolicyReason::SOURCE_NOT_ALLOWED); + + auto signal = context; + signal.signal_name = "blocked"; + EXPECT_EQ(filter.evaluate(signal).reason, PolicyReason::SIGNAL_NAME_DENIED); + + source.bridge_id = 0; + EXPECT_EQ(filter.evaluate(source).kind, PolicyDecisionKind::UNAVAILABLE); + EXPECT_EQ(filter.evaluate(source).reason, PolicyReason::MISSING_SOURCE_ID); +} + +TEST(TradingSystemBatch2, TradingWindowFilterHandlesWeekdaysAndOvernightWindows) { + const optionx::trading_system::TradingWindowFilter daytime( + {{9 * 3600, 10 * 3600}}, {}, {time_shield::WED}); + const auto context = make_context(); + EXPECT_EQ(daytime.evaluate(context).kind, PolicyDecisionKind::ALLOW); + + auto after_window = context; + after_window.evaluation_time_ms = + time_shield::to_timestamp(2026, 10, 28, 10, 0, 0) * 1000; + EXPECT_EQ(daytime.evaluate(after_window).reason, PolicyReason::TIME_NOT_ALLOWED); + + const optionx::trading_system::TradingWindowFilter overnight( + {{22 * 3600, 2 * 3600}}); + auto late = context; + late.evaluation_time_ms = + time_shield::to_timestamp(2026, 10, 28, 23, 30, 0) * 1000; + EXPECT_EQ(overnight.evaluate(late).kind, PolicyDecisionKind::ALLOW); + + auto early = context; + early.evaluation_time_ms = + time_shield::to_timestamp(2026, 10, 29, 1, 30, 0) * 1000; + EXPECT_EQ(overnight.evaluate(early).kind, PolicyDecisionKind::ALLOW); +} + +TEST(TradingSystemBatch2, OvernightWeekdayBelongsToWindowStartDay) { + const optionx::trading_system::TradingWindowFilter filter( + {{22 * 3600, 2 * 3600}}, {}, {time_shield::MON}); + auto context = make_context(); + + context.evaluation_time_ms = + time_shield::to_timestamp(2026, 11, 2, 23, 0, 0) * 1000; + EXPECT_EQ(filter.evaluate(context).kind, PolicyDecisionKind::ALLOW); + + context.evaluation_time_ms = + time_shield::to_timestamp(2026, 11, 3, 1, 0, 0) * 1000; + EXPECT_EQ(filter.evaluate(context).kind, PolicyDecisionKind::ALLOW); + + context.evaluation_time_ms = + time_shield::to_timestamp(2026, 11, 3, 3, 0, 0) * 1000; + EXPECT_EQ(filter.evaluate(context).reason, PolicyReason::TIME_NOT_ALLOWED); + + context.evaluation_time_ms = + time_shield::to_timestamp(2026, 11, 2, 1, 0, 0) * 1000; + EXPECT_EQ(filter.evaluate(context).kind, PolicyDecisionKind::REJECT); + EXPECT_EQ(filter.evaluate(context).reason, PolicyReason::WEEKDAY_NOT_ALLOWED); +} + +TEST(TradingSystemBatch2, TradingWindowBoundariesAreHalfOpenAndAllowMidnightEnd) { + const TradingTimeWindow until_midnight{22 * 3600, 0}; + EXPECT_TRUE(until_midnight.valid()); + EXPECT_TRUE(until_midnight.contains(22 * 3600)); + EXPECT_TRUE(until_midnight.contains(86400 - 1)); + EXPECT_FALSE(until_midnight.contains(0)); + EXPECT_FALSE((TradingTimeWindow{0, 0}.valid())); + + const optionx::trading_system::TradingWindowFilter until_midnight_filter( + {until_midnight}, {}, {time_shield::MON}); + auto midnight_context = make_context(); + midnight_context.evaluation_time_ms = + time_shield::to_timestamp(2026, 11, 2, 23, 59, 59) * 1000; + EXPECT_EQ( + until_midnight_filter.evaluate(midnight_context).kind, + PolicyDecisionKind::ALLOW); + midnight_context.evaluation_time_ms = + time_shield::to_timestamp(2026, 11, 3, 0, 0, 0) * 1000; + EXPECT_EQ( + until_midnight_filter.evaluate(midnight_context).reason, + PolicyReason::TIME_NOT_ALLOWED); + + const optionx::trading_system::TradingWindowFilter overnight( + {{22 * 3600, 2 * 3600}}, + {{30 * 60, 45 * 60}}, + {time_shield::MON}); + auto context = make_context(); + + context.evaluation_time_ms = + time_shield::to_timestamp(2026, 11, 2, 22, 0, 0) * 1000; + EXPECT_EQ(overnight.evaluate(context).kind, PolicyDecisionKind::ALLOW); + + context.evaluation_time_ms = + time_shield::to_timestamp(2026, 11, 3, 0, 30, 0) * 1000; + EXPECT_EQ(overnight.evaluate(context).kind, PolicyDecisionKind::REJECT); + EXPECT_EQ(overnight.evaluate(context).reason, PolicyReason::TIME_DENIED); + + context.evaluation_time_ms = + time_shield::to_timestamp(2026, 11, 3, 2, 0, 0) * 1000; + EXPECT_EQ(overnight.evaluate(context).reason, PolicyReason::TIME_NOT_ALLOWED); +} + +TEST(TradingSystemBatch2, TradingWindowFilterUsesContextTimezoneAndRejectsMissingTime) { + const optionx::trading_system::TradingWindowFilter filter( + {{0, 1 * 3600}}, {}, {time_shield::THU}); + auto context = make_context(); + context.time_zone = optionx::TradeTimeZone::fixed_offset(3 * 3600); + context.evaluation_time_ms = + time_shield::to_timestamp(2026, 10, 28, 21, 30, 0) * 1000; + EXPECT_EQ(filter.evaluate(context).kind, PolicyDecisionKind::ALLOW); + + context.evaluation_time_ms.reset(); + EXPECT_EQ(filter.evaluate(context).kind, PolicyDecisionKind::UNAVAILABLE); + EXPECT_EQ(filter.evaluate(context).reason, PolicyReason::MISSING_EVALUATION_TIME); +} + +TEST(TradingSystemBatch2, TradingWindowFilterUsesNamedZoneDstRules) { + const optionx::trading_system::TradingWindowFilter filter( + {{1 * 3600, 2 * 3600}}, {}, {time_shield::SUN}); + auto context = make_context(); + context.time_zone = optionx::TradeTimeZone::named(time_shield::CET); + + context.evaluation_time_ms = + time_shield::to_timestamp(2024, 3, 31, 0, 30, 0) * 1000; + EXPECT_EQ(filter.evaluate(context).kind, PolicyDecisionKind::ALLOW); + + context.evaluation_time_ms = + time_shield::to_timestamp(2024, 3, 31, 1, 30, 0) * 1000; + EXPECT_EQ(filter.evaluate(context).kind, PolicyDecisionKind::REJECT); + EXPECT_EQ(filter.evaluate(context).reason, PolicyReason::TIME_NOT_ALLOWED); + + const optionx::trading_system::TradingWindowFilter repeated_time_filter( + {{2 * 3600, 3 * 3600}}, {}, {time_shield::SUN}); + context.evaluation_time_ms = + time_shield::to_timestamp(2024, 10, 27, 0, 30, 0) * 1000; + EXPECT_EQ( + repeated_time_filter.evaluate(context).kind, + PolicyDecisionKind::ALLOW); + context.evaluation_time_ms = + time_shield::to_timestamp(2024, 10, 27, 1, 30, 0) * 1000; + EXPECT_EQ( + repeated_time_filter.evaluate(context).kind, + PolicyDecisionKind::ALLOW); +} + +TEST(TradingSystemBatch2, TradingWindowFilterFailsClosedForUnsupportedNamedZone) { + const optionx::trading_system::TradingWindowFilter filter({{0, 86400}}); + auto context = make_context(); + context.time_zone = optionx::TradeTimeZone::named(time_shield::UNKNOWN); + + const auto result = filter.evaluate(context); + EXPECT_EQ(result.kind, PolicyDecisionKind::UNAVAILABLE); + EXPECT_EQ(result.reason, PolicyReason::INVALID_CONFIGURATION); + EXPECT_EQ( + context.time_zone.to_local_ms(*context.evaluation_time_ms), + *context.evaluation_time_ms); + + context.time_zone = optionx::TradeTimeZone::named( + static_cast(999)); + const auto out_of_range = filter.evaluate(context); + EXPECT_EQ(out_of_range.kind, PolicyDecisionKind::UNAVAILABLE); + EXPECT_EQ(out_of_range.reason, PolicyReason::INVALID_CONFIGURATION); +} + +TEST(TradingSystemBatch2, InvalidWindowConfigurationIsUnavailable) { + const optionx::trading_system::TradingWindowFilter filter({{3600, 3600}}); + EXPECT_EQ(filter.evaluate(make_context()).kind, PolicyDecisionKind::UNAVAILABLE); + EXPECT_EQ(filter.evaluate(make_context()).reason, PolicyReason::INVALID_CONFIGURATION); +} + +TEST(TradingSystemBatch2, MoneyAmountRequiresExplicitPrecision) { + const auto parsed = MoneyAmount::from_decimal("12.50", CurrencyType::USD, 2); + ASSERT_TRUE(parsed.has_value()); + EXPECT_EQ(parsed->units, 1250); + EXPECT_EQ(parsed->to_decimal(), "12.50"); + + EXPECT_FALSE(MoneyAmount::from_decimal("12.501", CurrencyType::USD, 2).has_value()); + EXPECT_FALSE(MoneyAmount::from_decimal("nan", CurrencyType::USD, 2).has_value()); +} + +TEST(TradingSystemBatch2, MoneyAmountPreservesSignedInt64Boundaries) { + const auto maximum = MoneyAmount::from_decimal( + "92233720368547758.07", CurrencyType::USD, 2); + const auto minimum = MoneyAmount::from_decimal( + "-92233720368547758.08", CurrencyType::USD, 2); + + ASSERT_TRUE(maximum.has_value()); + ASSERT_TRUE(minimum.has_value()); + EXPECT_EQ(maximum->units, (std::numeric_limits::max)()); + EXPECT_EQ(minimum->units, (std::numeric_limits::min)()); + EXPECT_EQ(maximum->to_decimal(), "92233720368547758.07"); + EXPECT_EQ(minimum->to_decimal(), "-92233720368547758.08"); + + EXPECT_FALSE(MoneyAmount::from_decimal( + "92233720368547758.08", CurrencyType::USD, 2).has_value()); + EXPECT_FALSE(MoneyAmount::from_decimal( + "-92233720368547758.09", CurrencyType::USD, 2).has_value()); + EXPECT_FALSE(MoneyAmount::from_decimal( + "1e2", CurrencyType::USD, 2).has_value()); +} + +TEST(TradingSystemBatch2, FixedStakeIsPureAndRejectsInvalidAmounts) { + const auto context = make_context(); + const auto stake = optionx::trading_system::FixedStake(money("7.25")); + const auto result = stake.evaluate(context); + ASSERT_EQ(result.kind, SizingDecisionKind::SIZED); + ASSERT_TRUE(result.amount.has_value()); + EXPECT_EQ(result.amount->to_decimal(), "7.25"); + + const auto zero = optionx::trading_system::FixedStake(money("0.00")); + EXPECT_EQ(zero.evaluate(context).reason, SizingReason::INVALID_AMOUNT); + + const auto negative = optionx::trading_system::FixedStake(money("-1.00")); + EXPECT_EQ(negative.evaluate(context).reason, SizingReason::INVALID_AMOUNT); +} + +TEST(TradingSystemBatch2, PercentOfBalanceUsesFixedPointAndExplicitRounding) { + const auto context = make_context(); + const auto percent = optionx::trading_system::PercentOfBalance(12.5); + const auto result = percent.evaluate(context); + ASSERT_EQ(result.kind, SizingDecisionKind::SIZED); + ASSERT_TRUE(result.amount.has_value()); + EXPECT_EQ(result.amount->to_decimal(), "12.50"); + + const auto down = optionx::trading_system::PercentOfBalance::from_basis_points( + 5000, 2, MoneyRounding::DOWN); + auto boundary = context; + boundary.account.balance = money("1.01"); + EXPECT_EQ(down.evaluate(boundary).amount->to_decimal(), "0.50"); + + const auto half_up = optionx::trading_system::PercentOfBalance::from_basis_points( + 5000, 2, MoneyRounding::HALF_UP); + EXPECT_EQ(half_up.evaluate(boundary).amount->to_decimal(), "0.51"); + + auto higher_precision = context; + higher_precision.account.balance = MoneyAmount::from_decimal( + "1.00", CurrencyType::USD, 2); + const auto precise = optionx::trading_system::PercentOfBalance::from_basis_points( + 10, 3, MoneyRounding::DOWN); + EXPECT_EQ(precise.evaluate(higher_precision).amount->to_decimal(), "0.001"); + + const auto lower_precision = optionx::trading_system::PercentOfBalance::from_basis_points( + 5000, 1, MoneyRounding::DOWN); + EXPECT_EQ( + lower_precision.evaluate(context).reason, + SizingReason::PRECISION_MISMATCH); +} + +TEST(TradingSystemBatch2, PercentageRoundingUsesOutputPrecisionDenominator) { + auto context = make_context(); + context.account.balance = money("1.01"); + + const auto down = optionx::trading_system::PercentOfBalance::from_basis_points( + 1250, 4, MoneyRounding::DOWN); + const auto half_up = optionx::trading_system::PercentOfBalance::from_basis_points( + 1250, 4, MoneyRounding::HALF_UP); + const auto up = optionx::trading_system::PercentOfBalance::from_basis_points( + 1250, 4, MoneyRounding::UP); + + EXPECT_EQ(down.evaluate(context).amount->to_decimal(), "0.1262"); + EXPECT_EQ(half_up.evaluate(context).amount->to_decimal(), "0.1263"); + EXPECT_EQ(up.evaluate(context).amount->to_decimal(), "0.1263"); +} + +TEST(TradingSystemBatch2, PercentageSizingRejectsIntermediateOverflow) { + auto context = make_context(); + context.account.balance = MoneyAmount{ + (std::numeric_limits::max)(), 0, CurrencyType::USD}; + + const auto result = optionx::trading_system::PercentOfBalance::from_basis_points( + 10000, 1).evaluate(context); + + EXPECT_EQ(result.kind, SizingDecisionKind::REJECTED); + EXPECT_EQ(result.reason, SizingReason::NUMERIC_OVERFLOW); +} + +TEST(TradingSystemBatch2, PercentOfBalanceDistinguishesUnavailableAndInvalidBalance) { + auto context = make_context(); + const auto percent = optionx::trading_system::PercentOfBalance(10.0); + + context.account.availability = DataAvailability::STALE; + EXPECT_EQ(percent.evaluate(context).kind, SizingDecisionKind::UNAVAILABLE); + EXPECT_EQ(percent.evaluate(context).reason, SizingReason::BALANCE_STALE); + + context.account.availability = DataAvailability::UNAVAILABLE; + EXPECT_EQ(percent.evaluate(context).reason, SizingReason::BALANCE_UNAVAILABLE); + + context.account.availability = DataAvailability::SUPPORTED; + context.account.balance.reset(); + EXPECT_EQ(percent.evaluate(context).reason, SizingReason::MISSING_BALANCE); + + context.account.balance = money("0.00"); + EXPECT_EQ(percent.evaluate(context).reason, SizingReason::ZERO_BALANCE); + context.account.balance = money("-1.00"); + EXPECT_EQ(percent.evaluate(context).reason, SizingReason::NEGATIVE_BALANCE); +} + +TEST(TradingSystemBatch2, PercentOfBalanceRejectsInvalidPercentages) { + const auto context = make_context(); + EXPECT_EQ( + optionx::trading_system::PercentOfBalance(0.0).evaluate(context).reason, + SizingReason::INVALID_PERCENT); + EXPECT_EQ( + optionx::trading_system::PercentOfBalance(-1.0).evaluate(context).reason, + SizingReason::INVALID_PERCENT); + EXPECT_EQ( + optionx::trading_system::PercentOfBalance( + std::numeric_limits::quiet_NaN()).evaluate(context).reason, + SizingReason::INVALID_PERCENT); + EXPECT_EQ( + optionx::trading_system::PercentOfBalance( + std::numeric_limits::infinity()).evaluate(context).reason, + SizingReason::INVALID_PERCENT); + EXPECT_EQ( + optionx::trading_system::PercentOfBalance( + -std::numeric_limits::infinity()).evaluate(context).reason, + SizingReason::INVALID_PERCENT); + EXPECT_EQ( + optionx::trading_system::PercentOfBalance( + std::numeric_limits::denorm_min()).evaluate(context).reason, + SizingReason::INVALID_PERCENT); +} + +TEST(TradingSystemBatch2, PoliciesDoNotMutateContextAndAreDeterministic) { + auto context = make_context(); + const auto original = context; + const optionx::trading_system::SymbolFilter symbols({"EURUSD"}); + const auto first = symbols.evaluate(context); + const auto second = symbols.evaluate(context); + + EXPECT_EQ(first.kind, second.kind); + EXPECT_EQ(first.reason, second.reason); + EXPECT_EQ(context.symbol, original.symbol); + EXPECT_EQ(context.bridge_id, original.bridge_id); + EXPECT_EQ(context.signal_name, original.signal_name); + EXPECT_EQ(context.evaluation_time_ms, original.evaluation_time_ms); + ASSERT_TRUE(context.account.balance.has_value()); + EXPECT_EQ(context.account.balance->units, original.account.balance->units); +} + +TEST(TradingSystemBatch2, SignalFactoryOwnsItsDecisionSnapshot) { + optionx::TradeSignal signal; + signal.signal_id = 21; + signal.bridge_id = 9; + signal.symbol = "GBP/USD"; + signal.signal_name = "mean-reversion"; + + auto account = make_context().account; + account.availability = DataAvailability::SUPPORTED; + account.currency = CurrencyType::USD; + account.balance = money("25.00"); + + const auto context = TradingDecisionContext::from_signal( + signal, + time_shield::to_timestamp(2026, 10, 28, 9, 30, 0) * 1000, + optionx::TradeTimeZone::utc(), + account); + + signal.symbol = "EUR/USD"; + signal.signal_name = "mutated-after-snapshot"; + account.balance->units = 1; + + EXPECT_EQ(context.signal_id, 21U); + EXPECT_EQ(context.bridge_id, 9U); + EXPECT_EQ(context.symbol, "GBP/USD"); + EXPECT_EQ(context.signal_name, "mean-reversion"); + ASSERT_TRUE(context.account.balance.has_value()); + EXPECT_EQ(context.account.balance->to_decimal(), "25.00"); +} + +TEST(TradingSystemBatch2, UsageChainCanEvaluateWithoutBrokerCalls) { + TradeIntent intent; + intent.intent_id = 10; + intent.origin_signal_id = 7; + intent.execution_group.expected_members = 1; + intent.cardinality = optionx::trading_system::ExecutionCardinality::EXACTLY_ONE; + intent.idempotency_key = "batch2-10"; + intent.request.symbol = "EUR/USD"; + intent.request.bridge_id = 7; + intent.request.signal_name = "alpha"; + + auto context = TradingDecisionContext::from_intent( + intent, + time_shield::to_timestamp(2026, 10, 28, 9, 30, 0) * 1000, + optionx::TradeTimeZone::utc()); + context.account.availability = DataAvailability::SUPPORTED; + context.account.currency = CurrencyType::USD; + context.account.balance = money("100.00"); + + const optionx::trading_system::SymbolFilter symbol_filter({"EURUSD"}); + const optionx::trading_system::SourceFilter source_filter({7}, {}, {"alpha"}); + const optionx::trading_system::TradingWindowFilter time_filter({{9 * 3600, 10 * 3600}}); + const auto stake = optionx::trading_system::FixedStake(money("5.00")); + + EXPECT_TRUE(symbol_filter.evaluate(context).is_allowed()); + EXPECT_TRUE(source_filter.evaluate(context).is_allowed()); + EXPECT_TRUE(time_filter.evaluate(context).is_allowed()); + EXPECT_TRUE(stake.evaluate(context).is_sized()); +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}