Alerts and ServLoci Dispatch¶
Generate de-duplicated alerts and pass reviewed intents to a dry-run ServLoci order boundary.
Part 23 of 24 in the ServLoci algo/options trading notebook series — full index in notebooks/README.md.
Setup¶
# Get your dedicated static IPv6 + SOCKS5 credentials free:
# https://comm.servloci.in/register (or /auth/google?free=1 for an instant trial)
# Your api_key / api_secret pair shows up in the portal after signup:
# https://comm.servloci.in/user
!pip install -q "requests[socks]"
!curl -sL https://comm.servloci.in/sdk/servloci.py -o servloci.py
import os
from servloci import ServLoci
SERVLOCI_API_KEY = os.environ.get("SERVLOCI_API_KEY", "dhan:1000000001") # broker:client_id
SERVLOCI_API_SECRET = os.environ.get("SERVLOCI_API_SECRET", "") # from the portal — leave blank to run this notebook in demo mode
sl = None
if SERVLOCI_API_SECRET:
sl = ServLoci(api_key=SERVLOCI_API_KEY, api_secret=SERVLOCI_API_SECRET)
print("ServLoci configured:", sl.host, sl.port)
else:
print("SERVLOCI_API_SECRET not set — running in demo mode (no live proxy calls).")
Alert first, order later¶
This notebook turns completed candles into stateful alerts. It de-duplicates repeated signals, includes the observed values, and defaults to console output. Telegram and generic webhook sinks are opt-in. A separate, dry-run order boundary shows where ServLoci belongs.
!pip install -q pandas numpy requests
import numpy as np
import pandas as pd
def compute_top_50(frame):
"""Return 50 named indicators from an OHLCV DataFrame.
Input columns are case-insensitive: open, high, low, close and volume.
Warm-up rows contain NaN by design; never backfill them into a live signal.
"""
df = frame.rename(columns={c: str(c).lower() for c in frame.columns}).copy()
required = {"open", "high", "low", "close", "volume"}
missing = required.difference(df.columns)
if missing:
raise ValueError(f"missing OHLCV columns: {sorted(missing)}")
o, h, l, c, v = (df[x].astype(float) for x in ("open", "high", "low", "close", "volume"))
out = pd.DataFrame(index=df.index)
safe = lambda x: x.replace([np.inf, -np.inf], np.nan)
ema = lambda x, n: x.ewm(span=n, adjust=False, min_periods=n).mean()
wma = lambda x, n: x.rolling(n).apply(
lambda a: np.dot(a, np.arange(1, n + 1)) / (n * (n + 1) / 2), raw=True
)
# Trend and moving-average family (1-11)
out["01_sma_20"] = c.rolling(20).mean()
out["02_ema_20"] = ema(c, 20)
out["03_wma_20"] = wma(c, 20)
out["04_hma_20"] = wma(2 * wma(c, 10) - wma(c, 20), 4)
e1 = ema(c, 20); e2 = ema(e1, 20); e3 = ema(e2, 20)
out["05_dema_20"] = 2 * e1 - e2
out["06_tema_20"] = 3 * e1 - 3 * e2 + e3
out["07_vwma_20"] = safe((c * v).rolling(20).sum() / v.rolling(20).sum())
macd = ema(c, 12) - ema(c, 26)
out["08_macd"] = macd
out["09_macd_signal"] = ema(macd, 9)
out["10_ppo"] = safe(100 * macd / ema(c, 26))
ex1 = ema(c, 15); ex2 = ema(ex1, 15); ex3 = ema(ex2, 15)
out["11_trix"] = ex3.pct_change(fill_method=None) * 100
# Momentum and oscillator family (12-24)
out["12_roc_12"] = c.pct_change(12, fill_method=None) * 100
out["13_momentum_10"] = c.diff(10)
delta = c.diff(); gain = delta.clip(lower=0); loss = -delta.clip(upper=0)
avg_gain = gain.ewm(alpha=1/14, adjust=False, min_periods=14).mean()
avg_loss = loss.ewm(alpha=1/14, adjust=False, min_periods=14).mean()
out["14_rsi_14"] = 100 - (100 / (1 + safe(avg_gain / avg_loss)))
low14, high14 = l.rolling(14).min(), h.rolling(14).max()
stoch = safe(100 * (c - low14) / (high14 - low14))
out["15_stochastic_k"] = stoch
out["16_stochastic_d"] = stoch.rolling(3).mean()
out["17_williams_r"] = safe(-100 * (high14 - c) / (high14 - low14))
typical = (h + l + c) / 3
tp_mean = typical.rolling(20).mean()
mean_dev = typical.rolling(20).apply(lambda a: np.mean(np.abs(a - a.mean())), raw=True)
out["18_cci_20"] = safe((typical - tp_mean) / (0.015 * mean_dev))
prev = c.shift(1)
buy_pressure = c - pd.concat([l, prev], axis=1).min(axis=1)
true_range = pd.concat([h - l, (h - prev).abs(), (l - prev).abs()], axis=1).max(axis=1)
out["19_ultimate_oscillator"] = safe(100 * (
4 * buy_pressure.rolling(7).sum() / true_range.rolling(7).sum()
+ 2 * buy_pressure.rolling(14).sum() / true_range.rolling(14).sum()
+ buy_pressure.rolling(28).sum() / true_range.rolling(28).sum()
) / 7)
midpoint = (h + l) / 2
out["20_awesome_oscillator"] = midpoint.rolling(5).mean() - midpoint.rolling(34).mean()
r1, r2, r3, r4 = (c.pct_change(n, fill_method=None) * 100 for n in (10, 15, 20, 30))
out["21_kst"] = r1.rolling(10).sum() + 2*r2.rolling(10).sum() + 3*r3.rolling(10).sum() + 4*r4.rolling(15).sum()
pc = c.diff(); apc = pc.abs()
out["22_tsi"] = safe(100 * ema(ema(pc, 25), 13) / ema(ema(apc, 25), 13))
streak = pd.Series(0.0, index=c.index)
for i in range(1, len(c)):
direction = np.sign(c.iloc[i] - c.iloc[i - 1])
prior = streak.iloc[i - 1]
streak.iloc[i] = 0 if direction == 0 else direction * (abs(prior) + 1 if np.sign(prior) == direction else 1)
def rsi_series(x, n):
d = x.diff(); g = d.clip(lower=0).ewm(alpha=1/n, adjust=False, min_periods=n).mean()
q = (-d.clip(upper=0)).ewm(alpha=1/n, adjust=False, min_periods=n).mean()
return 100 - 100 / (1 + safe(g / q))
pct_rank = c.pct_change(fill_method=None).rolling(100).apply(lambda a: 100 * (a[-1] > a[:-1]).mean(), raw=True)
out["23_connors_rsi"] = (rsi_series(c, 3) + rsi_series(streak, 2) + pct_rank) / 3
sum_gain, sum_loss = gain.rolling(14).sum(), loss.rolling(14).sum()
out["24_cmo_14"] = safe(100 * (sum_gain - sum_loss) / (sum_gain + sum_loss))
# Volatility and channel family (25-36)
mid = c.rolling(20).mean(); std = c.rolling(20).std(ddof=0)
upper, lower = mid + 2*std, mid - 2*std
out["25_bollinger_upper"] = upper
out["26_bollinger_lower"] = lower
out["27_bollinger_bandwidth"] = safe(100 * (upper - lower) / mid)
atr = true_range.ewm(alpha=1/14, adjust=False, min_periods=14).mean()
out["28_atr_14"] = atr
out["29_natr_14"] = safe(100 * atr / c)
out["30_true_range"] = true_range
kel_mid = ema(c, 20)
out["31_keltner_upper"] = kel_mid + 2 * atr
out["32_keltner_lower"] = kel_mid - 2 * atr
out["33_donchian_upper"] = h.rolling(20).max()
out["34_donchian_lower"] = l.rolling(20).min()
out["35_stddev_20"] = std
out["36_historical_volatility"] = np.log(c / c.shift(1)).rolling(20).std(ddof=0) * np.sqrt(252) * 100
# Directional, stop and cloud family (37-46)
up_move, down_move = h.diff(), -l.diff()
plus_dm = up_move.where((up_move > down_move) & (up_move > 0), 0.0)
minus_dm = down_move.where((down_move > up_move) & (down_move > 0), 0.0)
plus_di = safe(100 * plus_dm.ewm(alpha=1/14, adjust=False, min_periods=14).mean() / atr)
minus_di = safe(100 * minus_dm.ewm(alpha=1/14, adjust=False, min_periods=14).mean() / atr)
out["37_adx_14"] = safe(100 * (plus_di - minus_di).abs() / (plus_di + minus_di)).ewm(alpha=1/14, adjust=False, min_periods=14).mean()
out["38_plus_di"] = plus_di
out["39_minus_di"] = minus_di
out["40_aroon_up"] = h.rolling(25).apply(lambda a: 100 * (np.argmax(a) + 1) / len(a), raw=True)
out["41_aroon_down"] = l.rolling(25).apply(lambda a: 100 * (np.argmin(a) + 1) / len(a), raw=True)
out["42_vortex_plus"] = safe((h - l.shift(1)).abs().rolling(14).sum() / true_range.rolling(14).sum())
out["43_vortex_minus"] = safe((l - h.shift(1)).abs().rolling(14).sum() / true_range.rolling(14).sum())
psar = pd.Series(np.nan, index=c.index)
if len(c):
bull, af, extreme = True, 0.02, h.iloc[0]
psar.iloc[0] = l.iloc[0]
for i in range(1, len(c)):
candidate = psar.iloc[i-1] + af * (extreme - psar.iloc[i-1])
if bull:
candidate = min(candidate, l.iloc[i-1], l.iloc[max(i-2, 0)])
if l.iloc[i] < candidate: bull, candidate, af, extreme = False, extreme, 0.02, l.iloc[i]
elif h.iloc[i] > extreme: extreme, af = h.iloc[i], min(af + 0.02, 0.2)
else:
candidate = max(candidate, h.iloc[i-1], h.iloc[max(i-2, 0)])
if h.iloc[i] > candidate: bull, candidate, af, extreme = True, extreme, 0.02, h.iloc[i]
elif l.iloc[i] < extreme: extreme, af = l.iloc[i], min(af + 0.02, 0.2)
psar.iloc[i] = candidate
out["44_parabolic_sar"] = psar
out["45_ichimoku_conversion"] = (h.rolling(9).max() + l.rolling(9).min()) / 2
out["46_ichimoku_base"] = (h.rolling(26).max() + l.rolling(26).min()) / 2
# Volume and money-flow family (47-50)
out["47_obv"] = (np.sign(c.diff()).fillna(0) * v).cumsum()
raw_money = typical * v; positive = raw_money.where(typical.diff() > 0, 0); negative = raw_money.where(typical.diff() < 0, 0)
out["48_mfi_14"] = 100 - 100 / (1 + safe(positive.rolling(14).sum() / negative.rolling(14).sum()))
money_flow_multiplier = safe(((c - l) - (h - c)) / (h - l))
money_flow_volume = money_flow_multiplier * v
out["49_cmf_20"] = safe(money_flow_volume.rolling(20).sum() / v.rolling(20).sum())
out["50_accumulation_distribution"] = money_flow_volume.fillna(0).cumsum()
assert out.shape[1] == 50
return out
from dataclasses import dataclass
from datetime import datetime, timezone
import json, os, time, requests
@dataclass(frozen=True)
class Alert:
key: str
symbol: str
side: str
message: str
observed_at: str
class AlertRouter:
def __init__(self, cooldown_seconds=900):
self.cooldown = cooldown_seconds
self.sent_at = {}
def should_send(self, alert):
now = time.time(); previous = self.sent_at.get(alert.key, 0)
if now - previous < self.cooldown:
return False
self.sent_at[alert.key] = now
return True
def console(self, alert):
print(json.dumps(alert.__dict__, indent=2))
def webhook(self, alert, url):
response = requests.post(url, json={"text": alert.message, "alert": alert.__dict__}, timeout=10)
response.raise_for_status()
def telegram(self, alert, bot_token, chat_id):
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
response = requests.post(url, json={"chat_id": chat_id, "text": alert.message}, timeout=10)
response.raise_for_status()
def closed_candle_signal(symbol, bars, indicators):
# Call only after the broker confirms the candle is closed.
latest, previous = indicators.iloc[-1], indicators.iloc[-2]
price = float(bars["close"].iloc[-1])
crossed_up = previous["08_macd"] <= previous["09_macd_signal"] and latest["08_macd"] > latest["09_macd_signal"]
risk_ok = 45 <= latest["14_rsi_14"] <= 70 and latest["37_adx_14"] >= 20
if not (crossed_up and risk_ok):
return None
when = bars.index[-1].isoformat()
return Alert(key=f"{symbol}:macd-up:{when}", symbol=symbol, side="BUY_WATCH",
message=f"{symbol}: MACD crossed up; close={price:.2f}, RSI={latest['14_rsi_14']:.1f}, ADX={latest['37_adx_14']:.1f}",
observed_at=when)
# Reproducible dry run. Replace `bars` with notebook 22's broker adapter output.
rng = np.random.default_rng(99); n = 340
close = pd.Series(20000 + np.r_[rng.normal(-3, 30, 300), rng.normal(25, 20, 40)].cumsum())
bars = pd.DataFrame({"open": close.shift().fillna(close.iloc[0]), "high": close+35,
"low": close-35, "close": close, "volume": rng.integers(100000, 900000, n)},
index=pd.date_range("2025-01-01", periods=n, freq="15min", tz="Asia/Kolkata"))
indicators = compute_top_50(bars)
alert = closed_candle_signal("NIFTY", bars, indicators)
router = AlertRouter()
if alert and router.should_send(alert):
router.console(alert)
if os.getenv("ALERT_WEBHOOK_URL"):
router.webhook(alert, os.environ["ALERT_WEBHOOK_URL"])
if os.getenv("TELEGRAM_BOT_TOKEN") and os.getenv("TELEGRAM_CHAT_ID"):
router.telegram(alert, os.environ["TELEGRAM_BOT_TOKEN"], os.environ["TELEGRAM_CHAT_ID"])
else:
print("No new completed-candle alert. This is a valid outcome.")
Optional ServLoci order boundary (dry-run by default)¶
DRY_RUN = True
def dispatch_after_risk_checks(alert, quantity, max_quantity=1):
if alert is None: return None
if quantity < 1 or quantity > max_quantity: raise ValueError("quantity rejected by local risk gate")
intent = {"symbol": alert.symbol, "side": "BUY", "quantity": quantity,
"client_order_id": alert.key, "signal_time": alert.observed_at}
if DRY_RUN:
print("DRY RUN — order intent not sent:", intent)
return intent
# Configure before constructing the supported broker SDK client:
# from servloci import configure
# configure(token=os.environ["STATIC_IP_TOKEN"], broker=os.environ["BROKER"])
# broker = build_current_broker_client_from_private_secrets()
# return broker.place_order(... current broker fields ...)
raise RuntimeError("Wire one reviewed broker adapter before disabling DRY_RUN")
dispatch_after_risk_checks(alert, quantity=1)
Production alerts should persist de-duplication state outside the notebook, retry with bounded backoff, record delivery responses and expose a heartbeat/no-data alert. The order worker should independently re-check candle freshness, position, funds/margin, market session, maximum loss, quantity and the idempotency key. An alert is evidence to review—not proof of a profitable trade.
« Previous: Broker Data to Indicator Pipeline
Try the concepts above interactively: Options Strategy Builder · Docs · Get your static IP