koval-engine 0.9.0__py3-none-any.whl

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.
Files changed (95) hide show
  1. koval/__init__.py +7 -0
  2. koval/_version.py +7 -0
  3. koval/cli/__init__.py +7 -0
  4. koval/cli/data.py +27 -0
  5. koval/cli/main.py +239 -0
  6. koval/engine/__init__.py +0 -0
  7. koval/engine/account_state.py +105 -0
  8. koval/engine/backtest_engine.py +114 -0
  9. koval/engine/broker.py +151 -0
  10. koval/engine/client_order_id.py +22 -0
  11. koval/engine/engine_events.py +25 -0
  12. koval/engine/execution_settings.py +170 -0
  13. koval/engine/live_engine.py +989 -0
  14. koval/engine/live_feed.py +216 -0
  15. koval/engine/logger.py +146 -0
  16. koval/engine/paper_broker.py +544 -0
  17. koval/engine/time_range.py +11 -0
  18. koval/engine/timeframe_utils.py +29 -0
  19. koval/engine/trade_context.py +46 -0
  20. koval/engine/trade_indicators.py +169 -0
  21. koval/engine/trade_metrics.py +50 -0
  22. koval/engine/trade_narrator.py +27 -0
  23. koval/engine/venue_metadata.py +164 -0
  24. koval/examples/__init__.py +25 -0
  25. koval/examples/data/README.md +5 -0
  26. koval/examples/data/sample-1h.csv +1201 -0
  27. koval/examples/graphs/ema_cross_trend.json +21 -0
  28. koval/exchanges/__init__.py +47 -0
  29. koval/exchanges/auth.py +76 -0
  30. koval/exchanges/base.py +180 -0
  31. koval/exchanges/binance.py +148 -0
  32. koval/exchanges/binance_sandbox.py +901 -0
  33. koval/exchanges/ohlcv_cache.py +221 -0
  34. koval/exchanges/sandbox_factory.py +39 -0
  35. koval/exchanges/whitebit.py +153 -0
  36. koval/exchanges/whitebit_sandbox.py +35 -0
  37. koval/py.typed +1 -0
  38. koval/strategy/__init__.py +0 -0
  39. koval/strategy/base/__init__.py +0 -0
  40. koval/strategy/base/declarative.py +110 -0
  41. koval/strategy/base/trade_setup.py +52 -0
  42. koval/strategy/block_assembler.py +62 -0
  43. koval/strategy/graph/__init__.py +0 -0
  44. koval/strategy/graph/compat.py +280 -0
  45. koval/strategy/graph/domains.py +40 -0
  46. koval/strategy/graph/entities.py +108 -0
  47. koval/strategy/graph/executor.py +144 -0
  48. koval/strategy/graph/node.py +82 -0
  49. koval/strategy/graph/ports.py +24 -0
  50. koval/strategy/graph/registry.py +44 -0
  51. koval/strategy/graph/state_store.py +17 -0
  52. koval/strategy/graph/strategy.py +159 -0
  53. koval/strategy/graph/validation.py +141 -0
  54. koval/strategy/helpers/__init__.py +0 -0
  55. koval/strategy/helpers/_math.py +132 -0
  56. koval/strategy/helpers/exits/__init__.py +0 -0
  57. koval/strategy/helpers/exits/breakeven.py +25 -0
  58. koval/strategy/helpers/exits/fixed.py +27 -0
  59. koval/strategy/helpers/exits/pricing.py +42 -0
  60. koval/strategy/helpers/exits/trailing.py +22 -0
  61. koval/strategy/helpers/filters/__init__.py +0 -0
  62. koval/strategy/helpers/filters/momentum.py +55 -0
  63. koval/strategy/helpers/filters/trend.py +83 -0
  64. koval/strategy/helpers/filters/volatility.py +42 -0
  65. koval/strategy/helpers/interp/__init__.py +0 -0
  66. koval/strategy/helpers/interp/scoring.py +28 -0
  67. koval/strategy/helpers/risk/__init__.py +0 -0
  68. koval/strategy/helpers/risk/gates.py +55 -0
  69. koval/strategy/helpers/risk/position_sizer.py +67 -0
  70. koval/strategy/helpers/risk/sizing.py +15 -0
  71. koval/strategy/helpers/signals/__init__.py +0 -0
  72. koval/strategy/helpers/signals/candlesticks.py +74 -0
  73. koval/strategy/helpers/signals/smc.py +110 -0
  74. koval/strategy/helpers/signals/technical.py +52 -0
  75. koval/strategy/nodes/__init__.py +12 -0
  76. koval/strategy/nodes/aggregator.py +186 -0
  77. koval/strategy/nodes/exec_pipeline.py +372 -0
  78. koval/strategy/nodes/execution.py +93 -0
  79. koval/strategy/nodes/fact.py +376 -0
  80. koval/strategy/nodes/interp.py +120 -0
  81. koval/strategy/nodes/policy.py +233 -0
  82. koval/strategy/nodes/scoring.py +276 -0
  83. koval/strategy/nodes/state.py +116 -0
  84. koval/strategy/presets/__init__.py +8 -0
  85. koval/strategy/presets/risk_pipeline_demo.py +52 -0
  86. koval/strategy/presets/sweep_choch_reversal.py +73 -0
  87. koval/strategy/promotion_gates.py +112 -0
  88. koval/strategy/registry.py +778 -0
  89. koval/strategy/schemas.py +225 -0
  90. koval_engine-0.9.0.dist-info/METADATA +174 -0
  91. koval_engine-0.9.0.dist-info/RECORD +95 -0
  92. koval_engine-0.9.0.dist-info/WHEEL +5 -0
  93. koval_engine-0.9.0.dist-info/entry_points.txt +2 -0
  94. koval_engine-0.9.0.dist-info/licenses/LICENSE +21 -0
  95. koval_engine-0.9.0.dist-info/top_level.txt +1 -0
koval/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Open engine for building and validating trading strategies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from koval._version import __version__
6
+
7
+ __all__ = ["__version__"]
koval/_version.py ADDED
@@ -0,0 +1,7 @@
1
+ """Package version, read from installed distribution metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import metadata
6
+
7
+ __version__ = metadata.version("koval-engine")
koval/cli/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Command-line interface for the Koval engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from koval.cli.main import main
6
+
7
+ __all__ = ["main"]
koval/cli/data.py ADDED
@@ -0,0 +1,27 @@
1
+ """CSV candle loading for offline CLI runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ from koval.exchanges.base import OHLCV_COLUMNS
11
+
12
+
13
+ def load_csv_candles(path: Path) -> np.ndarray:
14
+ """Read a chronological OHLCV CSV into an ``(N, 6)`` float array.
15
+
16
+ The file must have a header row naming exactly the OHLCV columns; extra
17
+ columns are ignored, missing ones are an error.
18
+ """
19
+ frame = pd.read_csv(path)
20
+ missing = [column for column in OHLCV_COLUMNS if column not in frame.columns]
21
+ if missing:
22
+ raise ValueError(f"{path}: missing required columns: {', '.join(missing)}")
23
+ candles = frame.loc[:, list(OHLCV_COLUMNS)].to_numpy(dtype=np.float64)
24
+ timestamps = candles[:, 0]
25
+ if np.any(np.diff(timestamps) <= 0):
26
+ raise ValueError(f"{path}: rows must be chronological with strictly increasing timestamps")
27
+ return candles
koval/cli/main.py ADDED
@@ -0,0 +1,239 @@
1
+ """Argument parsing and command dispatch for the ``koval`` console script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from koval import __version__
13
+ from koval.exchanges import get_exchange_adapter
14
+ from koval.exchanges.ohlcv_cache import OhlcvCache
15
+
16
+
17
+ def _load_graph(path: str) -> dict[str, Any]:
18
+ return json.loads(Path(path).read_text(encoding="utf-8"))
19
+
20
+
21
+ def _cmd_blocks(args: argparse.Namespace) -> int:
22
+ from koval.strategy.registry import BLOCK_CATALOG
23
+
24
+ specs = sorted(BLOCK_CATALOG.values(), key=lambda spec: spec.type)
25
+ if args.json:
26
+ print(
27
+ json.dumps(
28
+ [
29
+ {
30
+ "type": spec.type,
31
+ "category": str(spec.category),
32
+ "display_name": spec.display_name,
33
+ "description": spec.description,
34
+ }
35
+ for spec in specs
36
+ ],
37
+ indent=2,
38
+ )
39
+ )
40
+ return 0
41
+ width = max(len(spec.type) for spec in specs)
42
+ for spec in specs:
43
+ print(f"{spec.type:<{width}} {spec.description}")
44
+ return 0
45
+
46
+
47
+ def _cmd_validate(args: argparse.Namespace) -> int:
48
+ from koval.strategy.block_assembler import GraphValidationError, assemble_from_graph
49
+
50
+ try:
51
+ payload = _load_graph(args.graph)
52
+ except FileNotFoundError:
53
+ print(f"error: graph file not found: {args.graph}", file=sys.stderr)
54
+ return 1
55
+ except json.JSONDecodeError as exc:
56
+ print(f"error: {args.graph} is not valid JSON: {exc}", file=sys.stderr)
57
+ return 1
58
+ try:
59
+ assemble_from_graph(payload.get("graph", payload))
60
+ except GraphValidationError as exc:
61
+ print(f"error: {exc}", file=sys.stderr)
62
+ return 1
63
+ print("graph is valid")
64
+ return 0
65
+
66
+
67
+ def _cmd_examples(args: argparse.Namespace) -> int:
68
+ import shutil
69
+
70
+ from koval.examples import EXAMPLES_DIR
71
+
72
+ if args.copy is None:
73
+ print(EXAMPLES_DIR)
74
+ return 0
75
+
76
+ destination = Path(args.copy).expanduser() / "koval-examples"
77
+ if destination.exists():
78
+ print(
79
+ f"error: {destination} already exists; remove it or choose another directory",
80
+ file=sys.stderr,
81
+ )
82
+ return 1
83
+ shutil.copytree(EXAMPLES_DIR, destination, ignore=shutil.ignore_patterns("__pycache__", "*.py"))
84
+ print(f"copied bundled examples to {destination}")
85
+ return 0
86
+
87
+
88
+ def _cmd_backtest(args: argparse.Namespace) -> int:
89
+ from koval.cli.data import load_csv_candles
90
+ from koval.engine.backtest_engine import (
91
+ EngineRunSpec,
92
+ NoBacktestEngineError,
93
+ load_backtest_engine,
94
+ )
95
+ from koval.engine.time_range import date_to_ms
96
+ from koval.strategy.block_assembler import GraphValidationError
97
+
98
+ try:
99
+ payload = _load_graph(args.graph)
100
+ except FileNotFoundError:
101
+ print(f"error: graph file not found: {args.graph}", file=sys.stderr)
102
+ return 1
103
+ except json.JSONDecodeError as exc:
104
+ print(f"error: {args.graph} is not valid JSON: {exc}", file=sys.stderr)
105
+ return 1
106
+ graph = payload.get("graph", payload)
107
+
108
+ if args.data and args.symbol:
109
+ print("error: pass either --data or --symbol, not both", file=sys.stderr)
110
+ return 1
111
+ if not args.data and not args.symbol:
112
+ print(
113
+ "error: pass either --data <csv> for an offline run "
114
+ "or --symbol <symbol> to fetch candles",
115
+ file=sys.stderr,
116
+ )
117
+ return 1
118
+
119
+ if args.data:
120
+ try:
121
+ candles = load_csv_candles(Path(args.data))
122
+ except (FileNotFoundError, ValueError) as exc:
123
+ print(f"error: {exc}", file=sys.stderr)
124
+ return 1
125
+ else:
126
+ if not (args.start_date and args.end_date):
127
+ print("error: --symbol requires --from and --to", file=sys.stderr)
128
+ return 1
129
+ cache = OhlcvCache(root=Path(args.cache_dir))
130
+ try:
131
+ candles = cache.get(
132
+ get_exchange_adapter(args.exchange),
133
+ exchange=args.exchange,
134
+ symbol=args.symbol,
135
+ timeframe=args.timeframe,
136
+ start_ms=date_to_ms(args.start_date),
137
+ end_ms=date_to_ms(args.end_date),
138
+ )
139
+ except ValueError as exc:
140
+ print(f"error: {exc}", file=sys.stderr)
141
+ return 1
142
+ if candles.shape[0] == 0:
143
+ print(
144
+ f"error: no candles for {args.symbol} {args.timeframe} in the requested range",
145
+ file=sys.stderr,
146
+ )
147
+ return 1
148
+
149
+ spec = EngineRunSpec(
150
+ graph=graph,
151
+ feeds={args.timeframe: candles},
152
+ initial_capital=args.capital,
153
+ execution_config={"exchange": args.exchange, "exchange_type": "future"},
154
+ )
155
+ override = os.getenv("KOVAL_BACKTEST_ENGINE")
156
+ try:
157
+ engine = load_backtest_engine()
158
+ except NoBacktestEngineError as exc:
159
+ print(f"error: {exc}", file=sys.stderr)
160
+ return 1
161
+ except ImportError as exc:
162
+ print(
163
+ f"error: cannot import the backtest engine module '{override}' "
164
+ f"named by KOVAL_BACKTEST_ENGINE: {exc}",
165
+ file=sys.stderr,
166
+ )
167
+ return 1
168
+ except AttributeError:
169
+ print(
170
+ f"error: module '{override}' named by KOVAL_BACKTEST_ENGINE "
171
+ "does not define create_engine()",
172
+ file=sys.stderr,
173
+ )
174
+ return 1
175
+
176
+ try:
177
+ result = engine.run(spec)
178
+ except GraphValidationError as exc:
179
+ print(f"error: {exc}", file=sys.stderr)
180
+ return 1
181
+
182
+ if args.json:
183
+ print(
184
+ json.dumps({"metrics": result.metrics, "trades": result.trades}, indent=2, default=str)
185
+ )
186
+ return 0
187
+ for key in sorted(result.metrics):
188
+ print(f"{key:<24} {result.metrics[key]}")
189
+ return 0
190
+
191
+
192
+ def _build_parser() -> argparse.ArgumentParser:
193
+ parser = argparse.ArgumentParser(prog="koval", description="Koval trading-strategy engine")
194
+ parser.add_argument("--version", action="version", version=f"koval-engine {__version__}")
195
+ subparsers = parser.add_subparsers(dest="command", required=True)
196
+
197
+ blocks = subparsers.add_parser("blocks", help="list the available strategy blocks")
198
+ blocks.add_argument("--json", action="store_true", help="emit machine-readable JSON")
199
+ blocks.set_defaults(handler=_cmd_blocks)
200
+
201
+ validate = subparsers.add_parser("validate", help="validate a strategy graph file")
202
+ validate.add_argument("graph", help="path to a strategy graph JSON file")
203
+ validate.set_defaults(handler=_cmd_validate)
204
+
205
+ examples = subparsers.add_parser("examples", help="locate or copy the bundled examples")
206
+ examples.add_argument(
207
+ "--copy",
208
+ metavar="DIR",
209
+ help="copy the bundled examples into DIR/koval-examples/ so they can be edited",
210
+ )
211
+ examples.set_defaults(handler=_cmd_examples)
212
+
213
+ backtest = subparsers.add_parser("backtest", help="run a backtest over OHLCV candles")
214
+ backtest.add_argument("graph", help="path to a strategy graph JSON file")
215
+ backtest.add_argument("--data", help="path to an OHLCV CSV file (offline run)")
216
+ backtest.add_argument("--symbol", help="symbol to fetch, e.g. BTCUSDT")
217
+ backtest.add_argument("--from", dest="start_date", help="inclusive start date, YYYY-MM-DD")
218
+ backtest.add_argument("--to", dest="end_date", help="exclusive end date, YYYY-MM-DD")
219
+ backtest.add_argument(
220
+ "--cache-dir", default="data_cache", help="directory for cached OHLCV Parquet files"
221
+ )
222
+ backtest.add_argument(
223
+ "--timeframe", required=True, help="timeframe label for the candles, e.g. 1h"
224
+ )
225
+ backtest.add_argument("--capital", type=float, default=10_000.0, help="initial capital")
226
+ backtest.add_argument("--exchange", default="binance", help="venue used for the fee model")
227
+ backtest.add_argument("--json", action="store_true", help="emit machine-readable JSON")
228
+ backtest.set_defaults(handler=_cmd_backtest)
229
+
230
+ return parser
231
+
232
+
233
+ def main(argv: list[str] | None = None) -> int:
234
+ args = _build_parser().parse_args(argv)
235
+ return int(args.handler(args))
236
+
237
+
238
+ if __name__ == "__main__":
239
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,105 @@
1
+ """Platform Account State — per-run runtime service.
2
+
3
+ Source of truth for balance/equity/margin/drawdown surfaced read-only on
4
+ ``BarContext.account``. Equity is fed from the broker each bar;
5
+ daily PnL, drawdown and margin are derived here. Holds only plain data so it
6
+ survives the process-pool job queue. Not a graph node — updated via GraphStrategy
7
+ hooks (``on_bar`` / ``on_open`` / ``on_close``).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+ _MS_PER_DAY = 86_400_000
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class OpenPosition:
19
+ side: str # "buy" | "sell"
20
+ entry_price: float
21
+ quantity: float
22
+ current_stop: float
23
+ margin: float
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class AccountSnapshot:
28
+ balance: float
29
+ equity: float
30
+ free_margin: float
31
+ margin_used: float
32
+ unrealized_pnl: float
33
+ realized_pnl: float
34
+ daily_pnl: float
35
+ peak_equity: float
36
+ drawdown_pct: float
37
+ open_positions: int
38
+ open_position: OpenPosition | None
39
+
40
+
41
+ class PlatformAccountState:
42
+ def __init__(self, starting_balance: float = 0.0) -> None:
43
+ bal = float(starting_balance)
44
+ self._starting = bal
45
+ self._realized = 0.0
46
+ self._equity = bal
47
+ self._peak_equity = bal
48
+ self._margin_used = 0.0
49
+ self._position: OpenPosition | None = None
50
+ self._current_day: int | None = None
51
+ self._day_start_equity = bal
52
+
53
+ def on_bar(self, *, equity: float, timestamp_ms: int) -> None:
54
+ equity = float(equity)
55
+ day = int(timestamp_ms) // _MS_PER_DAY
56
+ if self._current_day is None or day != self._current_day:
57
+ # daily_pnl baseline is the FIRST bar's equity of the new UTC day, so
58
+ # any overnight gap between days is excluded from both days' daily_pnl.
59
+ self._current_day = day
60
+ self._day_start_equity = equity
61
+ self._equity = equity
62
+ if equity > self._peak_equity:
63
+ self._peak_equity = equity
64
+
65
+ def on_open(
66
+ self,
67
+ *,
68
+ side: str,
69
+ entry_price: float,
70
+ quantity: float,
71
+ current_stop: float,
72
+ margin: float,
73
+ ) -> None:
74
+ self._position = OpenPosition(
75
+ side=side,
76
+ entry_price=float(entry_price),
77
+ quantity=float(quantity),
78
+ current_stop=float(current_stop),
79
+ margin=float(margin),
80
+ )
81
+ self._margin_used += float(margin)
82
+
83
+ def on_close(self, *, realized_pnl: float) -> None:
84
+ self._realized += float(realized_pnl)
85
+ if self._position is not None:
86
+ self._margin_used = max(0.0, self._margin_used - self._position.margin)
87
+ self._position = None
88
+
89
+ def snapshot(self) -> AccountSnapshot:
90
+ balance = self._starting + self._realized
91
+ peak = self._peak_equity if self._peak_equity > 0 else self._equity
92
+ drawdown = (peak - self._equity) / peak * 100.0 if peak > 0 else 0.0
93
+ return AccountSnapshot(
94
+ balance=balance,
95
+ equity=self._equity,
96
+ free_margin=self._equity - self._margin_used,
97
+ margin_used=self._margin_used,
98
+ unrealized_pnl=self._equity - balance,
99
+ realized_pnl=self._realized,
100
+ daily_pnl=self._equity - self._day_start_equity,
101
+ peak_equity=peak,
102
+ drawdown_pct=max(0.0, drawdown),
103
+ open_positions=1 if self._position is not None else 0,
104
+ open_position=self._position,
105
+ )
@@ -0,0 +1,114 @@
1
+ """Backtest-engine plugin contract for independently distributed engines.
2
+
3
+ Concrete engines are discovered through Python entry points or loaded from an
4
+ explicit module path. This MIT package does not import an implementation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib
10
+ import os
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass, field
13
+ from importlib import metadata
14
+ from typing import Any, Protocol
15
+
16
+ import numpy as np
17
+
18
+ _ENTRY_POINT_GROUP = "koval.backtest_engines"
19
+ _DEFAULT_ENGINE_NAME = "backtrader"
20
+
21
+ # Bump when EngineRunSpec / BacktestResult change in a breaking way so external
22
+ # engines can negotiate compatibility.
23
+ ENGINE_PROTOCOL_VERSION = 1
24
+
25
+
26
+ @dataclass
27
+ class EngineRunSpec:
28
+ """Everything the engine needs for one run — pure, picklable data.
29
+
30
+ ``execution_config`` is an optional dict describing the venue (``exchange``,
31
+ ``exchange_type``) so the engine can mark realistic fees on the broker. When
32
+ omitted the run is fee-free — kept that way for unit tests that assert on
33
+ raw price action.
34
+ """
35
+
36
+ graph: dict[str, Any]
37
+ feeds: dict[str, np.ndarray]
38
+ initial_capital: float
39
+ execution_config: dict[str, Any] | None = None
40
+ protocol_version: int = ENGINE_PROTOCOL_VERSION
41
+
42
+
43
+ @dataclass
44
+ class BacktestResult:
45
+ """Engine output — plain Python only, no Backtrader objects."""
46
+
47
+ metrics: dict[str, Any] = field(default_factory=dict)
48
+ trades: list[dict[str, Any]] = field(default_factory=list)
49
+ equity_curve: list[dict[str, Any]] = field(default_factory=list)
50
+
51
+
52
+ class BacktestEngineProtocol(Protocol):
53
+ """The contract every backtest engine plugin implements."""
54
+
55
+ def run(
56
+ self,
57
+ spec: EngineRunSpec,
58
+ on_event: Callable[[dict], None] | None = None,
59
+ ) -> BacktestResult: ...
60
+
61
+
62
+ class ProtocolVersionError(RuntimeError):
63
+ """Raised when a spec's protocol version is not supported by the engine."""
64
+
65
+
66
+ def check_protocol_version(spec: EngineRunSpec) -> None:
67
+ if spec.protocol_version != ENGINE_PROTOCOL_VERSION:
68
+ raise ProtocolVersionError(
69
+ f"spec protocol_version={spec.protocol_version} unsupported; "
70
+ f"engine speaks {ENGINE_PROTOCOL_VERSION}"
71
+ )
72
+
73
+
74
+ class NoBacktestEngineError(RuntimeError):
75
+ """Raised when no backtest engine can be resolved."""
76
+
77
+
78
+ def _discover_entry_points() -> dict[str, metadata.EntryPoint]:
79
+ return {ep.name: ep for ep in metadata.entry_points(group=_ENTRY_POINT_GROUP)}
80
+
81
+
82
+ def _resolve_engine_target(name: str | None) -> Callable[[], BacktestEngineProtocol]:
83
+ """Resolve the engine factory: an explicit module override wins, then the
84
+ requested entry point."""
85
+ override = os.getenv("KOVAL_BACKTEST_ENGINE")
86
+ if override:
87
+ return importlib.import_module(override).create_engine
88
+ requested = name or _DEFAULT_ENGINE_NAME
89
+ entry_points = _discover_entry_points()
90
+ chosen = entry_points.get(requested)
91
+ if chosen is not None:
92
+ return chosen.load()
93
+ available = ", ".join(sorted(entry_points)) or "none"
94
+ if requested != _DEFAULT_ENGINE_NAME:
95
+ raise NoBacktestEngineError(
96
+ f"No backtest engine named '{requested}' is registered "
97
+ f"(available: {available}). Install/register a compatible plugin "
98
+ "or set KOVAL_BACKTEST_ENGINE to an engine module path."
99
+ )
100
+ raise NoBacktestEngineError(
101
+ f"No backtest engine named '{requested}' is registered "
102
+ f"(available: {available}) — install or register a compatible backtest "
103
+ "engine plugin through the 'koval.backtest_engines' entry-point group, "
104
+ "or set KOVAL_BACKTEST_ENGINE to an engine module path."
105
+ )
106
+
107
+
108
+ def load_backtest_engine(name: str | None = None) -> BacktestEngineProtocol:
109
+ """Resolve and instantiate a backtest engine via the
110
+ ``koval.backtest_engines`` entry-point group. ``KOVAL_BACKTEST_ENGINE``
111
+ (a module path) overrides discovery; ``name`` selects a registered engine
112
+ (default ``backtrader``). The env is read on every call so callers can
113
+ switch engines without clearing a cache."""
114
+ return _resolve_engine_target(name)()
koval/engine/broker.py ADDED
@@ -0,0 +1,151 @@
1
+ """Broker port for paper and sandbox live order routing.
2
+
3
+ This module is intentionally framework-free: no FastAPI, MongoDB, requests, or
4
+ Backtrader imports belong in the broker contract.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Literal, Protocol
11
+
12
+ OrderSide = Literal["buy", "sell"]
13
+ OrderStatus = Literal["accepted", "rejected", "partial", "filled", "canceled", "expired"]
14
+ OrderRole = Literal["entry", "stop", "target", "flatten", "cancel", "protection"]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class BrokerOrderIntent:
19
+ session_id: str
20
+ intent_id: str
21
+ client_order_id: str
22
+ symbol: str
23
+ side: OrderSide
24
+ order_type: str
25
+ quantity: str
26
+ target: str
27
+ price: str | None = None
28
+ stop_price: str | None = None
29
+ target_price: str | None = None
30
+ role: OrderRole = "entry"
31
+ metadata: dict[str, object] = field(default_factory=dict)
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class BrokerOrderAck:
36
+ session_id: str
37
+ client_order_id: str
38
+ exchange_order_id: str | None
39
+ status: OrderStatus
40
+ target: str
41
+ metadata: dict[str, object] = field(default_factory=dict)
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class BrokerFill:
46
+ session_id: str
47
+ client_order_id: str
48
+ exchange_order_id: str | None
49
+ symbol: str
50
+ side: OrderSide
51
+ status: Literal["partial", "filled", "canceled", "expired"]
52
+ role: OrderRole
53
+ quantity: str | None
54
+ price: str | None
55
+ timestamp_ms: int
56
+ realized_pnl: str | None = None
57
+ metadata: dict[str, object] = field(default_factory=dict)
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class ProtectiveOrderIntent:
62
+ session_id: str
63
+ entry_client_order_id: str
64
+ stop_client_order_id: str
65
+ target_client_order_id: str
66
+ symbol: str
67
+ side: OrderSide
68
+ quantity: str
69
+ stop_price: str
70
+ target_price: str
71
+ target: str
72
+ reduce_only: bool = True
73
+ metadata: dict[str, object] = field(default_factory=dict)
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class ProtectiveOrderState:
78
+ session_id: str
79
+ entry_client_order_id: str
80
+ stop_client_order_id: str | None
81
+ target_client_order_id: str | None
82
+ status: str
83
+ metadata: dict[str, object] = field(default_factory=dict)
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class BrokerPositionSnapshot:
88
+ symbol: str
89
+ side: str
90
+ quantity: str
91
+ entry_price: str | None = None
92
+ position_id: str | None = None
93
+ metadata: dict[str, object] = field(default_factory=dict)
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class BrokerReconciliationReport:
98
+ session_id: str
99
+ target: str
100
+ open_orders: list[BrokerOrderAck] = field(default_factory=list)
101
+ fills: list[BrokerFill] = field(default_factory=list)
102
+ positions: list[BrokerPositionSnapshot] = field(default_factory=list)
103
+ incidents: list[str] = field(default_factory=list)
104
+ metadata: dict[str, object] = field(default_factory=dict)
105
+
106
+ @property
107
+ def safe_to_trade(self) -> bool:
108
+ return not self.incidents
109
+
110
+
111
+ class BrokerPositionView(Protocol):
112
+ """Minimum position state the engine needs from every broker."""
113
+
114
+ side: str
115
+ entry_price: float | str
116
+ quantity: float | str
117
+
118
+
119
+ class Broker(Protocol):
120
+ target: str
121
+
122
+ @property
123
+ def pending(self) -> bool:
124
+ """Whether an entry order is still working."""
125
+
126
+ @property
127
+ def position(self) -> BrokerPositionView | None:
128
+ """Current reconciled/open position, if any."""
129
+
130
+ def submit_entry(self, intent: BrokerOrderIntent) -> BrokerOrderAck:
131
+ """Submit an entry order after the caller has persisted the intent."""
132
+
133
+ def place_protection(self, intent: ProtectiveOrderIntent) -> list[BrokerOrderAck]:
134
+ """Place or replace protective reduce-only stop/target orders."""
135
+
136
+ def poll_fills(self, session_id: str) -> list[BrokerFill]:
137
+ """Poll venue order state and return new fill/terminal events."""
138
+
139
+ def reconcile(
140
+ self, session_id: str, intents: list[BrokerOrderIntent]
141
+ ) -> BrokerReconciliationReport:
142
+ """Reconcile persisted intents against venue state before trading."""
143
+
144
+ def cancel_all(self, symbol: str, session_id: str) -> list[BrokerOrderAck]:
145
+ """Cancel all known working orders for a symbol/session."""
146
+
147
+ def flatten(self, symbol: str, session_id: str) -> BrokerOrderAck | None:
148
+ """Flatten or reduce open exposure if the venue has any."""
149
+
150
+ def modify_stop(self, stop_price: float) -> BrokerOrderAck | None:
151
+ """Replace the active protective stop without increasing exposure."""
@@ -0,0 +1,22 @@
1
+ """Deterministic, venue-safe client order identifiers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+
7
+
8
+ def make_client_order_id(
9
+ session_id: str,
10
+ intent_id: str,
11
+ venue: str,
12
+ role: str,
13
+ *,
14
+ max_len: int = 32,
15
+ ) -> str:
16
+ """Return a stable idempotency key safe for Binance and WhiteBIT."""
17
+
18
+ if max_len < 16:
19
+ raise ValueError("max_len must be at least 16")
20
+ raw = "\x1f".join((session_id, intent_id, venue, role)).encode("utf-8")
21
+ digest = hashlib.sha256(raw).hexdigest()
22
+ return f"kv-{digest}"[:max_len]