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
2 changes: 2 additions & 0 deletions db/migration-contributions/fixtures/central-migration.sha256
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
ccc5950a4d23b9c8f07b895a83dbea18a9fb072523b56b121eecb65bec15c533 V1__initial_schema.sql.fixture
5847cef02039bd9b327ac6ffbe35b097ef476f1a973ad87307957f6a3c440418 V20260825000000__backend_basic_strategy_execution_completion.sql.fixture
436585f89fb6943560986934b4f9e49a07a3e5c743860f426222c7e7ef0bb18b V20260825000001__pipeline_basic_strategy_feature_catalog.sql.fixture
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
-- Publish the complete Basic execution catalog without mutating the consolidated V1 definitions.
-- Existing releases stay pinned to the retired basic-elements:2026-08-08 catalog; new drafts select
-- the single active catalog published below.

UPDATE strategy.element_catalog_versions
SET retired_at = '2026-08-25 00:00:00+00'
WHERE catalog_version = 'basic-elements:2026-08-08'
AND retired_at IS NULL;

INSERT INTO strategy.element_catalog_versions (
id, language_version, schema_version, catalog_version, data_requirement_version,
definition_hash, published_at, retired_at
) VALUES (
'0f5a0000-0000-4000-8000-000000000001',
'basic/v1',
'basic-semantic/v1',
'basic-elements:2026-08-25',
'alpaca-sip/v1',
'sha256:' || encode(public.digest('basic-elements:2026-08-25', 'sha256'), 'hex'),
'2026-08-25 00:00:00+00',
NULL
);

WITH copied AS (
SELECT
element_code,
element_kind,
CASE element_code
WHEN 'BASIC_RSI_CROSS' THEN jsonb_set(parameter_schema, '{properties,threshold}',
'{"type":"string","minLength":1,"x-numericMinimum":"0","x-numericMaximum":"100"}'::jsonb)
WHEN 'BASIC_HOLDING_PERIOD' THEN jsonb_set(parameter_schema, '{properties,amount}',
'{"type":"string","minLength":1,"x-integer":true,"x-numericMinimum":"0"}'::jsonb)
WHEN 'BASIC_POSITION_RETURN' THEN jsonb_set(parameter_schema, '{properties,thresholdPercent}',
'{"type":"string","minLength":1,"x-numericMinimum":"0","x-numericMaximum":"100"}'::jsonb)
WHEN 'BASIC_PEAK_RETURN' THEN jsonb_set(parameter_schema, '{properties,thresholdPercent}',
'{"type":"string","minLength":1,"x-numericMinimum":"0","x-numericMaximum":"100"}'::jsonb)
WHEN 'BASIC_DRAWDOWN_FROM_PEAK' THEN jsonb_set(parameter_schema, '{properties,thresholdPercent}',
'{"type":"string","minLength":1,"x-numericMinimum":"0","x-numericMaximum":"100"}'::jsonb)
WHEN 'BASIC_SCHEDULE' THEN jsonb_set(parameter_schema, '{properties,interval}',
'{"type":"string","minLength":1,"x-integer":true,"x-numericExclusiveMinimum":"0"}'::jsonb)
WHEN 'BASIC_EQUAL_ALLOCATION_ORDER' THEN
jsonb_set(
jsonb_set(
jsonb_set(
jsonb_set(
jsonb_set(parameter_schema, '{required}',
(parameter_schema -> 'required') || '"maxPositionPercent"'::jsonb),
'{properties,orderPercent}',
'{"type":"string","minLength":1,"x-numericExclusiveMinimum":"0","x-numericMaximum":"100"}'::jsonb),
'{properties,maxPositionPercent}',
'{"type":"string","minLength":1,"x-numericExclusiveMinimum":"0","x-numericMaximum":"100"}'::jsonb,
true),
'{properties,waitInterval}',
'{"type":"string","minLength":1,"x-integer":true,"x-numericExclusiveMinimum":"0"}'::jsonb),
'{properties,maxExecutions}',
'{"type":"string","minLength":1,"x-integer":true,"x-numericExclusiveMinimum":"0"}'::jsonb)
ELSE parameter_schema
END AS parameter_schema,
input_port_schema,
output_port_schema,
CASE WHEN element_code = 'BASIC_EQUAL_ALLOCATION_ORDER' THEN
jsonb_set(execution_contract, '{runtime,arguments,maxPositionPercent}',
'"$maxPositionPercent"'::jsonb, true)
ELSE execution_contract END AS execution_contract
FROM strategy.element_definitions
WHERE element_catalog_version_id = '0f4a0000-0000-4000-8000-000000000001'
), versioned AS (
SELECT
md5('basic-elements:2026-08-25:' || element_code)::uuid AS id,
element_code,
element_kind,
parameter_schema,
input_port_schema,
output_port_schema,
execution_contract,
'sha256:' || encode(public.digest(
element_code || ':' || parameter_schema::text || ':' || execution_contract::text,
'sha256'), 'hex') AS definition_hash
FROM copied
)
INSERT INTO strategy.element_definitions (
id, element_catalog_version_id, element_code, element_kind, parameter_schema,
input_port_schema, output_port_schema, execution_contract, definition_hash
)
SELECT
id,
'0f5a0000-0000-4000-8000-000000000001',
element_code,
element_kind,
parameter_schema,
input_port_schema,
output_port_schema,
execution_contract,
definition_hash
FROM versioned;
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- Copy the immutable feature definitions for the new Basic catalog under pipeline ownership.
INSERT INTO market_data.feature_definitions (
id, element_catalog_version_id, feature_code, calculator_version, resolution,
normalized_parameters, output_value_type, required_history_points, definition_hash, created_at
)
SELECT
md5('basic-elements:2026-08-25:feature:' || feature_code || ':' || resolution)::uuid,
'0f5a0000-0000-4000-8000-000000000001',
feature_code,
calculator_version,
resolution,
normalized_parameters,
output_value_type,
required_history_points,
'sha256:' || encode(public.digest(
'basic-elements:2026-08-25:feature:' || feature_code || ':' || resolution || ':'
|| normalized_parameters::text,
'sha256'), 'hex'),
'2026-08-25 00:00:01+00'
FROM market_data.feature_definitions
WHERE element_catalog_version_id = '0f4a0000-0000-4000-8000-000000000001';
60 changes: 60 additions & 0 deletions src/backtest_engine/elements/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -955,11 +955,71 @@ def _production_spec(
)


def _v2_specs() -> Mapping[str, ElementSpec]:
specs = dict(_BASIC_ELEMENTS_2026_08_08.specs)
specs["HOLDING_PERIOD"] = _production_spec(
"HOLDING_PERIOD",
("unit", "amount", "resolution"),
enumerations={
"unit": ("SESSION_CLOSE", "BAR", "TRADING_DAY"),
"resolution": _PRODUCTION_RESOLUTIONS,
},
decimals=("amount",),
)
specs["EMIT_ORDER_CANDIDATE"] = ElementSpec(
operation="EMIT_ORDER_CANDIDATE",
required_arguments=(
"allocation",
"orderType",
"timeInForce",
"side",
"orderPercent",
"maxPositionPercent",
"executionMode",
"waitMode",
"waitInterval",
"maxExecutions",
),
enumerations=MappingProxyType(
{
"allocation": _TERMINAL_ALLOCATIONS,
"orderType": _TERMINAL_ORDER_TYPES,
"timeInForce": ("DAY",),
"side": _TERMINAL_SIDES,
"executionMode": _EXECUTION_MODES,
"waitMode": _WAIT_MODES,
}
),
decimal_arguments=(
"orderPercent",
"maxPositionPercent",
"waitInterval",
"maxExecutions",
),
feature_arguments=(),
terminal=True,
produces_value=False,
consumes_value=False,
evaluator=_evaluate_terminal,
)
return MappingProxyType(specs)


_BASIC_ELEMENTS_2026_08_25 = ElementCatalog(
version="basic-elements:2026-08-25",
specs=_v2_specs(),
feature_versions=_BASIC_ELEMENTS_2026_08_08.feature_versions,
canonical_feature_ids=_BASIC_ELEMENTS_2026_08_08.canonical_feature_ids,
canonical_feature_resolutions=_BASIC_ELEMENTS_2026_08_08.canonical_feature_resolutions,
)


ELEMENT_CATALOGS: Mapping[str, ElementCatalog] = MappingProxyType(
{
_BASIC_ELEMENTS_2026_07_31.version: _BASIC_ELEMENTS_2026_07_31,
_BASIC_ELEMENTS_2026_08_07.version: _BASIC_ELEMENTS_2026_08_07,
_BASIC_ELEMENTS_2026_08_08.version: _BASIC_ELEMENTS_2026_08_08,
_BASIC_ELEMENTS_2026_08_25.version: _BASIC_ELEMENTS_2026_08_25,
}
)

Expand Down
14 changes: 14 additions & 0 deletions src/backtest_engine/elements/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ class OrderCandidate:
session_date_et: date
session_closes_at: datetime
budget_cap_bps: int
max_position_percent: Decimal = Decimal("100")
order_percent: Decimal = Decimal("100")
execution_mode: str = "1회만"
wait_mode: str = "조건 재충족"
Expand Down Expand Up @@ -190,6 +191,10 @@ def __post_init__(self) -> None:
)
if not Decimal(0) < self.order_percent <= Decimal(100):
raise ElementEvaluationError("order_percent must lie in (0, 100]")
if not Decimal(0) < self.max_position_percent <= Decimal(100):
raise ElementEvaluationError(
"max_position_percent must lie in (0, 100]"
)
if self.execution_mode not in {
"1회만",
"주기마다",
Expand Down Expand Up @@ -248,6 +253,14 @@ def emit_order_candidate(
wait_mode = step.arguments.get("waitMode", "조건 재충족")
wait_interval = int(step.arguments.get("waitInterval", "1"))
max_executions = int(step.arguments.get("maxExecutions", "1"))
try:
max_position_percent = Decimal(
step.arguments.get("maxPositionPercent", "100")
)
except Exception as exc:
raise ElementEvaluationError(
"maxPositionPercent must be a decimal"
) from exc

if allocation_mode not in SUPPORTED_ALLOCATION_MODES:
raise _reject_argument(
Expand Down Expand Up @@ -290,6 +303,7 @@ def emit_order_candidate(
session_date_et=session_date_et,
session_closes_at=session_closes_at,
budget_cap_bps=budget_cap_bps,
max_position_percent=max_position_percent,
order_percent=order_percent,
execution_mode=execution_mode,
wait_mode=wait_mode,
Expand Down
42 changes: 42 additions & 0 deletions src/backtest_engine/execution_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
"FRACTIONAL_REQUIRES_MARKET_DAY",
"GROSS_EXPOSURE_EXCEEDED",
"INSTRUMENT_EXPOSURE_EXCEEDED",
"MAX_INSTRUMENT_POSITION_PERCENT",
"INSUFFICIENT_AVAILABLE_CASH",
"NOTIONAL_REQUIRES_LONG_EXPOSURE",
"ORDER_HORIZON_EXCEEDED",
Expand Down Expand Up @@ -394,6 +395,7 @@ class OrderRequest:
limit_price: Decimal | None = None
stop_price: Decimal | None = None
trail_percent: Decimal | None = None
max_instrument_position_notional: Decimal | None = None

def __post_init__(self) -> None:
object.__setattr__(self, "order_id", _uuid(self.order_id, "order_id"))
Expand Down Expand Up @@ -431,6 +433,18 @@ def __post_init__(self) -> None:
self._validate_requested_measure()
self._validate_parameters()
self._validate_expiry()
if self.max_instrument_position_notional is not None:
object.__setattr__(
self,
"max_instrument_position_notional",
quantize_money(
_positive(
self.max_instrument_position_notional,
"max_instrument_position_notional",
),
"max_instrument_position_notional",
),
)

def _validate_requested_measure(self) -> None:
if (self.quantity is None) == (self.notional_amount is None):
Expand Down Expand Up @@ -992,6 +1006,20 @@ def _reserve_or_reject(self, state: _OrderState) -> None:
return

estimated_notional, estimated_cash = self._estimated_commitment(state)
if request.max_instrument_position_notional is not None:
estimated_price = self._estimated_price(
OrderSide.BUY, request.reference_price
)
marked_position = quantize_money(
self.position(request.instrument_id).quantity * estimated_price,
"marked_position",
)
reserved = self._instrument_reserved_notional(
request.instrument_id, excluding=state
)
if marked_position + reserved >= request.max_instrument_position_notional:
self._reject(state, "MAX_INSTRUMENT_POSITION_PERCENT")
return
available_cash = self.buying_power - self._reserved_cash(excluding=state)
if estimated_cash > available_cash:
self._reject(state, "INSUFFICIENT_AVAILABLE_CASH")
Expand Down Expand Up @@ -1187,6 +1215,20 @@ def _fillable_quantity(
)
/ price
)
if request.max_instrument_position_notional is not None:
marked_position = quantize_money(
self.position(request.instrument_id).quantity * price,
"marked_position",
)
remaining_cap = max(
request.max_instrument_position_notional
- marked_position
- self._instrument_reserved_notional(
request.instrument_id, excluding=state
),
ZERO,
)
caps.append(remaining_cap / price)
caps.append(
max(
self._risk_limits.max_instrument_exposure
Expand Down
5 changes: 5 additions & 0 deletions src/backtest_engine/wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,11 @@ def place(self, candidate: Any) -> str | None:
eligible_at=candidate.eligible_at,
day_expires_at=candidate.session_closes_at,
reference_price=candidate.reference_price,
max_instrument_position_notional=apply_rate(
self._run.initial_cash,
Decimal(candidate.max_position_percent) / Decimal(100),
"max_instrument_position_notional",
),
)
order = self._model.submit(request)
self._records.append(
Expand Down
Loading
Loading