talis-cli 0.2.1__tar.gz → 0.3.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.
- {talis_cli-0.2.1 → talis_cli-0.3.0}/PKG-INFO +10 -3
- {talis_cli-0.2.1 → talis_cli-0.3.0}/README.md +9 -2
- {talis_cli-0.2.1 → talis_cli-0.3.0}/pyproject.toml +1 -1
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/api.py +21 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/mcp_server.py +117 -1
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis_cli.egg-info/PKG-INFO +10 -3
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis_cli.egg-info/SOURCES.txt +1 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_mcp.py +2 -1
- talis_cli-0.3.0/tests/test_mcp_backtest.py +109 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/setup.cfg +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/__init__.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/_eip55.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/cli.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/__init__.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/_shared.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/auth.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/brief.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/chat.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/fund.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/guide.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/history.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/notifications.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/outcome.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/portfolio.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/research.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/snapshot.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/strategies.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/test_tenant.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/trade.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/wait.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/withdraw.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/config.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/output.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/paper.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/spot.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/symbols.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis_cli.egg-info/dependency_links.txt +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis_cli.egg-info/entry_points.txt +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis_cli.egg-info/requires.txt +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/talis_cli.egg-info/top_level.txt +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_api.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_api_key_auth.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_brief.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_chat.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_commands.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_config.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_confirm_prompt.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_eip55.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_guide.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_history.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_login_flow.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_loopback_login.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_mcp_balance_contract.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_mcp_docstring_contract.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_mcp_mode_contract.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_mcp_sdk_compat.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_mcp_session_expiry.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_mcp_strategy_tools.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_non_interactive_auth.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_notifications.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_outcome.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_paper_mode.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_research.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_session_autorenew.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_signup.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_snapshot_diff.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_spot.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_spot_gateway_first.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_strategies_create.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_test_tenant.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_version_sync.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_wait.py +0 -0
- {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_withdraw.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: talis-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Command-line client for the Talis trading platform. Sign in via device flow, manage sessions, view portfolio, place orders.
|
|
5
5
|
Author: Talis
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -187,14 +187,21 @@ shape) — `claude_desktop_config.json`:
|
|
|
187
187
|
}
|
|
188
188
|
```
|
|
189
189
|
|
|
190
|
-
Tools (
|
|
190
|
+
Tools (26):
|
|
191
191
|
|
|
192
|
-
`amend_strategy`, `balance`, `brief_latest`, `buy`, `cancel_all_orders`,
|
|
192
|
+
`amend_strategy`, `backtest`, `balance`, `brief_latest`, `buy`, `cancel_all_orders`,
|
|
193
193
|
`cancel_order`, `chat`, `close`, `create_strategy`, `history`,
|
|
194
194
|
`notifications_create`, `open_orders`, `outcomes`, `packets`, `place_order`,
|
|
195
195
|
`portfolio`, `research`, `sell`, `start_strategy`, `stop_strategy`,
|
|
196
196
|
`strategies`, `strategy`, `strategy_activity`, `validate_strategy`, `whoami`.
|
|
197
197
|
|
|
198
|
+
**Strategy workflow.** `validate_strategy(dsl)` checks a DSL without side
|
|
199
|
+
effects; `backtest(pair, dsl, days=30, interval="1h")` runs it through the live
|
|
200
|
+
engine on real historical candles (read-only) and returns return, Sharpe,
|
|
201
|
+
drawdown, trade count; `create_strategy(pair, dsl, paper=True)` runs it on the
|
|
202
|
+
simulated book; the same call with `paper=False, confirm_real_money=True`
|
|
203
|
+
deploys it live.
|
|
204
|
+
|
|
198
205
|
**Safety — read this before pointing an agent at it.** The trading tools
|
|
199
206
|
(`buy` / `sell` / `close` / `create_strategy`) execute with **REAL MONEY by
|
|
200
207
|
default** (`paper=False`). Pass `paper=True` to simulate instead.
|
|
@@ -159,14 +159,21 @@ shape) — `claude_desktop_config.json`:
|
|
|
159
159
|
}
|
|
160
160
|
```
|
|
161
161
|
|
|
162
|
-
Tools (
|
|
162
|
+
Tools (26):
|
|
163
163
|
|
|
164
|
-
`amend_strategy`, `balance`, `brief_latest`, `buy`, `cancel_all_orders`,
|
|
164
|
+
`amend_strategy`, `backtest`, `balance`, `brief_latest`, `buy`, `cancel_all_orders`,
|
|
165
165
|
`cancel_order`, `chat`, `close`, `create_strategy`, `history`,
|
|
166
166
|
`notifications_create`, `open_orders`, `outcomes`, `packets`, `place_order`,
|
|
167
167
|
`portfolio`, `research`, `sell`, `start_strategy`, `stop_strategy`,
|
|
168
168
|
`strategies`, `strategy`, `strategy_activity`, `validate_strategy`, `whoami`.
|
|
169
169
|
|
|
170
|
+
**Strategy workflow.** `validate_strategy(dsl)` checks a DSL without side
|
|
171
|
+
effects; `backtest(pair, dsl, days=30, interval="1h")` runs it through the live
|
|
172
|
+
engine on real historical candles (read-only) and returns return, Sharpe,
|
|
173
|
+
drawdown, trade count; `create_strategy(pair, dsl, paper=True)` runs it on the
|
|
174
|
+
simulated book; the same call with `paper=False, confirm_real_money=True`
|
|
175
|
+
deploys it live.
|
|
176
|
+
|
|
170
177
|
**Safety — read this before pointing an agent at it.** The trading tools
|
|
171
178
|
(`buy` / `sell` / `close` / `create_strategy`) execute with **REAL MONEY by
|
|
172
179
|
default** (`paper=False`). Pass `paper=True` to simulate instead.
|
|
@@ -491,6 +491,27 @@ class Client:
|
|
|
491
491
|
params={"tenant_id": tenant_id},
|
|
492
492
|
)
|
|
493
493
|
|
|
494
|
+
def get_candles(self, *, symbol: str, interval: str = "1h", limit: int = 500,
|
|
495
|
+
exchange: str | None = None) -> dict:
|
|
496
|
+
"""Historical candles from the control plane (``GET /candles``).
|
|
497
|
+
|
|
498
|
+
Returns ``{"candles": [{t,o,h,l,c,v}, ...], "symbol", "interval"}`` —
|
|
499
|
+
abbreviated OHLCV keys, newest last. The backtester wants the full
|
|
500
|
+
names; ``backtest_candles`` in the MCP layer does that mapping.
|
|
501
|
+
"""
|
|
502
|
+
params: dict[str, Any] = {"symbol": symbol, "interval": interval, "limit": limit}
|
|
503
|
+
if exchange:
|
|
504
|
+
params["exchange"] = exchange
|
|
505
|
+
return self._request("GET", "/candles", params=params)
|
|
506
|
+
|
|
507
|
+
def create_backtest(self, *, body: dict) -> dict:
|
|
508
|
+
"""Submit a backtest (``POST /backtests``). Returns ``{backtest_id, status}``."""
|
|
509
|
+
return self._request("POST", "/backtests", json=body)
|
|
510
|
+
|
|
511
|
+
def get_backtest(self, *, backtest_id: str) -> dict:
|
|
512
|
+
"""Backtest status + results (``GET /backtests/{id}``)."""
|
|
513
|
+
return self._request("GET", f"/backtests/{backtest_id}")
|
|
514
|
+
|
|
494
515
|
def validate_strategy(self, *, dsl: dict) -> dict:
|
|
495
516
|
"""Validate a DSL WITHOUT creating anything. No side effects.
|
|
496
517
|
|
|
@@ -35,8 +35,11 @@ Design notes
|
|
|
35
35
|
from __future__ import annotations
|
|
36
36
|
|
|
37
37
|
import contextlib
|
|
38
|
+
import json
|
|
38
39
|
import os
|
|
40
|
+
import re
|
|
39
41
|
import sys
|
|
42
|
+
import time
|
|
40
43
|
from typing import Any
|
|
41
44
|
|
|
42
45
|
import typer
|
|
@@ -756,6 +759,116 @@ def close(symbol: str, paper: bool = False) -> dict:
|
|
|
756
759
|
return resp
|
|
757
760
|
|
|
758
761
|
|
|
762
|
+
_CANDLE_KEYS = {"t": "timestamp", "o": "open", "h": "high", "l": "low", "c": "close", "v": "volume"}
|
|
763
|
+
_INTERVAL_MINUTES = {"1m": 1, "5m": 5, "15m": 15, "30m": 30, "1h": 60, "4h": 240, "1d": 1440}
|
|
764
|
+
_BACKTEST_MAX_CANDLES = 5000 # the venue's candleSnapshot ceiling per request
|
|
765
|
+
_PAIR_RE = re.compile(r"\b([A-Z0-9]{2,12}-USD)\b")
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def backtest_candles(raw: list[dict]) -> list[dict]:
|
|
769
|
+
"""Map the control plane's abbreviated candle keys onto the backtester's."""
|
|
770
|
+
return [{_CANDLE_KEYS.get(k, k): v for k, v in c.items()} for c in raw]
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def backtest(pair: str, dsl: dict, days: int = 30, interval: str = "1h",
|
|
774
|
+
timeout_seconds: int = 180) -> dict:
|
|
775
|
+
"""Backtest a Talis DSL strategy on `pair` over the last `days` of `interval` candles. Read-only — never places an order.
|
|
776
|
+
|
|
777
|
+
Runs the SAME engine that executes live strategies (fast mode) against
|
|
778
|
+
real historical candles fetched for you, so the result is what this exact
|
|
779
|
+
DSL would have done. Validates the DSL first; an invalid DSL returns the
|
|
780
|
+
validator's errors and runs nothing.
|
|
781
|
+
|
|
782
|
+
Returns the backtest id, the window actually covered, and `results` with
|
|
783
|
+
`total_return_pct`, `sharpe_ratio`, `max_drawdown_pct`, `total_trades`,
|
|
784
|
+
`profit_factor` (plus whatever else the engine reports). Cross-asset
|
|
785
|
+
triggers (e.g. "ETH-USD.rsi_14") get their candles fetched automatically.
|
|
786
|
+
|
|
787
|
+
Workflow: validate_strategy → backtest → create_strategy(paper=True) →
|
|
788
|
+
create_strategy(paper=False, confirm_real_money=True). `days` × `interval`
|
|
789
|
+
is capped at the venue's 5000-candle window; the response says if it was.
|
|
790
|
+
"""
|
|
791
|
+
if interval not in _INTERVAL_MINUTES:
|
|
792
|
+
raise ValueError(f"interval must be one of {sorted(_INTERVAL_MINUTES)}")
|
|
793
|
+
if days <= 0:
|
|
794
|
+
raise ValueError("days must be positive")
|
|
795
|
+
creds = _require_creds()
|
|
796
|
+
trading_pair = to_trading_pair(pair)
|
|
797
|
+
dsl_config = dict(dsl)
|
|
798
|
+
cfg = dict(dsl_config.get("config") or {})
|
|
799
|
+
cfg.setdefault("exchange", _EXCHANGE)
|
|
800
|
+
cfg.setdefault("trading_pair", trading_pair)
|
|
801
|
+
dsl_config["config"] = cfg
|
|
802
|
+
|
|
803
|
+
wanted = days * 1440 // _INTERVAL_MINUTES[interval]
|
|
804
|
+
limit = min(wanted, _BACKTEST_MAX_CANDLES)
|
|
805
|
+
capped = wanted > _BACKTEST_MAX_CANDLES
|
|
806
|
+
|
|
807
|
+
with api.client_from_credentials(creds) as c:
|
|
808
|
+
check = c.validate_strategy(dsl=dsl_config)
|
|
809
|
+
if isinstance(check, dict) and check.get("valid") is False:
|
|
810
|
+
return {"valid": False, "errors": check.get("errors"),
|
|
811
|
+
"summary": check.get("human_readable_summary"),
|
|
812
|
+
"note": "DSL rejected by the engine validator; nothing was run."}
|
|
813
|
+
primary = c.get_candles(symbol=base_coin(trading_pair), interval=interval,
|
|
814
|
+
limit=limit, exchange=cfg["exchange"])
|
|
815
|
+
raw = list(primary.get("candles") or [])
|
|
816
|
+
if not raw:
|
|
817
|
+
return {"error": f"no candles returned for {trading_pair} at {interval}"}
|
|
818
|
+
candles = backtest_candles(raw)
|
|
819
|
+
cross: dict[str, list[dict]] = {}
|
|
820
|
+
for other in sorted(set(_PAIR_RE.findall(json.dumps(dsl_config))) - {trading_pair}):
|
|
821
|
+
resp = c.get_candles(symbol=base_coin(other), interval=interval,
|
|
822
|
+
limit=limit, exchange=cfg["exchange"])
|
|
823
|
+
if resp.get("candles"):
|
|
824
|
+
cross[other] = backtest_candles(resp["candles"])
|
|
825
|
+
start_ts = int(candles[0]["timestamp"])
|
|
826
|
+
end_ts = int(candles[-1]["timestamp"])
|
|
827
|
+
body: dict[str, Any] = {
|
|
828
|
+
"tenant_id": creds.tenant_id,
|
|
829
|
+
"mode": "fast",
|
|
830
|
+
"exchange": cfg["exchange"],
|
|
831
|
+
"trading_pair": trading_pair,
|
|
832
|
+
"start_ts": start_ts,
|
|
833
|
+
"end_ts": end_ts,
|
|
834
|
+
"interval": interval,
|
|
835
|
+
"candles": candles,
|
|
836
|
+
"dsl": dsl_config,
|
|
837
|
+
}
|
|
838
|
+
if cross:
|
|
839
|
+
body["cross_asset_candles"] = cross
|
|
840
|
+
submitted = c.create_backtest(body=body)
|
|
841
|
+
backtest_id = submitted.get("backtest_id")
|
|
842
|
+
if not backtest_id:
|
|
843
|
+
return {"error": "backtest was not accepted", "response": submitted}
|
|
844
|
+
deadline = time.monotonic() + timeout_seconds
|
|
845
|
+
status = submitted
|
|
846
|
+
while time.monotonic() < deadline:
|
|
847
|
+
status = c.get_backtest(backtest_id=backtest_id)
|
|
848
|
+
if str(status.get("status", "")).upper() not in ("RUNNING", "PENDING", "QUEUED", ""):
|
|
849
|
+
break
|
|
850
|
+
time.sleep(2)
|
|
851
|
+
|
|
852
|
+
results = status.get("results") if isinstance(status, dict) else None
|
|
853
|
+
out: dict[str, Any] = {
|
|
854
|
+
"backtest_id": backtest_id,
|
|
855
|
+
"status": status.get("status"),
|
|
856
|
+
"pair": trading_pair,
|
|
857
|
+
"window": {"interval": interval, "candles": len(candles), "start_ts": start_ts,
|
|
858
|
+
"end_ts": end_ts, "requested_days": days, "capped_to_venue_window": capped},
|
|
859
|
+
"cross_assets": sorted(cross),
|
|
860
|
+
"results": results,
|
|
861
|
+
}
|
|
862
|
+
if isinstance(results, dict):
|
|
863
|
+
out["summary"] = {k: results.get(k) for k in (
|
|
864
|
+
"total_return_pct", "sharpe_ratio", "max_drawdown_pct", "total_trades", "profit_factor")}
|
|
865
|
+
if str(out["status"] or "").upper() in ("RUNNING", "PENDING", "QUEUED"):
|
|
866
|
+
out["note"] = f"still running after {timeout_seconds}s; call again later via the API: GET /backtests/{backtest_id}"
|
|
867
|
+
out["next"] = ("create_strategy(pair, dsl, paper=True) runs it on the simulated book; "
|
|
868
|
+
"create_strategy(pair, dsl, paper=False, confirm_real_money=True) goes live.")
|
|
869
|
+
return out
|
|
870
|
+
|
|
871
|
+
|
|
759
872
|
def create_strategy(pair: str, dsl: dict, paper: bool = False,
|
|
760
873
|
confirm_real_money: bool = False) -> dict:
|
|
761
874
|
"""Create and auto-start a trading strategy on `pair` from a Talis DSL dict. Trades REAL money by default — a real strategy requires confirm_real_money=True; pass paper=True to simulate.
|
|
@@ -1259,6 +1372,7 @@ TOOL_FUNCTIONS = (
|
|
|
1259
1372
|
strategy,
|
|
1260
1373
|
strategy_activity,
|
|
1261
1374
|
validate_strategy,
|
|
1375
|
+
backtest,
|
|
1262
1376
|
outcomes,
|
|
1263
1377
|
buy,
|
|
1264
1378
|
sell,
|
|
@@ -1293,7 +1407,9 @@ _SERVER_INSTRUCTIONS = (
|
|
|
1293
1407
|
"never left unprotected. `stop_strategy` does NOT close the position; it "
|
|
1294
1408
|
"only ends the automation and leaves any position unmanaged, so use `close` "
|
|
1295
1409
|
"to actually exit, and `cancel_order` to kill an unfilled limit order. "
|
|
1296
|
-
"Check `validate_strategy` before creating anything with paper=False
|
|
1410
|
+
"Check `validate_strategy` before creating anything with paper=False, and "
|
|
1411
|
+
"`backtest` (read-only, real historical candles, the live engine) to see what "
|
|
1412
|
+
"a DSL would have done before deploying it. "
|
|
1297
1413
|
"Withdrawals are not available through this server."
|
|
1298
1414
|
)
|
|
1299
1415
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: talis-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Command-line client for the Talis trading platform. Sign in via device flow, manage sessions, view portfolio, place orders.
|
|
5
5
|
Author: Talis
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -187,14 +187,21 @@ shape) — `claude_desktop_config.json`:
|
|
|
187
187
|
}
|
|
188
188
|
```
|
|
189
189
|
|
|
190
|
-
Tools (
|
|
190
|
+
Tools (26):
|
|
191
191
|
|
|
192
|
-
`amend_strategy`, `balance`, `brief_latest`, `buy`, `cancel_all_orders`,
|
|
192
|
+
`amend_strategy`, `backtest`, `balance`, `brief_latest`, `buy`, `cancel_all_orders`,
|
|
193
193
|
`cancel_order`, `chat`, `close`, `create_strategy`, `history`,
|
|
194
194
|
`notifications_create`, `open_orders`, `outcomes`, `packets`, `place_order`,
|
|
195
195
|
`portfolio`, `research`, `sell`, `start_strategy`, `stop_strategy`,
|
|
196
196
|
`strategies`, `strategy`, `strategy_activity`, `validate_strategy`, `whoami`.
|
|
197
197
|
|
|
198
|
+
**Strategy workflow.** `validate_strategy(dsl)` checks a DSL without side
|
|
199
|
+
effects; `backtest(pair, dsl, days=30, interval="1h")` runs it through the live
|
|
200
|
+
engine on real historical candles (read-only) and returns return, Sharpe,
|
|
201
|
+
drawdown, trade count; `create_strategy(pair, dsl, paper=True)` runs it on the
|
|
202
|
+
simulated book; the same call with `paper=False, confirm_real_money=True`
|
|
203
|
+
deploys it live.
|
|
204
|
+
|
|
198
205
|
**Safety — read this before pointing an agent at it.** The trading tools
|
|
199
206
|
(`buy` / `sell` / `close` / `create_strategy`) execute with **REAL MONEY by
|
|
200
207
|
default** (`paper=False`). Pass `paper=True` to simulate instead.
|
|
@@ -439,7 +439,8 @@ class TestNotifications:
|
|
|
439
439
|
EXPECTED_TOOLS = [
|
|
440
440
|
"whoami", "portfolio", "balance", "research", "packets", "brief_latest",
|
|
441
441
|
"history", "open_orders", "strategies", "strategy", "strategy_activity",
|
|
442
|
-
"validate_strategy",
|
|
442
|
+
"validate_strategy",
|
|
443
|
+
"backtest", "outcomes",
|
|
443
444
|
"buy", "sell", "close", "place_order", "cancel_order", "cancel_all_orders",
|
|
444
445
|
"create_strategy", "amend_strategy", "start_strategy", "stop_strategy",
|
|
445
446
|
"chat", "notifications_create",
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""`backtest` over MCP: validate → fetch candles → POST /backtests (fast) → poll.
|
|
2
|
+
|
|
3
|
+
Pinned facts (verified against engine/api/routes/backtests.py + market.py):
|
|
4
|
+
* /candles answers abbreviated keys {t,o,h,l,c,v}; the backtester wants the
|
|
5
|
+
full names — the tool must map them or the run silently sees no data.
|
|
6
|
+
* fast mode requires `dsl`; the tool sends the DSL with its config block
|
|
7
|
+
completed (exchange + trading_pair), exactly what create_strategy sends.
|
|
8
|
+
* an INVALID DSL must never reach /backtests.
|
|
9
|
+
* cross-asset references ("ETH-USD.rsi_14") need their own candles.
|
|
10
|
+
* the tool is read-only: no order route is ever touched.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
import pytest
|
|
18
|
+
import respx
|
|
19
|
+
|
|
20
|
+
from talis.commands import mcp_server
|
|
21
|
+
|
|
22
|
+
BASE = "http://api.test"
|
|
23
|
+
DSL = {
|
|
24
|
+
"type": "position",
|
|
25
|
+
"config": {"leverage": 3},
|
|
26
|
+
"entry": {"trigger": {"indicator": {"indicator": "ETH-USD.rsi_14", "op": "<", "value": 30}},
|
|
27
|
+
"side": "buy", "amount": {"value": 100, "mode": "usd"}},
|
|
28
|
+
"exits": [{"name": "tp", "trigger": {"price_percent_from_entry": {"op": ">=", "value": 5}}}],
|
|
29
|
+
}
|
|
30
|
+
CANDLES = [{"t": 1_700_000_000_000 + i * 3_600_000, "o": 1, "h": 2, "l": 0.5, "c": 1.5, "v": 10}
|
|
31
|
+
for i in range(3)]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture(autouse=True)
|
|
35
|
+
def _no_sleep(monkeypatch):
|
|
36
|
+
monkeypatch.setattr(mcp_server.time, "sleep", lambda *_: None)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _routes(valid=True, statuses=("RUNNING", "COMPLETED")):
|
|
40
|
+
respx.post(f"{BASE}/strategies/validate").mock(
|
|
41
|
+
return_value=httpx.Response(200, json={"valid": valid, "errors": [] if valid else ["bad trigger"],
|
|
42
|
+
"human_readable_summary": "x"}))
|
|
43
|
+
respx.get(f"{BASE}/candles").mock(
|
|
44
|
+
return_value=httpx.Response(200, json={"candles": CANDLES, "symbol": "X", "interval": "1h"}))
|
|
45
|
+
submit = respx.post(f"{BASE}/backtests").mock(
|
|
46
|
+
return_value=httpx.Response(200, json={"backtest_id": "bt_1", "status": "RUNNING"}))
|
|
47
|
+
seq = [httpx.Response(200, json={"backtest_id": "bt_1", "status": st,
|
|
48
|
+
"results": ({"total_return_pct": 4.2, "sharpe_ratio": 1.1,
|
|
49
|
+
"max_drawdown_pct": -2.0, "total_trades": 7,
|
|
50
|
+
"profit_factor": 1.8, "extra": True}
|
|
51
|
+
if st == "COMPLETED" else None)})
|
|
52
|
+
for st in statuses]
|
|
53
|
+
respx.get(f"{BASE}/backtests/bt_1").mock(side_effect=seq)
|
|
54
|
+
return submit
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@respx.mock
|
|
58
|
+
def test_backtest_fetches_candles_normalises_keys_and_returns_summary(stored_creds):
|
|
59
|
+
submit = _routes()
|
|
60
|
+
out = mcp_server.backtest("BTC", DSL, days=1, interval="1h")
|
|
61
|
+
assert out["status"] == "COMPLETED" and out["backtest_id"] == "bt_1"
|
|
62
|
+
body = json.loads(submit.calls[0].request.content)
|
|
63
|
+
assert body["mode"] == "fast" and body["trading_pair"] == "BTC-USD"
|
|
64
|
+
assert body["dsl"]["config"]["trading_pair"] == "BTC-USD"
|
|
65
|
+
assert body["dsl"]["config"]["exchange"] == "hyperliquid_perpetual"
|
|
66
|
+
assert set(body["candles"][0]) == {"timestamp", "open", "high", "low", "close", "volume"}
|
|
67
|
+
assert body["start_ts"] == CANDLES[0]["t"] and body["end_ts"] == CANDLES[-1]["t"]
|
|
68
|
+
# cross-asset candles for the ETH-USD reference, never for the primary
|
|
69
|
+
assert set(body["cross_asset_candles"]) == {"ETH-USD"}
|
|
70
|
+
assert out["cross_assets"] == ["ETH-USD"]
|
|
71
|
+
assert out["summary"] == {"total_return_pct": 4.2, "sharpe_ratio": 1.1, "max_drawdown_pct": -2.0,
|
|
72
|
+
"total_trades": 7, "profit_factor": 1.8}
|
|
73
|
+
assert out["window"]["candles"] == 3 and out["window"]["capped_to_venue_window"] is False
|
|
74
|
+
assert "create_strategy" in out["next"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@respx.mock
|
|
78
|
+
def test_invalid_dsl_never_reaches_the_backtester(stored_creds):
|
|
79
|
+
submit = _routes(valid=False)
|
|
80
|
+
out = mcp_server.backtest("BTC", DSL, days=1)
|
|
81
|
+
assert out["valid"] is False and out["errors"] == ["bad trigger"]
|
|
82
|
+
assert not submit.called
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@respx.mock
|
|
86
|
+
def test_long_windows_are_capped_and_reported(stored_creds):
|
|
87
|
+
_routes()
|
|
88
|
+
out = mcp_server.backtest("BTC", DSL, days=365, interval="1m")
|
|
89
|
+
assert out["window"]["capped_to_venue_window"] is True
|
|
90
|
+
assert out["window"]["requested_days"] == 365
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@respx.mock
|
|
94
|
+
def test_still_running_returns_a_pointer_not_an_error(stored_creds):
|
|
95
|
+
_routes(statuses=("RUNNING", "RUNNING", "RUNNING"))
|
|
96
|
+
out = mcp_server.backtest("BTC", DSL, days=1, timeout_seconds=0)
|
|
97
|
+
assert out["status"] == "RUNNING" and "GET /backtests/bt_1" in out["note"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_bad_interval_is_rejected_before_any_network(stored_creds):
|
|
101
|
+
with pytest.raises(ValueError, match="interval"):
|
|
102
|
+
mcp_server.backtest("BTC", DSL, interval="7m")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_backtest_is_registered_and_read_only():
|
|
106
|
+
assert mcp_server.backtest in mcp_server.TOOL_FUNCTIONS
|
|
107
|
+
import inspect
|
|
108
|
+
assert "paper" not in inspect.signature(mcp_server.backtest).parameters
|
|
109
|
+
assert "confirm_real_money" not in inspect.signature(mcp_server.backtest).parameters
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|