tradingsignalbot 0.1.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.
@@ -0,0 +1,7 @@
1
+ node_modules/
2
+ package-lock.json
3
+ __pycache__/
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TradingSignalBot
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.
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.5
2
+ Name: tradingsignalbot
3
+ Version: 0.1.0
4
+ Summary: Verify TradingSignalBot webhook signatures (HMAC-SHA256, constant-time, timestamp window). Standard library only. The full API client comes when the sandbox opens.
5
+ Project-URL: Homepage, https://tradingsignalbot.com/api/?via=tsb-sdk
6
+ Project-URL: Source, https://github.com/TradingSignalBot/sdk/tree/main/python
7
+ Project-URL: Issues, https://github.com/TradingSignalBot/sdk/issues
8
+ Author: TradingSignalBot
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: forex signals api,hmac,signature,telegram signal bot,trading signals,tradingsignalbot,webhook
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Office/Business :: Financial :: Investment
15
+ Classifier: Topic :: Security :: Cryptography
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+
20
+ # tradingsignalbot
21
+
22
+ The Python client for the [TradingSignalBot API](https://tradingsignalbot.com/api/?via=tsb-sdk).
23
+
24
+ **The API is a preview.** It is not live yet, so this package holds only the part you need first: **verifying the signature of a webhook delivery**, so you know an event came from TradingSignalBot and was not changed or replayed. The full client (render a chart from your own levels, register webhooks, read signals) comes here when the sandbox opens. [Get early access](https://tradingsignalbot.com/?for=developers&via=tsb-sdk#start) to get sandbox keys first.
25
+
26
+ ```sh
27
+ pip install tradingsignalbot
28
+ ```
29
+
30
+ Standard library only (`hmac.compare_digest`), Python 3.8+, typed.
31
+
32
+ ## Verify a delivery
33
+
34
+ ```python
35
+ from flask import Flask, request, abort
36
+ from tradingsignalbot import verify_webhook, WebhookVerificationError
37
+
38
+ app = Flask(__name__)
39
+
40
+ @app.post("/hooks/signals")
41
+ def signals():
42
+ try:
43
+ event = verify_webhook(request.get_data(), request.headers.get("TSB-Signature"), SECRET)
44
+ except WebhookVerificationError as e:
45
+ abort(400, e.code)
46
+ return "", 204
47
+ ```
48
+
49
+ Pass the **raw body**, exactly as it arrived. Parsed and re-serialised JSON will not match.
50
+
51
+ ## The scheme
52
+
53
+ The scheme, as the [API page](https://tradingsignalbot.com/api/?via=tsb-sdk) states it:
54
+
55
+ | | |
56
+ | --- | --- |
57
+ | Header | `TSB-Signature: t=<timestamp>,v1=<signature>` (shown on the preview page) |
58
+ | Timestamp | Unix seconds (the preview's example uses the event's time in seconds) |
59
+ | Signed | `"<timestamp>.<raw body>"`, HMAC-SHA256 with your webhook secret |
60
+ | Signature | lowercase hex, 64 characters |
61
+ | Window | the timestamp within 300 seconds of your clock, against replays *(`tolerance` changes it, 0 turns it off)* |
62
+ | Several `v1=` | any one matching is enough, for secret rotation |
63
+
64
+ The repository's `test-vectors.json` holds the cases both packages are tested against.
65
+
66
+ Both packages compare in constant time and check every signature and every secret, so a mismatch takes as long as a match.
67
+
68
+ ## API
69
+
70
+ | Function | Does |
71
+ | --- | --- |
72
+ | `verify_webhook(body, header, secret, tolerance=300, now=None)` | Verifies, returns the parsed event |
73
+ | `verify_signature(...)` | Verifies, returns the timestamp; raises `WebhookVerificationError` |
74
+ | `is_valid_signature(...)` | `True` / `False` |
75
+ | `sign_payload(body, secret, timestamp=None)` | The header TradingSignalBot would send: for your tests |
76
+ | `compute_signature(body, secret, t)` | The hex HMAC |
77
+ | `parse_signature_header(header)` | `(timestamp, signatures)` |
78
+
79
+ `secret` can be a list while you rotate it. `WebhookVerificationError.code` is one of `missing_header`, `bad_header`, `no_signature`, `timestamp`, `mismatch`, `bad_json`.
80
+
81
+ ## Licence
82
+
83
+ MIT. Source and the JavaScript package: [github.com/TradingSignalBot/sdk](https://github.com/TradingSignalBot/sdk).
84
+
85
+ ---
86
+
87
+ Part of the Forexia network: InterestRateDecision.com · Forexia Academy · [TradingSignalBot](https://tradingsignalbot.com/?via=tsb-sdk) · PropSim · OpenCharts
@@ -0,0 +1,68 @@
1
+ # tradingsignalbot
2
+
3
+ The Python client for the [TradingSignalBot API](https://tradingsignalbot.com/api/?via=tsb-sdk).
4
+
5
+ **The API is a preview.** It is not live yet, so this package holds only the part you need first: **verifying the signature of a webhook delivery**, so you know an event came from TradingSignalBot and was not changed or replayed. The full client (render a chart from your own levels, register webhooks, read signals) comes here when the sandbox opens. [Get early access](https://tradingsignalbot.com/?for=developers&via=tsb-sdk#start) to get sandbox keys first.
6
+
7
+ ```sh
8
+ pip install tradingsignalbot
9
+ ```
10
+
11
+ Standard library only (`hmac.compare_digest`), Python 3.8+, typed.
12
+
13
+ ## Verify a delivery
14
+
15
+ ```python
16
+ from flask import Flask, request, abort
17
+ from tradingsignalbot import verify_webhook, WebhookVerificationError
18
+
19
+ app = Flask(__name__)
20
+
21
+ @app.post("/hooks/signals")
22
+ def signals():
23
+ try:
24
+ event = verify_webhook(request.get_data(), request.headers.get("TSB-Signature"), SECRET)
25
+ except WebhookVerificationError as e:
26
+ abort(400, e.code)
27
+ return "", 204
28
+ ```
29
+
30
+ Pass the **raw body**, exactly as it arrived. Parsed and re-serialised JSON will not match.
31
+
32
+ ## The scheme
33
+
34
+ The scheme, as the [API page](https://tradingsignalbot.com/api/?via=tsb-sdk) states it:
35
+
36
+ | | |
37
+ | --- | --- |
38
+ | Header | `TSB-Signature: t=<timestamp>,v1=<signature>` (shown on the preview page) |
39
+ | Timestamp | Unix seconds (the preview's example uses the event's time in seconds) |
40
+ | Signed | `"<timestamp>.<raw body>"`, HMAC-SHA256 with your webhook secret |
41
+ | Signature | lowercase hex, 64 characters |
42
+ | Window | the timestamp within 300 seconds of your clock, against replays *(`tolerance` changes it, 0 turns it off)* |
43
+ | Several `v1=` | any one matching is enough, for secret rotation |
44
+
45
+ The repository's `test-vectors.json` holds the cases both packages are tested against.
46
+
47
+ Both packages compare in constant time and check every signature and every secret, so a mismatch takes as long as a match.
48
+
49
+ ## API
50
+
51
+ | Function | Does |
52
+ | --- | --- |
53
+ | `verify_webhook(body, header, secret, tolerance=300, now=None)` | Verifies, returns the parsed event |
54
+ | `verify_signature(...)` | Verifies, returns the timestamp; raises `WebhookVerificationError` |
55
+ | `is_valid_signature(...)` | `True` / `False` |
56
+ | `sign_payload(body, secret, timestamp=None)` | The header TradingSignalBot would send: for your tests |
57
+ | `compute_signature(body, secret, t)` | The hex HMAC |
58
+ | `parse_signature_header(header)` | `(timestamp, signatures)` |
59
+
60
+ `secret` can be a list while you rotate it. `WebhookVerificationError.code` is one of `missing_header`, `bad_header`, `no_signature`, `timestamp`, `mismatch`, `bad_json`.
61
+
62
+ ## Licence
63
+
64
+ MIT. Source and the JavaScript package: [github.com/TradingSignalBot/sdk](https://github.com/TradingSignalBot/sdk).
65
+
66
+ ---
67
+
68
+ Part of the Forexia network: InterestRateDecision.com · Forexia Academy · [TradingSignalBot](https://tradingsignalbot.com/?via=tsb-sdk) · PropSim · OpenCharts
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tradingsignalbot"
7
+ version = "0.1.0"
8
+ description = "Verify TradingSignalBot webhook signatures (HMAC-SHA256, constant-time, timestamp window). Standard library only. The full API client comes when the sandbox opens."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "TradingSignalBot" }]
14
+ keywords = ["tradingsignalbot", "webhook", "signature", "hmac", "forex signals api", "telegram signal bot", "trading signals"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Office/Business :: Financial :: Investment",
18
+ "Topic :: Security :: Cryptography",
19
+ "Intended Audience :: Developers",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.urls]
25
+ Homepage = "https://tradingsignalbot.com/api/?via=tsb-sdk"
26
+ Source = "https://github.com/TradingSignalBot/sdk/tree/main/python"
27
+ Issues = "https://github.com/TradingSignalBot/sdk/issues"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/tradingsignalbot"]
@@ -0,0 +1,30 @@
1
+ """TradingSignalBot: verify webhook signatures. The full API client comes when the sandbox opens.
2
+
3
+ https://tradingsignalbot.com/api/?via=tsb-sdk
4
+ """
5
+
6
+ from .webhooks import (
7
+ DEFAULT_TOLERANCE,
8
+ SIGNATURE_HEADER,
9
+ WebhookVerificationError,
10
+ compute_signature,
11
+ is_valid_signature,
12
+ parse_signature_header,
13
+ sign_payload,
14
+ verify_signature,
15
+ verify_webhook,
16
+ )
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = [
21
+ "DEFAULT_TOLERANCE",
22
+ "SIGNATURE_HEADER",
23
+ "WebhookVerificationError",
24
+ "compute_signature",
25
+ "is_valid_signature",
26
+ "parse_signature_header",
27
+ "sign_payload",
28
+ "verify_signature",
29
+ "verify_webhook",
30
+ ]
File without changes
@@ -0,0 +1,145 @@
1
+ """Verify that a webhook delivery came from TradingSignalBot.
2
+
3
+ The signature scheme (see README, "The scheme")::
4
+
5
+ header TSB-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>...]
6
+ signed f"{t}.{raw_body}", HMAC-SHA256 with your webhook secret, lowercase hex
7
+ window t within 300 seconds of now (configurable)
8
+
9
+ Standard library only.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import hmac
16
+ import json
17
+ import re
18
+ import time
19
+ from typing import Any, Iterable, List, Optional, Sequence, Tuple, Union
20
+
21
+ SIGNATURE_HEADER = "TSB-Signature"
22
+ DEFAULT_TOLERANCE = 300
23
+
24
+ Payload = Union[bytes, bytearray, memoryview, str]
25
+ Secret = Union[str, bytes, Sequence[Union[str, bytes]]]
26
+
27
+ _HEX64 = re.compile(r"^[0-9a-fA-F]{64}$")
28
+
29
+
30
+ class WebhookVerificationError(Exception):
31
+ """Raised when a delivery cannot be trusted. ``code`` says why:
32
+ missing_header, bad_header, no_signature, timestamp, mismatch, bad_json."""
33
+
34
+ def __init__(self, message: str, code: str) -> None:
35
+ super().__init__(message)
36
+ self.code = code
37
+
38
+
39
+ def _bytes(payload: Payload) -> bytes:
40
+ if isinstance(payload, str):
41
+ return payload.encode("utf-8")
42
+ if isinstance(payload, (bytes, bytearray, memoryview)):
43
+ return bytes(payload)
44
+ raise TypeError("tradingsignalbot: pass the raw request body (bytes or str), not parsed JSON")
45
+
46
+
47
+ def _key(secret: Union[str, bytes]) -> bytes:
48
+ if not secret:
49
+ raise TypeError("tradingsignalbot: the webhook secret is required")
50
+ return secret.encode("utf-8") if isinstance(secret, str) else bytes(secret)
51
+
52
+
53
+ def parse_signature_header(header: Optional[Union[str, Iterable[str]]]) -> Tuple[int, List[str]]:
54
+ """``t=...,v1=...,v1=...`` to ``(timestamp, signatures)``. Unknown schemes are ignored."""
55
+ if header is None or header == "":
56
+ raise WebhookVerificationError(f"missing {SIGNATURE_HEADER} header", "missing_header")
57
+ if not isinstance(header, str):
58
+ header = ",".join(header)
59
+ timestamp: Optional[int] = None
60
+ signatures: List[str] = []
61
+ for part in header.split(","):
62
+ if "=" not in part:
63
+ continue
64
+ k, v = part.split("=", 1)
65
+ k, v = k.strip(), v.strip()
66
+ if k == "t":
67
+ timestamp = int(v) if v.isdigit() and v.isascii() else None
68
+ elif k == "v1" and _HEX64.match(v):
69
+ signatures.append(v.lower())
70
+ if timestamp is None:
71
+ raise WebhookVerificationError(f"{SIGNATURE_HEADER} has no valid t=", "bad_header")
72
+ if not signatures:
73
+ raise WebhookVerificationError(f"{SIGNATURE_HEADER} has no v1= signature", "no_signature")
74
+ return timestamp, signatures
75
+
76
+
77
+ def compute_signature(payload: Payload, secret: Union[str, bytes], timestamp: int) -> str:
78
+ """HMAC-SHA256 of ``f"{timestamp}.{payload}"``, lowercase hex."""
79
+ msg = f"{timestamp}.".encode("ascii") + _bytes(payload)
80
+ return hmac.new(_key(secret), msg, hashlib.sha256).hexdigest()
81
+
82
+
83
+ def verify_signature(
84
+ payload: Payload,
85
+ header: Optional[Union[str, Iterable[str]]],
86
+ secret: Secret,
87
+ tolerance: int = DEFAULT_TOLERANCE,
88
+ now: Optional[int] = None,
89
+ ) -> int:
90
+ """Raise WebhookVerificationError unless the header is a valid, fresh
91
+ signature of the raw body. Returns the delivery's timestamp.
92
+
93
+ ``secret`` may be a list while you rotate it. ``tolerance`` is in seconds;
94
+ 0 turns the timestamp check off."""
95
+ timestamp, signatures = parse_signature_header(header)
96
+ now = int(time.time()) if now is None else now
97
+ if tolerance > 0 and abs(now - timestamp) > tolerance:
98
+ raise WebhookVerificationError(
99
+ f"timestamp {timestamp} is outside the {tolerance} s window (now {now})", "timestamp"
100
+ )
101
+ secrets = [secret] if isinstance(secret, (str, bytes)) else list(secret)
102
+ ok = False
103
+ for sec in secrets:
104
+ expected = compute_signature(payload, sec, timestamp)
105
+ for sig in signatures: # check them all: no early exit
106
+ ok = hmac.compare_digest(expected, sig) or ok
107
+ if not ok:
108
+ raise WebhookVerificationError("signature does not match", "mismatch")
109
+ return timestamp
110
+
111
+
112
+ def is_valid_signature(
113
+ payload: Payload,
114
+ header: Optional[Union[str, Iterable[str]]],
115
+ secret: Secret,
116
+ tolerance: int = DEFAULT_TOLERANCE,
117
+ now: Optional[int] = None,
118
+ ) -> bool:
119
+ """Like verify_signature, but True or False instead of raising."""
120
+ try:
121
+ verify_signature(payload, header, secret, tolerance=tolerance, now=now)
122
+ return True
123
+ except WebhookVerificationError:
124
+ return False
125
+
126
+
127
+ def verify_webhook(
128
+ payload: Payload,
129
+ header: Optional[Union[str, Iterable[str]]],
130
+ secret: Secret,
131
+ tolerance: int = DEFAULT_TOLERANCE,
132
+ now: Optional[int] = None,
133
+ ) -> Any:
134
+ """Verify the delivery and return the parsed event (``{"type", "created", "data"}``)."""
135
+ verify_signature(payload, header, secret, tolerance=tolerance, now=now)
136
+ try:
137
+ return json.loads(_bytes(payload).decode("utf-8"))
138
+ except (ValueError, UnicodeDecodeError):
139
+ raise WebhookVerificationError("the body is not JSON", "bad_json") from None
140
+
141
+
142
+ def sign_payload(payload: Payload, secret: Union[str, bytes], timestamp: Optional[int] = None) -> str:
143
+ """The header TradingSignalBot would send for this body: for your tests and local replays."""
144
+ t = int(time.time()) if timestamp is None else timestamp
145
+ return f"t={t},v1={compute_signature(payload, secret, t)}"
@@ -0,0 +1,90 @@
1
+ import hashlib
2
+ import hmac
3
+ import json
4
+ import pathlib
5
+ import sys
6
+ import unittest
7
+
8
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "src"))
9
+
10
+ from tradingsignalbot import ( # noqa: E402
11
+ WebhookVerificationError,
12
+ compute_signature,
13
+ is_valid_signature,
14
+ parse_signature_header,
15
+ sign_payload,
16
+ verify_signature,
17
+ verify_webhook,
18
+ )
19
+
20
+ VECTORS = json.loads((pathlib.Path(__file__).resolve().parents[2] / "test-vectors.json").read_text("utf-8"))
21
+ SECRET = "whsec_test"
22
+ BODY = json.dumps({"type": "signal.target_hit", "created": "2026-10-14T19:30:00Z", "data": {"target": 2}})
23
+ T = 1792001400
24
+
25
+
26
+ def code(fn, *a, **k):
27
+ try:
28
+ fn(*a, **k)
29
+ except WebhookVerificationError as e:
30
+ return e.code
31
+ return "ok"
32
+
33
+
34
+ class Vectors(unittest.TestCase):
35
+ def test_shared_vectors(self):
36
+ for v in VECTORS["cases"]:
37
+ with self.subTest(v["name"]):
38
+ self.assertEqual(compute_signature(v["body"], v["secret"], v["timestamp"]), v["signature"])
39
+ self.assertEqual(code(verify_signature, v["body"], v["header"], v["secret"], now=v["now"]), v["expect"])
40
+
41
+
42
+ class Verify(unittest.TestCase):
43
+ def test_plain_hmac(self):
44
+ expected = hmac.new(SECRET.encode(), f"{T}.{BODY}".encode(), hashlib.sha256).hexdigest()
45
+ self.assertEqual(compute_signature(BODY, SECRET, T), expected)
46
+ self.assertEqual(sign_payload(BODY, SECRET, timestamp=T), f"t={T},v1={expected}")
47
+
48
+ def test_good_delivery(self):
49
+ header = sign_payload(BODY, SECRET, timestamp=T)
50
+ event = verify_webhook(BODY.encode(), header, SECRET, now=T + 10)
51
+ self.assertEqual(event["type"], "signal.target_hit")
52
+ self.assertTrue(is_valid_signature(bytearray(BODY.encode()), header, SECRET, now=T))
53
+ self.assertEqual(verify_signature(BODY, header, SECRET, now=T), T)
54
+
55
+ def test_refused(self):
56
+ header = sign_payload(BODY, SECRET, timestamp=T)
57
+ self.assertEqual(code(verify_signature, BODY + " ", header, SECRET, now=T), "mismatch")
58
+ self.assertEqual(code(verify_signature, BODY, header, "other", now=T), "mismatch")
59
+ self.assertEqual(code(verify_signature, BODY, header, SECRET, now=T + 301), "timestamp")
60
+ self.assertEqual(code(verify_signature, BODY, header, SECRET, now=T - 301), "timestamp")
61
+ self.assertEqual(code(verify_signature, BODY, header, SECRET, now=T + 10_000, tolerance=0), "ok")
62
+ self.assertEqual(code(verify_signature, BODY, header.replace(f"t={T}", f"t={T + 1}"), SECRET, now=T), "mismatch")
63
+
64
+ def test_bad_headers(self):
65
+ self.assertEqual(code(verify_signature, BODY, None, SECRET), "missing_header")
66
+ self.assertEqual(code(verify_signature, BODY, "", SECRET), "missing_header")
67
+ self.assertEqual(code(verify_signature, BODY, "v1=" + "a" * 64, SECRET), "bad_header")
68
+ self.assertEqual(code(verify_signature, BODY, f"t={T}", SECRET, now=T), "no_signature")
69
+ self.assertEqual(code(verify_signature, BODY, f"t={T},v1=short", SECRET, now=T), "no_signature")
70
+ self.assertEqual(code(verify_signature, BODY, "t=-5,v1=" + "a" * 64, SECRET), "bad_header")
71
+
72
+ def test_rotation(self):
73
+ sig = compute_signature(BODY, SECRET, T)
74
+ header = f"t={T},v1={'0' * 64},v1={sig},v0=whatever"
75
+ self.assertEqual(len(parse_signature_header(header)[1]), 2)
76
+ self.assertTrue(is_valid_signature(BODY, header, SECRET, now=T))
77
+ self.assertTrue(is_valid_signature(BODY, f"t={T},v1={sig}", ["new_secret", SECRET], now=T))
78
+ self.assertTrue(is_valid_signature(BODY, f" t={T} , v1={sig.upper()} ", SECRET, now=T))
79
+
80
+ def test_parsed_json_refused(self):
81
+ with self.assertRaises(TypeError):
82
+ verify_signature(json.loads(BODY), f"t={T},v1={'a' * 64}", SECRET, now=T)
83
+
84
+ def test_not_json(self):
85
+ header = sign_payload("not json", SECRET, timestamp=T)
86
+ self.assertEqual(code(verify_webhook, "not json", header, SECRET, now=T), "bad_json")
87
+
88
+
89
+ if __name__ == "__main__":
90
+ unittest.main()