diff --git a/CMakeLists.txt b/CMakeLists.txt index d8503f29..b157db1c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -79,6 +79,7 @@ endif() add_library(pineforge STATIC src/c_abi.cpp src/compat/pine/order_birth.cpp + src/compat/pine/exit_activation.cpp src/compat/pine/order_priority.cpp src/engine_aux_security.cpp src/engine_fills.cpp diff --git a/docs/pages/abi-stability.md b/docs/pages/abi-stability.md index 97186c61..e167ce33 100644 --- a/docs/pages/abi-stability.md +++ b/docs/pages/abi-stability.md @@ -112,32 +112,34 @@ notice: - The shape of internal log lines (use them for humans, not parsers). Rebuild generated and native C++ objects against matching engine headers and -runtime. The integrated quantity, predecessor and order-birth representation -changes `PendingOrder` and uses internal `engine_script_run_v5`. Exact frozen -c45/v4 headers are compiled before link tests: both native and generated-style -v4 callers must fail to link to the v5 archive, while matched v5 callers link. -Earlier exact base38/v2 and f864/v3 mismatch controls remain. A compile failure -cannot masquerade as mismatch protection; no pairing executable is run. +runtime. Owner-bound leg activation changes `PendingOrder` layout again; both +`PendingOrder` and `BacktestEngine` use internal `engine_script_run_v6`. Exact +shipped149/v5 headers are compiled before mismatch links. Native and +generated-style old engine callers, plus standalone priority callers using +`vector` without any engine-method reference, must reject the +v6 archive. Matching current and historical symbol-control links must succeed. +Earlier base38/v2, f864/v3 and c45/v4 controls remain. A compilation failure +cannot masquerade as stale-layout rejection; no pairing executable runs. `PINEFORGE_HAS_SCRIPT_RUN_PREPARE_V1` remains 1: it describes the existing hook capability, not the class layout version. Regenerate and rebuild a strategy module to obtain complete script-state reset; replacing an archive does not retrofit an old module. Public C function signatures, `PF_ABI_VERSION` (4), and `strategy_stream_api_version()` (1) are unchanged. The pending-order v1 -mirror keeps all 108 existing field names/types/offsets and its full old prefix; +mirror keeps all 128 shipped149 field names/types/offsets and its full old prefix; new typed facts append, and removed native booleans survive only as read-only derived outputs. Size-limited reads keep old callers within their buffers. Namespace versioning protects referenced internal C++ symbols; it does not validate an erased `pf_strategy_t` handle. Use a handle only with functions from its creating strategy module. A fully self-contained old module can still use -its own matching runtime; this check does not turn it into a v5 module. +its own matching runtime; this check does not turn it into a v6 module. The integrated representation advances the broker fingerprint domain to -`pineforge-broker-state/v5` and stream fingerprint version to 5. These identify -changed serialized quantity/reservation, predecessor and birth facts, alongside -existing Pine policy state. The Pine component schema remains 1; it is -independent of the aggregate fingerprint version. Prior v2/v3/v4 fingerprints are +`pineforge-broker-state/v6` and stream fingerprint version to 6. These identify +changed serialized leg owner/bounds and Pine placement evidence, alongside +existing quantity, predecessor and birth facts. The Pine component schema remains 1; it is +independent of the aggregate fingerprint version. Prior v2/v3/v4/v5 fingerprints are not comparable. Fingerprints are replay checks, not serialized checkpoints or complete hashes of private strategy state. The native runner already binds its strategy-library SHA; its ledger format and Python provenance fingerprints diff --git a/docs/pages/exit-leg-activation.md b/docs/pages/exit-leg-activation.md new file mode 100644 index 00000000..758246e9 --- /dev/null +++ b/docs/pages/exit-leg-activation.md @@ -0,0 +1,60 @@ +Exit stop and limit matching now consumes concrete activation bounds attached +to a position-cycle identity. Each bound names the earliest script bar where +that leg may be evaluated for that owner. A mismatch cannot reuse another +cycle's constraint; an unbound value imposes no lower-bound constraint. +This is one exposure-cycle scope, not a physical lot or `from_entry` claim. +Existing quantity/entry ownership and other path/trailing rules remain separate. + +The lower bounds also apply when a dormant bracket is revived after a margin +partial and when a prearmed bracket selects a stop or limit at the open. +Revival can restore the bracket without executing an unready stop. Open-gap +selection checks each leg before applying stop/limit precedence, so an eligible +sibling retains its own execution and slippage semantics. Risk liquidation +itself is independent of a bracket's activation time. +The historical chart-extreme rounding fallback checks the same per-leg bounds; +a normal no-fill result cannot become an early fill through that fallback. + +The transitions are connected to the current book. New or replaced EXIT +construction binds against live exposure. Both fresh-position producers bind +retained exits after establishing the new cycle, before later matching or +callbacks. Going flat clears bindings while retained policy evidence stays +with the order. Partial reductions and same-cycle adds do not refresh a +deadline. Order replacement creates fresh placement evidence and activation; +cancellation and OCA erasure remove the values with their owning order. + +For Pine compatibility, immutable `ExitPlacementEvidence` retains the original +callback marketability inputs: placement cycle/entry bar, physical exposure +direction, cursor price, raw stop/limit levels and any named limit continuation. +Current trigger fields can be neutralized or modified without rewriting those +facts. `LaterSameOpen` and `FirstHighRecross` continuations remain explicit +Pine exceptions; their existing predicates are evaluated in the adapter. +Their recorded observed fill sequence is the sequence visible at policy +selection; the separate `OrderBirth` still owns the actual callback interval. + +At binding, the adapter resolves the retained original decision to concrete +bounds for the new owner. A held leg receives `owner_entry_bar + 1`; the other +leg receives `owner_entry_bar`. The arithmetic uses a wider bar coordinate. +The matcher never refreshes these numbers from the current position. In +particular a surviving same-ID exit can bind a new cycle and receive another +hold, even though its original birth-bar deadline has already passed. New +marketability is not recalculated from the replacement owner's opposite side. + +The two native `coof_suppress_*_on_entry_bar` booleans are removed. Their old +C mirror fields are read-only projections from the Pine evidence; no native +decision reads those outputs. All 128 pre-existing mirror fields and the full +old byte prefix remain unchanged, and new activation/evidence fields append. +Every new owner, coordinate, evidence value and optional-presence discriminator +is hashed and mirrored. There is no side map or second pending-order book. + +This slice moves the two decisions out of the matching masks, but does not +eliminate the Pine policies or detach their producer from existing native +callback use. The Pine adapter is still invoked by the current exit producer. +`PineHistoricalBirthReach`, cascade leg/gap permissions, dormant-leg revival and +other historical execution rules remain separate work. The effective-level +accessor still reports resolved levels rather than masking eligibility. + +The internal object layout changes and requires matching C++ headers/library. +Both PendingOrder and BacktestEngine now use internal v6 with broker/stream +fingerprint v6. Exact shipped149/v5 engine and standalone PendingOrder clients +are checked by compile/link-only mismatch controls with matching positives. +Public C ABI 4, stream API 1 and pending mirror version 1 are preserved. diff --git a/include/pineforge/compat/pine/exit_activation.hpp b/include/pineforge/compat/pine/exit_activation.hpp new file mode 100644 index 00000000..5024b013 --- /dev/null +++ b/include/pineforge/compat/pine/exit_activation.hpp @@ -0,0 +1,95 @@ +#pragma once +#include "../../bar.hpp" +#include "../../leg_activation.hpp" +#include +#include +#include +#include +#include + +namespace pineforge { +enum class PositionSide; +inline namespace engine_script_run_v6 { struct PendingOrder; } +} +namespace pineforge::compat::pine { + +enum class LimitContinuationCause : int32_t { LaterSameOpen, FirstHighRecross }; +struct LimitContinuation { + LimitContinuationCause cause; + // Sequence observed when policy was selected; callback cause remains the + // independent immutable OrderBirth interval on the order. + uint64_t observed_fill_sequence; +}; + +// Immutable original policy evidence. Rebinding must not recompute original +// marketability from the new owner's side or a later-mutated trigger level. +struct ExitPlacementEvidence { + int64_t position_cycle; + int entry_bar; + int direction; + double cursor_price; + double stop_level; + double limit_level; + std::optional limit_continuation; +}; + +class ExitActivationPolicy { +public: + ExitActivationPolicy() = default; + explicit ExitActivationPolicy(ExitPlacementEvidence evidence) : evidence_(evidence) { + if (evidence.position_cycle <= 0 || evidence.entry_bar < 0 + || (evidence.direction != 1 && evidence.direction != -1) + || !std::isfinite(evidence.cursor_price)) + throw std::invalid_argument("invalid Pine exit placement evidence"); + } + const std::optional& evidence() const { return evidence_; } + bool holds_stop() const; + bool holds_limit() const; + bool continues_at_later_open() const; + ExitLegActivationBounds resolve(int64_t owner_cycle, int owner_entry_bar) const; +private: + std::optional evidence_; +}; + +// Transient producer facts, never retained as a parallel mutable mode bag. +struct ExitActivationContext { + const Bar& bar; + PositionSide side; + int64_t cycle; + int bar_index; + int position_open_bar; + int position_entry_count; + double position_quantity; + int pyramiding; + std::size_t lot_count; + const std::string& first_lot_id; + uint64_t first_lot_incarnation; + bool fill_recalc; + bool scheduler; + double cursor_price; + bool after_first_open_fill; + int recalc_leg; + bool historical_segment; + bool at_extreme; + int historical_point; + uint64_t market_recalc_incarnation; + uint64_t market_recalc_fill; + uint64_t current_fill; + bool magnifier; + bool process_on_close; + bool warmup; + bool stream_idle; + bool pending_empty; + int slippage; + double pointvalue; + double account_fx; + bool fx_series_empty; + double tick_high; +}; + +ExitActivationPolicy select_exit_activation(const PendingOrder& order, + double requested_stop, double requested_limit, const ExitActivationContext& context); + +} // namespace pineforge::compat::pine + +namespace pineforge { using PineExitActivationPolicy = compat::pine::ExitActivationPolicy; } diff --git a/include/pineforge/compat/pine/order_birth.hpp b/include/pineforge/compat/pine/order_birth.hpp index 78906582..b57ffe0e 100644 --- a/include/pineforge/compat/pine/order_birth.hpp +++ b/include/pineforge/compat/pine/order_birth.hpp @@ -1,7 +1,7 @@ #pragma once #include "../../order_birth.hpp" -namespace pineforge { inline namespace engine_script_run_v5 { struct PendingOrder; } } +namespace pineforge { inline namespace engine_script_run_v6 { struct PendingOrder; } } namespace pineforge::compat::pine { // Historical Pine permissions remain policy, not physical birth facts. diff --git a/include/pineforge/compat/pine/order_priority.hpp b/include/pineforge/compat/pine/order_priority.hpp index 3621990f..86aac962 100644 --- a/include/pineforge/compat/pine/order_priority.hpp +++ b/include/pineforge/compat/pine/order_priority.hpp @@ -5,7 +5,7 @@ #include #include -namespace pineforge { inline namespace engine_script_run_v5 { struct PendingOrder; } } +namespace pineforge { inline namespace engine_script_run_v6 { struct PendingOrder; } } namespace pineforge::compat::pine { struct OrderPriorityContext { diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 7a75d98a..64c73225 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -14,6 +14,8 @@ #include "bar.hpp" #include "broker_events.hpp" #include "quantity_intent.hpp" +#include "leg_activation.hpp" +#include "compat/pine/exit_activation.hpp" #include "order_birth.hpp" #include "compat/pine/order_birth.hpp" #include "compat/pine/intraday_cap.hpp" @@ -404,7 +406,7 @@ enum class ShortSeedCollisionRole : uint8_t { // PendingOrder crosses out-of-line helper boundaries independently of the // engine class, so its changed layout must carry the same internal epoch. -inline namespace engine_script_run_v5 { +inline namespace engine_script_run_v6 { struct PendingOrder { std::string id; std::string from_entry; // for exit orders @@ -459,14 +461,10 @@ struct PendingOrder { // fires, later bars—and later COOF scheduler segments on the same bar— // evaluate only the live limit leg until the order fills or is replaced. bool stop_limit_activated = false; - // A stop/limit leg emitted by a COOF recalc on the position's entry bar - // cannot consume the fill cursor that caused that recalc when the leg is - // already marketable there. Suppression is deliberately per-leg: the - // other, correctly-sided bracket leg remains live on the remaining path. - // Both bits expire automatically once bar_index_ advances, so an unfilled - // suppressed leg carries into the next bar as an ordinary order. - bool coof_suppress_stop_on_entry_bar = false; - bool coof_suppress_limit_on_entry_bar = false; + // Concrete activation bounds belong to the currently bound exposure cycle. + // Pine placement evidence is retained separately for explicit rebinding. + ExitLegActivation leg_activation; + PineExitActivationPolicy pine_exit_activation; // Immutable evaluation/fill origin, captured once for this incarnation. // Historical extreme-only/trailing permissions live in compat::pine. OrderBirth birth; @@ -1035,7 +1033,7 @@ struct PendingOrder { ShortSeedCollisionRole::NONE; }; - } // inline namespace engine_script_run_v5 (PendingOrder) + } // inline namespace engine_script_run_v6 (PendingOrder) // default_qty_type constants (matches TradingView) enum class QtyType { FIXED = 0, PERCENT_OF_EQUITY = 1, CASH = 2 }; @@ -1090,10 +1088,10 @@ struct StrategyOverrides { // The C++ subclass contract is internal, unlike pineforge.h's stable C ABI. // Changing its layout or vtable requires all generated/native C++ objects to be rebuilt. -// v5 integrates typed quantity, replacement identity and causal order birth. +// v6 adds explicit owner-bound exit-leg activation and Pine placement evidence. // Version the mangled class name so older headers' member offsets/vtable cannot // silently bind out-of-line members of this different object layout. -inline namespace engine_script_run_v5 { +inline namespace engine_script_run_v6 { class BacktestEngine { protected: // --- Position state --- @@ -4321,6 +4319,9 @@ class BacktestEngine { double fill_price, double explicit_qty, int explicit_qty_type, uint64_t entry_incarnation); + void bind_exit_activation(PendingOrder& order); + void bind_retained_exit_activations(); + void unbind_exit_activations(); void open_fresh_position(PositionSide requested, double fill_price, double qty, const std::string& id, uint64_t entry_incarnation); @@ -5128,5 +5129,5 @@ class BacktestEngine { void trace(const std::string& name, int value) { trace(name, static_cast(value)); } }; -} // inline namespace engine_script_run_v5 +} // inline namespace engine_script_run_v6 } // namespace pineforge diff --git a/include/pineforge/leg_activation.hpp b/include/pineforge/leg_activation.hpp new file mode 100644 index 00000000..4160c841 --- /dev/null +++ b/include/pineforge/leg_activation.hpp @@ -0,0 +1,37 @@ +#pragma once +#include +#include +#include + +namespace pineforge { + +// Concrete lower bounds resolved when an exit binds an exposure cycle. Other +// path/price/lifetime constraints remain independent of this lower bound. +struct ExitLegActivationBounds { + int64_t position_cycle; + int64_t stop_first_bar; + int64_t limit_first_bar; +}; + +class ExitLegActivation { +public: + void bind(ExitLegActivationBounds bounds) { + if (bounds.position_cycle <= 0 || bounds.stop_first_bar < 0 || bounds.limit_first_bar < 0) + throw std::invalid_argument("invalid exit-leg activation bounds"); + bounds_ = bounds; + } + void unbind() { bounds_.reset(); } + const std::optional& bounds() const { return bounds_; } + // An unbound value imposes no lower-bound constraint. Production EXIT + // producers bind on live exposure; exposure creation binds retained exits. + bool stop_ready(int64_t cycle, int64_t bar) const { + return !bounds_ || (bounds_->position_cycle == cycle && bar >= bounds_->stop_first_bar); + } + bool limit_ready(int64_t cycle, int64_t bar) const { + return !bounds_ || (bounds_->position_cycle == cycle && bar >= bounds_->limit_first_bar); + } +private: + std::optional bounds_; +}; + +} // namespace pineforge diff --git a/include/pineforge/pending_order_mirror.hpp b/include/pineforge/pending_order_mirror.hpp index 0bc79a65..3d23478b 100644 --- a/include/pineforge/pending_order_mirror.hpp +++ b/include/pineforge/pending_order_mirror.hpp @@ -1,5 +1,5 @@ // GENERATED by scripts/gen_pending_order_mirror.py from include/pineforge/engine.hpp -- do not edit. -// 94 PendingOrder members mirrored (128 POD fields incl. struct_version/size). +// 94 PendingOrder members mirrored (142 POD fields incl. struct_version/size). #pragma once #include @@ -46,8 +46,8 @@ typedef struct pf_pending_order_v1_s { uint64_t recreated_after_named_cancelled_entry_incarnation; uint64_t named_cancel_surviving_exit_incarnation; uint8_t stop_limit_activated; - uint8_t coof_suppress_stop_on_entry_bar; - uint8_t coof_suppress_limit_on_entry_bar; + uint8_t coof_suppress_stop_on_entry_bar; // deprecated, derived output only + uint8_t coof_suppress_limit_on_entry_bar; // deprecated, derived output only uint8_t created_during_coof_recalc; // deprecated, derived output only uint8_t coof_born_at_close_recalc; // deprecated, derived output only uint8_t coof_born_mid_bar; // deprecated, derived output only @@ -140,6 +140,20 @@ typedef struct pf_pending_order_v1_s { uint8_t quantity_reservation_present; double quantity_reservation_units; double quantity_reservation_basis_units; + int64_t leg_activation_owner_cycle; + uint8_t leg_activation_present; + int64_t leg_activation_stop_first_bar; + int64_t leg_activation_limit_first_bar; + int64_t pine_exit_activation_owner_cycle_at_birth; + uint8_t pine_exit_activation_present; + int32_t pine_exit_activation_entry_bar_at_birth; + int32_t pine_exit_activation_direction_at_birth; + double pine_exit_activation_cursor_price_at_birth; + double pine_exit_activation_stop_level_at_birth; + double pine_exit_activation_limit_level_at_birth; + uint8_t pine_exit_activation_limit_continuation_present; + int32_t pine_exit_activation_limit_continuation_cause; + uint64_t pine_exit_activation_limit_continuation_fill; } pf_pending_order_v1_t; /* One row of the self-describing layout table returned by diff --git a/scripts/check_broker_state_hash_coverage.py b/scripts/check_broker_state_hash_coverage.py index 78d35a62..be8190e4 100644 --- a/scripts/check_broker_state_hash_coverage.py +++ b/scripts/check_broker_state_hash_coverage.py @@ -166,7 +166,7 @@ def _class_fields(src: str, name: str) -> dict[str, str]: remaining data declaration must be one TYPE NAME; adding a new field is visible even when its name has no trailing underscore. """ - body = _one_braced_body(src, rf"\bclass\s+{name}\s*\{{", name) + body = _one_braced_body(src, rf"\b(?:class|struct)\s+{name}\s*\{{", name) fields = {} statement = "" i = 0 @@ -193,6 +193,8 @@ def _class_fields(src: str, name: str) -> dict[str, str]: statement = "" if re.fullmatch(re.escape(name) + r"\(\)\s*=\s*default", decl): continue + if re.fullmatch(r"(?:bool|ExitLegActivationBounds)\s+\w+\([^;{}]*\)\s+const", decl): + continue # declared read-only value query, never a stored field if decl and not decl.startswith("using "): match = re.fullmatch(r"((?:(?:static|constexpr|const)\s+)*[\w:<>]+)\s+(\w+)(?:\s*=\s*[^,]+)?", decl) if not match or match[2] in fields: @@ -411,8 +413,44 @@ def _birth_coverage(header: str, source: str) -> None: raise ValueError("birth facts must be unconditionally hashed at order-loop scope") +def _exit_activation_coverage(activation: str, policy: str, source: str) -> None: + activation = _strip_cpp_comments(activation) + policy = _strip_cpp_comments(policy) + expected = [ + (activation, "ExitLegActivation", {"bounds_": "std::optional"}), + (activation, "ExitLegActivationBounds", {"position_cycle": "int64_t", "stop_first_bar": "int64_t", "limit_first_bar": "int64_t"}), + (policy, "ExitActivationPolicy", {"evidence_": "std::optional"}), + (policy, "ExitPlacementEvidence", {"position_cycle": "int64_t", "entry_bar": "int", "direction": "int", "cursor_price": "double", "stop_level": "double", "limit_level": "double", "limit_continuation": "std::optional"}), + (policy, "LimitContinuation", {"cause": "LimitContinuationCause", "observed_fill_sequence": "uint64_t"}), + ] + for text, name, fields in expected: + if _class_fields(text, name) != fields: + raise ValueError(name + " activation fields changed; every value must be hashed") + loop = _collection_loop_body(source, "pending_orders_", "o") + expected = """f.b(o.leg_activation.bounds().has_value()); + if (const auto& bounds = o.leg_activation.bounds()) { + f.i(bounds->position_cycle); f.i(bounds->stop_first_bar); f.i(bounds->limit_first_bar); + } + f.b(o.pine_exit_activation.evidence().has_value()); + if (const auto& evidence = o.pine_exit_activation.evidence()) { + f.i(evidence->position_cycle); f.i(evidence->entry_bar); f.i(evidence->direction); + f.d(evidence->cursor_price); f.d(evidence->stop_level); f.d(evidence->limit_level); + f.b(evidence->limit_continuation.has_value()); + if (const auto& continuation = evidence->limit_continuation) { + f.i(static_cast(continuation->cause)); f.u(continuation->observed_fill_sequence); + } + }""" + compact = re.sub(r"\s+", "", loop) + folded = re.sub(r"\s+", "", expected) + if compact.count(folded) != 1: + raise ValueError("exit activation needs every nested fact and optional discriminator") + prefix = compact[:compact.index(folded)] + if prefix.count("{") != prefix.count("}"): + raise ValueError("exit activation hash block must be unconditional") + + def _runtime_version_coverage(header: str, source: str, stream: str) -> None: - """The v5 layout and serialized-state contracts must advance together. + """The v6 layout and serialized-state contracts must advance together. Pin the actual hash entry points, rather than accepting a version string mentioned in a comment or an unrelated helper. Public C ABI versions have @@ -420,18 +458,18 @@ def _runtime_version_coverage(header: str, source: str, stream: str) -> None: """ header = _strip_cpp_comments(header) namespaces = re.findall(r"inline\s+namespace\s+(engine_script_run_v\d+)\s*\{", header) - if namespaces != ["engine_script_run_v5", "engine_script_run_v5"]: - raise ValueError("PendingOrder and BacktestEngine layouts require internal namespace engine_script_run_v5") + if namespaces != ["engine_script_run_v6", "engine_script_run_v6"]: + raise ValueError("PendingOrder and BacktestEngine layouts require internal namespace engine_script_run_v6") broker = _one_braced_body(source, r"uint64_t\s+BacktestEngine::broker_state_hash\(\)\s+const\s*\{", "broker hash") - if not re.match(r'\s*Fnv\s+f;\s*f\.s\("pineforge-broker-state/v5"\);', broker): - raise ValueError("broker hash must start with pineforge-broker-state/v5") + if not re.match(r'\s*Fnv\s+f;\s*f\.s\("pineforge-broker-state/v6"\);', broker): + raise ValueError("broker hash must start with pineforge-broker-state/v6") stream_body = _one_braced_body(_strip_cpp_comments(stream), r"uint64_t\s+BacktestEngine::stream_state_hash\(\)\s+const\s*\{", "stream hash") compact = re.sub(r"\s+", "", stream_body) - fold = "integer(5);integer(broker_state_hash());" + fold = "integer(6);integer(broker_state_hash());" if compact.count(fold) != 1: - raise ValueError("stream hash requires version 5 followed by the broker hash") + raise ValueError("stream hash requires version 6 followed by the broker hash") prefix = compact[:compact.index(fold)] if prefix.count("{") != prefix.count("}") or (prefix and prefix[-1] not in ";}"): raise ValueError("stream version fold must be unconditional at function scope") @@ -447,6 +485,8 @@ def main(root: Path = ROOT) -> int: try: _runtime_version_coverage(hpp, src, (root / "src/engine_stream.cpp").read_text()) _birth_coverage((root / "include/pineforge/order_birth.hpp").read_text(), src) + _exit_activation_coverage((root / "include/pineforge/leg_activation.hpp").read_text(), + (root / "include/pineforge/compat/pine/exit_activation.hpp").read_text(), src) _opening_coverage((root / "include/pineforge/broker_events.hpp").read_text(), src) _quantity_request_coverage((root / "include/pineforge/quantity_intent.hpp").read_text(), src) _intraday_coverage( diff --git a/scripts/check_script_cpp_abi.py b/scripts/check_script_cpp_abi.py index 32797b35..afed8a76 100644 --- a/scripts/check_script_cpp_abi.py +++ b/scripts/check_script_cpp_abi.py @@ -16,7 +16,7 @@ BASE_COMMIT = "38dc73e5503fe5395458e5f8df2a2ad78054a1ae" BASE_ENGINE_SHA256 = "06c937a1ccd31815ca7775268ac699ffdfddb1a1f19de4628b777f37e9a6d193" -CURRENT_NAMESPACE = "engine_script_run_v5" +CURRENT_NAMESPACE = "engine_script_run_v6" BASE_NAMESPACE = "engine_script_run_v2" FIXTURE = Path(__file__).resolve().parents[1] / "tests/fixtures/script_cpp_abi/base38" @@ -150,6 +150,10 @@ def main(): frozen_headers(prior_include, FIXTURE.parent / "basec45", "c45cf5a4d0e67a2ac098d9066977e1fa21c408a9", "engine_script_run_v4", "3b4e2937a9b5f275dd119144373b1bf15e433092009500092cd32ea34963b293") + activation_include = root / "base149/include" + frozen_headers(activation_include, FIXTURE.parent / "base149", + "149f77ce16ef84c6da77e67d812bf8fa88e51cde", "engine_script_run_v5", + "5ba773889d947e4fdab3995cc55f22f88ab037a86e0ad4016a126d297ce82eed") common = [args.compiler, "-std=c++17", "-O0", *args.extra_flag] def compile_object(name, source, include): @@ -204,6 +208,13 @@ def compile_object(name, source, include): prior_priority = compile_object("basec45_pending_priority", priority_caller, prior_include) prior_priority_symbols = compile_object("basec45_pending_priority_symbols", priority_symbols, prior_include) + activation_native = compile_object("base149_native", caller("engine_script_run_v5"), activation_include) + activation_generated = compile_object("base149_generated", caller("engine_script_run_v5", True), activation_include) + activation_symbols = compile_object("base149_symbol_control", + BASE_SYMBOL_CONTROL.replace("engine_script_run_v2", "engine_script_run_v5"), activation_include) + activation_priority = compile_object("base149_pending_priority", priority_caller, activation_include) + activation_priority_symbols = compile_object("base149_pending_priority_symbols", priority_symbols, activation_include) + def link(name, obj, runtime, missing_namespace=None): linked = subprocess.run( [*common, str(obj), str(runtime), "-pthread", "-o", str(root / name)], @@ -253,10 +264,19 @@ def link(name, obj, runtime, missing_namespace=None): link("current_generated_to_v4_symbol_control", current_generated, prior_symbols, CURRENT_NAMESPACE) link("current_pending_priority_to_current", current_priority, args.library) link("basec45_pending_priority_to_v4_symbols", prior_priority, prior_priority_symbols) + link("base149_native_to_v5_symbol_control", activation_native, activation_symbols) + link("base149_generated_to_v5_symbol_control", activation_generated, activation_symbols) + link("base149_native_to_current", activation_native, args.library, "engine_script_run_v5") + link("base149_generated_to_current", activation_generated, args.library, "engine_script_run_v5") + link("current_native_to_v5_symbol_control", current_native, activation_symbols, CURRENT_NAMESPACE) + link("current_generated_to_v5_symbol_control", current_generated, activation_symbols, CURRENT_NAMESPACE) + link("base149_pending_priority_to_v5_symbols", activation_priority, activation_priority_symbols) for name, obj, runtime, expected in [ + ("base149_pending_priority_to_current", activation_priority, args.library, "pineforge::engine_script_run_v5::PendingOrder"), + ("current_pending_priority_to_v5_symbols", current_priority, activation_priority_symbols, "pineforge::engine_script_run_v6::PendingOrder"), ("basec45_pending_priority_to_current", prior_priority, args.library, "pineforge::PendingOrder"), ("current_pending_priority_to_v4_symbols", current_priority, prior_priority_symbols, - "pineforge::engine_script_run_v5::PendingOrder"), + "pineforge::engine_script_run_v6::PendingOrder"), ]: result = subprocess.run([*common, str(obj), str(runtime), "-pthread", "-o", str(root / name)], capture_output=True, text=True, timeout=60) @@ -264,7 +284,7 @@ def link(name, obj, runtime, missing_namespace=None): or "OrderPriority::select(" not in result.stderr or expected not in result.stderr): raise RuntimeError(name + " did not reject the expected PendingOrder type: " + result.stderr) print(name + ": rejected stale standalone PendingOrder argument type (not executed)") - print("15 translation units compiled; 10 positive links; 15 rejected links; no executable run") + print("20 translation units compiled; 13 positive links; 21 rejected links; no executable run") if __name__ == "__main__": diff --git a/scripts/gen_pending_order_mirror.py b/scripts/gen_pending_order_mirror.py index 4d127ad7..f740407e 100644 --- a/scripts/gen_pending_order_mirror.py +++ b/scripts/gen_pending_order_mirror.py @@ -61,6 +61,8 @@ # Public v1 is append-only. Removed native fields survive only as one-way # deprecated output projections at their original offsets. LEGACY_OUTPUTS = { + "coof_suppress_stop_on_entry_bar": "src.pine_exit_activation.holds_stop() ? 1 : 0", + "coof_suppress_limit_on_entry_bar": "src.pine_exit_activation.holds_limit() ? 1 : 0", "created_during_coof_recalc": "src.birth.from_fill() ? 1 : 0", "coof_born_at_close_recalc": "src.birth.at_terminal_fill() ? 1 : 0", "coof_born_mid_bar": "compat::pine::historical_cascade_reach(src) ? 1 : 0", @@ -71,6 +73,24 @@ "full_percent_exit_request": "src.quantity_request.requests_all() ? 1 : 0", } COMPOSITE_MAP = { + "ExitLegActivation": [ + ("owner_cycle", "int64_t", "src.{m}.bounds() ? src.{m}.bounds()->position_cycle : 0"), + ("present", "uint8_t", "src.{m}.bounds().has_value() ? 1 : 0"), + ("stop_first_bar", "int64_t", "src.{m}.bounds() ? src.{m}.bounds()->stop_first_bar : 0"), + ("limit_first_bar", "int64_t", "src.{m}.bounds() ? src.{m}.bounds()->limit_first_bar : 0"), + ], + "PineExitActivationPolicy": [ + ("owner_cycle_at_birth", "int64_t", "src.{m}.evidence() ? src.{m}.evidence()->position_cycle : 0"), + ("present", "uint8_t", "src.{m}.evidence().has_value() ? 1 : 0"), + ("entry_bar_at_birth", "int32_t", "src.{m}.evidence() ? src.{m}.evidence()->entry_bar : 0"), + ("direction_at_birth", "int32_t", "src.{m}.evidence() ? src.{m}.evidence()->direction : 0"), + ("cursor_price_at_birth", "double", "src.{m}.evidence() ? src.{m}.evidence()->cursor_price : 0.0"), + ("stop_level_at_birth", "double", "src.{m}.evidence() ? src.{m}.evidence()->stop_level : 0.0"), + ("limit_level_at_birth", "double", "src.{m}.evidence() ? src.{m}.evidence()->limit_level : 0.0"), + ("limit_continuation_present", "uint8_t", "src.{m}.evidence() && src.{m}.evidence()->limit_continuation ? 1 : 0"), + ("limit_continuation_cause", "int32_t", "src.{m}.evidence() && src.{m}.evidence()->limit_continuation ? static_cast(src.{m}.evidence()->limit_continuation->cause) : 0"), + ("limit_continuation_fill", "uint64_t", "src.{m}.evidence() && src.{m}.evidence()->limit_continuation ? src.{m}.evidence()->limit_continuation->observed_fill_sequence : 0"), + ], "QuantityRequest": [ ("intent_kind", "uint64_t", "src.{m}.intent() ? static_cast(src.{m}.intent()->kind()) + 1 : 0"), ("intent_units", "double", "src.{m}.intent() && src.{m}.intent()->kind() == QuantityIntent::Kind::Units ? src.{m}.intent()->units() : 0.0"), @@ -218,7 +238,12 @@ def generate() -> tuple[str, str]: if name not in LEGACY_OUTPUTS and native.get(name) != kind: _fail(f"public v1 prefix member {name} needs an explicit derived projection") prefix_names = {name for _, name in prefix} - ordered = prefix + [(kind, name) for kind, name in mirrored if name not in prefix_names] + # Preserve the full 149f77c extension as well as the original c45 prefix. + existing_extension = ["replaced_order_incarnation", "birth", "pine_birth_reach", "quantity_request"] + tail = [(native[name], name) for name in existing_extension] + tail += [(kind, name) for kind, name in mirrored + if name not in prefix_names and name not in existing_extension] + ordered = prefix + tail for t, m in ordered: if m in LEGACY_OUTPUTS: ct = TYPE_MAP[t][0] diff --git a/scripts/test_broker_state_hash_coverage.py b/scripts/test_broker_state_hash_coverage.py index 07b450f4..afa5398c 100644 --- a/scripts/test_broker_state_hash_coverage.py +++ b/scripts/test_broker_state_hash_coverage.py @@ -17,6 +17,8 @@ HEADER = (ROOT / "include/pineforge/engine.hpp").read_text() QUANTITY = (ROOT / "include/pineforge/quantity_intent.hpp").read_text() EVENTS = (ROOT / "include/pineforge/broker_events.hpp").read_text() +ACTIVATION = (ROOT / "include/pineforge/leg_activation.hpp").read_text() +EXIT_POLICY = (ROOT / "include/pineforge/compat/pine/exit_activation.hpp").read_text() BIRTH = (ROOT / "include/pineforge/order_birth.hpp").read_text() INTRADAY = (ROOT / "include/pineforge/compat/pine/intraday_order_budget.hpp").read_text() POLICY = (ROOT / "include/pineforge/compat/pine/intraday_cap.hpp").read_text() @@ -28,7 +30,7 @@ class PhysicalLotCoverage(unittest.TestCase): def check(self, header=HEADER, source=SOURCE, waivers=WAIVERS, events=EVENTS, - intraday=INTRADAY, policy=POLICY, obligation=OBLIGATION, stream=STREAM, quantity=QUANTITY, birth=BIRTH): + intraday=INTRADAY, policy=POLICY, obligation=OBLIGATION, stream=STREAM, quantity=QUANTITY, birth=BIRTH, activation=ACTIVATION, exit_policy=EXIT_POLICY): with tempfile.TemporaryDirectory(prefix="pf-lot-hash-check-") as temp: root = Path(temp) for name, content in [ @@ -36,6 +38,8 @@ def check(self, header=HEADER, source=SOURCE, waivers=WAIVERS, events=EVENTS, ("include/pineforge/broker_events.hpp", events), ("include/pineforge/quantity_intent.hpp", quantity), ("include/pineforge/order_birth.hpp", birth), + ("include/pineforge/leg_activation.hpp", activation), + ("include/pineforge/compat/pine/exit_activation.hpp", exit_policy), ("include/pineforge/compat/pine/intraday_order_budget.hpp", intraday), ("include/pineforge/compat/pine/intraday_cap.hpp", policy), ("include/pineforge/position_close_obligation.hpp", obligation), @@ -55,6 +59,16 @@ def check(self, header=HEADER, source=SOURCE, waivers=WAIVERS, events=EVENTS, output.write(str(exc.code)) return code, output.getvalue() + def test_exit_activation_has_complete_unconditional_coverage(self): + for fold in ["f.b(o.leg_activation.bounds().has_value());", "f.i(bounds->position_cycle);", + "f.i(bounds->stop_first_bar);", "f.i(bounds->limit_first_bar);", + "f.b(o.pine_exit_activation.evidence().has_value());", "f.d(evidence->stop_level);", + "f.d(evidence->limit_level);", "f.i(evidence->direction);", + "f.u(continuation->observed_fill_sequence);", "f.b(evidence->limit_continuation.has_value());"]: + with self.subTest(fold=fold): self.assertEqual(self.check(source=SOURCE.replace(fold,""))[0],1) + self.assertEqual(self.check(activation=ACTIVATION.replace("int64_t limit_first_bar;", "int64_t limit_first_bar; int64_t hidden;"))[0],1) + self.assertEqual(self.check(exit_policy=EXIT_POLICY.replace("double cursor_price;", "double cursor_price; double hidden;"))[0],1) + def test_actual_source_and_named_parser(self): self.assertEqual(self.check()[0], 0) self.assertEqual(len(members(HEADER, "PyramidEntry")), 18) @@ -85,26 +99,26 @@ def test_quantity_model_additions_and_variant_drift_fail_closed(self): self.assertIn(old, QUANTITY) self.assertEqual(self.check(quantity=QUANTITY.replace(old, new))[0], 1) - def test_layout_and_hash_versions_must_match_v5_contract(self): - self.assertIn("engine_script_run_v5", HEADER) - self.assertIn('f.s("pineforge-broker-state/v5");', SOURCE) - self.assertIn("integer(5); integer(broker_state_hash());", STREAM) - self.assertEqual(self.check(header=HEADER.replace("engine_script_run_v5", "engine_script_run_v2"))[0], 1) + def test_layout_and_hash_versions_must_match_v6_contract(self): + self.assertIn("engine_script_run_v6", HEADER) + self.assertIn('f.s("pineforge-broker-state/v6");', SOURCE) + self.assertIn("integer(6); integer(broker_state_hash());", STREAM) + self.assertEqual(self.check(header=HEADER.replace("engine_script_run_v6", "engine_script_run_v2"))[0], 1) for replacement in ['f.s("pineforge-broker-state/v2");', '', - '// f.s("pineforge-broker-state/v5");']: + '// f.s("pineforge-broker-state/v6");']: self.assertEqual(self.check(source=SOURCE.replace( - 'f.s("pineforge-broker-state/v5");', replacement))[0], 1) + 'f.s("pineforge-broker-state/v6");', replacement))[0], 1) for replacement in ["integer(2); integer(broker_state_hash());", "integer(broker_state_hash());", - "if (false) { integer(5); integer(broker_state_hash()); }"]: + "if (false) { integer(6); integer(broker_state_hash()); }"]: self.assertEqual(self.check(stream=STREAM.replace( - "integer(5); integer(broker_state_hash());", replacement))[0], 1) + "integer(6); integer(broker_state_hash());", replacement))[0], 1) def test_version_folds_in_unrelated_helpers_do_not_cover_entry_points(self): - broker_fold = 'f.s("pineforge-broker-state/v5");' + broker_fold = 'f.s("pineforge-broker-state/v6");' altered = SOURCE.replace(broker_fold, '') + '\nvoid other() { ' + broker_fold + ' }\n' self.assertEqual(self.check(source=altered)[0], 1) - stream_fold = "integer(5); integer(broker_state_hash());" + stream_fold = "integer(6); integer(broker_state_hash());" altered = STREAM.replace(stream_fold, '') + '\nvoid other() { ' + stream_fold + ' }\n' self.assertEqual(self.check(stream=altered)[0], 1) diff --git a/src/compat/pine/exit_activation.cpp b/src/compat/pine/exit_activation.cpp new file mode 100644 index 00000000..166f9bc0 --- /dev/null +++ b/src/compat/pine/exit_activation.cpp @@ -0,0 +1,64 @@ +#include +#include +#include "../../engine_internal.hpp" +#include + +namespace pineforge::compat::pine { + +bool ExitActivationPolicy::holds_stop() const { + return evidence_ && !std::isnan(evidence_->stop_level) + && (evidence_->direction > 0 ? evidence_->cursor_price <= evidence_->stop_level + : evidence_->cursor_price >= evidence_->stop_level); +} +bool ExitActivationPolicy::holds_limit() const { + return evidence_ && !evidence_->limit_continuation && !std::isnan(evidence_->limit_level) + && (evidence_->direction > 0 ? evidence_->cursor_price >= evidence_->limit_level + : evidence_->cursor_price <= evidence_->limit_level); +} +bool ExitActivationPolicy::continues_at_later_open() const { + return evidence_ && evidence_->limit_continuation + && evidence_->limit_continuation->cause == LimitContinuationCause::LaterSameOpen; +} +ExitLegActivationBounds ExitActivationPolicy::resolve(int64_t cycle, int entry_bar) const { + const int64_t first = entry_bar; + return {cycle, first + (holds_stop() ? 1 : 0), first + (holds_limit() ? 1 : 0)}; +} + +ExitActivationPolicy select_exit_activation(const PendingOrder& order, + double stop, double limit, const ExitActivationContext& c) { + if (!c.fill_recalc || !c.scheduler || !std::isfinite(c.cursor_price) + || c.side == PositionSide::FLAT || c.position_open_bar != c.bar_index) + return {}; + const bool long_side = c.side == PositionSide::LONG; + const bool limit_marketable = !std::isnan(limit) + && (long_side ? c.cursor_price >= limit : c.cursor_price <= limit); + const bool trailing = !std::isnan(order.trail_points) || !std::isnan(order.trail_price); + const bool later_open = !c.magnifier && historical_cascade_reach(order) + && c.after_first_open_fill && c.recalc_leg == 0 + && (!std::isnan(stop) || !std::isnan(limit)) && !trailing && limit_marketable; + const bool first_high_recross = !c.magnifier && !c.process_on_close + && !c.warmup && c.stream_idle && historical_cascade_reach(order) + && !c.historical_segment && c.at_extreme && c.historical_point == 1 + && c.recalc_leg == 1 && c.market_recalc_incarnation != 0 + && c.market_recalc_fill == c.current_fill + && long_side && c.position_entry_count == 1 && c.pyramiding == 0 + && c.lot_count == 1 && c.first_lot_incarnation == c.market_recalc_incarnation + && !order.from_entry.empty() && order.from_entry == c.first_lot_id + && !order.quantity_request.is_partial(internal::kFullQtyEps, internal::kFullPercentEps) + && std::isfinite(order.qty) + && std::abs(order.qty - c.position_quantity) <= internal::kQtyEpsilon + && c.pending_empty && order.oca_name.empty() && !trailing + && c.slippage == 0 && c.pointvalue == 1 && c.account_fx == 1 + && c.fx_series_empty && limit_marketable + && internal::bar_path_uses_high_first(c.bar) + && c.cursor_price == c.tick_high + && c.bar.low < order.limit_price && order.limit_price < c.bar.high + && (std::isnan(order.stop_price) || order.stop_price < c.bar.low); + std::optional continuation; + if (later_open) continuation = LimitContinuation{LimitContinuationCause::LaterSameOpen, c.current_fill}; + else if (first_high_recross) continuation = LimitContinuation{LimitContinuationCause::FirstHighRecross, c.current_fill}; + return ExitActivationPolicy({c.cycle, c.position_open_bar, long_side ? 1 : -1, + c.cursor_price, stop, limit, continuation}); +} + +} // namespace pineforge::compat::pine diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index e124c43c..eb5db227 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -683,11 +683,13 @@ BacktestEngine::CoofFillResult BacktestEngine::process_next_pending_order( const bool lower = raw == chart_bar->low && tick < raw; const bool long_position = position_side_ == PositionSide::LONG; const bool stop_touch = std::isfinite(order.stop_price) + && order.leg_activation.stop_ready(position_cycle_seq_, bar_index_) && ((!long_position && upper && raw < order.stop_price && order.stop_price <= tick) || (long_position && lower && tick <= order.stop_price && order.stop_price < raw)); const bool limit_touch = std::isfinite(order.limit_price) + && order.leg_activation.limit_ready(position_cycle_seq_, bar_index_) && ((long_position && upper && raw < order.limit_price && order.limit_price <= tick) || (!long_position && lower && tick <= order.limit_price @@ -2298,7 +2300,9 @@ void BacktestEngine::revive_position_brackets_after_margin_call_partial( ? o.qty_percent >= 100.0 - internal::kFullPercentEps : (!o.quantity_request.is_partial(kFullQtyEps, kFullPercentEps) || o.qty >= position_qty_ - kQtyEpsilon); + // Revival restores the leg, but cannot advance its activation bound. if (!full_pct || std::isnan(revive_stop) + || !o.leg_activation.stop_ready(position_cycle_seq_, bar_index_) || !std::isfinite(mc_price)) continue; const bool mk = (position_side_ == PositionSide::SHORT) ? (revive_stop <= mc_price) @@ -3072,10 +3076,10 @@ void BacktestEngine::sort_exit_siblings_by_path_fill(const Bar& bar) { bool is_ent_bar = (position_open_bar_ == bar_index_); double ma = exit_order_earliest_path_metric_no_trail( trigger_bar, high_first, a, position_side_, is_ent_bar, - position_entry_price_); + position_entry_price_, position_cycle_seq_, bar_index_); double mb = exit_order_earliest_path_metric_no_trail( trigger_bar, high_first, b, position_side_, is_ent_bar, - position_entry_price_); + position_entry_price_, position_cycle_seq_, bar_index_); const double inf = std::numeric_limits::infinity(); const double eps = kPathPosEps; if (ma < inf && mb < inf) { @@ -4113,19 +4117,15 @@ void BacktestEngine::sort_orders_by_fill_phase(const Bar& bar) { } bool exit_style = order_is_exit_style(o, position_side_); - const bool suppress_entry_bar_leg = - exit_style && position_open_bar_ == bar_index_; // Round 9 family X: a dormant bracket's stop / limit legs // are dead (finding-311 leg-scoped) — only its trail leg // can still fill, on the path. bool has_stop = !std::isnan(o.stop_price) && !o.dormant_bracket - && !(suppress_entry_bar_leg - && o.coof_suppress_stop_on_entry_bar); + && (!exit_style || o.leg_activation.stop_ready(position_cycle_seq_, bar_index_)); bool has_limit = !std::isnan(o.limit_price) && !o.dormant_bracket - && !(suppress_entry_bar_leg - && o.coof_suppress_limit_on_entry_bar); + && (!exit_style || o.leg_activation.limit_ready(position_cycle_seq_, bar_index_)); bool has_trail = !std::isnan(o.trail_points) || !std::isnan(o.trail_price); if (o.type == OrderType::MARKET @@ -4646,10 +4646,14 @@ bool BacktestEngine::prearmed_market_parent_bracket_gaps_at_open( // try_exit_open_gap_fill's resting-bracket precedence (trail, stop, // limit) for the same open-gap event on a later bar. const bool live_long = position_side_ == PositionSide::LONG; + // Apply readiness before precedence so a held stop cannot hide a ready + // limit or acquire a fill merely because an independent trail is present. const bool stop_gapped = std::isfinite(order.stop_price) + && order.leg_activation.stop_ready(position_cycle_seq_, bar_index_) && (live_long ? bar.open <= order.stop_price : bar.open >= order.stop_price); const bool limit_marketable = std::isfinite(order.limit_price) + && order.leg_activation.limit_ready(position_cycle_seq_, bar_index_) && (live_long ? bar.open >= order.limit_price : bar.open <= order.limit_price); if (!stop_gapped && !limit_marketable) return false; @@ -7779,6 +7783,7 @@ void BacktestEngine::apply_raw_order_fill(PendingOrder& order, double fill_price if (stream_observe_actions_) stream_observe_entry(pyramid_entries_.back()); id_unclosed_qty_[order.id] += qty; cycle_filled_entry_ids_.insert(order.id); + bind_retained_exit_activations(); if (!std::isnan(order.stop_price) || !std::isnan(order.limit_price)) { set_entry_fill_excursion_masks(pyramid_entries_.back(), current_bar_, fill_price); } @@ -8483,10 +8488,10 @@ BacktestEngine::FillEvaluation BacktestEngine::evaluate_fill_price( std::unordered_set& pass0_opposing_skip_ids) { bool exit_style = order_is_exit_style(order, position_side_); bool is_entry_bar = (exit_style && position_open_bar_ == bar_index_); - const bool suppress_stop = - is_entry_bar && order.coof_suppress_stop_on_entry_bar; - const bool suppress_limit = - is_entry_bar && order.coof_suppress_limit_on_entry_bar; + const bool suppress_stop = exit_style + && !order.leg_activation.stop_ready(position_cycle_seq_, bar_index_); + const bool suppress_limit = exit_style + && !order.leg_activation.limit_ready(position_cycle_seq_, bar_index_); // Round 9 family X (finding-311 is leg-scoped): a bracket killed by a // declined reversal reaches this kernel only for its live TRAIL leg; // its stop and limit legs stay dead until REVIVE-A/B. diff --git a/src/engine_internal.hpp b/src/engine_internal.hpp index 4f419145..a9c4a57f 100644 --- a/src/engine_internal.hpp +++ b/src/engine_internal.hpp @@ -376,14 +376,14 @@ double exit_order_earliest_path_metric_no_trail( const PendingOrder& order, PositionSide position_side, bool is_entry_bar, - double position_entry_price); + double position_entry_price, int64_t position_cycle = 0, int64_t bar_index = 0); double exit_order_earliest_path_metric_no_trail( const Bar& bar, bool high_first, const PendingOrder& order, PositionSide position_side, bool is_entry_bar, - double position_entry_price); + double position_entry_price, int64_t position_cycle = 0, int64_t bar_index = 0); // design-stop-tick-rounding: `tick_bar` is the bar the STOP / LIMIT legs are diff --git a/src/engine_orders.cpp b/src/engine_orders.cpp index b461fc4e..cbcb33c7 100644 --- a/src/engine_orders.cpp +++ b/src/engine_orders.cpp @@ -737,6 +737,7 @@ void BacktestEngine::emit_close_trade(const PyramidEntry& pe, double close_qty, // every full-close path (execute_market_exit) and by partial-exit settlement // when the FIFO loop drained the position. void BacktestEngine::reset_position_state_to_flat() { + unbind_exit_activations(); position_side_ = PositionSide::FLAT; position_cycle_seq_ = 0; position_entry_price_ = 0.0; @@ -798,6 +799,26 @@ void BacktestEngine::settle_position_after_partial_exit( } +// Exposure transitions resolve activation once. Matchers never refresh a +// deadline from whichever position happens to be current at read time. +void BacktestEngine::bind_exit_activation(PendingOrder& order) { + if (order.type != OrderType::EXIT) return; + if (position_side_ == PositionSide::FLAT || position_cycle_seq_ <= 0) { + order.leg_activation.unbind(); + return; + } + order.leg_activation.bind(order.pine_exit_activation.resolve( + position_cycle_seq_, position_open_bar_)); +} +void BacktestEngine::bind_retained_exit_activations() { + for (auto& order : pending_orders_) bind_exit_activation(order); +} +void BacktestEngine::unbind_exit_activations() { + for (auto& order : pending_orders_) { + if (order.type == OrderType::EXIT) order.leg_activation.unbind(); + } +} + // Establish a fresh position at fill_price/qty after a transition from FLAT // or a same-bar close. Resets all per-position state and seeds the first // pyramid entry. Used by every entry path that opens a brand-new position @@ -831,6 +852,7 @@ void BacktestEngine::open_fresh_position(PositionSide requested, double fill_pri if (stream_observe_actions_) stream_observe_entry(pyramid_entries_.back()); id_unclosed_qty_[id] += qty; cycle_filled_entry_ids_.insert(id); + bind_retained_exit_activations(); } diff --git a/src/engine_path_resolve.cpp b/src/engine_path_resolve.cpp index 3ddd64cf..9db88062 100644 --- a/src/engine_path_resolve.cpp +++ b/src/engine_path_resolve.cpp @@ -1108,10 +1108,10 @@ double exit_order_earliest_path_metric_no_trail( const PendingOrder& order, PositionSide position_side, bool is_entry_bar, - double position_entry_price) { + double position_entry_price, int64_t position_cycle, int64_t bar_index) { return exit_order_earliest_path_metric_no_trail( bar, bar_path_uses_high_first(bar), order, position_side, - is_entry_bar, position_entry_price); + is_entry_bar, position_entry_price, position_cycle, bar_index); } double exit_order_earliest_path_metric_no_trail( @@ -1120,7 +1120,7 @@ double exit_order_earliest_path_metric_no_trail( const PendingOrder& order, PositionSide position_side, bool is_entry_bar, - double position_entry_price) { + double position_entry_price, int64_t position_cycle, int64_t bar_index) { if (order.type != OrderType::EXIT) { return std::numeric_limits::infinity(); } @@ -1129,15 +1129,14 @@ double exit_order_earliest_path_metric_no_trail( } const bool is_long = (position_side == PositionSide::LONG); - // COOF entry-bar suppression is leg-scoped. A wrong-side leg is dormant - // only for the creation/entry bar; a correctly-sided sibling must retain - // its real path coordinate so sibling ordering cannot hide its fill. + // The owner transition resolved each leg's lower-bound coordinate. An + // unavailable leg cannot hide its independently ready sibling's path. const double stop_price = - is_entry_bar && order.coof_suppress_stop_on_entry_bar + !order.leg_activation.stop_ready(position_cycle, bar_index) ? std::numeric_limits::quiet_NaN() : order.stop_price; const double limit_price = - is_entry_bar && order.coof_suppress_limit_on_entry_bar + !order.leg_activation.limit_ready(position_cycle, bar_index) ? std::numeric_limits::quiet_NaN() : order.limit_price; if (std::isnan(stop_price) && std::isnan(limit_price)) { diff --git a/src/engine_state_hash.cpp b/src/engine_state_hash.cpp index f7222195..704892ee 100644 --- a/src/engine_state_hash.cpp +++ b/src/engine_state_hash.cpp @@ -86,9 +86,9 @@ void hash_str_set(Fnv& f, const std::unordered_set& s) { uint64_t BacktestEngine::broker_state_hash() const { Fnv f; - // v5 hashes typed request/reservation, generic predecessor and immutable birth. + // v6 includes resolved exit-leg owner/coordinates and immutable Pine placement evidence. // It is a serialization boundary, independent of the public C ABI version. - f.s("pineforge-broker-state/v5"); + f.s("pineforge-broker-state/v6"); // --- Position core --- f.i(static_cast(position_side_)); @@ -246,8 +246,19 @@ uint64_t BacktestEngine::broker_state_hash() const { f.u(o.named_cancel_surviving_exit_incarnation); // calc_on_order_fills birth provenance and per-leg suppression // (decide which waypoints / legs the order may fill at). - f.b(o.coof_suppress_stop_on_entry_bar); - f.b(o.coof_suppress_limit_on_entry_bar); + f.b(o.leg_activation.bounds().has_value()); + if (const auto& bounds = o.leg_activation.bounds()) { + f.i(bounds->position_cycle); f.i(bounds->stop_first_bar); f.i(bounds->limit_first_bar); + } + f.b(o.pine_exit_activation.evidence().has_value()); + if (const auto& evidence = o.pine_exit_activation.evidence()) { + f.i(evidence->position_cycle); f.i(evidence->entry_bar); f.i(evidence->direction); + f.d(evidence->cursor_price); f.d(evidence->stop_level); f.d(evidence->limit_level); + f.b(evidence->limit_continuation.has_value()); + if (const auto& continuation = evidence->limit_continuation) { + f.i(static_cast(continuation->cause)); f.u(continuation->observed_fill_sequence); + } + } f.i(static_cast(o.birth.cause())); f.i(o.birth.bar()); f.i(o.birth.timestamp()); diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index 302ea411..5a0fd56a 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -2171,75 +2171,27 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro order.birth, has_trail_request); // Later-open trailing permission is derived by the Pine policy from the // immutable physical origin. It never rewrites that origin. - // A priced exit born after a later fill at the SAME O is already held by - // the KI-67 cascade gate for its in-flight leg 0. The pinned exception is - // LIMIT-only: a marketable limit may resume at W1. A marketable stop keeps - // the established whole-entry-bar suppression (including M1). - const bool later_same_open_priced_exit_on_entry_bar = - !bar_magnifier_enabled_ && compat::pine::historical_cascade_reach(order) - && coof_recalc_after_first_open_fill_ - && coof_cascade_recalc_leg_ == 0 - && position_open_bar_ == bar_index_ - && (!std::isnan(stop_price) || !std::isnan(limit_price)) - && std::isnan(trail_points) && std::isnan(trail_price); - bool stop_marketable_at_coof_cursor = false; - bool limit_marketable_at_coof_cursor = false; - bool later_same_open_marketable_limit = false; - if (coof_fill_recalc_active_ && coof_scheduler_active_ - && std::isfinite(coof_cursor_price_) - && position_side_ != PositionSide::FLAT - && position_open_bar_ == bar_index_) { - const bool closing_long = position_side_ == PositionSide::LONG; - stop_marketable_at_coof_cursor = !std::isnan(stop_price) - && (closing_long ? coof_cursor_price_ <= stop_price - : coof_cursor_price_ >= stop_price); - limit_marketable_at_coof_cursor = !std::isnan(limit_price) - && (closing_long ? coof_cursor_price_ >= limit_price - : coof_cursor_price_ <= limit_price); - later_same_open_marketable_limit = - later_same_open_priced_exit_on_entry_bar - && limit_marketable_at_coof_cursor; - // Round15 F/EUR JOAT pins: a fresh MARKET long at W1=H arms a - // marketable full limit, waits through H->L, and takes its later L->C - // recross at the exact level. It neither fills at placement nor gets - // a new waypoint-gap permission. Keep KI-67's existing leg gate. - // This first patch covers only a limit strictly inside H/L with no - // reachable stop competitor and the actual opening's first callback. - const bool first_high_market_limit_recross = - !bar_magnifier_enabled_ && !process_orders_on_close_ - && !stream_warmup_mode_ && stream_phase_ == StreamPhase::IDLE - && compat::pine::historical_cascade_reach(order) && !coof_hist_is_segment_ - && coof_at_extreme_waypoint_ && coof_hist_path_index_ == 1 - && coof_cascade_recalc_leg_ == 1 - && coof_market_entry_recalc_incarnation_ != 0 - && coof_market_entry_recalc_fill_seq_ == broker_fill_event_seq_ - && position_side_ == PositionSide::LONG - && position_entry_count_ == 1 && pyramiding_ == 0 - && pyramid_entries_.size() == 1 - && pyramid_entries_.front().entry_incarnation - == coof_market_entry_recalc_incarnation_ - && !from_entry.empty() - && from_entry == pyramid_entries_.front().entry_id - && !is_partial && std::isfinite(reserved_qty) - && std::abs(reserved_qty - position_qty_) <= kQtyEpsilon - && pending_orders_.empty() && oca_name.empty() - && !has_trail_request && slippage_ == 0 - && syminfo_.pointvalue == 1 && account_currency_fx_ == 1 - && account_currency_fx_timestamps_.empty() - && limit_marketable_at_coof_cursor - && internal::bar_path_uses_high_first(current_bar_) - && coof_cursor_price_ == bar_fill_price(current_bar_.high) - && current_bar_.low < order.limit_price - && order.limit_price < current_bar_.high - && (std::isnan(order.stop_price) - || order.stop_price < current_bar_.low); - order.coof_suppress_stop_on_entry_bar = - stop_marketable_at_coof_cursor; - order.coof_suppress_limit_on_entry_bar = - limit_marketable_at_coof_cursor - && !later_same_open_marketable_limit - && !first_high_market_limit_recross; - } + const std::string no_entry_id; + const auto& first_entry_id = pyramid_entries_.empty() + ? no_entry_id : pyramid_entries_.front().entry_id; + const uint64_t first_entry_incarnation = pyramid_entries_.empty() + ? 0 : pyramid_entries_.front().entry_incarnation; + order.pine_exit_activation = compat::pine::select_exit_activation( + order, stop_price, limit_price, + {current_bar_, position_side_, position_cycle_seq_, bar_index_, + position_open_bar_, position_entry_count_, position_qty_, pyramiding_, + pyramid_entries_.size(), first_entry_id, first_entry_incarnation, + coof_fill_recalc_active_, coof_scheduler_active_, coof_cursor_price_, + coof_recalc_after_first_open_fill_, coof_cascade_recalc_leg_, + coof_hist_is_segment_, coof_at_extreme_waypoint_, coof_hist_path_index_, + coof_market_entry_recalc_incarnation_, coof_market_entry_recalc_fill_seq_, + broker_fill_event_seq_, bar_magnifier_enabled_, process_orders_on_close_, + stream_warmup_mode_, stream_phase_ == StreamPhase::IDLE, + pending_orders_.empty(), slippage_, syminfo_.pointvalue, account_currency_fx_, + account_currency_fx_timestamps_.empty(), bar_fill_price(current_bar_.high)}); + const bool later_same_open_marketable_limit = + order.pine_exit_activation.continues_at_later_open(); + bind_exit_activation(order); // KI-67 exit cascade (Model S). Record this mid-bar cascade exit's in-flight // leg so the historical dispatch gate can hold it on that leg's remainder, // exact-fill it on subsequent legs, and gap-fill it at the in-flight leg-end @@ -2890,6 +2842,7 @@ uint64_t BacktestEngine::queue_deferred_close_order( // against E2 can reserve 2 against new E4). Do not fabricate an original // Pine percentage, or an exposure-coverage receipt before that binding. order.quantity_request.request(QuantityIntent::units(qty_to_close)); + bind_exit_activation(order); order.oca_name = ""; order.oca_type = 0; order.created_bar = bar_index_; diff --git a/src/engine_stream.cpp b/src/engine_stream.cpp index c121c11f..1e522edd 100644 --- a/src/engine_stream.cpp +++ b/src/engine_stream.cpp @@ -678,7 +678,7 @@ uint64_t BacktestEngine::stream_state_hash() const { integer(static_cast(bar.timestamp)); real(bar.open); real(bar.high); real(bar.low); real(bar.close); real(bar.volume); }; - integer(5); integer(broker_state_hash()); + integer(6); integer(broker_state_hash()); integer(static_cast(stream_phase_)); integer(static_cast(stream_input_mode_)); integer(static_cast(stream_input_tf_ms_)); diff --git a/src/pending_order_mirror.cpp b/src/pending_order_mirror.cpp index f0e99c57..e6e82a76 100644 --- a/src/pending_order_mirror.cpp +++ b/src/pending_order_mirror.cpp @@ -58,8 +58,8 @@ void fill_pending_order_mirror(const PendingOrder& src, pf_pending_order_v1_t* o out->recreated_after_named_cancelled_entry_incarnation = src.recreated_after_named_cancelled_entry_incarnation; out->named_cancel_surviving_exit_incarnation = src.named_cancel_surviving_exit_incarnation; out->stop_limit_activated = src.stop_limit_activated ? 1 : 0; - out->coof_suppress_stop_on_entry_bar = src.coof_suppress_stop_on_entry_bar ? 1 : 0; - out->coof_suppress_limit_on_entry_bar = src.coof_suppress_limit_on_entry_bar ? 1 : 0; + out->coof_suppress_stop_on_entry_bar = src.pine_exit_activation.holds_stop() ? 1 : 0; + out->coof_suppress_limit_on_entry_bar = src.pine_exit_activation.holds_limit() ? 1 : 0; out->created_during_coof_recalc = src.birth.from_fill() ? 1 : 0; out->coof_born_at_close_recalc = src.birth.at_terminal_fill() ? 1 : 0; out->coof_born_mid_bar = compat::pine::historical_cascade_reach(src) ? 1 : 0; @@ -150,6 +150,20 @@ void fill_pending_order_mirror(const PendingOrder& src, pf_pending_order_v1_t* o out->quantity_reservation_present = src.quantity_request.reservation().has_value() ? 1 : 0; out->quantity_reservation_units = src.quantity_request.reservation() ? src.quantity_request.reservation()->units : 0.0; out->quantity_reservation_basis_units = src.quantity_request.reservation() ? src.quantity_request.reservation()->basis_units : 0.0; + out->leg_activation_owner_cycle = src.leg_activation.bounds() ? src.leg_activation.bounds()->position_cycle : 0; + out->leg_activation_present = src.leg_activation.bounds().has_value() ? 1 : 0; + out->leg_activation_stop_first_bar = src.leg_activation.bounds() ? src.leg_activation.bounds()->stop_first_bar : 0; + out->leg_activation_limit_first_bar = src.leg_activation.bounds() ? src.leg_activation.bounds()->limit_first_bar : 0; + out->pine_exit_activation_owner_cycle_at_birth = src.pine_exit_activation.evidence() ? src.pine_exit_activation.evidence()->position_cycle : 0; + out->pine_exit_activation_present = src.pine_exit_activation.evidence().has_value() ? 1 : 0; + out->pine_exit_activation_entry_bar_at_birth = src.pine_exit_activation.evidence() ? src.pine_exit_activation.evidence()->entry_bar : 0; + out->pine_exit_activation_direction_at_birth = src.pine_exit_activation.evidence() ? src.pine_exit_activation.evidence()->direction : 0; + out->pine_exit_activation_cursor_price_at_birth = src.pine_exit_activation.evidence() ? src.pine_exit_activation.evidence()->cursor_price : 0.0; + out->pine_exit_activation_stop_level_at_birth = src.pine_exit_activation.evidence() ? src.pine_exit_activation.evidence()->stop_level : 0.0; + out->pine_exit_activation_limit_level_at_birth = src.pine_exit_activation.evidence() ? src.pine_exit_activation.evidence()->limit_level : 0.0; + out->pine_exit_activation_limit_continuation_present = src.pine_exit_activation.evidence() && src.pine_exit_activation.evidence()->limit_continuation ? 1 : 0; + out->pine_exit_activation_limit_continuation_cause = src.pine_exit_activation.evidence() && src.pine_exit_activation.evidence()->limit_continuation ? static_cast(src.pine_exit_activation.evidence()->limit_continuation->cause) : 0; + out->pine_exit_activation_limit_continuation_fill = src.pine_exit_activation.evidence() && src.pine_exit_activation.evidence()->limit_continuation ? src.pine_exit_activation.evidence()->limit_continuation->observed_fill_sequence : 0; } namespace { @@ -287,6 +301,20 @@ const pf_field_desc_t kLayout[] = { PF_PO_FIELD(quantity_reservation_present, "uint8_t"), PF_PO_FIELD(quantity_reservation_units, "double"), PF_PO_FIELD(quantity_reservation_basis_units, "double"), + PF_PO_FIELD(leg_activation_owner_cycle, "int64_t"), + PF_PO_FIELD(leg_activation_present, "uint8_t"), + PF_PO_FIELD(leg_activation_stop_first_bar, "int64_t"), + PF_PO_FIELD(leg_activation_limit_first_bar, "int64_t"), + PF_PO_FIELD(pine_exit_activation_owner_cycle_at_birth, "int64_t"), + PF_PO_FIELD(pine_exit_activation_present, "uint8_t"), + PF_PO_FIELD(pine_exit_activation_entry_bar_at_birth, "int32_t"), + PF_PO_FIELD(pine_exit_activation_direction_at_birth, "int32_t"), + PF_PO_FIELD(pine_exit_activation_cursor_price_at_birth, "double"), + PF_PO_FIELD(pine_exit_activation_stop_level_at_birth, "double"), + PF_PO_FIELD(pine_exit_activation_limit_level_at_birth, "double"), + PF_PO_FIELD(pine_exit_activation_limit_continuation_present, "uint8_t"), + PF_PO_FIELD(pine_exit_activation_limit_continuation_cause, "int32_t"), + PF_PO_FIELD(pine_exit_activation_limit_continuation_fill, "uint64_t"), }; #undef PF_PO_FIELD diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index aab7cf1f..5a7de69a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,4 +1,6 @@ set(TEST_SOURCES + test_exit_leg_activation + test_exit_activation_routes test_pending_order_core test_pending_quantity_intent test_bulk_preflight diff --git a/tests/fixtures/leg_activation/149f77c_pending_mirror.hpp b/tests/fixtures/leg_activation/149f77c_pending_mirror.hpp new file mode 100644 index 00000000..0bc79a65 --- /dev/null +++ b/tests/fixtures/leg_activation/149f77c_pending_mirror.hpp @@ -0,0 +1,156 @@ +// GENERATED by scripts/gen_pending_order_mirror.py from include/pineforge/engine.hpp -- do not edit. +// 94 PendingOrder members mirrored (128 POD fields incl. struct_version/size). +#pragma once +#include + +/* C-compatible value snapshot of one resting pineforge::PendingOrder + * (spec 3.6). struct_version identifies the field set (this file: + * 1); size is sizeof(pf_pending_order_v1_t) as the producer + * compiled it. Strings are copied into a NUL-terminated char[64] + * (name_truncated = 1 when the source was longer than 63 bytes) with + * name_hash64 = FNV-1a 64 of the FULL source string. Enums are their + * int32 value; bool is 0/1 in a uint8_t. Append-only, like every + * pineforge.h POD. */ +typedef struct pf_pending_order_v1_s { + uint32_t struct_version; + uint32_t size; + char id[64]; + uint8_t id_truncated; + uint64_t id_hash64; + char from_entry[64]; + uint8_t from_entry_truncated; + uint64_t from_entry_hash64; + int32_t type; + uint8_t is_long; + double limit_price; + double stop_price; + double trail_points; + double trail_price; + double trail_offset; + double profit_ticks; + double loss_ticks; + double qty; + int32_t qty_type; + double qty_percent; + char oca_name[64]; + uint8_t oca_name_truncated; + uint64_t oca_name_hash64; + int32_t oca_type; + int32_t created_bar; + int64_t created_seq; + uint64_t incarnation; + uint8_t created_by_same_id_replacement; // deprecated, derived output only + uint64_t replaced_default_market_incarnation; + uint8_t declined_by_replaced_short_market; + uint64_t replaced_exit_order_incarnation; // deprecated, derived output only + uint64_t recreated_after_named_cancelled_entry_incarnation; + uint64_t named_cancel_surviving_exit_incarnation; + uint8_t stop_limit_activated; + uint8_t coof_suppress_stop_on_entry_bar; + uint8_t coof_suppress_limit_on_entry_bar; + uint8_t created_during_coof_recalc; // deprecated, derived output only + uint8_t coof_born_at_close_recalc; // deprecated, derived output only + uint8_t coof_born_mid_bar; // deprecated, derived output only + int32_t coof_cascade_seg_i; + uint8_t coof_cascade_inflight_fires; + int32_t created_position_side; + int64_t created_position_cycle_seq; + uint8_t created_after_position_close_in_bar; + uint8_t over_pyramiding_cap_at_placement; + int32_t same_id_stop_deferred_close_all_bar; + uint64_t same_id_stop_deferred_close_all_incarnation; + uint8_t reverses_same_bar_market_from_flat; + uint8_t paired_flat_market_candidate; + double paired_flat_market_own_qty; + double paired_flat_market_signal_close; + double paired_flat_market_signal_equity; + double paired_flat_market_signal_margin_pct; + double paired_flat_market_signal_pointvalue; + double paired_flat_market_signal_fx; + int64_t paired_flat_market_peer_seq; + double paired_flat_market_transaction_qty; + uint8_t default_flat_market_gross_candidate; + double tv_carry_qty; + double frozen_default_qty; + double default_stop_placement_qty; + double default_stop_placement_equity; + double default_stop_placement_signal_close; + double default_stop_sizing_price; + double sizing_equity; + double sizing_price; + double sizing_fx; + double sizing_mark; + uint8_t opening_affordability_exemption_candidate; + uint8_t explicit_flat_admission_candidate; + double explicit_placement_equity; + double explicit_slipped_signal_close; + double affordability_placement_equity; + double affordability_signal_price; + double affordability_held_qty; + uint8_t affordability_close_only; + uint8_t rounded_signal_cost_close_only; + int32_t signal_close_mc_bar; + uint64_t signal_close_mc_entry_incarnation; + uint64_t signal_close_mc_fill_seq; + double signal_close_mc_remaining_qty; + char comment[64]; + uint8_t comment_truncated; + uint64_t comment_hash64; + uint8_t requested_partial; // deprecated, derived output only + uint8_t full_percent_exit_request; // deprecated, derived output only + uint8_t pooc_global_full_exit_dynamic_qty; + uint8_t pooc_global_full_exit_tracks_bound_adds; + uint8_t pooc_global_full_exit_bound_add; + uint8_t created_while_in_position; // deprecated, derived output only + uint8_t sbmt_member; + double sbmt_own_qty; + double sbmt_tx_qty; + uint8_t sbmt_kept_over_cap; + double sbmt_close_qty; + uint8_t sbmt_close_buy; + uint8_t suppress_as_declined_reversal_close; + uint8_t dormant_bracket; + uint8_t dormant_reissue_pending; + double dormant_original_stop_price; + int32_t dormant_hold_bar; + int32_t dormant_reversal_kill_bar; + double dormant_trail_best; + double dormant_trail_best_start; + uint8_t dormant_trail_leg_dead; + double suppressed_close_consumed_ledger_qty; + double suppressed_close_retired_ledger_qty; + int32_t short_seed_collision_role; + uint64_t replaced_order_incarnation; + int64_t birth_timestamp; + int32_t birth_cause; + int32_t birth_bar; + int32_t birth_cursor_domain; + int32_t birth_cursor_position; + int32_t birth_cursor_index; + int32_t birth_cursor_count; + double birth_cursor_price; + uint64_t birth_first_fill; + uint64_t birth_last_fill; + uint64_t birth_evaluation_ordinal; + int32_t pine_birth_reach; + uint64_t quantity_intent_kind; + double quantity_intent_units; + double quantity_intent_numerator; + double quantity_intent_denominator; + uint8_t quantity_reservation_present; + double quantity_reservation_units; + double quantity_reservation_basis_units; +} pf_pending_order_v1_t; + +/* One row of the self-describing layout table returned by + * strategy_pending_order_layout(): field name, C type spelling + * ("uint8_t", "int32_t", "int64_t", "uint64_t", "double", "char[64]", + * "uint32_t"), byte offset inside pf_pending_order_v1_t, byte size. */ +typedef struct pf_field_desc_s { + const char* name; + const char* type; + uint32_t offset; + uint32_t size; +} pf_field_desc_t; +#define PF_PENDING_ORDER_STRUCT_VERSION 1 +#define PF_PENDING_ORDER_STR_CAP 64 diff --git a/tests/fixtures/leg_activation/README.md b/tests/fixtures/leg_activation/README.md new file mode 100644 index 00000000..d4e6829a --- /dev/null +++ b/tests/fixtures/leg_activation/README.md @@ -0,0 +1,2 @@ +Exact base149f77c public pending-order mirror, used only for compile-time prefix-layout controls. +SHA-256 27d2c6b6345521bcae14d24c23cce75846516b8bff52541ea1d499f8e2fc8163 diff --git a/tests/fixtures/script_cpp_abi/base149/README.md b/tests/fixtures/script_cpp_abi/base149/README.md new file mode 100644 index 00000000..bb9c862e --- /dev/null +++ b/tests/fixtures/script_cpp_abi/base149/README.md @@ -0,0 +1,2 @@ +Exact shipped149f77c/v5 public header closure for native, generated-style and standalone PendingOrder compile/link-only controls. +No executable runs; old symbol stubs only prove link compatibility. Files are independently bound to original Git blobs. diff --git a/tests/fixtures/script_cpp_abi/base149/headers.json.gz b/tests/fixtures/script_cpp_abi/base149/headers.json.gz new file mode 100644 index 00000000..3b74f40b Binary files /dev/null and b/tests/fixtures/script_cpp_abi/base149/headers.json.gz differ diff --git a/tests/fixtures/script_cpp_abi/base149/manifest.json b/tests/fixtures/script_cpp_abi/base149/manifest.json new file mode 100644 index 00000000..84280875 --- /dev/null +++ b/tests/fixtures/script_cpp_abi/base149/manifest.json @@ -0,0 +1,81 @@ +{ + "source_commit": "149f77ce16ef84c6da77e67d812bf8fa88e51cde", + "internal_namespace": "engine_script_run_v5", + "archive": "headers.json.gz", + "archive_sha256": "53d75684e34d42b684e5bbc389430ca1491173b755b174998cb3f2612533a5bc", + "generated_dependency": "pineforge/version.h comes from the matching current configure; no frozen layout uses its value.", + "files": { + "pineforge/bar.hpp": { + "sha256": "665c2528dca64ac9474de622dfd7ffdb540ff91316f70317e39aada158d1e0de", + "git_blob": "836f73daab382e9c8d5a6d03a942a921e39b756f" + }, + "pineforge/broker_events.hpp": { + "sha256": "4f3d9948a4d1604750901fa3982d6ea49bd50dd15a86c4e5d690b83d1f5f23c0", + "git_blob": "2972d53c7b039af266f4da161301c01eec54c16d" + }, + "pineforge/compat/pine/intraday_cap.hpp": { + "sha256": "0ac0eab34625ab9a974037ff8c0217603a01deb81599350a4b24f7bf48ac0cbd", + "git_blob": "0aba76b6a78251df33e92f19290473adecf5a61e" + }, + "pineforge/compat/pine/intraday_order_budget.hpp": { + "sha256": "43f7700f9c66c630ab8a9d8fd899eecb03c2fd9aabff11d2dc2b7fa85f92981b", + "git_blob": "730d941fffa3939e375489f40a50901ccea9ec87" + }, + "pineforge/compat/pine/order_birth.hpp": { + "sha256": "2c55abf6eb76d24c1f3a8e2bdbebf75928ecf5941d144d6b684e53c39f712d7f", + "git_blob": "78906582c661cbf5377ab963d56474a568d5bd7d" + }, + "pineforge/compat/pine/order_priority.hpp": { + "sha256": "f8c828181cb4c3c2b2a77a84c76daa248383a03d63c2dcd2a666d41589dbe6aa", + "git_blob": "3621990f3c450e359fee60238a4a54e6348cc759" + }, + "pineforge/engine.hpp": { + "sha256": "5ba773889d947e4fdab3995cc55f22f88ab037a86e0ad4016a126d297ce82eed", + "git_blob": "7a75d98abe02a9253b91c258e724ef59da3bda8b" + }, + "pineforge/magnifier.hpp": { + "sha256": "c0ed220b6df054b44596a434d4f48b1c3e960ca329bb8644a405a608175e06ff", + "git_blob": "1bbc910a869d162900d7610fa0bd9a88776b7b0f" + }, + "pineforge/na.hpp": { + "sha256": "0e8c256fcdeda94a0855458fa3b8c4ad4f9864dc2aecce969934f6bea82fd0a5", + "git_blob": "e98505aadc68a09cbe5a14aacf591a799a15ecd2" + }, + "pineforge/order_birth.hpp": { + "sha256": "0eadb3e2a92248fbc8e6ba8f94285b15b0e83d7a0396f5904b40c216619604e4", + "git_blob": "ed4a4038e9d079c92b4ebd777a5f22f1f1256b0c" + }, + "pineforge/order_priority.hpp": { + "sha256": "25bb7898972153b044675245db230c1171644856ef904f4dd02353b8968d433b", + "git_blob": "3e9fa5ee4121971a7962db8a60859aba6fb9ec95" + }, + "pineforge/pending_order_mirror.hpp": { + "sha256": "27d2c6b6345521bcae14d24c23cce75846516b8bff52541ea1d499f8e2fc8163", + "git_blob": "0bc79a658a04168196bd555ee2ee55d95b556baf" + }, + "pineforge/pineforge.h": { + "sha256": "1e0def60e31f529dcb821502c0f7104b473c06be0fa1df713be0f408e04e9288", + "git_blob": "232cd6c66d3f6e6ffdcffdf246e6a9dcce3ef3f8" + }, + "pineforge/position_close_obligation.hpp": { + "sha256": "6f6ad0aaca952dda7a9386cb78e57705b3494364dbd8970b0a051fe99d05b777", + "git_blob": "09576ee0f2a1d49becc46f5701f4369cf8f87bf6" + }, + "pineforge/quantity_intent.hpp": { + "sha256": "c4cc0f1cd5d6ed1f248825712a5ba4b4f5a808b787c05c58971934b89d770b90", + "git_blob": "41262fee5aa2db4032fc008542c573032563629b" + }, + "pineforge/series.hpp": { + "sha256": "e7709f67df9046c9eba163c2dbe91f8161084a955265ccf6b163355856c0afde", + "git_blob": "61baaef2c351d37b0d6a5eb179e91ce670eb83c3" + }, + "pineforge/session_time.hpp": { + "sha256": "f58c94bfae948e08fa21e52f20eae3940b31e398d83b800baf3a31f3a5fde669", + "git_blob": "af3a4472d852e0965947c006d0b6ad85304539e7" + }, + "pineforge/timeframe.hpp": { + "sha256": "5e4c312da463fdcff55e0887188523c8bbf85a2ae62f3317a2b23f22fe0161e2", + "git_blob": "3d1bc9d495ac7e5b39ea5bae57c9d9afe580192b" + } + } +} diff --git a/tests/test_exit_activation_routes.cpp b/tests/test_exit_activation_routes.cpp new file mode 100644 index 00000000..5a31d52a --- /dev/null +++ b/tests/test_exit_activation_routes.cpp @@ -0,0 +1,203 @@ +// Literal native readiness contracts. No Pine, external tapes, or grader. +#include +#include +#include +#include +#include +#include +using namespace pineforge; +namespace { +constexpr double missing = std::numeric_limits::quiet_NaN(); +int checks = 0, failures = 0; +#define CHECK(value) do { ++checks; if (!(value)) { ++failures; std::fprintf(stderr, "FAIL %d: %s\n", __LINE__, #value); } } while (0) + +class MarginBook : public BacktestEngine { +public: + MarginBook() { + initial_capital_ = 1000; + commission_value_ = 0; + margin_long_ = margin_short_ = 50; + pyramiding_ = 10; + qty_step_ = 1; + current_bar_ = {100, 100, 100, 100, 1, 0}; + } + void on_bar(const Bar&) override {} + PendingOrder& child() { + for (auto& order : pending_orders_) if (order.id == "X") return order; + throw std::logic_error("missing native bracket"); + } + void exercise(int mode) { + strategy_entry("E", true, missing, missing, 20); + ++bar_index_; + current_bar_ = {100, 100, 100, 100, 1, 60000}; + process_pending_orders(current_bar_); + CHECK(position_qty_ == 20 && position_cycle_seq_ == 1); + strategy_exit("X", "E", missing, 110); + // A valid dormant state is the fixture precondition. The originating + // rejection is separate from this activation/risk settlement contract. + child().dormant_bracket = true; + const bool ready = mode == 1; + const bool foreign = mode == 2; + child().leg_activation.bind({foreign ? 99 : position_cycle_seq_, ready ? 2 : 5, 5}); + ++bar_index_; + current_bar_ = {95, 95, 95, 95, 1, 120000}; + process_margin_call(current_bar_); + // Equity900 < margin950. The existing risk rule liquidates + // 4 * floor((950-900)/0.5/95) = 4 units, independently of the bracket. + CHECK(!trades_.empty()); + if (trades_.empty()) return; + CHECK(trades_[0].exit_id == "__margin_call__"); + CHECK(trades_[0].qty == 4 && trades_[0].exit_price == 95); + CHECK(trades_[0].exit_bar_index == 2); + if (ready) { + CHECK(position_qty_ == 0 && trades_.size() == 2); + if (trades_.size() != 2) return; + CHECK(trades_[1].exit_id == "X" && trades_[1].qty == 16); + CHECK(trades_[1].exit_price == 95 && trades_[1].exit_bar_index == 2); + return; + } + CHECK(position_qty_ == 16 && trades_.size() == 1); + CHECK(!child().dormant_bracket); // metadata revival is independent + CHECK(child().leg_activation.bounds()->stop_first_bar == 5); + for (int bar = 3; bar <= 5; ++bar) { + bar_index_ = bar; + current_bar_ = {95, 95, 95, 95, 1, bar * 60000LL}; + process_pending_orders(current_bar_); + CHECK(position_qty_ == (bar < 5 || foreign ? 16 : 0)); + } + if (foreign) { + CHECK(trades_.size() == 1); + child().leg_activation.bind({position_cycle_seq_, 5, 5}); + bar_index_ = 6; + current_bar_ = {95, 95, 95, 95, 1, 360000}; + process_pending_orders(current_bar_); + } + CHECK(position_qty_ == 0 && trades_.size() == 2); + if (trades_.size() != 2) return; + CHECK(trades_[1].exit_id == "X" && trades_[1].qty == 16); + CHECK(trades_[1].exit_price == 95); + CHECK(trades_[1].exit_bar_index == (foreign ? 6 : 5)); + } +}; + +enum class GapCase { HeldStop, ReadyLimit, HeldWithTrail, ReadyStop, + BothHeld, ForeignWithTrail, BothReady, LimitOnly }; +class PrearmedFrame : public BacktestEngine { +public: + PrearmedFrame() { + initial_capital_ = 100000; + commission_value_ = 0; + margin_long_ = margin_short_ = 0; + pyramiding_ = 0; + slippage_ = 2; + syminfo_mintick_ = 0.01; + current_bar_ = {100, 100, 100, 100, 1, 0}; + } + void on_bar(const Bar&) override {} + void exercise(GapCase mode) { + const bool trail = mode == GapCase::HeldWithTrail || mode == GapCase::ForeignWithTrail; + strategy_entry("E", true, missing, missing, 1); + strategy_exit("X", "E", mode == GapCase::HeldStop ? 150 : 90, + mode == GapCase::LimitOnly ? missing : 110, + trail ? 1000 : missing, trail ? 1 : missing); + const auto frame = pending_orders_; + CHECK(frame.size() == 2); + if (frame.size() != 2) return; + pending_orders_.resize(1); + ++bar_index_; + current_bar_ = {100, 100, 100, 100, 1, 60000}; + process_pending_orders(current_bar_); + CHECK(position_qty_ == 1 && position_side_ == PositionSide::LONG); + CHECK(std::abs(position_entry_price_ - 100.02) < 1e-9); + // Explicit post-parent/pre-compaction snapshot: retain the actual + // parent identity with no executable remainder. This targets the real + // matching route, not public placement chronology. + pending_orders_ = frame; + pending_orders_[0].qty = 0; + const bool stop_ready = mode == GapCase::ReadyStop || mode == GapCase::BothReady; + const bool limit_ready = mode == GapCase::HeldStop || mode == GapCase::ReadyLimit + || mode == GapCase::BothReady || mode == GapCase::LimitOnly; + pending_orders_[1].leg_activation.bind({ + mode == GapCase::ForeignWithTrail ? 99 : position_cycle_seq_, + stop_ready ? 1 : 5, limit_ready ? 1 : 5}); + process_pending_orders(current_bar_); + const bool filled = mode == GapCase::ReadyLimit || mode == GapCase::ReadyStop + || mode == GapCase::BothReady || mode == GapCase::LimitOnly; + CHECK(position_qty_ == (filled ? 0 : 1)); + CHECK(trades_.size() == (filled ? 1u : 0u)); + if (filled && trades_.size() == 1) { + const bool limit = mode == GapCase::ReadyLimit || mode == GapCase::LimitOnly; + CHECK(trades_[0].exit_id == "X" && trades_[0].qty == 1); + // Ready limits use the unslipped100 open; stop precedence uses + // 99.98. A held stop cannot borrow a ready sibling's permission. + CHECK(std::abs(trades_[0].exit_price - (limit ? 100 : 99.98)) < 1e-9); + } + } +}; + +class ChartPointBook : public BacktestEngine { + bool long_side_; + bool stop_leg_; + bool armed_ = false; + int first_bar_; +public: + ChartPointBook(bool long_side, bool stop_leg, int first_bar) + : long_side_(long_side), stop_leg_(stop_leg), first_bar_(first_bar) { + initial_capital_ = 100000; + commission_value_ = 0; + margin_long_ = margin_short_ = 0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1; + pyramiding_ = 0; + calc_on_order_fills_ = true; + syminfo_mintick_ = 0.01; + } + double level() const { return long_side_ == stop_leg_ ? 9.90 : 10.26; } + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("E", long_side_, missing, missing, 1); + if (bar_index_ != 1 || !coof_fill_recalc_active_ || armed_) return; + armed_ = true; + strategy_exit("X", "E", stop_leg_ ? missing : level(), stop_leg_ ? level() : missing); + for (auto& order : pending_orders_) if (order.id == "X") { + order.leg_activation.bind({position_cycle_seq_, first_bar_, first_bar_}); + } + } + void exercise() { + // The raw extremes do not reach9.90/10.26, but their chart tick + // projections do. Test all long/short stop/limit combinations. + const Bar bars[] = { + {10, 10, 10, 10, 1, 0}, {10, 10, 10, 10, 1, 60000}, + {10, 10.256, 9.904, 10, 1, 120000}, + {10, 10, 10, 10, 1, 180000}, {10, 10, 10, 10, 1, 240000}, + {10, 10.256, 9.904, 10, 1, 300000}, + }; + run(bars, 6); + CHECK(last_error().empty()); + CHECK(trades_.size() == 1); + if (trades_.size() != 1) return; + CHECK(trades_[0].exit_id == "X" && trades_[0].qty == 1); + CHECK(trades_[0].exit_bar_index == first_bar_); + CHECK(std::abs(trades_[0].exit_price - level()) < 1e-9); + } +}; +} +int main() { + for (int mode = 0; mode < 3; ++mode) { + try { MarginBook book; book.exercise(mode); } + catch (const std::exception& e) { ++failures; std::fprintf(stderr, "margin: %s\n", e.what()); } + } + for (int mode = 0; mode < 8; ++mode) { + try { PrearmedFrame book; book.exercise(static_cast(mode)); } + catch (const std::exception& e) { ++failures; std::fprintf(stderr, "prearmed: %s\n", e.what()); } + } + for (bool long_side : {false, true}) { + for (bool stop_leg : {false, true}) { + for (int first_bar : {2, 5}) { + try { ChartPointBook book(long_side, stop_leg, first_bar); book.exercise(); } + catch (const std::exception& e) { ++failures; std::fprintf(stderr, "chart point: %s\n", e.what()); } + } + } + } + std::printf("native activation routes: %d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_exit_leg_activation.cpp b/tests/test_exit_leg_activation.cpp new file mode 100644 index 00000000..e7099dcf --- /dev/null +++ b/tests/test_exit_leg_activation.cpp @@ -0,0 +1,384 @@ +// Literal native source-boundary witness. No Pine, reference tape or grader. +#include +#include +#include +namespace prior_leg_mirror { +#include "fixtures/leg_activation/149f77c_pending_mirror.hpp" +} +#include +#include +#include +static_assert(offsetof(pf_pending_order_v1_t, struct_version) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, struct_version), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, size) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, size), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, id) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, id), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, id_truncated) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, id_truncated), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, id_hash64) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, id_hash64), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, from_entry) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, from_entry), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, from_entry_truncated) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, from_entry_truncated), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, from_entry_hash64) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, from_entry_hash64), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, type) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, type), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, is_long) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, is_long), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, limit_price) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, limit_price), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, stop_price) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, stop_price), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, trail_points) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, trail_points), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, trail_price) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, trail_price), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, trail_offset) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, trail_offset), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, profit_ticks) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, profit_ticks), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, loss_ticks) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, loss_ticks), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, qty) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, qty), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, qty_type) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, qty_type), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, qty_percent) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, qty_percent), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, oca_name) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, oca_name), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, oca_name_truncated) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, oca_name_truncated), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, oca_name_hash64) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, oca_name_hash64), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, oca_type) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, oca_type), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, created_bar) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, created_bar), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, created_seq) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, created_seq), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, incarnation) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, incarnation), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, created_by_same_id_replacement) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, created_by_same_id_replacement), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, replaced_default_market_incarnation) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, replaced_default_market_incarnation), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, declined_by_replaced_short_market) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, declined_by_replaced_short_market), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, replaced_exit_order_incarnation) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, replaced_exit_order_incarnation), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, recreated_after_named_cancelled_entry_incarnation) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, recreated_after_named_cancelled_entry_incarnation), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, named_cancel_surviving_exit_incarnation) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, named_cancel_surviving_exit_incarnation), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, stop_limit_activated) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, stop_limit_activated), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, coof_suppress_stop_on_entry_bar) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, coof_suppress_stop_on_entry_bar), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, coof_suppress_limit_on_entry_bar) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, coof_suppress_limit_on_entry_bar), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, created_during_coof_recalc) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, created_during_coof_recalc), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, coof_born_at_close_recalc) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, coof_born_at_close_recalc), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, coof_born_mid_bar) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, coof_born_mid_bar), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, coof_cascade_seg_i) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, coof_cascade_seg_i), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, coof_cascade_inflight_fires) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, coof_cascade_inflight_fires), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, created_position_side) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, created_position_side), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, created_position_cycle_seq) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, created_position_cycle_seq), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, created_after_position_close_in_bar) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, created_after_position_close_in_bar), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, over_pyramiding_cap_at_placement) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, over_pyramiding_cap_at_placement), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, same_id_stop_deferred_close_all_bar) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, same_id_stop_deferred_close_all_bar), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, same_id_stop_deferred_close_all_incarnation) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, same_id_stop_deferred_close_all_incarnation), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, reverses_same_bar_market_from_flat) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, reverses_same_bar_market_from_flat), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, paired_flat_market_candidate) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, paired_flat_market_candidate), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, paired_flat_market_own_qty) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, paired_flat_market_own_qty), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, paired_flat_market_signal_close) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, paired_flat_market_signal_close), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, paired_flat_market_signal_equity) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, paired_flat_market_signal_equity), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, paired_flat_market_signal_margin_pct) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, paired_flat_market_signal_margin_pct), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, paired_flat_market_signal_pointvalue) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, paired_flat_market_signal_pointvalue), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, paired_flat_market_signal_fx) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, paired_flat_market_signal_fx), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, paired_flat_market_peer_seq) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, paired_flat_market_peer_seq), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, paired_flat_market_transaction_qty) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, paired_flat_market_transaction_qty), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, default_flat_market_gross_candidate) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, default_flat_market_gross_candidate), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, tv_carry_qty) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, tv_carry_qty), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, frozen_default_qty) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, frozen_default_qty), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, default_stop_placement_qty) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, default_stop_placement_qty), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, default_stop_placement_equity) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, default_stop_placement_equity), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, default_stop_placement_signal_close) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, default_stop_placement_signal_close), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, default_stop_sizing_price) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, default_stop_sizing_price), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, sizing_equity) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, sizing_equity), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, sizing_price) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, sizing_price), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, sizing_fx) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, sizing_fx), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, sizing_mark) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, sizing_mark), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, opening_affordability_exemption_candidate) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, opening_affordability_exemption_candidate), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, explicit_flat_admission_candidate) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, explicit_flat_admission_candidate), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, explicit_placement_equity) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, explicit_placement_equity), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, explicit_slipped_signal_close) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, explicit_slipped_signal_close), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, affordability_placement_equity) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, affordability_placement_equity), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, affordability_signal_price) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, affordability_signal_price), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, affordability_held_qty) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, affordability_held_qty), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, affordability_close_only) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, affordability_close_only), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, rounded_signal_cost_close_only) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, rounded_signal_cost_close_only), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, signal_close_mc_bar) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, signal_close_mc_bar), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, signal_close_mc_entry_incarnation) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, signal_close_mc_entry_incarnation), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, signal_close_mc_fill_seq) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, signal_close_mc_fill_seq), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, signal_close_mc_remaining_qty) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, signal_close_mc_remaining_qty), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, comment) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, comment), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, comment_truncated) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, comment_truncated), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, comment_hash64) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, comment_hash64), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, requested_partial) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, requested_partial), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, full_percent_exit_request) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, full_percent_exit_request), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, pooc_global_full_exit_dynamic_qty) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, pooc_global_full_exit_dynamic_qty), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, pooc_global_full_exit_tracks_bound_adds) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, pooc_global_full_exit_tracks_bound_adds), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, pooc_global_full_exit_bound_add) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, pooc_global_full_exit_bound_add), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, created_while_in_position) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, created_while_in_position), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, sbmt_member) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, sbmt_member), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, sbmt_own_qty) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, sbmt_own_qty), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, sbmt_tx_qty) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, sbmt_tx_qty), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, sbmt_kept_over_cap) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, sbmt_kept_over_cap), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, sbmt_close_qty) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, sbmt_close_qty), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, sbmt_close_buy) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, sbmt_close_buy), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, suppress_as_declined_reversal_close) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, suppress_as_declined_reversal_close), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, dormant_bracket) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, dormant_bracket), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, dormant_reissue_pending) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, dormant_reissue_pending), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, dormant_original_stop_price) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, dormant_original_stop_price), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, dormant_hold_bar) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, dormant_hold_bar), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, dormant_reversal_kill_bar) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, dormant_reversal_kill_bar), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, dormant_trail_best) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, dormant_trail_best), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, dormant_trail_best_start) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, dormant_trail_best_start), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, dormant_trail_leg_dead) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, dormant_trail_leg_dead), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, suppressed_close_consumed_ledger_qty) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, suppressed_close_consumed_ledger_qty), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, suppressed_close_retired_ledger_qty) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, suppressed_close_retired_ledger_qty), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, short_seed_collision_role) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, short_seed_collision_role), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, replaced_order_incarnation) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, replaced_order_incarnation), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, birth_timestamp) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, birth_timestamp), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, birth_cause) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, birth_cause), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, birth_bar) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, birth_bar), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, birth_cursor_domain) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, birth_cursor_domain), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, birth_cursor_position) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, birth_cursor_position), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, birth_cursor_index) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, birth_cursor_index), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, birth_cursor_count) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, birth_cursor_count), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, birth_cursor_price) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, birth_cursor_price), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, birth_first_fill) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, birth_first_fill), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, birth_last_fill) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, birth_last_fill), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, birth_evaluation_ordinal) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, birth_evaluation_ordinal), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, pine_birth_reach) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, pine_birth_reach), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, quantity_intent_kind) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, quantity_intent_kind), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, quantity_intent_units) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, quantity_intent_units), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, quantity_intent_numerator) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, quantity_intent_numerator), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, quantity_intent_denominator) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, quantity_intent_denominator), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, quantity_reservation_present) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, quantity_reservation_present), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, quantity_reservation_units) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, quantity_reservation_units), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, quantity_reservation_basis_units) == offsetof(prior_leg_mirror::pf_pending_order_v1_t, quantity_reservation_basis_units), "preserve existing v1 prefix offset"); +static_assert(offsetof(pf_pending_order_v1_t, leg_activation_owner_cycle) >= sizeof(prior_leg_mirror::pf_pending_order_v1_t), "append after old full prefix"); +using namespace pineforge; +namespace { +const double nan = std::numeric_limits::quiet_NaN(); +const Bar bars[] = { + {100, 101, 99, 100, 1, 0}, + {100, 105, 95, 100, 1, 60000}, + {90, 112, 80, 100, 1, 120000}, + {100, 112, 80, 100, 1, 180000}, +}; +class FutureOwner : public BacktestEngine { +public: + explicit FutureOwner(bool stop) : use_stop(stop) { + calc_on_order_fills_ = true; + initial_capital_ = 100000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1; + pyramiding_ = 10; + syminfo_mintick_ = 0.01; + commission_value_ = 0; + } + bool use_stop; + bool armed = false; + int born_bar = -1; + int bound_bar = -1; + int64_t born_cycle = 0, bound_cycle = 0; + uint64_t exit_incarnation = 0, bound_exit_incarnation = 0; + bool stop_flag_at_birth = false, limit_flag_at_birth = false; + bool stop_flag_at_binding = false, limit_flag_at_binding = false; + bool persisted_on_target_entry_bar = false; + int64_t first_stop_bar = -1, first_limit_bar = -1; + int64_t rebound_stop_bar = -1, rebound_limit_bar = -1; + int64_t first_activation_cycle = 0, rebound_activation_cycle = 0; + int seen_entry_callbacks = 0; + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("A", true, nan, nan, 1); + return; + } + if (bar_index_ == 1 && coof_fill_recalc_active_ && !armed) { + armed = true; + strategy_entry("A", false, nan, 90, 1); + strategy_exit("EY", "A", use_stop ? nan : 85, use_stop ? 110 : nan); + for (const auto& o : pending_orders_) if (o.id == "EY") { + born_bar = o.created_bar; + born_cycle = position_cycle_seq_; + exit_incarnation = o.incarnation; + stop_flag_at_birth = o.pine_exit_activation.holds_stop(); + limit_flag_at_birth = o.pine_exit_activation.holds_limit(); + if (o.leg_activation.bounds()) { + first_stop_bar = o.leg_activation.bounds()->stop_first_bar; + first_limit_bar = o.leg_activation.bounds()->limit_first_bar; + first_activation_cycle = o.leg_activation.bounds()->position_cycle; + } + } + } + if (bar_index_ == 2 && coof_fill_recalc_active_ + && position_side_ == PositionSide::SHORT) { + ++seen_entry_callbacks; + for (const auto& o : pending_orders_) if (o.id == "EY") { + bound_bar = position_open_bar_; + bound_cycle = position_cycle_seq_; + bound_exit_incarnation = o.incarnation; + stop_flag_at_binding = o.pine_exit_activation.holds_stop(); + limit_flag_at_binding = o.pine_exit_activation.holds_limit(); + if (o.leg_activation.bounds()) { + rebound_stop_bar = o.leg_activation.bounds()->stop_first_bar; + rebound_limit_bar = o.leg_activation.bounds()->limit_first_bar; + rebound_activation_cycle = o.leg_activation.bounds()->position_cycle; + } + } + } + if (bar_index_ == 2 && !coof_fill_recalc_active_) { + persisted_on_target_entry_bar = position_side_ == PositionSide::SHORT; + } + } + void print() const { + std::printf("%s born_bar=%d bound_bar=%d born_cycle=%lld bound_cycle=%lld exit_inc=%llu bound_exit_inc=%llu birth_stop=%d birth_limit=%d binding_stop=%d binding_limit=%d held_at_target_bar_close=%d target_callbacks=%d\n", + use_stop ? "stop" : "limit", born_bar, bound_bar, + (long long)born_cycle, (long long)bound_cycle, + (unsigned long long)exit_incarnation, (unsigned long long)bound_exit_incarnation, + stop_flag_at_birth, limit_flag_at_birth, stop_flag_at_binding, + limit_flag_at_binding, persisted_on_target_entry_bar, seen_entry_callbacks); + for (const auto& t : trades_) + std::printf("trade entry=%s exit=%s entry_bar=%d exit_bar=%d qty=%.6f entry_price=%.6f exit_price=%.6f\n", + t.entry_id.c_str(), t.exit_id.c_str(), t.entry_bar_index, + t.exit_bar_index, t.qty, t.entry_price, t.exit_price); + } + bool contract_holds() const { + if (born_bar != 1 || bound_bar != 2 || born_cycle != 1 || bound_cycle != 2 + || exit_incarnation != 3 || bound_exit_incarnation != exit_incarnation + || !persisted_on_target_entry_bar || seen_entry_callbacks != 1 + || first_activation_cycle != born_cycle || rebound_activation_cycle != bound_cycle + || first_stop_bar != (use_stop ? 2 : 1) || first_limit_bar != (use_stop ? 1 : 2) + || rebound_stop_bar != (use_stop ? 3 : 2) || rebound_limit_bar != (use_stop ? 2 : 3) + || stop_flag_at_birth != use_stop || limit_flag_at_birth == use_stop + || stop_flag_at_binding != stop_flag_at_birth + || limit_flag_at_binding != limit_flag_at_birth || trades_.size() != 2) + return false; + const auto& exit = trades_.back(); + return exit.entry_id == "A" && exit.exit_id == "EY" + && exit.entry_bar_index == 2 && exit.exit_bar_index == 3 + && exit.qty == 1 && exit.entry_price == 90 + && exit.exit_price == (use_stop ? 110 : 85) + // A fixed next-bar deadline from original birth is already past + // when the same exit binds the new position and is held again. + && bound_bar >= born_bar + 1; + } +}; +} +int preserved_rebinding_controls() { + int failures = 0; + for (bool stop : {true, false}) { + FutureOwner engine(stop); + engine.run(bars, 4); + engine.print(); + if (!engine.contract_holds()) ++failures; + } + std::printf("literal owner-rebinding contracts: %d failure(s)\n", failures); + return failures ? 1 : 0; +} + +namespace { +int native_checks = 0, native_failures = 0; +#define CHECK(x) do { ++native_checks; if (!(x)) { ++native_failures; std::fprintf(stderr,"FAIL %d %s\n",__LINE__,#x); } } while (0) +class NativeBook : public BacktestEngine { +public: + NativeBook() { + initial_capital_=100000; commission_value_=0; margin_long_=margin_short_=0; + pyramiding_=10; current_bar_={100,100,100,100,1,0}; + } + void on_bar(const Bar&) override {} + void step(double price=100) { + ++bar_index_; current_bar_={price,price,price,price,1,bar_index_*60000LL}; + process_pending_orders(current_bar_); + } + void raw_open(){strategy_order("A",true,1);step();} + void bracket(){strategy_exit("X","A",120,90);} + void add(){strategy_order("A",true,1);} + void partial(){strategy_close("A","",0.5);} + PendingOrder& exit(){for(auto& o:pending_orders_)if(o.id=="X")return o;throw std::logic_error("missing exit");} + int64_t cycle()const{return position_cycle_seq_;} + double quantity()const{return position_qty_;} + int bar()const{return bar_index_;} + void replace(){bracket();} + void cancel(){strategy_cancel("X");} +}; +void native_values_and_connected_constraints() { + ExitLegActivation activation; + CHECK(activation.stop_ready(1,0)); + activation.bind({7,3,5}); + CHECK(!activation.stop_ready(7,2)&&activation.stop_ready(7,3)); + CHECK(!activation.limit_ready(7,4)&&activation.limit_ready(7,5)); + CHECK(!activation.stop_ready(8,100)&&!activation.limit_ready(8,100)); + bool refused=false;try{activation.bind({0,1,1});}catch(const std::invalid_argument&){refused=true;} + CHECK(refused&&activation.bounds()->position_cycle==7); + activation.unbind();CHECK(!activation.bounds()); + NativeBook retained;retained.bracket(); + CHECK(!retained.exit().leg_activation.bounds()); + retained.raw_open(); + CHECK(retained.exit().leg_activation.bounds().has_value()); + CHECK(retained.exit().leg_activation.bounds()->position_cycle==retained.cycle()); + CHECK(retained.exit().leg_activation.bounds()->stop_first_bar==retained.bar()); + NativeBook b;b.raw_open();b.bracket(); + CHECK(b.exit().leg_activation.bounds()->position_cycle==b.cycle()); + CHECK(b.exit().leg_activation.bounds()->stop_first_bar==1); + CHECK(!b.exit().pine_exit_activation.evidence()); + const auto cycle=b.cycle(); + b.exit().leg_activation.bind({cycle,4,4}); + b.add();b.step(); + CHECK(b.quantity()==2&&b.cycle()==cycle); + CHECK(b.exit().leg_activation.bounds()->stop_first_bar==4); + b.partial();b.step(); + CHECK(b.quantity()==1.5&&b.cycle()==cycle); + CHECK(b.exit().leg_activation.bounds()->stop_first_bar==4); + const auto before=b.exit().incarnation; + b.replace(); + CHECK(b.exit().incarnation!=before&&b.exit().replaced_order_incarnation==before); + CHECK(b.exit().leg_activation.bounds()->stop_first_bar==1); + b.cancel();b.bracket();CHECK(b.exit().replaced_order_incarnation==0); + NativeBook bound;bound.raw_open();bound.bracket(); + bound.exit().leg_activation.bind({bound.cycle(),3,3}); + bound.step(90);CHECK(bound.quantity()==1); // actual matcher cannot refresh/delete the bound + bound.step(90);CHECK(bound.quantity()==0); +} +void original_policy_evidence_survives_new_owner_side() { + bool invalid=false; + try { (void)PineExitActivationPolicy({1,1,0,100,110,nan,std::nullopt}); } + catch(const std::invalid_argument&) { invalid=true; } + CHECK(invalid); + PineExitActivationPolicy policy({1,1,1,100,110,nan,std::nullopt}); + CHECK(policy.holds_stop()&&!policy.holds_limit()); + const auto one=policy.resolve(1,1), two=policy.resolve(2,5); + CHECK(one.position_cycle==1&&one.stop_first_bar==2&&one.limit_first_bar==1); + CHECK(two.position_cycle==2&&two.stop_first_bar==6&&two.limit_first_bar==5); + CHECK(policy.evidence()->position_cycle==1&&policy.evidence()->stop_level==110); + PineExitActivationPolicy recross({1,1,1,110,nan,105, + compat::pine::LimitContinuation{compat::pine::LimitContinuationCause::FirstHighRecross,9}}); + CHECK(!recross.holds_limit()&&!recross.continues_at_later_open()); + PineExitActivationPolicy later({1,1,1,110,nan,105, + compat::pine::LimitContinuation{compat::pine::LimitContinuationCause::LaterSameOpen,9}}); + CHECK(!later.holds_limit()&&later.continues_at_later_open()); +} +void named_pine_continuations_keep_their_guards() { + const Bar bar{100,105,90,104,1,120000}; + const std::string id="A"; + PendingOrder order{}; order.type=OrderType::EXIT; order.from_entry=id; + order.stop_price=nan; order.limit_price=102; order.trail_points=nan; order.trail_price=nan; + order.qty=1; order.quantity_request.request(QuantityIntent::all()); + order.quantity_request.reserve(1,1); + order.pine_birth_reach=PineHistoricalBirthReach::ExtremeWaypoints; + compat::pine::ExitActivationContext context{ + bar,PositionSide::LONG,1,2,2,1,1.0,0,1,id,7, + true,true,105,false,1,false,true,1,7,9,9, + false,false,false,true,true,0,1,1,true,105}; + const auto high=compat::pine::select_exit_activation(order,nan,102,context); + CHECK(high.evidence()&&high.evidence()->limit_continuation); + CHECK(high.evidence()->limit_continuation->cause==compat::pine::LimitContinuationCause::FirstHighRecross); + CHECK(!high.holds_limit()&&!high.continues_at_later_open()); + context.pending_empty=false; + const auto competitor=compat::pine::select_exit_activation(order,nan,102,context); + CHECK(competitor.holds_limit()&&!competitor.evidence()->limit_continuation); + context.pending_empty=true; context.cursor_price=100; context.after_first_open_fill=true; + context.recalc_leg=0; context.historical_point=0; context.at_extreme=false; + order.stop_price=101;order.limit_price=99; + const auto later=compat::pine::select_exit_activation(order,101,99,context); + CHECK(later.holds_stop()&&!later.holds_limit()&&later.continues_at_later_open()); + order.trail_points=5; + const auto trailing=compat::pine::select_exit_activation(order,101,99,context); + CHECK(trailing.holds_stop()&&trailing.holds_limit()&&!trailing.continues_at_later_open()); + context.fill_recalc=false; + const auto direct=compat::pine::select_exit_activation(order,101,99,context); + CHECK(!direct.evidence()); + // Later trigger neutralization cannot mutate the original policy evidence. + order.stop_price=order.limit_price=nan; + CHECK(later.holds_stop()&&later.evidence()->stop_level==101); +} + +} +int main(){ + const int preserved=preserved_rebinding_controls(); + try{native_values_and_connected_constraints();original_policy_evidence_survives_new_owner_side();named_pine_continuations_keep_their_guards();} + catch(const std::exception& e){++native_failures;std::fprintf(stderr,"native exception: %s\n",e.what());} + std::printf("native leg activation: %d checks, %d failures\n",native_checks,native_failures); + return preserved||native_failures?1:0; +} diff --git a/tests/test_live_state_hash.cpp b/tests/test_live_state_hash.cpp index 5830e855..f32af268 100644 --- a/tests/test_live_state_hash.cpp +++ b/tests/test_live_state_hash.cpp @@ -402,13 +402,12 @@ class Probe final : public BacktestEngine { if (!s.pending_orders_.empty()) s.pending_orders_[0].declined_by_replaced_short_market = !s.pending_orders_[0].declined_by_replaced_short_market; }}, - {"pending_orders_[].coof_suppress_stop_on_entry_bar", [](Probe& s) { - if (!s.pending_orders_.empty()) - s.pending_orders_[0].coof_suppress_stop_on_entry_bar = !s.pending_orders_[0].coof_suppress_stop_on_entry_bar; + {"pending_orders_[].leg_activation", [](Probe& s) { + if (!s.pending_orders_.empty()) s.pending_orders_[0].leg_activation.bind({1,2,3}); }}, - {"pending_orders_[].coof_suppress_limit_on_entry_bar", [](Probe& s) { - if (!s.pending_orders_.empty()) - s.pending_orders_[0].coof_suppress_limit_on_entry_bar = !s.pending_orders_[0].coof_suppress_limit_on_entry_bar; + {"pending_orders_[].pine_exit_activation", [](Probe& s) { + if (!s.pending_orders_.empty()) s.pending_orders_[0].pine_exit_activation = + PineExitActivationPolicy({1,0,1,100,110,90,std::nullopt}); }}, {"pending_orders_[].birth.fill_origin", [](Probe& s) { if (!s.pending_orders_.empty())