backtestchat 0.1.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.
- backtestchat/__init__.py +8 -0
- backtestchat/__main__.py +6 -0
- backtestchat/agent/__init__.py +11 -0
- backtestchat/agent/chat.py +222 -0
- backtestchat/agent/context.py +72 -0
- backtestchat/agent/graph.py +103 -0
- backtestchat/agent/prompt.py +62 -0
- backtestchat/agent/tools.py +378 -0
- backtestchat/artifacts.py +57 -0
- backtestchat/broker/__init__.py +41 -0
- backtestchat/broker/base.py +247 -0
- backtestchat/broker/binance.py +290 -0
- backtestchat/cli.py +204 -0
- backtestchat/config.py +89 -0
- backtestchat/paper/__init__.py +22 -0
- backtestchat/paper/runner.py +231 -0
- backtestchat/paper/store.py +209 -0
- backtestchat/render/__init__.py +26 -0
- backtestchat/render/plotly_render.py +204 -0
- backtestchat/render/widgets.py +95 -0
- backtestchat/strategy/__init__.py +26 -0
- backtestchat/strategy/engine.py +181 -0
- backtestchat/strategy/indicators.py +186 -0
- backtestchat/strategy/registry.py +272 -0
- backtestchat/strategy/signals.py +303 -0
- backtestchat-0.1.0.dist-info/METADATA +198 -0
- backtestchat-0.1.0.dist-info/RECORD +30 -0
- backtestchat-0.1.0.dist-info/WHEEL +4 -0
- backtestchat-0.1.0.dist-info/entry_points.txt +3 -0
- backtestchat-0.1.0.dist-info/licenses/LICENSE +21 -0
backtestchat/cli.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""backtestchat command-line interface (typer).
|
|
2
|
+
|
|
3
|
+
Commands: chat (the agent REPL), runs list/sync (paper runs), show (open a
|
|
4
|
+
rendered chart), config (print resolved settings). Heavy imports (the agent
|
|
5
|
+
stack, broker, store) are done lazily inside each command so `backtestchat --help`
|
|
6
|
+
and `config` stay fast and need no model key.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from backtestchat import __version__
|
|
17
|
+
from backtestchat.config import ensure_dirs, load_config
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(
|
|
20
|
+
name="backtestchat",
|
|
21
|
+
help="Local-first agentic strategy lab: chat, backtest, and paper-trade "
|
|
22
|
+
"long/flat strategies on keyless Binance public data. Paper-only.",
|
|
23
|
+
no_args_is_help=True,
|
|
24
|
+
add_completion=False,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
runs_app = typer.Typer(help="Inspect and advance paper runs.", no_args_is_help=True)
|
|
28
|
+
app.add_typer(runs_app, name="runs")
|
|
29
|
+
|
|
30
|
+
console = Console()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _version_callback(value: bool) -> None:
|
|
34
|
+
if value:
|
|
35
|
+
console.print(f"backtestchat {__version__}")
|
|
36
|
+
raise typer.Exit()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.callback()
|
|
40
|
+
def main(
|
|
41
|
+
_version: bool = typer.Option(
|
|
42
|
+
False,
|
|
43
|
+
"--version",
|
|
44
|
+
callback=_version_callback,
|
|
45
|
+
is_eager=True,
|
|
46
|
+
help="Show the backtestchat version and exit.",
|
|
47
|
+
),
|
|
48
|
+
) -> None:
|
|
49
|
+
"""backtestchat: paper-only agentic strategy lab."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_SETUP_HELP = """[yellow]No model configured.[/yellow] backtestchat is BYOK: set BACKTESTCHAT_MODEL to a
|
|
53
|
+
"provider:model" string (or put model = "provider:model" in ./backtestchat.toml).
|
|
54
|
+
|
|
55
|
+
Example:
|
|
56
|
+
uv sync --extra openai
|
|
57
|
+
export BACKTESTCHAT_MODEL=openai:gpt-5.4-nano
|
|
58
|
+
export OPENAI_API_KEY=...
|
|
59
|
+
|
|
60
|
+
Any init_chat_model provider works (install its --extra and set the matching
|
|
61
|
+
API key). Market data is keyless; only the model needs a key."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.command()
|
|
65
|
+
def chat(
|
|
66
|
+
new: bool = typer.Option(False, "--new", help="Start a fresh conversation thread."),
|
|
67
|
+
thread: str | None = typer.Option(None, "--thread", help="Resume a specific thread id."),
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Chat with the strategy agent (resumes the last thread by default)."""
|
|
70
|
+
from backtestchat.agent.chat import ModelSetupError, build_session, run_repl
|
|
71
|
+
|
|
72
|
+
cfg = load_config()
|
|
73
|
+
if not cfg.model:
|
|
74
|
+
console.print(_SETUP_HELP)
|
|
75
|
+
raise typer.Exit(1)
|
|
76
|
+
|
|
77
|
+
ensure_dirs(cfg)
|
|
78
|
+
thread_id = thread or (f"t-{uuid.uuid4().hex[:8]}" if new else "default")
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
session = build_session(cfg)
|
|
82
|
+
except ModelSetupError as exc:
|
|
83
|
+
console.print(f"[red]{exc}[/red]")
|
|
84
|
+
raise typer.Exit(1)
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
run_repl(session, thread_id, console)
|
|
88
|
+
finally:
|
|
89
|
+
session.close()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _runs_table(rows: list[dict]):
|
|
93
|
+
from rich.table import Table
|
|
94
|
+
|
|
95
|
+
table = Table(show_header=True, header_style="bold")
|
|
96
|
+
for col in ("id", "symbol", "interval", "strategy", "status", "equity",
|
|
97
|
+
"cash", "position", "realizedPnl", "lastBar"):
|
|
98
|
+
table.add_column(col)
|
|
99
|
+
for r in rows:
|
|
100
|
+
table.add_row(
|
|
101
|
+
str(r["id"]), r["symbol"], r["interval"], r["strategy_type"],
|
|
102
|
+
r["status"], f"{r['equity']:.2f}", f"{r['cash']:.2f}",
|
|
103
|
+
f"{r['position_qty']:.6f}", f"{r['realized_pnl']:.2f}",
|
|
104
|
+
r["last_bar_ts"] or "-",
|
|
105
|
+
)
|
|
106
|
+
return table
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@runs_app.command("list")
|
|
110
|
+
def runs_list(
|
|
111
|
+
status: str | None = typer.Option(None, "--status", help="Filter by run status (active/stopped)."),
|
|
112
|
+
) -> None:
|
|
113
|
+
"""List paper runs (stored state; run `backtestchat runs sync` to advance them)."""
|
|
114
|
+
from backtestchat.paper.store import Store
|
|
115
|
+
|
|
116
|
+
cfg = load_config()
|
|
117
|
+
store = Store(cfg.db_path)
|
|
118
|
+
try:
|
|
119
|
+
rows = store.list_runs(status)
|
|
120
|
+
finally:
|
|
121
|
+
store.close()
|
|
122
|
+
|
|
123
|
+
if not rows:
|
|
124
|
+
console.print("[dim]No paper runs yet. Start one from `backtestchat chat`.[/dim]")
|
|
125
|
+
return
|
|
126
|
+
console.print(_runs_table(rows))
|
|
127
|
+
console.print("[dim]Equity/position reflect the last sync. "
|
|
128
|
+
"Run `backtestchat runs sync` to advance to the latest closed bar.[/dim]")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@runs_app.command("sync")
|
|
132
|
+
def runs_sync() -> None:
|
|
133
|
+
"""Advance all active paper runs by replaying every closed bar since their
|
|
134
|
+
last tick."""
|
|
135
|
+
from backtestchat.broker.base import BrokerError
|
|
136
|
+
from backtestchat.broker.binance import BinanceBroker
|
|
137
|
+
from backtestchat.paper.runner import sync_all
|
|
138
|
+
from backtestchat.paper.store import Store
|
|
139
|
+
|
|
140
|
+
cfg = load_config()
|
|
141
|
+
ensure_dirs(cfg)
|
|
142
|
+
store = Store(cfg.db_path)
|
|
143
|
+
if not store.list_runs("active"):
|
|
144
|
+
console.print("[dim]No active runs to sync.[/dim]")
|
|
145
|
+
store.close()
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
broker = BinanceBroker(data_dir=cfg.data_dir)
|
|
149
|
+
try:
|
|
150
|
+
results = sync_all(store, broker)
|
|
151
|
+
except BrokerError as exc:
|
|
152
|
+
console.print(f"[red]sync failed:[/red] {exc}")
|
|
153
|
+
raise typer.Exit(1)
|
|
154
|
+
finally:
|
|
155
|
+
broker.close()
|
|
156
|
+
|
|
157
|
+
for r in results:
|
|
158
|
+
note = f" ({r['note']})" if r.get("note") else ""
|
|
159
|
+
console.print(
|
|
160
|
+
f"run {r['run_id']}: [bold]{r['status']}[/bold], "
|
|
161
|
+
f"{r['bars_processed']} bar(s) processed, equity {r['equity']:.2f}"
|
|
162
|
+
f"{note}"
|
|
163
|
+
)
|
|
164
|
+
console.print(_runs_table(store.list_runs()))
|
|
165
|
+
store.close()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@app.command()
|
|
169
|
+
def show(
|
|
170
|
+
target: str = typer.Argument("last", help="Artifact to open: 'last', a filename, or a path."),
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Open a rendered chart artifact in the browser."""
|
|
173
|
+
import webbrowser
|
|
174
|
+
|
|
175
|
+
from backtestchat.artifacts import resolve_artifact
|
|
176
|
+
|
|
177
|
+
cfg = load_config()
|
|
178
|
+
path = resolve_artifact(cfg, target)
|
|
179
|
+
if path is None:
|
|
180
|
+
console.print(f"[red]No artifact found for '{target}'.[/red] "
|
|
181
|
+
f"Charts are written to {cfg.artifacts_dir} during chat.")
|
|
182
|
+
raise typer.Exit(1)
|
|
183
|
+
|
|
184
|
+
console.print(f"opening {path}")
|
|
185
|
+
try:
|
|
186
|
+
webbrowser.open(path.resolve().as_uri())
|
|
187
|
+
except Exception as exc: # noqa: BLE001 - headless env
|
|
188
|
+
console.print(f"[yellow]could not open a browser ({exc}); the file is at "
|
|
189
|
+
f"{path}[/yellow]")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@app.command()
|
|
193
|
+
def config() -> None:
|
|
194
|
+
"""Print the resolved configuration."""
|
|
195
|
+
cfg = load_config()
|
|
196
|
+
console.print("[bold]backtestchat config[/bold]")
|
|
197
|
+
console.print(f" model : {cfg.model or '[red]unset[/red] (set BACKTESTCHAT_MODEL)'}")
|
|
198
|
+
console.print(f" data_dir : {cfg.data_dir}")
|
|
199
|
+
console.print(f" artifacts_dir : {cfg.artifacts_dir}")
|
|
200
|
+
console.print(f" db_path : {cfg.db_path}")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == "__main__":
|
|
204
|
+
app()
|
backtestchat/config.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Configuration resolution for backtestchat.
|
|
2
|
+
|
|
3
|
+
Resolution order (highest priority first):
|
|
4
|
+
1. Environment variables
|
|
5
|
+
2. ./backtestchat.toml (in the current working directory)
|
|
6
|
+
3. Built-in defaults
|
|
7
|
+
|
|
8
|
+
Keys:
|
|
9
|
+
BACKTESTCHAT_MODEL provider:model string for init_chat_model (BYOK).
|
|
10
|
+
No default: an unset model is a hard setup error.
|
|
11
|
+
BACKTESTCHAT_DATA_DIR sqlite + checkpoint dir. Default ./data
|
|
12
|
+
BACKTESTCHAT_ARTIFACTS_DIR chart HTML output dir. Default ./artifacts
|
|
13
|
+
|
|
14
|
+
The toml file uses lowercase, unprefixed keys, e.g.:
|
|
15
|
+
|
|
16
|
+
model = "openai:gpt-5.4-nano"
|
|
17
|
+
data_dir = "./data"
|
|
18
|
+
artifacts_dir = "./artifacts"
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import tomllib
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
_CONFIG_FILENAME = "backtestchat.toml"
|
|
29
|
+
|
|
30
|
+
# key -> (env var name, toml key, default)
|
|
31
|
+
_FIELDS = {
|
|
32
|
+
"model": ("BACKTESTCHAT_MODEL", "model", None),
|
|
33
|
+
"data_dir": ("BACKTESTCHAT_DATA_DIR", "data_dir", "./data"),
|
|
34
|
+
"artifacts_dir": ("BACKTESTCHAT_ARTIFACTS_DIR", "artifacts_dir", "./artifacts"),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Config:
|
|
40
|
+
"""Resolved backtestchat configuration."""
|
|
41
|
+
|
|
42
|
+
model: str | None
|
|
43
|
+
data_dir: Path
|
|
44
|
+
artifacts_dir: Path
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def db_path(self) -> Path:
|
|
48
|
+
"""Path to the application sqlite database."""
|
|
49
|
+
return self.data_dir / "backtestchat.sqlite3"
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def checkpoint_path(self) -> Path:
|
|
53
|
+
"""Path to the LangGraph SqliteSaver checkpoint database."""
|
|
54
|
+
return self.data_dir / "checkpoints.sqlite3"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _load_toml(cwd: Path) -> dict:
|
|
58
|
+
path = cwd / _CONFIG_FILENAME
|
|
59
|
+
if not path.is_file():
|
|
60
|
+
return {}
|
|
61
|
+
with path.open("rb") as fh:
|
|
62
|
+
return tomllib.load(fh)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_config(cwd: Path | None = None) -> Config:
|
|
66
|
+
"""Resolve configuration from env, ./backtestchat.toml, then defaults."""
|
|
67
|
+
cwd = cwd or Path.cwd()
|
|
68
|
+
toml_data = _load_toml(cwd)
|
|
69
|
+
|
|
70
|
+
resolved: dict[str, str | None] = {}
|
|
71
|
+
for key, (env_name, toml_key, default) in _FIELDS.items():
|
|
72
|
+
value = os.environ.get(env_name)
|
|
73
|
+
if value is None:
|
|
74
|
+
value = toml_data.get(toml_key)
|
|
75
|
+
if value is None:
|
|
76
|
+
value = default
|
|
77
|
+
resolved[key] = value
|
|
78
|
+
|
|
79
|
+
return Config(
|
|
80
|
+
model=resolved["model"],
|
|
81
|
+
data_dir=Path(resolved["data_dir"]).expanduser(),
|
|
82
|
+
artifacts_dir=Path(resolved["artifacts_dir"]).expanduser(),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def ensure_dirs(config: Config) -> None:
|
|
87
|
+
"""Create the runtime data and artifacts directories if missing."""
|
|
88
|
+
config.data_dir.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
config.artifacts_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Paper-trading layer: the sqlite store and the replay-based run advancer.
|
|
2
|
+
|
|
3
|
+
Single user, float money, and scheduler-free replay advancement.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from backtestchat.paper.runner import (
|
|
7
|
+
DEFAULT_STARTING_CASH,
|
|
8
|
+
create_run,
|
|
9
|
+
stop_run,
|
|
10
|
+
sync_all,
|
|
11
|
+
sync_run,
|
|
12
|
+
)
|
|
13
|
+
from backtestchat.paper.store import Store
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"DEFAULT_STARTING_CASH",
|
|
17
|
+
"Store",
|
|
18
|
+
"create_run",
|
|
19
|
+
"stop_run",
|
|
20
|
+
"sync_all",
|
|
21
|
+
"sync_run",
|
|
22
|
+
]
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Paper-run lifecycle and replay-based advancement.
|
|
2
|
+
|
|
3
|
+
There is no scheduler: advancing REPLAYS every closed bar since the run's last
|
|
4
|
+
processed bar, in chronological order, one signal evaluation per bar. The
|
|
5
|
+
"already processed this bar ts" guard is the only cadence control, so any CLI
|
|
6
|
+
invocation may safely trigger a sync at any time (idempotent: a sync with no new
|
|
7
|
+
closed bars changes nothing).
|
|
8
|
+
|
|
9
|
+
Key design points:
|
|
10
|
+
- Single user; storage is the hand-rolled sqlite Store.
|
|
11
|
+
- Money is float, not Decimal (fine for a paper sim).
|
|
12
|
+
- Fills are fractional and fully allocated: a buy converts ALL cash to
|
|
13
|
+
position at the bar close (qty = cash / price), a sell converts it all
|
|
14
|
+
back (whole-unit fills would floor to 0 for a $60k asset with $10k cash).
|
|
15
|
+
- First-tick rule: no backdated fills. The first bar a run may trade is the
|
|
16
|
+
first bar that CLOSES after the run was created.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
from backtestchat.broker.base import (
|
|
24
|
+
INTERVAL_MS,
|
|
25
|
+
iso_to_ms,
|
|
26
|
+
ms_to_iso,
|
|
27
|
+
validate_interval,
|
|
28
|
+
)
|
|
29
|
+
from backtestchat.paper.store import Store
|
|
30
|
+
from backtestchat.strategy.registry import (
|
|
31
|
+
get_signal_fn,
|
|
32
|
+
strategy_warmup,
|
|
33
|
+
validate_params,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
DEFAULT_STARTING_CASH = 10_000.0
|
|
37
|
+
|
|
38
|
+
# Extra bars fetched before the anchor so indicators are warm at the first
|
|
39
|
+
# unprocessed bar.
|
|
40
|
+
_WARMUP_BUFFER = 5
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _now_ms() -> int:
|
|
44
|
+
return int(time.time() * 1000)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def create_run(store: Store, broker, *, symbol, interval, strategy_type,
|
|
48
|
+
params=None, cash=DEFAULT_STARTING_CASH, strategy_id=None,
|
|
49
|
+
created_ms=None, now_ms=None) -> dict:
|
|
50
|
+
"""Create an active paper run and run its first sync.
|
|
51
|
+
|
|
52
|
+
No backdated fills: the run starts flat and waits for the first bar to
|
|
53
|
+
close after `created_ms` (default now). Passing a past `created_ms` (with
|
|
54
|
+
`now_ms` at the present) is how a test or a demo replays historical bars.
|
|
55
|
+
Returns the first sync result dict (which carries the new run_id).
|
|
56
|
+
"""
|
|
57
|
+
symbol = broker.validate_symbol(symbol)
|
|
58
|
+
validate_interval(interval)
|
|
59
|
+
params = validate_params(strategy_type, params or {})
|
|
60
|
+
|
|
61
|
+
created_ms = created_ms if created_ms is not None else _now_ms()
|
|
62
|
+
run_id = store.create_run(
|
|
63
|
+
strategy_id=strategy_id,
|
|
64
|
+
strategy_type=strategy_type,
|
|
65
|
+
params=params,
|
|
66
|
+
symbol=symbol,
|
|
67
|
+
interval=interval,
|
|
68
|
+
cash_start=float(cash),
|
|
69
|
+
created_at=ms_to_iso(created_ms),
|
|
70
|
+
)
|
|
71
|
+
return sync_run(store, broker, run_id, now_ms=now_ms if now_ms is not None else created_ms)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def sync_run(store: Store, broker, run_id, now_ms=None) -> dict:
|
|
75
|
+
"""Advance one run by replaying all closed bars since its last processed bar.
|
|
76
|
+
|
|
77
|
+
Returns {run_id, status, bars_processed, trades, position_qty, cash, equity,
|
|
78
|
+
last_bar_ts, note}. status is one of: inactive, no_data, waiting_first_tick,
|
|
79
|
+
up_to_date, advanced.
|
|
80
|
+
"""
|
|
81
|
+
now_ms = now_ms if now_ms is not None else _now_ms()
|
|
82
|
+
run = store.get_run(run_id)
|
|
83
|
+
if run is None:
|
|
84
|
+
return {"run_id": run_id, "status": "not_found"}
|
|
85
|
+
if run["status"] != "active":
|
|
86
|
+
return _result(run, "inactive", 0, [])
|
|
87
|
+
|
|
88
|
+
interval = run["interval"]
|
|
89
|
+
params = run["params"]
|
|
90
|
+
warmup = strategy_warmup(run["strategy_type"], params)
|
|
91
|
+
interval_ms = INTERVAL_MS[interval]
|
|
92
|
+
|
|
93
|
+
last_bar_ts = run["last_bar_ts"]
|
|
94
|
+
created_ms = iso_to_ms(run["created_at"])
|
|
95
|
+
anchor_ms = iso_to_ms(last_bar_ts) if last_bar_ts else created_ms
|
|
96
|
+
start_ms = anchor_ms - (warmup + _WARMUP_BUFFER) * interval_ms
|
|
97
|
+
|
|
98
|
+
bars = broker.get_bars(run["symbol"], interval, start_ms=start_ms, now_ms=now_ms)
|
|
99
|
+
if not bars:
|
|
100
|
+
return _result(run, "no_data", 0, [])
|
|
101
|
+
|
|
102
|
+
# Which fetched bars are unprocessed?
|
|
103
|
+
if last_bar_ts is None:
|
|
104
|
+
# First tick: a bar is eligible once it CLOSES after creation. Bars are
|
|
105
|
+
# stamped at open time, so close = open + interval.
|
|
106
|
+
unprocessed = [
|
|
107
|
+
i for i, bar in enumerate(bars)
|
|
108
|
+
if iso_to_ms(bar["ts"]) + interval_ms > created_ms
|
|
109
|
+
]
|
|
110
|
+
else:
|
|
111
|
+
unprocessed = [i for i, bar in enumerate(bars) if bar["ts"] > last_bar_ts]
|
|
112
|
+
|
|
113
|
+
if not unprocessed:
|
|
114
|
+
status = "waiting_first_tick" if last_bar_ts is None else "up_to_date"
|
|
115
|
+
note = ("Waiting for the first bar to close after the run was created."
|
|
116
|
+
if last_bar_ts is None else run["note"])
|
|
117
|
+
if note != run["note"]:
|
|
118
|
+
store.update_run(run_id, note=note)
|
|
119
|
+
run["note"] = note
|
|
120
|
+
return _result(run, status, 0, [])
|
|
121
|
+
|
|
122
|
+
# Signals are trailing-only (no look-ahead), so evaluating once over the
|
|
123
|
+
# full fetched window and indexing is equivalent to per-bar incremental
|
|
124
|
+
# evaluation.
|
|
125
|
+
signal_fn = get_signal_fn(run["strategy_type"])
|
|
126
|
+
signals = signal_fn(bars, params)
|
|
127
|
+
|
|
128
|
+
cash = run["cash"]
|
|
129
|
+
qty = run["position_qty"]
|
|
130
|
+
avg = run["position_avg_price"]
|
|
131
|
+
entry_ts = run["position_entry_ts"]
|
|
132
|
+
realized = run["realized_pnl"]
|
|
133
|
+
last_price = run["last_price"]
|
|
134
|
+
new_trades = []
|
|
135
|
+
|
|
136
|
+
for i in unprocessed:
|
|
137
|
+
bar = bars[i]
|
|
138
|
+
price = bar["close"]
|
|
139
|
+
target = signals[i] if i < len(signals) else 0
|
|
140
|
+
|
|
141
|
+
if target == 1 and qty == 0 and cash > 0:
|
|
142
|
+
buy_qty = cash / price
|
|
143
|
+
cash = 0.0
|
|
144
|
+
qty = buy_qty
|
|
145
|
+
avg = price
|
|
146
|
+
entry_ts = bar["ts"]
|
|
147
|
+
equity = qty * price
|
|
148
|
+
new_trades.append(_trade("buy", bar["ts"], price, buy_qty, equity))
|
|
149
|
+
elif target == 0 and qty > 0:
|
|
150
|
+
proceeds = qty * price
|
|
151
|
+
realized += (price - (avg or price)) * qty
|
|
152
|
+
cash += proceeds
|
|
153
|
+
sell_qty = qty
|
|
154
|
+
qty = 0.0
|
|
155
|
+
avg = None
|
|
156
|
+
entry_ts = None
|
|
157
|
+
equity = cash
|
|
158
|
+
new_trades.append(_trade("sell", bar["ts"], price, sell_qty, equity))
|
|
159
|
+
|
|
160
|
+
last_price = price
|
|
161
|
+
last_bar_ts = bar["ts"]
|
|
162
|
+
|
|
163
|
+
equity = cash + qty * (last_price or 0.0)
|
|
164
|
+
|
|
165
|
+
for trade in new_trades:
|
|
166
|
+
store.append_trade(run_id, trade["side"], trade["ts"], trade["price"],
|
|
167
|
+
trade["qty"], trade["equity_after"])
|
|
168
|
+
|
|
169
|
+
store.update_run(
|
|
170
|
+
run_id,
|
|
171
|
+
cash=cash,
|
|
172
|
+
position_qty=qty,
|
|
173
|
+
position_avg_price=avg,
|
|
174
|
+
position_entry_ts=entry_ts,
|
|
175
|
+
last_price=last_price,
|
|
176
|
+
equity=equity,
|
|
177
|
+
realized_pnl=realized,
|
|
178
|
+
last_bar_ts=last_bar_ts,
|
|
179
|
+
last_tick_at=ms_to_iso(now_ms),
|
|
180
|
+
note="",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
run = store.get_run(run_id)
|
|
184
|
+
return _result(run, "advanced", len(unprocessed), new_trades)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def sync_all(store: Store, broker, now_ms=None) -> list[dict]:
|
|
188
|
+
"""Sync every active run. Returns a result dict per run."""
|
|
189
|
+
return [
|
|
190
|
+
sync_run(store, broker, run["id"], now_ms=now_ms)
|
|
191
|
+
for run in store.list_runs(status="active")
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def stop_run(store: Store, broker, run_id, now_ms=None) -> dict:
|
|
196
|
+
"""Sync a run one last time, then mark it stopped. Returns a final summary."""
|
|
197
|
+
result = sync_run(store, broker, run_id, now_ms=now_ms)
|
|
198
|
+
run = store.get_run(run_id)
|
|
199
|
+
if run is None:
|
|
200
|
+
return {"run_id": run_id, "status": "not_found"}
|
|
201
|
+
if run["status"] == "active":
|
|
202
|
+
store.update_run(run_id, status="stopped")
|
|
203
|
+
run["status"] = "stopped"
|
|
204
|
+
summary = _result(run, "stopped", result.get("bars_processed", 0),
|
|
205
|
+
result.get("trades", []))
|
|
206
|
+
summary["realized_pnl"] = run["realized_pnl"]
|
|
207
|
+
summary["trade_count"] = len(store.list_trades(run_id))
|
|
208
|
+
return summary
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _trade(side, ts, price, qty, equity_after) -> dict:
|
|
212
|
+
return {"side": side, "ts": ts, "price": price, "qty": qty,
|
|
213
|
+
"equity_after": equity_after}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _result(run, status, bars_processed, trades) -> dict:
|
|
217
|
+
return {
|
|
218
|
+
"run_id": run["id"],
|
|
219
|
+
"status": status,
|
|
220
|
+
"bars_processed": bars_processed,
|
|
221
|
+
"trades": trades,
|
|
222
|
+
"symbol": run["symbol"],
|
|
223
|
+
"interval": run["interval"],
|
|
224
|
+
"strategy_type": run["strategy_type"],
|
|
225
|
+
"position_qty": run["position_qty"],
|
|
226
|
+
"cash": run["cash"],
|
|
227
|
+
"equity": run["equity"],
|
|
228
|
+
"realized_pnl": run["realized_pnl"],
|
|
229
|
+
"last_bar_ts": run["last_bar_ts"],
|
|
230
|
+
"note": run["note"],
|
|
231
|
+
}
|