talis-cli 0.2.0__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 (75) hide show
  1. {talis_cli-0.2.0 → talis_cli-0.3.0}/PKG-INFO +54 -10
  2. {talis_cli-0.2.0 → talis_cli-0.3.0}/README.md +53 -9
  3. {talis_cli-0.2.0 → talis_cli-0.3.0}/pyproject.toml +1 -1
  4. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/api.py +21 -0
  5. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/cli.py +66 -1
  6. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/mcp_server.py +180 -16
  7. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis_cli.egg-info/PKG-INFO +54 -10
  8. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis_cli.egg-info/SOURCES.txt +1 -0
  9. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_mcp.py +2 -1
  10. talis_cli-0.3.0/tests/test_mcp_backtest.py +109 -0
  11. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_mcp_docstring_contract.py +45 -0
  12. talis_cli-0.3.0/tests/test_mcp_session_expiry.py +276 -0
  13. talis_cli-0.3.0/tests/test_session_autorenew.py +286 -0
  14. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_version_sync.py +6 -1
  15. talis_cli-0.2.0/tests/test_mcp_session_expiry.py +0 -124
  16. talis_cli-0.2.0/tests/test_session_autorenew.py +0 -141
  17. {talis_cli-0.2.0 → talis_cli-0.3.0}/setup.cfg +0 -0
  18. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/__init__.py +0 -0
  19. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/_eip55.py +0 -0
  20. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/__init__.py +0 -0
  21. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/_shared.py +0 -0
  22. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/auth.py +0 -0
  23. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/brief.py +0 -0
  24. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/chat.py +0 -0
  25. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/fund.py +0 -0
  26. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/guide.py +0 -0
  27. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/history.py +0 -0
  28. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/notifications.py +0 -0
  29. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/outcome.py +0 -0
  30. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/portfolio.py +0 -0
  31. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/research.py +0 -0
  32. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/snapshot.py +0 -0
  33. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/strategies.py +0 -0
  34. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/test_tenant.py +0 -0
  35. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/trade.py +0 -0
  36. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/wait.py +0 -0
  37. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/withdraw.py +0 -0
  38. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/config.py +0 -0
  39. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/output.py +0 -0
  40. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/paper.py +0 -0
  41. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/spot.py +0 -0
  42. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/symbols.py +0 -0
  43. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis_cli.egg-info/dependency_links.txt +0 -0
  44. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis_cli.egg-info/entry_points.txt +0 -0
  45. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis_cli.egg-info/requires.txt +0 -0
  46. {talis_cli-0.2.0 → talis_cli-0.3.0}/talis_cli.egg-info/top_level.txt +0 -0
  47. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_api.py +0 -0
  48. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_api_key_auth.py +0 -0
  49. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_brief.py +0 -0
  50. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_chat.py +0 -0
  51. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_commands.py +0 -0
  52. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_config.py +0 -0
  53. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_confirm_prompt.py +0 -0
  54. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_eip55.py +0 -0
  55. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_guide.py +0 -0
  56. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_history.py +0 -0
  57. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_login_flow.py +0 -0
  58. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_loopback_login.py +0 -0
  59. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_mcp_balance_contract.py +0 -0
  60. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_mcp_mode_contract.py +0 -0
  61. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_mcp_sdk_compat.py +0 -0
  62. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_mcp_strategy_tools.py +0 -0
  63. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_non_interactive_auth.py +0 -0
  64. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_notifications.py +0 -0
  65. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_outcome.py +0 -0
  66. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_paper_mode.py +0 -0
  67. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_research.py +0 -0
  68. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_signup.py +0 -0
  69. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_snapshot_diff.py +0 -0
  70. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_spot.py +0 -0
  71. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_spot_gateway_first.py +0 -0
  72. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_strategies_create.py +0 -0
  73. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_test_tenant.py +0 -0
  74. {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_wait.py +0 -0
  75. {talis_cli-0.2.0 → 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.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
@@ -114,6 +114,27 @@ suitable for parsing from scripts or LLM agents. Force JSON anywhere with
114
114
  The CLI stores its session token at `~/.talis/credentials` with `chmod 600`
115
115
  on POSIX systems. The token is a JWT; treat it like any other credential.
116
116
 
117
+ ### Sessions renew themselves
118
+
119
+ You should not have to think about session expiry. When the CLI or the MCP
120
+ server runs with under 48 hours left on the clock, it renews in place against
121
+ `POST /auth/session/refresh` before doing its work — at most once every 15
122
+ minutes, so a burst of commands costs one renewal, not one each. Renewal
123
+ preserves the
124
+ session's **original grant**, not a fresh maximum — a token issued with a 24h
125
+ life renews to another 24h, never to something longer. That is deliberate: an
126
+ auto-renewing session must not be able to ratchet itself into a longer-lived
127
+ credential than the one you approved.
128
+
129
+ Renewal is best-effort and silent: if it cannot happen, your existing
130
+ credential is left untouched and the command still runs. What you WILL see is a
131
+ `session_warning` on MCP tool responses once renewal stops keeping up, or when
132
+ under six hours remain. If that appears and keeps counting down, renewal is
133
+ failing — run `talis login` to sign in again.
134
+
135
+ A steady `session_hours_remaining` with no `session_warning` is the healthy
136
+ state, not a countdown to worry about.
137
+
117
138
  ## Relationship to `cli/jarvis_cli.sh`
118
139
 
119
140
  The bash script at [`cli/jarvis_cli.sh`](../../cli/jarvis_cli.sh) is the legacy
@@ -142,12 +163,16 @@ your portfolio, pull Talis Research, place paper trades, create strategies,
142
163
  set price alerts, and talk to the Talis chat agent. Log in first
143
164
  (`talis login`); the server authenticates as the logged-in user.
144
165
 
145
- Add it to **Claude Code**:
166
+ Install and add it to **Claude Code**:
146
167
 
147
168
  ```bash
169
+ pip install talis-cli # or: uv tool install talis-cli
170
+ talis login # device-flow browser approval, once
148
171
  claude mcp add talis -- talis mcp
149
172
  ```
150
173
 
174
+ No local checkout required — `talis` is on PyPI.
175
+
151
176
  Or to **Claude Desktop** (and other clients using the `mcpServers` config
152
177
  shape) — `claude_desktop_config.json`:
153
178
 
@@ -162,14 +187,33 @@ shape) — `claude_desktop_config.json`:
162
187
  }
163
188
  ```
164
189
 
165
- Tools: `whoami`, `portfolio`, `balance`, `research`, `packets`,
166
- `brief_latest`, `history`, `buy`, `sell`, `close`, `create_strategy`, `chat`,
167
- `notifications_create`.
168
-
169
- **Safety**: the trading tools (`buy` / `sell` / `close` / `create_strategy`)
170
- default to `paper=True` — an isolated simulated book. An agent must pass
171
- `paper=False` explicitly to trade real money. Set `TALIS_PAPER=1` in the
172
- server's environment to force paper mode regardless of tool arguments.
190
+ Tools (26):
191
+
192
+ `amend_strategy`, `backtest`, `balance`, `brief_latest`, `buy`, `cancel_all_orders`,
193
+ `cancel_order`, `chat`, `close`, `create_strategy`, `history`,
194
+ `notifications_create`, `open_orders`, `outcomes`, `packets`, `place_order`,
195
+ `portfolio`, `research`, `sell`, `start_strategy`, `stop_strategy`,
196
+ `strategies`, `strategy`, `strategy_activity`, `validate_strategy`, `whoami`.
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
+
205
+ **Safety — read this before pointing an agent at it.** The trading tools
206
+ (`buy` / `sell` / `close` / `create_strategy`) execute with **REAL MONEY by
207
+ default** (`paper=False`). Pass `paper=True` to simulate instead.
208
+
209
+ `buy`, `sell` and `create_strategy` additionally refuse to spend real money
210
+ unless the caller passes `confirm_real_money=True`. `close` deliberately has no
211
+ such gate: a protective exit must never be blocked by a missing flag.
212
+ `place_order` (limit orders, spot, HIP-4 outcome markets) has **no paper mode
213
+ at all** and refuses without `confirm_real_money=True`.
214
+
215
+ Set `TALIS_PAPER=1` in the server's environment to force paper mode regardless
216
+ of tool arguments.
173
217
  Withdrawals and wallet creation are not exposed over MCP. For a strictly
174
218
  read-only agent, log in with `talis login --read-only` — the server rejects
175
219
  every mutation at the API layer.
@@ -86,6 +86,27 @@ suitable for parsing from scripts or LLM agents. Force JSON anywhere with
86
86
  The CLI stores its session token at `~/.talis/credentials` with `chmod 600`
87
87
  on POSIX systems. The token is a JWT; treat it like any other credential.
88
88
 
89
+ ### Sessions renew themselves
90
+
91
+ You should not have to think about session expiry. When the CLI or the MCP
92
+ server runs with under 48 hours left on the clock, it renews in place against
93
+ `POST /auth/session/refresh` before doing its work — at most once every 15
94
+ minutes, so a burst of commands costs one renewal, not one each. Renewal
95
+ preserves the
96
+ session's **original grant**, not a fresh maximum — a token issued with a 24h
97
+ life renews to another 24h, never to something longer. That is deliberate: an
98
+ auto-renewing session must not be able to ratchet itself into a longer-lived
99
+ credential than the one you approved.
100
+
101
+ Renewal is best-effort and silent: if it cannot happen, your existing
102
+ credential is left untouched and the command still runs. What you WILL see is a
103
+ `session_warning` on MCP tool responses once renewal stops keeping up, or when
104
+ under six hours remain. If that appears and keeps counting down, renewal is
105
+ failing — run `talis login` to sign in again.
106
+
107
+ A steady `session_hours_remaining` with no `session_warning` is the healthy
108
+ state, not a countdown to worry about.
109
+
89
110
  ## Relationship to `cli/jarvis_cli.sh`
90
111
 
91
112
  The bash script at [`cli/jarvis_cli.sh`](../../cli/jarvis_cli.sh) is the legacy
@@ -114,12 +135,16 @@ your portfolio, pull Talis Research, place paper trades, create strategies,
114
135
  set price alerts, and talk to the Talis chat agent. Log in first
115
136
  (`talis login`); the server authenticates as the logged-in user.
116
137
 
117
- Add it to **Claude Code**:
138
+ Install and add it to **Claude Code**:
118
139
 
119
140
  ```bash
141
+ pip install talis-cli # or: uv tool install talis-cli
142
+ talis login # device-flow browser approval, once
120
143
  claude mcp add talis -- talis mcp
121
144
  ```
122
145
 
146
+ No local checkout required — `talis` is on PyPI.
147
+
123
148
  Or to **Claude Desktop** (and other clients using the `mcpServers` config
124
149
  shape) — `claude_desktop_config.json`:
125
150
 
@@ -134,14 +159,33 @@ shape) — `claude_desktop_config.json`:
134
159
  }
135
160
  ```
136
161
 
137
- Tools: `whoami`, `portfolio`, `balance`, `research`, `packets`,
138
- `brief_latest`, `history`, `buy`, `sell`, `close`, `create_strategy`, `chat`,
139
- `notifications_create`.
140
-
141
- **Safety**: the trading tools (`buy` / `sell` / `close` / `create_strategy`)
142
- default to `paper=True` — an isolated simulated book. An agent must pass
143
- `paper=False` explicitly to trade real money. Set `TALIS_PAPER=1` in the
144
- server's environment to force paper mode regardless of tool arguments.
162
+ Tools (26):
163
+
164
+ `amend_strategy`, `backtest`, `balance`, `brief_latest`, `buy`, `cancel_all_orders`,
165
+ `cancel_order`, `chat`, `close`, `create_strategy`, `history`,
166
+ `notifications_create`, `open_orders`, `outcomes`, `packets`, `place_order`,
167
+ `portfolio`, `research`, `sell`, `start_strategy`, `stop_strategy`,
168
+ `strategies`, `strategy`, `strategy_activity`, `validate_strategy`, `whoami`.
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
+
177
+ **Safety — read this before pointing an agent at it.** The trading tools
178
+ (`buy` / `sell` / `close` / `create_strategy`) execute with **REAL MONEY by
179
+ default** (`paper=False`). Pass `paper=True` to simulate instead.
180
+
181
+ `buy`, `sell` and `create_strategy` additionally refuse to spend real money
182
+ unless the caller passes `confirm_real_money=True`. `close` deliberately has no
183
+ such gate: a protective exit must never be blocked by a missing flag.
184
+ `place_order` (limit orders, spot, HIP-4 outcome markets) has **no paper mode
185
+ at all** and refuses without `confirm_real_money=True`.
186
+
187
+ Set `TALIS_PAPER=1` in the server's environment to force paper mode regardless
188
+ of tool arguments.
145
189
  Withdrawals and wallet creation are not exposed over MCP. For a strictly
146
190
  read-only agent, log in with `talis login --read-only` — the server rejects
147
191
  every mutation at the API layer.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "talis-cli"
3
- version = "0.2.0"
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
 
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  import typer
15
15
 
16
- from . import __version__, output
16
+ from . import __version__, config, output
17
17
  from .commands import auth, mcp_server, trade
18
18
  from .commands import chat as chat_cmd
19
19
  from .commands import guide as guide_cmd
@@ -83,6 +83,71 @@ app.add_typer(fund_app, name="fund")
83
83
  app.command("deposit")(__import__("talis.commands.fund",fromlist=["deposit"]).deposit)
84
84
 
85
85
 
86
+ # Commands that must NEVER trigger a renewal.
87
+ # login/signup/approve — there is no session yet to renew.
88
+ # logout — we are about to revoke the session we hold; renewing it first is
89
+ # wasted work and muddies the audit trail.
90
+ # version — must stay answerable with no credentials and no network at all.
91
+ _NO_RENEW = frozenset({"login", "signup", "approve", "logout", "version"})
92
+
93
+ # Minimum gap between renewal attempts across separate CLI invocations.
94
+ _RENEW_COOLDOWN_SECONDS = 900.0
95
+
96
+
97
+ def _renewed_recently() -> bool:
98
+ """True when the stored credential was rewritten inside the cooldown.
99
+
100
+ WHY A COOLDOWN AT ALL. ensure_fresh fires whenever under 48h remain, but a
101
+ session's grant is 24h — permanently under that threshold. Without a gate,
102
+ wiring renewal into the CLI would mint a new JWT and pay a network
103
+ round-trip on EVERY command, forever.
104
+
105
+ WHY MTIME rather than a new state file. The MCP server uses an in-memory
106
+ cooldown, which cannot work here: a CLI process is born and dies per
107
+ command. The credentials file's mtime is ALREADY an exact record of the
108
+ last SUCCESSFUL renewal (renewal rewrites it via atomic replace), so use
109
+ that instead of inventing a second source of truth that could disagree
110
+ with the first.
111
+
112
+ A FAILED renewal leaves mtime untouched, so a broken renewal keeps being
113
+ retried rather than being silenced by its own failure — the behaviour we
114
+ want, obtained for free.
115
+ """
116
+ import os
117
+ import time as _t
118
+ try:
119
+ age = _t.time() - os.stat(str(config.credentials_path())).st_mtime
120
+ except OSError:
121
+ return False # unreadable/absent: let ensure_fresh decide
122
+ # A negative age means the clock moved; treat it as recent so a skewed
123
+ # machine backs off rather than renewing in a tight loop.
124
+ return age < _RENEW_COOLDOWN_SECONDS
125
+
126
+
127
+ @app.callback()
128
+ def _keep_session_alive(ctx: typer.Context) -> None:
129
+ """Renew a short-lived session before running the command.
130
+
131
+ WHY THIS EXISTS: renewal was wired into the MCP server only, so a human
132
+ running `talis portfolio` every day still watched their session run out —
133
+ the CLI shipped the renewal code and never called it. The README promised
134
+ otherwise. This is the code catching up to the claim.
135
+
136
+ Best-effort BY CONTRACT: any failure leaves the stored credential exactly
137
+ as it was and the command runs anyway. config.ensure_fresh also no-ops
138
+ without touching the network unless the session is genuinely short, so this
139
+ costs nothing on the overwhelming majority of invocations.
140
+ """
141
+ if ctx.invoked_subcommand in _NO_RENEW or _renewed_recently():
142
+ return
143
+ try:
144
+ creds = config.load()
145
+ if creds:
146
+ config.ensure_fresh(creds)
147
+ except Exception:
148
+ pass
149
+
150
+
86
151
  @app.command("version")
87
152
  def version(json_: bool = typer.Option(False, "--json", help="JSON output.")) -> None:
88
153
  """Print the CLI version."""
@@ -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
@@ -64,9 +67,31 @@ class NotLoggedInError(RuntimeError):
64
67
  # warning, because the tool being used is the only one guaranteed to be seen.
65
68
  _SESSION_WARN_HOURS = 48.0
66
69
 
70
+ # Below this the session is genuinely at risk and we warn however healthy
71
+ # renewal looks. Above it, a session that is renewing on schedule is NOT news.
72
+ #
73
+ # WHY THIS SECOND TIER EXISTS: /auth/session/refresh deliberately carries the
74
+ # ORIGINAL grant rather than issuing a fresh maximum-length one — a token minted
75
+ # with a 24h life renews to 24h, never to 7d. That is the token-lifetime ceiling
76
+ # working as designed. But it means a perfectly self-renewing session sits
77
+ # permanently at ~24h, which is permanently under a 48h warn floor. Warning on
78
+ # that alone emitted SESSION EXPIRES on every single tool response forever — one
79
+ # second after a successful renewal included. And because the warning is
80
+ # deliberately hoisted to the FRONT of the dict, it was the first thing the model
81
+ # read on every call. A warning that always fires is one the reader learns to
82
+ # skip, including on the day renewal actually breaks.
83
+ _SESSION_CRITICAL_HOURS = 6.0
84
+
85
+ # What the last renewal attempt actually ACHIEVED. Written by _maybe_renew, read
86
+ # below to tell "short grant, renewing fine" apart from "renewal is broken".
87
+ # Starts "unknown" and "unknown" warns: absence of evidence that renewal works
88
+ # is not evidence that it does.
89
+ _RENEW_OUTCOME = ["unknown"] # unknown | renewed | not_needed | failed
90
+ _RENEW_HEALTHY = ("renewed", "not_needed")
91
+
67
92
 
68
93
  def _session_expiry_warning(creds) -> dict:
69
- """{} when the session is comfortably alive, else a loud actionable dict."""
94
+ """{} when the session is alive AND renewing, else a loud actionable dict."""
70
95
  raw = getattr(creds, "expires_at", None)
71
96
  if not raw:
72
97
  return {}
@@ -84,17 +109,21 @@ def _session_expiry_warning(creds) -> dict:
84
109
  if hours <= 0:
85
110
  return {"session_expired": True,
86
111
  "session_warning": (
87
- f"SESSION EXPIRED at {raw}. Every call will now fail. Renew with "
88
- "`talis_token_refresh.py --force` (admin key), or a human "
89
- "can run `talis login`.")}
90
- return {"session_hours_remaining": round(hours, 1),
112
+ f"SESSION EXPIRED at {raw}. Every call will now fail. "
113
+ "Automatic renewal did not save it — run `talis login` to "
114
+ "sign in again.")}
115
+ # Always REPORT the number; raise the ALARM only when renewal is not holding
116
+ # the line. Readers key off `session_warning`, never off this field.
117
+ remaining = {"session_hours_remaining": round(hours, 1)}
118
+ if hours > _SESSION_CRITICAL_HOURS and _RENEW_OUTCOME[0] in _RENEW_HEALTHY:
119
+ return remaining
120
+ return {**remaining,
91
121
  "session_warning": (
92
- f"SESSION EXPIRES IN {hours:.1f} HOURS ({raw}). The token itself carries no "
93
- "refresh token, but tools/talis-cli/talis_token_refresh.py "
94
- "re-mints it via POST /auth/tokens and is installed as a "
95
- "LaunchAgent (com.talis.token-refresh, every 6h, renews under "
96
- "8h remaining). If this warning keeps counting down below 8h, "
97
- "that job is NOT running — check ~/.talis/token-refresh.log.")}
122
+ f"SESSION EXPIRES IN {hours:.1f} HOURS ({raw}). This client "
123
+ "renews itself against POST /auth/session/refresh as you use "
124
+ "it, but the last attempt did not extend the session. If this "
125
+ "keeps counting down, renewal is failing — run `talis login` "
126
+ "to sign in again before it expires.")}
98
127
 
99
128
 
100
129
  _LAST_RENEW_ATTEMPT = [0.0]
@@ -111,7 +140,9 @@ def _maybe_renew() -> None:
111
140
  and config.ensure_fresh itself no-ops unless the session is actually short.
112
141
 
113
142
  Silent by contract: a renewal that cannot happen must never turn into a
114
- tool error. The session_warning that follows is what tells the reader.
143
+ tool error. The session_warning that follows is what tells the reader — and
144
+ it can only tell the truth if this records what actually happened, so every
145
+ exit path here writes _RENEW_OUTCOME.
115
146
  """
116
147
  import time as _t
117
148
 
@@ -121,10 +152,30 @@ def _maybe_renew() -> None:
121
152
  _LAST_RENEW_ATTEMPT[0] = now
122
153
  try:
123
154
  creds = config.load()
124
- if creds:
125
- config.ensure_fresh(creds)
155
+ if not creds:
156
+ _RENEW_OUTCOME[0] = "unknown"
157
+ return
158
+ before_token = getattr(creds, "token", None)
159
+ before_hours = config.hours_remaining(creds)
160
+ fresh = config.ensure_fresh(creds)
161
+ after_token = getattr(fresh, "token", None)
162
+ if after_token and after_token != before_token:
163
+ # A DIFFERENT token came back — renewal demonstrably worked. Keyed
164
+ # on token identity, not on elapsed hours: comparing expiries means
165
+ # comparing floats across a wall-clock read, and a no-op would drift
166
+ # below its own start value on any slow call.
167
+ _RENEW_OUTCOME[0] = "renewed"
168
+ elif before_hours is None or before_hours > config.REFRESH_UNDER_HOURS:
169
+ # ensure_fresh no-ops above the threshold, and for API-key creds
170
+ # which carry no expiry at all. Nothing was attempted, so nothing
171
+ # failed — do not cry wolf.
172
+ _RENEW_OUTCOME[0] = "not_needed"
173
+ else:
174
+ # Under the threshold and the token did not change: renewal was
175
+ # attempted and did not take. THIS is the state worth shouting at.
176
+ _RENEW_OUTCOME[0] = "failed"
126
177
  except Exception:
127
- pass
178
+ _RENEW_OUTCOME[0] = "failed"
128
179
 
129
180
 
130
181
  def _with_session_warning(fn):
@@ -708,6 +759,116 @@ def close(symbol: str, paper: bool = False) -> dict:
708
759
  return resp
709
760
 
710
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
+
711
872
  def create_strategy(pair: str, dsl: dict, paper: bool = False,
712
873
  confirm_real_money: bool = False) -> dict:
713
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.
@@ -1211,6 +1372,7 @@ TOOL_FUNCTIONS = (
1211
1372
  strategy,
1212
1373
  strategy_activity,
1213
1374
  validate_strategy,
1375
+ backtest,
1214
1376
  outcomes,
1215
1377
  buy,
1216
1378
  sell,
@@ -1245,7 +1407,9 @@ _SERVER_INSTRUCTIONS = (
1245
1407
  "never left unprotected. `stop_strategy` does NOT close the position; it "
1246
1408
  "only ends the automation and leaves any position unmanaged, so use `close` "
1247
1409
  "to actually exit, and `cancel_order` to kill an unfilled limit order. "
1248
- "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. "
1249
1413
  "Withdrawals are not available through this server."
1250
1414
  )
1251
1415
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: talis-cli
3
- Version: 0.2.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
@@ -114,6 +114,27 @@ suitable for parsing from scripts or LLM agents. Force JSON anywhere with
114
114
  The CLI stores its session token at `~/.talis/credentials` with `chmod 600`
115
115
  on POSIX systems. The token is a JWT; treat it like any other credential.
116
116
 
117
+ ### Sessions renew themselves
118
+
119
+ You should not have to think about session expiry. When the CLI or the MCP
120
+ server runs with under 48 hours left on the clock, it renews in place against
121
+ `POST /auth/session/refresh` before doing its work — at most once every 15
122
+ minutes, so a burst of commands costs one renewal, not one each. Renewal
123
+ preserves the
124
+ session's **original grant**, not a fresh maximum — a token issued with a 24h
125
+ life renews to another 24h, never to something longer. That is deliberate: an
126
+ auto-renewing session must not be able to ratchet itself into a longer-lived
127
+ credential than the one you approved.
128
+
129
+ Renewal is best-effort and silent: if it cannot happen, your existing
130
+ credential is left untouched and the command still runs. What you WILL see is a
131
+ `session_warning` on MCP tool responses once renewal stops keeping up, or when
132
+ under six hours remain. If that appears and keeps counting down, renewal is
133
+ failing — run `talis login` to sign in again.
134
+
135
+ A steady `session_hours_remaining` with no `session_warning` is the healthy
136
+ state, not a countdown to worry about.
137
+
117
138
  ## Relationship to `cli/jarvis_cli.sh`
118
139
 
119
140
  The bash script at [`cli/jarvis_cli.sh`](../../cli/jarvis_cli.sh) is the legacy
@@ -142,12 +163,16 @@ your portfolio, pull Talis Research, place paper trades, create strategies,
142
163
  set price alerts, and talk to the Talis chat agent. Log in first
143
164
  (`talis login`); the server authenticates as the logged-in user.
144
165
 
145
- Add it to **Claude Code**:
166
+ Install and add it to **Claude Code**:
146
167
 
147
168
  ```bash
169
+ pip install talis-cli # or: uv tool install talis-cli
170
+ talis login # device-flow browser approval, once
148
171
  claude mcp add talis -- talis mcp
149
172
  ```
150
173
 
174
+ No local checkout required — `talis` is on PyPI.
175
+
151
176
  Or to **Claude Desktop** (and other clients using the `mcpServers` config
152
177
  shape) — `claude_desktop_config.json`:
153
178
 
@@ -162,14 +187,33 @@ shape) — `claude_desktop_config.json`:
162
187
  }
163
188
  ```
164
189
 
165
- Tools: `whoami`, `portfolio`, `balance`, `research`, `packets`,
166
- `brief_latest`, `history`, `buy`, `sell`, `close`, `create_strategy`, `chat`,
167
- `notifications_create`.
168
-
169
- **Safety**: the trading tools (`buy` / `sell` / `close` / `create_strategy`)
170
- default to `paper=True` — an isolated simulated book. An agent must pass
171
- `paper=False` explicitly to trade real money. Set `TALIS_PAPER=1` in the
172
- server's environment to force paper mode regardless of tool arguments.
190
+ Tools (26):
191
+
192
+ `amend_strategy`, `backtest`, `balance`, `brief_latest`, `buy`, `cancel_all_orders`,
193
+ `cancel_order`, `chat`, `close`, `create_strategy`, `history`,
194
+ `notifications_create`, `open_orders`, `outcomes`, `packets`, `place_order`,
195
+ `portfolio`, `research`, `sell`, `start_strategy`, `stop_strategy`,
196
+ `strategies`, `strategy`, `strategy_activity`, `validate_strategy`, `whoami`.
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
+
205
+ **Safety — read this before pointing an agent at it.** The trading tools
206
+ (`buy` / `sell` / `close` / `create_strategy`) execute with **REAL MONEY by
207
+ default** (`paper=False`). Pass `paper=True` to simulate instead.
208
+
209
+ `buy`, `sell` and `create_strategy` additionally refuse to spend real money
210
+ unless the caller passes `confirm_real_money=True`. `close` deliberately has no
211
+ such gate: a protective exit must never be blocked by a missing flag.
212
+ `place_order` (limit orders, spot, HIP-4 outcome markets) has **no paper mode
213
+ at all** and refuses without `confirm_real_money=True`.
214
+
215
+ Set `TALIS_PAPER=1` in the server's environment to force paper mode regardless
216
+ of tool arguments.
173
217
  Withdrawals and wallet creation are not exposed over MCP. For a strictly
174
218
  read-only agent, log in with `talis login --read-only` — the server rejects
175
219
  every mutation at the API layer.
@@ -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