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.
@@ -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,2 @@
1
+ [console_scripts]
2
+ pulse-tv = pulse_tradingview.cli:main
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()