diff --git a/db/migration-contributions/fixtures/central-migration.sha256 b/db/migration-contributions/fixtures/central-migration.sha256 index 42ebd7d..8744548 100644 --- a/db/migration-contributions/fixtures/central-migration.sha256 +++ b/db/migration-contributions/fixtures/central-migration.sha256 @@ -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 diff --git a/db/migration-contributions/fixtures/central-migration/V20260825000000__backend_basic_strategy_execution_completion.sql.fixture b/db/migration-contributions/fixtures/central-migration/V20260825000000__backend_basic_strategy_execution_completion.sql.fixture new file mode 100644 index 0000000..b8a007f --- /dev/null +++ b/db/migration-contributions/fixtures/central-migration/V20260825000000__backend_basic_strategy_execution_completion.sql.fixture @@ -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; diff --git a/db/migration-contributions/fixtures/central-migration/V20260825000001__pipeline_basic_strategy_feature_catalog.sql.fixture b/db/migration-contributions/fixtures/central-migration/V20260825000001__pipeline_basic_strategy_feature_catalog.sql.fixture new file mode 100644 index 0000000..9760861 --- /dev/null +++ b/db/migration-contributions/fixtures/central-migration/V20260825000001__pipeline_basic_strategy_feature_catalog.sql.fixture @@ -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'; diff --git a/src/backtest_engine/elements/catalog.py b/src/backtest_engine/elements/catalog.py index bbc5739..1589f67 100644 --- a/src/backtest_engine/elements/catalog.py +++ b/src/backtest_engine/elements/catalog.py @@ -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, } ) diff --git a/src/backtest_engine/elements/orders.py b/src/backtest_engine/elements/orders.py index b2ffa86..7886fca 100644 --- a/src/backtest_engine/elements/orders.py +++ b/src/backtest_engine/elements/orders.py @@ -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 = "조건 재충족" @@ -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회만", "주기마다", @@ -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( @@ -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, diff --git a/src/backtest_engine/execution_model.py b/src/backtest_engine/execution_model.py index 430c5c9..9d2c77c 100644 --- a/src/backtest_engine/execution_model.py +++ b/src/backtest_engine/execution_model.py @@ -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", @@ -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")) @@ -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): @@ -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") @@ -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 diff --git a/src/backtest_engine/wiring.py b/src/backtest_engine/wiring.py index eea6c5b..0b8eb0d 100644 --- a/src/backtest_engine/wiring.py +++ b/src/backtest_engine/wiring.py @@ -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( diff --git a/tests/fixtures/contracts/basic-element-conformance.v1.json b/tests/fixtures/contracts/basic-element-conformance.v1.json new file mode 100644 index 0000000..cf761ef --- /dev/null +++ b/tests/fixtures/contracts/basic-element-conformance.v1.json @@ -0,0 +1,167 @@ +{ + "schemaVersion": "basic-element-conformance/v1", + "catalogVersion": "basic-elements:2026-08-25", + "cases": [ + { + "elementCode": "BASIC_PRICE_COMPARE", + "containers": ["BUY", "SELL"], + "validParameters": {"resolution": "30m", "operator": "GT", "reference": "PREVIOUS_CLOSE"}, + "invalidParameters": [{"name": "unsupported-clock", "code": "BASIC_INVALID_RESOLUTION", "parameters": {"resolution": "5m", "operator": "GT", "reference": "PREVIOUS_CLOSE"}}], + "operation": "PRICE_COMPARE", + "arguments": {"resolution": "30m", "operator": "GT", "reference": "PREVIOUS_CLOSE"}, + "trueInputs": {"currentPrice": 101, "referencePrice": 100}, + "falseInputs": {"currentPrice": 99, "referencePrice": 100}, + "expectedReviewKo": "30분 현재가가 전일 종가보다 높음" + }, + { + "elementCode": "BASIC_PRICE_CHANGE_PERCENT", + "containers": ["BUY", "SELL"], + "validParameters": {"resolution": "1h", "base": "PREVIOUS_CLOSE", "direction": "UP", "thresholdPercent": "3.5"}, + "invalidParameters": [{"name": "missing-percent", "code": "BASIC_PARAMETER_REQUIRED", "parameters": {"resolution": "1h", "base": "PREVIOUS_CLOSE", "direction": "UP"}}], + "operation": "PRICE_CHANGE_PERCENT", + "arguments": {"resolution": "1h", "base": "PREVIOUS_CLOSE", "direction": "UP", "thresholdPercent": "3.5"}, + "trueInputs": {"currentPrice": 103.5, "referencePrice": 100}, + "falseInputs": {"currentPrice": 102, "referencePrice": 100}, + "expectedReviewKo": "1시간 등락률이 전일 종가 대비 3.5% 이상" + }, + { + "elementCode": "BASIC_VOLUME_COMPARE", + "containers": ["BUY", "SELL"], + "validParameters": {"resolution": "4h", "operator": "GTE", "reference": "AVERAGE_VOLUME", "period": "20", "multiplier": "2"}, + "invalidParameters": [{"name": "zero-multiplier", "code": "BASIC_PARAMETER_OUT_OF_RANGE", "parameters": {"resolution": "4h", "operator": "GTE", "reference": "AVERAGE_VOLUME", "period": "20", "multiplier": "0"}}], + "operation": "VOLUME_COMPARE", + "arguments": {"resolution": "4h", "operator": "GTE", "reference": "AVERAGE_VOLUME", "period": "20", "multiplier": "2"}, + "trueInputs": {"currentVolume": 2000, "referenceVolume": 1000}, + "falseInputs": {"currentVolume": 1500, "referenceVolume": 1000}, + "expectedReviewKo": "4시간 거래량이 20봉 평균의 2배 이상" + }, + { + "elementCode": "BASIC_STREAK", + "containers": ["BUY", "SELL"], + "validParameters": {"resolution": "1d", "direction": "UP", "bars": "3"}, + "invalidParameters": [{"name": "count-too-small", "code": "BASIC_PARAMETER_OUT_OF_RANGE", "parameters": {"resolution": "1d", "direction": "UP", "bars": "0"}}], + "operation": "STREAK", + "arguments": {"resolution": "1d", "direction": "UP", "bars": "3"}, + "trueInputs": {"closes": [100, 101, 102, 103]}, + "falseInputs": {"closes": [100, 101, 100, 103]}, + "expectedReviewKo": "일봉 종가가 3회 연속 상승" + }, + { + "elementCode": "BASIC_SMA_CROSS", + "containers": ["BUY", "SELL"], + "validParameters": {"resolution": "30m", "direction": "UP", "shortPeriod": "5", "longPeriod": "20"}, + "invalidParameters": [{"name": "reversed-periods", "code": "BASIC_PERIOD_ORDER_INVALID", "parameters": {"resolution": "30m", "direction": "UP", "shortPeriod": "20", "longPeriod": "5"}}], + "operation": "SMA_CROSS", + "arguments": {"resolution": "30m", "direction": "UP", "shortPeriod": "5", "longPeriod": "20"}, + "trueInputs": {"shortPrevious": 99, "longPrevious": 100, "shortCurrent": 101, "longCurrent": 100}, + "falseInputs": {"shortPrevious": 101, "longPrevious": 100, "shortCurrent": 102, "longCurrent": 100}, + "expectedReviewKo": "30분 5단순이동평균이 20단순이동평균을 상향 돌파" + }, + { + "elementCode": "BASIC_RSI_CROSS", + "containers": ["BUY", "SELL"], + "validParameters": {"resolution": "1h", "direction": "UP", "period": "14", "threshold": "30"}, + "invalidParameters": [{"name": "threshold-over-100", "code": "BASIC_PARAMETER_OUT_OF_RANGE", "parameters": {"resolution": "1h", "direction": "UP", "period": "14", "threshold": "101"}}], + "operation": "RSI_CROSS", + "arguments": {"resolution": "1h", "direction": "UP", "period": "14", "threshold": "30"}, + "trueInputs": {"previousRsi": 29, "currentRsi": 31}, + "falseInputs": {"previousRsi": 31, "currentRsi": 32}, + "expectedReviewKo": "1시간 RSI(14)가 30을 상향 돌파" + }, + { + "elementCode": "BASIC_MACD_CROSS", + "containers": ["BUY", "SELL"], + "validParameters": {"resolution": "4h", "direction": "UP", "fastPeriod": "12", "slowPeriod": "26", "signalPeriod": "9"}, + "invalidParameters": [{"name": "reversed-periods", "code": "BASIC_PERIOD_ORDER_INVALID", "parameters": {"resolution": "4h", "direction": "UP", "fastPeriod": "26", "slowPeriod": "12", "signalPeriod": "9"}}], + "operation": "MACD_CROSS", + "arguments": {"resolution": "4h", "direction": "UP", "fastPeriod": "12", "slowPeriod": "26", "signalPeriod": "9"}, + "trueInputs": {"previousMacd": -0.2, "previousSignal": -0.1, "currentMacd": 0.1, "currentSignal": 0}, + "falseInputs": {"previousMacd": 0.1, "previousSignal": 0, "currentMacd": 0.2, "currentSignal": 0.1}, + "expectedReviewKo": "4시간 MACD(12,26,9)가 시그널을 상향 돌파" + }, + { + "elementCode": "BASIC_BOLLINGER_REVERSAL", + "containers": ["BUY", "SELL"], + "validParameters": {"resolution": "1d", "direction": "UP", "period": "20", "deviations": "2"}, + "invalidParameters": [{"name": "negative-deviation", "code": "BASIC_PARAMETER_OUT_OF_RANGE", "parameters": {"resolution": "1d", "direction": "UP", "period": "20", "deviations": "-1"}}], + "operation": "BOLLINGER_REVERSAL", + "arguments": {"resolution": "1d", "direction": "UP", "period": "20", "deviations": "2"}, + "trueInputs": {"previousPrice": 89, "previousLowerBand": 90, "currentPrice": 92, "currentLowerBand": 91}, + "falseInputs": {"previousPrice": 92, "previousLowerBand": 90, "currentPrice": 93, "currentLowerBand": 91}, + "expectedReviewKo": "일봉 종가가 볼린저 하단(20, 2σ) 밖에서 안으로 재진입" + }, + { + "elementCode": "BASIC_POSITION_RETURN", + "containers": ["SELL"], + "validParameters": {"direction": "LOSS", "thresholdPercent": "5"}, + "invalidParameters": [{"name": "missing-return", "code": "BASIC_PARAMETER_REQUIRED", "parameters": {"direction": "LOSS"}}], + "operation": "POSITION_RETURN", + "arguments": {"direction": "LOSS", "thresholdPercent": "5"}, + "trueInputs": {"positionReturnPercent": -5.1}, + "falseInputs": {"positionReturnPercent": -4.9}, + "expectedReviewKo": "보유 포지션 수익률이 -5% 이하" + }, + { + "elementCode": "BASIC_HOLDING_PERIOD", + "containers": ["SELL"], + "validParameters": {"unit": "TRADING_DAY", "amount": "10", "resolution": "1d"}, + "invalidParameters": [{"name": "fractional-bars", "code": "BASIC_PARAMETER_INTEGER_REQUIRED", "parameters": {"unit": "TRADING_DAY", "amount": "1.5", "resolution": "1d"}}], + "operation": "HOLDING_PERIOD", + "arguments": {"unit": "TRADING_DAY", "amount": "10", "resolution": "1d"}, + "trueInputs": {"heldBars": 10}, + "falseInputs": {"heldBars": 9}, + "expectedReviewKo": "보유 기간이 일봉 10개 이상" + }, + { + "elementCode": "BASIC_PEAK_RETURN", + "containers": ["SELL"], + "validParameters": {"operator": "GTE", "thresholdPercent": "15"}, + "invalidParameters": [{"name": "negative-peak", "code": "BASIC_PARAMETER_OUT_OF_RANGE", "parameters": {"operator": "GTE", "thresholdPercent": "-1"}}], + "operation": "PEAK_RETURN", + "arguments": {"operator": "GTE", "thresholdPercent": "15"}, + "trueInputs": {"peakReturnPercent": 15.1}, + "falseInputs": {"peakReturnPercent": 14.9}, + "expectedReviewKo": "보유 중 최고 수익률이 15% 이상" + }, + { + "elementCode": "BASIC_DRAWDOWN_FROM_PEAK", + "containers": ["SELL"], + "validParameters": {"operator": "GTE", "thresholdPercent": "7"}, + "invalidParameters": [{"name": "over-100", "code": "BASIC_PARAMETER_OUT_OF_RANGE", "parameters": {"operator": "GTE", "thresholdPercent": "101"}}], + "operation": "DRAWDOWN_FROM_PEAK", + "arguments": {"operator": "GTE", "thresholdPercent": "7"}, + "trueInputs": {"peakPrice": 110, "currentPrice": 102}, + "falseInputs": {"peakPrice": 110, "currentPrice": 109}, + "expectedReviewKo": "고점 대비 하락률이 7% 이상" + }, + { + "elementCode": "BASIC_SCHEDULE", + "containers": ["BUY"], + "validParameters": {"cycle": "EVERY_N_TRADING_DAYS", "interval": "5", "resolution": "1d"}, + "invalidParameters": [{"name": "zero-interval", "code": "BASIC_PARAMETER_OUT_OF_RANGE", "parameters": {"cycle": "EVERY_N_TRADING_DAYS", "interval": "0", "resolution": "1d"}}], + "operation": "SCHEDULE", + "arguments": {"cycle": "EVERY_N_TRADING_DAYS", "interval": "5", "resolution": "1d"}, + "trueInputs": {"timestamp": "2026-08-26T09:00:00+09:00"}, + "falseInputs": {"timestamp": "2026-08-27T09:00:00+09:00"}, + "expectedReviewKo": "Asia/Seoul 기준 월·수·금 09:00에 실행" + }, + { + "elementCode": "BASIC_EQUAL_ALLOCATION_ORDER", + "containers": ["BUY", "SELL"], + "validParameters": {"orderPercent": "25", "maxPositionPercent": "40", "executionMode": "1회만", "waitMode": "조건 재충족", "waitInterval": "1", "maxExecutions": "1"}, + "invalidParameters": [ + {"name": "zero-cap", "code": "BASIC_POSITION_CAP_OUT_OF_RANGE", "parameters": {"orderPercent": "25", "maxPositionPercent": "0", "executionMode": "1회만", "waitMode": "조건 재충족", "waitInterval": "1", "maxExecutions": "1"}}, + {"name": "cap-over-100", "code": "BASIC_POSITION_CAP_OUT_OF_RANGE", "parameters": {"orderPercent": "25", "maxPositionPercent": "100.1", "executionMode": "1회만", "waitMode": "조건 재충족", "waitInterval": "1", "maxExecutions": "1"}}, + {"name": "negative-cap", "code": "BASIC_POSITION_CAP_OUT_OF_RANGE", "parameters": {"orderPercent": "25", "maxPositionPercent": "-1", "executionMode": "1회만", "waitMode": "조건 재충족", "waitInterval": "1", "maxExecutions": "1"}}, + {"name": "empty-cap", "code": "BASIC_POSITION_CAP_REQUIRED", "parameters": {"orderPercent": "25", "maxPositionPercent": "", "executionMode": "1회만", "waitMode": "조건 재충족", "waitInterval": "1", "maxExecutions": "1"}}, + {"name": "malformed-cap", "code": "BASIC_POSITION_CAP_NUMBER_REQUIRED", "parameters": {"orderPercent": "25", "maxPositionPercent": "many", "executionMode": "1회만", "waitMode": "조건 재충족", "waitInterval": "1", "maxExecutions": "1"}} + ], + "operation": "EMIT_ORDER_CANDIDATE", + "arguments": {"orderPercent": "25", "maxPositionPercent": "40", "executionMode": "1회만", "waitMode": "조건 재충족", "waitInterval": "1", "maxExecutions": "1"}, + "trueInputs": {"conditionsPassed": true, "currentPositionPercent": 10, "side": "BUY"}, + "falseInputs": {"conditionsPassed": true, "currentPositionPercent": 40, "side": "BUY"}, + "expectedReviewKo": "조건 충족 시 25% 균등 주문, 종목별 보유 비중 40% 상한" + } + ] +} + diff --git a/tests/test_basic_element_conformance.py b/tests/test_basic_element_conformance.py new file mode 100644 index 0000000..cb61c29 --- /dev/null +++ b/tests/test_basic_element_conformance.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone +from decimal import Decimal +from pathlib import Path + +import pytest + +from backtest_engine.elements import ( + ElementEvaluation, + ElementInputMissing, + InstrumentInput, + PinnedFeatureSeries, + PinnedFeatureValue, + PlanStep, + element_catalog, +) + + +FIXTURE = Path(__file__).parent / "fixtures/contracts/basic-element-conformance.v1.json" +INSTRUMENT = "00000000-0000-4000-8000-000000000301" +AS_OF = datetime(2026, 8, 26, 20, 0, tzinfo=timezone.utc) + + +def test_v2_catalog_accepts_every_compiled_operation_and_argument_from_the_corpus() -> None: + document = json.loads(FIXTURE.read_text(encoding="utf-8")) + catalog = element_catalog(document["catalogVersion"]) + + assert len(document["cases"]) == 14 + assert {case["operation"] for case in document["cases"]} == set(catalog.operations) + + for case in document["cases"]: + arguments = dict(case["arguments"]) + if case["operation"] == "EMIT_ORDER_CANDIDATE": + arguments.update( + allocation="EQUAL", + orderType="MARKET", + timeInForce="DAY", + side="BUY", + ) + catalog.validate_step( + PlanStep(sequence=1, operation=case["operation"], arguments=arguments) + ) + + +def _values(operation: str, passed: bool) -> dict[str, str]: + common = {"bar.closed.30m": "true", "bar.closed.1h": "true", "bar.closed.4h": "true", "bar.closed.1d": "true"} + scenarios = { + "PRICE_COMPARE": {"closes.30m": "100,101" if passed else "100,99"}, + "PRICE_CHANGE_PERCENT": {"closes.1h": "100,103.5" if passed else "100,102"}, + "VOLUME_COMPARE": {"volumes.4h": ",".join(["1000"] * 20 + (["2000"] if passed else ["1500"]))}, + "STREAK": {"closes.1d": "100,101,102,103" if passed else "100,101,100,103"}, + "SMA_CROSS": {"closes.30m": ",".join((["100"] * 16 + ["90"] * 4 + ["200"]) if passed else ["100"] * 21)}, + "MACD_CROSS": {"closes.4h": ",".join((["100"] * 37 + ["110"]) if passed else ["100"] * 38)}, + "BOLLINGER_REVERSAL": {"closes.1d": ",".join((["100"] * 19 + ["80", "100"]) if passed else ["100"] * 21)}, + "POSITION_RETURN": {"position.returnPercent": "-5.1" if passed else "-4.9"}, + "HOLDING_PERIOD": {"position.holdingTradingDays": "10" if passed else "9"}, + "PEAK_RETURN": {"position.peakReturnPercent": "15.1" if passed else "14.9"}, + "DRAWDOWN_FROM_PEAK": {"position.drawdownPercent": "7.1" if passed else "0.9"}, + "SCHEDULE": { + "schedule.newTradingDay": "true", + "schedule.tradingDayIndex": "1" if passed else "2", + "schedule.weekFirstTradingDay": "false", + "schedule.monthFirstTradingDay": "false", + "schedule.monthLastTradingDay": "false", + }, + } + return {**common, **scenarios.get(operation, {})} + + +def _evaluation(operation: str, passed: bool) -> ElementEvaluation: + features = () + if operation == "RSI_CROSS": + period = timedelta(hours=1) + features = (PinnedFeatureSeries( + feature_id="RSI_14", + instrument_id=INSTRUMENT, + resolution="1h", + values=( + PinnedFeatureValue(AS_OF - period * 2, Decimal("29.00000000") if passed else Decimal("31.00000000")), + PinnedFeatureValue(AS_OF - period, Decimal("31.00000000") if passed else Decimal("32.00000000")), + ), + ),) + return ElementEvaluation( + instrument_id=INSTRUMENT, + as_of=AS_OF, + inputs=InstrumentInput( + instrument_id=INSTRUMENT, + series=(), + feature_series=features, + require_pinned_features=bool(features), + values=_values(operation, passed), + ), + ) + + +@pytest.mark.parametrize("passed", [True, False]) +def test_every_condition_has_real_true_and_false_runtime_outcomes(passed: bool) -> None: + document = json.loads(FIXTURE.read_text(encoding="utf-8")) + catalog = element_catalog(document["catalogVersion"]) + + for case in document["cases"]: + if case["operation"] == "EMIT_ORDER_CANDIDATE": + continue + step = PlanStep(sequence=1, operation=case["operation"], arguments=case["arguments"]) + outcome = catalog.evaluate(step, _evaluation(case["operation"], passed)) + assert outcome.is_passed is passed, case["operation"] + + +def test_missing_history_is_unavailable_and_an_open_bar_waits_instead_of_becoming_a_signal() -> None: + catalog = element_catalog("basic-elements:2026-08-25") + price = PlanStep( + sequence=1, + operation="PRICE_COMPARE", + arguments={"resolution": "30m", "operator": "GT", "reference": "PREVIOUS_CLOSE"}, + ) + missing = ElementEvaluation( + instrument_id=INSTRUMENT, + as_of=AS_OF, + inputs=InstrumentInput(instrument_id=INSTRUMENT, series=(), values={"bar.closed.30m": "true"}), + ) + with pytest.raises(ElementInputMissing): + catalog.evaluate(price, missing) + + open_bar = ElementEvaluation( + instrument_id=INSTRUMENT, + as_of=AS_OF, + inputs=InstrumentInput( + instrument_id=INSTRUMENT, + series=(), + values={"bar.closed.30m": "false", "closes.30m": "100,101"}, + ), + ) + outcome = catalog.evaluate(price, open_bar) + assert outcome.is_passed is False + assert outcome.reason_code == "WAITING_FOR_BAR_CLOSE" diff --git a/tests/test_basic_runtime.py b/tests/test_basic_runtime.py index 54236c0..86aa833 100644 --- a/tests/test_basic_runtime.py +++ b/tests/test_basic_runtime.py @@ -244,6 +244,65 @@ def full_catalog(document: dict[str, Any]) -> None: assert result.decisions[0].reference_price == Decimal("101.00000000") +def test_v2_catalog_position_cap_survives_plan_loading_and_candidate_emission() -> None: + def v2_catalog(document: dict[str, Any]) -> None: + document["elementCatalogVersion"] = "basic-elements:2026-08-25" + document["requiredFeatures"] = [] + document["steps"] = [ + {"sequence": 1, "operation": "PRICE_COMPARE", "arguments": { + "resolution": "30m", "operator": "GT", "reference": "PREVIOUS_CLOSE", + }}, + {"sequence": 2, "operation": "EMIT_ORDER_CANDIDATE", "arguments": { + "allocation": "EQUAL", "orderType": "MARKET", "timeInForce": "DAY", + "side": "BUY", "orderPercent": "25", "maxPositionPercent": "40", + "executionMode": "1회만", "waitMode": "조건 재충족", + "waitInterval": "1", "maxExecutions": "1", + }}, + ] + + plan = _runtime().load(_resealed(v2_catalog)) + evaluation_at = OPEN + timedelta(hours=1) + bars = tuple( + SeriesBar( + instrument_id=FIRST, + resolution="30m", + starts_at=OPEN + timedelta(minutes=30 * index), + ends_at=OPEN + timedelta(minutes=30 * (index + 1)), + close=Decimal(value), + volume=Decimal("1000"), + ) + for index, value in enumerate(("100", "101")) + ) + result = _runtime().execute( + plan, + {FIRST: InstrumentInput( + instrument_id=FIRST, + series=(InstrumentSeries( + instrument_id=FIRST, + data_kind="ADJUSTED_BAR", + resolution="30m", + bars=bars, + ),), + values={ + "bar.closed.30m": "true", + "closes.30m": "100,101", + "volumes.30m": "1000,1000", + }, + )}, + as_of=evaluation_at, + ) + candidate = _runtime().order_candidates( + plan, + result, + evaluation_id="evaluation-v2", + session_date_et=SESSION_DATE, + session_closes_at=evaluation_at + timedelta(hours=1), + )[0] + + assert candidate.order_percent == Decimal("25") + assert candidate.max_position_percent == Decimal("40") + + @pytest.mark.parametrize( ("resolution", "wire_resolution", "feature_id"), [ diff --git a/tests/test_elements.py b/tests/test_elements.py index 9fe7cfd..5592efc 100644 --- a/tests/test_elements.py +++ b/tests/test_elements.py @@ -326,6 +326,7 @@ def test_the_published_catalog_version_is_the_one_b_emits() -> None: "basic-elements:2026-07-31", "basic-elements:2026-08-07", "basic-elements:2026-08-08", + "basic-elements:2026-08-25", ) catalog = element_catalog(CATALOG_VERSION) assert catalog.version == CATALOG_VERSION diff --git a/tests/test_elements_orders.py b/tests/test_elements_orders.py index 0b34950..7e10664 100644 --- a/tests/test_elements_orders.py +++ b/tests/test_elements_orders.py @@ -39,6 +39,23 @@ arguments={"allocation": "EQUAL", "orderType": "MARKET", "side": "BUY"}, ) +V2_BUY_STEP = PlanStep( + sequence=3, + operation="EMIT_ORDER_CANDIDATE", + arguments={ + "allocation": "EQUAL", + "orderType": "MARKET", + "timeInForce": "DAY", + "side": "BUY", + "orderPercent": "25", + "maxPositionPercent": "40", + "executionMode": "1회만", + "waitMode": "조건 재충족", + "waitInterval": "1", + "maxExecutions": "1", + }, +) + def _emit(**overrides: Any) -> OrderCandidate: arguments: dict[str, Any] = { @@ -87,6 +104,25 @@ def test_emits_every_field_the_execution_layer_needs() -> None: assert candidate.session_date_et == date(2025, 11, 28) assert candidate.session_closes_at == CLOSES_AT assert candidate.budget_cap_bps == 10000 + assert candidate.max_position_percent == Decimal("100") + + +def test_v2_terminal_requires_and_emits_the_per_instrument_position_cap() -> None: + candidate = _emit(step=V2_BUY_STEP) + + assert candidate.max_position_percent == Decimal("40") + + +@pytest.mark.parametrize("cap", ["0", "100.1", "-1", "many"]) +def test_v2_terminal_refuses_an_invalid_per_instrument_position_cap(cap: str) -> None: + step = PlanStep( + sequence=V2_BUY_STEP.sequence, + operation=V2_BUY_STEP.operation, + arguments={**V2_BUY_STEP.arguments, "maxPositionPercent": cap}, + ) + + with pytest.raises((ElementCompatibilityError, ElementEvaluationError)): + _emit(step=step) def test_the_side_comes_from_the_step_and_a_sell_carries_no_allocation() -> None: diff --git a/tests/test_wiring.py b/tests/test_wiring.py index ceb689b..bbd0e34 100644 --- a/tests/test_wiring.py +++ b/tests/test_wiring.py @@ -122,15 +122,17 @@ def _engine( def _candidate( *, + instrument_id: str = INSTRUMENT_ID, side: str = "BUY", allocation: Fraction | None = Fraction(1, 1), budget_cap_bps: int = 10000, + max_position_percent: Decimal = Decimal("100"), reference_price: Decimal = Decimal("100.00000000"), decided_at: datetime = datetime(2024, 1, 2, 14, 45, tzinfo=UTC), ) -> OrderCandidate: return OrderCandidate( evaluation_id=f"eval-{decided_at.isoformat()}-{side}", - instrument_id=INSTRUMENT_ID, + instrument_id=instrument_id, partition_key="partition-1", flow_id="flow-1", side=side, @@ -142,6 +144,7 @@ def _candidate( session_date_et=date(2024, 1, 2), session_closes_at=SESSION_CLOSE, budget_cap_bps=budget_cap_bps, + max_position_percent=max_position_percent, ) @@ -305,6 +308,54 @@ def test_a_refused_order_is_still_recorded_as_evidence() -> None: assert engine.declined_candidates == () +def test_per_instrument_cap_counts_filled_and_reserved_buy_exposure() -> None: + engine = _engine() + first = _candidate( + budget_cap_bps=2500, + max_position_percent=Decimal("40"), + ) + assert engine.place(first) is not None + assert engine.settle(_bar_event(15)) == 1 + + second = _candidate( + budget_cap_bps=2500, + max_position_percent=Decimal("40"), + decided_at=datetime(2024, 1, 2, 14, 47, tzinfo=UTC), + ) + assert engine.place(second) is not None + + third = _candidate( + budget_cap_bps=2500, + max_position_percent=Decimal("40"), + decided_at=datetime(2024, 1, 2, 14, 48, tzinfo=UTC), + ) + assert engine.place(third) is None + assert engine.records[-1].kind is ResultRecordKind.REJECTION + assert engine.records[-1].reason_code == "MAX_INSTRUMENT_POSITION_PERCENT" + + assert engine.settle(_bar_event(17)) >= 1 + position = engine.summary().positions[INSTRUMENT_ID] + latest_fill_price = engine.records[-1].price + assert latest_fill_price is not None + assert position * latest_fill_price <= Decimal("40000.00000000") + + +def test_position_caps_are_isolated_between_instruments() -> None: + engine = _engine() + other_instrument = "00000000-0000-4000-8000-000000000302" + + assert engine.place(_candidate( + budget_cap_bps=2000, + max_position_percent=Decimal("20"), + )) is not None + assert engine.place(_candidate( + instrument_id=other_instrument, + budget_cap_bps=2000, + max_position_percent=Decimal("20"), + decided_at=datetime(2024, 1, 2, 14, 47, tzinfo=UTC), + )) is not None + + def test_a_sell_candidate_is_sized_from_the_held_position() -> None: """A disposal carries no allocation; its size is what the run actually holds.""" engine = _engine() @@ -315,6 +366,7 @@ def test_a_sell_candidate_is_sized_from_the_held_position() -> None: sell = _candidate( side="SELL", allocation=None, + max_position_percent=Decimal("1"), decided_at=datetime(2024, 1, 2, 14, 47, tzinfo=UTC), ) order_id = engine.place(sell)