591 lines
19 KiB
Python
591 lines
19 KiB
Python
import pandas as pd
|
||
import numpy as np
|
||
import yfinance as yf
|
||
|
||
# =========================================================
|
||
# CONFIG
|
||
# =========================================================
|
||
|
||
START_DATE = "2010-01-01"
|
||
END_DATE = "2025-12-31"
|
||
|
||
SIP_START_DATE = "2018-04-01"
|
||
SIP_END_DATE = "2025-10-31" # set None for "till last data"
|
||
|
||
MONTHLY_SIP = 100
|
||
|
||
# Order frequency parameter (N trading days)
|
||
ORDER_EVERY_N = 5 # 5 => every 5 trading days, 30 => every 30 trading days
|
||
|
||
# Whether to keep MONTHLY_SIP constant (recommended)
|
||
# True => scales cash per order so approx monthly investment stays ~MONTHLY_SIP
|
||
# False => invests MONTHLY_SIP every order (be careful: for N=5 this is much larger than monthly SIP)
|
||
KEEP_MONTHLY_BUDGET_CONSTANT = True
|
||
|
||
NIFTY = "NIFTYBEES.NS"
|
||
GOLD = "GOLDBEES.NS"
|
||
|
||
SIP_START_DATE = pd.to_datetime(SIP_START_DATE)
|
||
SIP_END_DATE = pd.to_datetime(SIP_END_DATE) if SIP_END_DATE else None
|
||
|
||
# =========================================================
|
||
# CENTRAL SIP DATE WINDOW
|
||
# =========================================================
|
||
|
||
def in_sip_window(date):
|
||
if date < SIP_START_DATE:
|
||
return False
|
||
if SIP_END_DATE and date > SIP_END_DATE:
|
||
return False
|
||
return True
|
||
|
||
# =========================================================
|
||
# VALUATION (SMA MEAN REVERSION)
|
||
# =========================================================
|
||
|
||
SMA_MONTHS = 36
|
||
TILT_MULT = 1.5
|
||
MAX_TILT = 0.25
|
||
BASE_EQUITY = 0.60
|
||
|
||
MIN_EQUITY = 0.20
|
||
MAX_EQUITY = 0.90
|
||
|
||
# For daily data
|
||
TRADING_DAYS_PER_MONTH = 21
|
||
SMA_DAYS = SMA_MONTHS * TRADING_DAYS_PER_MONTH # ~36 months on daily series
|
||
|
||
# =========================================================
|
||
# DATA LOAD
|
||
# =========================================================
|
||
|
||
def load_price(ticker):
|
||
df = yf.download(
|
||
ticker,
|
||
start=START_DATE,
|
||
end=END_DATE,
|
||
auto_adjust=True,
|
||
progress=False
|
||
)
|
||
if isinstance(df.columns, pd.MultiIndex):
|
||
df.columns = df.columns.get_level_values(0)
|
||
return df["Close"]
|
||
|
||
prices = pd.DataFrame({
|
||
"NIFTY": load_price(NIFTY),
|
||
"GOLD": load_price(GOLD)
|
||
}).dropna()
|
||
|
||
# Use business-day frequency (daily for trading)
|
||
prices = prices.resample("B").last().dropna()
|
||
|
||
# =========================================================
|
||
# ORDER SCHEDULE: EVERY N TRADING DAYS
|
||
# =========================================================
|
||
|
||
def get_order_dates(index: pd.DatetimeIndex, n: int) -> pd.DatetimeIndex:
|
||
window = index[(index >= SIP_START_DATE) & ((index <= SIP_END_DATE) if SIP_END_DATE else True)]
|
||
return window[::n]
|
||
|
||
ORDER_DATES = get_order_dates(prices.index, ORDER_EVERY_N)
|
||
|
||
# Cash per order
|
||
if KEEP_MONTHLY_BUDGET_CONSTANT:
|
||
# Approx monthly orders = 21 / N
|
||
orders_per_month = TRADING_DAYS_PER_MONTH / ORDER_EVERY_N
|
||
SIP_AMOUNT_PER_ORDER = MONTHLY_SIP / orders_per_month
|
||
else:
|
||
SIP_AMOUNT_PER_ORDER = MONTHLY_SIP
|
||
|
||
# =========================================================
|
||
# (OPTIONAL) CHECK PRICE ON A GIVEN DATE (UNCHANGED)
|
||
# =========================================================
|
||
|
||
def check_price_on_date(ticker, date_str):
|
||
date = pd.to_datetime(date_str)
|
||
|
||
df = yf.download(
|
||
ticker,
|
||
start=date - pd.Timedelta(days=5),
|
||
end=date + pd.Timedelta(days=5),
|
||
auto_adjust=False,
|
||
progress=False
|
||
)
|
||
|
||
if df.empty:
|
||
print(f"No data returned for {ticker}")
|
||
return
|
||
|
||
if isinstance(df.columns, pd.MultiIndex):
|
||
df.columns = df.columns.get_level_values(0)
|
||
|
||
if date not in df.index:
|
||
print(f"{ticker} | {date.date()} → No trading data (holiday / no volume)")
|
||
return
|
||
|
||
row = df.loc[date]
|
||
|
||
print(f"\n{ticker} — {date.date()}")
|
||
print(f"Open : ₹{row['Open']:.2f}")
|
||
print(f"High : ₹{row['High']:.2f}")
|
||
print(f"Low : ₹{row['Low']:.2f}")
|
||
print(f"Close: ₹{row['Close']:.2f}")
|
||
print(f"Volume: {int(row['Volume'])}")
|
||
|
||
check_price_on_date("NIFTYBEES.NS", "2025-11-10")
|
||
|
||
# =========================================================
|
||
# SMA DEVIATION (DAILY)
|
||
# =========================================================
|
||
|
||
sma_nifty = prices["NIFTY"].rolling(SMA_DAYS).mean()
|
||
sma_gold = prices["GOLD"].rolling(SMA_DAYS).mean()
|
||
|
||
dev_nifty = (prices["NIFTY"] / sma_nifty) - 1
|
||
dev_gold = (prices["GOLD"] / sma_gold) - 1
|
||
|
||
rel_dev = dev_nifty - dev_gold
|
||
|
||
# =========================================================
|
||
# SIPXAR ENGINE (FLOW ONLY, NO REBALANCE) — EXECUTES ON ORDER_DATES
|
||
# =========================================================
|
||
|
||
def run_sipxar(prices, rel_dev, order_dates, sip_amount_per_order):
|
||
|
||
nifty_units = 0.0
|
||
gold_units = 0.0
|
||
|
||
total_invested = 0.0
|
||
prev_value = None
|
||
|
||
rows = []
|
||
|
||
for date in order_dates:
|
||
|
||
nifty_price = prices.loc[date, "NIFTY"]
|
||
gold_price = prices.loc[date, "GOLD"]
|
||
|
||
cash = float(sip_amount_per_order)
|
||
total_invested += cash
|
||
|
||
rd = rel_dev.loc[date]
|
||
|
||
tilt = 0.0
|
||
if not pd.isna(rd):
|
||
tilt = np.clip(-rd * TILT_MULT, -MAX_TILT, MAX_TILT)
|
||
|
||
equity_w = BASE_EQUITY * (1 + tilt)
|
||
equity_w = min(max(equity_w, MIN_EQUITY), MAX_EQUITY)
|
||
gold_w = 1 - equity_w
|
||
|
||
nifty_buy = cash * equity_w
|
||
gold_buy = cash * gold_w
|
||
|
||
nifty_units += nifty_buy / nifty_price
|
||
gold_units += gold_buy / gold_price
|
||
|
||
nifty_val = nifty_units * nifty_price
|
||
gold_val = gold_units * gold_price
|
||
port_val = nifty_val + gold_val
|
||
|
||
unrealized = port_val - total_invested
|
||
|
||
if prev_value is None:
|
||
period_pnl = 0.0
|
||
else:
|
||
period_pnl = port_val - prev_value - cash
|
||
|
||
prev_value = port_val
|
||
|
||
rows.append({
|
||
"Date": date,
|
||
"Cash_Added": round(cash, 4),
|
||
"Total_Invested": round(total_invested, 4),
|
||
|
||
"Equity_Weight": round(equity_w, 3),
|
||
"Gold_Weight": round(gold_w, 3),
|
||
|
||
"Rel_Deviation": round(float(rd) if not pd.isna(rd) else np.nan, 4),
|
||
|
||
"NIFTY_Units": round(nifty_units, 6),
|
||
"GOLD_Units": round(gold_units, 6),
|
||
|
||
"NIFTY_Value": round(nifty_val, 4),
|
||
"GOLD_Value": round(gold_val, 4),
|
||
|
||
"Portfolio_Value": round(port_val, 4),
|
||
"Period_PnL": round(period_pnl, 4),
|
||
"Unrealized_PnL": round(unrealized, 4)
|
||
})
|
||
|
||
return pd.DataFrame(rows).set_index("Date")
|
||
|
||
# =========================================================
|
||
# RUN SIPXAR
|
||
# =========================================================
|
||
|
||
sipxar_ledger = run_sipxar(
|
||
prices=prices,
|
||
rel_dev=rel_dev,
|
||
order_dates=ORDER_DATES,
|
||
sip_amount_per_order=SIP_AMOUNT_PER_ORDER
|
||
)
|
||
|
||
start_dt = sipxar_ledger.index.min()
|
||
end_dt = sipxar_ledger.index.max()
|
||
|
||
# =========================================================
|
||
# XIRR
|
||
# =========================================================
|
||
|
||
def xirr(cashflows):
|
||
dates = np.array([cf[0] for cf in cashflows], dtype="datetime64[D]")
|
||
amounts = np.array([cf[1] for cf in cashflows], dtype=float)
|
||
|
||
def npv(rate):
|
||
years = (dates - dates[0]).astype(int) / 365.25
|
||
return np.sum(amounts / ((1 + rate) ** years))
|
||
|
||
low, high = -0.99, 5.0
|
||
for _ in range(200):
|
||
mid = (low + high) / 2
|
||
val = npv(mid)
|
||
if abs(val) < 1e-6:
|
||
return mid
|
||
if val > 0:
|
||
low = mid
|
||
else:
|
||
high = mid
|
||
|
||
return mid
|
||
|
||
cashflows_sipxar = []
|
||
for date, row in sipxar_ledger.iterrows():
|
||
cashflows_sipxar.append((date, -row["Cash_Added"]))
|
||
|
||
final_date = sipxar_ledger.index[-1]
|
||
final_value = sipxar_ledger["Portfolio_Value"].iloc[-1]
|
||
cashflows_sipxar.append((final_date, final_value))
|
||
|
||
sipxar_xirr = xirr(cashflows_sipxar)
|
||
|
||
# =========================================================
|
||
# COMPARISONS: NIFTY-ONLY + STATIC 60/40 — EXECUTES ON ORDER_DATES
|
||
# =========================================================
|
||
|
||
def run_nifty_sip(prices, order_dates, sip_amount):
|
||
units = 0.0
|
||
rows = []
|
||
for date in order_dates:
|
||
price = prices.loc[date, "NIFTY"]
|
||
units += sip_amount / price
|
||
rows.append((date, units * price))
|
||
return pd.DataFrame(rows, columns=["Date", "Value"]).set_index("Date")
|
||
|
||
def run_static_sip(prices, order_dates, sip_amount, eq_w=0.6):
|
||
n_units = 0.0
|
||
g_units = 0.0
|
||
rows = []
|
||
for date in order_dates:
|
||
n_price = prices.loc[date, "NIFTY"]
|
||
g_price = prices.loc[date, "GOLD"]
|
||
|
||
n_units += (sip_amount * eq_w) / n_price
|
||
g_units += (sip_amount * (1 - eq_w)) / g_price
|
||
|
||
rows.append((date, n_units * n_price + g_units * g_price))
|
||
return pd.DataFrame(rows, columns=["Date", "Value"]).set_index("Date")
|
||
|
||
def build_sip_ledger(value_df, sip_amount):
|
||
total = 0.0
|
||
rows = []
|
||
prev_value = None
|
||
|
||
for date, row in value_df.iterrows():
|
||
total += sip_amount
|
||
value = float(row.iloc[0])
|
||
|
||
if prev_value is None:
|
||
period_pnl = 0.0
|
||
else:
|
||
period_pnl = value - prev_value - sip_amount
|
||
|
||
prev_value = value
|
||
|
||
rows.append({
|
||
"Date": date,
|
||
"Cash_Added": round(sip_amount, 4),
|
||
"Total_Invested": round(total, 4),
|
||
"Portfolio_Value": round(value, 4),
|
||
"Period_PnL": round(period_pnl, 4)
|
||
})
|
||
|
||
return pd.DataFrame(rows).set_index("Date")
|
||
|
||
nifty_sip = run_nifty_sip(prices, ORDER_DATES, SIP_AMOUNT_PER_ORDER)
|
||
static_sip = run_static_sip(prices, ORDER_DATES, SIP_AMOUNT_PER_ORDER, 0.6)
|
||
|
||
nifty_sip = nifty_sip.loc[start_dt:end_dt]
|
||
static_sip = static_sip.loc[start_dt:end_dt]
|
||
|
||
nifty_ledger = build_sip_ledger(nifty_sip, SIP_AMOUNT_PER_ORDER)
|
||
static_ledger = build_sip_ledger(static_sip, SIP_AMOUNT_PER_ORDER)
|
||
|
||
cashflows_nifty = [(d, -SIP_AMOUNT_PER_ORDER) for d in nifty_sip.index]
|
||
cashflows_nifty.append((nifty_sip.index[-1], float(nifty_sip["Value"].iloc[-1])))
|
||
nifty_xirr = xirr(cashflows_nifty)
|
||
|
||
cashflows_static = [(d, -SIP_AMOUNT_PER_ORDER) for d in static_sip.index]
|
||
cashflows_static.append((static_sip.index[-1], float(static_sip["Value"].iloc[-1])))
|
||
static_xirr = xirr(cashflows_static)
|
||
|
||
# =========================================================
|
||
# PRINTS
|
||
# =========================================================
|
||
|
||
print("\n=== CONFIG SUMMARY ===")
|
||
print(f"Order every N trading days: {ORDER_EVERY_N}")
|
||
print(f"Keep monthly budget constant: {KEEP_MONTHLY_BUDGET_CONSTANT}")
|
||
print(f"MONTHLY_SIP (target): ₹{MONTHLY_SIP}")
|
||
print(f"SIP_AMOUNT_PER_ORDER: ₹{SIP_AMOUNT_PER_ORDER:.4f}")
|
||
print(f"Orders executed: {len(ORDER_DATES)}")
|
||
print(f"Period: {start_dt.date()} → {end_dt.date()}")
|
||
|
||
print("\n=== SIPXAR LEDGER (LAST 12 ROWS) ===")
|
||
print(sipxar_ledger.tail(12))
|
||
|
||
print("\n=== EQUITY WEIGHT DISTRIBUTION ===")
|
||
print(sipxar_ledger["Equity_Weight"].describe())
|
||
|
||
print("\n=== STEP 1: XIRR COMPARISON ===")
|
||
print(f"SIPXAR XIRR : {sipxar_xirr*100:.2f}%")
|
||
print(f"NIFTY SIP XIRR : {nifty_xirr*100:.2f}%")
|
||
print(f"60/40 SIP XIRR : {static_xirr*100:.2f}%")
|
||
|
||
# =========================================================
|
||
# EXPORT
|
||
# =========================================================
|
||
|
||
output_file = "SIPXAR_Momentum_SIP.xlsx"
|
||
|
||
with pd.ExcelWriter(output_file, engine="xlsxwriter") as writer:
|
||
sipxar_ledger.to_excel(writer, sheet_name="Ledger")
|
||
|
||
yearly = sipxar_ledger.copy()
|
||
yearly["Year"] = yearly.index.year
|
||
|
||
yearly_summary = yearly.groupby("Year").agg({
|
||
"Cash_Added": "sum",
|
||
"Total_Invested": "last",
|
||
"Portfolio_Value": "last",
|
||
"Unrealized_PnL": "last"
|
||
})
|
||
yearly_summary.to_excel(writer, sheet_name="Yearly_Summary")
|
||
|
||
print(f"\nExcel exported successfully: {output_file}")
|
||
|
||
# =========================================================
|
||
# PHASE 2: CRASH & SIDEWAYS REGIME BACKTEST (ADAPTED TO DAILY INDEX)
|
||
# =========================================================
|
||
|
||
def rolling_cagr(series: pd.Series, periods: int, years: float):
|
||
r = series / series.shift(periods)
|
||
return (r ** (1 / years)) - 1
|
||
|
||
def window_xirr_from_value(value_df, start, end, sip_amount):
|
||
df = value_df.loc[start:end]
|
||
if len(df) < 6:
|
||
return np.nan
|
||
cashflows = [(d, -sip_amount) for d in df.index]
|
||
cashflows.append((df.index[-1], float(df.iloc[-1, 0])))
|
||
return xirr(cashflows)
|
||
|
||
def sip_max_drawdown(ledger):
|
||
value = ledger["Portfolio_Value"]
|
||
peak = value.cummax()
|
||
dd = value / peak - 1
|
||
trough = dd.idxmin()
|
||
peak_date = value.loc[:trough].idxmax()
|
||
return {"Peak": peak_date, "Trough": trough, "Max_Drawdown": float(dd.min())}
|
||
|
||
def worst_rolling_xirr(ledger, periods: int):
|
||
dates = ledger.index
|
||
worst = None
|
||
|
||
for i in range(len(dates) - periods):
|
||
start = dates[i]
|
||
end = dates[i + periods]
|
||
window = ledger.loc[start:end]
|
||
if len(window) < max(6, periods // 2):
|
||
continue
|
||
|
||
cashflows = [(d, -float(row["Cash_Added"])) for d, row in window.iterrows()]
|
||
cashflows.append((end, float(window["Portfolio_Value"].iloc[-1])))
|
||
|
||
try:
|
||
rx = xirr(cashflows)
|
||
if worst is None or rx < worst.get("XIRR", np.inf):
|
||
worst = {"Start": start, "End": end, "XIRR": rx}
|
||
except Exception:
|
||
pass
|
||
|
||
return worst
|
||
|
||
# 1) Identify crash windows from NIFTY drawdowns (daily)
|
||
nifty_price = prices["NIFTY"].loc[
|
||
(prices.index >= SIP_START_DATE) &
|
||
((prices.index <= SIP_END_DATE) if SIP_END_DATE else True)
|
||
]
|
||
|
||
peak = nifty_price.cummax()
|
||
drawdown = nifty_price / peak - 1.0
|
||
|
||
CRASH_THRESHOLD = -0.15
|
||
in_crash = drawdown <= CRASH_THRESHOLD
|
||
|
||
crash_windows = []
|
||
groups = (in_crash != in_crash.shift()).cumsum()
|
||
|
||
for _, block in in_crash.groupby(groups):
|
||
if block.iloc[0]:
|
||
crash_windows.append((block.index[0], block.index[-1]))
|
||
|
||
print("\n=== CRASH WINDOWS (NIFTY DD <= -15%) ===")
|
||
for s, e in crash_windows:
|
||
print(s.date(), "->", e.date())
|
||
|
||
# 2) Sideways windows using ~36M rolling CAGR on daily data
|
||
ROLL_MONTHS = 36
|
||
SIDEWAYS_CAGR = 0.06
|
||
|
||
ROLL_DAYS = ROLL_MONTHS * TRADING_DAYS_PER_MONTH
|
||
cagr_36 = rolling_cagr(nifty_price, periods=ROLL_DAYS, years=ROLL_MONTHS / 12.0)
|
||
|
||
in_sideways = cagr_36 <= SIDEWAYS_CAGR
|
||
|
||
sideways_windows = []
|
||
groups = (in_sideways != in_sideways.shift()).cumsum()
|
||
|
||
for _, block in in_sideways.groupby(groups):
|
||
if block.iloc[0]:
|
||
sideways_windows.append((block.index[0], block.index[-1]))
|
||
|
||
print("\n=== SIDEWAYS WINDOWS (~36M CAGR <= 6%) ===")
|
||
for s, e in sideways_windows:
|
||
print(s.date(), "->", e.date())
|
||
|
||
# 3) Score each regime window using order-based ledgers
|
||
rows = []
|
||
for label, windows in [("CRASH", crash_windows), ("SIDEWAYS", sideways_windows)]:
|
||
for s, e in windows:
|
||
# align to nearest available ledger dates
|
||
s2 = sipxar_ledger.index[sipxar_ledger.index.get_indexer([s], method="nearest")[0]]
|
||
e2 = sipxar_ledger.index[sipxar_ledger.index.get_indexer([e], method="nearest")[0]]
|
||
|
||
months_like = (e2.year - s2.year) * 12 + (e2.month - s2.month) + 1
|
||
|
||
rows.append({
|
||
"Regime": label,
|
||
"Start": s2.date(),
|
||
"End": e2.date(),
|
||
"MonthsLike": months_like,
|
||
"SIPXAR_XIRR": window_xirr_from_value(sipxar_ledger[["Portfolio_Value"]], s2, e2, SIP_AMOUNT_PER_ORDER),
|
||
"NIFTY_SIP_XIRR": window_xirr_from_value(nifty_sip, s2, e2, SIP_AMOUNT_PER_ORDER),
|
||
"STATIC_60_40_XIRR": window_xirr_from_value(static_sip, s2, e2, SIP_AMOUNT_PER_ORDER)
|
||
})
|
||
|
||
regime_results = pd.DataFrame(rows)
|
||
|
||
print("\n=== REGIME PERFORMANCE SUMMARY ===")
|
||
if len(regime_results) == 0:
|
||
print("No regime windows detected (check thresholds / data range).")
|
||
else:
|
||
print(regime_results.to_string(index=False))
|
||
|
||
# =========================================================
|
||
# METRIC 1: TIME UNDERWATER
|
||
# =========================================================
|
||
|
||
sipxar_ledger["Underwater"] = (sipxar_ledger["Portfolio_Value"] < sipxar_ledger["Total_Invested"])
|
||
periods_underwater = int(sipxar_ledger["Underwater"].sum())
|
||
|
||
print("\n=== TIME UNDERWATER ===")
|
||
print(f"Periods underwater: {periods_underwater} / {len(sipxar_ledger)}")
|
||
print(f"% Time underwater : {periods_underwater / len(sipxar_ledger) * 100:.1f}%")
|
||
|
||
# =========================================================
|
||
# METRIC 2: SIP-AWARE MAX DRAWDOWN
|
||
# =========================================================
|
||
|
||
dd_sipxar = sip_max_drawdown(sipxar_ledger)
|
||
dd_nifty = sip_max_drawdown(nifty_ledger)
|
||
dd_static = sip_max_drawdown(static_ledger)
|
||
|
||
print("\n=== SIP-AWARE MAX DRAWDOWN ===")
|
||
for name, dd in [("SIPXAR", dd_sipxar), ("NIFTY SIP", dd_nifty), ("60/40 SIP", dd_static)]:
|
||
print(
|
||
f"{name:10s} | "
|
||
f"Peak: {dd['Peak'].date()} | "
|
||
f"Trough: {dd['Trough'].date()} | "
|
||
f"DD: {dd['Max_Drawdown']*100:.2f}%"
|
||
)
|
||
|
||
# =========================================================
|
||
# METRIC 3: WORST ROLLING 24M SIP XIRR (ORDER-BASED)
|
||
# =========================================================
|
||
|
||
# Convert 24 months to "order periods" approximately
|
||
ORDERS_PER_MONTH = TRADING_DAYS_PER_MONTH / ORDER_EVERY_N
|
||
ROLL_24M_PERIODS = int(round(24 * ORDERS_PER_MONTH))
|
||
ROLL_24M_PERIODS = max(6, ROLL_24M_PERIODS)
|
||
|
||
worst_24_sipxar = worst_rolling_xirr(sipxar_ledger, ROLL_24M_PERIODS)
|
||
worst_24_nifty = worst_rolling_xirr(nifty_ledger, ROLL_24M_PERIODS)
|
||
worst_24_static = worst_rolling_xirr(static_ledger, ROLL_24M_PERIODS)
|
||
|
||
print("\n=== WORST ROLLING ~24M XIRR (by order periods) ===")
|
||
print(f"Using {ROLL_24M_PERIODS} periods (~24 months) given N={ORDER_EVERY_N}")
|
||
for name, w in [("SIPXAR", worst_24_sipxar), ("NIFTY SIP", worst_24_nifty), ("60/40 SIP", worst_24_static)]:
|
||
if not w or pd.isna(w.get("XIRR", np.nan)):
|
||
print(f"{name:10s} | insufficient data")
|
||
continue
|
||
print(f"{name:10s} | {w['Start'].date()} → {w['End'].date()} | {w['XIRR']*100:.2f}%")
|
||
|
||
# =========================================================
|
||
# METRIC 4: PnL VOLATILITY (PER-ORDER)
|
||
# =========================================================
|
||
|
||
period_pnl = sipxar_ledger["Period_PnL"]
|
||
pnl_std = float(period_pnl.std())
|
||
pnl_mean = float(period_pnl.mean())
|
||
|
||
print("\n=== PnL VOLATILITY (PER ORDER PERIOD) ===")
|
||
print(f"Avg Period PnL : ₹{pnl_mean:,.2f}")
|
||
print(f"PnL Std Dev : ₹{pnl_std:,.2f}")
|
||
print(f"Volatility % : {pnl_std / SIP_AMOUNT_PER_ORDER * 100:.1f}% of per-order SIP")
|
||
|
||
# =========================================================
|
||
# SIP GRAPH: INVESTED vs PORTFOLIO VALUE (HEADLESS SAFE)
|
||
# =========================================================
|
||
|
||
import matplotlib
|
||
matplotlib.use("Agg")
|
||
import matplotlib.pyplot as plt
|
||
|
||
sipxar_ledger["Invested_Capital"] = sipxar_ledger["Total_Invested"]
|
||
|
||
plt.figure(figsize=(10, 5))
|
||
plt.plot(sipxar_ledger.index, sipxar_ledger["Portfolio_Value"], label="Portfolio Value")
|
||
plt.plot(sipxar_ledger.index, sipxar_ledger["Invested_Capital"], label="Total Invested")
|
||
|
||
plt.xlabel("Date")
|
||
plt.ylabel("Value (₹)")
|
||
plt.title(f"SIPXAR – SIP Performance (Every {ORDER_EVERY_N} Trading Days)")
|
||
plt.legend()
|
||
plt.tight_layout()
|
||
|
||
plt.savefig("sipxar_performance.png", dpi=150)
|
||
plt.close()
|
||
|
||
print("Plot saved: sipxar_performance.png")
|