256 lines
6.8 KiB
Python
256 lines
6.8 KiB
Python
import streamlit as st
|
|
import pandas as pd
|
|
import numpy as np
|
|
import yfinance as yf
|
|
from datetime import datetime, timedelta, time as dtime
|
|
import pytz
|
|
import time
|
|
|
|
# =========================
|
|
# CONFIG
|
|
# =========================
|
|
SP500 = "SPY"
|
|
GOLD = "GLD"
|
|
|
|
SMA_MONTHS = 36
|
|
BASE_EQUITY = 0.60
|
|
TILT_MULT = 1.5
|
|
MAX_TILT = 0.25
|
|
MIN_EQUITY = 0.20
|
|
MAX_EQUITY = 0.90
|
|
|
|
PRICE_REFRESH_SEC = 5
|
|
|
|
# =========================
|
|
# DATA
|
|
# =========================
|
|
@st.cache_data(ttl=3600)
|
|
def load_history(ticker):
|
|
df = yf.download(
|
|
ticker,
|
|
period="10y",
|
|
auto_adjust=True,
|
|
progress=False,
|
|
)
|
|
|
|
if df.empty:
|
|
raise RuntimeError(f"No historical data for {ticker}")
|
|
|
|
if isinstance(df.columns, pd.MultiIndex):
|
|
df.columns = df.columns.get_level_values(0)
|
|
|
|
if "Close" not in df.columns:
|
|
raise RuntimeError(f"'Close' not found for {ticker}")
|
|
|
|
series = df["Close"].copy()
|
|
|
|
if not isinstance(series.index, pd.DatetimeIndex):
|
|
raise RuntimeError(f"Index is not DatetimeIndex for {ticker}")
|
|
|
|
series = series.resample("M").last()
|
|
|
|
return series
|
|
|
|
|
|
@st.cache_data(ttl=15)
|
|
def live_price(ticker):
|
|
df = yf.download(
|
|
ticker,
|
|
period="1d",
|
|
interval="1m",
|
|
progress=False,
|
|
)
|
|
|
|
if df.empty:
|
|
raise RuntimeError(f"No live data for {ticker}")
|
|
|
|
if isinstance(df.columns, pd.MultiIndex):
|
|
df.columns = df.columns.get_level_values(0)
|
|
|
|
price = df["Close"].iloc[-1]
|
|
|
|
return float(price)
|
|
|
|
|
|
def us_market_status():
|
|
tz = pytz.timezone("America/New_York")
|
|
now = datetime.now(tz)
|
|
|
|
market_open = dtime(9, 30)
|
|
market_close = dtime(16, 0)
|
|
|
|
is_weekday = now.weekday() < 5
|
|
is_open = is_weekday and market_open <= now.time() <= market_close
|
|
|
|
return {
|
|
"is_open": is_open,
|
|
"now_et": now.strftime("%Y-%m-%d %H:%M:%S"),
|
|
"session": "OPEN" if is_open else "CLOSED",
|
|
}
|
|
|
|
|
|
sp_hist = load_history(SP500)
|
|
gd_hist = load_history(GOLD)
|
|
|
|
hist_prices = pd.concat(
|
|
[sp_hist, gd_hist],
|
|
axis=1,
|
|
keys=["SP500", "GOLD"],
|
|
).dropna()
|
|
|
|
sma_sp = hist_prices["SP500"].rolling(SMA_MONTHS).mean()
|
|
sma_gd = hist_prices["GOLD"].rolling(SMA_MONTHS).mean()
|
|
|
|
# =========================
|
|
# STATE INIT
|
|
# =========================
|
|
if "running" not in st.session_state:
|
|
st.session_state.running = False
|
|
st.session_state.last_sip = None
|
|
st.session_state.total_invested = 0.0
|
|
st.session_state.sp_units = 0.0
|
|
st.session_state.gd_units = 0.0
|
|
st.session_state.last_sp_price = None
|
|
st.session_state.last_gd_price = None
|
|
if "pnl_ledger" not in st.session_state:
|
|
st.session_state.pnl_ledger = pd.DataFrame(
|
|
columns=["timestamp", "pnl"]
|
|
)
|
|
|
|
# =========================
|
|
# UI
|
|
# =========================
|
|
st.title("SIPXAR - Live SIP Portfolio (US)")
|
|
market = us_market_status()
|
|
|
|
if market["is_open"]:
|
|
st.success(f"US Market OPEN (ET {market['now_et']})")
|
|
else:
|
|
st.warning(f"US Market CLOSED (ET {market['now_et']})")
|
|
|
|
sip_amount = st.number_input("SIP Amount ($)", min_value=10, value=1000, step=100)
|
|
sip_minutes = st.number_input(
|
|
"SIP Frequency (minutes) - TEST MODE",
|
|
min_value=1,
|
|
value=2,
|
|
step=1,
|
|
)
|
|
sip_interval = timedelta(minutes=sip_minutes)
|
|
|
|
col1, col2 = st.columns(2)
|
|
if col1.button("START"):
|
|
st.session_state.running = True
|
|
if st.session_state.last_sip is None:
|
|
st.session_state.last_sip = datetime.utcnow() - sip_interval
|
|
|
|
if col2.button("STOP"):
|
|
st.session_state.running = False
|
|
|
|
# =========================
|
|
# ENGINE LOOP
|
|
# =========================
|
|
if st.session_state.running:
|
|
now = datetime.utcnow()
|
|
|
|
if market["is_open"]:
|
|
sp_price = live_price(SP500)
|
|
gd_price = live_price(GOLD)
|
|
st.session_state.last_sp_price = sp_price
|
|
st.session_state.last_gd_price = gd_price
|
|
else:
|
|
sp_price = st.session_state.get("last_sp_price")
|
|
gd_price = st.session_state.get("last_gd_price")
|
|
|
|
if sp_price is None or pd.isna(sp_price):
|
|
sp_price = hist_prices["SP500"].iloc[-1]
|
|
if gd_price is None or pd.isna(gd_price):
|
|
gd_price = hist_prices["GOLD"].iloc[-1]
|
|
|
|
if st.session_state.last_sip is None:
|
|
st.session_state.last_sip = now - sip_interval
|
|
|
|
# SIP trigger only when market is open
|
|
if (
|
|
market["is_open"]
|
|
and (now - st.session_state.last_sip) >= sip_interval
|
|
):
|
|
rd = (
|
|
(hist_prices["SP500"].iloc[-1] / sma_sp.iloc[-1])
|
|
- (hist_prices["GOLD"].iloc[-1] / sma_gd.iloc[-1])
|
|
)
|
|
|
|
tilt = np.clip(-rd * TILT_MULT, -MAX_TILT, MAX_TILT)
|
|
eq_w = np.clip(BASE_EQUITY * (1 + tilt), MIN_EQUITY, MAX_EQUITY)
|
|
gd_w = 1 - eq_w
|
|
|
|
sp_buy = sip_amount * eq_w
|
|
gd_buy = sip_amount * gd_w
|
|
|
|
st.session_state.sp_units += sp_buy / sp_price
|
|
st.session_state.gd_units += gd_buy / gd_price
|
|
st.session_state.total_invested += sip_amount
|
|
st.session_state.last_sip = now
|
|
|
|
# MTM
|
|
sp_val = st.session_state.sp_units * sp_price
|
|
gd_val = st.session_state.gd_units * gd_price
|
|
port_val = sp_val + gd_val
|
|
pnl = port_val - st.session_state.total_invested
|
|
st.session_state.pnl_ledger = pd.concat(
|
|
[
|
|
st.session_state.pnl_ledger,
|
|
pd.DataFrame(
|
|
{
|
|
"timestamp": [datetime.utcnow()],
|
|
"pnl": [pnl],
|
|
}
|
|
),
|
|
],
|
|
ignore_index=True,
|
|
)
|
|
|
|
# =========================
|
|
# DISPLAY
|
|
# =========================
|
|
st.subheader("Portfolio Snapshot")
|
|
st.caption(
|
|
"Prices updating live" if market["is_open"] else "Prices frozen - market closed"
|
|
)
|
|
|
|
c1, c2, c3 = st.columns(3)
|
|
c1.metric("Total Invested", f"${st.session_state.total_invested:,.2f}")
|
|
c2.metric("Portfolio Value", f"${port_val:,.2f}")
|
|
c3.metric("Unrealized PnL", f"${pnl:,.2f}", delta=f"{pnl:,.2f}")
|
|
|
|
next_sip_in = sip_interval - (now - st.session_state.last_sip)
|
|
next_sip_sec = max(0, int(next_sip_in.total_seconds()))
|
|
st.caption(f"Next SIP in ~ {next_sip_sec} seconds (TEST MODE)")
|
|
|
|
st.subheader("Equity Curve (PnL)")
|
|
if len(st.session_state.pnl_ledger) > 1:
|
|
pnl_df = st.session_state.pnl_ledger.copy()
|
|
pnl_df["timestamp"] = pd.to_datetime(pnl_df["timestamp"])
|
|
pnl_df.set_index("timestamp", inplace=True)
|
|
|
|
st.line_chart(
|
|
pnl_df["pnl"],
|
|
height=350,
|
|
)
|
|
else:
|
|
st.info("Equity curve will appear after portfolio updates.")
|
|
|
|
st.subheader("Holdings")
|
|
st.dataframe(
|
|
pd.DataFrame(
|
|
{
|
|
"Asset": ["SP500 (SPY)", "Gold (GLD)"],
|
|
"Units": [st.session_state.sp_units, st.session_state.gd_units],
|
|
"Price": [sp_price, gd_price],
|
|
"Value": [sp_val, gd_val],
|
|
}
|
|
)
|
|
)
|
|
|
|
time.sleep(PRICE_REFRESH_SEC)
|
|
st.rerun()
|