# — Risk & Capital Architecture —
TOTAL_CAPITAL = 30000
MAX_DAILY_LOSS = -1500
CAPITAL_PER_TRADE = 10000
MAX_CONCURRENT_POSITIONS = TOTAL_CAPITAL // CAPITAL_PER_TRADE # 3, given current numbers
ATR_MULT_SL = 1.5 # Matches backtest
ATR_MULT_TGT = 3.0 # Matches backtest
# Where the day’s “already traded” state is persisted between runs.
STATE_DIR = “algo_state”
os.makedirs(STATE_DIR, exist_ok=True)
def state_file_for_today():
return os.path.join(STATE_DIR, f"traded\_{datetime.now().strftime('%Y%m%d')}.json")
def load_traded_state():
"""
Load today's 'already traded' set from disk. This is what fixes the
original bug: algo_memory used to reset to empty every time the script
ran, so if you re-run/re-schedule the scanner during the day (which you
must, since it trades off 5-min candles), a symbol that already fired
could fire again and open a duplicate position.
"""
path = state_file_for_today()
if os.path.exists(path):
with open(path, "r") as f:
return json.load(f)
return {}
def save_traded_state(state):
path = state_file_for_today()
with open(path, "w") as f:
json.dump(state, f, indent=2)
def reconcile_with_broker(tsl, local_state, watchlist):
"""
Local state (the JSON file) is a convenience cache, not the source of
truth. Before trading, cross-check against live broker positions so
that:
- a symbol with an open position is never re-entered, even if the
local state file was deleted/corrupted/out of sync
- a symbol whose local state says 'traded' but has NO live position
(e.g. the order was rejected, or it hit target/SL and squared off)
is treated correctly rather than blindly trusted either way
Returns (updated_state, currently_open_count).
"""
try:
positions_df = tsl.get_positions()
except Exception as e:
print(f"\[bold red\]Could not fetch live positions for reconciliation: {e}\[/bold red\]")
\# Fail closed: if we can't verify broker state, don't trust the
\# local file either — treat everything as untradeable this run.
return {name: {"traded": "yes", "reason": "reconcile_failed"} for name in watchlist}, len(watchlist)
open_symbols = set()
if positions_df is not None and not positions_df.empty and "tradingsymbol" in positions_df.columns:
qty_col = "net_qty" if "net_qty" in positions_df.columns else "quantity"
if qty_col in positions_df.columns:
open_rows = positions_df\[positions_df\[qty_col\] != 0\]
open_symbols = set(open_rows\["tradingsymbol"\].tolist())
for name in watchlist:
if name in open_symbols:
\# Broker confirms a live position exists — lock it regardless
\# of what the local file said.
local_state.setdefault(name, {})
local_state\[name\]\["traded"\] = "yes"
local_state\[name\]\["reason"\] = "live_position_confirmed"
elif local_state.get(name, {}).get("traded") == "yes" and local_state\[name\].get("reason") != "eod_flat":
\# Local file thought we were in a trade, but broker shows no
\# open position (order rejected, or already squared off /
\# hit target/SL). Don't silently re-enter it in the same
\# session — mark it as done for the day rather than treating
\# a completed trade as a fresh signal.
local_state\[name\]\["reason"\] = local_state\[name\].get("reason", "closed_or_never_filled")
currently_open_count = len(open_symbols & set(watchlist))
return local_state, currently_open_count
def order_was_filled_or_accepted(tsl, order_id):
"""
Never assume a placed order succeeded. Poll the order book / order
status endpoint and only commit to state + send a 'LIVE BUY/SELL'
alert once the broker actually confirms the order (accepted/open/
filled), not just because place_super_order() returned without an
exception.
"""
if not order_id:
return False
try:
order_status = tsl.get_order_detail(order_id=order_id)
status = str(order_status.get("status", "")).lower()
return status in ("open", "pending", "trigger pending", "complete", "traded")
except Exception as e:
print(f"\[bold red\]Could not verify order {order_id} status: {e}\[/bold red\]")
return False
# — Initialize Dhan API —
tsl = Tradehull(ClientCode=client_code, mode=“pin_totp”, pin=pin, totp_secret=totp_secret)
# NOTE: fill in client_code / pin / totp_secret / receiver_chat_id / bot_token above with
# your real values before running — these are placeholders, not live credentials.
# — Time-Window Checks (Indian Standard Time) —
now = datetime.now()
current_time_int = int(now.strftime(“%H%M”))
# 1. Block early orders (Before 09:30 AM)
if current_time_int < 930:
print("\[bold orange3\]Execution Halted: Waiting for the 09:30 AM Cool-Down filter to bypass opening noise.\[/bold orange3\]")
sys.exit()
# 2. Block late orders / Trigger EOD Check (After 03:15 PM)
if current_time_int >= 1515:
print("\[bold red\]Market handling window closed (> 15:15). Processing automatic terminal square-offs.\[/bold red\]")
sys.exit()
# — Global Risk Kill Switch Check —
# NOTE: this now fails CLOSED. If we can’t confirm PnL is within limits,
# we do not trade, instead of defaulting to 0 and proceeding.
try:
positions_df = tsl.get_positions()
if positions_df is not None and not positions_df.empty:
if "net_pnl" in positions_df.columns:
current_daily_pnl = positions_df\["net_pnl"\].sum()
elif "pl" in positions_df.columns:
current_daily_pnl = positions_df\["pl"\].sum()
else:
raise ValueError("Positions dataframe has neither 'net_pnl' nor 'pl' column")
else:
current_daily_pnl = 0
print(f"\[bold cyan\]Live Account MTM Status: ₹{current_daily_pnl}\[/bold cyan\]")
if current_daily_pnl <= MAX_DAILY_LOSS:
message = f"🚨 KILL SWITCH ACTIVE: Current P&L (₹{current_daily_pnl}) breached daily limit of ₹{MAX_DAILY_LOSS}. Run denied."
print(f"\[bold white on red\]{message}\[/bold white on red\]")
tsl.send_telegram_alert(message=message, receiver_chat_id=receiver_chat_id, bot_token=bot_token)
sys.exit()
except Exception as e:
message = f"🚨 RISK CHECK FAILED: Could not verify P&L ({e}). Halting run — will not trade blind."
print(f"\[bold white on red\]{message}\[/bold white on red\]")
try:
tsl.send_telegram_alert(message=message, receiver_chat_id=receiver_chat_id, bot_token=bot_token)
except Exception:
pass
sys.exit()
# — Watchlist —
watchlist = [
"ADANIENT", "TCS", "SUNPHARMA", "CIPLA", "INDIGO", "TRENT", "TITAN", "LT",
"GRASIM", "M&M", "ASIANPAINT", "HINDUNILVR", "SBILIFE", "BHARTIARTL", "ADANIPORTS",
"BAJAJFINSV", "TECHM", "NESTLEIND", "RELIANCE", "JSWSTEEL", "DRREDDY", "AXISBANK",
"ICICIBANK", "INFY", "HCLTECH", "TATACONSUM", "HINDALCO", "SBIN", "MAXHEALTH",
"SHRIRAMFIN", "BAJFINANCE", "HDFCBANK", "HDFCLIFE", "COALINDIA", "TMPV", "NTPC",
"KOTAKBANK", "POWERGRID", "ITC", "ONGC", "ETERNAL", "JIOFIN", "TATASTEEL", "WIPRO"
]
# Load persisted state, then reconcile against live broker positions.
algo_memory = load_traded_state()
algo_memory, currently_open_count = reconcile_with_broker(tsl, algo_memory, watchlist)
save_traded_state(algo_memory)
print(f"[bold cyan]Currently open positions (broker-confirmed): {currently_open_count} / {MAX_CONCURRENT_POSITIONS}[/bold cyan]")
# — Core Scanner Engine —
for name in watchlist:
print(f"\[bold yellow\]Scanning: {name}\[/bold yellow\]")
try:
\# Loop Risk Intercept (kill switch)
positions_df = tsl.get_positions()
if positions_df is not None and not positions_df.empty:
if "net_pnl" in positions_df.columns:
current_daily_pnl = positions_df\["net_pnl"\].sum()
elif "pl" in positions_df.columns:
current_daily_pnl = positions_df\["pl"\].sum()
else:
raise ValueError("Positions dataframe has neither 'net_pnl' nor 'pl' column")
if current_daily_pnl <= MAX_DAILY_LOSS:
print(f"\[bold white on red\]🚨 Kill Switch triggered mid-scan! MTM: ₹{current_daily_pnl}. Breaking execution loop.\[/bold white on red\]")
break
\# Portfolio-level capital cap: don't open more concurrent
\# positions than TOTAL_CAPITAL / CAPITAL_PER_TRADE allows.
if currently_open_count >= MAX_CONCURRENT_POSITIONS:
print(f"\[bold orange3\]Max concurrent positions ({MAX_CONCURRENT_POSITIONS}) reached. Skipping new entries this pass.\[/bold orange3\]")
break
\# Skip symbols already handled today (broker-confirmed open
\# position, or a completed/rejected trade from earlier today).
if algo_memory.get(name, {}).get("traded") == "yes":
continue
\# 1. Fetch Structural Market Matrix and Calculate Indicators
chart = tsl.get_historical_data(tradingsymbol=name, exchange="NSE", timeframe="5")
chart\["rsi"\] = chart.ta.rsi(length=14)
chart\["ema"\] = chart.ta.ema(length=30)
chart\["atr"\] = chart.ta.atr(length=14)
if len(chart) < 2:
print(f"\[bold orange3\]Not enough candle history yet for {name}, skipping.\[/bold orange3\]")
continue
\# 2. Extract Gap Data
ohlc_data = tsl.get_ohlc_data(names=name)
previous_close = ohlc_data\[name\]\["ohlc"\]\["close"\]
todays_open = ohlc_data\[name\]\["ohlc"\]\["open"\]
if not previous_close:
print(f"\[bold orange3\]Missing previous close for {name}, skipping.\[/bold orange3\]")
continue
gap = round((todays_open - previous_close) / previous_close \* 100, 2)
completed_candle = chart.iloc\[-2\]
current_atr = completed_candle\["atr"\]
close_price = completed_candle\["close"\]:
if current_atr is None or current_atr != current_atr : # NaN check
print(f"\[bold orange3\]ATR not yet available for {name} (warm-up period), skipping.\[/bold orange3\]")
continue
\# --- Rule Matrix Validations ---
\# Long and short conditions are structurally symmetric and mutually
\# exclusive (EMA side, RSI side, gap side all point the same
\# direction), so a symbol can't satisfy both in the same pass.
price_above_ema = close_price > completed_candle\["ema"\]
rsi_above_60 = completed_candle\["rsi"\] > 60
gap_up_by_0_5 = gap >= 0.5
price_below_ema = close_price < completed_candle\["ema"\]
rsi_below_40 = completed_candle\["rsi"\] < 40
gap_down_by_0_5 = gap <= -0.5
\# 3A. Execute Optimized BUY
\# Direction check: enter above EMA, momentum RSI>60, gap up ->
\# trend-following long. SL below entry, target above entry. Correct.
if price_above_ema and rsi_above_60 and gap_up_by_0_5:
print(f"\[bold green\] Target spotted (LONG): {name}\[/bold green\]")
quantity = int(CAPITAL_PER_TRADE / close_price)
entry_price = round(close_price \* 1.002, 1) # tight limit slippage buffer, chasing the move
sl_price = round(close_price - (current_atr \* ATR_MULT_SL), 1)
target_price = round(close_price + (current_atr \* ATR_MULT_TGT), 1)
\# Sanity checks on the bracket before firing the order.
if quantity <= 0:
print(f"\[bold orange3\]Computed quantity <=0 for {name}, skipping.\[/bold orange3\]")
continue
if not (sl_price < entry_price < target_price):
print(f"\[bold red\]Bracket sanity check failed for {name} (SL {sl_price} / Entry {entry_price} / Target {target_price}), skipping.\[/bold red\]")
continue
order_id = tsl.place_super_order(
tradingsymbol=name, exchange="NSE", transaction_type="BUY",
quantity=quantity, order_type="LIMIT", trade_type="MIS",
price=entry_price, target_price=target_price, stop_loss_price=sl_price,
trailing_jump=0.5
)
time.sleep(1)
if order_was_filled_or_accepted(tsl, order_id):
algo_memory\[name\] = {"traded": "yes", "direction": "BUY", "target": target_price,
"sl": sl_price, "order_id": order_id}
currently_open_count += 1
save_traded_state(algo_memory)
tsl.send_telegram_alert(
message=f"🚀 LIVE BUY: {quantity} shares of {name}.\\nEntry Limit: {entry_price}\\nTarget (ATR): {target_price}\\nSL (ATR): {sl_price}",
receiver_chat_id=receiver_chat_id, bot_token=bot_token
)
else:
print(f"\[bold red\]Order for {name} was NOT confirmed by broker — not marking as traded, not alerting.\[/bold red\]")
\# 3B. Execute Optimized SHORT SELL
\# Direction check: enter below EMA, weak RSI<40, gap down -> trend-
\# following short. SL above entry, target below entry. Correct.
elif price_below_ema and rsi_below_40 and gap_down_by_0_5:
print(f"\[bold red\] Target spotted (SHORT): {name}\[/bold red\]")
quantity = int(CAPITAL_PER_TRADE / close_price)
entry_price = round(close_price \* 0.998, 1)
sl_price = round(close_price + (current_atr \* ATR_MULT_SL), 1)
target_price = round(close_price - (current_atr \* ATR_MULT_TGT), 1)
if quantity <= 0:
print(f"\[bold orange3\]Computed quantity <=0 for {name}, skipping.\[/bold orange3\]")
continue
if not (target_price < entry_price < sl_price):
print(f"\[bold red\]Bracket sanity check failed for {name} (Target {target_price} / Entry {entry_price} / SL {sl_price}), skipping.\[/bold red\]")
continue
order_id = tsl.place_super_order(
tradingsymbol=name, exchange="NSE", transaction_type="SELL",
quantity=quantity, order_type="LIMIT", trade_type="MIS",
price=entry_price, target_price=target_price, stop_loss_price=sl_price,
trailing_jump=0.5
)
time.sleep(1)
if order_was_filled_or_accepted(tsl, order_id):
algo_memory\[name\] = {"traded": "yes", "direction": "SELL", "target": target_price,
"sl": sl_price, "order_id": order_id}
currently_open_count += 1
save_traded_state(algo_memory)
tsl.send_telegram_alert(
message=f"💥 LIVE SHORT: {quantity} shares of {name}.\\nEntry Limit: {entry_price}\\nTarget (ATR): {target_price}\\nSL (ATR): {sl_price}",
receiver_chat_id=receiver_chat_id, bot_token=bot_token
)
else:
print(f"\[bold red\]Order for {name} was NOT confirmed by broker — not marking as traded, not alerting.\[/bold red\]")
except Exception as e:
print(f"\[bold red\]Skipping {name} due to active processing error: {e}\[/bold red\]")
continue
print(“[bold green]Trading execution pass complete.[/bold green]”)