Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ add_library(pineforge STATIC
src/engine_metrics.cpp
src/engine_orders.cpp
src/engine_execution.cpp
src/engine_execution_lifecycle.cpp
src/engine_path_resolve.cpp
src/engine_report.cpp
src/engine_risk.cpp
Expand Down
27 changes: 24 additions & 3 deletions docs/native-settlement.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
trusted matching adapters. It applies an already admitted, matched execution
to the engine's existing physical lot book and emits the resulting close and
open observations. It adds no second position ledger or persistent selector.
Callers that supply transient EXIT lifecycle effects use the separately named
`settle_execution_with_lifecycle` seam; the two-argument symbol is unchanged
and forwards empty effects.

The caller supplies an `execution::Action` and `execution::Fill` from
`<pineforge/execution.hpp>`. These values describe immediate effects:
Expand Down Expand Up @@ -69,9 +72,27 @@ ordinary floating-point rounding. A rebate increases it.

## Integration and limits

The kernel currently serves full market exits and selected frozen-transaction
and same-side materialization paths. Existing source scheduling and dust
decisions remain at their call sites. Migrated frozen transactions and the
The kernel currently serves full market exits, the close-opposite-then-enter
reversal family, and selected frozen-transaction and same-side materialization
paths. Existing source scheduling and dust decisions remain at their call
sites. The reversal helper consumes one already-resolved `Fill` and one current
fee; it does not slip again. `compat::pine` suspension selection stays at the
replacement caller. `settle_resolved_execution` remains the original
two-argument symbol and forwards empty effects to
`settle_execution_with_lifecycle`, the protected seam that consumes transient
lifecycle effects. Those effects name exact pending identities, revisions and
operations; `created_seq` 0 and `Target{0,0}` are literal expected values.
Source selection may preview the upcoming lifecycle frame without consuming it
and must supply a literal operation payload. They are not stored, hashed, or
reusable execution authority. Empty effects leave other settlement callers
unchanged.

Authorized pre-close events run first, then close observations and the existing
flat unbind, then the listed pending removals, then `open_quoted_position`,
which still binds only remaining exits. False removal lists do not replace or
reallocate `pending_orders_`. Native settlement does not recognize source
cases, rewrite supplied window/barrier facts, or install a callback/plan.
Migrated frozen transactions and the
final short-seed crossing settle their close/open effects in one native call.
Other legacy close loops are not yet
grouped into one parent execution; their per-row current ticket behavior is
Expand Down
53 changes: 51 additions & 2 deletions include/pineforge/engine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include <unordered_map>
#include <unordered_set>
#include <stdexcept>
#include <optional>
#include "na.hpp"
#include "bar.hpp"
#include "broker_events.hpp"
Expand Down Expand Up @@ -1796,8 +1797,18 @@ class BacktestEngine {
// order, perform admission/slippage, or provide cancellation/replay. The
// owning run must abort on an exception; failed commits are not retryable
// in place. A saved arithmetic plan is not execution authority.
execution::Result settle_resolved_execution(const execution::Action& action,
const execution::Fill& fill);
// Empty lifecycle effects. Member-pointer type is the original two-argument
// symbol; it forwards to settle_execution_with_lifecycle.
execution::Result settle_resolved_execution(
const execution::Action& action, const execution::Fill& fill);
// Trusted matching-adapter extension for one execution plus lifecycle effects.
// Pre-close operations, then close observations and old-cycle unbind, then
// the listed pending removals, then the quoted opening path which binds
// only remaining exits. Native settlement does not call compat::pine
// selectors; the effects value is not retained.
execution::Result settle_execution_with_lifecycle(
const execution::Action& action, const execution::Fill& fill,
const execution::LifecycleEffects& lifecycle);
// Native account value: realized balance plus marked physical lots minus
// their remaining paid entry costs, for every fee type. No Pine sizing or
// end-of-range reporting convention participates in this value.
Expand Down Expand Up @@ -3871,6 +3882,10 @@ class BacktestEngine {
void execute_market_exit(double fill_price);
void append_same_side_fill(PyramidEntry lot);
void append_quoted_lot(PyramidEntry lot, double total_qty, double average_price);
// Allocates the new position cycle, lots and observations, then binds
// exits that still remain in pending_orders_. Settlement that authorized
// pending removals applies those erasures after old-cycle unbind and
// before this opening bind.
void open_quoted_position(PositionSide requested, PyramidEntry lot);
// Range-end accounting: record the rows that close a position still
// open after the final script bar at that bar's close, the way
Expand Down Expand Up @@ -4054,8 +4069,33 @@ class BacktestEngine {
// done). Called right after every process_margin_call dispatch site.
void settle_dormant_bracket_reissues(exit_legs::Domain domain);
exit_legs::Frame next_leg_event(exit_legs::Phase phase = exit_legs::Phase::Observation);
exit_legs::Frame preview_next_leg_event(
exit_legs::Phase phase = exit_legs::Phase::Observation) const;
void apply_leg_action(PendingOrder& order, exit_legs::Operation operation,
std::optional<exit_legs::Frame> cause = std::nullopt);
exit_legs::Domain current_exit_leg_domain() const;
enum class ExitLegTransitionResult {
Applied, Replay, StaleIdentity, BindRefused, ActionRefused, Exhausted,
RevisionExhausted
};
ExitLegTransitionResult transition_exit_leg(
exit_legs::Lifecycle& legs, uint64_t order_incarnation,
exit_legs::Operation operation, std::optional<exit_legs::Frame> supplied,
uint64_t& event_seq, int64_t position_cycle) const;
std::optional<execution::Status> validate_lifecycle_effects(
const execution::LifecycleEffects& lifecycle) const;
std::optional<execution::Status> preflight_settlement_lifecycle(
const execution::LifecycleEffects& lifecycle,
bool will_reset_to_flat, bool will_open_quoted);
void apply_pre_close_lifecycle_batch(const execution::LifecycleBatch& batch);
void apply_authorized_pending_removals(
const std::vector<execution::PendingRemoval>& removals);
std::vector<execution::PendingRemoval> snapshot_exit_pending_removals() const;
std::optional<execution::LifecycleBatch> select_declined_reversal_pre_close(
const Bar& bar) const;
const PendingOrder* find_unique_pending(
uint64_t incarnation, int64_t created_seq) const;
PendingOrder* find_unique_pending(uint64_t incarnation, int64_t created_seq);
// Per-OrderType fill kernels. Called only after risk + intraday
// gates pass; each updates the engine's position/trade state and
// any per-type out-parameters the post-fill bookkeeping needs.
Expand Down Expand Up @@ -4276,12 +4316,21 @@ class BacktestEngine {
PositionSide created_position_side,
bool is_priced_entry,
uint64_t entry_incarnation);
// `fill_price` is already resolved. Source sizing, direction and dust
// selection stay here; purge_pending_exits is translated into exact
// pending removals for the settlement coordinator. False does not
// touch pending_orders_ storage.
void close_opposite_then_enter(const std::string& id, bool is_long,
double fill_price, double explicit_qty,
int explicit_qty_type,
bool purge_pending_exits,
bool explicit_qty_prequantized,
uint64_t entry_incarnation);
void apply_resolved_close_opposite_then_enter(
const std::string& id, bool is_long, double fill_price,
double explicit_qty, int explicit_qty_type,
bool explicit_qty_prequantized, uint64_t entry_incarnation,
execution::LifecycleEffects lifecycle);
void flip_market_position_to(const std::string& id, bool is_long,
double fill_price, double explicit_qty,
int explicit_qty_type,
Expand Down
43 changes: 42 additions & 1 deletion include/pineforge/execution.hpp
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
#pragma once

#include "exit_leg_lifecycle.hpp"
#include "order_action.hpp"
#include <cstdint>
#include <optional>
#include <string>
#include <variant>
#include <vector>

namespace pineforge::execution {

Expand All @@ -27,7 +29,10 @@ struct Fill {

enum class Status {
Applied, NoEffect, InvalidPrice, InvalidQuantity, InvalidBook,
UnrepresentableQuantity, InvalidAccounting
UnrepresentableQuantity, InvalidAccounting,
// Caller-supplied lifecycle targets/revisions/operations that cannot be
// applied to the current pending book. Not durable engine state.
InvalidLifecycle
};

struct Result {
Expand All @@ -38,4 +43,40 @@ struct Result {
double opened_units = 0.0;
};

// One pre-close operation on an exact pending identity. created_seq 0 and
// Target{0,0} are actual expected values, not wildcards. The coordinator
// allocates the batch cause and does not rewrite operation payloads.
struct LifecycleIntent {
uint64_t order_incarnation = 0;
int64_t created_seq = 0;
exit_legs::Target target{};
uint64_t expected_revision = 0;
exit_legs::Operation operation{};
};

// Engaging this batch allocates one observation frame on a successful
// commit even when operations is empty. Empty optional means no allocation.
// Phase must be a valid exit_legs::Phase even for an empty operations list.
struct LifecycleBatch {
exit_legs::Phase phase = exit_legs::Phase::Observation;
std::vector<LifecycleIntent> operations;
};

// Exact pending EXIT to remove after old-cycle unbind and before any
// quoted opening bind. Fields are the initial snapshot before this
// execution's own effects; incarnation 0 is refused.
struct PendingRemoval {
uint64_t incarnation = 0;
int64_t created_seq = 0;
exit_legs::Target target{};
uint64_t expected_revision = 0;
};

// Transient, stack-local effects for one settle_execution_with_lifecycle
// call. Not stored, hashed, replayed, or reusable execution authority.
struct LifecycleEffects {
std::optional<LifecycleBatch> pre_close;
std::vector<PendingRemoval> removals;
};

} // namespace pineforge::execution
20 changes: 20 additions & 0 deletions src/engine_execution.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include <algorithm>
#include <cmath>
#include <limits>
#include <optional>
#include <stdexcept>
#include <utility>

Expand All @@ -22,6 +23,12 @@ void reserve_effects(std::vector<T>& values, size_t extra) {

execution::Result BacktestEngine::settle_resolved_execution(
const execution::Action& action, const execution::Fill& fill) {
return settle_execution_with_lifecycle(action, fill, {});
}

execution::Result BacktestEngine::settle_execution_with_lifecycle(
const execution::Action& action, const execution::Fill& fill,
const execution::LifecycleEffects& lifecycle) {
using execution::Status;
if (!std::isfinite(fill.price)) return {Status::InvalidPrice};
if (fill.commission_account && !std::isfinite(*fill.commission_account))
Expand Down Expand Up @@ -53,6 +60,8 @@ execution::Result BacktestEngine::settle_resolved_execution(
held += lot.qty;
if (!std::isfinite(held)) return {Status::InvalidBook};
}
if (auto invalid = validate_lifecycle_effects(lifecycle))
return {*invalid};
const double signed_held = position_side_ == PositionSide::SHORT ? -held : held;
if (!flatten && requested == 0.0) return no_effect();
if ((flatten || reduce) && pyramid_entries_.empty()) return no_effect();
Expand Down Expand Up @@ -182,6 +191,13 @@ execution::Result BacktestEngine::settle_resolved_execution(
&& position_entry_count_ == std::numeric_limits<int>::max())
throw std::overflow_error("position entry counter exhausted");

const bool will_reset = closed > 0.0 && survivors.empty();
const bool will_open_quoted = opening > 0.0
&& (position_side_ == PositionSide::FLAT || survivors.empty());
if (auto invalid = preflight_settlement_lifecycle(
lifecycle, will_reset, will_open_quoted))
return {*invalid};

const size_t first_trade = trades_.size();
const size_t first_action = stream_order_actions_.size();
const size_t events = closed_trades.size() + (opening > 0.0 ? 1 : 0);
Expand All @@ -195,6 +211,9 @@ execution::Result BacktestEngine::settle_resolved_execution(
// Commit through the existing accounting/observation sinks. Allocation or
// lifecycle exceptions still abort the owning engine run; this internal
// synchronous kernel does not promise recovery/replay of a failed commit.
// Order: authorized pre-close events, close observations and old-cycle
// unbind, authorized pending removals, then quoted opening bind.
if (lifecycle.pre_close) apply_pre_close_lifecycle_batch(*lifecycle.pre_close);
for (auto& trade : closed_trades) record_close_trade(std::move(trade));
if (closed > 0.0) {
if (survivors.empty()) {
Expand All @@ -206,6 +225,7 @@ execution::Result BacktestEngine::settle_resolved_execution(
position_entry_count_ = static_cast<int>(pyramid_entries_.size());
}
}
apply_authorized_pending_removals(lifecycle.removals);
if (opening > 0.0) {
PyramidEntry lot{fill.price, current_bar_.timestamp, opening, fill.id, bar_index_};
lot.entry_incarnation = fill.incarnation;
Expand Down
Loading
Loading