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.
- petrosa_contracts-0.1.0/PKG-INFO +116 -0
- petrosa_contracts-0.1.0/README.md +98 -0
- petrosa_contracts-0.1.0/pyproject.toml +61 -0
- petrosa_contracts-0.1.0/setup.cfg +4 -0
- petrosa_contracts-0.1.0/src/petrosa_contracts/__init__.py +43 -0
- petrosa_contracts-0.1.0/src/petrosa_contracts/signal.py +288 -0
- petrosa_contracts-0.1.0/src/petrosa_contracts/trading_config.py +270 -0
- petrosa_contracts-0.1.0/src/petrosa_contracts.egg-info/PKG-INFO +116 -0
- petrosa_contracts-0.1.0/src/petrosa_contracts.egg-info/SOURCES.txt +12 -0
- petrosa_contracts-0.1.0/src/petrosa_contracts.egg-info/dependency_links.txt +1 -0
- petrosa_contracts-0.1.0/src/petrosa_contracts.egg-info/requires.txt +6 -0
- petrosa_contracts-0.1.0/src/petrosa_contracts.egg-info/top_level.txt +1 -0
- petrosa_contracts-0.1.0/tests/test_signal.py +120 -0
- petrosa_contracts-0.1.0/tests/test_trading_config.py +113 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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
|
+
)
|