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.
Files changed (73) hide show
  1. {talis_cli-0.2.1 → talis_cli-0.3.0}/PKG-INFO +10 -3
  2. {talis_cli-0.2.1 → talis_cli-0.3.0}/README.md +9 -2
  3. {talis_cli-0.2.1 → talis_cli-0.3.0}/pyproject.toml +1 -1
  4. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/api.py +21 -0
  5. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/mcp_server.py +117 -1
  6. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis_cli.egg-info/PKG-INFO +10 -3
  7. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis_cli.egg-info/SOURCES.txt +1 -0
  8. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_mcp.py +2 -1
  9. talis_cli-0.3.0/tests/test_mcp_backtest.py +109 -0
  10. {talis_cli-0.2.1 → talis_cli-0.3.0}/setup.cfg +0 -0
  11. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/__init__.py +0 -0
  12. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/_eip55.py +0 -0
  13. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/cli.py +0 -0
  14. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/__init__.py +0 -0
  15. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/_shared.py +0 -0
  16. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/auth.py +0 -0
  17. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/brief.py +0 -0
  18. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/chat.py +0 -0
  19. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/fund.py +0 -0
  20. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/guide.py +0 -0
  21. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/history.py +0 -0
  22. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/notifications.py +0 -0
  23. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/outcome.py +0 -0
  24. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/portfolio.py +0 -0
  25. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/research.py +0 -0
  26. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/snapshot.py +0 -0
  27. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/strategies.py +0 -0
  28. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/test_tenant.py +0 -0
  29. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/trade.py +0 -0
  30. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/wait.py +0 -0
  31. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/commands/withdraw.py +0 -0
  32. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/config.py +0 -0
  33. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/output.py +0 -0
  34. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/paper.py +0 -0
  35. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/spot.py +0 -0
  36. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis/symbols.py +0 -0
  37. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis_cli.egg-info/dependency_links.txt +0 -0
  38. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis_cli.egg-info/entry_points.txt +0 -0
  39. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis_cli.egg-info/requires.txt +0 -0
  40. {talis_cli-0.2.1 → talis_cli-0.3.0}/talis_cli.egg-info/top_level.txt +0 -0
  41. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_api.py +0 -0
  42. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_api_key_auth.py +0 -0
  43. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_brief.py +0 -0
  44. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_chat.py +0 -0
  45. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_commands.py +0 -0
  46. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_config.py +0 -0
  47. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_confirm_prompt.py +0 -0
  48. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_eip55.py +0 -0
  49. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_guide.py +0 -0
  50. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_history.py +0 -0
  51. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_login_flow.py +0 -0
  52. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_loopback_login.py +0 -0
  53. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_mcp_balance_contract.py +0 -0
  54. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_mcp_docstring_contract.py +0 -0
  55. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_mcp_mode_contract.py +0 -0
  56. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_mcp_sdk_compat.py +0 -0
  57. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_mcp_session_expiry.py +0 -0
  58. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_mcp_strategy_tools.py +0 -0
  59. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_non_interactive_auth.py +0 -0
  60. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_notifications.py +0 -0
  61. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_outcome.py +0 -0
  62. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_paper_mode.py +0 -0
  63. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_research.py +0 -0
  64. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_session_autorenew.py +0 -0
  65. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_signup.py +0 -0
  66. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_snapshot_diff.py +0 -0
  67. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_spot.py +0 -0
  68. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_spot_gateway_first.py +0 -0
  69. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_strategies_create.py +0 -0
  70. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_test_tenant.py +0 -0
  71. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_version_sync.py +0 -0
  72. {talis_cli-0.2.1 → talis_cli-0.3.0}/tests/test_wait.py +0 -0
  73. {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.2.1
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 (25):
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 (25):
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "talis-cli"
3
- version = "0.2.1"
3
+ version = "0.3.0"
4
4
  description = "Command-line client for the Talis trading platform. Sign in via device flow, manage sessions, view portfolio, place orders."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -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.2.1
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 (25):
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.
@@ -47,6 +47,7 @@ tests/test_history.py
47
47
  tests/test_login_flow.py
48
48
  tests/test_loopback_login.py
49
49
  tests/test_mcp.py
50
+ tests/test_mcp_backtest.py
50
51
  tests/test_mcp_balance_contract.py
51
52
  tests/test_mcp_docstring_contract.py
52
53
  tests/test_mcp_mode_contract.py
@@ -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", "outcomes",
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