Position Monitoring & Exit Rules
monitor_open_positions() is called every scan cycle (every 30 s).
DC/DCS threshold ordering lives in execution.position_monitor; the engine still owns LTP fetching, order placement, and final close bookkeeping.
Position State Machine
| State | When Set | Meaning |
|---|---|---|
OPEN | At entry | Normal monitoring. |
PROFIT_LOCK | Peak observed P&L reaches TRAILING_PROFIT_LOCK_PCT of expected profit | Trailing floor is active for DC-family option spreads. |
EXPIRY_APPROACH | Near expiry DTE ≤ 2 | Position is near expiry and should be watched under tighter operator scrutiny. |
CLOSING | Exit condition has fired and close routing is about to run | Close is in progress or awaiting confirmation; repeated monitor actions are skipped. |
Box Spread — 3 exit conditions (checked in priority order)
| # | Condition | Action | Why |
|---|---|---|---|
| 1 | days_left < 0 |
Close immediately | Bot was down over expiry weekend |
| 2 | days_left ≤ 2 |
Close early | Avoid pin risk near expiry |
| 3 | net_profit_after_close ≥ 0 AND gross_profit ≥ 70% of max_gross |
Close early | Capture profit early, free up margin — but only if closing costs don't wipe the gain |
# Profit target calculation (live LTPs fetched each cycle)
current_box_value = C1_ltp − C2_ltp + P2_ltp − P1_ltp
current_profit_gross = (current_box_value − entry_net_debit) × lot
max_profit_gross = (K2 − K1 − entry_net_debit) × lot
est_close_cost = trade["total_cost"] # use opening cost as proxy for closing cost
net_profit_after_close = current_profit_gross − est_close_cost
if net_profit_after_close > 0 and current_profit_gross >= max_profit_gross * 0.70:
close_position() # 70% gross captured AND net is positive → exit
elif net_profit_after_close <= 0 and current_profit_gross > 0:
hold_to_expiry() # close cost would wipe the profit — hold
Calendar Spread — 1 exit condition
| Condition | Action |
|---|---|
|z_score| ≤ CAL_EXIT_SIGMA (0.5) |
Close — spread reverted to mean |
Double Calendar — 4 exit conditions (checked in priority order)
| # | Condition | Action | Why |
|---|---|---|---|
| 1 | dte_near < 0, or dte_near = 0 at/after NEAR_EXPIRY_TIME_STOP_HOUR:NEAR_EXPIRY_TIME_STOP_MIN IST |
Time stop — close all 4 legs | Holds through expiry day morning, then exits before the close window. |
| 2 | Net P&L ≤ −DC_STOP_PNL_PCT (20%) × |net_debit|(after entry + estimated close costs) |
P&L stop — Trigger 1 | Max loss guardrail regardless of IV or spot. Fires first — fastest to compute. |
| 3 | Entry IV − current IV ≥ DC_STOP_IV_DROP (10 pts)Current IV estimated from live straddle LTPs |
IV crush stop — Trigger 2 | DC is Vega-long — sustained IV drop kills far legs faster than near theta helps. 10pt drop is the configured regime-change stop. |
| 4 | |spot − atm_strike| > DC_STOP_TENT_WIDTH (1.30) × near_straddle_entryOnly active when DTE ≥ DC_STOP_TENT_DTE (3) |
Tent break — Trigger 3 | Spot outside the profit tent means delta is no longer neutral. Tight tent at DTE ≥ 3 only — inside 3 DTE the near gamma is too high to trust the tent shape. |
| ✓ | Net P&L ≥ DC_PROFIT_TARGET_PCT (32%) × |net_debit|(net of entry cost + estimated close cost) |
Profit target — close all 4 legs | 32% of net debit captured — theta is front-loaded so the bulk arrives in the first 2-3 days. |
# DC P&L calculation (live LTPs fetched each cycle)
near_pnl = (near_ce_entry − nc_ltp) + (near_pe_entry − np_ltp) # profit from decaying short
far_pnl = (fc_ltp − far_ce_entry) + (fp_ltp − far_pe_entry) # profit from long legs
gross_pnl = (near_pnl + far_pnl) × lot_size
entry_cost = trade["entry_cost"] # stored at execution time
close_cost = TransactionCosts.dc_close_cost(lot, nc, np, fc, fp) # estimated live
net_pnl = gross_pnl − entry_cost − close_cost
# Trigger 1: P&L stop
pnl_stop = −abs(trade["net_debit"]) × DC_STOP_PNL_PCT # −20% of net debit
if net_pnl <= pnl_stop: close_all_4_legs()
# Trigger 2: IV drop (estimated from straddle LTPs, no extra API call)
cur_iv = (nc_ltp + np_ltp) / (0.8 × spot × sqrt(dte_near / 365)) × 100
if entry_iv − cur_iv >= DC_STOP_IV_DROP: close_all_4_legs()
# Trigger 3: tent break (only when DTE ≥ 3)
tent_half = trade["near_straddle_entry"] × DC_STOP_TENT_WIDTH
if dte_near >= DC_STOP_TENT_DTE and abs(spot − atm_strike) > tent_half: close_all_4_legs()
# Profit target: 32% of net debit, net of costs
target = abs(trade["net_debit"]) × DC_PROFIT_TARGET_PCT
if net_pnl >= target: close_all_4_legs()
Stretched DC (DCS) — same 4 triggers, higher profit target
DCS uses the same P&L, IV, and time-stop triggers as DC, but its tent break is wider: DCS_STOP_TENT_WIDTH = 1.40. Its profit target is 42% of net debit (vs 32%) because the OTM wings take 1-2 extra days to contribute meaningful theta differential, so the position is held slightly longer.
AI Market — 3 exit conditions
| # | Condition | Action | Bookkeeping |
|---|---|---|---|
| 1 | Live option LTP reaches the parsed target price | Profit target — close dry-run leg | Remove from open_trades, close matching ai_market_trades row. |
| 2 | Live option LTP reaches the parsed stop price | Stop loss — close dry-run leg | Records realised simulated P&L against the analysis id. |
| 3 | End-of-day monitor window | Time close | Prevents the AI single-leg trade from carrying overnight. |
AI_MARKET is phase-1 dry-run only. The close path fetches live LTP for valuation but never sends a broker close order.
Master DC (MDC_*) — same base exits as the routed strategy
MDC trades are persisted with an MDC_* strategy key, but exit routing strips the prefix and reuses the same base close logic as the selected strategy. For example: MDC_DC follows the DC exit rules, MDC_DCS and MDC_DCS_SKEW follow the DCS exit rules, MDC_DDC follows DDC rules, and MDC_IC follows IC rules.
Iron Condor (IC) — 3 exit conditions
| # | Condition | Action | Calculation |
|---|---|---|---|
| 1 | Credit captured ≥ IC_PROFIT_TARGET_PCT (50%) × entry credit |
Profit target — close all 4 legs | current_pnl = entry_credit - current_credit_value |
| 2 | Current loss ≥ IC_STOP_LOSS_CREDIT_X (1.5×) × entry credit |
Stop loss — close all 4 legs | current_loss = current_credit_value - entry_credit |
| 3 | dte_near < 0, or dte_near = 0 at/after configured time-stop IST |
Time stop — close all 4 legs | Uses live LTPs if available; falls back to entry credit on expiry-edge LTP failure. |
Backtest reuse of live exit helpers
The backtesting engine never re-implements exit logic. arb_bot/backtest/simulation/exit_evaluator.py
calls the exact same evaluate_dc_exit(), evaluate_dcs_exit(), evaluate_ddc_exit(),
and evaluate_ic_exit() functions (with DCS_SKEW routed through evaluate_dcs_exit()),
preceded by update_calendar_position_state() on every cycle — identical to live monitoring.
The human-readable reason string each helper returns is normalized to a stable enum-like code for
reporting: PROFIT_TARGET, PNL_STOP, TENT_BREAK, TIME_STOP,
IV_STOP, SHORT_STRIKE_BREACH, TRAILING_STOP, plus the engine-only
END_OF_RANGE. Because the helpers read Config directly and the engine temporarily patches
those class attributes per cycle, parameter sweeps change real exit decisions without any code duplication.