pulse-tradingview 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.
- pulse_tradingview-0.1.0/PKG-INFO +42 -0
- pulse_tradingview-0.1.0/pulse_tradingview/__init__.py +13 -0
- pulse_tradingview-0.1.0/pulse_tradingview/cli.py +222 -0
- pulse_tradingview-0.1.0/pulse_tradingview/converter.py +186 -0
- pulse_tradingview-0.1.0/pulse_tradingview/router.py +160 -0
- pulse_tradingview-0.1.0/pulse_tradingview/version.py +1 -0
- pulse_tradingview-0.1.0/pulse_tradingview/webhook.py +153 -0
- pulse_tradingview-0.1.0/pulse_tradingview.egg-info/PKG-INFO +42 -0
- pulse_tradingview-0.1.0/pulse_tradingview.egg-info/SOURCES.txt +14 -0
- pulse_tradingview-0.1.0/pulse_tradingview.egg-info/dependency_links.txt +1 -0
- pulse_tradingview-0.1.0/pulse_tradingview.egg-info/entry_points.txt +2 -0
- pulse_tradingview-0.1.0/pulse_tradingview.egg-info/requires.txt +27 -0
- pulse_tradingview-0.1.0/pulse_tradingview.egg-info/top_level.txt +1 -0
- pulse_tradingview-0.1.0/pyproject.toml +53 -0
- pulse_tradingview-0.1.0/setup.cfg +4 -0
- pulse_tradingview-0.1.0/tests/test_tradingview.py +368 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pulse-tradingview
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Connect TradingView alerts to any crypto exchange via PULSE Protocol — free alternative to 3Commas
|
|
5
|
+
Author: PULSE Protocol
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/pulseprotocolorg-cyber/pulse-tradingview
|
|
8
|
+
Project-URL: Repository, https://github.com/pulseprotocolorg-cyber/pulse-tradingview
|
|
9
|
+
Keywords: tradingview,webhook,crypto,trading,binance,bybit,automation,pine-script,pulse
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
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
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Provides-Extra: binance
|
|
24
|
+
Requires-Dist: pulse-binance>=0.1.0; extra == "binance"
|
|
25
|
+
Provides-Extra: bybit
|
|
26
|
+
Requires-Dist: pulse-bybit>=0.1.0; extra == "bybit"
|
|
27
|
+
Provides-Extra: kraken
|
|
28
|
+
Requires-Dist: pulse-kraken>=0.1.0; extra == "kraken"
|
|
29
|
+
Provides-Extra: okx
|
|
30
|
+
Requires-Dist: pulse-okx>=0.1.0; extra == "okx"
|
|
31
|
+
Provides-Extra: freqtrade
|
|
32
|
+
Requires-Dist: pulse-freqtrade>=0.1.0; extra == "freqtrade"
|
|
33
|
+
Provides-Extra: all
|
|
34
|
+
Requires-Dist: pulse-binance>=0.1.0; extra == "all"
|
|
35
|
+
Requires-Dist: pulse-bybit>=0.1.0; extra == "all"
|
|
36
|
+
Requires-Dist: pulse-kraken>=0.1.0; extra == "all"
|
|
37
|
+
Requires-Dist: pulse-okx>=0.1.0; extra == "all"
|
|
38
|
+
Requires-Dist: pulse-freqtrade>=0.1.0; extra == "all"
|
|
39
|
+
Provides-Extra: dev
|
|
40
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
41
|
+
Requires-Dist: build>=0.10; extra == "dev"
|
|
42
|
+
Requires-Dist: twine>=4.0; extra == "dev"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""pulse-tradingview — connect TradingView alerts to any exchange via PULSE Protocol."""
|
|
2
|
+
|
|
3
|
+
from pulse_tradingview.version import __version__
|
|
4
|
+
from pulse_tradingview.converter import AlertConverter
|
|
5
|
+
from pulse_tradingview.router import SignalRouter
|
|
6
|
+
from pulse_tradingview.webhook import WebhookReceiver
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"__version__",
|
|
10
|
+
"AlertConverter",
|
|
11
|
+
"SignalRouter",
|
|
12
|
+
"WebhookReceiver",
|
|
13
|
+
]
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""pulse-tv CLI — start the TradingView webhook server."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from pulse_tradingview.converter import AlertConverter
|
|
11
|
+
from pulse_tradingview.router import SignalRouter
|
|
12
|
+
from pulse_tradingview.webhook import WebhookReceiver
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def setup_logging(verbose: bool) -> None:
|
|
16
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
17
|
+
logging.basicConfig(
|
|
18
|
+
level=level,
|
|
19
|
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
20
|
+
datefmt="%H:%M:%S",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_config(path: str) -> dict:
|
|
25
|
+
if not os.path.exists(path):
|
|
26
|
+
return {}
|
|
27
|
+
with open(path) as f:
|
|
28
|
+
return json.load(f)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_router(config: dict) -> SignalRouter:
|
|
32
|
+
router = SignalRouter()
|
|
33
|
+
|
|
34
|
+
if "binance" in config:
|
|
35
|
+
from pulse_binance import BinanceAdapter
|
|
36
|
+
c = config["binance"]
|
|
37
|
+
adapter = BinanceAdapter(
|
|
38
|
+
api_key=c["api_key"],
|
|
39
|
+
api_secret=c["api_secret"],
|
|
40
|
+
testnet=c.get("testnet", False),
|
|
41
|
+
)
|
|
42
|
+
router.add_adapter(adapter, "Binance")
|
|
43
|
+
|
|
44
|
+
if "bybit" in config:
|
|
45
|
+
from pulse_bybit import BybitAdapter
|
|
46
|
+
c = config["bybit"]
|
|
47
|
+
adapter = BybitAdapter(
|
|
48
|
+
api_key=c["api_key"],
|
|
49
|
+
api_secret=c["api_secret"],
|
|
50
|
+
testnet=c.get("testnet", False),
|
|
51
|
+
)
|
|
52
|
+
router.add_adapter(adapter, "Bybit")
|
|
53
|
+
|
|
54
|
+
if "kraken" in config:
|
|
55
|
+
from pulse_kraken import KrakenAdapter
|
|
56
|
+
c = config["kraken"]
|
|
57
|
+
adapter = KrakenAdapter(api_key=c["api_key"], api_secret=c["api_secret"])
|
|
58
|
+
router.add_adapter(adapter, "Kraken")
|
|
59
|
+
|
|
60
|
+
if "okx" in config:
|
|
61
|
+
from pulse_okx import OKXAdapter
|
|
62
|
+
c = config["okx"]
|
|
63
|
+
adapter = OKXAdapter(
|
|
64
|
+
api_key=c["api_key"],
|
|
65
|
+
api_secret=c["api_secret"],
|
|
66
|
+
passphrase=c["passphrase"],
|
|
67
|
+
)
|
|
68
|
+
router.add_adapter(adapter, "OKX")
|
|
69
|
+
|
|
70
|
+
if "freqtrade" in config:
|
|
71
|
+
from pulse_freqtrade import FreqtradeAdapter
|
|
72
|
+
c = config["freqtrade"]
|
|
73
|
+
adapter = FreqtradeAdapter(
|
|
74
|
+
url=c.get("url", "http://localhost:8080"),
|
|
75
|
+
username=c.get("username", "freqtrader"),
|
|
76
|
+
password=c["password"],
|
|
77
|
+
)
|
|
78
|
+
router.add_adapter(adapter, "Freqtrade")
|
|
79
|
+
|
|
80
|
+
# Confidence filter
|
|
81
|
+
min_confidence = config.get("min_confidence", 0.0)
|
|
82
|
+
if min_confidence > 0:
|
|
83
|
+
router.add_filter(lambda p: p.get("confidence", 1.0) >= min_confidence)
|
|
84
|
+
|
|
85
|
+
# Pair whitelist
|
|
86
|
+
pairs = config.get("pairs", [])
|
|
87
|
+
if pairs:
|
|
88
|
+
router.add_filter(lambda p: p.get("pair") in pairs)
|
|
89
|
+
|
|
90
|
+
return router
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def cmd_start(args):
|
|
94
|
+
"""Start the webhook server."""
|
|
95
|
+
config = load_config(args.config)
|
|
96
|
+
setup_logging(args.verbose)
|
|
97
|
+
|
|
98
|
+
router = build_router(config)
|
|
99
|
+
|
|
100
|
+
if not router._adapters:
|
|
101
|
+
print("Warning: No exchanges configured. Signals will be logged but not executed.")
|
|
102
|
+
print(f"Create a config file: pulse-tv init --output {args.config}")
|
|
103
|
+
|
|
104
|
+
def on_signal(params):
|
|
105
|
+
print(
|
|
106
|
+
f" Signal: {params.get('pair')} | {params.get('direction').upper()} | "
|
|
107
|
+
f"confidence={params.get('confidence', 1.0):.0%} | "
|
|
108
|
+
f"price={params.get('price', 'market')}"
|
|
109
|
+
)
|
|
110
|
+
return router.route(params)
|
|
111
|
+
|
|
112
|
+
receiver = WebhookReceiver(
|
|
113
|
+
port=args.port,
|
|
114
|
+
on_signal=on_signal,
|
|
115
|
+
secret=args.secret or config.get("secret"),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
print(f"\npulse-tradingview v0.1.0")
|
|
119
|
+
print(f"Webhook server: http://0.0.0.0:{args.port}/")
|
|
120
|
+
print(f"Health check: http://localhost:{args.port}/")
|
|
121
|
+
if router._adapters:
|
|
122
|
+
print(f"Exchanges: {', '.join(name for name, _ in router._adapters)}")
|
|
123
|
+
print(f"\nIn TradingView → Alert → Webhook URL:")
|
|
124
|
+
print(f" http://YOUR_PUBLIC_IP:{args.port}/\n")
|
|
125
|
+
print("Waiting for signals... (Ctrl+C to stop)\n")
|
|
126
|
+
|
|
127
|
+
receiver.start()
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
while True:
|
|
131
|
+
time.sleep(1)
|
|
132
|
+
except KeyboardInterrupt:
|
|
133
|
+
print("\nStopping...")
|
|
134
|
+
receiver.stop()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cmd_init(args):
|
|
138
|
+
"""Create a sample config file."""
|
|
139
|
+
sample = {
|
|
140
|
+
"binance": {
|
|
141
|
+
"api_key": "YOUR_BINANCE_API_KEY",
|
|
142
|
+
"api_secret": "YOUR_BINANCE_API_SECRET",
|
|
143
|
+
"testnet": False
|
|
144
|
+
},
|
|
145
|
+
"min_confidence": 0.7,
|
|
146
|
+
"pairs": ["BTC/USDT", "ETH/USDT", "SOL/USDT"],
|
|
147
|
+
"secret": "optional-shared-secret-for-security"
|
|
148
|
+
}
|
|
149
|
+
output = args.output or "pulse_tv_config.json"
|
|
150
|
+
with open(output, "w") as f:
|
|
151
|
+
json.dump(sample, f, indent=2)
|
|
152
|
+
print(f"Created: {output}")
|
|
153
|
+
print("Edit it to add your exchange API keys and remove exchanges you don't use.")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def cmd_test(args):
|
|
157
|
+
"""Send a test signal to a running webhook server."""
|
|
158
|
+
import urllib.request
|
|
159
|
+
|
|
160
|
+
payload = json.dumps({
|
|
161
|
+
"action": args.action,
|
|
162
|
+
"symbol": args.symbol.replace("/", ""),
|
|
163
|
+
"price": str(args.price) if args.price else "0",
|
|
164
|
+
"interval": "1h",
|
|
165
|
+
"confidence": str(args.confidence),
|
|
166
|
+
}).encode()
|
|
167
|
+
|
|
168
|
+
url = f"http://localhost:{args.port}/"
|
|
169
|
+
req = urllib.request.Request(
|
|
170
|
+
url, data=payload,
|
|
171
|
+
headers={"Content-Type": "application/json"},
|
|
172
|
+
method="POST",
|
|
173
|
+
)
|
|
174
|
+
try:
|
|
175
|
+
with urllib.request.urlopen(req, timeout=5) as r:
|
|
176
|
+
result = json.loads(r.read())
|
|
177
|
+
print(f"Response: {json.dumps(result, indent=2)}")
|
|
178
|
+
except Exception as e:
|
|
179
|
+
print(f"Error: {e}")
|
|
180
|
+
print(f"Is the server running? Start it with: pulse-tv start")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def main():
|
|
184
|
+
parser = argparse.ArgumentParser(
|
|
185
|
+
prog="pulse-tv",
|
|
186
|
+
description="TradingView webhook server for PULSE Protocol.",
|
|
187
|
+
)
|
|
188
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
189
|
+
|
|
190
|
+
# start
|
|
191
|
+
start_p = subparsers.add_parser("start", help="Start webhook server")
|
|
192
|
+
start_p.add_argument("--port", type=int, default=8888, help="Port (default: 8888)")
|
|
193
|
+
start_p.add_argument("--config", default="pulse_tv_config.json", help="Config file")
|
|
194
|
+
start_p.add_argument("--secret", help="Shared secret for webhook validation")
|
|
195
|
+
start_p.add_argument("--verbose", "-v", action="store_true")
|
|
196
|
+
|
|
197
|
+
# init
|
|
198
|
+
init_p = subparsers.add_parser("init", help="Create sample config")
|
|
199
|
+
init_p.add_argument("--output", help="Output file path")
|
|
200
|
+
|
|
201
|
+
# test
|
|
202
|
+
test_p = subparsers.add_parser("test", help="Send test signal to running server")
|
|
203
|
+
test_p.add_argument("--port", type=int, default=8888)
|
|
204
|
+
test_p.add_argument("--action", default="buy", choices=["buy", "sell", "close"])
|
|
205
|
+
test_p.add_argument("--symbol", default="BTC/USDT")
|
|
206
|
+
test_p.add_argument("--price", type=float)
|
|
207
|
+
test_p.add_argument("--confidence", type=float, default=0.9)
|
|
208
|
+
|
|
209
|
+
args = parser.parse_args()
|
|
210
|
+
|
|
211
|
+
if args.command == "start":
|
|
212
|
+
cmd_start(args)
|
|
213
|
+
elif args.command == "init":
|
|
214
|
+
cmd_init(args)
|
|
215
|
+
elif args.command == "test":
|
|
216
|
+
cmd_test(args)
|
|
217
|
+
else:
|
|
218
|
+
parser.print_help()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
if __name__ == "__main__":
|
|
222
|
+
main()
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""AlertConverter — converts TradingView webhook alerts to PULSE messages."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Map TradingView action strings to PULSE directions
|
|
8
|
+
ACTION_MAP = {
|
|
9
|
+
"buy": "long",
|
|
10
|
+
"long": "long",
|
|
11
|
+
"enter_long": "long",
|
|
12
|
+
"sell": "short",
|
|
13
|
+
"short": "short",
|
|
14
|
+
"enter_short": "short",
|
|
15
|
+
"close": "neutral",
|
|
16
|
+
"exit": "neutral",
|
|
17
|
+
"flat": "neutral",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
# Map TradingView interval strings to standard labels
|
|
21
|
+
INTERVAL_MAP = {
|
|
22
|
+
"1": "1m", "3": "3m", "5": "5m", "15": "15m", "30": "30m",
|
|
23
|
+
"45": "45m", "60": "1h", "120": "2h", "180": "3h", "240": "4h",
|
|
24
|
+
"1D": "1d", "1W": "1w", "1M": "1M",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AlertConverter:
|
|
29
|
+
"""Convert TradingView webhook JSON to PULSE message parameters.
|
|
30
|
+
|
|
31
|
+
TradingView sends alerts as JSON via HTTP POST. The exact format
|
|
32
|
+
depends on the alert message template the user sets up in Pine Script.
|
|
33
|
+
|
|
34
|
+
Supported alert formats:
|
|
35
|
+
|
|
36
|
+
1. Standard format (recommended):
|
|
37
|
+
{"action": "buy", "symbol": "BTCUSDT", "price": "83000",
|
|
38
|
+
"interval": "1h", "confidence": "0.85"}
|
|
39
|
+
|
|
40
|
+
2. Minimal format:
|
|
41
|
+
{"action": "buy", "symbol": "BTCUSDT"}
|
|
42
|
+
|
|
43
|
+
3. Pine Script strategy format:
|
|
44
|
+
{"action": "{{strategy.order.action}}", "symbol": "{{ticker}}",
|
|
45
|
+
"price": "{{close}}", "interval": "{{interval}}"}
|
|
46
|
+
|
|
47
|
+
4. Plain text (legacy):
|
|
48
|
+
"BUY BTCUSDT"
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
>>> converter = AlertConverter()
|
|
52
|
+
>>> params = converter.parse('{"action": "buy", "symbol": "BTCUSDT", "price": "83000"}')
|
|
53
|
+
>>> params["direction"] # "long"
|
|
54
|
+
>>> params["pair"] # "BTC/USDT"
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def parse(self, raw: str) -> Dict[str, Any]:
|
|
58
|
+
"""Parse a TradingView alert message into PULSE parameters.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
raw: Raw alert body (JSON string or plain text)
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Dict with keys: pair, direction, price, interval, confidence,
|
|
65
|
+
symbol, source
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
ValueError: If the alert cannot be parsed
|
|
69
|
+
"""
|
|
70
|
+
raw = raw.strip()
|
|
71
|
+
|
|
72
|
+
# Try JSON first
|
|
73
|
+
if raw.startswith("{"):
|
|
74
|
+
try:
|
|
75
|
+
data = json.loads(raw)
|
|
76
|
+
return self._parse_json(data)
|
|
77
|
+
except json.JSONDecodeError as e:
|
|
78
|
+
raise ValueError(f"Invalid JSON in alert: {e}")
|
|
79
|
+
|
|
80
|
+
# Fallback: plain text "BUY BTCUSDT" or "SELL ETHUSDT 3500"
|
|
81
|
+
return self._parse_text(raw)
|
|
82
|
+
|
|
83
|
+
def _parse_json(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
84
|
+
"""Parse structured JSON alert."""
|
|
85
|
+
# Action → direction
|
|
86
|
+
action_raw = str(data.get("action", data.get("side", ""))).lower().strip()
|
|
87
|
+
direction = ACTION_MAP.get(action_raw)
|
|
88
|
+
if not direction:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"Unknown action '{action_raw}'. Use: buy, sell, long, short, close"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Symbol → pair
|
|
94
|
+
symbol = str(data.get("symbol", data.get("ticker", ""))).upper().strip()
|
|
95
|
+
if not symbol:
|
|
96
|
+
raise ValueError("Alert missing 'symbol' field")
|
|
97
|
+
pair = self._symbol_to_pair(symbol)
|
|
98
|
+
|
|
99
|
+
# Price
|
|
100
|
+
price_raw = data.get("price", data.get("close", None))
|
|
101
|
+
price = float(price_raw) if price_raw is not None else None
|
|
102
|
+
|
|
103
|
+
# Interval
|
|
104
|
+
interval_raw = str(data.get("interval", data.get("timeframe", ""))).strip()
|
|
105
|
+
interval = INTERVAL_MAP.get(interval_raw, interval_raw) if interval_raw else None
|
|
106
|
+
|
|
107
|
+
# Confidence (0-1)
|
|
108
|
+
confidence_raw = data.get("confidence", data.get("strength", None))
|
|
109
|
+
if confidence_raw is not None:
|
|
110
|
+
confidence = float(confidence_raw)
|
|
111
|
+
if confidence > 1.0:
|
|
112
|
+
confidence = confidence / 100.0 # handle percentage format
|
|
113
|
+
else:
|
|
114
|
+
confidence = 1.0 # TradingView alerts are deterministic, full confidence
|
|
115
|
+
|
|
116
|
+
# Extra passthrough fields
|
|
117
|
+
extra = {
|
|
118
|
+
k: v for k, v in data.items()
|
|
119
|
+
if k not in ("action", "side", "symbol", "ticker", "price", "close",
|
|
120
|
+
"interval", "timeframe", "confidence", "strength")
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
result = {
|
|
124
|
+
"pair": pair,
|
|
125
|
+
"symbol": symbol,
|
|
126
|
+
"direction": direction,
|
|
127
|
+
"confidence": confidence,
|
|
128
|
+
"source": "tradingview",
|
|
129
|
+
}
|
|
130
|
+
if price is not None:
|
|
131
|
+
result["price"] = price
|
|
132
|
+
if interval:
|
|
133
|
+
result["interval"] = interval
|
|
134
|
+
if extra:
|
|
135
|
+
result["extra"] = extra
|
|
136
|
+
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
def _parse_text(self, text: str) -> Dict[str, Any]:
|
|
140
|
+
"""Parse plain text alert like 'BUY BTCUSDT' or 'SELL ETHUSDT 3500'."""
|
|
141
|
+
parts = text.upper().split()
|
|
142
|
+
if len(parts) < 2:
|
|
143
|
+
raise ValueError(
|
|
144
|
+
f"Cannot parse alert text '{text}'. "
|
|
145
|
+
"Expected format: 'BUY BTCUSDT' or JSON"
|
|
146
|
+
)
|
|
147
|
+
action_raw = parts[0].lower()
|
|
148
|
+
direction = ACTION_MAP.get(action_raw)
|
|
149
|
+
if not direction:
|
|
150
|
+
raise ValueError(f"Unknown action '{parts[0]}'")
|
|
151
|
+
|
|
152
|
+
symbol = parts[1]
|
|
153
|
+
pair = self._symbol_to_pair(symbol)
|
|
154
|
+
price = float(parts[2]) if len(parts) > 2 else None
|
|
155
|
+
|
|
156
|
+
result = {
|
|
157
|
+
"pair": pair,
|
|
158
|
+
"symbol": symbol,
|
|
159
|
+
"direction": direction,
|
|
160
|
+
"confidence": 1.0,
|
|
161
|
+
"source": "tradingview",
|
|
162
|
+
}
|
|
163
|
+
if price is not None:
|
|
164
|
+
result["price"] = price
|
|
165
|
+
return result
|
|
166
|
+
|
|
167
|
+
def _symbol_to_pair(self, symbol: str) -> str:
|
|
168
|
+
"""Convert exchange symbol to PULSE pair format.
|
|
169
|
+
|
|
170
|
+
BTCUSDT → BTC/USDT
|
|
171
|
+
ETHBTC → ETH/BTC
|
|
172
|
+
BTC/USDT → BTC/USDT (already formatted)
|
|
173
|
+
"""
|
|
174
|
+
if "/" in symbol:
|
|
175
|
+
return symbol
|
|
176
|
+
|
|
177
|
+
quotes = ["USDT", "USDC", "BUSD", "BTC", "ETH", "BNB", "EUR", "USD", "GBP"]
|
|
178
|
+
for quote in quotes:
|
|
179
|
+
if symbol.endswith(quote) and len(symbol) > len(quote):
|
|
180
|
+
base = symbol[: -len(quote)]
|
|
181
|
+
return f"{base}/{quote}"
|
|
182
|
+
|
|
183
|
+
# Fallback: split at 3/4 chars
|
|
184
|
+
if len(symbol) >= 6:
|
|
185
|
+
return f"{symbol[:3]}/{symbol[3:]}"
|
|
186
|
+
return symbol
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""SignalRouter — routes PULSE signals to configured exchange adapters."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SignalRouter:
|
|
10
|
+
"""Route incoming TradingView signals to one or more exchange adapters.
|
|
11
|
+
|
|
12
|
+
Supports any PULSE-compatible adapter: pulse-binance, pulse-bybit,
|
|
13
|
+
pulse-kraken, pulse-okx, or pulse-freqtrade.
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
>>> from pulse_binance import BinanceAdapter
|
|
17
|
+
>>> router = SignalRouter()
|
|
18
|
+
>>> router.add_adapter(BinanceAdapter(api_key="...", api_secret="..."))
|
|
19
|
+
>>> router.route({"pair": "BTC/USDT", "direction": "long", "price": 83000})
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self):
|
|
23
|
+
self._adapters: List = []
|
|
24
|
+
self._filters: List = []
|
|
25
|
+
|
|
26
|
+
def add_adapter(self, adapter, name: Optional[str] = None) -> "SignalRouter":
|
|
27
|
+
"""Add an exchange adapter to route signals to.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
adapter: Any PULSE-compatible adapter (BinanceAdapter, BybitAdapter, etc.)
|
|
31
|
+
name: Optional label for logging
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Self for chaining.
|
|
35
|
+
"""
|
|
36
|
+
label = name or getattr(adapter, "__class__", type(adapter)).__name__
|
|
37
|
+
self._adapters.append((label, adapter))
|
|
38
|
+
logger.info(f"Added adapter: {label}")
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
def add_filter(self, fn) -> "SignalRouter":
|
|
42
|
+
"""Add a filter function that can block signals.
|
|
43
|
+
|
|
44
|
+
The filter receives the parsed parameters dict and returns True to
|
|
45
|
+
allow the signal, False to drop it.
|
|
46
|
+
|
|
47
|
+
Example:
|
|
48
|
+
>>> router.add_filter(lambda p: p["confidence"] >= 0.8)
|
|
49
|
+
>>> router.add_filter(lambda p: p["pair"] in ["BTC/USDT", "ETH/USDT"])
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
fn: Callable that takes params dict and returns bool
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Self for chaining.
|
|
56
|
+
"""
|
|
57
|
+
self._filters.append(fn)
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
def route(self, params: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
61
|
+
"""Route a parsed signal to all configured adapters.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
params: Parsed alert parameters from AlertConverter
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
List of results from each adapter (one per adapter).
|
|
68
|
+
"""
|
|
69
|
+
# Apply filters
|
|
70
|
+
for f in self._filters:
|
|
71
|
+
if not f(params):
|
|
72
|
+
logger.info(
|
|
73
|
+
f"Signal filtered out: {params.get('pair')} {params.get('direction')} "
|
|
74
|
+
f"(confidence={params.get('confidence')})"
|
|
75
|
+
)
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
if not self._adapters:
|
|
79
|
+
logger.warning("No adapters configured — signal dropped")
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
results = []
|
|
83
|
+
for label, adapter in self._adapters:
|
|
84
|
+
try:
|
|
85
|
+
result = self._send_to_adapter(adapter, params, label)
|
|
86
|
+
results.append({"adapter": label, "status": "ok", "result": result})
|
|
87
|
+
logger.info(
|
|
88
|
+
f"Signal sent to {label}: {params.get('pair')} {params.get('direction')}"
|
|
89
|
+
)
|
|
90
|
+
except Exception as e:
|
|
91
|
+
logger.error(f"Error sending to {label}: {e}")
|
|
92
|
+
results.append({"adapter": label, "status": "error", "error": str(e)})
|
|
93
|
+
|
|
94
|
+
return results
|
|
95
|
+
|
|
96
|
+
def _send_to_adapter(self, adapter, params: Dict[str, Any], label: str):
|
|
97
|
+
"""Send signal to a specific adapter using PULSE message format."""
|
|
98
|
+
# Import here to avoid circular imports
|
|
99
|
+
try:
|
|
100
|
+
from pulse.message import PulseMessage
|
|
101
|
+
except ImportError:
|
|
102
|
+
raise ImportError(
|
|
103
|
+
"pulse-protocol not installed. Run: pip install pulse-protocol"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
direction = params.get("direction", "neutral")
|
|
107
|
+
|
|
108
|
+
# Build PULSE message
|
|
109
|
+
msg_params = {
|
|
110
|
+
"pair": params.get("pair", ""),
|
|
111
|
+
"direction": direction,
|
|
112
|
+
"confidence": params.get("confidence", 1.0),
|
|
113
|
+
"source": params.get("source", "tradingview"),
|
|
114
|
+
}
|
|
115
|
+
if "price" in params:
|
|
116
|
+
msg_params["price"] = params["price"]
|
|
117
|
+
if "interval" in params:
|
|
118
|
+
msg_params["interval"] = params["interval"]
|
|
119
|
+
|
|
120
|
+
# For exchange adapters: use ACT.TRANSACT.REQUEST for actual trades
|
|
121
|
+
# For signal-only flow: use ACT.RECOMMEND.ACTION
|
|
122
|
+
adapter_class = type(adapter).__name__
|
|
123
|
+
|
|
124
|
+
if direction == "neutral":
|
|
125
|
+
# Close/exit signal — use ACT.CANCEL if supported
|
|
126
|
+
msg = PulseMessage(
|
|
127
|
+
action="ACT.RECOMMEND.ACTION",
|
|
128
|
+
parameters=msg_params,
|
|
129
|
+
validate=False,
|
|
130
|
+
)
|
|
131
|
+
elif adapter_class in ("FreqtradeAdapter",):
|
|
132
|
+
# Freqtrade: inject as signal recommendation, not direct trade
|
|
133
|
+
msg = PulseMessage(
|
|
134
|
+
action="ACT.RECOMMEND.ACTION",
|
|
135
|
+
parameters=msg_params,
|
|
136
|
+
validate=False,
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
# Direct exchange adapter: place trade
|
|
140
|
+
trade_params = {
|
|
141
|
+
"pair": params.get("pair", ""),
|
|
142
|
+
"symbol": params.get("symbol", ""),
|
|
143
|
+
"side": "BUY" if direction == "long" else "SELL",
|
|
144
|
+
"type": "MARKET",
|
|
145
|
+
}
|
|
146
|
+
if "price" in params:
|
|
147
|
+
trade_params["price"] = params["price"]
|
|
148
|
+
|
|
149
|
+
msg = PulseMessage(
|
|
150
|
+
action="ACT.TRANSACT.REQUEST",
|
|
151
|
+
parameters=trade_params,
|
|
152
|
+
validate=False,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Connect if needed
|
|
156
|
+
if hasattr(adapter, "connect") and not getattr(adapter, "_connected", False):
|
|
157
|
+
adapter.connect()
|
|
158
|
+
|
|
159
|
+
response = adapter.send(msg)
|
|
160
|
+
return response.content if hasattr(response, "content") else response
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""WebhookReceiver — HTTP server that accepts TradingView alert webhooks."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
7
|
+
from typing import Callable, Optional
|
|
8
|
+
|
|
9
|
+
from pulse_tradingview.converter import AlertConverter
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WebhookReceiver:
|
|
15
|
+
"""HTTP server that receives TradingView webhook alerts.
|
|
16
|
+
|
|
17
|
+
TradingView sends an HTTP POST to your webhook URL when an alert fires.
|
|
18
|
+
WebhookReceiver listens on a configurable port, parses the alert,
|
|
19
|
+
and calls your handler function.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
>>> def handle(params):
|
|
23
|
+
... print(f"Signal: {params['pair']} {params['direction']}")
|
|
24
|
+
...
|
|
25
|
+
>>> receiver = WebhookReceiver(port=8888, on_signal=handle)
|
|
26
|
+
>>> receiver.start()
|
|
27
|
+
>>> # Set TradingView webhook URL to: http://YOUR_IP:8888/
|
|
28
|
+
>>> receiver.stop()
|
|
29
|
+
|
|
30
|
+
Or use as a context manager:
|
|
31
|
+
>>> with WebhookReceiver(port=8888, on_signal=handle) as r:
|
|
32
|
+
... input("Press Enter to stop...")
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
port: int = 8888,
|
|
38
|
+
on_signal: Optional[Callable] = None,
|
|
39
|
+
secret: Optional[str] = None,
|
|
40
|
+
host: str = "0.0.0.0",
|
|
41
|
+
):
|
|
42
|
+
"""
|
|
43
|
+
Args:
|
|
44
|
+
port: Port to listen on (default: 8888)
|
|
45
|
+
on_signal: Callback called with parsed params dict on each alert
|
|
46
|
+
secret: Optional shared secret for basic auth validation
|
|
47
|
+
host: Host to bind to (default: 0.0.0.0 = all interfaces)
|
|
48
|
+
"""
|
|
49
|
+
self.port = port
|
|
50
|
+
self.host = host
|
|
51
|
+
self.on_signal = on_signal or (lambda params: None)
|
|
52
|
+
self.secret = secret
|
|
53
|
+
self._server: Optional[HTTPServer] = None
|
|
54
|
+
self._thread: Optional[threading.Thread] = None
|
|
55
|
+
self._converter = AlertConverter()
|
|
56
|
+
|
|
57
|
+
def start(self) -> None:
|
|
58
|
+
"""Start the webhook server in a background thread."""
|
|
59
|
+
handler = self._make_handler()
|
|
60
|
+
self._server = HTTPServer((self.host, self.port), handler)
|
|
61
|
+
self._thread = threading.Thread(
|
|
62
|
+
target=self._server.serve_forever,
|
|
63
|
+
daemon=True,
|
|
64
|
+
name="pulse-tradingview-webhook",
|
|
65
|
+
)
|
|
66
|
+
self._thread.start()
|
|
67
|
+
logger.info(f"WebhookReceiver listening on {self.host}:{self.port}")
|
|
68
|
+
print(f"[pulse-tradingview] Webhook server started on port {self.port}")
|
|
69
|
+
print(f"[pulse-tradingview] Set TradingView webhook URL to: http://YOUR_SERVER_IP:{self.port}/")
|
|
70
|
+
|
|
71
|
+
def stop(self) -> None:
|
|
72
|
+
"""Stop the webhook server."""
|
|
73
|
+
if self._server:
|
|
74
|
+
self._server.shutdown()
|
|
75
|
+
self._server = None
|
|
76
|
+
logger.info("WebhookReceiver stopped")
|
|
77
|
+
|
|
78
|
+
def __enter__(self):
|
|
79
|
+
self.start()
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
def __exit__(self, *args):
|
|
83
|
+
self.stop()
|
|
84
|
+
|
|
85
|
+
def _make_handler(self):
|
|
86
|
+
"""Create the HTTP request handler class."""
|
|
87
|
+
receiver = self
|
|
88
|
+
|
|
89
|
+
class Handler(BaseHTTPRequestHandler):
|
|
90
|
+
def do_POST(self):
|
|
91
|
+
# Validate secret if configured
|
|
92
|
+
if receiver.secret:
|
|
93
|
+
auth = self.headers.get("X-PULSE-Secret", "")
|
|
94
|
+
if auth != receiver.secret:
|
|
95
|
+
self.send_error(401, "Unauthorized")
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
# Read body
|
|
99
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
100
|
+
raw = self.rfile.read(content_length).decode("utf-8", errors="replace")
|
|
101
|
+
|
|
102
|
+
if not raw.strip():
|
|
103
|
+
self.send_error(400, "Empty request body")
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
# Parse alert
|
|
107
|
+
try:
|
|
108
|
+
params = receiver._converter.parse(raw)
|
|
109
|
+
except ValueError as e:
|
|
110
|
+
logger.error(f"Alert parse error: {e} | raw={raw[:200]}")
|
|
111
|
+
self.send_error(400, str(e))
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
# Log the incoming signal
|
|
115
|
+
logger.info(
|
|
116
|
+
f"Alert received: {params.get('pair')} {params.get('direction')} "
|
|
117
|
+
f"(confidence={params.get('confidence', 1.0):.0%})"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Call handler
|
|
121
|
+
try:
|
|
122
|
+
results = receiver.on_signal(params)
|
|
123
|
+
response = {
|
|
124
|
+
"status": "ok",
|
|
125
|
+
"pair": params.get("pair"),
|
|
126
|
+
"direction": params.get("direction"),
|
|
127
|
+
"results": results if isinstance(results, list) else [],
|
|
128
|
+
}
|
|
129
|
+
self.send_response(200)
|
|
130
|
+
self.send_header("Content-Type", "application/json")
|
|
131
|
+
self.end_headers()
|
|
132
|
+
self.wfile.write(json.dumps(response).encode())
|
|
133
|
+
except Exception as e:
|
|
134
|
+
logger.error(f"Handler error: {e}")
|
|
135
|
+
self.send_error(500, str(e))
|
|
136
|
+
|
|
137
|
+
def do_GET(self):
|
|
138
|
+
"""Health check endpoint."""
|
|
139
|
+
self.send_response(200)
|
|
140
|
+
self.send_header("Content-Type", "application/json")
|
|
141
|
+
self.end_headers()
|
|
142
|
+
self.wfile.write(json.dumps({
|
|
143
|
+
"status": "ok",
|
|
144
|
+
"service": "pulse-tradingview",
|
|
145
|
+
"version": "0.1.0",
|
|
146
|
+
"message": "Webhook server is running. POST your TradingView alerts here.",
|
|
147
|
+
}).encode())
|
|
148
|
+
|
|
149
|
+
def log_message(self, fmt, *args):
|
|
150
|
+
# Route HTTP server logs through Python logging
|
|
151
|
+
logger.debug(f"HTTP: {fmt % args}")
|
|
152
|
+
|
|
153
|
+
return Handler
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pulse-tradingview
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Connect TradingView alerts to any crypto exchange via PULSE Protocol — free alternative to 3Commas
|
|
5
|
+
Author: PULSE Protocol
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/pulseprotocolorg-cyber/pulse-tradingview
|
|
8
|
+
Project-URL: Repository, https://github.com/pulseprotocolorg-cyber/pulse-tradingview
|
|
9
|
+
Keywords: tradingview,webhook,crypto,trading,binance,bybit,automation,pine-script,pulse
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
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
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Provides-Extra: binance
|
|
24
|
+
Requires-Dist: pulse-binance>=0.1.0; extra == "binance"
|
|
25
|
+
Provides-Extra: bybit
|
|
26
|
+
Requires-Dist: pulse-bybit>=0.1.0; extra == "bybit"
|
|
27
|
+
Provides-Extra: kraken
|
|
28
|
+
Requires-Dist: pulse-kraken>=0.1.0; extra == "kraken"
|
|
29
|
+
Provides-Extra: okx
|
|
30
|
+
Requires-Dist: pulse-okx>=0.1.0; extra == "okx"
|
|
31
|
+
Provides-Extra: freqtrade
|
|
32
|
+
Requires-Dist: pulse-freqtrade>=0.1.0; extra == "freqtrade"
|
|
33
|
+
Provides-Extra: all
|
|
34
|
+
Requires-Dist: pulse-binance>=0.1.0; extra == "all"
|
|
35
|
+
Requires-Dist: pulse-bybit>=0.1.0; extra == "all"
|
|
36
|
+
Requires-Dist: pulse-kraken>=0.1.0; extra == "all"
|
|
37
|
+
Requires-Dist: pulse-okx>=0.1.0; extra == "all"
|
|
38
|
+
Requires-Dist: pulse-freqtrade>=0.1.0; extra == "all"
|
|
39
|
+
Provides-Extra: dev
|
|
40
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
41
|
+
Requires-Dist: build>=0.10; extra == "dev"
|
|
42
|
+
Requires-Dist: twine>=4.0; extra == "dev"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
pulse_tradingview/__init__.py
|
|
3
|
+
pulse_tradingview/cli.py
|
|
4
|
+
pulse_tradingview/converter.py
|
|
5
|
+
pulse_tradingview/router.py
|
|
6
|
+
pulse_tradingview/version.py
|
|
7
|
+
pulse_tradingview/webhook.py
|
|
8
|
+
pulse_tradingview.egg-info/PKG-INFO
|
|
9
|
+
pulse_tradingview.egg-info/SOURCES.txt
|
|
10
|
+
pulse_tradingview.egg-info/dependency_links.txt
|
|
11
|
+
pulse_tradingview.egg-info/entry_points.txt
|
|
12
|
+
pulse_tradingview.egg-info/requires.txt
|
|
13
|
+
pulse_tradingview.egg-info/top_level.txt
|
|
14
|
+
tests/test_tradingview.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
|
|
2
|
+
[all]
|
|
3
|
+
pulse-binance>=0.1.0
|
|
4
|
+
pulse-bybit>=0.1.0
|
|
5
|
+
pulse-kraken>=0.1.0
|
|
6
|
+
pulse-okx>=0.1.0
|
|
7
|
+
pulse-freqtrade>=0.1.0
|
|
8
|
+
|
|
9
|
+
[binance]
|
|
10
|
+
pulse-binance>=0.1.0
|
|
11
|
+
|
|
12
|
+
[bybit]
|
|
13
|
+
pulse-bybit>=0.1.0
|
|
14
|
+
|
|
15
|
+
[dev]
|
|
16
|
+
pytest>=7.0
|
|
17
|
+
build>=0.10
|
|
18
|
+
twine>=4.0
|
|
19
|
+
|
|
20
|
+
[freqtrade]
|
|
21
|
+
pulse-freqtrade>=0.1.0
|
|
22
|
+
|
|
23
|
+
[kraken]
|
|
24
|
+
pulse-kraken>=0.1.0
|
|
25
|
+
|
|
26
|
+
[okx]
|
|
27
|
+
pulse-okx>=0.1.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pulse_tradingview
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pulse-tradingview"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Connect TradingView alerts to any crypto exchange via PULSE Protocol — free alternative to 3Commas"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "Apache-2.0" }
|
|
11
|
+
authors = [{ name = "PULSE Protocol" }]
|
|
12
|
+
keywords = ["tradingview", "webhook", "crypto", "trading", "binance", "bybit", "automation", "pine-script", "pulse"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Intended Audience :: Financial and Insurance Industry",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.8",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Office/Business :: Financial",
|
|
25
|
+
]
|
|
26
|
+
requires-python = ">=3.8"
|
|
27
|
+
dependencies = []
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
binance = ["pulse-binance>=0.1.0"]
|
|
31
|
+
bybit = ["pulse-bybit>=0.1.0"]
|
|
32
|
+
kraken = ["pulse-kraken>=0.1.0"]
|
|
33
|
+
okx = ["pulse-okx>=0.1.0"]
|
|
34
|
+
freqtrade = ["pulse-freqtrade>=0.1.0"]
|
|
35
|
+
all = [
|
|
36
|
+
"pulse-binance>=0.1.0",
|
|
37
|
+
"pulse-bybit>=0.1.0",
|
|
38
|
+
"pulse-kraken>=0.1.0",
|
|
39
|
+
"pulse-okx>=0.1.0",
|
|
40
|
+
"pulse-freqtrade>=0.1.0",
|
|
41
|
+
]
|
|
42
|
+
dev = ["pytest>=7.0", "build>=0.10", "twine>=4.0"]
|
|
43
|
+
|
|
44
|
+
[project.scripts]
|
|
45
|
+
pulse-tv = "pulse_tradingview.cli:main"
|
|
46
|
+
|
|
47
|
+
[project.urls]
|
|
48
|
+
Homepage = "https://github.com/pulseprotocolorg-cyber/pulse-tradingview"
|
|
49
|
+
Repository = "https://github.com/pulseprotocolorg-cyber/pulse-tradingview"
|
|
50
|
+
|
|
51
|
+
[tool.setuptools.packages.find]
|
|
52
|
+
where = ["."]
|
|
53
|
+
include = ["pulse_tradingview*"]
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Tests for pulse-tradingview."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import urllib.request
|
|
7
|
+
from unittest.mock import MagicMock, patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from pulse_tradingview import AlertConverter, SignalRouter, WebhookReceiver
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# --- AlertConverter tests ---
|
|
15
|
+
|
|
16
|
+
class TestAlertConverter:
|
|
17
|
+
def setup_method(self):
|
|
18
|
+
self.converter = AlertConverter()
|
|
19
|
+
|
|
20
|
+
# JSON format tests
|
|
21
|
+
def test_parse_buy_json(self):
|
|
22
|
+
raw = '{"action": "buy", "symbol": "BTCUSDT", "price": "83000", "interval": "1h"}'
|
|
23
|
+
params = self.converter.parse(raw)
|
|
24
|
+
assert params["direction"] == "long"
|
|
25
|
+
assert params["pair"] == "BTC/USDT"
|
|
26
|
+
assert params["price"] == 83000.0
|
|
27
|
+
assert params["interval"] == "1h"
|
|
28
|
+
assert params["source"] == "tradingview"
|
|
29
|
+
|
|
30
|
+
def test_parse_sell_json(self):
|
|
31
|
+
params = self.converter.parse('{"action": "sell", "symbol": "ETHUSDT"}')
|
|
32
|
+
assert params["direction"] == "short"
|
|
33
|
+
assert params["pair"] == "ETH/USDT"
|
|
34
|
+
|
|
35
|
+
def test_parse_long_action(self):
|
|
36
|
+
params = self.converter.parse('{"action": "long", "symbol": "SOLUSDT"}')
|
|
37
|
+
assert params["direction"] == "long"
|
|
38
|
+
|
|
39
|
+
def test_parse_short_action(self):
|
|
40
|
+
params = self.converter.parse('{"action": "short", "symbol": "BTCUSDT"}')
|
|
41
|
+
assert params["direction"] == "short"
|
|
42
|
+
|
|
43
|
+
def test_parse_close_action(self):
|
|
44
|
+
params = self.converter.parse('{"action": "close", "symbol": "BTCUSDT"}')
|
|
45
|
+
assert params["direction"] == "neutral"
|
|
46
|
+
|
|
47
|
+
def test_parse_confidence(self):
|
|
48
|
+
params = self.converter.parse(
|
|
49
|
+
'{"action": "buy", "symbol": "BTCUSDT", "confidence": "0.85"}'
|
|
50
|
+
)
|
|
51
|
+
assert params["confidence"] == 0.85
|
|
52
|
+
|
|
53
|
+
def test_parse_confidence_percentage(self):
|
|
54
|
+
# Some users send confidence as 85 instead of 0.85
|
|
55
|
+
params = self.converter.parse(
|
|
56
|
+
'{"action": "buy", "symbol": "BTCUSDT", "confidence": "85"}'
|
|
57
|
+
)
|
|
58
|
+
assert params["confidence"] == 0.85
|
|
59
|
+
|
|
60
|
+
def test_default_confidence_is_1(self):
|
|
61
|
+
params = self.converter.parse('{"action": "buy", "symbol": "BTCUSDT"}')
|
|
62
|
+
assert params["confidence"] == 1.0
|
|
63
|
+
|
|
64
|
+
def test_parse_ticker_field(self):
|
|
65
|
+
# TradingView uses {{ticker}} which sends as "ticker" key
|
|
66
|
+
params = self.converter.parse('{"action": "buy", "ticker": "BTCUSDT"}')
|
|
67
|
+
assert params["pair"] == "BTC/USDT"
|
|
68
|
+
|
|
69
|
+
def test_parse_with_extra_fields(self):
|
|
70
|
+
raw = '{"action": "buy", "symbol": "BTCUSDT", "rsi": "28", "strategy": "RSI_BB"}'
|
|
71
|
+
params = self.converter.parse(raw)
|
|
72
|
+
assert params["direction"] == "long"
|
|
73
|
+
assert "extra" in params
|
|
74
|
+
assert params["extra"]["rsi"] == "28"
|
|
75
|
+
|
|
76
|
+
# Symbol conversion tests
|
|
77
|
+
def test_symbol_btcusdt(self):
|
|
78
|
+
params = self.converter.parse('{"action": "buy", "symbol": "BTCUSDT"}')
|
|
79
|
+
assert params["pair"] == "BTC/USDT"
|
|
80
|
+
|
|
81
|
+
def test_symbol_ethbtc(self):
|
|
82
|
+
params = self.converter.parse('{"action": "buy", "symbol": "ETHBTC"}')
|
|
83
|
+
assert params["pair"] == "ETH/BTC"
|
|
84
|
+
|
|
85
|
+
def test_symbol_solusdt(self):
|
|
86
|
+
params = self.converter.parse('{"action": "sell", "symbol": "SOLUSDT"}')
|
|
87
|
+
assert params["pair"] == "SOL/USDT"
|
|
88
|
+
|
|
89
|
+
def test_symbol_already_formatted(self):
|
|
90
|
+
params = self.converter.parse('{"action": "buy", "symbol": "BTC/USDT"}')
|
|
91
|
+
assert params["pair"] == "BTC/USDT"
|
|
92
|
+
|
|
93
|
+
def test_symbol_eur_pair(self):
|
|
94
|
+
params = self.converter.parse('{"action": "buy", "symbol": "BTCEUR"}')
|
|
95
|
+
assert params["pair"] == "BTC/EUR"
|
|
96
|
+
|
|
97
|
+
# Interval tests
|
|
98
|
+
def test_interval_60_to_1h(self):
|
|
99
|
+
params = self.converter.parse('{"action": "buy", "symbol": "BTCUSDT", "interval": "60"}')
|
|
100
|
+
assert params["interval"] == "1h"
|
|
101
|
+
|
|
102
|
+
def test_interval_1D(self):
|
|
103
|
+
params = self.converter.parse('{"action": "buy", "symbol": "BTCUSDT", "interval": "1D"}')
|
|
104
|
+
assert params["interval"] == "1d"
|
|
105
|
+
|
|
106
|
+
def test_interval_passthrough(self):
|
|
107
|
+
params = self.converter.parse('{"action": "buy", "symbol": "BTCUSDT", "interval": "4h"}')
|
|
108
|
+
assert params["interval"] == "4h"
|
|
109
|
+
|
|
110
|
+
# Plain text format
|
|
111
|
+
def test_parse_plain_buy(self):
|
|
112
|
+
params = self.converter.parse("BUY BTCUSDT")
|
|
113
|
+
assert params["direction"] == "long"
|
|
114
|
+
assert params["pair"] == "BTC/USDT"
|
|
115
|
+
|
|
116
|
+
def test_parse_plain_sell_with_price(self):
|
|
117
|
+
params = self.converter.parse("SELL ETHUSDT 3500")
|
|
118
|
+
assert params["direction"] == "short"
|
|
119
|
+
assert params["price"] == 3500.0
|
|
120
|
+
|
|
121
|
+
def test_parse_plain_case_insensitive(self):
|
|
122
|
+
params = self.converter.parse("buy BTCUSDT")
|
|
123
|
+
assert params["direction"] == "long"
|
|
124
|
+
|
|
125
|
+
# Error cases
|
|
126
|
+
def test_invalid_action(self):
|
|
127
|
+
with pytest.raises(ValueError, match="Unknown action"):
|
|
128
|
+
self.converter.parse('{"action": "hold", "symbol": "BTCUSDT"}')
|
|
129
|
+
|
|
130
|
+
def test_missing_symbol(self):
|
|
131
|
+
with pytest.raises(ValueError, match="missing 'symbol'"):
|
|
132
|
+
self.converter.parse('{"action": "buy"}')
|
|
133
|
+
|
|
134
|
+
def test_invalid_json(self):
|
|
135
|
+
with pytest.raises(ValueError, match="Invalid JSON"):
|
|
136
|
+
self.converter.parse('{"action": "buy", broken}')
|
|
137
|
+
|
|
138
|
+
def test_plain_text_too_short(self):
|
|
139
|
+
with pytest.raises(ValueError):
|
|
140
|
+
self.converter.parse("BUY")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# --- SignalRouter tests ---
|
|
144
|
+
|
|
145
|
+
class TestSignalRouter:
|
|
146
|
+
def test_add_adapter(self):
|
|
147
|
+
router = SignalRouter()
|
|
148
|
+
mock = MagicMock()
|
|
149
|
+
router.add_adapter(mock, "test")
|
|
150
|
+
assert len(router._adapters) == 1
|
|
151
|
+
|
|
152
|
+
def test_route_calls_adapter(self):
|
|
153
|
+
router = SignalRouter()
|
|
154
|
+
mock_adapter = MagicMock()
|
|
155
|
+
mock_adapter.send.return_value = MagicMock(content={"result": "ok"})
|
|
156
|
+
|
|
157
|
+
with patch("pulse_tradingview.router.PulseMessage") if False else patch.object(
|
|
158
|
+
router, "_send_to_adapter", return_value={"result": "ok"}
|
|
159
|
+
):
|
|
160
|
+
router.add_adapter(mock_adapter, "test")
|
|
161
|
+
results = router.route({
|
|
162
|
+
"pair": "BTC/USDT",
|
|
163
|
+
"symbol": "BTCUSDT",
|
|
164
|
+
"direction": "long",
|
|
165
|
+
"confidence": 0.9,
|
|
166
|
+
"source": "tradingview",
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
assert len(results) == 1
|
|
170
|
+
assert results[0]["status"] == "ok"
|
|
171
|
+
|
|
172
|
+
def test_filter_blocks_low_confidence(self):
|
|
173
|
+
router = SignalRouter()
|
|
174
|
+
mock = MagicMock()
|
|
175
|
+
router.add_adapter(mock, "test")
|
|
176
|
+
router.add_filter(lambda p: p["confidence"] >= 0.8)
|
|
177
|
+
|
|
178
|
+
results = router.route({
|
|
179
|
+
"pair": "BTC/USDT",
|
|
180
|
+
"direction": "long",
|
|
181
|
+
"confidence": 0.5,
|
|
182
|
+
})
|
|
183
|
+
assert results == []
|
|
184
|
+
|
|
185
|
+
def test_filter_allows_high_confidence(self):
|
|
186
|
+
router = SignalRouter()
|
|
187
|
+
mock = MagicMock()
|
|
188
|
+
router.add_adapter(mock, "test")
|
|
189
|
+
router.add_filter(lambda p: p["confidence"] >= 0.8)
|
|
190
|
+
|
|
191
|
+
with patch.object(router, "_send_to_adapter", return_value={}):
|
|
192
|
+
results = router.route({
|
|
193
|
+
"pair": "BTC/USDT",
|
|
194
|
+
"direction": "long",
|
|
195
|
+
"confidence": 0.9,
|
|
196
|
+
})
|
|
197
|
+
assert len(results) == 1
|
|
198
|
+
|
|
199
|
+
def test_pair_filter(self):
|
|
200
|
+
router = SignalRouter()
|
|
201
|
+
mock = MagicMock()
|
|
202
|
+
router.add_adapter(mock, "test")
|
|
203
|
+
router.add_filter(lambda p: p["pair"] in ["BTC/USDT", "ETH/USDT"])
|
|
204
|
+
|
|
205
|
+
results = router.route({"pair": "DOGE/USDT", "direction": "long", "confidence": 1.0})
|
|
206
|
+
assert results == []
|
|
207
|
+
|
|
208
|
+
def test_no_adapters_returns_empty(self):
|
|
209
|
+
router = SignalRouter()
|
|
210
|
+
results = router.route({"pair": "BTC/USDT", "direction": "long"})
|
|
211
|
+
assert results == []
|
|
212
|
+
|
|
213
|
+
def test_adapter_error_is_caught(self):
|
|
214
|
+
router = SignalRouter()
|
|
215
|
+
mock = MagicMock()
|
|
216
|
+
router.add_adapter(mock, "test")
|
|
217
|
+
|
|
218
|
+
with patch.object(router, "_send_to_adapter", side_effect=RuntimeError("API error")):
|
|
219
|
+
results = router.route({"pair": "BTC/USDT", "direction": "long", "confidence": 1.0})
|
|
220
|
+
|
|
221
|
+
assert len(results) == 1
|
|
222
|
+
assert results[0]["status"] == "error"
|
|
223
|
+
assert "API error" in results[0]["error"]
|
|
224
|
+
|
|
225
|
+
def test_chaining(self):
|
|
226
|
+
router = SignalRouter()
|
|
227
|
+
mock = MagicMock()
|
|
228
|
+
result = router.add_adapter(mock).add_filter(lambda p: True)
|
|
229
|
+
assert result is router
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# --- WebhookReceiver tests ---
|
|
233
|
+
|
|
234
|
+
class TestWebhookReceiver:
|
|
235
|
+
def test_start_stop(self):
|
|
236
|
+
received = []
|
|
237
|
+
receiver = WebhookReceiver(port=18888, on_signal=lambda p: received.append(p))
|
|
238
|
+
receiver.start()
|
|
239
|
+
time.sleep(0.1)
|
|
240
|
+
receiver.stop()
|
|
241
|
+
|
|
242
|
+
def test_health_check(self):
|
|
243
|
+
receiver = WebhookReceiver(port=18889)
|
|
244
|
+
receiver.start()
|
|
245
|
+
time.sleep(0.1)
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
req = urllib.request.Request("http://localhost:18889/")
|
|
249
|
+
with urllib.request.urlopen(req, timeout=2) as r:
|
|
250
|
+
data = json.loads(r.read())
|
|
251
|
+
assert data["status"] == "ok"
|
|
252
|
+
assert "pulse-tradingview" in data["service"]
|
|
253
|
+
finally:
|
|
254
|
+
receiver.stop()
|
|
255
|
+
|
|
256
|
+
def test_receives_alert(self):
|
|
257
|
+
received = []
|
|
258
|
+
|
|
259
|
+
def handler(params):
|
|
260
|
+
received.append(params)
|
|
261
|
+
return []
|
|
262
|
+
|
|
263
|
+
receiver = WebhookReceiver(port=18890, on_signal=handler)
|
|
264
|
+
receiver.start()
|
|
265
|
+
time.sleep(0.1)
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
payload = json.dumps({
|
|
269
|
+
"action": "buy",
|
|
270
|
+
"symbol": "BTCUSDT",
|
|
271
|
+
"price": "83000",
|
|
272
|
+
}).encode()
|
|
273
|
+
req = urllib.request.Request(
|
|
274
|
+
"http://localhost:18890/",
|
|
275
|
+
data=payload,
|
|
276
|
+
headers={"Content-Type": "application/json"},
|
|
277
|
+
method="POST",
|
|
278
|
+
)
|
|
279
|
+
with urllib.request.urlopen(req, timeout=2) as r:
|
|
280
|
+
response = json.loads(r.read())
|
|
281
|
+
|
|
282
|
+
assert response["status"] == "ok"
|
|
283
|
+
assert len(received) == 1
|
|
284
|
+
assert received[0]["direction"] == "long"
|
|
285
|
+
assert received[0]["pair"] == "BTC/USDT"
|
|
286
|
+
finally:
|
|
287
|
+
receiver.stop()
|
|
288
|
+
|
|
289
|
+
def test_bad_payload_returns_400(self):
|
|
290
|
+
receiver = WebhookReceiver(port=18891)
|
|
291
|
+
receiver.start()
|
|
292
|
+
time.sleep(0.1)
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
payload = b'{"action": "hold", "symbol": "BTCUSDT"}'
|
|
296
|
+
req = urllib.request.Request(
|
|
297
|
+
"http://localhost:18891/",
|
|
298
|
+
data=payload,
|
|
299
|
+
headers={"Content-Type": "application/json"},
|
|
300
|
+
method="POST",
|
|
301
|
+
)
|
|
302
|
+
try:
|
|
303
|
+
urllib.request.urlopen(req, timeout=2)
|
|
304
|
+
assert False, "Should have raised"
|
|
305
|
+
except urllib.error.HTTPError as e:
|
|
306
|
+
assert e.code == 400
|
|
307
|
+
finally:
|
|
308
|
+
receiver.stop()
|
|
309
|
+
|
|
310
|
+
def test_context_manager(self):
|
|
311
|
+
received = []
|
|
312
|
+
with WebhookReceiver(port=18892, on_signal=lambda p: received.append(p)):
|
|
313
|
+
time.sleep(0.05)
|
|
314
|
+
# Server should be running
|
|
315
|
+
req = urllib.request.Request("http://localhost:18892/")
|
|
316
|
+
with urllib.request.urlopen(req, timeout=2) as r:
|
|
317
|
+
data = json.loads(r.read())
|
|
318
|
+
assert data["status"] == "ok"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# --- Integration test ---
|
|
322
|
+
|
|
323
|
+
class TestIntegration:
|
|
324
|
+
def test_full_pipeline(self):
|
|
325
|
+
"""TradingView alert → WebhookReceiver → AlertConverter → SignalRouter"""
|
|
326
|
+
routed = []
|
|
327
|
+
|
|
328
|
+
converter = AlertConverter()
|
|
329
|
+
router = SignalRouter()
|
|
330
|
+
|
|
331
|
+
def fake_send(adapter, params, label):
|
|
332
|
+
routed.append(params)
|
|
333
|
+
return {"result": "ok"}
|
|
334
|
+
|
|
335
|
+
mock_adapter = MagicMock()
|
|
336
|
+
router.add_adapter(mock_adapter, "test")
|
|
337
|
+
router._send_to_adapter = fake_send
|
|
338
|
+
|
|
339
|
+
def on_signal(params):
|
|
340
|
+
return router.route(params)
|
|
341
|
+
|
|
342
|
+
receiver = WebhookReceiver(port=18893, on_signal=on_signal)
|
|
343
|
+
receiver.start()
|
|
344
|
+
time.sleep(0.1)
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
alerts = [
|
|
348
|
+
'{"action": "buy", "symbol": "BTCUSDT", "price": "83000", "confidence": "0.92"}',
|
|
349
|
+
'{"action": "sell", "symbol": "ETHUSDT", "price": "3500"}',
|
|
350
|
+
]
|
|
351
|
+
for alert in alerts:
|
|
352
|
+
req = urllib.request.Request(
|
|
353
|
+
"http://localhost:18893/",
|
|
354
|
+
data=alert.encode(),
|
|
355
|
+
headers={"Content-Type": "application/json"},
|
|
356
|
+
method="POST",
|
|
357
|
+
)
|
|
358
|
+
with urllib.request.urlopen(req, timeout=2):
|
|
359
|
+
pass
|
|
360
|
+
|
|
361
|
+
assert len(routed) == 2
|
|
362
|
+
assert routed[0]["direction"] == "long"
|
|
363
|
+
assert routed[0]["pair"] == "BTC/USDT"
|
|
364
|
+
assert routed[0]["confidence"] == 0.92
|
|
365
|
+
assert routed[1]["direction"] == "short"
|
|
366
|
+
assert routed[1]["pair"] == "ETH/USDT"
|
|
367
|
+
finally:
|
|
368
|
+
receiver.stop()
|