petrosa-contracts 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: petrosa-contracts
3
+ Version: 0.1.0
4
+ Summary: Canonical shared pydantic contracts (Signal, TradingConfig) for the Petrosa trading path
5
+ Author-email: Petrosa Team <dev@petrosa.com>
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: pydantic>=2.0.0
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
16
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
17
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
18
+
19
+ # petrosa-contracts
20
+
21
+ Canonical, shared `pydantic` contracts for the Petrosa trading path.
22
+
23
+ This package resolves petrosa_k8s#1116: `contracts/signal.py` and
24
+ `contracts/trading_config.py` were copy-pasted into `petrosa-cio/` and
25
+ `petrosa-tradeengine/` and had diverged in both directions (CIO had
26
+ `leverage`, tradeengine had `decision_id`; the `TradingConfigAudit.config_type`
27
+ enum differed). One of those divergences (`leverage` reaching tradeengine)
28
+ was the direct cause of `petrosa-tradeengine#599` — a P0 where every position
29
+ traded at a hardcoded 10x leverage because the field was silently dropped by
30
+ `extra="ignore"`.
31
+
32
+ ## What's here
33
+
34
+ - `petrosa_contracts.signal.Signal` and its enums (`SignalType`,
35
+ `SignalStrength`, `TimeFrame`, `OrderType`, `TimeInForce`, `StrategyMode`) —
36
+ the reconciled superset of both repos' `Signal` models, with both
37
+ `leverage` and `decision_id`.
38
+ - `petrosa_contracts.trading_config.TradingConfig`, `TradingConfigAudit`,
39
+ `LeverageStatus` — the reconciled superset, with `TradingConfigAudit`
40
+ supporting all four `config_type` values (`global`, `symbol`,
41
+ `symbol_side`, `strategy`).
42
+ - Every model sets `model_config = ConfigDict(extra="forbid")`. A producer
43
+ field with no matching consumer attribute now raises `ValidationError` at
44
+ parse time instead of being silently discarded.
45
+
46
+ ## Installation
47
+
48
+ Install the published package from PyPI:
49
+
50
+ ```bash
51
+ pip install petrosa-contracts
52
+ ```
53
+
54
+ For local development only, contributors with access to the private repository may
55
+ install directly from the source tree:
56
+
57
+ ```bash
58
+ pip install "petrosa-contracts @ git+https://github.com/PetroSa2/petrosa_k8s.git@main#subdirectory=petrosa-contracts"
59
+ ```
60
+
61
+ This private-repository URL requires repository access and must not appear in a public
62
+ consumer's `requirements.txt`. Tags named `petrosa-contracts-vX.Y.Z` trigger the
63
+ publication workflow.
64
+
65
+ ## Usage
66
+
67
+ ```python
68
+ from petrosa_contracts import Signal, TradingConfig
69
+
70
+ signal = Signal(
71
+ strategy_id="orderbook_skew",
72
+ symbol="BTCUSDT",
73
+ action="buy",
74
+ confidence=0.82,
75
+ price=65000.0,
76
+ quantity=0.01,
77
+ current_price=65000.0,
78
+ source="petrosa-cio",
79
+ strategy="orderbook_skew",
80
+ leverage=5,
81
+ decision_id="a1b2c3d4e5f6",
82
+ )
83
+
84
+ # Any field neither producer nor consumer declared now raises immediately:
85
+ Signal(**{**signal.model_dump(), "unexpected_field": 1})
86
+ # -> pydantic.ValidationError: Extra inputs are not permitted
87
+ ```
88
+
89
+ ## Scope of petrosa_k8s#1116 covered by this package
90
+
91
+ This PR creates the canonical package and its contract tests
92
+ (petrosa_k8s#1116 AC: "exist in exactly one place", "superset reconciling
93
+ both divergences", "`extra=forbid`", "contract test in CI"). It does
94
+ **not** yet remove the vendored copies from `petrosa-cio/contracts/` and
95
+ `petrosa-tradeengine/contracts/` or switch those services to import from
96
+ this package — that requires coordinated, staged changes in each of those
97
+ repos' own CI/deploy pipelines and is tracked as follow-up work:
98
+
99
+ - `petrosa-cio`: migrate to `petrosa_contracts`, delete the vendored copy.
100
+ - `petrosa-tradeengine`: migrate to `petrosa_contracts`, delete the vendored
101
+ copy.
102
+
103
+ Both follow-ups should keep the same staged-rollout discipline the parent
104
+ ticket calls for (log-then-enforce) for any field this package's
105
+ `extra="forbid"` newly rejects that either service's real traffic still
106
+ sends.
107
+
108
+ ## Development
109
+
110
+ ```bash
111
+ cd petrosa-contracts
112
+ pip install -e ".[dev]"
113
+ pytest --cov=petrosa_contracts --cov-report=term-missing
114
+ ruff check src/ tests/
115
+ ruff format --check src/ tests/
116
+ ```
@@ -0,0 +1,98 @@
1
+ # petrosa-contracts
2
+
3
+ Canonical, shared `pydantic` contracts for the Petrosa trading path.
4
+
5
+ This package resolves petrosa_k8s#1116: `contracts/signal.py` and
6
+ `contracts/trading_config.py` were copy-pasted into `petrosa-cio/` and
7
+ `petrosa-tradeengine/` and had diverged in both directions (CIO had
8
+ `leverage`, tradeengine had `decision_id`; the `TradingConfigAudit.config_type`
9
+ enum differed). One of those divergences (`leverage` reaching tradeengine)
10
+ was the direct cause of `petrosa-tradeengine#599` — a P0 where every position
11
+ traded at a hardcoded 10x leverage because the field was silently dropped by
12
+ `extra="ignore"`.
13
+
14
+ ## What's here
15
+
16
+ - `petrosa_contracts.signal.Signal` and its enums (`SignalType`,
17
+ `SignalStrength`, `TimeFrame`, `OrderType`, `TimeInForce`, `StrategyMode`) —
18
+ the reconciled superset of both repos' `Signal` models, with both
19
+ `leverage` and `decision_id`.
20
+ - `petrosa_contracts.trading_config.TradingConfig`, `TradingConfigAudit`,
21
+ `LeverageStatus` — the reconciled superset, with `TradingConfigAudit`
22
+ supporting all four `config_type` values (`global`, `symbol`,
23
+ `symbol_side`, `strategy`).
24
+ - Every model sets `model_config = ConfigDict(extra="forbid")`. A producer
25
+ field with no matching consumer attribute now raises `ValidationError` at
26
+ parse time instead of being silently discarded.
27
+
28
+ ## Installation
29
+
30
+ Install the published package from PyPI:
31
+
32
+ ```bash
33
+ pip install petrosa-contracts
34
+ ```
35
+
36
+ For local development only, contributors with access to the private repository may
37
+ install directly from the source tree:
38
+
39
+ ```bash
40
+ pip install "petrosa-contracts @ git+https://github.com/PetroSa2/petrosa_k8s.git@main#subdirectory=petrosa-contracts"
41
+ ```
42
+
43
+ This private-repository URL requires repository access and must not appear in a public
44
+ consumer's `requirements.txt`. Tags named `petrosa-contracts-vX.Y.Z` trigger the
45
+ publication workflow.
46
+
47
+ ## Usage
48
+
49
+ ```python
50
+ from petrosa_contracts import Signal, TradingConfig
51
+
52
+ signal = Signal(
53
+ strategy_id="orderbook_skew",
54
+ symbol="BTCUSDT",
55
+ action="buy",
56
+ confidence=0.82,
57
+ price=65000.0,
58
+ quantity=0.01,
59
+ current_price=65000.0,
60
+ source="petrosa-cio",
61
+ strategy="orderbook_skew",
62
+ leverage=5,
63
+ decision_id="a1b2c3d4e5f6",
64
+ )
65
+
66
+ # Any field neither producer nor consumer declared now raises immediately:
67
+ Signal(**{**signal.model_dump(), "unexpected_field": 1})
68
+ # -> pydantic.ValidationError: Extra inputs are not permitted
69
+ ```
70
+
71
+ ## Scope of petrosa_k8s#1116 covered by this package
72
+
73
+ This PR creates the canonical package and its contract tests
74
+ (petrosa_k8s#1116 AC: "exist in exactly one place", "superset reconciling
75
+ both divergences", "`extra=forbid`", "contract test in CI"). It does
76
+ **not** yet remove the vendored copies from `petrosa-cio/contracts/` and
77
+ `petrosa-tradeengine/contracts/` or switch those services to import from
78
+ this package — that requires coordinated, staged changes in each of those
79
+ repos' own CI/deploy pipelines and is tracked as follow-up work:
80
+
81
+ - `petrosa-cio`: migrate to `petrosa_contracts`, delete the vendored copy.
82
+ - `petrosa-tradeengine`: migrate to `petrosa_contracts`, delete the vendored
83
+ copy.
84
+
85
+ Both follow-ups should keep the same staged-rollout discipline the parent
86
+ ticket calls for (log-then-enforce) for any field this package's
87
+ `extra="forbid"` newly rejects that either service's real traffic still
88
+ sends.
89
+
90
+ ## Development
91
+
92
+ ```bash
93
+ cd petrosa-contracts
94
+ pip install -e ".[dev]"
95
+ pytest --cov=petrosa_contracts --cov-report=term-missing
96
+ ruff check src/ tests/
97
+ ruff format --check src/ tests/
98
+ ```
@@ -0,0 +1,61 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "petrosa-contracts"
7
+ version = "0.1.0"
8
+ description = "Canonical shared pydantic contracts (Signal, TradingConfig) for the Petrosa trading path"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ authors = [
12
+ { name = "Petrosa Team", email = "dev@petrosa.com" }
13
+ ]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ ]
21
+
22
+ dependencies = [
23
+ "pydantic>=2.0.0",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ dev = [
28
+ "pytest>=7.0.0",
29
+ "pytest-cov>=4.0.0",
30
+ "ruff>=0.1.0",
31
+ ]
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
35
+
36
+ [tool.ruff]
37
+ line-length = 88
38
+ target-version = "0.1.0"
39
+
40
+ [tool.ruff.lint]
41
+ select = ["E", "F", "I", "N", "W", "UP"]
42
+ ignore = ["E501"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
46
+ python_files = "test_*.py"
47
+ python_classes = "Test*"
48
+ python_functions = "test_*"
49
+ addopts = "-v --cov=petrosa_contracts --cov-report=term-missing --cov-fail-under=40"
50
+
51
+ [tool.coverage.run]
52
+ source = ["petrosa_contracts"]
53
+ omit = [
54
+ "*/tests/*",
55
+ "*/__pycache__/*",
56
+ ]
57
+
58
+ [tool.coverage.report]
59
+ precision = 2
60
+ show_missing = true
61
+ skip_covered = false
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,43 @@
1
+ """petrosa-contracts: canonical, shared pydantic contracts for the Petrosa trading path.
2
+
3
+ This package is the single source of truth for the `Signal` and `TradingConfig`
4
+ family of models previously copy-pasted into `petrosa-cio/contracts/` and
5
+ `petrosa-tradeengine/contracts/` (petrosa_k8s#1116). All models here declare
6
+ `model_config = ConfigDict(extra="forbid")`: because this package has no live
7
+ consumers yet, there is no "silent divergence" blast radius to stage around —
8
+ unlike flipping `extra="forbid"` on the two *existing* copies, which the
9
+ originating ticket correctly identifies as unsafe to do blind against live
10
+ traffic. Consuming services should construct these models directly from
11
+ producer payloads so any unknown field raises at parse time instead of being
12
+ silently dropped (the root cause of petrosa-tradeengine#599).
13
+ """
14
+
15
+ from petrosa_contracts.signal import (
16
+ OrderType,
17
+ Signal,
18
+ SignalStrength,
19
+ SignalType,
20
+ StrategyMode,
21
+ TimeFrame,
22
+ TimeInForce,
23
+ )
24
+ from petrosa_contracts.trading_config import (
25
+ LeverageStatus,
26
+ TradingConfig,
27
+ TradingConfigAudit,
28
+ )
29
+
30
+ __version__ = "0.1.0"
31
+
32
+ __all__ = [
33
+ "OrderType",
34
+ "Signal",
35
+ "SignalStrength",
36
+ "SignalType",
37
+ "StrategyMode",
38
+ "TimeFrame",
39
+ "TimeInForce",
40
+ "LeverageStatus",
41
+ "TradingConfig",
42
+ "TradingConfigAudit",
43
+ ]
@@ -0,0 +1,288 @@
1
+ """Canonical `Signal` contract shared by petrosa-cio and petrosa-tradeengine.
2
+
3
+ Reconciles the two previously copy-pasted implementations
4
+ (`petrosa-cio/contracts/signal.py`, `petrosa-tradeengine/contracts/signal.py`)
5
+ into a single superset:
6
+
7
+ - `leverage` (originally only on CIO's copy, ported to tradeengine's copy by
8
+ petrosa-tradeengine#599) is present here.
9
+ - `decision_id` (originally only on tradeengine's copy) is present here.
10
+ - `extra="forbid"` is set unconditionally. Unlike flipping this flag on an
11
+ *existing* copy with live producers/consumers already depending on
12
+ `extra="ignore"` (which the parent ticket, petrosa_k8s#1116, explicitly
13
+ warns must be staged behind a logging period — see the ticket's
14
+ "Sequencing" section), this package has no consumers yet, so there is
15
+ nothing depending on unknown fields being silently dropped. Forbidding
16
+ from day one is the safe default for a greenfield contract.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ from datetime import UTC, datetime
23
+ from enum import StrEnum
24
+ from typing import Any, Literal
25
+
26
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ _MIN_UNIX_TS = 946684800 # 2000-01-01T00:00:00Z
31
+ _MAX_UNIX_TS = 4102444800 # 2100-01-01T00:00:00Z
32
+
33
+
34
+ class SignalType(StrEnum):
35
+ """Signal types for trading actions."""
36
+
37
+ BUY = "buy"
38
+ SELL = "sell"
39
+ HOLD = "hold"
40
+ CLOSE = "close"
41
+
42
+
43
+ class SignalStrength(StrEnum):
44
+ """Signal strength levels."""
45
+
46
+ WEAK = "weak"
47
+ MEDIUM = "medium"
48
+ STRONG = "strong"
49
+ EXTREME = "extreme"
50
+
51
+
52
+ class TimeFrame(StrEnum):
53
+ """Trading timeframes for signal analysis."""
54
+
55
+ TICK = "tick"
56
+ MINUTE_1 = "1m"
57
+ MINUTE_3 = "3m"
58
+ MINUTE_5 = "5m"
59
+ MINUTE_15 = "15m"
60
+ MINUTE_30 = "30m"
61
+ HOUR_1 = "1h"
62
+ HOUR_2 = "2h"
63
+ HOUR_4 = "4h"
64
+ HOUR_6 = "6h"
65
+ HOUR_8 = "8h"
66
+ HOUR_12 = "12h"
67
+ DAY_1 = "1d"
68
+ DAY_3 = "3d"
69
+ WEEK_1 = "1w"
70
+ MONTH_1 = "1M"
71
+
72
+
73
+ class OrderType(StrEnum):
74
+ """Supported order types."""
75
+
76
+ MARKET = "market"
77
+ LIMIT = "limit"
78
+ STOP = "stop"
79
+ STOP_LIMIT = "stop_limit"
80
+ TAKE_PROFIT = "take_profit"
81
+ TAKE_PROFIT_LIMIT = "take_profit_limit"
82
+ CONDITIONAL_LIMIT = "conditional_limit"
83
+ CONDITIONAL_STOP = "conditional_stop"
84
+
85
+
86
+ class TimeInForce(StrEnum):
87
+ """Order time in force options."""
88
+
89
+ GTC = "GTC"
90
+ IOC = "IOC"
91
+ FOK = "FOK"
92
+ GTX = "GTX"
93
+
94
+
95
+ class StrategyMode(StrEnum):
96
+ """Strategy processing modes."""
97
+
98
+ DETERMINISTIC = "deterministic"
99
+ ML_LIGHT = "ml_light"
100
+ LLM_REASONING = "llm_reasoning"
101
+
102
+
103
+ def _parse_datetime_aware(value: Any) -> datetime:
104
+ """Best-effort, timezone-aware datetime coercion.
105
+
106
+ Mirrors the two (near-identical) validators previously duplicated in
107
+ `petrosa-cio` and `petrosa-tradeengine`. Falls back to "now" on anything
108
+ unparseable rather than raising, matching prior behavior for both
109
+ consumers.
110
+ """
111
+ if isinstance(value, datetime):
112
+ return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
113
+ if isinstance(value, int | float):
114
+ if _MIN_UNIX_TS <= value <= _MAX_UNIX_TS:
115
+ return datetime.fromtimestamp(value, UTC)
116
+ logger.warning(
117
+ "Unix timestamp %s out of valid range - using current time", value
118
+ )
119
+ return datetime.now(UTC)
120
+ if isinstance(value, str):
121
+ try:
122
+ return _parse_datetime_aware(datetime.fromisoformat(value))
123
+ except ValueError:
124
+ try:
125
+ return _parse_datetime_aware(float(value))
126
+ except (ValueError, TypeError):
127
+ logger.warning(
128
+ "Invalid timestamp format '%s' - using current time", value
129
+ )
130
+ return datetime.now(UTC)
131
+ logger.warning("Invalid timestamp type %s - using current time", type(value))
132
+ return datetime.now(UTC)
133
+
134
+
135
+ class Signal(BaseModel):
136
+ """Enhanced trading signal with advanced features (canonical, superset)."""
137
+
138
+ model_config = ConfigDict(
139
+ json_encoders={datetime: lambda v: v.isoformat()},
140
+ protected_namespaces=(),
141
+ extra="forbid",
142
+ )
143
+
144
+ # Core signal information
145
+ id: str | None = Field(None, description="Unique identifier for this signal")
146
+ strategy_id: str = Field(..., description="Unique identifier for the strategy")
147
+ signal_id: str | None = Field(None, description="Unique identifier for this signal")
148
+ decision_id: str | None = Field(
149
+ None,
150
+ description=(
151
+ "uuid4 hex string assigned by petrosa-cio for the decision that "
152
+ "produced this signal; None for strategy-direct signals"
153
+ ),
154
+ )
155
+ leverage: int | None = Field(
156
+ None,
157
+ ge=1,
158
+ description=(
159
+ "Admission-time leverage decided by CIO's leverage arbiter. "
160
+ "None for strategy-direct signals or legacy producers; the "
161
+ "consumer falls back to its configured default and should log "
162
+ "the fallback explicitly rather than silently applying a fixed "
163
+ "value (petrosa-tradeengine#599)."
164
+ ),
165
+ )
166
+ strategy_mode: StrategyMode = Field(
167
+ StrategyMode.DETERMINISTIC, description="Processing mode for this signal"
168
+ )
169
+
170
+ # Trading parameters
171
+ symbol: str = Field(..., description="Trading symbol (e.g., BTCUSDT)")
172
+ signal_type: SignalType | None = Field(
173
+ None, description="Signal type (buy/sell/hold/close) - deprecated, use action"
174
+ )
175
+ action: Literal["buy", "sell", "hold", "close"] = Field(
176
+ ..., description="Trading action"
177
+ )
178
+ confidence: float = Field(..., ge=0, le=1, description="Signal confidence (0-1)")
179
+ strength: SignalStrength = Field(
180
+ SignalStrength.MEDIUM, description="Signal strength level"
181
+ )
182
+
183
+ # Price and quantity information
184
+ price: float = Field(..., description="Signal price")
185
+ quantity: float = Field(..., description="Signal quantity")
186
+ current_price: float = Field(..., description="Current market price")
187
+ target_price: float | None = Field(None, description="Target execution price")
188
+
189
+ # Source and metadata
190
+ source: str = Field(..., description="Signal source")
191
+ strategy: str = Field(..., description="Strategy name")
192
+ metadata: dict[str, Any] = Field(
193
+ default_factory=dict, description="Additional metadata"
194
+ )
195
+
196
+ # Timeframe information
197
+ timeframe: str = Field("1h", description="Timeframe used for signal analysis")
198
+
199
+ # Order configuration
200
+ order_type: OrderType = Field(OrderType.MARKET, description="Order type to execute")
201
+ time_in_force: TimeInForce = Field(
202
+ TimeInForce.GTC, description="Order time in force"
203
+ )
204
+ position_size_pct: float | None = Field(
205
+ None, ge=0, le=1, description="Position size as percentage of portfolio"
206
+ )
207
+ quote_quantity: float | None = Field(
208
+ None, description="Quote quantity for quote-based orders"
209
+ )
210
+
211
+ # Risk management
212
+ stop_loss: float | None = Field(None, description="Stop loss price")
213
+ stop_loss_pct: float | None = Field(
214
+ None, ge=0, le=1, description="Stop loss as percentage"
215
+ )
216
+ take_profit: float | None = Field(None, description="Take profit price")
217
+ take_profit_pct: float | None = Field(
218
+ None, ge=0, le=1, description="Take profit as percentage"
219
+ )
220
+
221
+ # Conditional orders
222
+ conditional_price: float | None = Field(
223
+ None, description="Price level for conditional execution"
224
+ )
225
+ conditional_direction: Literal["above", "below"] | None = Field(
226
+ None, description="Direction for conditional execution"
227
+ )
228
+ conditional_timeout: int | None = Field(
229
+ None, description="Timeout in seconds for conditional orders"
230
+ )
231
+
232
+ # Advanced order features
233
+ iceberg_quantity: float | None = Field(
234
+ None, description="Iceberg quantity for iceberg orders"
235
+ )
236
+ client_order_id: str | None = Field(None, description="Client-provided order ID")
237
+
238
+ # ML/LLM specific fields
239
+ model_confidence: float | None = Field(
240
+ None, ge=0, le=1, description="ML model confidence score"
241
+ )
242
+ model_features: dict[str, Any] | None = Field(
243
+ None, description="Features used by ML model"
244
+ )
245
+ llm_reasoning: str | None = Field(None, description="LLM reasoning for the signal")
246
+ llm_alternatives: list[dict[str, Any]] | None = Field(
247
+ None, description="Alternative actions considered by LLM"
248
+ )
249
+
250
+ # Market indicators
251
+ indicators: dict[str, Any] | None = Field(
252
+ None, description="Technical indicators and market data"
253
+ )
254
+
255
+ # Metadata
256
+ rationale: str | None = Field(
257
+ None, description="Human-readable rationale for the signal"
258
+ )
259
+ meta: dict[str, Any] = Field(
260
+ default_factory=dict, description="Additional metadata"
261
+ )
262
+
263
+ # Timestamp
264
+ timestamp: datetime = Field(
265
+ default_factory=lambda: datetime.now(UTC), description="Signal timestamp"
266
+ )
267
+
268
+ @field_validator("timestamp", mode="before")
269
+ @classmethod
270
+ def validate_timestamp(cls, v: Any) -> datetime:
271
+ """Ensure timestamp is timezone-aware."""
272
+ return _parse_datetime_aware(v)
273
+
274
+ @field_validator("confidence", "model_confidence")
275
+ @classmethod
276
+ def validate_confidence(cls, v: Any) -> float | None:
277
+ """Validate confidence values."""
278
+ if v is not None and (v < 0 or v > 1):
279
+ raise ValueError("Confidence must be between 0 and 1")
280
+ return float(v) if v is not None else None
281
+
282
+ @field_validator("position_size_pct", "stop_loss_pct", "take_profit_pct")
283
+ @classmethod
284
+ def validate_percentages(cls, v: Any) -> float | None:
285
+ """Validate percentage values."""
286
+ if v is not None and (v < 0 or v > 1):
287
+ raise ValueError("Percentage must be between 0 and 1")
288
+ return float(v) if v is not None else None
@@ -0,0 +1,270 @@
1
+ """Canonical `TradingConfig` family shared by petrosa-cio and petrosa-tradeengine.
2
+
3
+ Reconciles the two previously copy-pasted implementations
4
+ (`petrosa-cio/contracts/trading_config.py`,
5
+ `petrosa-tradeengine/contracts/trading_config.py`) into a single superset:
6
+
7
+ - `TradingConfigAudit.config_type` includes tradeengine's fourth value,
8
+ `"strategy"`, in addition to CIO's three (`global`, `symbol`, `symbol_side`).
9
+ - `TradingConfigAudit.strategy_id` (tradeengine-only) is present here.
10
+ - `TradingConfig.strategy_id` docstring uses tradeengine's broader wording
11
+ (both repos' fields were functionally identical; only the description
12
+ text differed).
13
+ - `extra="forbid"` is set on every model here (see `signal.py` module
14
+ docstring for the rationale — this package is greenfield, so there is no
15
+ existing traffic to break by forbidding unknown fields immediately).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from datetime import UTC, datetime
21
+ from typing import Any, Literal
22
+
23
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
24
+
25
+
26
+ class TradingConfig(BaseModel):
27
+ """
28
+ Trading configuration model.
29
+
30
+ Represents a complete configuration for trading execution,
31
+ either global (applied to all symbols) or symbol/side-specific.
32
+
33
+ Configuration Hierarchy:
34
+ - Global: Applies to all symbols and all sides
35
+ - Symbol: Applies to specific symbol, both LONG and SHORT
36
+ - Strategy: Applies to a specific strategy ID (e.g., 'orderbook_skew')
37
+ - Symbol-Side: Applies to specific symbol and specific side only
38
+ """
39
+
40
+ model_config = ConfigDict(
41
+ json_schema_extra={
42
+ "example": {
43
+ "id": "507f1f77bcf86cd799439011", # pragma: allowlist secret
44
+ "symbol": "BTCUSDT",
45
+ "side": "LONG",
46
+ "parameters": {
47
+ "leverage": 10,
48
+ "position_size_pct": 0.1,
49
+ "stop_loss_pct": 2.0,
50
+ "take_profit_pct": 5.0,
51
+ },
52
+ "version": 3,
53
+ "created_at": "2025-10-20T10:30:00Z",
54
+ "updated_at": "2025-10-20T14:45:00Z",
55
+ "created_by": "llm_agent_v1",
56
+ "metadata": {
57
+ "notes": "Optimized for BTC long positions",
58
+ "performance": "+15% win rate",
59
+ },
60
+ }
61
+ },
62
+ extra="forbid",
63
+ )
64
+
65
+ id: str | None = Field(None, description="Configuration ID")
66
+
67
+ # Scope identifiers
68
+ strategy_id: str | None = Field(
69
+ None, description="Strategy identifier (None for global/symbol configs)"
70
+ )
71
+ symbol: str | None = Field(
72
+ None, description="Trading symbol (None for global configs, e.g., 'BTCUSDT')"
73
+ )
74
+ side: Literal["LONG", "SHORT"] | None = Field(
75
+ None, description="Position side (None for global/symbol configs)"
76
+ )
77
+
78
+ # Configuration data
79
+ parameters: dict[str, Any] = Field(
80
+ ..., description="Trading parameters as key-value pairs"
81
+ )
82
+
83
+ # Versioning
84
+ version: int = Field(
85
+ 1, description="Configuration version number (incremented on updates)"
86
+ )
87
+
88
+ # Timestamps
89
+ created_at: datetime = Field(
90
+ default_factory=lambda: datetime.now(UTC),
91
+ description="When configuration was created",
92
+ )
93
+ updated_at: datetime = Field(
94
+ default_factory=lambda: datetime.now(UTC),
95
+ description="When configuration was last updated",
96
+ )
97
+
98
+ # Audit
99
+ created_by: str = Field(
100
+ ..., description="Who/what created this config (e.g., 'llm_agent_v1', 'admin')"
101
+ )
102
+
103
+ # Additional metadata
104
+ metadata: dict[str, Any] = Field(
105
+ default_factory=dict,
106
+ description="Additional metadata (notes, performance metrics, etc.)",
107
+ )
108
+
109
+ @field_validator("side")
110
+ @classmethod
111
+ def validate_side(cls, v: str | None) -> str | None:
112
+ """Validate position side."""
113
+ if v is not None and v not in ["LONG", "SHORT"]:
114
+ raise ValueError("Side must be 'LONG' or 'SHORT'")
115
+ return v
116
+
117
+ def get_scope_key(self) -> str:
118
+ """Get unique scope key for this config."""
119
+ if self.side:
120
+ return f"{self.symbol}:{self.side}"
121
+ elif self.symbol:
122
+ return self.symbol
123
+ return "global"
124
+
125
+
126
+ class TradingConfigAudit(BaseModel):
127
+ """
128
+ Audit trail record for trading configuration changes.
129
+
130
+ Tracks all modifications to trading configurations for compliance,
131
+ debugging, and performance analysis.
132
+ """
133
+
134
+ model_config = ConfigDict(
135
+ json_schema_extra={
136
+ "example": {
137
+ "id": "audit-123",
138
+ "config_type": "symbol_side",
139
+ "symbol": "BTCUSDT",
140
+ "side": "LONG",
141
+ "action": "update",
142
+ "parameters_before": {"leverage": 10},
143
+ "parameters_after": {"leverage": 15},
144
+ "version_before": 2,
145
+ "version_after": 3,
146
+ "changed_by": "llm_agent_v1",
147
+ "reason": "Increasing leverage for bullish market conditions",
148
+ "timestamp": "2025-10-20T15:00:00Z",
149
+ "metadata": {"ip_address": "192.168.1.1"},
150
+ }
151
+ },
152
+ extra="forbid",
153
+ )
154
+
155
+ id: str | None = Field(None, description="Audit record ID")
156
+
157
+ # Scope of change
158
+ config_type: Literal["global", "symbol", "symbol_side", "strategy"] = Field(
159
+ ..., description="Type of configuration that was changed"
160
+ )
161
+ symbol: str | None = Field(None, description="Symbol if applicable")
162
+ side: Literal["LONG", "SHORT"] | None = Field(
163
+ None, description="Side if applicable"
164
+ )
165
+ strategy_id: str | None = Field(None, description="Strategy if applicable")
166
+
167
+ # Change details
168
+ action: Literal["create", "update", "delete"] = Field(
169
+ ..., description="Action performed"
170
+ )
171
+
172
+ # Before/after state
173
+ parameters_before: dict[str, Any] | None = Field(
174
+ None, description="Parameters before the change"
175
+ )
176
+ parameters_after: dict[str, Any] | None = Field(
177
+ None, description="Parameters after the change"
178
+ )
179
+ version_before: int | None = Field(
180
+ None, description="Version number before the change"
181
+ )
182
+ version_after: int | None = Field(
183
+ None, description="Version number after the change"
184
+ )
185
+
186
+ # Audit metadata
187
+ changed_by: str = Field(..., description="Who/what made the change")
188
+ reason: str | None = Field(None, description="Reason for the change")
189
+ timestamp: datetime = Field(
190
+ default_factory=lambda: datetime.now(UTC),
191
+ description="When the change occurred",
192
+ )
193
+
194
+ # Additional context
195
+ metadata: dict[str, Any] = Field(
196
+ default_factory=dict, description="Additional audit metadata"
197
+ )
198
+
199
+ def get_change_summary(self) -> str:
200
+ """Get human-readable summary of the change."""
201
+ if self.strategy_id:
202
+ return f"{self.action.upper()} strategy {self.strategy_id} by {self.changed_by}"
203
+ scope = f"{self.symbol or 'global'}"
204
+ if self.side:
205
+ scope += f"-{self.side}"
206
+ return f"{self.action.upper()} {scope} by {self.changed_by}"
207
+
208
+
209
+ class LeverageStatus(BaseModel):
210
+ """
211
+ Leverage status tracking model.
212
+
213
+ Tracks the difference between configured leverage (in our system)
214
+ and actual leverage (on Binance exchange) for each symbol.
215
+ """
216
+
217
+ model_config = ConfigDict(
218
+ json_schema_extra={
219
+ "example": {
220
+ "id": "lev-status-btc",
221
+ "symbol": "BTCUSDT",
222
+ "configured_leverage": 10,
223
+ "actual_leverage": 10,
224
+ "last_sync_at": "2025-10-20T15:00:00Z",
225
+ "last_sync_success": True,
226
+ "last_sync_error": None,
227
+ "updated_at": "2025-10-20T15:00:00Z",
228
+ }
229
+ },
230
+ extra="forbid",
231
+ )
232
+
233
+ id: str | None = Field(None, description="Status record ID")
234
+
235
+ symbol: str = Field(..., description="Trading symbol")
236
+
237
+ # Leverage values
238
+ configured_leverage: int = Field(
239
+ ..., description="Leverage configured in our system", ge=1, le=125
240
+ )
241
+ actual_leverage: int | None = Field(
242
+ None, description="Actual leverage on Binance (None if unknown)", ge=1, le=125
243
+ )
244
+
245
+ # Sync status
246
+ last_sync_at: datetime | None = Field(
247
+ None, description="When leverage was last synced with Binance"
248
+ )
249
+ last_sync_success: bool = Field(
250
+ False, description="Whether last sync attempt was successful"
251
+ )
252
+ last_sync_error: str | None = Field(
253
+ None, description="Error message if sync failed"
254
+ )
255
+
256
+ # Metadata
257
+ updated_at: datetime = Field(
258
+ default_factory=lambda: datetime.now(UTC),
259
+ description="When this record was last updated",
260
+ )
261
+
262
+ def is_synced(self) -> bool:
263
+ """Check if configured and actual leverage match."""
264
+ if self.actual_leverage is None:
265
+ return False
266
+ return self.configured_leverage == self.actual_leverage
267
+
268
+ def needs_sync(self) -> bool:
269
+ """Check if leverage needs to be synced."""
270
+ return not self.is_synced()
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: petrosa-contracts
3
+ Version: 0.1.0
4
+ Summary: Canonical shared pydantic contracts (Signal, TradingConfig) for the Petrosa trading path
5
+ Author-email: Petrosa Team <dev@petrosa.com>
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: pydantic>=2.0.0
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
16
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
17
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
18
+
19
+ # petrosa-contracts
20
+
21
+ Canonical, shared `pydantic` contracts for the Petrosa trading path.
22
+
23
+ This package resolves petrosa_k8s#1116: `contracts/signal.py` and
24
+ `contracts/trading_config.py` were copy-pasted into `petrosa-cio/` and
25
+ `petrosa-tradeengine/` and had diverged in both directions (CIO had
26
+ `leverage`, tradeengine had `decision_id`; the `TradingConfigAudit.config_type`
27
+ enum differed). One of those divergences (`leverage` reaching tradeengine)
28
+ was the direct cause of `petrosa-tradeengine#599` — a P0 where every position
29
+ traded at a hardcoded 10x leverage because the field was silently dropped by
30
+ `extra="ignore"`.
31
+
32
+ ## What's here
33
+
34
+ - `petrosa_contracts.signal.Signal` and its enums (`SignalType`,
35
+ `SignalStrength`, `TimeFrame`, `OrderType`, `TimeInForce`, `StrategyMode`) —
36
+ the reconciled superset of both repos' `Signal` models, with both
37
+ `leverage` and `decision_id`.
38
+ - `petrosa_contracts.trading_config.TradingConfig`, `TradingConfigAudit`,
39
+ `LeverageStatus` — the reconciled superset, with `TradingConfigAudit`
40
+ supporting all four `config_type` values (`global`, `symbol`,
41
+ `symbol_side`, `strategy`).
42
+ - Every model sets `model_config = ConfigDict(extra="forbid")`. A producer
43
+ field with no matching consumer attribute now raises `ValidationError` at
44
+ parse time instead of being silently discarded.
45
+
46
+ ## Installation
47
+
48
+ Install the published package from PyPI:
49
+
50
+ ```bash
51
+ pip install petrosa-contracts
52
+ ```
53
+
54
+ For local development only, contributors with access to the private repository may
55
+ install directly from the source tree:
56
+
57
+ ```bash
58
+ pip install "petrosa-contracts @ git+https://github.com/PetroSa2/petrosa_k8s.git@main#subdirectory=petrosa-contracts"
59
+ ```
60
+
61
+ This private-repository URL requires repository access and must not appear in a public
62
+ consumer's `requirements.txt`. Tags named `petrosa-contracts-vX.Y.Z` trigger the
63
+ publication workflow.
64
+
65
+ ## Usage
66
+
67
+ ```python
68
+ from petrosa_contracts import Signal, TradingConfig
69
+
70
+ signal = Signal(
71
+ strategy_id="orderbook_skew",
72
+ symbol="BTCUSDT",
73
+ action="buy",
74
+ confidence=0.82,
75
+ price=65000.0,
76
+ quantity=0.01,
77
+ current_price=65000.0,
78
+ source="petrosa-cio",
79
+ strategy="orderbook_skew",
80
+ leverage=5,
81
+ decision_id="a1b2c3d4e5f6",
82
+ )
83
+
84
+ # Any field neither producer nor consumer declared now raises immediately:
85
+ Signal(**{**signal.model_dump(), "unexpected_field": 1})
86
+ # -> pydantic.ValidationError: Extra inputs are not permitted
87
+ ```
88
+
89
+ ## Scope of petrosa_k8s#1116 covered by this package
90
+
91
+ This PR creates the canonical package and its contract tests
92
+ (petrosa_k8s#1116 AC: "exist in exactly one place", "superset reconciling
93
+ both divergences", "`extra=forbid`", "contract test in CI"). It does
94
+ **not** yet remove the vendored copies from `petrosa-cio/contracts/` and
95
+ `petrosa-tradeengine/contracts/` or switch those services to import from
96
+ this package — that requires coordinated, staged changes in each of those
97
+ repos' own CI/deploy pipelines and is tracked as follow-up work:
98
+
99
+ - `petrosa-cio`: migrate to `petrosa_contracts`, delete the vendored copy.
100
+ - `petrosa-tradeengine`: migrate to `petrosa_contracts`, delete the vendored
101
+ copy.
102
+
103
+ Both follow-ups should keep the same staged-rollout discipline the parent
104
+ ticket calls for (log-then-enforce) for any field this package's
105
+ `extra="forbid"` newly rejects that either service's real traffic still
106
+ sends.
107
+
108
+ ## Development
109
+
110
+ ```bash
111
+ cd petrosa-contracts
112
+ pip install -e ".[dev]"
113
+ pytest --cov=petrosa_contracts --cov-report=term-missing
114
+ ruff check src/ tests/
115
+ ruff format --check src/ tests/
116
+ ```
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/petrosa_contracts/__init__.py
4
+ src/petrosa_contracts/signal.py
5
+ src/petrosa_contracts/trading_config.py
6
+ src/petrosa_contracts.egg-info/PKG-INFO
7
+ src/petrosa_contracts.egg-info/SOURCES.txt
8
+ src/petrosa_contracts.egg-info/dependency_links.txt
9
+ src/petrosa_contracts.egg-info/requires.txt
10
+ src/petrosa_contracts.egg-info/top_level.txt
11
+ tests/test_signal.py
12
+ tests/test_trading_config.py
@@ -0,0 +1,6 @@
1
+ pydantic>=2.0.0
2
+
3
+ [dev]
4
+ pytest>=7.0.0
5
+ pytest-cov>=4.0.0
6
+ ruff>=0.1.0
@@ -0,0 +1 @@
1
+ petrosa_contracts
@@ -0,0 +1,120 @@
1
+ """Contract tests for `petrosa_contracts.signal.Signal`.
2
+
3
+ These tests are the petrosa_k8s#1116 AC4 deliverable: "A contract test runs
4
+ in CI that fails when a producer emits a field no consumer declares." They
5
+ encode the exact payload shapes petrosa-cio and petrosa-tradeengine
6
+ currently produce/consume so any future drift between a producer and this
7
+ canonical contract fails CI immediately.
8
+ """
9
+
10
+ from datetime import UTC, datetime
11
+
12
+ import pytest
13
+ from pydantic import ValidationError
14
+
15
+ from petrosa_contracts.signal import (
16
+ OrderType,
17
+ Signal,
18
+ SignalStrength,
19
+ StrategyMode,
20
+ TimeInForce,
21
+ )
22
+
23
+ BASE_REQUIRED_FIELDS = {
24
+ "strategy_id": "orderbook_skew",
25
+ "symbol": "BTCUSDT",
26
+ "action": "buy",
27
+ "confidence": 0.75,
28
+ "price": 65000.0,
29
+ "quantity": 0.01,
30
+ "current_price": 65000.5,
31
+ "source": "petrosa-cio",
32
+ "strategy": "orderbook_skew",
33
+ }
34
+
35
+
36
+ def test_minimal_signal_constructs_with_only_required_fields():
37
+ signal = Signal(**BASE_REQUIRED_FIELDS)
38
+ assert signal.symbol == "BTCUSDT"
39
+ assert signal.action == "buy"
40
+ assert signal.leverage is None
41
+ assert signal.decision_id is None
42
+ assert signal.strength == SignalStrength.MEDIUM
43
+ assert signal.order_type == OrderType.MARKET
44
+ assert signal.time_in_force == TimeInForce.GTC
45
+ assert signal.strategy_mode == StrategyMode.DETERMINISTIC
46
+
47
+
48
+ def test_signal_accepts_cio_leverage_field():
49
+ """petrosa-cio/cio/output/translator.py:90 emits `leverage` on every payload."""
50
+ signal = Signal(**BASE_REQUIRED_FIELDS, leverage=5)
51
+ assert signal.leverage == 5
52
+
53
+
54
+ def test_signal_accepts_tradeengine_decision_id_field():
55
+ """petrosa-tradeengine attaches `decision_id` to CIO-originated signals."""
56
+ signal = Signal(**BASE_REQUIRED_FIELDS, decision_id="a1b2c3d4e5f6")
57
+ assert signal.decision_id == "a1b2c3d4e5f6"
58
+
59
+
60
+ def test_signal_accepts_both_leverage_and_decision_id_simultaneously():
61
+ """The reconciled superset: both previously-copy-pasted-only fields at once."""
62
+ signal = Signal(**BASE_REQUIRED_FIELDS, leverage=10, decision_id="deadbeef")
63
+ assert signal.leverage == 10
64
+ assert signal.decision_id == "deadbeef"
65
+
66
+
67
+ def test_signal_rejects_unknown_field():
68
+ """The core petrosa_k8s#1116 fix: `extra='forbid'` instead of silent drop.
69
+
70
+ This is the exact failure mode that hid petrosa-tradeengine#599: a
71
+ producer field with no matching consumer attribute must raise, not
72
+ vanish.
73
+ """
74
+ with pytest.raises(ValidationError):
75
+ Signal(**BASE_REQUIRED_FIELDS, made_up_field_no_consumer_declares="x")
76
+
77
+
78
+ def test_signal_leverage_must_be_positive():
79
+ with pytest.raises(ValidationError):
80
+ Signal(**BASE_REQUIRED_FIELDS, leverage=0)
81
+
82
+
83
+ @pytest.mark.parametrize(
84
+ "confidence",
85
+ [-0.01, 1.01],
86
+ )
87
+ def test_signal_confidence_out_of_range_rejected(confidence):
88
+ with pytest.raises(ValidationError):
89
+ Signal(**{**BASE_REQUIRED_FIELDS, "confidence": confidence})
90
+
91
+
92
+ def test_signal_timestamp_defaults_to_timezone_aware_now():
93
+ signal = Signal(**BASE_REQUIRED_FIELDS)
94
+ assert signal.timestamp.tzinfo is not None
95
+
96
+
97
+ def test_signal_timestamp_accepts_iso_string():
98
+ signal = Signal(**BASE_REQUIRED_FIELDS, timestamp="2026-01-01T00:00:00+00:00")
99
+ assert signal.timestamp == datetime(2026, 1, 1, tzinfo=UTC)
100
+
101
+
102
+ def test_signal_timestamp_accepts_naive_datetime_and_localizes_utc():
103
+ naive = datetime(2026, 1, 1, 12, 0, 0)
104
+ signal = Signal(**BASE_REQUIRED_FIELDS, timestamp=naive)
105
+ assert signal.timestamp.tzinfo is not None
106
+
107
+
108
+ def test_signal_timestamp_falls_back_to_now_on_garbage_input():
109
+ signal = Signal(**BASE_REQUIRED_FIELDS, timestamp="not-a-timestamp")
110
+ assert signal.timestamp.tzinfo is not None
111
+
112
+
113
+ def test_signal_roundtrips_through_model_dump():
114
+ """Producer -> dict -> wire -> consumer round trip must be lossless."""
115
+ original = Signal(**BASE_REQUIRED_FIELDS, leverage=3, decision_id="abc123")
116
+ dumped = original.model_dump(mode="json")
117
+ reconstructed = Signal(**dumped)
118
+ assert reconstructed.leverage == original.leverage
119
+ assert reconstructed.decision_id == original.decision_id
120
+ assert reconstructed.symbol == original.symbol
@@ -0,0 +1,113 @@
1
+ """Contract tests for `petrosa_contracts.trading_config`.
2
+
3
+ Encodes the reconciled `TradingConfigAudit.config_type` enum (petrosa-cio's
4
+ three values plus tradeengine's `"strategy"` addition) and the shared
5
+ `extra="forbid"` behavior.
6
+ """
7
+
8
+ import pytest
9
+ from pydantic import ValidationError
10
+
11
+ from petrosa_contracts.trading_config import (
12
+ LeverageStatus,
13
+ TradingConfig,
14
+ TradingConfigAudit,
15
+ )
16
+
17
+
18
+ def test_trading_config_minimal():
19
+ cfg = TradingConfig(parameters={"leverage": 10}, created_by="llm_agent_v1")
20
+ assert cfg.version == 1
21
+ assert cfg.get_scope_key() == "global"
22
+
23
+
24
+ def test_trading_config_symbol_side_scope_key():
25
+ cfg = TradingConfig(
26
+ symbol="BTCUSDT",
27
+ side="LONG",
28
+ parameters={"leverage": 10},
29
+ created_by="admin",
30
+ )
31
+ assert cfg.get_scope_key() == "BTCUSDT:LONG"
32
+
33
+
34
+ def test_trading_config_rejects_invalid_side():
35
+ with pytest.raises(ValidationError):
36
+ TradingConfig(symbol="BTCUSDT", side="UP", parameters={}, created_by="admin")
37
+
38
+
39
+ def test_trading_config_rejects_unknown_field():
40
+ with pytest.raises(ValidationError):
41
+ TradingConfig(
42
+ parameters={},
43
+ created_by="admin",
44
+ unknown_field_no_consumer_declares=True,
45
+ )
46
+
47
+
48
+ @pytest.mark.parametrize("config_type", ["global", "symbol", "symbol_side", "strategy"])
49
+ def test_trading_config_audit_accepts_all_reconciled_config_types(config_type):
50
+ """`"strategy"` was tradeengine-only; the other three were shared.
51
+
52
+ This is the reconciled superset from petrosa_k8s#1116.
53
+ """
54
+ audit = TradingConfigAudit(
55
+ config_type=config_type,
56
+ action="update",
57
+ changed_by="llm_agent_v1",
58
+ )
59
+ assert audit.config_type == config_type
60
+
61
+
62
+ def test_trading_config_audit_strategy_scoped_summary():
63
+ audit = TradingConfigAudit(
64
+ config_type="strategy",
65
+ strategy_id="orderbook_skew",
66
+ action="update",
67
+ changed_by="llm_agent_v1",
68
+ )
69
+ assert "strategy orderbook_skew" in audit.get_change_summary()
70
+
71
+
72
+ def test_trading_config_audit_symbol_side_summary():
73
+ audit = TradingConfigAudit(
74
+ config_type="symbol_side",
75
+ symbol="BTCUSDT",
76
+ side="LONG",
77
+ action="update",
78
+ changed_by="llm_agent_v1",
79
+ )
80
+ assert audit.get_change_summary() == "UPDATE BTCUSDT-LONG by llm_agent_v1"
81
+
82
+
83
+ def test_trading_config_audit_rejects_unknown_field():
84
+ with pytest.raises(ValidationError):
85
+ TradingConfigAudit(
86
+ config_type="global",
87
+ action="update",
88
+ changed_by="llm_agent_v1",
89
+ made_up_field=1,
90
+ )
91
+
92
+
93
+ def test_leverage_status_sync_check():
94
+ status = LeverageStatus(
95
+ symbol="BTCUSDT", configured_leverage=10, actual_leverage=10
96
+ )
97
+ assert status.is_synced()
98
+ assert not status.needs_sync()
99
+
100
+
101
+ def test_leverage_status_out_of_sync():
102
+ status = LeverageStatus(symbol="BTCUSDT", configured_leverage=10, actual_leverage=5)
103
+ assert not status.is_synced()
104
+ assert status.needs_sync()
105
+
106
+
107
+ def test_leverage_status_rejects_unknown_field():
108
+ with pytest.raises(ValidationError):
109
+ LeverageStatus(
110
+ symbol="BTCUSDT",
111
+ configured_leverage=10,
112
+ made_up_field=1,
113
+ )