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.
- koval/__init__.py +7 -0
- koval/_version.py +7 -0
- koval/cli/__init__.py +7 -0
- koval/cli/data.py +27 -0
- koval/cli/main.py +239 -0
- koval/engine/__init__.py +0 -0
- koval/engine/account_state.py +105 -0
- koval/engine/backtest_engine.py +114 -0
- koval/engine/broker.py +151 -0
- koval/engine/client_order_id.py +22 -0
- koval/engine/engine_events.py +25 -0
- koval/engine/execution_settings.py +170 -0
- koval/engine/live_engine.py +989 -0
- koval/engine/live_feed.py +216 -0
- koval/engine/logger.py +146 -0
- koval/engine/paper_broker.py +544 -0
- koval/engine/time_range.py +11 -0
- koval/engine/timeframe_utils.py +29 -0
- koval/engine/trade_context.py +46 -0
- koval/engine/trade_indicators.py +169 -0
- koval/engine/trade_metrics.py +50 -0
- koval/engine/trade_narrator.py +27 -0
- koval/engine/venue_metadata.py +164 -0
- koval/examples/__init__.py +25 -0
- koval/examples/data/README.md +5 -0
- koval/examples/data/sample-1h.csv +1201 -0
- koval/examples/graphs/ema_cross_trend.json +21 -0
- koval/exchanges/__init__.py +47 -0
- koval/exchanges/auth.py +76 -0
- koval/exchanges/base.py +180 -0
- koval/exchanges/binance.py +148 -0
- koval/exchanges/binance_sandbox.py +901 -0
- koval/exchanges/ohlcv_cache.py +221 -0
- koval/exchanges/sandbox_factory.py +39 -0
- koval/exchanges/whitebit.py +153 -0
- koval/exchanges/whitebit_sandbox.py +35 -0
- koval/py.typed +1 -0
- koval/strategy/__init__.py +0 -0
- koval/strategy/base/__init__.py +0 -0
- koval/strategy/base/declarative.py +110 -0
- koval/strategy/base/trade_setup.py +52 -0
- koval/strategy/block_assembler.py +62 -0
- koval/strategy/graph/__init__.py +0 -0
- koval/strategy/graph/compat.py +280 -0
- koval/strategy/graph/domains.py +40 -0
- koval/strategy/graph/entities.py +108 -0
- koval/strategy/graph/executor.py +144 -0
- koval/strategy/graph/node.py +82 -0
- koval/strategy/graph/ports.py +24 -0
- koval/strategy/graph/registry.py +44 -0
- koval/strategy/graph/state_store.py +17 -0
- koval/strategy/graph/strategy.py +159 -0
- koval/strategy/graph/validation.py +141 -0
- koval/strategy/helpers/__init__.py +0 -0
- koval/strategy/helpers/_math.py +132 -0
- koval/strategy/helpers/exits/__init__.py +0 -0
- koval/strategy/helpers/exits/breakeven.py +25 -0
- koval/strategy/helpers/exits/fixed.py +27 -0
- koval/strategy/helpers/exits/pricing.py +42 -0
- koval/strategy/helpers/exits/trailing.py +22 -0
- koval/strategy/helpers/filters/__init__.py +0 -0
- koval/strategy/helpers/filters/momentum.py +55 -0
- koval/strategy/helpers/filters/trend.py +83 -0
- koval/strategy/helpers/filters/volatility.py +42 -0
- koval/strategy/helpers/interp/__init__.py +0 -0
- koval/strategy/helpers/interp/scoring.py +28 -0
- koval/strategy/helpers/risk/__init__.py +0 -0
- koval/strategy/helpers/risk/gates.py +55 -0
- koval/strategy/helpers/risk/position_sizer.py +67 -0
- koval/strategy/helpers/risk/sizing.py +15 -0
- koval/strategy/helpers/signals/__init__.py +0 -0
- koval/strategy/helpers/signals/candlesticks.py +74 -0
- koval/strategy/helpers/signals/smc.py +110 -0
- koval/strategy/helpers/signals/technical.py +52 -0
- koval/strategy/nodes/__init__.py +12 -0
- koval/strategy/nodes/aggregator.py +186 -0
- koval/strategy/nodes/exec_pipeline.py +372 -0
- koval/strategy/nodes/execution.py +93 -0
- koval/strategy/nodes/fact.py +376 -0
- koval/strategy/nodes/interp.py +120 -0
- koval/strategy/nodes/policy.py +233 -0
- koval/strategy/nodes/scoring.py +276 -0
- koval/strategy/nodes/state.py +116 -0
- koval/strategy/presets/__init__.py +8 -0
- koval/strategy/presets/risk_pipeline_demo.py +52 -0
- koval/strategy/presets/sweep_choch_reversal.py +73 -0
- koval/strategy/promotion_gates.py +112 -0
- koval/strategy/registry.py +778 -0
- koval/strategy/schemas.py +225 -0
- koval_engine-0.9.0.dist-info/METADATA +174 -0
- koval_engine-0.9.0.dist-info/RECORD +95 -0
- koval_engine-0.9.0.dist-info/WHEEL +5 -0
- koval_engine-0.9.0.dist-info/entry_points.txt +2 -0
- koval_engine-0.9.0.dist-info/licenses/LICENSE +21 -0
- koval_engine-0.9.0.dist-info/top_level.txt +1 -0
koval/__init__.py
ADDED
koval/_version.py
ADDED
koval/cli/__init__.py
ADDED
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())
|
koval/engine/__init__.py
ADDED
|
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]
|