filtrix-cli 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.
- filtrix_cli/AGENTS.md +47 -0
- filtrix_cli/__init__.py +1 -0
- filtrix_cli/__main__.py +60 -0
- filtrix_cli/auth.py +76 -0
- filtrix_cli/client.py +132 -0
- filtrix_cli/commands/__init__.py +0 -0
- filtrix_cli/commands/_helpers.py +44 -0
- filtrix_cli/commands/account.py +9 -0
- filtrix_cli/commands/authcmd.py +166 -0
- filtrix_cli/commands/backtest.py +25 -0
- filtrix_cli/commands/configcmd.py +40 -0
- filtrix_cli/commands/filters.py +21 -0
- filtrix_cli/commands/keys.py +29 -0
- filtrix_cli/commands/llms.py +12 -0
- filtrix_cli/commands/screen.py +22 -0
- filtrix_cli/commands/ticker.py +11 -0
- filtrix_cli/config.py +86 -0
- filtrix_cli/filters.py +98 -0
- filtrix_cli/output.py +56 -0
- filtrix_cli/render.py +120 -0
- filtrix_cli/state.py +30 -0
- filtrix_cli-0.1.0.dist-info/METADATA +80 -0
- filtrix_cli-0.1.0.dist-info/RECORD +26 -0
- filtrix_cli-0.1.0.dist-info/WHEEL +4 -0
- filtrix_cli-0.1.0.dist-info/entry_points.txt +2 -0
- filtrix_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
filtrix_cli/AGENTS.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# filtrix-cli — agent guide
|
|
2
|
+
|
|
3
|
+
Structured command-line access to the FILTRIX historical backtest engine.
|
|
4
|
+
|
|
5
|
+
## Output contract
|
|
6
|
+
- Add `--json` (or pipe stdout) to get JSON on stdout. Human tables otherwise.
|
|
7
|
+
- Data goes to stdout; errors/progress go to stderr.
|
|
8
|
+
- Errors (stderr, JSON): {"error":{"type","message","status","retryable","suggestion","input"}}.
|
|
9
|
+
|
|
10
|
+
## Exit codes
|
|
11
|
+
- 0 success
|
|
12
|
+
- 1 server/unexpected error (retryable)
|
|
13
|
+
- 2 usage / invalid filter
|
|
14
|
+
- 3 not found
|
|
15
|
+
- 4 authentication required/failed
|
|
16
|
+
- 5 rate limited (retryable)
|
|
17
|
+
- 6 network/timeout (retryable)
|
|
18
|
+
|
|
19
|
+
## Auth
|
|
20
|
+
- Reads/backtests: set env FILTRIX_API_KEY=flt_... (or `filtrix config set api_key ...`).
|
|
21
|
+
- Key management: `filtrix auth login` first (stores a JWT).
|
|
22
|
+
|
|
23
|
+
## Commands
|
|
24
|
+
- filtrix backtest --filter "COL OP VAL" [--filter ...] [--outcome ...] [--start YYYY-MM-DD] [--end YYYY-MM-DD]
|
|
25
|
+
- filtrix screen --filter ... [--limit N] [--offset N]
|
|
26
|
+
- filtrix ticker SYMBOL
|
|
27
|
+
- filtrix filters [--search TEXT] [--type number|date|string]
|
|
28
|
+
- filtrix account
|
|
29
|
+
- filtrix keys list|create|revoke
|
|
30
|
+
- filtrix auth login|logout|status
|
|
31
|
+
- filtrix auth register --email E --password P --accept-tos (emails a 6-digit code, then prompts for it, then auto-logs-in)
|
|
32
|
+
- filtrix auth verify-code CODE --email E --password P (resume a pending signup)
|
|
33
|
+
- filtrix auth resend-code --email E
|
|
34
|
+
- filtrix config get|set|list|path
|
|
35
|
+
|
|
36
|
+
Note: global flags like `--json` go BEFORE the subcommand (e.g. `filtrix --json backtest ...`).
|
|
37
|
+
|
|
38
|
+
## Filter DSL
|
|
39
|
+
"COL OP VAL", operators: >= <= != = > < like in. Repeat --filter for AND.
|
|
40
|
+
Examples: "gap_gup>=10", "price_price<5", "earnings_date_days_earningd=0.25", "sic_code in 7372,7389".
|
|
41
|
+
Use the RAW schema column names from `filtrix filters` (e.g. price_price, float_shares_float) — these
|
|
42
|
+
can differ from screen's friendlier output field names (price, float_shares). Unknown columns return exit 2.
|
|
43
|
+
For exact control, pass `--filters-json '[{"column":...,"operator":...,"value":...,"value_type":...}]'`
|
|
44
|
+
or `--filters-json -` to read the JSON array from stdin.
|
|
45
|
+
|
|
46
|
+
## Discover filters
|
|
47
|
+
Run `filtrix filters --json` to enumerate valid columns, value types, and operators.
|
filtrix_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
filtrix_cli/__main__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from filtrix_cli import __version__
|
|
4
|
+
from filtrix_cli.state import set_state
|
|
5
|
+
from filtrix_cli.commands import backtest as backtest_cmd
|
|
6
|
+
from filtrix_cli.commands import screen as screen_cmd
|
|
7
|
+
from filtrix_cli.commands import ticker as ticker_cmd
|
|
8
|
+
from filtrix_cli.commands import filters as filters_cmd
|
|
9
|
+
from filtrix_cli.commands import account as account_cmd
|
|
10
|
+
from filtrix_cli.commands import keys as keys_cmd
|
|
11
|
+
from filtrix_cli.commands import authcmd
|
|
12
|
+
from filtrix_cli.commands import configcmd
|
|
13
|
+
from filtrix_cli.commands import llms as llms_cmd
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
add_completion=False,
|
|
17
|
+
help="FILTRIX CLI — terminal access to the FILTRIX backtest engine.",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.callback(invoke_without_command=True)
|
|
22
|
+
def _root(
|
|
23
|
+
ctx: typer.Context,
|
|
24
|
+
json: bool = typer.Option(False, "--json", help="Force JSON output to stdout."),
|
|
25
|
+
quiet: bool = typer.Option(False, "--quiet", "-q", help="Bare values only."),
|
|
26
|
+
no_color: bool = typer.Option(False, "--no-color", help="Disable colored output."),
|
|
27
|
+
profile: str = typer.Option(None, "--profile", help="Config profile to use."),
|
|
28
|
+
api_key: str = typer.Option(None, "--api-key", help="Override API key for this run."),
|
|
29
|
+
base_url: str = typer.Option(None, "--base-url", help="Override API base URL for this run."),
|
|
30
|
+
) -> None:
|
|
31
|
+
"""FILTRIX CLI — terminal access to the FILTRIX backtest engine."""
|
|
32
|
+
set_state(json=json, quiet=quiet, no_color=no_color,
|
|
33
|
+
profile=profile, api_key=api_key, base_url=base_url)
|
|
34
|
+
if ctx.invoked_subcommand is None:
|
|
35
|
+
typer.echo(ctx.get_help())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.command()
|
|
39
|
+
def version() -> None:
|
|
40
|
+
"""Print the CLI version."""
|
|
41
|
+
typer.echo(f"filtrix-cli {__version__}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
app.command("backtest")(backtest_cmd.backtest)
|
|
45
|
+
app.command("screen")(screen_cmd.screen)
|
|
46
|
+
app.command("ticker")(ticker_cmd.ticker)
|
|
47
|
+
app.command("filters")(filters_cmd.filters)
|
|
48
|
+
app.command("account")(account_cmd.account)
|
|
49
|
+
app.add_typer(keys_cmd.app, name="keys")
|
|
50
|
+
app.add_typer(authcmd.app, name="auth")
|
|
51
|
+
app.add_typer(configcmd.app, name="config")
|
|
52
|
+
app.command("llms")(llms_cmd.llms)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main() -> None:
|
|
56
|
+
app()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
main()
|
filtrix_cli/auth.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Interactive login flows: direct, Telegram-2FA poll, and backup code."""
|
|
2
|
+
import time
|
|
3
|
+
from typing import Callable, Optional
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from filtrix_cli import __version__
|
|
8
|
+
from filtrix_cli.output import CLIError
|
|
9
|
+
|
|
10
|
+
_POLL_DEADLINE_SECONDS = 300
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _ua() -> dict:
|
|
14
|
+
return {"User-Agent": f"filtrix-cli/{__version__}", "X-Agent-Request": "true"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def login(
|
|
18
|
+
base_url: str,
|
|
19
|
+
email: str,
|
|
20
|
+
password: str,
|
|
21
|
+
*,
|
|
22
|
+
poll_interval: float = 2.0,
|
|
23
|
+
on_pending: Optional[Callable[[str, Optional[str]], None]] = None,
|
|
24
|
+
) -> str:
|
|
25
|
+
"""Return a JWT. Handles direct login and Telegram 2FA. Raises CLIError
|
|
26
|
+
(type 'backup_code_required') when only a backup code can proceed."""
|
|
27
|
+
base = base_url.rstrip("/")
|
|
28
|
+
with httpx.Client(timeout=30) as client:
|
|
29
|
+
resp = client.post(f"{base}/api/auth/login", data={"username": email, "password": password}, headers=_ua())
|
|
30
|
+
if resp.status_code == 200:
|
|
31
|
+
return resp.json()["access_token"]
|
|
32
|
+
if resp.status_code == 400:
|
|
33
|
+
raise CLIError("auth_failed", "Incorrect email or password.", status=400, exit_code=4)
|
|
34
|
+
if resp.status_code == 202:
|
|
35
|
+
body = resp.json()
|
|
36
|
+
required = body.get("auth_required")
|
|
37
|
+
if required == "backup_code_only":
|
|
38
|
+
raise CLIError("backup_code_required",
|
|
39
|
+
"2FA is on but Telegram is unlinked. Re-run with --backup-code.",
|
|
40
|
+
status=202, exit_code=4)
|
|
41
|
+
if required == "telegram":
|
|
42
|
+
pending_id = body["pending_id"]
|
|
43
|
+
if on_pending:
|
|
44
|
+
on_pending(pending_id, body.get("expires_at"))
|
|
45
|
+
return _poll_login(client, base, pending_id, poll_interval)
|
|
46
|
+
raise CLIError("auth_failed", f"Unexpected auth_required: {required}", status=202, exit_code=4)
|
|
47
|
+
raise CLIError("server_error", f"Login failed (HTTP {resp.status_code}).",
|
|
48
|
+
status=resp.status_code, exit_code=1)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _poll_login(client: httpx.Client, base: str, pending_id: str, interval: float) -> str:
|
|
52
|
+
deadline = time.time() + _POLL_DEADLINE_SECONDS
|
|
53
|
+
while time.time() < deadline:
|
|
54
|
+
status = client.get(f"{base}/api/auth/login/status/{pending_id}", headers=_ua()).json().get("status")
|
|
55
|
+
if status == "approved":
|
|
56
|
+
done = client.post(f"{base}/api/auth/login/complete/{pending_id}", headers=_ua())
|
|
57
|
+
if done.status_code == 200:
|
|
58
|
+
return done.json()["access_token"]
|
|
59
|
+
raise CLIError("auth_failed", f"Could not finalize login (HTTP {done.status_code}).",
|
|
60
|
+
status=done.status_code, exit_code=4)
|
|
61
|
+
if status == "denied":
|
|
62
|
+
raise CLIError("auth_failed", "Login was denied from Telegram.", exit_code=4)
|
|
63
|
+
if status == "expired":
|
|
64
|
+
raise CLIError("auth_failed", "Login approval expired. Try again.", exit_code=4)
|
|
65
|
+
time.sleep(interval)
|
|
66
|
+
raise CLIError("auth_failed", "Timed out waiting for Telegram approval.", retryable=True, exit_code=6)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def login_backup_code(base_url: str, email: str, password: str, code: str) -> str:
|
|
70
|
+
base = base_url.rstrip("/")
|
|
71
|
+
with httpx.Client(timeout=30) as client:
|
|
72
|
+
resp = client.post(f"{base}/api/auth/login/backup-code",
|
|
73
|
+
json={"email": email, "password": password, "code": code}, headers=_ua())
|
|
74
|
+
if resp.status_code == 200:
|
|
75
|
+
return resp.json()["access_token"]
|
|
76
|
+
raise CLIError("auth_failed", "Backup-code login failed.", status=resp.status_code, exit_code=4)
|
filtrix_cli/client.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Synchronous httpx client for the FILTRIX API, with error -> CLIError mapping."""
|
|
2
|
+
import json
|
|
3
|
+
from typing import Any, List, Optional
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from filtrix_cli import __version__
|
|
8
|
+
from filtrix_cli.output import CLIError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _safe_detail(resp: httpx.Response) -> str:
|
|
12
|
+
try:
|
|
13
|
+
data = resp.json()
|
|
14
|
+
except Exception:
|
|
15
|
+
return resp.text or f"HTTP {resp.status_code}"
|
|
16
|
+
if isinstance(data, dict) and "detail" in data:
|
|
17
|
+
detail = data["detail"]
|
|
18
|
+
return detail if isinstance(detail, str) else json.dumps(detail)
|
|
19
|
+
return json.dumps(data)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FiltrixClient:
|
|
23
|
+
def __init__(self, base_url: str, api_key: Optional[str] = None,
|
|
24
|
+
jwt: Optional[str] = None, timeout: float = 60.0):
|
|
25
|
+
self.base_url = base_url.rstrip("/")
|
|
26
|
+
self.api_key = api_key
|
|
27
|
+
self.jwt = jwt
|
|
28
|
+
self.timeout = timeout
|
|
29
|
+
|
|
30
|
+
def _headers(self, auth: str) -> dict:
|
|
31
|
+
"""auth in {'any', 'jwt', 'none'}."""
|
|
32
|
+
headers = {
|
|
33
|
+
"User-Agent": f"filtrix-cli/{__version__}",
|
|
34
|
+
"X-Agent-Request": "true",
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
}
|
|
37
|
+
if auth == "none":
|
|
38
|
+
return headers
|
|
39
|
+
if auth == "jwt":
|
|
40
|
+
if not self.jwt:
|
|
41
|
+
raise CLIError("auth_failed", "This command requires login. Run `filtrix auth login`.",
|
|
42
|
+
suggestion="filtrix auth login", exit_code=4)
|
|
43
|
+
headers["Authorization"] = f"Bearer {self.jwt}"
|
|
44
|
+
return headers
|
|
45
|
+
# auth == "any"
|
|
46
|
+
if self.api_key:
|
|
47
|
+
headers["X-API-Key"] = self.api_key
|
|
48
|
+
elif self.jwt:
|
|
49
|
+
headers["Authorization"] = f"Bearer {self.jwt}"
|
|
50
|
+
else:
|
|
51
|
+
raise CLIError("auth_failed", "No credentials. Set FILTRIX_API_KEY or run `filtrix auth login`.",
|
|
52
|
+
suggestion="filtrix auth login", exit_code=4)
|
|
53
|
+
return headers
|
|
54
|
+
|
|
55
|
+
def _request(self, method: str, path: str, *, json_body: Any = None, auth: str = "any") -> Any:
|
|
56
|
+
url = f"{self.base_url}{path}"
|
|
57
|
+
headers = self._headers(auth)
|
|
58
|
+
try:
|
|
59
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
60
|
+
resp = client.request(method, url, headers=headers, json=json_body)
|
|
61
|
+
except httpx.TimeoutException as e:
|
|
62
|
+
raise CLIError("network", f"Request timed out: {e}", retryable=True, exit_code=6)
|
|
63
|
+
except httpx.HTTPError as e:
|
|
64
|
+
raise CLIError("network", f"Network error: {e}", retryable=True, exit_code=6)
|
|
65
|
+
return self._handle_response(resp)
|
|
66
|
+
|
|
67
|
+
def _handle_response(self, resp: httpx.Response) -> Any:
|
|
68
|
+
if resp.status_code < 400:
|
|
69
|
+
return resp.json() if resp.content else {}
|
|
70
|
+
detail = _safe_detail(resp)
|
|
71
|
+
code = resp.status_code
|
|
72
|
+
if code in (401, 403):
|
|
73
|
+
raise CLIError("auth_failed", detail, status=code,
|
|
74
|
+
suggestion="Run `filtrix auth login` or check your API key.", exit_code=4)
|
|
75
|
+
if code == 404:
|
|
76
|
+
raise CLIError("not_found", detail, status=404, exit_code=3)
|
|
77
|
+
if code == 429:
|
|
78
|
+
raise CLIError("rate_limited", detail, status=429, retryable=True,
|
|
79
|
+
suggestion="Run `filtrix account` to see your quota.", exit_code=5)
|
|
80
|
+
if 400 <= code < 500:
|
|
81
|
+
raise CLIError("invalid_request", detail, status=code, exit_code=2)
|
|
82
|
+
raise CLIError("server_error", detail, status=code, retryable=True, exit_code=1)
|
|
83
|
+
|
|
84
|
+
# ---- Public API v1 ----
|
|
85
|
+
def backtest(self, filters: List[dict], outcome: str = "day_close_position",
|
|
86
|
+
flat_threshold: float = 0.002, start_date: Optional[str] = None,
|
|
87
|
+
end_date: Optional[str] = None) -> Any:
|
|
88
|
+
body: dict = {"filters": filters, "outcome": outcome, "flat_threshold": flat_threshold}
|
|
89
|
+
if start_date:
|
|
90
|
+
body["start_date"] = start_date
|
|
91
|
+
if end_date:
|
|
92
|
+
body["end_date"] = end_date
|
|
93
|
+
return self._request("POST", "/api/v1/backtest", json_body=body)
|
|
94
|
+
|
|
95
|
+
def screen(self, filters: List[dict], limit: int = 50, offset: int = 0) -> Any:
|
|
96
|
+
return self._request("POST", "/api/v1/screen",
|
|
97
|
+
json_body={"filters": filters, "limit": limit, "offset": offset})
|
|
98
|
+
|
|
99
|
+
def ticker_info(self, symbol: str) -> Any:
|
|
100
|
+
return self._request("GET", f"/api/v1/ticker/{symbol.upper()}")
|
|
101
|
+
|
|
102
|
+
def list_filters(self) -> Any:
|
|
103
|
+
return self._request("GET", "/api/v1/filters", auth="none")
|
|
104
|
+
|
|
105
|
+
def account(self) -> Any:
|
|
106
|
+
return self._request("GET", "/api/v1/account")
|
|
107
|
+
|
|
108
|
+
# ---- Key management (JWT) ----
|
|
109
|
+
def keys_list(self) -> Any:
|
|
110
|
+
return self._request("GET", "/api/keys", auth="jwt")
|
|
111
|
+
|
|
112
|
+
def keys_create(self, name: str = "default") -> Any:
|
|
113
|
+
return self._request("POST", "/api/keys", json_body={"name": name}, auth="jwt")
|
|
114
|
+
|
|
115
|
+
def keys_revoke(self, key_id: int) -> Any:
|
|
116
|
+
return self._request("DELETE", f"/api/keys/{key_id}", auth="jwt")
|
|
117
|
+
|
|
118
|
+
# ---- Registration (pre-auth) ----
|
|
119
|
+
def register(self, email: str, password: str, delivery: str = "code",
|
|
120
|
+
ref_source: Optional[str] = None) -> Any:
|
|
121
|
+
body: dict = {"email": email, "password": password, "delivery": delivery}
|
|
122
|
+
if ref_source:
|
|
123
|
+
body["ref_data"] = {"ref_source": ref_source}
|
|
124
|
+
return self._request("POST", "/api/auth/register", json_body=body, auth="none")
|
|
125
|
+
|
|
126
|
+
def verify_email_code(self, email: str, code: str) -> Any:
|
|
127
|
+
return self._request("POST", "/api/auth/verify-email-code",
|
|
128
|
+
json_body={"email": email, "code": code}, auth="none")
|
|
129
|
+
|
|
130
|
+
def resend_code(self, email: str) -> Any:
|
|
131
|
+
return self._request("POST", "/api/auth/resend-code",
|
|
132
|
+
json_body={"email": email}, auth="none")
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Shared plumbing for Typer commands: error handling, client construction, output."""
|
|
2
|
+
import functools
|
|
3
|
+
from typing import Any, Callable, Tuple
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from filtrix_cli import config as config_mod
|
|
8
|
+
from filtrix_cli.client import FiltrixClient
|
|
9
|
+
from filtrix_cli.output import CLIError, emit_error, emit_json, is_json_mode
|
|
10
|
+
from filtrix_cli.render import render_human
|
|
11
|
+
from filtrix_cli.state import get_state
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def command(func: Callable) -> Callable:
|
|
15
|
+
"""Wrap a Typer command: catch CLIError, emit it, exit with its code.
|
|
16
|
+
functools.wraps keeps the original signature so Typer still sees the params."""
|
|
17
|
+
@functools.wraps(func)
|
|
18
|
+
def wrapper(*args, **kwargs):
|
|
19
|
+
json_mode = is_json_mode(get_state().json)
|
|
20
|
+
try:
|
|
21
|
+
return func(*args, **kwargs)
|
|
22
|
+
except CLIError as err:
|
|
23
|
+
emit_error(err, json_mode)
|
|
24
|
+
raise typer.Exit(err.exit_code)
|
|
25
|
+
return wrapper
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_client() -> Tuple[FiltrixClient, dict]:
|
|
29
|
+
state = get_state()
|
|
30
|
+
settings = config_mod.resolve_settings(
|
|
31
|
+
profile=state.profile, api_key=state.api_key, base_url=state.base_url
|
|
32
|
+
)
|
|
33
|
+
client = FiltrixClient(
|
|
34
|
+
base_url=settings["base_url"], api_key=settings["api_key"], jwt=settings["jwt"]
|
|
35
|
+
)
|
|
36
|
+
return client, settings
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def output_result(result: Any, *, kind: str) -> None:
|
|
40
|
+
state = get_state()
|
|
41
|
+
if is_json_mode(state.json):
|
|
42
|
+
emit_json(result)
|
|
43
|
+
else:
|
|
44
|
+
render_human(result, kind=kind, quiet=state.quiet, no_color=state.no_color)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from filtrix_cli.commands._helpers import build_client, command, output_result
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@command
|
|
5
|
+
def account() -> None:
|
|
6
|
+
"""Show your tier and remaining daily quota."""
|
|
7
|
+
client, _ = build_client()
|
|
8
|
+
result = client.account()
|
|
9
|
+
output_result(result, kind="account")
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from filtrix_cli import auth as auth_flow
|
|
7
|
+
from filtrix_cli import config as config_mod
|
|
8
|
+
from filtrix_cli.commands._helpers import build_client, command, output_result
|
|
9
|
+
from filtrix_cli.output import CLIError
|
|
10
|
+
from filtrix_cli.state import get_state
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(no_args_is_help=True, help="Authenticate and manage your session.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _active_profile() -> str:
|
|
16
|
+
return get_state().profile or "default"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command("login")
|
|
20
|
+
@command
|
|
21
|
+
def login(
|
|
22
|
+
email: Optional[str] = typer.Option(None, "--email", help="Account email (prompted if omitted)."),
|
|
23
|
+
password: Optional[str] = typer.Option(None, "--password", help="Account password (prompted if omitted)."),
|
|
24
|
+
backup_code: bool = typer.Option(False, "--backup-code", help="Use a 2FA backup code instead of Telegram."),
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Log in and store a JWT in your config profile."""
|
|
27
|
+
settings = config_mod.resolve_settings(profile=_active_profile(),
|
|
28
|
+
base_url=get_state().base_url)
|
|
29
|
+
base_url = settings["base_url"]
|
|
30
|
+
email = email or typer.prompt("Email")
|
|
31
|
+
password = password or typer.prompt("Password", hide_input=True)
|
|
32
|
+
profile = settings["profile"]
|
|
33
|
+
|
|
34
|
+
if backup_code:
|
|
35
|
+
code = typer.prompt("Backup code")
|
|
36
|
+
token = auth_flow.login_backup_code(base_url, email, password, code)
|
|
37
|
+
else:
|
|
38
|
+
def _notify(pending_id: str, expires_at):
|
|
39
|
+
typer.echo("Approve this login in your Telegram app...", err=True)
|
|
40
|
+
try:
|
|
41
|
+
token = auth_flow.login(base_url, email, password, on_pending=_notify)
|
|
42
|
+
except CLIError as err:
|
|
43
|
+
if err.error_type == "backup_code_required":
|
|
44
|
+
code = typer.prompt("2FA backup code")
|
|
45
|
+
token = auth_flow.login_backup_code(base_url, email, password, code)
|
|
46
|
+
else:
|
|
47
|
+
raise
|
|
48
|
+
|
|
49
|
+
config_mod.set_value(profile, "jwt", token)
|
|
50
|
+
config_mod.set_value(profile, "email", email)
|
|
51
|
+
output_result({"logged_in": True, "email": email, "profile": profile}, kind="account")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command("logout")
|
|
55
|
+
@command
|
|
56
|
+
def logout() -> None:
|
|
57
|
+
"""Remove the stored JWT from the active profile."""
|
|
58
|
+
profile = _active_profile()
|
|
59
|
+
data = config_mod.load_config()
|
|
60
|
+
table = data.get("default", {}) if profile == "default" else data.get("profile", {}).get(profile, {})
|
|
61
|
+
table.pop("jwt", None)
|
|
62
|
+
table.pop("email", None)
|
|
63
|
+
config_mod.save_config(data)
|
|
64
|
+
output_result({"logged_out": True, "profile": profile}, kind="account")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command("status")
|
|
68
|
+
@command
|
|
69
|
+
def status() -> None:
|
|
70
|
+
"""Show whether you are logged in and which credentials resolve."""
|
|
71
|
+
settings = config_mod.resolve_settings(
|
|
72
|
+
profile=_active_profile(), api_key=get_state().api_key, base_url=get_state().base_url)
|
|
73
|
+
output_result({
|
|
74
|
+
"logged_in": bool(settings["jwt"]),
|
|
75
|
+
"has_api_key": bool(settings["api_key"]),
|
|
76
|
+
"email": settings["email"],
|
|
77
|
+
"profile": settings["profile"],
|
|
78
|
+
"base_url": settings["base_url"],
|
|
79
|
+
}, kind="account")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command("register")
|
|
83
|
+
@command
|
|
84
|
+
def register(
|
|
85
|
+
email: Optional[str] = typer.Option(None, "--email", help="Account email (prompted if omitted)."),
|
|
86
|
+
password: Optional[str] = typer.Option(None, "--password", help="Account password (prompted if omitted)."),
|
|
87
|
+
accept_tos: bool = typer.Option(False, "--accept-tos", help="Accept the Terms of Service (https://filtrix.net/terms)."),
|
|
88
|
+
ref_source: Optional[str] = typer.Option(None, "--ref-source", help="Optional acquisition source tag."),
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Create a FILTRIX account from the terminal (emailed 6-digit code), then auto-login."""
|
|
91
|
+
if not accept_tos:
|
|
92
|
+
raise CLIError(
|
|
93
|
+
"tos_required",
|
|
94
|
+
"You must accept the Terms of Service to register. "
|
|
95
|
+
"Re-run with --accept-tos (https://filtrix.net/terms).",
|
|
96
|
+
exit_code=2,
|
|
97
|
+
)
|
|
98
|
+
settings = config_mod.resolve_settings(profile=_active_profile(), base_url=get_state().base_url)
|
|
99
|
+
base_url, profile = settings["base_url"], settings["profile"]
|
|
100
|
+
email = email or typer.prompt("Email")
|
|
101
|
+
password = password or typer.prompt("Password", hide_input=True)
|
|
102
|
+
|
|
103
|
+
client, _ = build_client()
|
|
104
|
+
client.register(email, password, delivery="code", ref_source=ref_source)
|
|
105
|
+
typer.echo(f"We emailed a 6-digit code to {email}.", err=True)
|
|
106
|
+
typer.echo("Code: ", err=True, nl=False)
|
|
107
|
+
code = sys.stdin.readline().strip()
|
|
108
|
+
try:
|
|
109
|
+
client.verify_email_code(email, code)
|
|
110
|
+
except CLIError as err:
|
|
111
|
+
if err.error_type == "auth_failed":
|
|
112
|
+
raise CLIError(
|
|
113
|
+
"invalid_code",
|
|
114
|
+
f"That code is invalid or expired. Re-check it, or run "
|
|
115
|
+
f"`filtrix auth resend-code --email {email}` for a new one.",
|
|
116
|
+
exit_code=4,
|
|
117
|
+
)
|
|
118
|
+
raise
|
|
119
|
+
|
|
120
|
+
token = auth_flow.login(base_url, email, password)
|
|
121
|
+
config_mod.set_value(profile, "jwt", token)
|
|
122
|
+
config_mod.set_value(profile, "email", email)
|
|
123
|
+
output_result({"registered": True, "logged_in": True, "email": email, "profile": profile}, kind="account")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@app.command("verify-code")
|
|
127
|
+
@command
|
|
128
|
+
def verify_code(
|
|
129
|
+
code: str = typer.Argument(..., help="The 6-digit code emailed to you."),
|
|
130
|
+
email: Optional[str] = typer.Option(None, "--email", help="Account email (prompted if omitted)."),
|
|
131
|
+
password: Optional[str] = typer.Option(None, "--password", help="Account password (prompted if omitted)."),
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Verify a pending account with its emailed code, then auto-login."""
|
|
134
|
+
settings = config_mod.resolve_settings(profile=_active_profile(), base_url=get_state().base_url)
|
|
135
|
+
base_url, profile = settings["base_url"], settings["profile"]
|
|
136
|
+
email = email or typer.prompt("Email")
|
|
137
|
+
password = password or typer.prompt("Password", hide_input=True)
|
|
138
|
+
|
|
139
|
+
client, _ = build_client()
|
|
140
|
+
try:
|
|
141
|
+
client.verify_email_code(email, code)
|
|
142
|
+
except CLIError as err:
|
|
143
|
+
if err.error_type == "auth_failed":
|
|
144
|
+
raise CLIError(
|
|
145
|
+
"invalid_code",
|
|
146
|
+
f"That code is invalid or expired. Re-check it, or run "
|
|
147
|
+
f"`filtrix auth resend-code --email {email}` for a new one.",
|
|
148
|
+
exit_code=4,
|
|
149
|
+
)
|
|
150
|
+
raise
|
|
151
|
+
token = auth_flow.login(base_url, email, password)
|
|
152
|
+
config_mod.set_value(profile, "jwt", token)
|
|
153
|
+
config_mod.set_value(profile, "email", email)
|
|
154
|
+
output_result({"verified": True, "logged_in": True, "email": email, "profile": profile}, kind="account")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@app.command("resend-code")
|
|
158
|
+
@command
|
|
159
|
+
def resend_code(
|
|
160
|
+
email: Optional[str] = typer.Option(None, "--email", help="Account email (prompted if omitted)."),
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Request a fresh verification code by email."""
|
|
163
|
+
email = email or typer.prompt("Email")
|
|
164
|
+
client, _ = build_client()
|
|
165
|
+
client.resend_code(email)
|
|
166
|
+
output_result({"resent": True, "email": email}, kind="account")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from typing import List, Optional
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from filtrix_cli.commands._helpers import build_client, command, output_result
|
|
6
|
+
from filtrix_cli.filters import resolve_filters
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@command
|
|
10
|
+
def backtest(
|
|
11
|
+
filter: Optional[List[str]] = typer.Option(None, "--filter", "-f",
|
|
12
|
+
help='Filter "COL OP VAL" (repeatable). E.g. -f "gap_gup>=10".'),
|
|
13
|
+
filters_json: Optional[str] = typer.Option(None, "--filters-json",
|
|
14
|
+
help="JSON array of filter objects, or '-' to read from stdin."),
|
|
15
|
+
outcome: str = typer.Option("day_close_position", "--outcome", help="Outcome to analyse."),
|
|
16
|
+
start: Optional[str] = typer.Option(None, "--start", help="Start date YYYY-MM-DD."),
|
|
17
|
+
end: Optional[str] = typer.Option(None, "--end", help="End date YYYY-MM-DD."),
|
|
18
|
+
flat_threshold: float = typer.Option(0.002, "--flat-threshold", help="Flat/range threshold."),
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Run a historical backtest and print summary statistics."""
|
|
21
|
+
filters = resolve_filters(filter, filters_json)
|
|
22
|
+
client, _ = build_client()
|
|
23
|
+
result = client.backtest(filters, outcome=outcome, start_date=start,
|
|
24
|
+
end_date=end, flat_threshold=flat_threshold)
|
|
25
|
+
output_result(result, kind="backtest")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from filtrix_cli import config as config_mod
|
|
4
|
+
from filtrix_cli.commands._helpers import command, output_result
|
|
5
|
+
from filtrix_cli.state import get_state
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(no_args_is_help=True, help="Inspect and edit CLI config.")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _profile() -> str:
|
|
11
|
+
return get_state().profile or "default"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("get")
|
|
15
|
+
@command
|
|
16
|
+
def get_cmd(key: str = typer.Argument(..., help="Config key.")) -> None:
|
|
17
|
+
"""Print a single config value from the active profile."""
|
|
18
|
+
output_result({"key": key, "value": config_mod.get_value(_profile(), key)}, kind="raw")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command("set")
|
|
22
|
+
@command
|
|
23
|
+
def set_cmd(key: str = typer.Argument(...), value: str = typer.Argument(...)) -> None:
|
|
24
|
+
"""Set a config value in the active profile."""
|
|
25
|
+
config_mod.set_value(_profile(), key, value)
|
|
26
|
+
output_result({"key": key, "value": value, "profile": _profile()}, kind="raw")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command("list")
|
|
30
|
+
@command
|
|
31
|
+
def list_cmd() -> None:
|
|
32
|
+
"""Print the whole config file as JSON."""
|
|
33
|
+
output_result(config_mod.load_config(), kind="raw")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command("path")
|
|
37
|
+
@command
|
|
38
|
+
def path_cmd() -> None:
|
|
39
|
+
"""Print the config file path."""
|
|
40
|
+
typer.echo(str(config_mod.config_path()))
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from filtrix_cli.commands._helpers import build_client, command, output_result
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@command
|
|
9
|
+
def filters(
|
|
10
|
+
search: Optional[str] = typer.Option(None, "--search", help="Substring filter on column name."),
|
|
11
|
+
type: Optional[str] = typer.Option(None, "--type", help="Filter by value_type (number/date/string/time)."),
|
|
12
|
+
) -> None:
|
|
13
|
+
"""List available filter columns (no authentication required)."""
|
|
14
|
+
client, _ = build_client()
|
|
15
|
+
result = client.list_filters()
|
|
16
|
+
cols = result.get("columns", []) or []
|
|
17
|
+
if search:
|
|
18
|
+
cols = [c for c in cols if search.lower() in c.get("column", "").lower()]
|
|
19
|
+
if type:
|
|
20
|
+
cols = [c for c in cols if c.get("value_type") == type]
|
|
21
|
+
output_result({"total": len(cols), "columns": cols}, kind="filters")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from filtrix_cli.commands._helpers import build_client, command, output_result
|
|
4
|
+
|
|
5
|
+
app = typer.Typer(no_args_is_help=True, help="Manage API keys (requires login).")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@app.command("list")
|
|
9
|
+
@command
|
|
10
|
+
def list_keys() -> None:
|
|
11
|
+
"""List your API keys (prefixes only)."""
|
|
12
|
+
client, _ = build_client()
|
|
13
|
+
output_result(client.keys_list(), kind="keys")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.command("create")
|
|
17
|
+
@command
|
|
18
|
+
def create_key(name: str = typer.Option("default", "--name", help="A label for the key.")) -> None:
|
|
19
|
+
"""Create a new API key. The full key is shown once."""
|
|
20
|
+
client, _ = build_client()
|
|
21
|
+
output_result(client.keys_create(name), kind="keys")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command("revoke")
|
|
25
|
+
@command
|
|
26
|
+
def revoke_key(key_id: int = typer.Argument(..., help="ID of the key to revoke.")) -> None:
|
|
27
|
+
"""Revoke an API key by ID."""
|
|
28
|
+
client, _ = build_client()
|
|
29
|
+
output_result(client.keys_revoke(key_id), kind="keys")
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from importlib import resources
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from filtrix_cli.commands._helpers import command
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@command
|
|
9
|
+
def llms() -> None:
|
|
10
|
+
"""Print the agent-facing usage guide (AGENTS.md) to stdout."""
|
|
11
|
+
doc = resources.files("filtrix_cli").joinpath("AGENTS.md").read_text(encoding="utf-8")
|
|
12
|
+
typer.echo(doc)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from typing import List, Optional
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from filtrix_cli.commands._helpers import build_client, command, output_result
|
|
6
|
+
from filtrix_cli.filters import resolve_filters
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@command
|
|
10
|
+
def screen(
|
|
11
|
+
filter: Optional[List[str]] = typer.Option(None, "--filter", "-f",
|
|
12
|
+
help='Filter "COL OP VAL" (repeatable).'),
|
|
13
|
+
filters_json: Optional[str] = typer.Option(None, "--filters-json",
|
|
14
|
+
help="JSON array of filter objects, or '-' to read from stdin."),
|
|
15
|
+
limit: int = typer.Option(50, "--limit", help="Max results (1-200)."),
|
|
16
|
+
offset: int = typer.Option(0, "--offset", help="Pagination offset."),
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Screen historical events matching the filters."""
|
|
19
|
+
filters = resolve_filters(filter, filters_json)
|
|
20
|
+
client, _ = build_client()
|
|
21
|
+
result = client.screen(filters, limit=limit, offset=offset)
|
|
22
|
+
output_result(result, kind="screen")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from filtrix_cli.commands._helpers import build_client, command, output_result
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@command
|
|
7
|
+
def ticker(symbol: str = typer.Argument(..., help="Ticker symbol, e.g. AAPL.")) -> None:
|
|
8
|
+
"""Show metadata for a ticker."""
|
|
9
|
+
client, _ = build_client()
|
|
10
|
+
result = client.ticker_info(symbol)
|
|
11
|
+
output_result(result, kind="ticker")
|
filtrix_cli/config.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""TOML config with named profiles and flag > env > file precedence."""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
import tomllib # Python 3.11+
|
|
7
|
+
except ModuleNotFoundError: # pragma: no cover - 3.10 fallback
|
|
8
|
+
import tomli as tomllib
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
import platformdirs
|
|
12
|
+
import tomli_w
|
|
13
|
+
|
|
14
|
+
APP_NAME = "filtrix"
|
|
15
|
+
DEFAULT_BASE_URL = "https://filtrix.net"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def config_path() -> Path:
|
|
19
|
+
return Path(platformdirs.user_config_dir(APP_NAME)) / "config.toml"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_config() -> dict:
|
|
23
|
+
path = config_path()
|
|
24
|
+
if not path.exists():
|
|
25
|
+
return {}
|
|
26
|
+
with path.open("rb") as f:
|
|
27
|
+
return tomllib.load(f)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def save_config(data: dict) -> None:
|
|
31
|
+
path = config_path()
|
|
32
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
with path.open("wb") as f:
|
|
34
|
+
tomli_w.dump(data, f)
|
|
35
|
+
try:
|
|
36
|
+
os.chmod(path, 0o600) # best-effort; Windows relies on profile ACL
|
|
37
|
+
except (OSError, NotImplementedError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _profile_table(data: dict, profile: str) -> dict:
|
|
42
|
+
if profile == "default":
|
|
43
|
+
return data.get("default", {})
|
|
44
|
+
return data.get("profile", {}).get(profile, {})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_value(profile: str, key: str) -> Any:
|
|
48
|
+
return _profile_table(load_config(), profile).get(key)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def set_value(profile: str, key: str, value: Any) -> None:
|
|
52
|
+
data = load_config()
|
|
53
|
+
if profile == "default":
|
|
54
|
+
data.setdefault("default", {})[key] = value
|
|
55
|
+
else:
|
|
56
|
+
data.setdefault("profile", {}).setdefault(profile, {})[key] = value
|
|
57
|
+
save_config(data)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_settings(
|
|
61
|
+
profile: Optional[str] = None,
|
|
62
|
+
api_key: Optional[str] = None,
|
|
63
|
+
base_url: Optional[str] = None,
|
|
64
|
+
) -> dict:
|
|
65
|
+
"""Resolve effective settings. Precedence: explicit arg > env var > config file."""
|
|
66
|
+
profile = profile or os.environ.get("FILTRIX_PROFILE", "default")
|
|
67
|
+
table = _profile_table(load_config(), profile)
|
|
68
|
+
|
|
69
|
+
resolved_base = (
|
|
70
|
+
base_url
|
|
71
|
+
or os.environ.get("FILTRIX_BASE_URL")
|
|
72
|
+
or table.get("base_url")
|
|
73
|
+
or DEFAULT_BASE_URL
|
|
74
|
+
)
|
|
75
|
+
resolved_key = (
|
|
76
|
+
api_key
|
|
77
|
+
or os.environ.get("FILTRIX_API_KEY")
|
|
78
|
+
or table.get("api_key")
|
|
79
|
+
)
|
|
80
|
+
return {
|
|
81
|
+
"profile": profile,
|
|
82
|
+
"base_url": resolved_base.rstrip("/"),
|
|
83
|
+
"api_key": resolved_key,
|
|
84
|
+
"jwt": table.get("jwt"),
|
|
85
|
+
"email": table.get("email"),
|
|
86
|
+
}
|
filtrix_cli/filters.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Parse the --filter mini-DSL ("COL OP VAL") into FilterCondition dicts."""
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from filtrix_cli.output import CLIError
|
|
8
|
+
|
|
9
|
+
_TWO_CHAR = {">=", "<=", "!="}
|
|
10
|
+
_ONE_CHAR = {">", "<", "="}
|
|
11
|
+
_WORD_OP_RE = re.compile(r"^(?P<col>\w+)\s+(?P<op>like|in)\s+(?P<val>.+)$", re.IGNORECASE)
|
|
12
|
+
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def infer_value_type(value: str) -> str:
|
|
16
|
+
v = value.strip()
|
|
17
|
+
if _DATE_RE.match(v):
|
|
18
|
+
return "date"
|
|
19
|
+
try:
|
|
20
|
+
float(v)
|
|
21
|
+
return "number"
|
|
22
|
+
except ValueError:
|
|
23
|
+
return "string"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _find_symbol_op(s: str):
|
|
27
|
+
"""Return (index, operator) of the first symbol operator, longest-match
|
|
28
|
+
at that position; or None."""
|
|
29
|
+
i = 0
|
|
30
|
+
while i < len(s):
|
|
31
|
+
pair = s[i:i + 2]
|
|
32
|
+
if pair in _TWO_CHAR:
|
|
33
|
+
return i, pair
|
|
34
|
+
if s[i] in _ONE_CHAR:
|
|
35
|
+
return i, s[i]
|
|
36
|
+
i += 1
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_filter(expr: str) -> dict:
|
|
41
|
+
s = expr.strip()
|
|
42
|
+
if not s:
|
|
43
|
+
raise CLIError("invalid_filter", "Empty filter expression.", exit_code=2)
|
|
44
|
+
|
|
45
|
+
word = _WORD_OP_RE.match(s)
|
|
46
|
+
if word:
|
|
47
|
+
col = word.group("col")
|
|
48
|
+
op = word.group("op").lower()
|
|
49
|
+
val = word.group("val").strip()
|
|
50
|
+
vtype = "string" if op == "like" else infer_value_type(val.split(",")[0])
|
|
51
|
+
return {"column": col, "operator": op, "value": val, "value_type": vtype}
|
|
52
|
+
|
|
53
|
+
found = _find_symbol_op(s)
|
|
54
|
+
if not found:
|
|
55
|
+
raise CLIError(
|
|
56
|
+
"invalid_filter",
|
|
57
|
+
f'Could not find an operator in "{expr}". '
|
|
58
|
+
'Use one of >= <= != = > < like in.',
|
|
59
|
+
input={"filter": expr},
|
|
60
|
+
exit_code=2,
|
|
61
|
+
)
|
|
62
|
+
idx, op = found
|
|
63
|
+
col = s[:idx].strip()
|
|
64
|
+
val = s[idx + len(op):].strip()
|
|
65
|
+
if not col:
|
|
66
|
+
raise CLIError("invalid_filter", f'Missing column in "{expr}".', input={"filter": expr}, exit_code=2)
|
|
67
|
+
if not val:
|
|
68
|
+
raise CLIError("invalid_filter", f'Missing value in "{expr}".', input={"filter": expr}, exit_code=2)
|
|
69
|
+
if set(val) <= set("><!="):
|
|
70
|
+
raise CLIError("invalid_filter", f'Malformed value in "{expr}".', input={"filter": expr}, exit_code=2)
|
|
71
|
+
return {"column": col, "operator": op, "value": val, "value_type": infer_value_type(val)}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_filters(exprs: List[str]) -> List[dict]:
|
|
75
|
+
return [parse_filter(e) for e in exprs]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_filters_json(arg: str) -> List[dict]:
|
|
79
|
+
raw = sys.stdin.read() if arg == "-" else arg
|
|
80
|
+
try:
|
|
81
|
+
data = json.loads(raw)
|
|
82
|
+
except json.JSONDecodeError as e:
|
|
83
|
+
raise CLIError("invalid_filter", f"Invalid --filters-json: {e}", exit_code=2)
|
|
84
|
+
if not isinstance(data, list):
|
|
85
|
+
raise CLIError("invalid_filter", "--filters-json must be a JSON array.", exit_code=2)
|
|
86
|
+
return data
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def resolve_filters(filter_exprs: Optional[List[str]], filters_json: Optional[str]) -> List[dict]:
|
|
90
|
+
"""Pick exactly one input source and return a list of filter dicts."""
|
|
91
|
+
has_dsl = bool(filter_exprs)
|
|
92
|
+
if filters_json is not None and has_dsl:
|
|
93
|
+
raise CLIError("invalid_filter", "Use either --filter or --filters-json, not both.", exit_code=2)
|
|
94
|
+
if filters_json is not None:
|
|
95
|
+
return load_filters_json(filters_json)
|
|
96
|
+
if not has_dsl:
|
|
97
|
+
raise CLIError("invalid_filter", "At least one --filter (or --filters-json) is required.", exit_code=2)
|
|
98
|
+
return parse_filters(filter_exprs)
|
filtrix_cli/output.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Framework-agnostic output + error primitives. No Typer/Rich imports here."""
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CLIError(Exception):
|
|
8
|
+
"""A user-facing error with a machine-readable type and an exit code."""
|
|
9
|
+
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
error_type: str,
|
|
13
|
+
message: str,
|
|
14
|
+
*,
|
|
15
|
+
status: Optional[int] = None,
|
|
16
|
+
retryable: bool = False,
|
|
17
|
+
suggestion: Optional[str] = None,
|
|
18
|
+
input: Optional[Any] = None,
|
|
19
|
+
exit_code: int = 1,
|
|
20
|
+
):
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.error_type = error_type
|
|
23
|
+
self.message = message
|
|
24
|
+
self.status = status
|
|
25
|
+
self.retryable = retryable
|
|
26
|
+
self.suggestion = suggestion
|
|
27
|
+
self.input = input
|
|
28
|
+
self.exit_code = exit_code
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict:
|
|
31
|
+
err: dict = {"type": self.error_type, "message": self.message, "retryable": self.retryable}
|
|
32
|
+
if self.status is not None:
|
|
33
|
+
err["status"] = self.status
|
|
34
|
+
if self.suggestion:
|
|
35
|
+
err["suggestion"] = self.suggestion
|
|
36
|
+
if self.input is not None:
|
|
37
|
+
err["input"] = self.input
|
|
38
|
+
return {"error": err}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_json_mode(json_flag: bool) -> bool:
|
|
42
|
+
"""JSON when explicitly requested or when stdout is not a TTY (piped)."""
|
|
43
|
+
return json_flag or not sys.stdout.isatty()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def emit_json(data: Any) -> None:
|
|
47
|
+
sys.stdout.write(json.dumps(data, default=str) + "\n")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def emit_error(err: CLIError, json_mode: bool) -> None:
|
|
51
|
+
if json_mode:
|
|
52
|
+
sys.stderr.write(json.dumps(err.to_dict(), default=str) + "\n")
|
|
53
|
+
else:
|
|
54
|
+
sys.stderr.write(f"Error [{err.error_type}]: {err.message}\n")
|
|
55
|
+
if err.suggestion:
|
|
56
|
+
sys.stderr.write(f" -> {err.suggestion}\n")
|
filtrix_cli/render.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Rich human-readable renderers. Only used when not in JSON mode."""
|
|
2
|
+
import json
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
_console = Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def render_human(result: Any, *, kind: str, quiet: bool = False, no_color: bool = False) -> None:
|
|
12
|
+
global _console
|
|
13
|
+
_console = Console(no_color=no_color)
|
|
14
|
+
renderer = _RENDERERS.get(kind, _render_fallback)
|
|
15
|
+
renderer(result, quiet)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _render_fallback(result: Any, quiet: bool) -> None:
|
|
19
|
+
_console.print_json(data=result)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _render_backtest(result: dict, quiet: bool) -> None:
|
|
23
|
+
n = result.get("n", 0)
|
|
24
|
+
if quiet:
|
|
25
|
+
_console.print(n)
|
|
26
|
+
return
|
|
27
|
+
summary = result.get("summary", {}) or {}
|
|
28
|
+
table = Table(title=f"Backtest — n={n}")
|
|
29
|
+
table.add_column("metric")
|
|
30
|
+
table.add_column("value", justify="right")
|
|
31
|
+
for key in ("mean", "median", "p05", "p25", "p75", "p95",
|
|
32
|
+
"long_pct", "short_pct", "flat_pct", "close_up_pct", "close_down_pct"):
|
|
33
|
+
if key in summary:
|
|
34
|
+
table.add_row(key, str(summary[key]))
|
|
35
|
+
_console.print(table)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _render_screen(result: dict, quiet: bool) -> None:
|
|
39
|
+
rows = result.get("results", []) or []
|
|
40
|
+
if quiet:
|
|
41
|
+
for row in rows:
|
|
42
|
+
_console.print(row.get("symbol", ""))
|
|
43
|
+
return
|
|
44
|
+
table = Table(title=f"Screen — {result.get('total', len(rows))} match(es)")
|
|
45
|
+
for col in ("date", "symbol", "name", "price", "gap_pct", "dollar_volume", "float_shares", "relative_volume"):
|
|
46
|
+
table.add_column(col)
|
|
47
|
+
for row in rows:
|
|
48
|
+
table.add_row(*[("" if row.get(c) is None else str(row.get(c))) for c in
|
|
49
|
+
("date", "symbol", "name", "price", "gap_pct", "dollar_volume", "float_shares", "relative_volume")])
|
|
50
|
+
_console.print(table)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _render_ticker(result: dict, quiet: bool) -> None:
|
|
54
|
+
if quiet:
|
|
55
|
+
_console.print(result.get("symbol", ""))
|
|
56
|
+
return
|
|
57
|
+
table = Table(show_header=False, title=result.get("symbol", "ticker"))
|
|
58
|
+
table.add_column("field", style="bold")
|
|
59
|
+
table.add_column("value")
|
|
60
|
+
for key, value in result.items():
|
|
61
|
+
table.add_row(key, "" if value is None else str(value))
|
|
62
|
+
_console.print(table)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _render_filters(result: dict, quiet: bool) -> None:
|
|
66
|
+
cols = result.get("columns", []) or []
|
|
67
|
+
if quiet:
|
|
68
|
+
for c in cols:
|
|
69
|
+
_console.print(c.get("column", ""))
|
|
70
|
+
return
|
|
71
|
+
table = Table(title=f"Filters — {result.get('total', len(cols))} columns")
|
|
72
|
+
table.add_column("column")
|
|
73
|
+
table.add_column("value_type")
|
|
74
|
+
table.add_column("operators")
|
|
75
|
+
for c in cols:
|
|
76
|
+
table.add_row(c.get("column", ""), c.get("value_type", ""), " ".join(c.get("operators", [])))
|
|
77
|
+
_console.print(table)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _render_account(result: dict, quiet: bool) -> None:
|
|
81
|
+
if quiet:
|
|
82
|
+
_console.print(result.get("remaining", ""))
|
|
83
|
+
return
|
|
84
|
+
table = Table(show_header=False, title="Account")
|
|
85
|
+
table.add_column("field", style="bold")
|
|
86
|
+
table.add_column("value")
|
|
87
|
+
for key in ("email", "tier", "is_admin", "daily_limit", "used_today", "remaining", "resets_at_utc"):
|
|
88
|
+
if key in result:
|
|
89
|
+
table.add_row(key, str(result[key]))
|
|
90
|
+
_console.print(table)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _render_keys(result: Any, quiet: bool) -> None:
|
|
94
|
+
if isinstance(result, dict) and not any(k in result for k in ("keys", "id", "key_prefix")):
|
|
95
|
+
if quiet:
|
|
96
|
+
return
|
|
97
|
+
for k, v in result.items():
|
|
98
|
+
_console.print(f"{k}: {v}")
|
|
99
|
+
return
|
|
100
|
+
keys = result if isinstance(result, list) else result.get("keys", [result])
|
|
101
|
+
if quiet:
|
|
102
|
+
for k in keys:
|
|
103
|
+
_console.print(k.get("key_prefix", k.get("id", "")))
|
|
104
|
+
return
|
|
105
|
+
table = Table(title="API keys")
|
|
106
|
+
for col in ("id", "name", "key_prefix", "created_at"):
|
|
107
|
+
table.add_column(col)
|
|
108
|
+
for k in keys:
|
|
109
|
+
table.add_row(*[str(k.get(c, "")) for c in ("id", "name", "key_prefix", "created_at")])
|
|
110
|
+
_console.print(table)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
_RENDERERS = {
|
|
114
|
+
"backtest": _render_backtest,
|
|
115
|
+
"screen": _render_screen,
|
|
116
|
+
"ticker": _render_ticker,
|
|
117
|
+
"filters": _render_filters,
|
|
118
|
+
"account": _render_account,
|
|
119
|
+
"keys": _render_keys,
|
|
120
|
+
}
|
filtrix_cli/state.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Process-global CLI state set by the root Typer callback."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class State:
|
|
8
|
+
json: bool = False
|
|
9
|
+
quiet: bool = False
|
|
10
|
+
no_color: bool = False
|
|
11
|
+
profile: Optional[str] = None
|
|
12
|
+
api_key: Optional[str] = None
|
|
13
|
+
base_url: Optional[str] = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_state = State()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_state() -> State:
|
|
20
|
+
return _state
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def set_state(**kwargs) -> None:
|
|
24
|
+
for key, value in kwargs.items():
|
|
25
|
+
setattr(_state, key, value)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def reset_state() -> None:
|
|
29
|
+
global _state
|
|
30
|
+
_state = State()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: filtrix-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Agent-first command-line interface for the FILTRIX backtest engine.
|
|
5
|
+
Project-URL: Homepage, https://filtrix.net
|
|
6
|
+
Project-URL: Documentation, https://pypi.org/project/filtrix-cli/
|
|
7
|
+
Author-email: Filtrix <team@filtrix.net>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai-agents,backtest,cli,filtrix,quant,screener,trading
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: httpx>=0.27
|
|
23
|
+
Requires-Dist: platformdirs>=4
|
|
24
|
+
Requires-Dist: rich>=13
|
|
25
|
+
Requires-Dist: tomli-w>=1.0
|
|
26
|
+
Requires-Dist: tomli>=2.0; python_version < '3.11'
|
|
27
|
+
Requires-Dist: typer>=0.12
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
30
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# filtrix-cli
|
|
34
|
+
|
|
35
|
+
Agent-first command-line interface to [FILTRIX](https://filtrix.net) — historical trading-pattern backtesting.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pipx install filtrix-cli # or: uvx filtrix-cli ...
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Authenticate
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
export FILTRIX_API_KEY=flt_your_key # for backtests/screens/ticker/account
|
|
47
|
+
filtrix auth login # for managing API keys (stores a JWT)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Sign up (terminal)
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
filtrix auth register --email you@example.com --accept-tos
|
|
54
|
+
# FILTRIX emails a 6-digit code; paste it when prompted; you're logged in.
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Examples
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
filtrix backtest --filter "gap_gup>=50" --filter "price_price<5"
|
|
61
|
+
filtrix screen --filter "gap_gup>=50" --limit 20
|
|
62
|
+
filtrix --json screen --filter "gap_gup>=50" # force JSON in a terminal
|
|
63
|
+
filtrix ticker NVDA
|
|
64
|
+
filtrix filters --search gap
|
|
65
|
+
filtrix account
|
|
66
|
+
filtrix keys create --name laptop
|
|
67
|
+
filtrix llms # machine-readable usage guide for AI agents
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Output & scripting
|
|
71
|
+
|
|
72
|
+
Output is human-readable in a terminal and JSON when piped or with the global `--json` flag.
|
|
73
|
+
Global flags (`--json`, `--quiet`, `--profile`, `--api-key`, `--base-url`) go BEFORE the subcommand.
|
|
74
|
+
Exit codes: 0 ok, 2 usage, 3 not-found, 4 auth, 5 rate-limited, 6 network, 1 server.
|
|
75
|
+
|
|
76
|
+
## Configuration
|
|
77
|
+
|
|
78
|
+
Settings resolve in order: CLI flag > environment variable > config file
|
|
79
|
+
(`filtrix config path` shows the location). Use `--profile NAME` for multiple environments.
|
|
80
|
+
Credentials (API key / JWT) are stored in cleartext in this TOML file (best-effort `chmod 600` on POSIX) — treat it like an SSH key.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
filtrix_cli/__init__.py,sha256=QTYqXqSTHFRkM9TEgpDFcHvwLbvqHDqvqfQ9EiXkcAM,23
|
|
2
|
+
filtrix_cli/__main__.py,sha256=etHHkz9bwutDNPKq2qEFxKDIPcCs1rbSV6dnZ0l4NKU,2195
|
|
3
|
+
filtrix_cli/auth.py,sha256=iUljzwsWNN3odzzO3zvBDonCSKWO8z9UXP131MdrsQE,3626
|
|
4
|
+
filtrix_cli/client.py,sha256=o8vlCTvpnio_8il6d4OVAvnJ8_BAIs99p6mEoROpWCc,5903
|
|
5
|
+
filtrix_cli/config.py,sha256=-uPyJOLUWbGDTqrvrqOF6-PccubjMfSN-i4pDqRuuKg,2414
|
|
6
|
+
filtrix_cli/filters.py,sha256=uYxpwUydRtAZif6Pa4ZX-w4EryF8RsPRH5ndDEx_iQI,3477
|
|
7
|
+
filtrix_cli/output.py,sha256=Pg39y59H8lxWj3Wsh-fUiCaQtuzolghwdRXhmPkUwc0,1805
|
|
8
|
+
filtrix_cli/render.py,sha256=K6_VGqkXfzEpJ0aOiUomw1TxbyV5wmhrsm5cNIo6icY,4256
|
|
9
|
+
filtrix_cli/state.py,sha256=I7RQwUNqb8945chu_SPfteny9czq_3zISvp0G1m7h0E,600
|
|
10
|
+
filtrix_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
filtrix_cli/commands/_helpers.py,sha256=-0550fgHvyv-Rh2xLX6Y3-WBZhB3dStGddJBUnUCIEI,1542
|
|
12
|
+
filtrix_cli/commands/account.py,sha256=i71OmIvVVOnQ_kPgSkfQHZDu2FeGY6wDXHLYdtliQgQ,277
|
|
13
|
+
filtrix_cli/commands/authcmd.py,sha256=lAVEKbT8ydsZbruRxGjj0SiSE1NRrZ49ztPrXvJPf-U,7051
|
|
14
|
+
filtrix_cli/commands/backtest.py,sha256=wXzfd4cPN9cPl7kevpbHQI7VG7-6lvznLbxF5rak41s,1233
|
|
15
|
+
filtrix_cli/commands/configcmd.py,sha256=5w1p3O2MQLWlgkYIp70MTDA71IJP14NvNdYJ3c1jmvs,1211
|
|
16
|
+
filtrix_cli/commands/filters.py,sha256=4IoTg8G9NQ7tMsM7w0SjxgwwYbp50ZZfE4cfAao8Zrs,817
|
|
17
|
+
filtrix_cli/commands/keys.py,sha256=rHqKJVGUBFwI6aHibM1u8_RCR7rhrv_22Ej3aF4NRcA,923
|
|
18
|
+
filtrix_cli/commands/llms.py,sha256=niWYjhpwG5q64sjMNSf5PwwrNyJlnqKUMXtmFng3Dso,319
|
|
19
|
+
filtrix_cli/commands/screen.py,sha256=e0fn4Yc7FxB4VWLwiFZfahrw7bGj2LJIk05BqH32NlM,893
|
|
20
|
+
filtrix_cli/commands/ticker.py,sha256=8UEiKWOunMIK2WQpiGx0QrluUS6lg8XB-T-XF5M5q5g,354
|
|
21
|
+
filtrix_cli/AGENTS.md,sha256=0OrM8nTivZY_KYMzKNaMHPlJWMURqXbcyGC6vZ1wkpE,2178
|
|
22
|
+
filtrix_cli-0.1.0.dist-info/METADATA,sha256=fvL5VfzxVZWeQy39cjSCTfLCNVsTBD4Y0b5N5ILHyAQ,2816
|
|
23
|
+
filtrix_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
24
|
+
filtrix_cli-0.1.0.dist-info/entry_points.txt,sha256=zj35uQw9zqaUf2A6MFwQtbSFoDopNeRwAn5IaKpC-r8,54
|
|
25
|
+
filtrix_cli-0.1.0.dist-info/licenses/LICENSE,sha256=r9lgEuR1lokoRIe3ucR7n8Xn_CtgLHz54VNAdvidDJw,1064
|
|
26
|
+
filtrix_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Filtrix
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|