diff --git a/indian_paper_trading_strategy/app/streamlit_app.py b/indian_paper_trading_strategy/app/streamlit_app.py index 8ce6e63..6c4ddf5 100644 --- a/indian_paper_trading_strategy/app/streamlit_app.py +++ b/indian_paper_trading_strategy/app/streamlit_app.py @@ -1,208 +1,208 @@ -import streamlit as st -import time -from datetime import datetime -from pathlib import Path -import sys -import pandas as pd - -PROJECT_ROOT = Path(__file__).resolve().parents[2] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) - -from indian_paper_trading_strategy.engine.history import load_monthly_close -from indian_paper_trading_strategy.engine.market import india_market_status -from indian_paper_trading_strategy.engine.data import fetch_live_price -from indian_paper_trading_strategy.engine.strategy import allocation -from indian_paper_trading_strategy.engine.execution import try_execute_sip -from indian_paper_trading_strategy.engine.state import load_state, save_state -from indian_paper_trading_strategy.engine.db import db_connection, insert_engine_event, run_with_retry, get_default_user_id, get_active_run_id, set_context -from indian_paper_trading_strategy.engine.mtm import log_mtm, should_log_mtm - -_STREAMLIT_USER_ID = get_default_user_id() -_STREAMLIT_RUN_ID = get_active_run_id(_STREAMLIT_USER_ID) if _STREAMLIT_USER_ID else None -if _STREAMLIT_USER_ID and _STREAMLIT_RUN_ID: - set_context(_STREAMLIT_USER_ID, _STREAMLIT_RUN_ID) - -def reset_runtime_state(): - def _op(cur, _conn): - cur.execute( - "DELETE FROM mtm_ledger WHERE user_id = %s AND run_id = %s", - (_STREAMLIT_USER_ID, _STREAMLIT_RUN_ID), - ) - cur.execute( - "DELETE FROM event_ledger WHERE user_id = %s AND run_id = %s", - (_STREAMLIT_USER_ID, _STREAMLIT_RUN_ID), - ) - cur.execute( - "DELETE FROM engine_state WHERE user_id = %s AND run_id = %s", - (_STREAMLIT_USER_ID, _STREAMLIT_RUN_ID), - ) - insert_engine_event(cur, "LIVE_RESET", data={}) - - run_with_retry(_op) - -def load_mtm_df(): - with db_connection() as conn: - return pd.read_sql_query( - "SELECT timestamp, pnl FROM mtm_ledger WHERE user_id = %s AND run_id = %s ORDER BY timestamp", - conn, - params=(_STREAMLIT_USER_ID, _STREAMLIT_RUN_ID), - ) - -def is_engine_running(): - state = load_state(mode="LIVE") - return state.get("total_invested", 0) > 0 or \ - state.get("nifty_units", 0) > 0 or \ - state.get("gold_units", 0) > 0 - -if "engine_active" not in st.session_state: - st.session_state.engine_active = is_engine_running() - -NIFTY = "NIFTYBEES.NS" -GOLD = "GOLDBEES.NS" -SMA_MONTHS = 36 - -def get_prices(): - try: - nifty = fetch_live_price(NIFTY) - gold = fetch_live_price(GOLD) - return nifty, gold - except Exception as e: - st.error(e) - return None, None - -SIP_AMOUNT = st.number_input("SIP Amount (\u20B9)", 500, 100000, 5000) -SIP_INTERVAL_SEC = st.number_input("SIP Interval (sec) [TEST]", 30, 3600, 120) -REFRESH_SEC = st.slider("Refresh interval (sec)", 5, 60, 10) - -st.title("SIPXAR INDIA - Phase-1 Safe Engine") - -market_open, market_time = india_market_status() -st.info(f"NSE Market {'OPEN' if market_open else 'CLOSED'} | IST {market_time}") -if not market_open: - st.info("Market is closed. Portfolio values are frozen at last available prices.") - -col1, col2 = st.columns(2) - -with col1: - if st.button("START ENGINE"): - if is_engine_running(): - st.info("Engine already running. Resuming.") - st.session_state.engine_active = True - else: - st.session_state.engine_active = True - - # HARD RESET ONLY ON FIRST START - reset_runtime_state() - - save_state({ - "total_invested": 0.0, - "nifty_units": 0.0, - "gold_units": 0.0, - "last_sip_ts": None, - }, mode="LIVE", emit_event=True, event_meta={"source": "streamlit_start"}) - - st.success("Engine started") - -with col2: - if st.button("KILL ENGINE"): - st.session_state.engine_active = False - - reset_runtime_state() - - st.warning("Engine killed and state wiped") - st.stop() - -if not st.session_state.engine_active: - st.stop() - -state = load_state(mode="LIVE") -nifty_price, gold_price = get_prices() - -if nifty_price is None: - st.stop() - -st.subheader("Latest Market Prices (LTP)") - -c1, c2 = st.columns(2) - -with c1: - st.metric( - label="NIFTYBEES", - value=f"\u20B9{nifty_price:,.2f}", - help="Last traded price (delayed)" - ) - -with c2: - st.metric( - label="GOLDBEES", - value=f"\u20B9{gold_price:,.2f}", - help="Last traded price (delayed)" - ) - -st.caption(f"Price timestamp: {datetime.now().strftime('%H:%M:%S')}") - -nifty_hist = load_monthly_close(NIFTY) -gold_hist = load_monthly_close(GOLD) - -nifty_sma = nifty_hist.rolling(SMA_MONTHS).mean().iloc[-1] -gold_sma = gold_hist.rolling(SMA_MONTHS).mean().iloc[-1] - -eq_w, gd_w = allocation( - sp_price=nifty_price, - gd_price=gold_price, - sp_sma=nifty_sma, - gd_sma=gold_sma -) - -state, executed = try_execute_sip( - now=datetime.now(), - market_open=market_open, - sip_interval=SIP_INTERVAL_SEC, - sip_amount=SIP_AMOUNT, - sp_price=nifty_price, - gd_price=gold_price, - eq_w=eq_w, - gd_w=gd_w, - mode="LIVE", -) - -now = datetime.now() - -if market_open and should_log_mtm(None, now): - portfolio_value, pnl = log_mtm( - nifty_units=state["nifty_units"], - gold_units=state["gold_units"], - nifty_price=nifty_price, - gold_price=gold_price, - total_invested=state["total_invested"], - ) -else: - # Market closed -> freeze valuation (do NOT log) - portfolio_value = ( - state["nifty_units"] * nifty_price + - state["gold_units"] * gold_price - ) - pnl = portfolio_value - state["total_invested"] - -st.subheader("Equity Curve (Unrealized PnL)") - -mtm_df = load_mtm_df() - -if "timestamp" in mtm_df.columns and "pnl" in mtm_df.columns and len(mtm_df) > 1: - mtm_df["timestamp"] = pd.to_datetime(mtm_df["timestamp"]) - mtm_df = mtm_df.sort_values("timestamp").set_index("timestamp") - - st.line_chart(mtm_df["pnl"], height=350) -else: - st.warning("Not enough MTM data or missing columns. Expected: timestamp, pnl.") - -st.metric("Total Invested", f"\u20B9{state['total_invested']:,.0f}") -st.metric("NIFTY Units", round(state["nifty_units"], 4)) -st.metric("Gold Units", round(state["gold_units"], 4)) -st.metric("Portfolio Value", f"\u20B9{portfolio_value:,.0f}") -st.metric("PnL", f"\u20B9{pnl:,.0f}") - -time.sleep(REFRESH_SEC) -st.rerun() - +import streamlit as st +import time +from datetime import datetime +from pathlib import Path +import sys +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from indian_paper_trading_strategy.engine.history import load_monthly_close +from indian_paper_trading_strategy.engine.market import india_market_status +from indian_paper_trading_strategy.engine.data import fetch_live_price +from indian_paper_trading_strategy.engine.strategy import allocation +from indian_paper_trading_strategy.engine.execution import try_execute_sip +from indian_paper_trading_strategy.engine.state import load_state, save_state +from indian_paper_trading_strategy.engine.db import db_connection, insert_engine_event, run_with_retry, get_default_user_id, get_active_run_id, set_context +from indian_paper_trading_strategy.engine.mtm import log_mtm, should_log_mtm + +_STREAMLIT_USER_ID = get_default_user_id() +_STREAMLIT_RUN_ID = get_active_run_id(_STREAMLIT_USER_ID) if _STREAMLIT_USER_ID else None +if _STREAMLIT_USER_ID and _STREAMLIT_RUN_ID: + set_context(_STREAMLIT_USER_ID, _STREAMLIT_RUN_ID) + +def reset_runtime_state(): + def _op(cur, _conn): + cur.execute( + "DELETE FROM mtm_ledger WHERE user_id = %s AND run_id = %s", + (_STREAMLIT_USER_ID, _STREAMLIT_RUN_ID), + ) + cur.execute( + "DELETE FROM event_ledger WHERE user_id = %s AND run_id = %s", + (_STREAMLIT_USER_ID, _STREAMLIT_RUN_ID), + ) + cur.execute( + "DELETE FROM engine_state WHERE user_id = %s AND run_id = %s", + (_STREAMLIT_USER_ID, _STREAMLIT_RUN_ID), + ) + insert_engine_event(cur, "LIVE_RESET", data={}) + + run_with_retry(_op) + +def load_mtm_df(): + with db_connection() as conn: + return pd.read_sql_query( + "SELECT timestamp, pnl FROM mtm_ledger WHERE user_id = %s AND run_id = %s ORDER BY timestamp", + conn, + params=(_STREAMLIT_USER_ID, _STREAMLIT_RUN_ID), + ) + +def is_engine_running(): + state = load_state(mode="LIVE") + return state.get("total_invested", 0) > 0 or \ + state.get("nifty_units", 0) > 0 or \ + state.get("gold_units", 0) > 0 + +if "engine_active" not in st.session_state: + st.session_state.engine_active = is_engine_running() + +NIFTY = "NIFTYBEES.NS" +GOLD = "GOLDBEES.NS" +SMA_MONTHS = 36 + +def get_prices(): + try: + nifty = fetch_live_price(NIFTY) + gold = fetch_live_price(GOLD) + return nifty, gold + except Exception as e: + st.error(e) + return None, None + +SIP_AMOUNT = st.number_input("SIP Amount (\u20B9)", 500, 100000, 5000) +SIP_INTERVAL_SEC = st.number_input("SIP Interval (sec) [TEST]", 30, 3600, 120) +REFRESH_SEC = st.slider("Refresh interval (sec)", 5, 60, 10) + +st.title("SIPXAR INDIA - Phase-1 Safe Engine") + +market_open, market_time = india_market_status() +st.info(f"NSE Market {'OPEN' if market_open else 'CLOSED'} | IST {market_time}") +if not market_open: + st.info("Market is closed. Portfolio values are frozen at last available prices.") + +col1, col2 = st.columns(2) + +with col1: + if st.button("START ENGINE"): + if is_engine_running(): + st.info("Engine already running. Resuming.") + st.session_state.engine_active = True + else: + st.session_state.engine_active = True + + # HARD RESET ONLY ON FIRST START + reset_runtime_state() + + save_state({ + "total_invested": 0.0, + "nifty_units": 0.0, + "gold_units": 0.0, + "last_sip_ts": None, + }, mode="LIVE", emit_event=True, event_meta={"source": "streamlit_start"}) + + st.success("Engine started") + +with col2: + if st.button("KILL ENGINE"): + st.session_state.engine_active = False + + reset_runtime_state() + + st.warning("Engine killed and state wiped") + st.stop() + +if not st.session_state.engine_active: + st.stop() + +state = load_state(mode="LIVE") +nifty_price, gold_price = get_prices() + +if nifty_price is None: + st.stop() + +st.subheader("Latest Market Prices (LTP)") + +c1, c2 = st.columns(2) + +with c1: + st.metric( + label="NIFTYBEES", + value=f"\u20B9{nifty_price:,.2f}", + help="Last traded price (delayed)" + ) + +with c2: + st.metric( + label="GOLDBEES", + value=f"\u20B9{gold_price:,.2f}", + help="Last traded price (delayed)" + ) + +st.caption(f"Price timestamp: {datetime.now().strftime('%H:%M:%S')}") + +nifty_hist = load_monthly_close(NIFTY) +gold_hist = load_monthly_close(GOLD) + +nifty_sma = nifty_hist.rolling(SMA_MONTHS).mean().iloc[-1] +gold_sma = gold_hist.rolling(SMA_MONTHS).mean().iloc[-1] + +eq_w, gd_w = allocation( + sp_price=nifty_price, + gd_price=gold_price, + sp_sma=nifty_sma, + gd_sma=gold_sma +) + +state, executed = try_execute_sip( + now=datetime.now(), + market_open=market_open, + sip_interval=SIP_INTERVAL_SEC, + sip_amount=SIP_AMOUNT, + sp_price=nifty_price, + gd_price=gold_price, + eq_w=eq_w, + gd_w=gd_w, + mode="LIVE", +) + +now = datetime.now() + +if market_open and should_log_mtm(None, now): + portfolio_value, pnl = log_mtm( + nifty_units=state["nifty_units"], + gold_units=state["gold_units"], + nifty_price=nifty_price, + gold_price=gold_price, + total_invested=state["total_invested"], + ) +else: + # Market closed -> freeze valuation (do NOT log) + portfolio_value = ( + state["nifty_units"] * nifty_price + + state["gold_units"] * gold_price + ) + pnl = portfolio_value - state["total_invested"] + +st.subheader("Equity Curve (Unrealized PnL)") + +mtm_df = load_mtm_df() + +if "timestamp" in mtm_df.columns and "pnl" in mtm_df.columns and len(mtm_df) > 1: + mtm_df["timestamp"] = pd.to_datetime(mtm_df["timestamp"]) + mtm_df = mtm_df.sort_values("timestamp").set_index("timestamp") + + st.line_chart(mtm_df["pnl"], height=350) +else: + st.warning("Not enough MTM data or missing columns. Expected: timestamp, pnl.") + +st.metric("Total Invested", f"\u20B9{state['total_invested']:,.0f}") +st.metric("NIFTY Units", round(state["nifty_units"], 4)) +st.metric("Gold Units", round(state["gold_units"], 4)) +st.metric("Portfolio Value", f"\u20B9{portfolio_value:,.0f}") +st.metric("PnL", f"\u20B9{pnl:,.0f}") + +time.sleep(REFRESH_SEC) +st.rerun() + diff --git a/indian_paper_trading_strategy/engine/__init__.py b/indian_paper_trading_strategy/engine/__init__.py index bf0d74e..02aa093 100644 --- a/indian_paper_trading_strategy/engine/__init__.py +++ b/indian_paper_trading_strategy/engine/__init__.py @@ -1 +1 @@ -"""Engine package for the India paper trading strategy.""" +"""Engine package for the India paper trading strategy.""" diff --git a/indian_paper_trading_strategy/engine/broker.py b/indian_paper_trading_strategy/engine/broker.py index cc3ec87..b443eba 100644 --- a/indian_paper_trading_strategy/engine/broker.py +++ b/indian_paper_trading_strategy/engine/broker.py @@ -1,697 +1,697 @@ -from __future__ import annotations - -from abc import ABC, abstractmethod -from dataclasses import dataclass -from datetime import datetime, timezone -import hashlib - -from psycopg2.extras import execute_values - -from indian_paper_trading_strategy.engine.data import fetch_live_price -from indian_paper_trading_strategy.engine.db import db_connection, insert_engine_event, run_with_retry, get_context - - -class Broker(ABC): - @abstractmethod - def place_order( - self, - symbol: str, - side: str, - quantity: float, - price: float | None = None, - logical_time: datetime | None = None, - ): - raise NotImplementedError - - @abstractmethod - def get_positions(self): - raise NotImplementedError - - @abstractmethod - def get_orders(self): - raise NotImplementedError - - @abstractmethod - def get_funds(self): - raise NotImplementedError - - -def _local_tz(): - return datetime.now().astimezone().tzinfo - - -def _format_utc_ts(value: datetime | None): - if value is None: - return None - if value.tzinfo is None: - value = value.replace(tzinfo=_local_tz()) - return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") - - -def _format_local_ts(value: datetime | None): - if value is None: - return None - if value.tzinfo is None: - value = value.replace(tzinfo=_local_tz()) - return value.astimezone(_local_tz()).replace(tzinfo=None).isoformat() - - -def _parse_ts(value, assume_local: bool = True): - if value is None: - return None - if isinstance(value, datetime): - if value.tzinfo is None: - return value.replace(tzinfo=_local_tz() if assume_local else timezone.utc) - return value - if isinstance(value, str): - text = value.strip() - if not text: - return None - if text.endswith("Z"): - try: - return datetime.fromisoformat(text.replace("Z", "+00:00")) - except ValueError: - return None - try: - parsed = datetime.fromisoformat(text) - except ValueError: - return None - if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=_local_tz() if assume_local else timezone.utc) - return parsed - return None - - -def _stable_num(value: float) -> str: - return f"{float(value):.12f}" - - -def _normalize_ts_for_id(ts: datetime) -> str: - if ts.tzinfo is None: - ts = ts.replace(tzinfo=timezone.utc) - return ts.astimezone(timezone.utc).replace(microsecond=0).isoformat() - - -def _deterministic_id(prefix: str, parts: list[str]) -> str: - payload = "|".join(parts) - digest = hashlib.sha1(payload.encode("utf-8")).hexdigest()[:16] - return f"{prefix}_{digest}" - - -def _resolve_scope(user_id: str | None, run_id: str | None): - return get_context(user_id, run_id) - - -@dataclass -class PaperBroker(Broker): - initial_cash: float - store_path: str | None = None - - def _default_store(self): - return { - "cash": float(self.initial_cash), - "positions": {}, - "orders": [], - "trades": [], - "equity_curve": [], - } - - def _load_store(self, cur=None, for_update: bool = False, user_id: str | None = None, run_id: str | None = None): - scope_user, scope_run = _resolve_scope(user_id, run_id) - if cur is None: - with db_connection() as conn: - with conn.cursor() as cur: - return self._load_store( - cur=cur, - for_update=for_update, - user_id=scope_user, - run_id=scope_run, - ) - - store = self._default_store() - lock_clause = " FOR UPDATE" if for_update else "" - cur.execute( - f"SELECT cash FROM paper_broker_account WHERE user_id = %s AND run_id = %s{lock_clause} LIMIT 1", - (scope_user, scope_run), - ) - row = cur.fetchone() - if row and row[0] is not None: - store["cash"] = float(row[0]) - - cur.execute( - f""" - SELECT symbol, qty, avg_price, last_price - FROM paper_position - WHERE user_id = %s AND run_id = %s{lock_clause} - """ - , - (scope_user, scope_run), - ) - positions = {} - for symbol, qty, avg_price, last_price in cur.fetchall(): - positions[symbol] = { - "qty": float(qty) if qty is not None else 0.0, - "avg_price": float(avg_price) if avg_price is not None else 0.0, - "last_price": float(last_price) if last_price is not None else 0.0, - } - store["positions"] = positions - - cur.execute( - """ - SELECT id, symbol, side, qty, price, status, timestamp, logical_time - FROM paper_order - WHERE user_id = %s AND run_id = %s - ORDER BY timestamp, id - """ - , - (scope_user, scope_run), - ) - orders = [] - for order_id, symbol, side, qty, price, status, ts, logical_ts in cur.fetchall(): - orders.append( - { - "id": order_id, - "symbol": symbol, - "side": side, - "qty": float(qty) if qty is not None else 0.0, - "price": float(price) if price is not None else 0.0, - "status": status, - "timestamp": _format_utc_ts(ts), - "_logical_time": _format_utc_ts(logical_ts), - } - ) - store["orders"] = orders - - cur.execute( - """ - SELECT id, order_id, symbol, side, qty, price, timestamp, logical_time - FROM paper_trade - WHERE user_id = %s AND run_id = %s - ORDER BY timestamp, id - """ - , - (scope_user, scope_run), - ) - trades = [] - for trade_id, order_id, symbol, side, qty, price, ts, logical_ts in cur.fetchall(): - trades.append( - { - "id": trade_id, - "order_id": order_id, - "symbol": symbol, - "side": side, - "qty": float(qty) if qty is not None else 0.0, - "price": float(price) if price is not None else 0.0, - "timestamp": _format_utc_ts(ts), - "_logical_time": _format_utc_ts(logical_ts), - } - ) - store["trades"] = trades - - cur.execute( - """ - SELECT timestamp, logical_time, equity, pnl - FROM paper_equity_curve - WHERE user_id = %s AND run_id = %s - ORDER BY timestamp - """ - , - (scope_user, scope_run), - ) - equity_curve = [] - for ts, logical_ts, equity, pnl in cur.fetchall(): - equity_curve.append( - { - "timestamp": _format_local_ts(ts), - "_logical_time": _format_local_ts(logical_ts), - "equity": float(equity) if equity is not None else 0.0, - "pnl": float(pnl) if pnl is not None else 0.0, - } - ) - store["equity_curve"] = equity_curve - return store - - def _save_store(self, store, cur=None, user_id: str | None = None, run_id: str | None = None): - scope_user, scope_run = _resolve_scope(user_id, run_id) - if cur is None: - def _persist(cur, _conn): - self._save_store(store, cur=cur, user_id=scope_user, run_id=scope_run) - return run_with_retry(_persist) - - cash = store.get("cash") - if cash is not None: - cur.execute( - """ - INSERT INTO paper_broker_account (user_id, run_id, cash) - VALUES (%s, %s, %s) - ON CONFLICT (user_id, run_id) DO UPDATE - SET cash = EXCLUDED.cash - """, - (scope_user, scope_run, float(cash)), - ) - - positions = store.get("positions") - if isinstance(positions, dict): - symbols = [s for s in positions.keys() if s] - if symbols: - cur.execute( - "DELETE FROM paper_position WHERE user_id = %s AND run_id = %s AND symbol NOT IN %s", - (scope_user, scope_run, tuple(symbols)), - ) - else: - cur.execute( - "DELETE FROM paper_position WHERE user_id = %s AND run_id = %s", - (scope_user, scope_run), - ) - - if symbols: - rows = [] - updated_at = datetime.now(timezone.utc) - for symbol, data in positions.items(): - if not symbol or not isinstance(data, dict): - continue - rows.append( - ( - scope_user, - scope_run, - symbol, - float(data.get("qty", 0.0)), - float(data.get("avg_price", 0.0)), - float(data.get("last_price", 0.0)), - updated_at, - ) - ) - if rows: - execute_values( - cur, - """ - INSERT INTO paper_position ( - user_id, run_id, symbol, qty, avg_price, last_price, updated_at - ) - VALUES %s - ON CONFLICT (user_id, run_id, symbol) DO UPDATE - SET qty = EXCLUDED.qty, - avg_price = EXCLUDED.avg_price, - last_price = EXCLUDED.last_price, - updated_at = EXCLUDED.updated_at - """, - rows, - ) - - orders = store.get("orders") - if isinstance(orders, list) and orders: - rows = [] - for order in orders: - if not isinstance(order, dict): - continue - order_id = order.get("id") - if not order_id: - continue - ts = _parse_ts(order.get("timestamp"), assume_local=False) - logical_ts = _parse_ts(order.get("_logical_time"), assume_local=False) or ts - rows.append( - ( - scope_user, - scope_run, - order_id, - order.get("symbol"), - order.get("side"), - float(order.get("qty", 0.0)), - float(order.get("price", 0.0)), - order.get("status"), - ts, - logical_ts, - ) - ) - if rows: - execute_values( - cur, - """ - INSERT INTO paper_order ( - user_id, run_id, id, symbol, side, qty, price, status, timestamp, logical_time - ) - VALUES %s - ON CONFLICT DO NOTHING - """, - rows, - ) - - trades = store.get("trades") - if isinstance(trades, list) and trades: - rows = [] - for trade in trades: - if not isinstance(trade, dict): - continue - trade_id = trade.get("id") - if not trade_id: - continue - ts = _parse_ts(trade.get("timestamp"), assume_local=False) - logical_ts = _parse_ts(trade.get("_logical_time"), assume_local=False) or ts - rows.append( - ( - scope_user, - scope_run, - trade_id, - trade.get("order_id"), - trade.get("symbol"), - trade.get("side"), - float(trade.get("qty", 0.0)), - float(trade.get("price", 0.0)), - ts, - logical_ts, - ) - ) - if rows: - execute_values( - cur, - """ - INSERT INTO paper_trade ( - user_id, run_id, id, order_id, symbol, side, qty, price, timestamp, logical_time - ) - VALUES %s - ON CONFLICT DO NOTHING - """, - rows, - ) - - equity_curve = store.get("equity_curve") - if isinstance(equity_curve, list) and equity_curve: - rows = [] - for point in equity_curve: - if not isinstance(point, dict): - continue - ts = _parse_ts(point.get("timestamp"), assume_local=True) - logical_ts = _parse_ts(point.get("_logical_time"), assume_local=True) or ts - if ts is None: - continue - rows.append( - ( - scope_user, - scope_run, - ts, - logical_ts, - float(point.get("equity", 0.0)), - float(point.get("pnl", 0.0)), - ) - ) - if rows: - execute_values( - cur, - """ - INSERT INTO paper_equity_curve (user_id, run_id, timestamp, logical_time, equity, pnl) - VALUES %s - ON CONFLICT DO NOTHING - """, - rows, - ) - - def get_funds(self, cur=None): - store = self._load_store(cur=cur) - cash = float(store.get("cash", 0)) - positions = store.get("positions", {}) - positions_value = 0.0 - for position in positions.values(): - qty = float(position.get("qty", 0)) - last_price = float(position.get("last_price", position.get("avg_price", 0))) - positions_value += qty * last_price - total_equity = cash + positions_value - return { - "cash_available": cash, - "invested_value": positions_value, - "cash": cash, - "used_margin": 0.0, - "available": cash, - "net": total_equity, - "total_equity": total_equity, - } - - def get_positions(self, cur=None): - store = self._load_store(cur=cur) - positions = store.get("positions", {}) - return [ - { - "symbol": symbol, - "qty": float(data.get("qty", 0)), - "avg_price": float(data.get("avg_price", 0)), - "last_price": float(data.get("last_price", data.get("avg_price", 0))), - } - for symbol, data in positions.items() - ] - - def get_orders(self, cur=None): - store = self._load_store(cur=cur) - orders = [] - for order in store.get("orders", []): - if isinstance(order, dict): - order = {k: v for k, v in order.items() if k != "_logical_time"} - orders.append(order) - return orders - - def get_trades(self, cur=None): - store = self._load_store(cur=cur) - trades = [] - for trade in store.get("trades", []): - if isinstance(trade, dict): - trade = {k: v for k, v in trade.items() if k != "_logical_time"} - trades.append(trade) - return trades - - def get_equity_curve(self, cur=None): - store = self._load_store(cur=cur) - points = [] - for point in store.get("equity_curve", []): - if isinstance(point, dict): - point = {k: v for k, v in point.items() if k != "_logical_time"} - points.append(point) - return points - - def _update_equity_in_tx( - self, - cur, - prices: dict[str, float], - now: datetime, - logical_time: datetime | None = None, - user_id: str | None = None, - run_id: str | None = None, - ): - store = self._load_store(cur=cur, for_update=True, user_id=user_id, run_id=run_id) - positions = store.get("positions", {}) - for symbol, price in prices.items(): - if symbol in positions: - positions[symbol]["last_price"] = float(price) - - cash = float(store.get("cash", 0)) - positions_value = 0.0 - for symbol, position in positions.items(): - qty = float(position.get("qty", 0)) - price = float(position.get("last_price", position.get("avg_price", 0))) - positions_value += qty * price - - equity = cash + positions_value - pnl = equity - float(self.initial_cash) - ts_for_equity = logical_time or now - store.setdefault("equity_curve", []).append( - { - "timestamp": _format_local_ts(ts_for_equity), - "_logical_time": _format_local_ts(ts_for_equity), - "equity": equity, - "pnl": pnl, - } - ) - store["positions"] = positions - self._save_store(store, cur=cur, user_id=user_id, run_id=run_id) - insert_engine_event( - cur, - "EQUITY_UPDATED", - data={ - "timestamp": _format_utc_ts(ts_for_equity), - "equity": equity, - "pnl": pnl, - }, - ) - return equity - - def update_equity( - self, - prices: dict[str, float], - now: datetime, - cur=None, - logical_time: datetime | None = None, - user_id: str | None = None, - run_id: str | None = None, - ): - if cur is not None: - return self._update_equity_in_tx( - cur, - prices, - now, - logical_time=logical_time, - user_id=user_id, - run_id=run_id, - ) - - def _op(cur, _conn): - return self._update_equity_in_tx( - cur, - prices, - now, - logical_time=logical_time, - user_id=user_id, - run_id=run_id, - ) - - return run_with_retry(_op) - - def _place_order_in_tx( - self, - cur, - symbol: str, - side: str, - quantity: float, - price: float | None, - logical_time: datetime | None = None, - user_id: str | None = None, - run_id: str | None = None, - ): - scope_user, scope_run = _resolve_scope(user_id, run_id) - store = self._load_store(cur=cur, for_update=True, user_id=scope_user, run_id=scope_run) - side = side.upper().strip() - qty = float(quantity) - if price is None: - price = fetch_live_price(symbol) - price = float(price) - - logical_ts = logical_time or datetime.utcnow().replace(tzinfo=timezone.utc) - timestamp = logical_ts - timestamp_str = _format_utc_ts(timestamp) - logical_ts_str = _format_utc_ts(logical_ts) - order_id = _deterministic_id( - "ord", - [ - scope_user, - scope_run, - _normalize_ts_for_id(logical_ts), - symbol, - side, - _stable_num(qty), - _stable_num(price), - ], - ) - - order = { - "id": order_id, - "symbol": symbol, - "side": side, - "qty": qty, - "price": price, - "status": "REJECTED", - "timestamp": timestamp_str, - "_logical_time": logical_ts_str, - } - - if qty <= 0 or price <= 0: - store.setdefault("orders", []).append(order) - self._save_store(store, cur=cur, user_id=user_id, run_id=run_id) - insert_engine_event(cur, "ORDER_PLACED", data=order) - return order - - positions = store.get("positions", {}) - cash = float(store.get("cash", 0)) - trade = None - - if side == "BUY": - cost = qty * price - if cash >= cost: - cash -= cost - existing = positions.get(symbol, {"qty": 0.0, "avg_price": 0.0, "last_price": price}) - new_qty = float(existing.get("qty", 0)) + qty - prev_cost = float(existing.get("qty", 0)) * float(existing.get("avg_price", 0)) - avg_price = (prev_cost + cost) / new_qty if new_qty else price - positions[symbol] = { - "qty": new_qty, - "avg_price": avg_price, - "last_price": price, - } - order["status"] = "FILLED" - trade = { - "id": _deterministic_id("trd", [order_id]), - "order_id": order_id, - "symbol": symbol, - "side": side, - "qty": qty, - "price": price, - "timestamp": timestamp_str, - "_logical_time": logical_ts_str, - } - store.setdefault("trades", []).append(trade) - elif side == "SELL": - existing = positions.get(symbol) - if existing and float(existing.get("qty", 0)) >= qty: - cash += qty * price - remaining = float(existing.get("qty", 0)) - qty - if remaining > 0: - existing["qty"] = remaining - existing["last_price"] = price - positions[symbol] = existing - else: - positions.pop(symbol, None) - order["status"] = "FILLED" - trade = { - "id": _deterministic_id("trd", [order_id]), - "order_id": order_id, - "symbol": symbol, - "side": side, - "qty": qty, - "price": price, - "timestamp": timestamp_str, - "_logical_time": logical_ts_str, - } - store.setdefault("trades", []).append(trade) - - store["cash"] = cash - store["positions"] = positions - store.setdefault("orders", []).append(order) - self._save_store(store, cur=cur, user_id=user_id, run_id=run_id) - insert_engine_event(cur, "ORDER_PLACED", data=order) - if trade is not None: - insert_engine_event(cur, "TRADE_EXECUTED", data=trade) - insert_engine_event(cur, "ORDER_FILLED", data={"order_id": order_id}) - return order - - def place_order( - self, - symbol: str, - side: str, - quantity: float, - price: float | None = None, - cur=None, - logical_time: datetime | None = None, - user_id: str | None = None, - run_id: str | None = None, - ): - if cur is not None: - return self._place_order_in_tx( - cur, - symbol, - side, - quantity, - price, - logical_time=logical_time, - user_id=user_id, - run_id=run_id, - ) - - def _op(cur, _conn): - return self._place_order_in_tx( - cur, - symbol, - side, - quantity, - price, - logical_time=logical_time, - user_id=user_id, - run_id=run_id, - ) - - return run_with_retry(_op) - +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from datetime import datetime, timezone +import hashlib + +from psycopg2.extras import execute_values + +from indian_paper_trading_strategy.engine.data import fetch_live_price +from indian_paper_trading_strategy.engine.db import db_connection, insert_engine_event, run_with_retry, get_context + + +class Broker(ABC): + @abstractmethod + def place_order( + self, + symbol: str, + side: str, + quantity: float, + price: float | None = None, + logical_time: datetime | None = None, + ): + raise NotImplementedError + + @abstractmethod + def get_positions(self): + raise NotImplementedError + + @abstractmethod + def get_orders(self): + raise NotImplementedError + + @abstractmethod + def get_funds(self): + raise NotImplementedError + + +def _local_tz(): + return datetime.now().astimezone().tzinfo + + +def _format_utc_ts(value: datetime | None): + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=_local_tz()) + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _format_local_ts(value: datetime | None): + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=_local_tz()) + return value.astimezone(_local_tz()).replace(tzinfo=None).isoformat() + + +def _parse_ts(value, assume_local: bool = True): + if value is None: + return None + if isinstance(value, datetime): + if value.tzinfo is None: + return value.replace(tzinfo=_local_tz() if assume_local else timezone.utc) + return value + if isinstance(value, str): + text = value.strip() + if not text: + return None + if text.endswith("Z"): + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + return None + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=_local_tz() if assume_local else timezone.utc) + return parsed + return None + + +def _stable_num(value: float) -> str: + return f"{float(value):.12f}" + + +def _normalize_ts_for_id(ts: datetime) -> str: + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + return ts.astimezone(timezone.utc).replace(microsecond=0).isoformat() + + +def _deterministic_id(prefix: str, parts: list[str]) -> str: + payload = "|".join(parts) + digest = hashlib.sha1(payload.encode("utf-8")).hexdigest()[:16] + return f"{prefix}_{digest}" + + +def _resolve_scope(user_id: str | None, run_id: str | None): + return get_context(user_id, run_id) + + +@dataclass +class PaperBroker(Broker): + initial_cash: float + store_path: str | None = None + + def _default_store(self): + return { + "cash": float(self.initial_cash), + "positions": {}, + "orders": [], + "trades": [], + "equity_curve": [], + } + + def _load_store(self, cur=None, for_update: bool = False, user_id: str | None = None, run_id: str | None = None): + scope_user, scope_run = _resolve_scope(user_id, run_id) + if cur is None: + with db_connection() as conn: + with conn.cursor() as cur: + return self._load_store( + cur=cur, + for_update=for_update, + user_id=scope_user, + run_id=scope_run, + ) + + store = self._default_store() + lock_clause = " FOR UPDATE" if for_update else "" + cur.execute( + f"SELECT cash FROM paper_broker_account WHERE user_id = %s AND run_id = %s{lock_clause} LIMIT 1", + (scope_user, scope_run), + ) + row = cur.fetchone() + if row and row[0] is not None: + store["cash"] = float(row[0]) + + cur.execute( + f""" + SELECT symbol, qty, avg_price, last_price + FROM paper_position + WHERE user_id = %s AND run_id = %s{lock_clause} + """ + , + (scope_user, scope_run), + ) + positions = {} + for symbol, qty, avg_price, last_price in cur.fetchall(): + positions[symbol] = { + "qty": float(qty) if qty is not None else 0.0, + "avg_price": float(avg_price) if avg_price is not None else 0.0, + "last_price": float(last_price) if last_price is not None else 0.0, + } + store["positions"] = positions + + cur.execute( + """ + SELECT id, symbol, side, qty, price, status, timestamp, logical_time + FROM paper_order + WHERE user_id = %s AND run_id = %s + ORDER BY timestamp, id + """ + , + (scope_user, scope_run), + ) + orders = [] + for order_id, symbol, side, qty, price, status, ts, logical_ts in cur.fetchall(): + orders.append( + { + "id": order_id, + "symbol": symbol, + "side": side, + "qty": float(qty) if qty is not None else 0.0, + "price": float(price) if price is not None else 0.0, + "status": status, + "timestamp": _format_utc_ts(ts), + "_logical_time": _format_utc_ts(logical_ts), + } + ) + store["orders"] = orders + + cur.execute( + """ + SELECT id, order_id, symbol, side, qty, price, timestamp, logical_time + FROM paper_trade + WHERE user_id = %s AND run_id = %s + ORDER BY timestamp, id + """ + , + (scope_user, scope_run), + ) + trades = [] + for trade_id, order_id, symbol, side, qty, price, ts, logical_ts in cur.fetchall(): + trades.append( + { + "id": trade_id, + "order_id": order_id, + "symbol": symbol, + "side": side, + "qty": float(qty) if qty is not None else 0.0, + "price": float(price) if price is not None else 0.0, + "timestamp": _format_utc_ts(ts), + "_logical_time": _format_utc_ts(logical_ts), + } + ) + store["trades"] = trades + + cur.execute( + """ + SELECT timestamp, logical_time, equity, pnl + FROM paper_equity_curve + WHERE user_id = %s AND run_id = %s + ORDER BY timestamp + """ + , + (scope_user, scope_run), + ) + equity_curve = [] + for ts, logical_ts, equity, pnl in cur.fetchall(): + equity_curve.append( + { + "timestamp": _format_local_ts(ts), + "_logical_time": _format_local_ts(logical_ts), + "equity": float(equity) if equity is not None else 0.0, + "pnl": float(pnl) if pnl is not None else 0.0, + } + ) + store["equity_curve"] = equity_curve + return store + + def _save_store(self, store, cur=None, user_id: str | None = None, run_id: str | None = None): + scope_user, scope_run = _resolve_scope(user_id, run_id) + if cur is None: + def _persist(cur, _conn): + self._save_store(store, cur=cur, user_id=scope_user, run_id=scope_run) + return run_with_retry(_persist) + + cash = store.get("cash") + if cash is not None: + cur.execute( + """ + INSERT INTO paper_broker_account (user_id, run_id, cash) + VALUES (%s, %s, %s) + ON CONFLICT (user_id, run_id) DO UPDATE + SET cash = EXCLUDED.cash + """, + (scope_user, scope_run, float(cash)), + ) + + positions = store.get("positions") + if isinstance(positions, dict): + symbols = [s for s in positions.keys() if s] + if symbols: + cur.execute( + "DELETE FROM paper_position WHERE user_id = %s AND run_id = %s AND symbol NOT IN %s", + (scope_user, scope_run, tuple(symbols)), + ) + else: + cur.execute( + "DELETE FROM paper_position WHERE user_id = %s AND run_id = %s", + (scope_user, scope_run), + ) + + if symbols: + rows = [] + updated_at = datetime.now(timezone.utc) + for symbol, data in positions.items(): + if not symbol or not isinstance(data, dict): + continue + rows.append( + ( + scope_user, + scope_run, + symbol, + float(data.get("qty", 0.0)), + float(data.get("avg_price", 0.0)), + float(data.get("last_price", 0.0)), + updated_at, + ) + ) + if rows: + execute_values( + cur, + """ + INSERT INTO paper_position ( + user_id, run_id, symbol, qty, avg_price, last_price, updated_at + ) + VALUES %s + ON CONFLICT (user_id, run_id, symbol) DO UPDATE + SET qty = EXCLUDED.qty, + avg_price = EXCLUDED.avg_price, + last_price = EXCLUDED.last_price, + updated_at = EXCLUDED.updated_at + """, + rows, + ) + + orders = store.get("orders") + if isinstance(orders, list) and orders: + rows = [] + for order in orders: + if not isinstance(order, dict): + continue + order_id = order.get("id") + if not order_id: + continue + ts = _parse_ts(order.get("timestamp"), assume_local=False) + logical_ts = _parse_ts(order.get("_logical_time"), assume_local=False) or ts + rows.append( + ( + scope_user, + scope_run, + order_id, + order.get("symbol"), + order.get("side"), + float(order.get("qty", 0.0)), + float(order.get("price", 0.0)), + order.get("status"), + ts, + logical_ts, + ) + ) + if rows: + execute_values( + cur, + """ + INSERT INTO paper_order ( + user_id, run_id, id, symbol, side, qty, price, status, timestamp, logical_time + ) + VALUES %s + ON CONFLICT DO NOTHING + """, + rows, + ) + + trades = store.get("trades") + if isinstance(trades, list) and trades: + rows = [] + for trade in trades: + if not isinstance(trade, dict): + continue + trade_id = trade.get("id") + if not trade_id: + continue + ts = _parse_ts(trade.get("timestamp"), assume_local=False) + logical_ts = _parse_ts(trade.get("_logical_time"), assume_local=False) or ts + rows.append( + ( + scope_user, + scope_run, + trade_id, + trade.get("order_id"), + trade.get("symbol"), + trade.get("side"), + float(trade.get("qty", 0.0)), + float(trade.get("price", 0.0)), + ts, + logical_ts, + ) + ) + if rows: + execute_values( + cur, + """ + INSERT INTO paper_trade ( + user_id, run_id, id, order_id, symbol, side, qty, price, timestamp, logical_time + ) + VALUES %s + ON CONFLICT DO NOTHING + """, + rows, + ) + + equity_curve = store.get("equity_curve") + if isinstance(equity_curve, list) and equity_curve: + rows = [] + for point in equity_curve: + if not isinstance(point, dict): + continue + ts = _parse_ts(point.get("timestamp"), assume_local=True) + logical_ts = _parse_ts(point.get("_logical_time"), assume_local=True) or ts + if ts is None: + continue + rows.append( + ( + scope_user, + scope_run, + ts, + logical_ts, + float(point.get("equity", 0.0)), + float(point.get("pnl", 0.0)), + ) + ) + if rows: + execute_values( + cur, + """ + INSERT INTO paper_equity_curve (user_id, run_id, timestamp, logical_time, equity, pnl) + VALUES %s + ON CONFLICT DO NOTHING + """, + rows, + ) + + def get_funds(self, cur=None): + store = self._load_store(cur=cur) + cash = float(store.get("cash", 0)) + positions = store.get("positions", {}) + positions_value = 0.0 + for position in positions.values(): + qty = float(position.get("qty", 0)) + last_price = float(position.get("last_price", position.get("avg_price", 0))) + positions_value += qty * last_price + total_equity = cash + positions_value + return { + "cash_available": cash, + "invested_value": positions_value, + "cash": cash, + "used_margin": 0.0, + "available": cash, + "net": total_equity, + "total_equity": total_equity, + } + + def get_positions(self, cur=None): + store = self._load_store(cur=cur) + positions = store.get("positions", {}) + return [ + { + "symbol": symbol, + "qty": float(data.get("qty", 0)), + "avg_price": float(data.get("avg_price", 0)), + "last_price": float(data.get("last_price", data.get("avg_price", 0))), + } + for symbol, data in positions.items() + ] + + def get_orders(self, cur=None): + store = self._load_store(cur=cur) + orders = [] + for order in store.get("orders", []): + if isinstance(order, dict): + order = {k: v for k, v in order.items() if k != "_logical_time"} + orders.append(order) + return orders + + def get_trades(self, cur=None): + store = self._load_store(cur=cur) + trades = [] + for trade in store.get("trades", []): + if isinstance(trade, dict): + trade = {k: v for k, v in trade.items() if k != "_logical_time"} + trades.append(trade) + return trades + + def get_equity_curve(self, cur=None): + store = self._load_store(cur=cur) + points = [] + for point in store.get("equity_curve", []): + if isinstance(point, dict): + point = {k: v for k, v in point.items() if k != "_logical_time"} + points.append(point) + return points + + def _update_equity_in_tx( + self, + cur, + prices: dict[str, float], + now: datetime, + logical_time: datetime | None = None, + user_id: str | None = None, + run_id: str | None = None, + ): + store = self._load_store(cur=cur, for_update=True, user_id=user_id, run_id=run_id) + positions = store.get("positions", {}) + for symbol, price in prices.items(): + if symbol in positions: + positions[symbol]["last_price"] = float(price) + + cash = float(store.get("cash", 0)) + positions_value = 0.0 + for symbol, position in positions.items(): + qty = float(position.get("qty", 0)) + price = float(position.get("last_price", position.get("avg_price", 0))) + positions_value += qty * price + + equity = cash + positions_value + pnl = equity - float(self.initial_cash) + ts_for_equity = logical_time or now + store.setdefault("equity_curve", []).append( + { + "timestamp": _format_local_ts(ts_for_equity), + "_logical_time": _format_local_ts(ts_for_equity), + "equity": equity, + "pnl": pnl, + } + ) + store["positions"] = positions + self._save_store(store, cur=cur, user_id=user_id, run_id=run_id) + insert_engine_event( + cur, + "EQUITY_UPDATED", + data={ + "timestamp": _format_utc_ts(ts_for_equity), + "equity": equity, + "pnl": pnl, + }, + ) + return equity + + def update_equity( + self, + prices: dict[str, float], + now: datetime, + cur=None, + logical_time: datetime | None = None, + user_id: str | None = None, + run_id: str | None = None, + ): + if cur is not None: + return self._update_equity_in_tx( + cur, + prices, + now, + logical_time=logical_time, + user_id=user_id, + run_id=run_id, + ) + + def _op(cur, _conn): + return self._update_equity_in_tx( + cur, + prices, + now, + logical_time=logical_time, + user_id=user_id, + run_id=run_id, + ) + + return run_with_retry(_op) + + def _place_order_in_tx( + self, + cur, + symbol: str, + side: str, + quantity: float, + price: float | None, + logical_time: datetime | None = None, + user_id: str | None = None, + run_id: str | None = None, + ): + scope_user, scope_run = _resolve_scope(user_id, run_id) + store = self._load_store(cur=cur, for_update=True, user_id=scope_user, run_id=scope_run) + side = side.upper().strip() + qty = float(quantity) + if price is None: + price = fetch_live_price(symbol) + price = float(price) + + logical_ts = logical_time or datetime.utcnow().replace(tzinfo=timezone.utc) + timestamp = logical_ts + timestamp_str = _format_utc_ts(timestamp) + logical_ts_str = _format_utc_ts(logical_ts) + order_id = _deterministic_id( + "ord", + [ + scope_user, + scope_run, + _normalize_ts_for_id(logical_ts), + symbol, + side, + _stable_num(qty), + _stable_num(price), + ], + ) + + order = { + "id": order_id, + "symbol": symbol, + "side": side, + "qty": qty, + "price": price, + "status": "REJECTED", + "timestamp": timestamp_str, + "_logical_time": logical_ts_str, + } + + if qty <= 0 or price <= 0: + store.setdefault("orders", []).append(order) + self._save_store(store, cur=cur, user_id=user_id, run_id=run_id) + insert_engine_event(cur, "ORDER_PLACED", data=order) + return order + + positions = store.get("positions", {}) + cash = float(store.get("cash", 0)) + trade = None + + if side == "BUY": + cost = qty * price + if cash >= cost: + cash -= cost + existing = positions.get(symbol, {"qty": 0.0, "avg_price": 0.0, "last_price": price}) + new_qty = float(existing.get("qty", 0)) + qty + prev_cost = float(existing.get("qty", 0)) * float(existing.get("avg_price", 0)) + avg_price = (prev_cost + cost) / new_qty if new_qty else price + positions[symbol] = { + "qty": new_qty, + "avg_price": avg_price, + "last_price": price, + } + order["status"] = "FILLED" + trade = { + "id": _deterministic_id("trd", [order_id]), + "order_id": order_id, + "symbol": symbol, + "side": side, + "qty": qty, + "price": price, + "timestamp": timestamp_str, + "_logical_time": logical_ts_str, + } + store.setdefault("trades", []).append(trade) + elif side == "SELL": + existing = positions.get(symbol) + if existing and float(existing.get("qty", 0)) >= qty: + cash += qty * price + remaining = float(existing.get("qty", 0)) - qty + if remaining > 0: + existing["qty"] = remaining + existing["last_price"] = price + positions[symbol] = existing + else: + positions.pop(symbol, None) + order["status"] = "FILLED" + trade = { + "id": _deterministic_id("trd", [order_id]), + "order_id": order_id, + "symbol": symbol, + "side": side, + "qty": qty, + "price": price, + "timestamp": timestamp_str, + "_logical_time": logical_ts_str, + } + store.setdefault("trades", []).append(trade) + + store["cash"] = cash + store["positions"] = positions + store.setdefault("orders", []).append(order) + self._save_store(store, cur=cur, user_id=user_id, run_id=run_id) + insert_engine_event(cur, "ORDER_PLACED", data=order) + if trade is not None: + insert_engine_event(cur, "TRADE_EXECUTED", data=trade) + insert_engine_event(cur, "ORDER_FILLED", data={"order_id": order_id}) + return order + + def place_order( + self, + symbol: str, + side: str, + quantity: float, + price: float | None = None, + cur=None, + logical_time: datetime | None = None, + user_id: str | None = None, + run_id: str | None = None, + ): + if cur is not None: + return self._place_order_in_tx( + cur, + symbol, + side, + quantity, + price, + logical_time=logical_time, + user_id=user_id, + run_id=run_id, + ) + + def _op(cur, _conn): + return self._place_order_in_tx( + cur, + symbol, + side, + quantity, + price, + logical_time=logical_time, + user_id=user_id, + run_id=run_id, + ) + + return run_with_retry(_op) + diff --git a/indian_paper_trading_strategy/engine/config.py b/indian_paper_trading_strategy/engine/config.py index 9321b89..b2b9424 100644 --- a/indian_paper_trading_strategy/engine/config.py +++ b/indian_paper_trading_strategy/engine/config.py @@ -1,150 +1,150 @@ -import json -from datetime import datetime - -from indian_paper_trading_strategy.engine.db import db_connection, get_context - -DEFAULT_CONFIG = { - "active": False, - "sip_amount": 0, - "sip_frequency": {"value": 30, "unit": "days"}, - "next_run": None -} - -def _maybe_parse_json(value): - if value is None: - return None - if not isinstance(value, str): - return value - text = value.strip() - if not text: - return None - try: - return json.loads(text) - except Exception: - return value - - -def _format_ts(value: datetime | None): - if value is None: - return None - return value.isoformat() - - -def load_strategy_config(user_id: str | None = None, run_id: str | None = None): - scope_user, scope_run = get_context(user_id, run_id) - with db_connection() as conn: - with conn.cursor() as cur: - cur.execute( - """ - SELECT strategy, sip_amount, sip_frequency_value, sip_frequency_unit, - mode, broker, active, frequency, frequency_days, unit, next_run - FROM strategy_config - WHERE user_id = %s AND run_id = %s - LIMIT 1 - """, - (scope_user, scope_run), - ) - row = cur.fetchone() - if not row: - return DEFAULT_CONFIG.copy() - - cfg = DEFAULT_CONFIG.copy() - cfg["strategy"] = row[0] - cfg["strategy_name"] = row[0] - cfg["sip_amount"] = float(row[1]) if row[1] is not None else cfg.get("sip_amount") - cfg["mode"] = row[4] - cfg["broker"] = row[5] - cfg["active"] = row[6] if row[6] is not None else cfg.get("active") - cfg["frequency"] = _maybe_parse_json(row[7]) - cfg["frequency_days"] = row[8] - cfg["unit"] = row[9] - cfg["next_run"] = _format_ts(row[10]) - if row[2] is not None or row[3] is not None: - cfg["sip_frequency"] = {"value": row[2], "unit": row[3]} - else: - value = cfg.get("frequency") - unit = cfg.get("unit") - if isinstance(value, dict): - unit = value.get("unit", unit) - value = value.get("value") - if value is None and cfg.get("frequency_days") is not None: - value = cfg.get("frequency_days") - unit = unit or "days" - if value is not None and unit: - cfg["sip_frequency"] = {"value": value, "unit": unit} - return cfg - -def save_strategy_config(cfg, user_id: str | None = None, run_id: str | None = None): - scope_user, scope_run = get_context(user_id, run_id) - sip_frequency = cfg.get("sip_frequency") - sip_value = None - sip_unit = None - if isinstance(sip_frequency, dict): - sip_value = sip_frequency.get("value") - sip_unit = sip_frequency.get("unit") - - frequency = cfg.get("frequency") - if not isinstance(frequency, str) and frequency is not None: - frequency = json.dumps(frequency) - - next_run = cfg.get("next_run") - next_run_dt = None - if isinstance(next_run, str): - try: - next_run_dt = datetime.fromisoformat(next_run) - except ValueError: - next_run_dt = None - - strategy = cfg.get("strategy") or cfg.get("strategy_name") - - with db_connection() as conn: - with conn: - with conn.cursor() as cur: - cur.execute( - """ - INSERT INTO strategy_config ( - user_id, - run_id, - strategy, - sip_amount, - sip_frequency_value, - sip_frequency_unit, - mode, - broker, - active, - frequency, - frequency_days, - unit, - next_run - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - ON CONFLICT (user_id, run_id) DO UPDATE - SET strategy = EXCLUDED.strategy, - sip_amount = EXCLUDED.sip_amount, - sip_frequency_value = EXCLUDED.sip_frequency_value, - sip_frequency_unit = EXCLUDED.sip_frequency_unit, - mode = EXCLUDED.mode, - broker = EXCLUDED.broker, - active = EXCLUDED.active, - frequency = EXCLUDED.frequency, - frequency_days = EXCLUDED.frequency_days, - unit = EXCLUDED.unit, - next_run = EXCLUDED.next_run - """, - ( - scope_user, - scope_run, - strategy, - cfg.get("sip_amount"), - sip_value, - sip_unit, - cfg.get("mode"), - cfg.get("broker"), - cfg.get("active"), - frequency, - cfg.get("frequency_days"), - cfg.get("unit"), - next_run_dt, - ), - ) - +import json +from datetime import datetime + +from indian_paper_trading_strategy.engine.db import db_connection, get_context + +DEFAULT_CONFIG = { + "active": False, + "sip_amount": 0, + "sip_frequency": {"value": 30, "unit": "days"}, + "next_run": None +} + +def _maybe_parse_json(value): + if value is None: + return None + if not isinstance(value, str): + return value + text = value.strip() + if not text: + return None + try: + return json.loads(text) + except Exception: + return value + + +def _format_ts(value: datetime | None): + if value is None: + return None + return value.isoformat() + + +def load_strategy_config(user_id: str | None = None, run_id: str | None = None): + scope_user, scope_run = get_context(user_id, run_id) + with db_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT strategy, sip_amount, sip_frequency_value, sip_frequency_unit, + mode, broker, active, frequency, frequency_days, unit, next_run + FROM strategy_config + WHERE user_id = %s AND run_id = %s + LIMIT 1 + """, + (scope_user, scope_run), + ) + row = cur.fetchone() + if not row: + return DEFAULT_CONFIG.copy() + + cfg = DEFAULT_CONFIG.copy() + cfg["strategy"] = row[0] + cfg["strategy_name"] = row[0] + cfg["sip_amount"] = float(row[1]) if row[1] is not None else cfg.get("sip_amount") + cfg["mode"] = row[4] + cfg["broker"] = row[5] + cfg["active"] = row[6] if row[6] is not None else cfg.get("active") + cfg["frequency"] = _maybe_parse_json(row[7]) + cfg["frequency_days"] = row[8] + cfg["unit"] = row[9] + cfg["next_run"] = _format_ts(row[10]) + if row[2] is not None or row[3] is not None: + cfg["sip_frequency"] = {"value": row[2], "unit": row[3]} + else: + value = cfg.get("frequency") + unit = cfg.get("unit") + if isinstance(value, dict): + unit = value.get("unit", unit) + value = value.get("value") + if value is None and cfg.get("frequency_days") is not None: + value = cfg.get("frequency_days") + unit = unit or "days" + if value is not None and unit: + cfg["sip_frequency"] = {"value": value, "unit": unit} + return cfg + +def save_strategy_config(cfg, user_id: str | None = None, run_id: str | None = None): + scope_user, scope_run = get_context(user_id, run_id) + sip_frequency = cfg.get("sip_frequency") + sip_value = None + sip_unit = None + if isinstance(sip_frequency, dict): + sip_value = sip_frequency.get("value") + sip_unit = sip_frequency.get("unit") + + frequency = cfg.get("frequency") + if not isinstance(frequency, str) and frequency is not None: + frequency = json.dumps(frequency) + + next_run = cfg.get("next_run") + next_run_dt = None + if isinstance(next_run, str): + try: + next_run_dt = datetime.fromisoformat(next_run) + except ValueError: + next_run_dt = None + + strategy = cfg.get("strategy") or cfg.get("strategy_name") + + with db_connection() as conn: + with conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO strategy_config ( + user_id, + run_id, + strategy, + sip_amount, + sip_frequency_value, + sip_frequency_unit, + mode, + broker, + active, + frequency, + frequency_days, + unit, + next_run + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (user_id, run_id) DO UPDATE + SET strategy = EXCLUDED.strategy, + sip_amount = EXCLUDED.sip_amount, + sip_frequency_value = EXCLUDED.sip_frequency_value, + sip_frequency_unit = EXCLUDED.sip_frequency_unit, + mode = EXCLUDED.mode, + broker = EXCLUDED.broker, + active = EXCLUDED.active, + frequency = EXCLUDED.frequency, + frequency_days = EXCLUDED.frequency_days, + unit = EXCLUDED.unit, + next_run = EXCLUDED.next_run + """, + ( + scope_user, + scope_run, + strategy, + cfg.get("sip_amount"), + sip_value, + sip_unit, + cfg.get("mode"), + cfg.get("broker"), + cfg.get("active"), + frequency, + cfg.get("frequency_days"), + cfg.get("unit"), + next_run_dt, + ), + ) + diff --git a/indian_paper_trading_strategy/engine/data.py b/indian_paper_trading_strategy/engine/data.py index c60aba2..d26917c 100644 --- a/indian_paper_trading_strategy/engine/data.py +++ b/indian_paper_trading_strategy/engine/data.py @@ -1,81 +1,81 @@ -# engine/data.py -from datetime import datetime, timezone -from pathlib import Path -import os -import threading - -import pandas as pd -import yfinance as yf - -ENGINE_ROOT = Path(__file__).resolve().parents[1] -HISTORY_DIR = ENGINE_ROOT / "storage" / "history" -ALLOW_PRICE_CACHE = os.getenv("ALLOW_PRICE_CACHE", "0").strip().lower() in {"1", "true", "yes"} - -_LAST_PRICE: dict[str, dict[str, object]] = {} -_LAST_PRICE_LOCK = threading.Lock() - - -def _set_last_price(ticker: str, price: float, source: str): - now = datetime.now(timezone.utc) - with _LAST_PRICE_LOCK: - _LAST_PRICE[ticker] = {"price": float(price), "source": source, "ts": now} - - -def get_price_snapshot(ticker: str) -> dict[str, object] | None: - with _LAST_PRICE_LOCK: - data = _LAST_PRICE.get(ticker) - if not data: - return None - return dict(data) - - -def _get_last_live_price(ticker: str) -> float | None: - with _LAST_PRICE_LOCK: - data = _LAST_PRICE.get(ticker) - if not data: - return None - if data.get("source") == "live": - return float(data.get("price", 0)) - return None - - -def _cached_last_close(ticker: str) -> float | None: - file = HISTORY_DIR / f"{ticker}.csv" - if not file.exists(): - return None - df = pd.read_csv(file) - if df.empty or "Close" not in df.columns: - return None - return float(df["Close"].iloc[-1]) - - -def fetch_live_price(ticker, allow_cache: bool | None = None): - if allow_cache is None: - allow_cache = ALLOW_PRICE_CACHE - try: - df = yf.download( - ticker, - period="1d", - interval="1m", - auto_adjust=True, - progress=False, - timeout=5, - ) - if df is not None and not df.empty: - price = float(df["Close"].iloc[-1]) - _set_last_price(ticker, price, "live") - return price - except Exception: - pass - - if allow_cache: - last_live = _get_last_live_price(ticker) - if last_live is not None: - return last_live - - cached = _cached_last_close(ticker) - if cached is not None: - _set_last_price(ticker, cached, "cache") - return cached - - raise RuntimeError(f"No live data for {ticker}") +# engine/data.py +from datetime import datetime, timezone +from pathlib import Path +import os +import threading + +import pandas as pd +import yfinance as yf + +ENGINE_ROOT = Path(__file__).resolve().parents[1] +HISTORY_DIR = ENGINE_ROOT / "storage" / "history" +ALLOW_PRICE_CACHE = os.getenv("ALLOW_PRICE_CACHE", "0").strip().lower() in {"1", "true", "yes"} + +_LAST_PRICE: dict[str, dict[str, object]] = {} +_LAST_PRICE_LOCK = threading.Lock() + + +def _set_last_price(ticker: str, price: float, source: str): + now = datetime.now(timezone.utc) + with _LAST_PRICE_LOCK: + _LAST_PRICE[ticker] = {"price": float(price), "source": source, "ts": now} + + +def get_price_snapshot(ticker: str) -> dict[str, object] | None: + with _LAST_PRICE_LOCK: + data = _LAST_PRICE.get(ticker) + if not data: + return None + return dict(data) + + +def _get_last_live_price(ticker: str) -> float | None: + with _LAST_PRICE_LOCK: + data = _LAST_PRICE.get(ticker) + if not data: + return None + if data.get("source") == "live": + return float(data.get("price", 0)) + return None + + +def _cached_last_close(ticker: str) -> float | None: + file = HISTORY_DIR / f"{ticker}.csv" + if not file.exists(): + return None + df = pd.read_csv(file) + if df.empty or "Close" not in df.columns: + return None + return float(df["Close"].iloc[-1]) + + +def fetch_live_price(ticker, allow_cache: bool | None = None): + if allow_cache is None: + allow_cache = ALLOW_PRICE_CACHE + try: + df = yf.download( + ticker, + period="1d", + interval="1m", + auto_adjust=True, + progress=False, + timeout=5, + ) + if df is not None and not df.empty: + price = float(df["Close"].iloc[-1]) + _set_last_price(ticker, price, "live") + return price + except Exception: + pass + + if allow_cache: + last_live = _get_last_live_price(ticker) + if last_live is not None: + return last_live + + cached = _cached_last_close(ticker) + if cached is not None: + _set_last_price(ticker, cached, "cache") + return cached + + raise RuntimeError(f"No live data for {ticker}") diff --git a/indian_paper_trading_strategy/engine/db.py b/indian_paper_trading_strategy/engine/db.py new file mode 100644 index 0000000..fc8b1b7 --- /dev/null +++ b/indian_paper_trading_strategy/engine/db.py @@ -0,0 +1,324 @@ +import os +import threading +import time +from contextlib import contextmanager +from datetime import datetime, timezone +from contextvars import ContextVar + +import psycopg2 +from psycopg2 import pool +from psycopg2 import OperationalError, InterfaceError +from psycopg2.extras import Json + +_POOL = None +_POOL_LOCK = threading.Lock() +_DEFAULT_USER_ID = None +_DEFAULT_LOCK = threading.Lock() + +_USER_ID = ContextVar("engine_user_id", default=None) +_RUN_ID = ContextVar("engine_run_id", default=None) + + +def _db_config(): + url = os.getenv("DATABASE_URL") + if url: + return {"dsn": url} + + schema = os.getenv("DB_SCHEMA") or os.getenv("PGSCHEMA") or "quant_app" + + return { + "host": os.getenv("DB_HOST") or os.getenv("PGHOST") or "localhost", + "port": int(os.getenv("DB_PORT") or os.getenv("PGPORT") or "5432"), + "dbname": os.getenv("DB_NAME") or os.getenv("PGDATABASE") or "trading_db", + "user": os.getenv("DB_USER") or os.getenv("PGUSER") or "trader", + "password": os.getenv("DB_PASSWORD") or os.getenv("PGPASSWORD") or "traderpass", + "connect_timeout": int(os.getenv("DB_CONNECT_TIMEOUT", "5")), + "options": f"-csearch_path={schema},public" if schema else None, + } + + +def _init_pool(): + config = _db_config() + config = {k: v for k, v in config.items() if v is not None} + minconn = int(os.getenv("DB_POOL_MIN", "1")) + maxconn = int(os.getenv("DB_POOL_MAX", "10")) + if "dsn" in config: + return pool.ThreadedConnectionPool(minconn, maxconn, dsn=config["dsn"]) + return pool.ThreadedConnectionPool(minconn, maxconn, **config) + + +def get_pool(): + global _POOL + if _POOL is None: + with _POOL_LOCK: + if _POOL is None: + _POOL = _init_pool() + return _POOL + + +def _get_connection(): + return get_pool().getconn() + + +def _put_connection(conn, close=False): + try: + get_pool().putconn(conn, close=close) + except Exception: + try: + conn.close() + except Exception: + pass + + +@contextmanager +def db_connection(retries: int | None = None, delay: float | None = None): + attempts = retries if retries is not None else int(os.getenv("DB_RETRY_COUNT", "3")) + backoff = delay if delay is not None else float(os.getenv("DB_RETRY_DELAY", "0.2")) + last_error = None + for attempt in range(attempts): + conn = None + try: + conn = _get_connection() + conn.autocommit = False + yield conn + return + except (OperationalError, InterfaceError) as exc: + last_error = exc + if conn is not None: + _put_connection(conn, close=True) + conn = None + time.sleep(backoff * (2 ** attempt)) + continue + finally: + if conn is not None: + _put_connection(conn, close=conn.closed != 0) + if last_error: + raise last_error + + +def run_with_retry(operation, retries: int | None = None, delay: float | None = None): + attempts = retries if retries is not None else int(os.getenv("DB_RETRY_COUNT", "3")) + backoff = delay if delay is not None else float(os.getenv("DB_RETRY_DELAY", "0.2")) + last_error = None + for attempt in range(attempts): + with db_connection(retries=1) as conn: + try: + with conn.cursor() as cur: + result = operation(cur, conn) + conn.commit() + return result + except (OperationalError, InterfaceError) as exc: + conn.rollback() + last_error = exc + time.sleep(backoff * (2 ** attempt)) + continue + except Exception: + conn.rollback() + raise + if last_error: + raise last_error + + +@contextmanager +def db_transaction(): + with db_connection() as conn: + try: + with conn.cursor() as cur: + yield cur + conn.commit() + except Exception: + conn.rollback() + raise + + +def _utc_now(): + return datetime.utcnow().replace(tzinfo=timezone.utc) + + +def set_context(user_id: str | None, run_id: str | None): + token_user = _USER_ID.set(user_id) + token_run = _RUN_ID.set(run_id) + return token_user, token_run + + +def reset_context(token_user, token_run): + _USER_ID.reset(token_user) + _RUN_ID.reset(token_run) + + +@contextmanager +def engine_context(user_id: str, run_id: str): + token_user, token_run = set_context(user_id, run_id) + try: + yield + finally: + reset_context(token_user, token_run) + + +def _resolve_context(user_id: str | None = None, run_id: str | None = None): + ctx_user = user_id or _USER_ID.get() + ctx_run = run_id or _RUN_ID.get() + if ctx_user and ctx_run: + return ctx_user, ctx_run + env_user = os.getenv("ENGINE_USER_ID") + env_run = os.getenv("ENGINE_RUN_ID") + if not ctx_user and env_user: + ctx_user = env_user + if not ctx_run and env_run: + ctx_run = env_run + if ctx_user and ctx_run: + return ctx_user, ctx_run + if not ctx_user: + ctx_user = get_default_user_id() + if ctx_user and not ctx_run: + ctx_run = get_active_run_id(ctx_user) + if not ctx_user or not ctx_run: + raise ValueError("engine context missing user_id/run_id") + return ctx_user, ctx_run + + +def get_context(user_id: str | None = None, run_id: str | None = None): + return _resolve_context(user_id, run_id) + + +def get_default_user_id(): + global _DEFAULT_USER_ID + if _DEFAULT_USER_ID: + return _DEFAULT_USER_ID + + def _op(cur, _conn): + cur.execute("SELECT id FROM app_user ORDER BY username LIMIT 1") + row = cur.fetchone() + return row[0] if row else None + + user_id = run_with_retry(_op) + if user_id: + with _DEFAULT_LOCK: + _DEFAULT_USER_ID = user_id + return user_id + + +def _default_run_id(user_id: str) -> str: + return f"default_{user_id}" + + +def ensure_default_run(user_id: str): + run_id = _default_run_id(user_id) + + def _op(cur, _conn): + now = _utc_now() + cur.execute( + """ + INSERT INTO strategy_run ( + run_id, user_id, created_at, started_at, stopped_at, status, strategy, mode, broker, meta + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (run_id) DO NOTHING + """, + ( + run_id, + user_id, + now, + None, + None, + "STOPPED", + None, + None, + None, + Json({}), + ), + ) + return run_id + + return run_with_retry(_op) + + +def get_active_run_id(user_id: str): + def _op(cur, _conn): + cur.execute( + """ + SELECT run_id + FROM strategy_run + WHERE user_id = %s AND status = 'RUNNING' + ORDER BY created_at DESC + LIMIT 1 + """, + (user_id,), + ) + row = cur.fetchone() + if row: + return row[0] + cur.execute( + """ + SELECT run_id + FROM strategy_run + WHERE user_id = %s + ORDER BY created_at DESC + LIMIT 1 + """, + (user_id,), + ) + row = cur.fetchone() + if row: + return row[0] + return None + + run_id = run_with_retry(_op) + if run_id: + return run_id + return ensure_default_run(user_id) + + +def get_running_runs(user_id: str | None = None): + def _op(cur, _conn): + if user_id: + cur.execute( + """ + SELECT user_id, run_id + FROM strategy_run + WHERE user_id = %s AND status = 'RUNNING' + ORDER BY created_at DESC + """, + (user_id,), + ) + else: + cur.execute( + """ + SELECT user_id, run_id + FROM strategy_run + WHERE status = 'RUNNING' + ORDER BY created_at DESC + """ + ) + return cur.fetchall() + + return run_with_retry(_op) + + +def insert_engine_event( + cur, + event: str, + data=None, + message: str | None = None, + meta=None, + ts=None, + user_id: str | None = None, + run_id: str | None = None, +): + when = ts or _utc_now() + scope_user, scope_run = _resolve_context(user_id, run_id) + cur.execute( + """ + INSERT INTO engine_event (user_id, run_id, ts, event, data, message, meta) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + ( + scope_user, + scope_run, + when, + event, + Json(data) if data is not None else None, + message, + Json(meta) if meta is not None else None, + ), + ) diff --git a/indian_paper_trading_strategy/engine/engine_runner.py b/indian_paper_trading_strategy/engine/engine_runner.py index 0be5f65..c711ec8 100644 --- a/indian_paper_trading_strategy/engine/engine_runner.py +++ b/indian_paper_trading_strategy/engine/engine_runner.py @@ -1,198 +1,198 @@ -import time -from datetime import datetime, timezone - -from indian_paper_trading_strategy.engine.db import ( - run_with_retry, - insert_engine_event, - get_default_user_id, - get_active_run_id, - get_running_runs, - engine_context, -) - -def log_event(event: str, data: dict | None = None): - now = datetime.utcnow().replace(tzinfo=timezone.utc) - payload = data or {} - - def _op(cur, _conn): - insert_engine_event(cur, event, data=payload, ts=now) - - run_with_retry(_op) - -def _update_engine_status(user_id: str, run_id: str, status: str): - now = datetime.utcnow().replace(tzinfo=timezone.utc) - - def _op(cur, _conn): - cur.execute( - """ - INSERT INTO engine_status (user_id, run_id, status, last_updated) - VALUES (%s, %s, %s, %s) - ON CONFLICT (user_id, run_id) DO UPDATE - SET status = EXCLUDED.status, - last_updated = EXCLUDED.last_updated - """, - (user_id, run_id, status, now), - ) - - run_with_retry(_op) - -from indian_paper_trading_strategy.engine.config import load_strategy_config, save_strategy_config -from indian_paper_trading_strategy.engine.market import india_market_status -from indian_paper_trading_strategy.engine.execution import try_execute_sip -from indian_paper_trading_strategy.engine.state import load_state -from indian_paper_trading_strategy.engine.broker import PaperBroker -from indian_paper_trading_strategy.engine.data import fetch_live_price -from indian_paper_trading_strategy.engine.mtm import log_mtm, should_log_mtm -from indian_paper_trading_strategy.engine.history import load_monthly_close -from indian_paper_trading_strategy.engine.strategy import allocation -from indian_paper_trading_strategy.engine.time_utils import frequency_to_timedelta, normalize_logical_time - -NIFTY = "NIFTYBEES.NS" -GOLD = "GOLDBEES.NS" -SMA_MONTHS = 36 - -def run_engine(user_id: str | None = None, run_id: str | None = None): - print("Strategy engine started") - active_runs: dict[tuple[str, str], bool] = {} - - if run_id and not user_id: - raise ValueError("user_id is required when run_id is provided") - - while True: - try: - if user_id and run_id: - runs = [(user_id, run_id)] - elif user_id: - runs = get_running_runs(user_id) - else: - runs = get_running_runs() - if not runs: - default_user = get_default_user_id() - if default_user: - runs = get_running_runs(default_user) - - seen = set() - for scope_user, scope_run in runs: - if not scope_user or not scope_run: - continue - seen.add((scope_user, scope_run)) - with engine_context(scope_user, scope_run): - cfg = load_strategy_config(user_id=scope_user, run_id=scope_run) - if not cfg.get("active"): - continue - - strategy_name = cfg.get("strategy_name", "golden_nifty") - sip_amount = cfg.get("sip_amount", 0) - configured_frequency = cfg.get("sip_frequency") or {} - if not isinstance(configured_frequency, dict): - configured_frequency = {} - frequency_value = int(configured_frequency.get("value", cfg.get("frequency", 0))) - frequency_unit = configured_frequency.get("unit", cfg.get("unit", "days")) - frequency_info = {"value": frequency_value, "unit": frequency_unit} - frequency_label = f"{frequency_value} {frequency_unit}" - - if not active_runs.get((scope_user, scope_run)): - log_event( - "ENGINE_START", - { - "strategy": strategy_name, - "sip_amount": sip_amount, - "frequency": frequency_label, - }, - ) - active_runs[(scope_user, scope_run)] = True - - _update_engine_status(scope_user, scope_run, "RUNNING") - - market_open, _ = india_market_status() - if not market_open: - log_event("MARKET_CLOSED", {"reason": "Outside market hours"}) - continue - - now = datetime.now() - mode = (cfg.get("mode") or "PAPER").strip().upper() - if mode not in {"PAPER", "LIVE"}: - mode = "PAPER" - state = load_state(mode=mode) - initial_cash = float(state.get("initial_cash") or 0.0) - broker = PaperBroker(initial_cash=initial_cash) if mode == "PAPER" else None - - nifty_price = fetch_live_price(NIFTY) - gold_price = fetch_live_price(GOLD) - - next_run = cfg.get("next_run") - if next_run is None or now >= datetime.fromisoformat(next_run): - nifty_hist = load_monthly_close(NIFTY) - gold_hist = load_monthly_close(GOLD) - - nifty_sma = nifty_hist.rolling(SMA_MONTHS).mean().iloc[-1] - gold_sma = gold_hist.rolling(SMA_MONTHS).mean().iloc[-1] - - eq_w, gd_w = allocation( - sp_price=nifty_price, - gd_price=gold_price, - sp_sma=nifty_sma, - gd_sma=gold_sma, - ) - - weights = {"equity": eq_w, "gold": gd_w} - state, executed = try_execute_sip( - now=now, - market_open=True, - sip_interval=frequency_to_timedelta(frequency_info).total_seconds(), - sip_amount=sip_amount, - sp_price=nifty_price, - gd_price=gold_price, - eq_w=eq_w, - gd_w=gd_w, - broker=broker, - mode=mode, - ) - - if executed: - log_event( - "SIP_TRIGGERED", - { - "date": now.date().isoformat(), - "allocation": weights, - "cash_used": sip_amount, - }, - ) - portfolio_value = ( - state["nifty_units"] * nifty_price - + state["gold_units"] * gold_price - ) - log_event( - "PORTFOLIO_UPDATED", - { - "nifty_units": state["nifty_units"], - "gold_units": state["gold_units"], - "portfolio_value": portfolio_value, - }, - ) - cfg["next_run"] = (now + frequency_to_timedelta(frequency_info)).isoformat() - save_strategy_config(cfg, user_id=scope_user, run_id=scope_run) - - if should_log_mtm(None, now): - state = load_state(mode=mode) - log_mtm( - nifty_units=state["nifty_units"], - gold_units=state["gold_units"], - nifty_price=nifty_price, - gold_price=gold_price, - total_invested=state["total_invested"], - logical_time=normalize_logical_time(now), - ) - - for key in list(active_runs.keys()): - if key not in seen: - active_runs.pop(key, None) - - time.sleep(30) - except Exception as e: - log_event("ENGINE_ERROR", {"error": str(e)}) - raise - -if __name__ == "__main__": - run_engine() - +import time +from datetime import datetime, timezone + +from indian_paper_trading_strategy.engine.db import ( + run_with_retry, + insert_engine_event, + get_default_user_id, + get_active_run_id, + get_running_runs, + engine_context, +) + +def log_event(event: str, data: dict | None = None): + now = datetime.utcnow().replace(tzinfo=timezone.utc) + payload = data or {} + + def _op(cur, _conn): + insert_engine_event(cur, event, data=payload, ts=now) + + run_with_retry(_op) + +def _update_engine_status(user_id: str, run_id: str, status: str): + now = datetime.utcnow().replace(tzinfo=timezone.utc) + + def _op(cur, _conn): + cur.execute( + """ + INSERT INTO engine_status (user_id, run_id, status, last_updated) + VALUES (%s, %s, %s, %s) + ON CONFLICT (user_id, run_id) DO UPDATE + SET status = EXCLUDED.status, + last_updated = EXCLUDED.last_updated + """, + (user_id, run_id, status, now), + ) + + run_with_retry(_op) + +from indian_paper_trading_strategy.engine.config import load_strategy_config, save_strategy_config +from indian_paper_trading_strategy.engine.market import india_market_status +from indian_paper_trading_strategy.engine.execution import try_execute_sip +from indian_paper_trading_strategy.engine.state import load_state +from indian_paper_trading_strategy.engine.broker import PaperBroker +from indian_paper_trading_strategy.engine.data import fetch_live_price +from indian_paper_trading_strategy.engine.mtm import log_mtm, should_log_mtm +from indian_paper_trading_strategy.engine.history import load_monthly_close +from indian_paper_trading_strategy.engine.strategy import allocation +from indian_paper_trading_strategy.engine.time_utils import frequency_to_timedelta, normalize_logical_time + +NIFTY = "NIFTYBEES.NS" +GOLD = "GOLDBEES.NS" +SMA_MONTHS = 36 + +def run_engine(user_id: str | None = None, run_id: str | None = None): + print("Strategy engine started") + active_runs: dict[tuple[str, str], bool] = {} + + if run_id and not user_id: + raise ValueError("user_id is required when run_id is provided") + + while True: + try: + if user_id and run_id: + runs = [(user_id, run_id)] + elif user_id: + runs = get_running_runs(user_id) + else: + runs = get_running_runs() + if not runs: + default_user = get_default_user_id() + if default_user: + runs = get_running_runs(default_user) + + seen = set() + for scope_user, scope_run in runs: + if not scope_user or not scope_run: + continue + seen.add((scope_user, scope_run)) + with engine_context(scope_user, scope_run): + cfg = load_strategy_config(user_id=scope_user, run_id=scope_run) + if not cfg.get("active"): + continue + + strategy_name = cfg.get("strategy_name", "golden_nifty") + sip_amount = cfg.get("sip_amount", 0) + configured_frequency = cfg.get("sip_frequency") or {} + if not isinstance(configured_frequency, dict): + configured_frequency = {} + frequency_value = int(configured_frequency.get("value", cfg.get("frequency", 0))) + frequency_unit = configured_frequency.get("unit", cfg.get("unit", "days")) + frequency_info = {"value": frequency_value, "unit": frequency_unit} + frequency_label = f"{frequency_value} {frequency_unit}" + + if not active_runs.get((scope_user, scope_run)): + log_event( + "ENGINE_START", + { + "strategy": strategy_name, + "sip_amount": sip_amount, + "frequency": frequency_label, + }, + ) + active_runs[(scope_user, scope_run)] = True + + _update_engine_status(scope_user, scope_run, "RUNNING") + + market_open, _ = india_market_status() + if not market_open: + log_event("MARKET_CLOSED", {"reason": "Outside market hours"}) + continue + + now = datetime.now() + mode = (cfg.get("mode") or "PAPER").strip().upper() + if mode not in {"PAPER", "LIVE"}: + mode = "PAPER" + state = load_state(mode=mode) + initial_cash = float(state.get("initial_cash") or 0.0) + broker = PaperBroker(initial_cash=initial_cash) if mode == "PAPER" else None + + nifty_price = fetch_live_price(NIFTY) + gold_price = fetch_live_price(GOLD) + + next_run = cfg.get("next_run") + if next_run is None or now >= datetime.fromisoformat(next_run): + nifty_hist = load_monthly_close(NIFTY) + gold_hist = load_monthly_close(GOLD) + + nifty_sma = nifty_hist.rolling(SMA_MONTHS).mean().iloc[-1] + gold_sma = gold_hist.rolling(SMA_MONTHS).mean().iloc[-1] + + eq_w, gd_w = allocation( + sp_price=nifty_price, + gd_price=gold_price, + sp_sma=nifty_sma, + gd_sma=gold_sma, + ) + + weights = {"equity": eq_w, "gold": gd_w} + state, executed = try_execute_sip( + now=now, + market_open=True, + sip_interval=frequency_to_timedelta(frequency_info).total_seconds(), + sip_amount=sip_amount, + sp_price=nifty_price, + gd_price=gold_price, + eq_w=eq_w, + gd_w=gd_w, + broker=broker, + mode=mode, + ) + + if executed: + log_event( + "SIP_TRIGGERED", + { + "date": now.date().isoformat(), + "allocation": weights, + "cash_used": sip_amount, + }, + ) + portfolio_value = ( + state["nifty_units"] * nifty_price + + state["gold_units"] * gold_price + ) + log_event( + "PORTFOLIO_UPDATED", + { + "nifty_units": state["nifty_units"], + "gold_units": state["gold_units"], + "portfolio_value": portfolio_value, + }, + ) + cfg["next_run"] = (now + frequency_to_timedelta(frequency_info)).isoformat() + save_strategy_config(cfg, user_id=scope_user, run_id=scope_run) + + if should_log_mtm(None, now): + state = load_state(mode=mode) + log_mtm( + nifty_units=state["nifty_units"], + gold_units=state["gold_units"], + nifty_price=nifty_price, + gold_price=gold_price, + total_invested=state["total_invested"], + logical_time=normalize_logical_time(now), + ) + + for key in list(active_runs.keys()): + if key not in seen: + active_runs.pop(key, None) + + time.sleep(30) + except Exception as e: + log_event("ENGINE_ERROR", {"error": str(e)}) + raise + +if __name__ == "__main__": + run_engine() + diff --git a/indian_paper_trading_strategy/engine/execution.py b/indian_paper_trading_strategy/engine/execution.py index e135a24..9463e09 100644 --- a/indian_paper_trading_strategy/engine/execution.py +++ b/indian_paper_trading_strategy/engine/execution.py @@ -1,157 +1,157 @@ -# engine/execution.py -from datetime import datetime, timezone -from indian_paper_trading_strategy.engine.state import load_state, save_state - -from indian_paper_trading_strategy.engine.broker import Broker -from indian_paper_trading_strategy.engine.ledger import log_event, event_exists -from indian_paper_trading_strategy.engine.db import run_with_retry -from indian_paper_trading_strategy.engine.time_utils import compute_logical_time - -def _as_float(value): - if hasattr(value, "item"): - try: - return float(value.item()) - except Exception: - pass - if hasattr(value, "iloc"): - try: - return float(value.iloc[-1]) - except Exception: - pass - return float(value) - -def _local_tz(): - return datetime.now().astimezone().tzinfo - -def try_execute_sip( - now, - market_open, - sip_interval, - sip_amount, - sp_price, - gd_price, - eq_w, - gd_w, - broker: Broker | None = None, - mode: str | None = "LIVE", -): - def _op(cur, _conn): - if now.tzinfo is None: - now_ts = now.replace(tzinfo=_local_tz()) - else: - now_ts = now - event_ts = now_ts - log_event("DEBUG_ENTER_TRY_EXECUTE", { - "now": now_ts.isoformat(), - }, cur=cur, ts=event_ts) - - state = load_state(mode=mode, cur=cur, for_update=True) - - force_execute = state.get("last_sip_ts") is None - - if not market_open: - return state, False - - last = state.get("last_sip_ts") or state.get("last_run") - if last and not force_execute: - try: - last_dt = datetime.fromisoformat(last) - except ValueError: - last_dt = None - if last_dt: - if last_dt.tzinfo is None: - last_dt = last_dt.replace(tzinfo=_local_tz()) - if now_ts.tzinfo and last_dt.tzinfo and last_dt.tzinfo != now_ts.tzinfo: - last_dt = last_dt.astimezone(now_ts.tzinfo) - if last_dt and (now_ts - last_dt).total_seconds() < sip_interval: - return state, False - - logical_time = compute_logical_time(now_ts, last, sip_interval) - if event_exists("SIP_EXECUTED", logical_time, cur=cur): - return state, False - - sp_price_val = _as_float(sp_price) - gd_price_val = _as_float(gd_price) - eq_w_val = _as_float(eq_w) - gd_w_val = _as_float(gd_w) - sip_amount_val = _as_float(sip_amount) - - nifty_qty = (sip_amount_val * eq_w_val) / sp_price_val - gold_qty = (sip_amount_val * gd_w_val) / gd_price_val - - if broker is None: - return state, False - - funds = broker.get_funds(cur=cur) - cash = funds.get("cash") - if cash is not None and float(cash) < sip_amount_val: - return state, False - - log_event("DEBUG_EXECUTION_DECISION", { - "force_execute": force_execute, - "last_sip_ts": state.get("last_sip_ts"), - "now": now_ts.isoformat(), - }, cur=cur, ts=event_ts) - - nifty_order = broker.place_order( - "NIFTYBEES.NS", - "BUY", - nifty_qty, - sp_price_val, - cur=cur, - logical_time=logical_time, - ) - gold_order = broker.place_order( - "GOLDBEES.NS", - "BUY", - gold_qty, - gd_price_val, - cur=cur, - logical_time=logical_time, - ) - orders = [nifty_order, gold_order] - executed = all( - isinstance(order, dict) and order.get("status") == "FILLED" - for order in orders - ) - if not executed: - return state, False - assert len(orders) > 0, "executed=True but no broker orders placed" - - funds_after = broker.get_funds(cur=cur) - cash_after = funds_after.get("cash") - if cash_after is not None: - state["cash"] = float(cash_after) - - state["nifty_units"] += nifty_qty - state["gold_units"] += gold_qty - state["total_invested"] += sip_amount_val - state["last_sip_ts"] = now_ts.isoformat() - state["last_run"] = now_ts.isoformat() - - save_state( - state, - mode=mode, - cur=cur, - emit_event=True, - event_meta={"source": "sip"}, - ) - - log_event( - "SIP_EXECUTED", - { - "nifty_units": nifty_qty, - "gold_units": gold_qty, - "nifty_price": sp_price_val, - "gold_price": gd_price_val, - "amount": sip_amount_val, - }, - cur=cur, - ts=event_ts, - logical_time=logical_time, - ) - - return state, True - - return run_with_retry(_op) - +# engine/execution.py +from datetime import datetime, timezone +from indian_paper_trading_strategy.engine.state import load_state, save_state + +from indian_paper_trading_strategy.engine.broker import Broker +from indian_paper_trading_strategy.engine.ledger import log_event, event_exists +from indian_paper_trading_strategy.engine.db import run_with_retry +from indian_paper_trading_strategy.engine.time_utils import compute_logical_time + +def _as_float(value): + if hasattr(value, "item"): + try: + return float(value.item()) + except Exception: + pass + if hasattr(value, "iloc"): + try: + return float(value.iloc[-1]) + except Exception: + pass + return float(value) + +def _local_tz(): + return datetime.now().astimezone().tzinfo + +def try_execute_sip( + now, + market_open, + sip_interval, + sip_amount, + sp_price, + gd_price, + eq_w, + gd_w, + broker: Broker | None = None, + mode: str | None = "LIVE", +): + def _op(cur, _conn): + if now.tzinfo is None: + now_ts = now.replace(tzinfo=_local_tz()) + else: + now_ts = now + event_ts = now_ts + log_event("DEBUG_ENTER_TRY_EXECUTE", { + "now": now_ts.isoformat(), + }, cur=cur, ts=event_ts) + + state = load_state(mode=mode, cur=cur, for_update=True) + + force_execute = state.get("last_sip_ts") is None + + if not market_open: + return state, False + + last = state.get("last_sip_ts") or state.get("last_run") + if last and not force_execute: + try: + last_dt = datetime.fromisoformat(last) + except ValueError: + last_dt = None + if last_dt: + if last_dt.tzinfo is None: + last_dt = last_dt.replace(tzinfo=_local_tz()) + if now_ts.tzinfo and last_dt.tzinfo and last_dt.tzinfo != now_ts.tzinfo: + last_dt = last_dt.astimezone(now_ts.tzinfo) + if last_dt and (now_ts - last_dt).total_seconds() < sip_interval: + return state, False + + logical_time = compute_logical_time(now_ts, last, sip_interval) + if event_exists("SIP_EXECUTED", logical_time, cur=cur): + return state, False + + sp_price_val = _as_float(sp_price) + gd_price_val = _as_float(gd_price) + eq_w_val = _as_float(eq_w) + gd_w_val = _as_float(gd_w) + sip_amount_val = _as_float(sip_amount) + + nifty_qty = (sip_amount_val * eq_w_val) / sp_price_val + gold_qty = (sip_amount_val * gd_w_val) / gd_price_val + + if broker is None: + return state, False + + funds = broker.get_funds(cur=cur) + cash = funds.get("cash") + if cash is not None and float(cash) < sip_amount_val: + return state, False + + log_event("DEBUG_EXECUTION_DECISION", { + "force_execute": force_execute, + "last_sip_ts": state.get("last_sip_ts"), + "now": now_ts.isoformat(), + }, cur=cur, ts=event_ts) + + nifty_order = broker.place_order( + "NIFTYBEES.NS", + "BUY", + nifty_qty, + sp_price_val, + cur=cur, + logical_time=logical_time, + ) + gold_order = broker.place_order( + "GOLDBEES.NS", + "BUY", + gold_qty, + gd_price_val, + cur=cur, + logical_time=logical_time, + ) + orders = [nifty_order, gold_order] + executed = all( + isinstance(order, dict) and order.get("status") == "FILLED" + for order in orders + ) + if not executed: + return state, False + assert len(orders) > 0, "executed=True but no broker orders placed" + + funds_after = broker.get_funds(cur=cur) + cash_after = funds_after.get("cash") + if cash_after is not None: + state["cash"] = float(cash_after) + + state["nifty_units"] += nifty_qty + state["gold_units"] += gold_qty + state["total_invested"] += sip_amount_val + state["last_sip_ts"] = now_ts.isoformat() + state["last_run"] = now_ts.isoformat() + + save_state( + state, + mode=mode, + cur=cur, + emit_event=True, + event_meta={"source": "sip"}, + ) + + log_event( + "SIP_EXECUTED", + { + "nifty_units": nifty_qty, + "gold_units": gold_qty, + "nifty_price": sp_price_val, + "gold_price": gd_price_val, + "amount": sip_amount_val, + }, + cur=cur, + ts=event_ts, + logical_time=logical_time, + ) + + return state, True + + return run_with_retry(_op) + diff --git a/indian_paper_trading_strategy/engine/history.py b/indian_paper_trading_strategy/engine/history.py index 28e4697..cf6ecf8 100644 --- a/indian_paper_trading_strategy/engine/history.py +++ b/indian_paper_trading_strategy/engine/history.py @@ -1,34 +1,34 @@ -# engine/history.py -import yfinance as yf -import pandas as pd -from pathlib import Path - -ENGINE_ROOT = Path(__file__).resolve().parents[1] -STORAGE_DIR = ENGINE_ROOT / "storage" -STORAGE_DIR.mkdir(exist_ok=True) - -CACHE_DIR = STORAGE_DIR / "history" -CACHE_DIR.mkdir(exist_ok=True) - -def load_monthly_close(ticker, years=10): - file = CACHE_DIR / f"{ticker}.csv" - - if file.exists(): - df = pd.read_csv(file, parse_dates=["Date"], index_col="Date") - return df["Close"] - - df = yf.download( - ticker, - period=f"{years}y", - auto_adjust=True, - progress=False, - timeout=5, - ) - - if df.empty: - raise RuntimeError(f"No history for {ticker}") - - series = df["Close"].resample("M").last() - series.to_csv(file, header=["Close"]) - - return series +# engine/history.py +import yfinance as yf +import pandas as pd +from pathlib import Path + +ENGINE_ROOT = Path(__file__).resolve().parents[1] +STORAGE_DIR = ENGINE_ROOT / "storage" +STORAGE_DIR.mkdir(exist_ok=True) + +CACHE_DIR = STORAGE_DIR / "history" +CACHE_DIR.mkdir(exist_ok=True) + +def load_monthly_close(ticker, years=10): + file = CACHE_DIR / f"{ticker}.csv" + + if file.exists(): + df = pd.read_csv(file, parse_dates=["Date"], index_col="Date") + return df["Close"] + + df = yf.download( + ticker, + period=f"{years}y", + auto_adjust=True, + progress=False, + timeout=5, + ) + + if df.empty: + raise RuntimeError(f"No history for {ticker}") + + series = df["Close"].resample("M").last() + series.to_csv(file, header=["Close"]) + + return series diff --git a/indian_paper_trading_strategy/engine/ledger.py b/indian_paper_trading_strategy/engine/ledger.py index 874a5fa..ebd2caf 100644 --- a/indian_paper_trading_strategy/engine/ledger.py +++ b/indian_paper_trading_strategy/engine/ledger.py @@ -1,113 +1,113 @@ -# engine/ledger.py -from datetime import datetime, timezone - -from indian_paper_trading_strategy.engine.db import insert_engine_event, run_with_retry, get_context -from indian_paper_trading_strategy.engine.time_utils import normalize_logical_time - - -def _event_exists_in_tx(cur, event, logical_time, user_id: str | None = None, run_id: str | None = None): - scope_user, scope_run = get_context(user_id, run_id) - logical_ts = normalize_logical_time(logical_time) - cur.execute( - """ - SELECT 1 - FROM event_ledger - WHERE user_id = %s AND run_id = %s AND event = %s AND logical_time = %s - LIMIT 1 - """, - (scope_user, scope_run, event, logical_ts), - ) - return cur.fetchone() is not None - - -def event_exists(event, logical_time, *, cur=None, user_id: str | None = None, run_id: str | None = None): - if cur is not None: - return _event_exists_in_tx(cur, event, logical_time, user_id=user_id, run_id=run_id) - - def _op(cur, _conn): - return _event_exists_in_tx(cur, event, logical_time, user_id=user_id, run_id=run_id) - - return run_with_retry(_op) - - -def _log_event_in_tx( - cur, - event, - payload, - ts, - logical_time=None, - user_id: str | None = None, - run_id: str | None = None, -): - scope_user, scope_run = get_context(user_id, run_id) - logical_ts = normalize_logical_time(logical_time or ts) - cur.execute( - """ - INSERT INTO event_ledger ( - user_id, - run_id, - timestamp, - logical_time, - event, - nifty_units, - gold_units, - nifty_price, - gold_price, - amount - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - ON CONFLICT DO NOTHING - """, - ( - scope_user, - scope_run, - ts, - logical_ts, - event, - payload.get("nifty_units"), - payload.get("gold_units"), - payload.get("nifty_price"), - payload.get("gold_price"), - payload.get("amount"), - ), - ) - if cur.rowcount: - insert_engine_event(cur, event, data=payload, ts=ts) - - -def log_event( - event, - payload, - *, - cur=None, - ts=None, - logical_time=None, - user_id: str | None = None, - run_id: str | None = None, -): - now = ts or logical_time or datetime.utcnow().replace(tzinfo=timezone.utc) - if cur is not None: - _log_event_in_tx( - cur, - event, - payload, - now, - logical_time=logical_time, - user_id=user_id, - run_id=run_id, - ) - return - - def _op(cur, _conn): - _log_event_in_tx( - cur, - event, - payload, - now, - logical_time=logical_time, - user_id=user_id, - run_id=run_id, - ) - - return run_with_retry(_op) - +# engine/ledger.py +from datetime import datetime, timezone + +from indian_paper_trading_strategy.engine.db import insert_engine_event, run_with_retry, get_context +from indian_paper_trading_strategy.engine.time_utils import normalize_logical_time + + +def _event_exists_in_tx(cur, event, logical_time, user_id: str | None = None, run_id: str | None = None): + scope_user, scope_run = get_context(user_id, run_id) + logical_ts = normalize_logical_time(logical_time) + cur.execute( + """ + SELECT 1 + FROM event_ledger + WHERE user_id = %s AND run_id = %s AND event = %s AND logical_time = %s + LIMIT 1 + """, + (scope_user, scope_run, event, logical_ts), + ) + return cur.fetchone() is not None + + +def event_exists(event, logical_time, *, cur=None, user_id: str | None = None, run_id: str | None = None): + if cur is not None: + return _event_exists_in_tx(cur, event, logical_time, user_id=user_id, run_id=run_id) + + def _op(cur, _conn): + return _event_exists_in_tx(cur, event, logical_time, user_id=user_id, run_id=run_id) + + return run_with_retry(_op) + + +def _log_event_in_tx( + cur, + event, + payload, + ts, + logical_time=None, + user_id: str | None = None, + run_id: str | None = None, +): + scope_user, scope_run = get_context(user_id, run_id) + logical_ts = normalize_logical_time(logical_time or ts) + cur.execute( + """ + INSERT INTO event_ledger ( + user_id, + run_id, + timestamp, + logical_time, + event, + nifty_units, + gold_units, + nifty_price, + gold_price, + amount + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT DO NOTHING + """, + ( + scope_user, + scope_run, + ts, + logical_ts, + event, + payload.get("nifty_units"), + payload.get("gold_units"), + payload.get("nifty_price"), + payload.get("gold_price"), + payload.get("amount"), + ), + ) + if cur.rowcount: + insert_engine_event(cur, event, data=payload, ts=ts) + + +def log_event( + event, + payload, + *, + cur=None, + ts=None, + logical_time=None, + user_id: str | None = None, + run_id: str | None = None, +): + now = ts or logical_time or datetime.utcnow().replace(tzinfo=timezone.utc) + if cur is not None: + _log_event_in_tx( + cur, + event, + payload, + now, + logical_time=logical_time, + user_id=user_id, + run_id=run_id, + ) + return + + def _op(cur, _conn): + _log_event_in_tx( + cur, + event, + payload, + now, + logical_time=logical_time, + user_id=user_id, + run_id=run_id, + ) + + return run_with_retry(_op) + diff --git a/indian_paper_trading_strategy/engine/market.py b/indian_paper_trading_strategy/engine/market.py index c16f5de..0a0d938 100644 --- a/indian_paper_trading_strategy/engine/market.py +++ b/indian_paper_trading_strategy/engine/market.py @@ -1,42 +1,42 @@ -# engine/market.py -from datetime import datetime, time as dtime, timedelta -import pytz - -_MARKET_TZ = pytz.timezone("Asia/Kolkata") -_OPEN_T = dtime(9, 15) -_CLOSE_T = dtime(15, 30) - -def _as_market_tz(value: datetime) -> datetime: - if value.tzinfo is None: - return _MARKET_TZ.localize(value) - return value.astimezone(_MARKET_TZ) - -def is_market_open(now: datetime) -> bool: - now = _as_market_tz(now) - return now.weekday() < 5 and _OPEN_T <= now.time() <= _CLOSE_T - -def india_market_status(): - now = datetime.now(_MARKET_TZ) - - return is_market_open(now), now - -def next_market_open_after(value: datetime) -> datetime: - current = _as_market_tz(value) - while current.weekday() >= 5: - current = current + timedelta(days=1) - current = current.replace(hour=_OPEN_T.hour, minute=_OPEN_T.minute, second=0, microsecond=0) - if current.time() < _OPEN_T: - return current.replace(hour=_OPEN_T.hour, minute=_OPEN_T.minute, second=0, microsecond=0) - if current.time() > _CLOSE_T: - current = current + timedelta(days=1) - while current.weekday() >= 5: - current = current + timedelta(days=1) - return current.replace(hour=_OPEN_T.hour, minute=_OPEN_T.minute, second=0, microsecond=0) - return current - -def align_to_market_open(value: datetime) -> datetime: - current = _as_market_tz(value) - aligned = current if is_market_open(current) else next_market_open_after(current) - if value.tzinfo is None: - return aligned.replace(tzinfo=None) - return aligned +# engine/market.py +from datetime import datetime, time as dtime, timedelta +import pytz + +_MARKET_TZ = pytz.timezone("Asia/Kolkata") +_OPEN_T = dtime(9, 15) +_CLOSE_T = dtime(15, 30) + +def _as_market_tz(value: datetime) -> datetime: + if value.tzinfo is None: + return _MARKET_TZ.localize(value) + return value.astimezone(_MARKET_TZ) + +def is_market_open(now: datetime) -> bool: + now = _as_market_tz(now) + return now.weekday() < 5 and _OPEN_T <= now.time() <= _CLOSE_T + +def india_market_status(): + now = datetime.now(_MARKET_TZ) + + return is_market_open(now), now + +def next_market_open_after(value: datetime) -> datetime: + current = _as_market_tz(value) + while current.weekday() >= 5: + current = current + timedelta(days=1) + current = current.replace(hour=_OPEN_T.hour, minute=_OPEN_T.minute, second=0, microsecond=0) + if current.time() < _OPEN_T: + return current.replace(hour=_OPEN_T.hour, minute=_OPEN_T.minute, second=0, microsecond=0) + if current.time() > _CLOSE_T: + current = current + timedelta(days=1) + while current.weekday() >= 5: + current = current + timedelta(days=1) + return current.replace(hour=_OPEN_T.hour, minute=_OPEN_T.minute, second=0, microsecond=0) + return current + +def align_to_market_open(value: datetime) -> datetime: + current = _as_market_tz(value) + aligned = current if is_market_open(current) else next_market_open_after(current) + if value.tzinfo is None: + return aligned.replace(tzinfo=None) + return aligned diff --git a/indian_paper_trading_strategy/engine/mtm.py b/indian_paper_trading_strategy/engine/mtm.py index 90d6b1a..520413b 100644 --- a/indian_paper_trading_strategy/engine/mtm.py +++ b/indian_paper_trading_strategy/engine/mtm.py @@ -1,154 +1,154 @@ -from datetime import datetime, timezone -from pathlib import Path - -from indian_paper_trading_strategy.engine.db import db_connection, insert_engine_event, run_with_retry, get_context -from indian_paper_trading_strategy.engine.time_utils import normalize_logical_time - -ENGINE_ROOT = Path(__file__).resolve().parents[1] -STORAGE_DIR = ENGINE_ROOT / "storage" -MTM_FILE = STORAGE_DIR / "mtm_ledger.csv" - -MTM_INTERVAL_SECONDS = 60 - -def _log_mtm_in_tx( - cur, - nifty_units, - gold_units, - nifty_price, - gold_price, - total_invested, - ts, - logical_time=None, - user_id: str | None = None, - run_id: str | None = None, -): - scope_user, scope_run = get_context(user_id, run_id) - logical_ts = normalize_logical_time(logical_time or ts) - nifty_value = nifty_units * nifty_price - gold_value = gold_units * gold_price - portfolio_value = nifty_value + gold_value - pnl = portfolio_value - total_invested - - row = { - "timestamp": ts.isoformat(), - "logical_time": logical_ts.isoformat(), - "nifty_units": nifty_units, - "gold_units": gold_units, - "nifty_price": nifty_price, - "gold_price": gold_price, - "nifty_value": nifty_value, - "gold_value": gold_value, - "portfolio_value": portfolio_value, - "total_invested": total_invested, - "pnl": pnl, - } - cur.execute( - """ - INSERT INTO mtm_ledger ( - user_id, - run_id, - timestamp, - logical_time, - nifty_units, - gold_units, - nifty_price, - gold_price, - nifty_value, - gold_value, - portfolio_value, - total_invested, - pnl - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - ON CONFLICT DO NOTHING - """, - ( - scope_user, - scope_run, - ts, - logical_ts, - row["nifty_units"], - row["gold_units"], - row["nifty_price"], - row["gold_price"], - row["nifty_value"], - row["gold_value"], - row["portfolio_value"], - row["total_invested"], - row["pnl"], - ), - ) - if cur.rowcount: - insert_engine_event(cur, "MTM_UPDATED", data=row, ts=ts) - return portfolio_value, pnl - -def log_mtm( - nifty_units, - gold_units, - nifty_price, - gold_price, - total_invested, - *, - cur=None, - logical_time=None, - user_id: str | None = None, - run_id: str | None = None, -): - ts = logical_time or datetime.now(timezone.utc) - if cur is not None: - return _log_mtm_in_tx( - cur, - nifty_units, - gold_units, - nifty_price, - gold_price, - total_invested, - ts, - logical_time=logical_time, - user_id=user_id, - run_id=run_id, - ) - - def _op(cur, _conn): - return _log_mtm_in_tx( - cur, - nifty_units, - gold_units, - nifty_price, - gold_price, - total_invested, - ts, - logical_time=logical_time, - user_id=user_id, - run_id=run_id, - ) - - return run_with_retry(_op) - -def _get_last_mtm_ts(user_id: str | None = None, run_id: str | None = None): - scope_user, scope_run = get_context(user_id, run_id) - with db_connection() as conn: - with conn.cursor() as cur: - cur.execute( - "SELECT MAX(timestamp) FROM mtm_ledger WHERE user_id = %s AND run_id = %s", - (scope_user, scope_run), - ) - row = cur.fetchone() - if not row or row[0] is None: - return None - return row[0].astimezone().replace(tzinfo=None) - -def should_log_mtm(df, now, user_id: str | None = None, run_id: str | None = None): - if df is None: - last_ts = _get_last_mtm_ts(user_id=user_id, run_id=run_id) - if last_ts is None: - return True - return (now - last_ts).total_seconds() >= MTM_INTERVAL_SECONDS - if getattr(df, "empty", False): - return True - try: - last_ts = datetime.fromisoformat(str(df.iloc[-1]["timestamp"])) - except Exception: - return True - return (now - last_ts).total_seconds() >= MTM_INTERVAL_SECONDS - +from datetime import datetime, timezone +from pathlib import Path + +from indian_paper_trading_strategy.engine.db import db_connection, insert_engine_event, run_with_retry, get_context +from indian_paper_trading_strategy.engine.time_utils import normalize_logical_time + +ENGINE_ROOT = Path(__file__).resolve().parents[1] +STORAGE_DIR = ENGINE_ROOT / "storage" +MTM_FILE = STORAGE_DIR / "mtm_ledger.csv" + +MTM_INTERVAL_SECONDS = 60 + +def _log_mtm_in_tx( + cur, + nifty_units, + gold_units, + nifty_price, + gold_price, + total_invested, + ts, + logical_time=None, + user_id: str | None = None, + run_id: str | None = None, +): + scope_user, scope_run = get_context(user_id, run_id) + logical_ts = normalize_logical_time(logical_time or ts) + nifty_value = nifty_units * nifty_price + gold_value = gold_units * gold_price + portfolio_value = nifty_value + gold_value + pnl = portfolio_value - total_invested + + row = { + "timestamp": ts.isoformat(), + "logical_time": logical_ts.isoformat(), + "nifty_units": nifty_units, + "gold_units": gold_units, + "nifty_price": nifty_price, + "gold_price": gold_price, + "nifty_value": nifty_value, + "gold_value": gold_value, + "portfolio_value": portfolio_value, + "total_invested": total_invested, + "pnl": pnl, + } + cur.execute( + """ + INSERT INTO mtm_ledger ( + user_id, + run_id, + timestamp, + logical_time, + nifty_units, + gold_units, + nifty_price, + gold_price, + nifty_value, + gold_value, + portfolio_value, + total_invested, + pnl + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT DO NOTHING + """, + ( + scope_user, + scope_run, + ts, + logical_ts, + row["nifty_units"], + row["gold_units"], + row["nifty_price"], + row["gold_price"], + row["nifty_value"], + row["gold_value"], + row["portfolio_value"], + row["total_invested"], + row["pnl"], + ), + ) + if cur.rowcount: + insert_engine_event(cur, "MTM_UPDATED", data=row, ts=ts) + return portfolio_value, pnl + +def log_mtm( + nifty_units, + gold_units, + nifty_price, + gold_price, + total_invested, + *, + cur=None, + logical_time=None, + user_id: str | None = None, + run_id: str | None = None, +): + ts = logical_time or datetime.now(timezone.utc) + if cur is not None: + return _log_mtm_in_tx( + cur, + nifty_units, + gold_units, + nifty_price, + gold_price, + total_invested, + ts, + logical_time=logical_time, + user_id=user_id, + run_id=run_id, + ) + + def _op(cur, _conn): + return _log_mtm_in_tx( + cur, + nifty_units, + gold_units, + nifty_price, + gold_price, + total_invested, + ts, + logical_time=logical_time, + user_id=user_id, + run_id=run_id, + ) + + return run_with_retry(_op) + +def _get_last_mtm_ts(user_id: str | None = None, run_id: str | None = None): + scope_user, scope_run = get_context(user_id, run_id) + with db_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT MAX(timestamp) FROM mtm_ledger WHERE user_id = %s AND run_id = %s", + (scope_user, scope_run), + ) + row = cur.fetchone() + if not row or row[0] is None: + return None + return row[0].astimezone().replace(tzinfo=None) + +def should_log_mtm(df, now, user_id: str | None = None, run_id: str | None = None): + if df is None: + last_ts = _get_last_mtm_ts(user_id=user_id, run_id=run_id) + if last_ts is None: + return True + return (now - last_ts).total_seconds() >= MTM_INTERVAL_SECONDS + if getattr(df, "empty", False): + return True + try: + last_ts = datetime.fromisoformat(str(df.iloc[-1]["timestamp"])) + except Exception: + return True + return (now - last_ts).total_seconds() >= MTM_INTERVAL_SECONDS + diff --git a/indian_paper_trading_strategy/engine/runner.py b/indian_paper_trading_strategy/engine/runner.py index e0e6a6c..bd89f0c 100644 --- a/indian_paper_trading_strategy/engine/runner.py +++ b/indian_paper_trading_strategy/engine/runner.py @@ -1,58 +1,58 @@ -import os -import threading -import time -from datetime import datetime, timedelta, timezone - -from indian_paper_trading_strategy.engine.market import is_market_open, align_to_market_open -from indian_paper_trading_strategy.engine.execution import try_execute_sip -from indian_paper_trading_strategy.engine.broker import PaperBroker -from indian_paper_trading_strategy.engine.mtm import log_mtm, should_log_mtm -from indian_paper_trading_strategy.engine.state import load_state -from indian_paper_trading_strategy.engine.data import fetch_live_price -from indian_paper_trading_strategy.engine.history import load_monthly_close -from indian_paper_trading_strategy.engine.strategy import allocation -from indian_paper_trading_strategy.engine.time_utils import normalize_logical_time - -from indian_paper_trading_strategy.engine.db import db_transaction, insert_engine_event, run_with_retry, get_context, set_context - - -def _update_engine_status(user_id: str, run_id: str, status: str): - now = datetime.utcnow().replace(tzinfo=timezone.utc) - - def _op(cur, _conn): - cur.execute( - """ - INSERT INTO engine_status (user_id, run_id, status, last_updated) - VALUES (%s, %s, %s, %s) - ON CONFLICT (user_id, run_id) DO UPDATE - SET status = EXCLUDED.status, - last_updated = EXCLUDED.last_updated - """, - (user_id, run_id, status, now), - ) - - run_with_retry(_op) - -NIFTY = "NIFTYBEES.NS" -GOLD = "GOLDBEES.NS" -SMA_MONTHS = 36 - -_DEFAULT_ENGINE_STATE = { - "state": "STOPPED", - "run_id": None, - "user_id": None, - "last_heartbeat_ts": None, -} - +import os +import threading +import time +from datetime import datetime, timedelta, timezone + +from indian_paper_trading_strategy.engine.market import is_market_open, align_to_market_open +from indian_paper_trading_strategy.engine.execution import try_execute_sip +from indian_paper_trading_strategy.engine.broker import PaperBroker +from indian_paper_trading_strategy.engine.mtm import log_mtm, should_log_mtm +from indian_paper_trading_strategy.engine.state import load_state +from indian_paper_trading_strategy.engine.data import fetch_live_price +from indian_paper_trading_strategy.engine.history import load_monthly_close +from indian_paper_trading_strategy.engine.strategy import allocation +from indian_paper_trading_strategy.engine.time_utils import normalize_logical_time + +from indian_paper_trading_strategy.engine.db import db_transaction, insert_engine_event, run_with_retry, get_context, set_context + + +def _update_engine_status(user_id: str, run_id: str, status: str): + now = datetime.utcnow().replace(tzinfo=timezone.utc) + + def _op(cur, _conn): + cur.execute( + """ + INSERT INTO engine_status (user_id, run_id, status, last_updated) + VALUES (%s, %s, %s, %s) + ON CONFLICT (user_id, run_id) DO UPDATE + SET status = EXCLUDED.status, + last_updated = EXCLUDED.last_updated + """, + (user_id, run_id, status, now), + ) + + run_with_retry(_op) + +NIFTY = "NIFTYBEES.NS" +GOLD = "GOLDBEES.NS" +SMA_MONTHS = 36 + +_DEFAULT_ENGINE_STATE = { + "state": "STOPPED", + "run_id": None, + "user_id": None, + "last_heartbeat_ts": None, +} + _ENGINE_STATES = {} _ENGINE_STATES_LOCK = threading.Lock() _RUNNERS = {} _RUNNERS_LOCK = threading.Lock() - -engine_state = _ENGINE_STATES - - + +engine_state = _ENGINE_STATES + + def _state_key(user_id: str, run_id: str): return (user_id, run_id) @@ -84,38 +84,38 @@ def _set_state(user_id: str, run_id: str, **updates): def get_engine_state(user_id: str, run_id: str): state = _get_state(user_id, run_id) return dict(state) - -def log_event( - event: str, - data: dict | None = None, - message: str | None = None, - meta: dict | None = None, -): - entry = { - "ts": datetime.utcnow().replace(tzinfo=timezone.utc).isoformat(), - "event": event, - } - if message is not None or meta is not None: - entry["message"] = message or "" - entry["meta"] = meta or {} - else: - entry["data"] = data or {} - event_ts = datetime.fromisoformat(entry["ts"].replace("Z", "+00:00")) - data = entry.get("data") if "data" in entry else None - meta = entry.get("meta") if "meta" in entry else None - - def _op(cur, _conn): - insert_engine_event( - cur, - entry.get("event"), - data=data, - message=entry.get("message"), - meta=meta, - ts=event_ts, - ) - - run_with_retry(_op) - + +def log_event( + event: str, + data: dict | None = None, + message: str | None = None, + meta: dict | None = None, +): + entry = { + "ts": datetime.utcnow().replace(tzinfo=timezone.utc).isoformat(), + "event": event, + } + if message is not None or meta is not None: + entry["message"] = message or "" + entry["meta"] = meta or {} + else: + entry["data"] = data or {} + event_ts = datetime.fromisoformat(entry["ts"].replace("Z", "+00:00")) + data = entry.get("data") if "data" in entry else None + meta = entry.get("meta") if "meta" in entry else None + + def _op(cur, _conn): + insert_engine_event( + cur, + entry.get("event"), + data=data, + message=entry.get("message"), + meta=meta, + ts=event_ts, + ) + + run_with_retry(_op) + def sleep_with_heartbeat( total_seconds: int, stop_event: threading.Event, @@ -133,321 +133,321 @@ def _clear_runner(user_id: str, run_id: str): key = _state_key(user_id, run_id) with _RUNNERS_LOCK: _RUNNERS.pop(key, None) - -def can_execute(now: datetime) -> tuple[bool, str]: - if not is_market_open(now): - return False, "MARKET_CLOSED" - return True, "OK" - -def _engine_loop(config, stop_event: threading.Event): - print("Strategy engine started with config:", config) - - user_id = config.get("user_id") - run_id = config.get("run_id") - scope_user, scope_run = get_context(user_id, run_id) - set_context(scope_user, scope_run) - - strategy_name = config.get("strategy_name") or config.get("strategy") or "golden_nifty" - sip_amount = config["sip_amount"] - configured_frequency = config.get("sip_frequency") or {} - if not isinstance(configured_frequency, dict): - configured_frequency = {} - frequency_value = int(configured_frequency.get("value", config.get("frequency", 0))) - frequency_unit = configured_frequency.get("unit", config.get("unit", "days")) - frequency_label = f"{frequency_value} {frequency_unit}" - emit_event_cb = config.get("emit_event") - if not callable(emit_event_cb): - emit_event_cb = None - debug_enabled = os.getenv("ENGINE_DEBUG", "1").strip().lower() not in {"0", "false", "no"} - - def debug_event(event: str, message: str, meta: dict | None = None): - if not debug_enabled: - return - try: - log_event(event=event, message=message, meta=meta or {}) - except Exception: - pass - if emit_event_cb: - emit_event_cb(event=event, message=message, meta=meta or {}) - print(f"[ENGINE] {event} {message} {meta or {}}", flush=True) - mode = (config.get("mode") or "LIVE").strip().upper() - if mode not in {"PAPER", "LIVE"}: - mode = "LIVE" - broker_type = config.get("broker") or "paper" - if broker_type != "paper": - broker_type = "paper" - if broker_type == "paper": - mode = "PAPER" - initial_cash = float(config.get("initial_cash", 0)) - broker = PaperBroker(initial_cash=initial_cash) - log_event( - event="DEBUG_PAPER_STORE_PATH", - message="Paper broker store path", - meta={ - "cwd": os.getcwd(), - "paper_store_path": str(broker.store_path) if hasattr(broker, "store_path") else "NO_STORE_PATH", - "abs_store_path": os.path.abspath(str(broker.store_path)) if hasattr(broker, "store_path") else "N/A", - }, - ) - if emit_event_cb: - emit_event_cb( - event="DEBUG_PAPER_STORE_PATH", - message="Paper broker store path", - meta={ - "cwd": os.getcwd(), - "paper_store_path": str(broker.store_path) if hasattr(broker, "store_path") else "NO_STORE_PATH", - "abs_store_path": os.path.abspath(str(broker.store_path)) if hasattr(broker, "store_path") else "N/A", - }, - ) - - log_event("ENGINE_START", { - "strategy": strategy_name, - "sip_amount": sip_amount, - "frequency": frequency_label, - }) - debug_event("ENGINE_START_DEBUG", "engine loop started", {"run_id": scope_run, "user_id": scope_user}) - + +def can_execute(now: datetime) -> tuple[bool, str]: + if not is_market_open(now): + return False, "MARKET_CLOSED" + return True, "OK" + +def _engine_loop(config, stop_event: threading.Event): + print("Strategy engine started with config:", config) + + user_id = config.get("user_id") + run_id = config.get("run_id") + scope_user, scope_run = get_context(user_id, run_id) + set_context(scope_user, scope_run) + + strategy_name = config.get("strategy_name") or config.get("strategy") or "golden_nifty" + sip_amount = config["sip_amount"] + configured_frequency = config.get("sip_frequency") or {} + if not isinstance(configured_frequency, dict): + configured_frequency = {} + frequency_value = int(configured_frequency.get("value", config.get("frequency", 0))) + frequency_unit = configured_frequency.get("unit", config.get("unit", "days")) + frequency_label = f"{frequency_value} {frequency_unit}" + emit_event_cb = config.get("emit_event") + if not callable(emit_event_cb): + emit_event_cb = None + debug_enabled = os.getenv("ENGINE_DEBUG", "1").strip().lower() not in {"0", "false", "no"} + + def debug_event(event: str, message: str, meta: dict | None = None): + if not debug_enabled: + return + try: + log_event(event=event, message=message, meta=meta or {}) + except Exception: + pass + if emit_event_cb: + emit_event_cb(event=event, message=message, meta=meta or {}) + print(f"[ENGINE] {event} {message} {meta or {}}", flush=True) + mode = (config.get("mode") or "LIVE").strip().upper() + if mode not in {"PAPER", "LIVE"}: + mode = "LIVE" + broker_type = config.get("broker") or "paper" + if broker_type != "paper": + broker_type = "paper" + if broker_type == "paper": + mode = "PAPER" + initial_cash = float(config.get("initial_cash", 0)) + broker = PaperBroker(initial_cash=initial_cash) + log_event( + event="DEBUG_PAPER_STORE_PATH", + message="Paper broker store path", + meta={ + "cwd": os.getcwd(), + "paper_store_path": str(broker.store_path) if hasattr(broker, "store_path") else "NO_STORE_PATH", + "abs_store_path": os.path.abspath(str(broker.store_path)) if hasattr(broker, "store_path") else "N/A", + }, + ) + if emit_event_cb: + emit_event_cb( + event="DEBUG_PAPER_STORE_PATH", + message="Paper broker store path", + meta={ + "cwd": os.getcwd(), + "paper_store_path": str(broker.store_path) if hasattr(broker, "store_path") else "NO_STORE_PATH", + "abs_store_path": os.path.abspath(str(broker.store_path)) if hasattr(broker, "store_path") else "N/A", + }, + ) + + log_event("ENGINE_START", { + "strategy": strategy_name, + "sip_amount": sip_amount, + "frequency": frequency_label, + }) + debug_event("ENGINE_START_DEBUG", "engine loop started", {"run_id": scope_run, "user_id": scope_user}) + _set_state( scope_user, scope_run, state="RUNNING", last_heartbeat_ts=datetime.utcnow().isoformat() + "Z", ) - _update_engine_status(scope_user, scope_run, "RUNNING") - - try: - while not stop_event.is_set(): + _update_engine_status(scope_user, scope_run, "RUNNING") + + try: + while not stop_event.is_set(): _set_state(scope_user, scope_run, last_heartbeat_ts=datetime.utcnow().isoformat() + "Z") _update_engine_status(scope_user, scope_run, "RUNNING") - - state = load_state(mode=mode) - debug_event( - "STATE_LOADED", - "loaded engine state", - { - "last_sip_ts": state.get("last_sip_ts"), - "last_run": state.get("last_run"), - "cash": state.get("cash"), - "total_invested": state.get("total_invested"), - }, - ) - state_frequency = state.get("sip_frequency") - if not isinstance(state_frequency, dict): - state_frequency = {"value": frequency_value, "unit": frequency_unit} - freq = int(state_frequency.get("value", frequency_value)) - unit = state_frequency.get("unit", frequency_unit) - frequency_label = f"{freq} {unit}" - if unit == "minutes": - delta = timedelta(minutes=freq) - else: - delta = timedelta(days=freq) - - # Gate 2: time to SIP - last_run = state.get("last_run") or state.get("last_sip_ts") - is_first_run = last_run is None - now = datetime.now() - debug_event( - "ENGINE_LOOP_TICK", - "engine loop tick", - {"now": now.isoformat(), "frequency": frequency_label}, - ) - - if last_run and not is_first_run: - next_run = datetime.fromisoformat(last_run) + delta - next_run = align_to_market_open(next_run) - if now < next_run: - log_event( - event="SIP_WAITING", - message="Waiting for next SIP window", - meta={ - "last_run": last_run, - "next_eligible": next_run.isoformat(), - "now": now.isoformat(), - "frequency": frequency_label, - }, - ) - if emit_event_cb: - emit_event_cb( - event="SIP_WAITING", - message="Waiting for next SIP window", - meta={ - "last_run": last_run, - "next_eligible": next_run.isoformat(), - "now": now.isoformat(), - "frequency": frequency_label, - }, - ) + + state = load_state(mode=mode) + debug_event( + "STATE_LOADED", + "loaded engine state", + { + "last_sip_ts": state.get("last_sip_ts"), + "last_run": state.get("last_run"), + "cash": state.get("cash"), + "total_invested": state.get("total_invested"), + }, + ) + state_frequency = state.get("sip_frequency") + if not isinstance(state_frequency, dict): + state_frequency = {"value": frequency_value, "unit": frequency_unit} + freq = int(state_frequency.get("value", frequency_value)) + unit = state_frequency.get("unit", frequency_unit) + frequency_label = f"{freq} {unit}" + if unit == "minutes": + delta = timedelta(minutes=freq) + else: + delta = timedelta(days=freq) + + # Gate 2: time to SIP + last_run = state.get("last_run") or state.get("last_sip_ts") + is_first_run = last_run is None + now = datetime.now() + debug_event( + "ENGINE_LOOP_TICK", + "engine loop tick", + {"now": now.isoformat(), "frequency": frequency_label}, + ) + + if last_run and not is_first_run: + next_run = datetime.fromisoformat(last_run) + delta + next_run = align_to_market_open(next_run) + if now < next_run: + log_event( + event="SIP_WAITING", + message="Waiting for next SIP window", + meta={ + "last_run": last_run, + "next_eligible": next_run.isoformat(), + "now": now.isoformat(), + "frequency": frequency_label, + }, + ) + if emit_event_cb: + emit_event_cb( + event="SIP_WAITING", + message="Waiting for next SIP window", + meta={ + "last_run": last_run, + "next_eligible": next_run.isoformat(), + "now": now.isoformat(), + "frequency": frequency_label, + }, + ) sleep_with_heartbeat(60, stop_event, scope_user, scope_run) continue - - try: - debug_event("PRICE_FETCH_START", "fetching live prices", {"tickers": [NIFTY, GOLD]}) - nifty_price = fetch_live_price(NIFTY) - gold_price = fetch_live_price(GOLD) - debug_event( - "PRICE_FETCHED", - "fetched live prices", - {"nifty_price": float(nifty_price), "gold_price": float(gold_price)}, - ) - except Exception as exc: - debug_event("PRICE_FETCH_ERROR", "live price fetch failed", {"error": str(exc)}) + + try: + debug_event("PRICE_FETCH_START", "fetching live prices", {"tickers": [NIFTY, GOLD]}) + nifty_price = fetch_live_price(NIFTY) + gold_price = fetch_live_price(GOLD) + debug_event( + "PRICE_FETCHED", + "fetched live prices", + {"nifty_price": float(nifty_price), "gold_price": float(gold_price)}, + ) + except Exception as exc: + debug_event("PRICE_FETCH_ERROR", "live price fetch failed", {"error": str(exc)}) sleep_with_heartbeat(30, stop_event, scope_user, scope_run) continue - - try: - nifty_hist = load_monthly_close(NIFTY) - gold_hist = load_monthly_close(GOLD) - except Exception as exc: - debug_event("HISTORY_LOAD_ERROR", "history load failed", {"error": str(exc)}) + + try: + nifty_hist = load_monthly_close(NIFTY) + gold_hist = load_monthly_close(GOLD) + except Exception as exc: + debug_event("HISTORY_LOAD_ERROR", "history load failed", {"error": str(exc)}) sleep_with_heartbeat(30, stop_event, scope_user, scope_run) continue - - nifty_sma = nifty_hist.rolling(SMA_MONTHS).mean().iloc[-1] - gold_sma = gold_hist.rolling(SMA_MONTHS).mean().iloc[-1] - - eq_w, gd_w = allocation( - sp_price=nifty_price, - gd_price=gold_price, - sp_sma=nifty_sma, - gd_sma=gold_sma - ) - debug_event( - "WEIGHTS_COMPUTED", - "computed allocation weights", - {"equity_weight": float(eq_w), "gold_weight": float(gd_w)}, - ) - - weights = {"equity": eq_w, "gold": gd_w} - allowed, reason = can_execute(now) - executed = False - if not allowed: - log_event( - event="EXECUTION_BLOCKED", - message="Execution blocked by market gate", - meta={ - "reason": reason, - "eligible_since": last_run, - "checked_at": now.isoformat(), - }, - ) - debug_event("MARKET_GATE", "market closed", {"reason": reason}) - if emit_event_cb: - emit_event_cb( - event="EXECUTION_BLOCKED", - message="Execution blocked by market gate", - meta={ - "reason": reason, - "eligible_since": last_run, - "checked_at": now.isoformat(), - }, - ) - else: - log_event( - event="DEBUG_BEFORE_TRY_EXECUTE", - message="About to call try_execute_sip", - meta={ - "last_run": last_run, - "frequency": frequency_label, - "allowed": allowed, - "reason": reason, - "sip_amount": sip_amount, - "broker": type(broker).__name__, - "now": now.isoformat(), - }, - ) - if emit_event_cb: - emit_event_cb( - event="DEBUG_BEFORE_TRY_EXECUTE", - message="About to call try_execute_sip", - meta={ - "last_run": last_run, - "frequency": frequency_label, - "allowed": allowed, - "reason": reason, - "sip_amount": sip_amount, - "broker": type(broker).__name__, - "now": now.isoformat(), - }, - ) - debug_event( - "TRY_EXECUTE_START", - "calling try_execute_sip", - {"sip_interval_sec": delta.total_seconds(), "sip_amount": sip_amount}, - ) - state, executed = try_execute_sip( - now=now, - market_open=True, - sip_interval=delta.total_seconds(), - sip_amount=sip_amount, - sp_price=nifty_price, - gd_price=gold_price, - eq_w=eq_w, - gd_w=gd_w, - broker=broker, - mode=mode, - ) - log_event( - event="DEBUG_AFTER_TRY_EXECUTE", - message="Returned from try_execute_sip", - meta={ - "executed": executed, - "state_last_run": state.get("last_run"), - "state_last_sip_ts": state.get("last_sip_ts"), - }, - ) - if emit_event_cb: - emit_event_cb( - event="DEBUG_AFTER_TRY_EXECUTE", - message="Returned from try_execute_sip", - meta={ - "executed": executed, - "state_last_run": state.get("last_run"), - "state_last_sip_ts": state.get("last_sip_ts"), - }, - ) - debug_event( - "TRY_EXECUTE_DONE", - "try_execute_sip finished", - {"executed": executed, "last_run": state.get("last_run")}, - ) - - if executed: - log_event("SIP_TRIGGERED", { - "date": now.date().isoformat(), - "allocation": weights, - "cash_used": sip_amount - }) - debug_event("SIP_TRIGGERED", "sip executed", {"cash_used": sip_amount}) - portfolio_value = ( - state["nifty_units"] * nifty_price - + state["gold_units"] * gold_price - ) - log_event("PORTFOLIO_UPDATED", { - "nifty_units": state["nifty_units"], - "gold_units": state["gold_units"], - "portfolio_value": portfolio_value - }) - print("SIP executed at", now) - - if should_log_mtm(None, now): - logical_time = normalize_logical_time(now) - with db_transaction() as cur: - log_mtm( - nifty_units=state["nifty_units"], - gold_units=state["gold_units"], - nifty_price=nifty_price, - gold_price=gold_price, - total_invested=state["total_invested"], - cur=cur, - logical_time=logical_time, - ) - broker.update_equity( - {NIFTY: nifty_price, GOLD: gold_price}, - now, - cur=cur, - logical_time=logical_time, - ) - + + nifty_sma = nifty_hist.rolling(SMA_MONTHS).mean().iloc[-1] + gold_sma = gold_hist.rolling(SMA_MONTHS).mean().iloc[-1] + + eq_w, gd_w = allocation( + sp_price=nifty_price, + gd_price=gold_price, + sp_sma=nifty_sma, + gd_sma=gold_sma + ) + debug_event( + "WEIGHTS_COMPUTED", + "computed allocation weights", + {"equity_weight": float(eq_w), "gold_weight": float(gd_w)}, + ) + + weights = {"equity": eq_w, "gold": gd_w} + allowed, reason = can_execute(now) + executed = False + if not allowed: + log_event( + event="EXECUTION_BLOCKED", + message="Execution blocked by market gate", + meta={ + "reason": reason, + "eligible_since": last_run, + "checked_at": now.isoformat(), + }, + ) + debug_event("MARKET_GATE", "market closed", {"reason": reason}) + if emit_event_cb: + emit_event_cb( + event="EXECUTION_BLOCKED", + message="Execution blocked by market gate", + meta={ + "reason": reason, + "eligible_since": last_run, + "checked_at": now.isoformat(), + }, + ) + else: + log_event( + event="DEBUG_BEFORE_TRY_EXECUTE", + message="About to call try_execute_sip", + meta={ + "last_run": last_run, + "frequency": frequency_label, + "allowed": allowed, + "reason": reason, + "sip_amount": sip_amount, + "broker": type(broker).__name__, + "now": now.isoformat(), + }, + ) + if emit_event_cb: + emit_event_cb( + event="DEBUG_BEFORE_TRY_EXECUTE", + message="About to call try_execute_sip", + meta={ + "last_run": last_run, + "frequency": frequency_label, + "allowed": allowed, + "reason": reason, + "sip_amount": sip_amount, + "broker": type(broker).__name__, + "now": now.isoformat(), + }, + ) + debug_event( + "TRY_EXECUTE_START", + "calling try_execute_sip", + {"sip_interval_sec": delta.total_seconds(), "sip_amount": sip_amount}, + ) + state, executed = try_execute_sip( + now=now, + market_open=True, + sip_interval=delta.total_seconds(), + sip_amount=sip_amount, + sp_price=nifty_price, + gd_price=gold_price, + eq_w=eq_w, + gd_w=gd_w, + broker=broker, + mode=mode, + ) + log_event( + event="DEBUG_AFTER_TRY_EXECUTE", + message="Returned from try_execute_sip", + meta={ + "executed": executed, + "state_last_run": state.get("last_run"), + "state_last_sip_ts": state.get("last_sip_ts"), + }, + ) + if emit_event_cb: + emit_event_cb( + event="DEBUG_AFTER_TRY_EXECUTE", + message="Returned from try_execute_sip", + meta={ + "executed": executed, + "state_last_run": state.get("last_run"), + "state_last_sip_ts": state.get("last_sip_ts"), + }, + ) + debug_event( + "TRY_EXECUTE_DONE", + "try_execute_sip finished", + {"executed": executed, "last_run": state.get("last_run")}, + ) + + if executed: + log_event("SIP_TRIGGERED", { + "date": now.date().isoformat(), + "allocation": weights, + "cash_used": sip_amount + }) + debug_event("SIP_TRIGGERED", "sip executed", {"cash_used": sip_amount}) + portfolio_value = ( + state["nifty_units"] * nifty_price + + state["gold_units"] * gold_price + ) + log_event("PORTFOLIO_UPDATED", { + "nifty_units": state["nifty_units"], + "gold_units": state["gold_units"], + "portfolio_value": portfolio_value + }) + print("SIP executed at", now) + + if should_log_mtm(None, now): + logical_time = normalize_logical_time(now) + with db_transaction() as cur: + log_mtm( + nifty_units=state["nifty_units"], + gold_units=state["gold_units"], + nifty_price=nifty_price, + gold_price=gold_price, + total_invested=state["total_invested"], + cur=cur, + logical_time=logical_time, + ) + broker.update_equity( + {NIFTY: nifty_price, GOLD: gold_price}, + now, + cur=cur, + logical_time=logical_time, + ) + sleep_with_heartbeat(30, stop_event, scope_user, scope_run) except Exception as e: _set_state(scope_user, scope_run, state="ERROR", last_heartbeat_ts=datetime.utcnow().isoformat() + "Z") @@ -465,7 +465,7 @@ def _engine_loop(config, stop_event: threading.Event): _update_engine_status(scope_user, scope_run, "STOPPED") print("Strategy engine stopped") _clear_runner(scope_user, scope_run) - + def start_engine(config): user_id = config.get("user_id") run_id = config.get("run_id") @@ -481,8 +481,8 @@ def start_engine(config): return False stop_event = threading.Event() - thread = threading.Thread( - target=_engine_loop, + thread = threading.Thread( + target=_engine_loop, args=(config, stop_event), daemon=True, ) @@ -515,4 +515,4 @@ def stop_engine(user_id: str, run_id: str | None = None, timeout: float | None = else: stopped_all = False return stopped_all - + diff --git a/indian_paper_trading_strategy/engine/state.py b/indian_paper_trading_strategy/engine/state.py index 9ec4ccc..ef15553 100644 --- a/indian_paper_trading_strategy/engine/state.py +++ b/indian_paper_trading_strategy/engine/state.py @@ -1,303 +1,303 @@ -# engine/state.py -from datetime import datetime, timezone - -from indian_paper_trading_strategy.engine.db import db_connection, insert_engine_event, run_with_retry, get_context - -DEFAULT_STATE = { - "initial_cash": 0.0, - "cash": 0.0, - "total_invested": 0.0, - "nifty_units": 0.0, - "gold_units": 0.0, - "last_sip_ts": None, - "last_run": None, - "sip_frequency": None, -} - -DEFAULT_PAPER_STATE = { - **DEFAULT_STATE, - "initial_cash": 1_000_000.0, - "cash": 1_000_000.0, - "sip_frequency": {"value": 30, "unit": "days"}, -} - -def _state_key(mode: str | None): - key = (mode or "LIVE").strip().upper() - return "PAPER" if key == "PAPER" else "LIVE" - -def _default_state(mode: str | None): - if _state_key(mode) == "PAPER": - return DEFAULT_PAPER_STATE.copy() - return DEFAULT_STATE.copy() - -def _local_tz(): - return datetime.now().astimezone().tzinfo - -def _format_local_ts(value: datetime | None): - if value is None: - return None - return value.astimezone(_local_tz()).replace(tzinfo=None).isoformat() - -def _parse_ts(value): - if value is None: - return None - if isinstance(value, datetime): - if value.tzinfo is None: - return value.replace(tzinfo=_local_tz()) - return value - if isinstance(value, str): - text = value.strip() - if not text: - return None - try: - parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) - except ValueError: - return None - if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=_local_tz()) - return parsed - return None - -def _resolve_scope(user_id: str | None, run_id: str | None): - return get_context(user_id, run_id) - - -def load_state( - mode: str | None = "LIVE", - *, - cur=None, - for_update: bool = False, - user_id: str | None = None, - run_id: str | None = None, -): - scope_user, scope_run = _resolve_scope(user_id, run_id) - key = _state_key(mode) - if key == "PAPER": - if cur is None: - with db_connection() as conn: - with conn.cursor() as cur: - return load_state( - mode=mode, - cur=cur, - for_update=for_update, - user_id=scope_user, - run_id=scope_run, - ) - lock_clause = " FOR UPDATE" if for_update else "" - cur.execute( - f""" - SELECT initial_cash, cash, total_invested, nifty_units, gold_units, - last_sip_ts, last_run, sip_frequency_value, sip_frequency_unit - FROM engine_state_paper - WHERE user_id = %s AND run_id = %s{lock_clause} - LIMIT 1 - """, - (scope_user, scope_run), - ) - row = cur.fetchone() - if not row: - return _default_state(mode) - merged = _default_state(mode) - merged.update( - { - "initial_cash": float(row[0]) if row[0] is not None else merged["initial_cash"], - "cash": float(row[1]) if row[1] is not None else merged["cash"], - "total_invested": float(row[2]) if row[2] is not None else merged["total_invested"], - "nifty_units": float(row[3]) if row[3] is not None else merged["nifty_units"], - "gold_units": float(row[4]) if row[4] is not None else merged["gold_units"], - "last_sip_ts": _format_local_ts(row[5]), - "last_run": _format_local_ts(row[6]), - } - ) - if row[7] is not None or row[8] is not None: - merged["sip_frequency"] = {"value": row[7], "unit": row[8]} - return merged - - if cur is None: - with db_connection() as conn: - with conn.cursor() as cur: - return load_state( - mode=mode, - cur=cur, - for_update=for_update, - user_id=scope_user, - run_id=scope_run, - ) - lock_clause = " FOR UPDATE" if for_update else "" - cur.execute( - f""" - SELECT total_invested, nifty_units, gold_units, last_sip_ts, last_run - FROM engine_state - WHERE user_id = %s AND run_id = %s{lock_clause} - LIMIT 1 - """, - (scope_user, scope_run), - ) - row = cur.fetchone() - if not row: - return _default_state(mode) - merged = _default_state(mode) - merged.update( - { - "total_invested": float(row[0]) if row[0] is not None else merged["total_invested"], - "nifty_units": float(row[1]) if row[1] is not None else merged["nifty_units"], - "gold_units": float(row[2]) if row[2] is not None else merged["gold_units"], - "last_sip_ts": _format_local_ts(row[3]), - "last_run": _format_local_ts(row[4]), - } - ) - return merged - -def init_paper_state( - initial_cash: float, - sip_frequency: dict | None = None, - *, - cur=None, - user_id: str | None = None, - run_id: str | None = None, -): - state = DEFAULT_PAPER_STATE.copy() - state.update( - { - "initial_cash": float(initial_cash), - "cash": float(initial_cash), - "total_invested": 0.0, - "nifty_units": 0.0, - "gold_units": 0.0, - "last_sip_ts": None, - "last_run": None, - "sip_frequency": sip_frequency or state.get("sip_frequency"), - } - ) - save_state(state, mode="PAPER", cur=cur, emit_event=True, user_id=user_id, run_id=run_id) - return state - -def save_state( - state, - mode: str | None = "LIVE", - *, - cur=None, - emit_event: bool = False, - event_meta: dict | None = None, - user_id: str | None = None, - run_id: str | None = None, -): - scope_user, scope_run = _resolve_scope(user_id, run_id) - key = _state_key(mode) - last_sip_ts = _parse_ts(state.get("last_sip_ts")) - last_run = _parse_ts(state.get("last_run")) - if key == "PAPER": - sip_frequency = state.get("sip_frequency") - sip_value = None - sip_unit = None - if isinstance(sip_frequency, dict): - sip_value = sip_frequency.get("value") - sip_unit = sip_frequency.get("unit") - def _save(cur): - cur.execute( - """ - INSERT INTO engine_state_paper ( - user_id, run_id, initial_cash, cash, total_invested, nifty_units, gold_units, - last_sip_ts, last_run, sip_frequency_value, sip_frequency_unit - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - ON CONFLICT (user_id, run_id) DO UPDATE - SET initial_cash = EXCLUDED.initial_cash, - cash = EXCLUDED.cash, - total_invested = EXCLUDED.total_invested, - nifty_units = EXCLUDED.nifty_units, - gold_units = EXCLUDED.gold_units, - last_sip_ts = EXCLUDED.last_sip_ts, - last_run = EXCLUDED.last_run, - sip_frequency_value = EXCLUDED.sip_frequency_value, - sip_frequency_unit = EXCLUDED.sip_frequency_unit - """, - ( - scope_user, - scope_run, - float(state.get("initial_cash", 0.0)), - float(state.get("cash", 0.0)), - float(state.get("total_invested", 0.0)), - float(state.get("nifty_units", 0.0)), - float(state.get("gold_units", 0.0)), - last_sip_ts, - last_run, - sip_value, - sip_unit, - ), - ) - if emit_event: - insert_engine_event( - cur, - "STATE_UPDATED", - data={ - "mode": "PAPER", - "cash": state.get("cash"), - "total_invested": state.get("total_invested"), - "nifty_units": state.get("nifty_units"), - "gold_units": state.get("gold_units"), - "last_sip_ts": state.get("last_sip_ts"), - "last_run": state.get("last_run"), - }, - meta=event_meta, - ts=datetime.utcnow().replace(tzinfo=timezone.utc), - ) - - if cur is not None: - _save(cur) - return - - def _op(cur, _conn): - _save(cur) - - return run_with_retry(_op) - - def _save(cur): - cur.execute( - """ - INSERT INTO engine_state ( - user_id, run_id, total_invested, nifty_units, gold_units, last_sip_ts, last_run - ) - VALUES (%s, %s, %s, %s, %s, %s, %s) - ON CONFLICT (user_id, run_id) DO UPDATE - SET total_invested = EXCLUDED.total_invested, - nifty_units = EXCLUDED.nifty_units, - gold_units = EXCLUDED.gold_units, - last_sip_ts = EXCLUDED.last_sip_ts, - last_run = EXCLUDED.last_run - """, - ( - scope_user, - scope_run, - float(state.get("total_invested", 0.0)), - float(state.get("nifty_units", 0.0)), - float(state.get("gold_units", 0.0)), - last_sip_ts, - last_run, - ), - ) - if emit_event: - insert_engine_event( - cur, - "STATE_UPDATED", - data={ - "mode": "LIVE", - "total_invested": state.get("total_invested"), - "nifty_units": state.get("nifty_units"), - "gold_units": state.get("gold_units"), - "last_sip_ts": state.get("last_sip_ts"), - "last_run": state.get("last_run"), - }, - meta=event_meta, - ts=datetime.utcnow().replace(tzinfo=timezone.utc), - ) - - if cur is not None: - _save(cur) - return - - def _op(cur, _conn): - _save(cur) - - return run_with_retry(_op) - +# engine/state.py +from datetime import datetime, timezone + +from indian_paper_trading_strategy.engine.db import db_connection, insert_engine_event, run_with_retry, get_context + +DEFAULT_STATE = { + "initial_cash": 0.0, + "cash": 0.0, + "total_invested": 0.0, + "nifty_units": 0.0, + "gold_units": 0.0, + "last_sip_ts": None, + "last_run": None, + "sip_frequency": None, +} + +DEFAULT_PAPER_STATE = { + **DEFAULT_STATE, + "initial_cash": 1_000_000.0, + "cash": 1_000_000.0, + "sip_frequency": {"value": 30, "unit": "days"}, +} + +def _state_key(mode: str | None): + key = (mode or "LIVE").strip().upper() + return "PAPER" if key == "PAPER" else "LIVE" + +def _default_state(mode: str | None): + if _state_key(mode) == "PAPER": + return DEFAULT_PAPER_STATE.copy() + return DEFAULT_STATE.copy() + +def _local_tz(): + return datetime.now().astimezone().tzinfo + +def _format_local_ts(value: datetime | None): + if value is None: + return None + return value.astimezone(_local_tz()).replace(tzinfo=None).isoformat() + +def _parse_ts(value): + if value is None: + return None + if isinstance(value, datetime): + if value.tzinfo is None: + return value.replace(tzinfo=_local_tz()) + return value + if isinstance(value, str): + text = value.strip() + if not text: + return None + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=_local_tz()) + return parsed + return None + +def _resolve_scope(user_id: str | None, run_id: str | None): + return get_context(user_id, run_id) + + +def load_state( + mode: str | None = "LIVE", + *, + cur=None, + for_update: bool = False, + user_id: str | None = None, + run_id: str | None = None, +): + scope_user, scope_run = _resolve_scope(user_id, run_id) + key = _state_key(mode) + if key == "PAPER": + if cur is None: + with db_connection() as conn: + with conn.cursor() as cur: + return load_state( + mode=mode, + cur=cur, + for_update=for_update, + user_id=scope_user, + run_id=scope_run, + ) + lock_clause = " FOR UPDATE" if for_update else "" + cur.execute( + f""" + SELECT initial_cash, cash, total_invested, nifty_units, gold_units, + last_sip_ts, last_run, sip_frequency_value, sip_frequency_unit + FROM engine_state_paper + WHERE user_id = %s AND run_id = %s{lock_clause} + LIMIT 1 + """, + (scope_user, scope_run), + ) + row = cur.fetchone() + if not row: + return _default_state(mode) + merged = _default_state(mode) + merged.update( + { + "initial_cash": float(row[0]) if row[0] is not None else merged["initial_cash"], + "cash": float(row[1]) if row[1] is not None else merged["cash"], + "total_invested": float(row[2]) if row[2] is not None else merged["total_invested"], + "nifty_units": float(row[3]) if row[3] is not None else merged["nifty_units"], + "gold_units": float(row[4]) if row[4] is not None else merged["gold_units"], + "last_sip_ts": _format_local_ts(row[5]), + "last_run": _format_local_ts(row[6]), + } + ) + if row[7] is not None or row[8] is not None: + merged["sip_frequency"] = {"value": row[7], "unit": row[8]} + return merged + + if cur is None: + with db_connection() as conn: + with conn.cursor() as cur: + return load_state( + mode=mode, + cur=cur, + for_update=for_update, + user_id=scope_user, + run_id=scope_run, + ) + lock_clause = " FOR UPDATE" if for_update else "" + cur.execute( + f""" + SELECT total_invested, nifty_units, gold_units, last_sip_ts, last_run + FROM engine_state + WHERE user_id = %s AND run_id = %s{lock_clause} + LIMIT 1 + """, + (scope_user, scope_run), + ) + row = cur.fetchone() + if not row: + return _default_state(mode) + merged = _default_state(mode) + merged.update( + { + "total_invested": float(row[0]) if row[0] is not None else merged["total_invested"], + "nifty_units": float(row[1]) if row[1] is not None else merged["nifty_units"], + "gold_units": float(row[2]) if row[2] is not None else merged["gold_units"], + "last_sip_ts": _format_local_ts(row[3]), + "last_run": _format_local_ts(row[4]), + } + ) + return merged + +def init_paper_state( + initial_cash: float, + sip_frequency: dict | None = None, + *, + cur=None, + user_id: str | None = None, + run_id: str | None = None, +): + state = DEFAULT_PAPER_STATE.copy() + state.update( + { + "initial_cash": float(initial_cash), + "cash": float(initial_cash), + "total_invested": 0.0, + "nifty_units": 0.0, + "gold_units": 0.0, + "last_sip_ts": None, + "last_run": None, + "sip_frequency": sip_frequency or state.get("sip_frequency"), + } + ) + save_state(state, mode="PAPER", cur=cur, emit_event=True, user_id=user_id, run_id=run_id) + return state + +def save_state( + state, + mode: str | None = "LIVE", + *, + cur=None, + emit_event: bool = False, + event_meta: dict | None = None, + user_id: str | None = None, + run_id: str | None = None, +): + scope_user, scope_run = _resolve_scope(user_id, run_id) + key = _state_key(mode) + last_sip_ts = _parse_ts(state.get("last_sip_ts")) + last_run = _parse_ts(state.get("last_run")) + if key == "PAPER": + sip_frequency = state.get("sip_frequency") + sip_value = None + sip_unit = None + if isinstance(sip_frequency, dict): + sip_value = sip_frequency.get("value") + sip_unit = sip_frequency.get("unit") + def _save(cur): + cur.execute( + """ + INSERT INTO engine_state_paper ( + user_id, run_id, initial_cash, cash, total_invested, nifty_units, gold_units, + last_sip_ts, last_run, sip_frequency_value, sip_frequency_unit + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (user_id, run_id) DO UPDATE + SET initial_cash = EXCLUDED.initial_cash, + cash = EXCLUDED.cash, + total_invested = EXCLUDED.total_invested, + nifty_units = EXCLUDED.nifty_units, + gold_units = EXCLUDED.gold_units, + last_sip_ts = EXCLUDED.last_sip_ts, + last_run = EXCLUDED.last_run, + sip_frequency_value = EXCLUDED.sip_frequency_value, + sip_frequency_unit = EXCLUDED.sip_frequency_unit + """, + ( + scope_user, + scope_run, + float(state.get("initial_cash", 0.0)), + float(state.get("cash", 0.0)), + float(state.get("total_invested", 0.0)), + float(state.get("nifty_units", 0.0)), + float(state.get("gold_units", 0.0)), + last_sip_ts, + last_run, + sip_value, + sip_unit, + ), + ) + if emit_event: + insert_engine_event( + cur, + "STATE_UPDATED", + data={ + "mode": "PAPER", + "cash": state.get("cash"), + "total_invested": state.get("total_invested"), + "nifty_units": state.get("nifty_units"), + "gold_units": state.get("gold_units"), + "last_sip_ts": state.get("last_sip_ts"), + "last_run": state.get("last_run"), + }, + meta=event_meta, + ts=datetime.utcnow().replace(tzinfo=timezone.utc), + ) + + if cur is not None: + _save(cur) + return + + def _op(cur, _conn): + _save(cur) + + return run_with_retry(_op) + + def _save(cur): + cur.execute( + """ + INSERT INTO engine_state ( + user_id, run_id, total_invested, nifty_units, gold_units, last_sip_ts, last_run + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (user_id, run_id) DO UPDATE + SET total_invested = EXCLUDED.total_invested, + nifty_units = EXCLUDED.nifty_units, + gold_units = EXCLUDED.gold_units, + last_sip_ts = EXCLUDED.last_sip_ts, + last_run = EXCLUDED.last_run + """, + ( + scope_user, + scope_run, + float(state.get("total_invested", 0.0)), + float(state.get("nifty_units", 0.0)), + float(state.get("gold_units", 0.0)), + last_sip_ts, + last_run, + ), + ) + if emit_event: + insert_engine_event( + cur, + "STATE_UPDATED", + data={ + "mode": "LIVE", + "total_invested": state.get("total_invested"), + "nifty_units": state.get("nifty_units"), + "gold_units": state.get("gold_units"), + "last_sip_ts": state.get("last_sip_ts"), + "last_run": state.get("last_run"), + }, + meta=event_meta, + ts=datetime.utcnow().replace(tzinfo=timezone.utc), + ) + + if cur is not None: + _save(cur) + return + + def _op(cur, _conn): + _save(cur) + + return run_with_retry(_op) + diff --git a/indian_paper_trading_strategy/engine/strategy.py b/indian_paper_trading_strategy/engine/strategy.py index 504034e..e02755d 100644 --- a/indian_paper_trading_strategy/engine/strategy.py +++ b/indian_paper_trading_strategy/engine/strategy.py @@ -1,12 +1,12 @@ -# engine/strategy.py -import numpy as np - -def allocation(sp_price, gd_price, sp_sma, gd_sma, - base=0.6, tilt_mult=1.5, - max_tilt=0.25, min_eq=0.2, max_eq=0.9): - - rd = (sp_price / sp_sma) - (gd_price / gd_sma) - tilt = np.clip(-rd * tilt_mult, -max_tilt, max_tilt) - - eq_w = np.clip(base * (1 + tilt), min_eq, max_eq) - return eq_w, 1 - eq_w +# engine/strategy.py +import numpy as np + +def allocation(sp_price, gd_price, sp_sma, gd_sma, + base=0.6, tilt_mult=1.5, + max_tilt=0.25, min_eq=0.2, max_eq=0.9): + + rd = (sp_price / sp_sma) - (gd_price / gd_sma) + tilt = np.clip(-rd * tilt_mult, -max_tilt, max_tilt) + + eq_w = np.clip(base * (1 + tilt), min_eq, max_eq) + return eq_w, 1 - eq_w diff --git a/indian_paper_trading_strategy/engine/time_utils.py b/indian_paper_trading_strategy/engine/time_utils.py index 3315338..9e4943d 100644 --- a/indian_paper_trading_strategy/engine/time_utils.py +++ b/indian_paper_trading_strategy/engine/time_utils.py @@ -1,41 +1,41 @@ -from datetime import datetime, timedelta - - -def frequency_to_timedelta(freq: dict) -> timedelta: - value = int(freq.get("value", 0)) - unit = freq.get("unit") - - if value <= 0: - raise ValueError("Frequency value must be > 0") - - if unit == "minutes": - return timedelta(minutes=value) - if unit == "days": - return timedelta(days=value) - raise ValueError(f"Unsupported frequency unit: {unit}") - - -def normalize_logical_time(ts: datetime) -> datetime: - return ts.replace(microsecond=0) - - -def compute_logical_time( - now: datetime, - last_run: str | None, - interval_seconds: float | None, -) -> datetime: - base = now - if last_run and interval_seconds: - try: - parsed = datetime.fromisoformat(last_run.replace("Z", "+00:00")) - except ValueError: - parsed = None - if parsed is not None: - if now.tzinfo and parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=now.tzinfo) - elif now.tzinfo is None and parsed.tzinfo: - parsed = parsed.replace(tzinfo=None) - candidate = parsed + timedelta(seconds=interval_seconds) - if now >= candidate: - base = candidate - return normalize_logical_time(base) +from datetime import datetime, timedelta + + +def frequency_to_timedelta(freq: dict) -> timedelta: + value = int(freq.get("value", 0)) + unit = freq.get("unit") + + if value <= 0: + raise ValueError("Frequency value must be > 0") + + if unit == "minutes": + return timedelta(minutes=value) + if unit == "days": + return timedelta(days=value) + raise ValueError(f"Unsupported frequency unit: {unit}") + + +def normalize_logical_time(ts: datetime) -> datetime: + return ts.replace(microsecond=0) + + +def compute_logical_time( + now: datetime, + last_run: str | None, + interval_seconds: float | None, +) -> datetime: + base = now + if last_run and interval_seconds: + try: + parsed = datetime.fromisoformat(last_run.replace("Z", "+00:00")) + except ValueError: + parsed = None + if parsed is not None: + if now.tzinfo and parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=now.tzinfo) + elif now.tzinfo is None and parsed.tzinfo: + parsed = parsed.replace(tzinfo=None) + candidate = parsed + timedelta(seconds=interval_seconds) + if now >= candidate: + base = candidate + return normalize_logical_time(base) diff --git a/indian_paper_trading_strategy/storage/history/GOLDBEES.NS.csv b/indian_paper_trading_strategy/storage/history/GOLDBEES.NS.csv new file mode 100644 index 0000000..a86125e --- /dev/null +++ b/indian_paper_trading_strategy/storage/history/GOLDBEES.NS.csv @@ -0,0 +1,122 @@ +Date,Close +2015-12-31,22.792499542236328 +2016-01-31,24.23349952697754 +2016-02-29,26.489500045776367 +2016-03-31,25.637500762939453 +2016-04-30,27.17099952697754 +2016-05-31,26.45599937438965 +2016-06-30,27.773000717163086 +2016-07-31,27.96299934387207 +2016-08-31,28.120500564575195 +2016-09-30,28.25200080871582 +2016-10-31,27.35700035095215 +2016-11-30,26.456499099731445 +2016-12-31,25.645999908447266 +2017-01-31,26.30699920654297 +2017-02-28,27.021499633789062 +2017-03-31,26.12849998474121 +2017-04-30,26.40999984741211 +2017-05-31,26.184499740600586 +2017-06-30,25.768999099731445 +2017-07-31,25.709999084472656 +2017-08-31,26.525999069213867 +2017-09-30,26.662500381469727 +2017-10-31,26.37150001525879 +2017-11-30,26.300500869750977 +2017-12-31,26.26799964904785 +2018-01-31,27.089000701904297 +2018-02-28,27.302499771118164 +2018-03-31,27.45800018310547 +2018-04-30,27.749000549316406 +2018-05-31,27.781999588012695 +2018-06-30,27.13050079345703 +2018-07-31,26.503999710083008 +2018-08-31,27.047500610351562 +2018-09-30,27.027999877929688 +2018-10-31,28.370500564575195 +2018-11-30,26.98550033569336 +2018-12-31,28.02050018310547 +2019-01-31,29.348499298095703 +2019-02-28,29.502500534057617 +2019-03-31,28.25149917602539 +2019-04-30,28.15250015258789 +2019-05-31,28.381999969482422 +2019-06-30,29.97949981689453 +2019-07-31,30.6564998626709 +2019-08-31,33.79949951171875 +2019-09-30,32.97249984741211 +2019-10-31,34.06549835205078 +2019-11-30,33.41350173950195 +2019-12-31,34.45000076293945 +2020-01-31,35.869998931884766 +2020-02-29,37.369998931884766 +2020-03-31,38.209999084472656 +2020-04-30,42.56999969482422 +2020-05-31,40.9900016784668 +2020-06-30,42.61000061035156 +2020-07-31,47.13999938964844 +2020-08-31,45.34000015258789 +2020-09-30,44.18000030517578 +2020-10-31,44.31999969482422 +2020-11-30,42.84000015258789 +2020-12-31,43.75 +2021-01-31,42.650001525878906 +2021-02-28,40.25 +2021-03-31,38.16999816894531 +2021-04-30,40.59000015258789 +2021-05-31,42.59000015258789 +2021-06-30,40.52000045776367 +2021-07-31,41.91999816894531 +2021-08-31,40.83000183105469 +2021-09-30,39.720001220703125 +2021-10-31,41.5 +2021-11-30,41.5099983215332 +2021-12-31,41.47999954223633 +2022-01-31,41.25 +2022-02-28,43.59000015258789 +2022-03-31,44.119998931884766 +2022-04-30,44.790000915527344 +2022-05-31,43.95000076293945 +2022-06-30,43.7599983215332 +2022-07-31,44.060001373291016 +2022-08-31,43.900001525878906 +2022-09-30,43.15999984741211 +2022-10-31,43.22999954223633 +2022-11-30,45.099998474121094 +2022-12-31,46.790000915527344 +2023-01-31,48.66999816894531 +2023-02-28,47.40999984741211 +2023-03-31,51.06999969482422 +2023-04-30,51.25 +2023-05-31,51.130001068115234 +2023-06-30,49.310001373291016 +2023-07-31,50.65999984741211 +2023-08-31,50.349998474121094 +2023-09-30,49.290000915527344 +2023-10-31,52.060001373291016 +2023-11-30,53.150001525878906 +2023-12-31,53.65999984741211 +2024-01-31,53.349998474121094 +2024-02-29,52.779998779296875 +2024-03-31,56.61000061035156 +2024-04-30,60.880001068115234 +2024-05-31,61.290000915527344 +2024-06-30,60.630001068115234 +2024-07-31,58.68000030517578 +2024-08-31,60.65999984741211 +2024-09-30,63.54999923706055 +2024-10-31,66.91999816894531 +2024-11-30,64.29000091552734 +2024-12-31,64.08999633789062 +2025-01-31,69.08000183105469 +2025-02-28,71.0999984741211 +2025-03-31,74.16000366210938 +2025-04-30,79.08999633789062 +2025-05-31,79.48999786376953 +2025-06-30,79.98999786376953 +2025-07-31,82.19999694824219 +2025-08-31,85.33999633789062 +2025-09-30,95.8499984741211 +2025-10-31,100.0199966430664 +2025-11-30,104.62000274658203 +2025-12-31,114.0999984741211 diff --git a/indian_paper_trading_strategy/storage/history/NIFTYBEES.NS.csv b/indian_paper_trading_strategy/storage/history/NIFTYBEES.NS.csv new file mode 100644 index 0000000..326501a --- /dev/null +++ b/indian_paper_trading_strategy/storage/history/NIFTYBEES.NS.csv @@ -0,0 +1,122 @@ +Date,Close +2015-12-31,80.3010025024414 +2016-01-31,76.22899627685547 +2016-02-29,70.83799743652344 +2016-03-31,79.12699890136719 +2016-04-30,79.51699829101562 +2016-05-31,82.66799926757812 +2016-06-30,84.28800201416016 +2016-07-31,88.11100006103516 +2016-08-31,89.61699676513672 +2016-09-30,87.85800170898438 +2016-10-31,88.17900085449219 +2016-11-30,83.91799926757812 +2016-12-31,83.48899841308594 +2017-01-31,87.22799682617188 +2017-02-28,90.66500091552734 +2017-03-31,93.8270034790039 +2017-04-30,94.98799896240234 +2017-05-31,98.22200012207031 +2017-06-30,97.5979995727539 +2017-07-31,103.427001953125 +2017-08-31,102.15299987792969 +2017-09-30,100.99800109863281 +2017-10-31,106.51699829101562 +2017-11-30,105.74600219726562 +2017-12-31,108.447998046875 +2018-01-31,113.54399871826172 +2018-02-28,108.66999816894531 +2018-03-31,104.92500305175781 +2018-04-30,111.16799926757812 +2018-05-31,111.25 +2018-06-30,111.18800354003906 +2018-07-31,118.25 +2018-08-31,121.9000015258789 +2018-09-30,114.13999938964844 +2018-10-31,108.47599792480469 +2018-11-30,113.50800323486328 +2018-12-31,113.67500305175781 +2019-01-31,113.46900177001953 +2019-02-28,113.36000061035156 +2019-03-31,120.97699737548828 +2019-04-30,123.40699768066406 +2019-05-31,125.2490005493164 +2019-06-30,124.29199981689453 +2019-07-31,117.5770034790039 +2019-08-31,116.61399841308594 +2019-09-30,121.56700134277344 +2019-10-31,125.49800109863281 +2019-11-30,127.51300048828125 +2019-12-31,129.14999389648438 +2020-01-31,127.16999816894531 +2020-02-29,119.29000091552734 +2020-03-31,91.94000244140625 +2020-04-30,104.18000030517578 +2020-05-31,101.37999725341797 +2020-06-30,109.5199966430664 +2020-07-31,118.0199966430664 +2020-08-31,121.02999877929688 +2020-09-30,119.93000030517578 +2020-10-31,124.56999969482422 +2020-11-30,138.3800048828125 +2020-12-31,149.07000732421875 +2021-01-31,146.10000610351562 +2021-02-28,156.14999389648438 +2021-03-31,157.16000366210938 +2021-04-30,157.0 +2021-05-31,166.7899932861328 +2021-06-30,169.1300048828125 +2021-07-31,169.69000244140625 +2021-08-31,184.5500030517578 +2021-09-30,190.11000061035156 +2021-10-31,191.16000366210938 +2021-11-30,183.6999969482422 +2021-12-31,187.7899932861328 +2022-01-31,187.6300048828125 +2022-02-28,181.83999633789062 +2022-03-31,188.8699951171875 +2022-04-30,185.42999267578125 +2022-05-31,179.8699951171875 +2022-06-30,171.60000610351562 +2022-07-31,186.5399932861328 +2022-08-31,193.9199981689453 +2022-09-30,186.8300018310547 +2022-10-31,196.27000427246094 +2022-11-30,204.6999969482422 +2022-12-31,198.0399932861328 +2023-01-31,193.44000244140625 +2023-02-28,189.6999969482422 +2023-03-31,189.86000061035156 +2023-04-30,197.2899932861328 +2023-05-31,203.66000366210938 +2023-06-30,210.61000061035156 +2023-07-31,217.5800018310547 +2023-08-31,212.77000427246094 +2023-09-30,216.75 +2023-10-31,210.97999572753906 +2023-11-30,222.1300048828125 +2023-12-31,239.64999389648438 +2024-01-31,240.25999450683594 +2024-02-29,243.25 +2024-03-31,246.9600067138672 +2024-04-30,250.05999755859375 +2024-05-31,251.14999389648438 +2024-06-30,267.4800109863281 +2024-07-31,277.75 +2024-08-31,281.0799865722656 +2024-09-30,288.3800048828125 +2024-10-31,270.6400146484375 +2024-11-30,269.8800048828125 +2024-12-31,264.4700012207031 +2025-01-31,263.0 +2025-02-28,248.4499969482422 +2025-03-31,263.2099914550781 +2025-04-30,271.989990234375 +2025-05-31,277.9700012207031 +2025-06-30,286.510009765625 +2025-07-31,279.260009765625 +2025-08-31,276.05999755859375 +2025-09-30,277.5199890136719 +2025-10-31,291.0799865722656 +2025-11-30,296.489990234375 +2025-12-31,294.45001220703125 diff --git a/indian_paper_trading_strategy/storage/ledger.csv.bak b/indian_paper_trading_strategy/storage/ledger.csv.bak new file mode 100644 index 0000000..8b5b685 --- /dev/null +++ b/indian_paper_trading_strategy/storage/ledger.csv.bak @@ -0,0 +1,565 @@ +timestamp,event,nifty_units,gold_units,nifty_price,gold_price,amount +2026-01-01T05:58:03.476849,SIP_EXECUTED,12.680485106982822,11.335812442673163,295.7300109863281,110.2699966430664,5000.0 +2026-01-01T09:12:36.112648,DEBUG_ENTER_TRY_EXECUTE,2026-01-01T14:42:34.306960 +2026-01-01T09:12:36.126094,SIP_EXECUTED,12.692502437171184,11.32041315638739,295.45001220703125,110.41999816894531,5000.0 +2026-01-01T09:52:48.760352,DEBUG_ENTER_TRY_EXECUTE,2026-01-01T15:22:48.072786 +2026-01-01T09:52:48.764210,DEBUG_EXECUTION_DECISION,True,,2026-01-01T15:22:48.072786 +2026-01-01T09:52:48.785706,SIP_EXECUTED,12.695080449700082,11.304033091894532,295.3900146484375,110.58000183105469,5000.0 +2026-01-02T04:32:00.780979,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T10:02:00.431831 +2026-01-02T04:32:00.862153,DEBUG_EXECUTION_DECISION,True,,2026-01-02T10:02:00.431831 +2026-01-02T04:32:00.878460,SIP_EXECUTED,17.695834331967646,15.738825088363468,296.67999267578125,111.19000244140625,7000.0 +2026-01-02T05:13:28.371587,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T10:43:28.169205 +2026-01-02T05:13:28.382183,DEBUG_EXECUTION_DECISION,True,,2026-01-02T10:43:28.169205 +2026-01-02T05:13:28.394370,SIP_EXECUTED,12.636899747262005,11.24201792025962,296.75,111.19000244140625,5000.0 +2026-01-02T05:14:45.182100,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T10:44:44.902097 +2026-01-02T05:14:45.190620,DEBUG_EXECUTION_DECISION,True,,2026-01-02T10:44:44.902097 +2026-01-02T05:14:45.200479,SIP_EXECUTED,12.63647350248328,11.24808810306665,296.760009765625,111.12999725341797,5000.0 +2026-01-02T05:17:15.533201,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T10:47:15.253937 +2026-01-02T05:17:15.534401,DEBUG_EXECUTION_DECISION,False,2026-01-02T10:44:44.902097,2026-01-02T10:47:15.253937 +2026-01-02T05:17:15.546059,SIP_EXECUTED,12.635196239877473,11.24404084364895,296.7900085449219,111.16999816894531,5000.0 +2026-01-02T05:19:45.848301,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T10:49:45.609528 +2026-01-02T05:19:45.849882,DEBUG_EXECUTION_DECISION,False,2026-01-02T10:47:15.253937,2026-01-02T10:49:45.609528 +2026-01-02T05:19:45.864453,SIP_EXECUTED,12.633068475487395,11.24404084364895,296.8399963378906,111.16999816894531,5000.0 +2026-01-02T05:22:16.169776,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T10:52:15.920523 +2026-01-02T05:22:16.171153,DEBUG_EXECUTION_DECISION,False,2026-01-02T10:49:45.609528,2026-01-02T10:52:15.920523 +2026-01-02T05:22:16.182257,SIP_EXECUTED,12.632642489124033,11.236965336252677,296.8500061035156,111.23999786376953,5000.0 +2026-01-02T05:24:46.539256,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T10:54:46.241128 +2026-01-02T05:24:46.540682,DEBUG_EXECUTION_DECISION,False,2026-01-02T10:52:15.920523,2026-01-02T10:54:46.241128 +2026-01-02T05:24:46.555772,SIP_EXECUTED,12.633068475487395,11.229898728027536,296.8399963378906,111.30999755859375,5000.0 +2026-01-02T05:27:16.950164,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T10:57:16.609517 +2026-01-02T05:27:16.951433,DEBUG_EXECUTION_DECISION,False,2026-01-02T10:54:46.241128,2026-01-02T10:57:16.609517 +2026-01-02T05:27:16.965248,SIP_EXECUTED,12.632642489124033,11.231916521851678,296.8500061035156,111.29000091552734,5000.0 +2026-01-02T05:29:47.290169,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T10:59:47.029426 +2026-01-02T05:29:47.291663,DEBUG_EXECUTION_DECISION,False,2026-01-02T10:57:16.609517,2026-01-02T10:59:47.029426 +2026-01-02T05:29:47.307418,SIP_EXECUTED,12.631791901097017,11.231916521851678,296.8699951171875,111.29000091552734,5000.0 +2026-01-02T05:32:17.640707,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T11:02:17.367732 +2026-01-02T05:32:17.642525,DEBUG_EXECUTION_DECISION,False,2026-01-02T10:59:47.029426,2026-01-02T11:02:17.367732 +2026-01-02T05:32:17.661131,SIP_EXECUTED,12.62923952614863,11.230907149379977,296.92999267578125,111.30000305175781,5000.0 +2026-01-02T05:34:47.871879,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T11:04:47.726590 +2026-01-02T05:34:47.873422,DEBUG_EXECUTION_DECISION,False,2026-01-02T11:02:17.367732,2026-01-02T11:04:47.726590 +2026-01-02T05:34:47.887928,SIP_EXECUTED,12.630515584676825,4.209887994984313,296.8999938964844,296.9200134277344,5000.0 +2026-01-02T05:37:49.447179,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T11:07:49.194030 +2026-01-02T05:37:49.450689,DEBUG_EXECUTION_DECISION,True,,2026-01-02T11:07:49.194030 +2026-01-02T05:37:49.461281,SIP_EXECUTED,12.630089770459836,11.230907149379977,296.9100036621094,111.30000305175781,5000.0 +2026-01-02T05:37:59.197358,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T11:07:58.950032 +2026-01-02T05:37:59.199218,DEBUG_EXECUTION_DECISION,True,,2026-01-02T11:07:58.950032 +2026-01-02T05:37:59.209559,SIP_EXECUTED,12.630089770459836,11.229898728027536,296.9100036621094,111.30999755859375,5000.0 +2026-01-02T05:40:29.519965,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T11:10:29.266583 +2026-01-02T05:40:29.521195,DEBUG_EXECUTION_DECISION,False,2026-01-02T11:07:58.950032,2026-01-02T11:10:29.266583 +2026-01-02T05:40:29.534463,SIP_EXECUTED,12.640306840156798,11.229898728027536,296.6700134277344,111.30999755859375,5000.0 +2026-01-02T05:42:59.789448,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T11:12:59.581871 +2026-01-02T05:42:59.790893,DEBUG_EXECUTION_DECISION,False,2026-01-02T11:10:29.266583,2026-01-02T11:12:59.581871 +2026-01-02T05:42:59.806339,SIP_EXECUTED,12.634771409141003,11.229898728027536,296.79998779296875,111.30999755859375,5000.0 +2026-01-02T05:45:30.148210,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T11:15:29.873692 +2026-01-02T05:45:30.149482,DEBUG_EXECUTION_DECISION,False,2026-01-02T11:12:59.581871,2026-01-02T11:15:29.873692 +2026-01-02T05:45:31.939212,SIP_EXECUTED,12.630515584676825,11.229898728027536,296.8999938964844,111.30999755859375,5000.0 +2026-01-02T05:48:02.251839,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T11:18:01.987772 +2026-01-02T05:48:02.253162,DEBUG_EXECUTION_DECISION,False,2026-01-02T11:15:29.873692,2026-01-02T11:18:01.987772 +2026-01-02T05:48:02.266372,SIP_EXECUTED,12.630515584676825,11.230907149379977,296.8999938964844,111.30000305175781,5000.0 +2026-01-02T05:50:32.781667,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T11:20:32.524156 +2026-01-02T05:50:32.783021,DEBUG_EXECUTION_DECISION,False,2026-01-02T11:18:01.987772,2026-01-02T11:20:32.524156 +2026-01-02T05:50:32.797277,SIP_EXECUTED,12.628388098484162,11.228889718170905,296.95001220703125,111.31999969482422,5000.0 +2026-01-02T05:53:03.093498,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T11:23:02.852193 +2026-01-02T05:53:03.095076,DEBUG_EXECUTION_DECISION,False,2026-01-02T11:20:32.524156,2026-01-02T11:23:02.852193 +2026-01-02T05:53:03.115518,SIP_EXECUTED,12.627538083259335,11.225864545391198,296.9700012207031,111.3499984741211,5000.0 +2026-01-02T05:55:35.173087,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T11:25:34.906273 +2026-01-02T05:55:35.174639,DEBUG_EXECUTION_DECISION,False,2026-01-02T11:23:02.852193,2026-01-02T11:25:34.906273 +2026-01-02T05:55:35.188199,SIP_EXECUTED,12.627538083259335,11.223848156350726,296.9700012207031,111.37000274658203,5000.0 +2026-01-02T05:58:05.581589,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T11:28:05.247300 +2026-01-02T05:58:05.583163,DEBUG_EXECUTION_DECISION,False,2026-01-02T11:25:34.906273,2026-01-02T11:28:05.247300 +2026-01-02T05:58:05.599412,SIP_EXECUTED,12.626262626262626,11.224856260316914,297.0,111.36000061035156,5000.0 +2026-01-02T06:00:36.027342,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T11:30:35.671929 +2026-01-02T06:00:36.028705,DEBUG_EXECUTION_DECISION,False,2026-01-02T11:28:05.247300,2026-01-02T11:30:35.671929 +2026-01-02T06:00:36.044440,SIP_EXECUTED,12.626262626262626,11.22687301162239,297.0,111.33999633789062,5000.0 +2026-01-02T06:03:06.408278,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T11:33:06.101772 +2026-01-02T06:03:06.410821,DEBUG_EXECUTION_DECISION,False,2026-01-02T11:30:35.671929,2026-01-02T11:33:06.101772 +2026-01-02T06:03:06.427684,SIP_EXECUTED,12.626262626262626,11.22687301162239,297.0,111.33999633789062,5000.0 +2026-01-02T06:26:35.161177,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T11:56:34.868541 +2026-01-02T06:26:35.166307,DEBUG_EXECUTION_DECISION,True,,2026-01-02T11:56:34.868541 +2026-01-02T06:26:35.190868,SIP_EXECUTED,12.61309754395646,11.227880889617678,297.30999755859375,111.33000183105469,5000.0 +2026-01-02T06:29:05.546031,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T11:59:05.227324 +2026-01-02T06:29:05.547409,DEBUG_EXECUTION_DECISION,False,2026-01-02T11:56:34.868541,2026-01-02T11:59:05.227324 +2026-01-02T06:29:05.573435,SIP_EXECUTED,12.615643397813288,11.227880889617678,297.25,111.33000183105469,5000.0 +2026-01-02T06:31:36.123447,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:01:35.709182 +2026-01-02T06:31:36.124647,DEBUG_EXECUTION_DECISION,False,2026-01-02T11:59:05.227324,2026-01-02T12:01:35.709182 +2026-01-02T06:31:36.150934,SIP_EXECUTED,12.61861529181472,11.228889718170905,297.17999267578125,111.31999969482422,5000.0 +2026-01-02T06:34:06.331380,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:04:06.192255 +2026-01-02T06:34:06.332732,DEBUG_EXECUTION_DECISION,False,2026-01-02T12:01:35.709182,2026-01-02T12:04:06.192255 +2026-01-02T06:34:08.079260,SIP_EXECUTED,12.618190279598476,11.228889718170905,297.19000244140625,111.31999969482422,5000.0 +2026-01-02T06:36:38.523647,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:06:38.125235 +2026-01-02T06:36:38.524980,DEBUG_EXECUTION_DECISION,False,2026-01-02T12:04:06.192255,2026-01-02T12:06:38.125235 +2026-01-02T06:36:38.560698,SIP_EXECUTED,12.614370342432728,11.227880889617678,297.2799987792969,111.33000183105469,5000.0 +2026-01-02T06:39:08.891753,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:09:08.608700 +2026-01-02T06:39:08.893090,DEBUG_EXECUTION_DECISION,False,2026-01-02T12:06:38.125235,2026-01-02T12:09:08.608700 +2026-01-02T06:39:08.919794,SIP_EXECUTED,12.613945616114972,11.22687301162239,297.2900085449219,111.33999633789062,5000.0 +2026-01-02T06:41:39.672495,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:11:38.965816 +2026-01-02T06:41:39.673763,DEBUG_EXECUTION_DECISION,False,2026-01-02T12:09:08.608700,2026-01-02T12:11:38.965816 +2026-01-02T06:41:39.698935,SIP_EXECUTED,12.614795097353511,11.230907149379977,297.2699890136719,111.30000305175781,5000.0 +2026-01-02T06:44:10.070501,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:14:09.744877 +2026-01-02T06:44:10.071848,DEBUG_EXECUTION_DECISION,False,2026-01-02T12:11:38.965816,2026-01-02T12:14:09.744877 +2026-01-02T06:44:10.093468,SIP_EXECUTED,12.61309754395646,11.231916521851678,297.30999755859375,111.29000091552734,5000.0 +2026-01-02T06:46:40.576767,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:16:40.141259 +2026-01-02T06:46:40.578178,DEBUG_EXECUTION_DECISION,False,2026-01-02T12:14:09.744877,2026-01-02T12:16:40.141259 +2026-01-02T06:46:40.602665,SIP_EXECUTED,12.613945616114972,11.231916521851678,297.2900085449219,111.29000091552734,5000.0 +2026-01-02T06:49:10.983710,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:19:10.649167 +2026-01-02T06:49:10.985361,DEBUG_EXECUTION_DECISION,False,2026-01-02T12:16:40.141259,2026-01-02T12:19:10.649167 +2026-01-02T06:49:11.013758,SIP_EXECUTED,12.61606823847326,11.230907149379977,297.239990234375,111.30000305175781,5000.0 +2026-01-02T06:51:41.373876,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:21:41.098025 +2026-01-02T06:52:11.790140,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:22:11.407495 +2026-01-02T06:52:42.118673,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:22:41.821207 +2026-01-02T06:53:12.335393,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:23:12.151146 +2026-01-02T06:53:42.620830,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:23:42.355737 +2026-01-02T06:54:12.966364,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:24:12.654653 +2026-01-02T06:54:43.174257,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:24:42.987309 +2026-01-02T06:55:13.406894,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:25:13.209279 +2026-01-02T06:55:43.865567,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:25:43.444555 +2026-01-02T06:56:14.169655,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:26:13.911016 +2026-01-02T06:56:44.634522,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:26:44.190515 +2026-01-02T06:57:14.871879,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:27:14.683277 +2026-01-02T06:57:45.159095,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:27:44.897628 +2026-01-02T06:58:15.419866,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:28:15.194239 +2026-01-02T06:58:45.754988,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:28:45.442942 +2026-01-02T06:59:16.044665,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:29:15.790301 +2026-01-02T06:59:46.317902,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:29:46.068587 +2026-01-02T07:00:16.503189,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:30:16.369723 +2026-01-02T07:00:46.778131,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:30:46.524842 +2026-01-02T07:01:17.131399,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:31:16.812978 +2026-01-02T07:01:47.466590,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:31:47.153432 +2026-01-02T07:02:17.772282,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:32:17.501504 +2026-01-02T07:02:48.160836,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:32:47.794088 +2026-01-02T07:03:18.689219,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:33:18.218594 +2026-01-02T07:03:49.174720,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:33:48.713810 +2026-01-02T07:04:19.392362,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:34:19.212162 +2026-01-02T07:04:49.754539,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:34:49.425794 +2026-01-02T07:05:20.037005,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:35:19.804851 +2026-01-02T07:05:50.227435,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:35:50.060712 +2026-01-02T07:06:20.621828,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:36:20.264642 +2026-01-02T07:06:50.925277,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:36:50.648097 +2026-01-02T07:07:21.229912,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:37:20.960328 +2026-01-02T07:07:51.393719,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:37:51.255164 +2026-01-02T07:08:21.763821,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:38:21.564796 +2026-01-02T07:08:52.117993,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:38:51.788257 +2026-01-02T07:09:22.442868,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:39:22.172548 +2026-01-02T07:09:52.759654,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:39:52.466182 +2026-01-02T07:10:23.186163,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:40:22.799263 +2026-01-02T07:10:53.435155,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:40:53.210715 +2026-01-02T07:11:23.678565,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:41:23.483857 +2026-01-02T07:11:54.171460,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:41:53.705566 +2026-01-02T07:12:24.493696,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:42:24.234953 +2026-01-02T07:12:54.774296,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:42:54.525260 +2026-01-02T07:13:25.102794,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:43:24.825793 +2026-01-02T07:13:55.521540,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:43:55.139089 +2026-01-02T07:14:25.859124,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:44:25.574597 +2026-01-02T07:14:56.245862,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:44:55.890082 +2026-01-02T07:15:26.698632,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:45:26.319891 +2026-01-02T07:15:56.943886,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:45:56.726904 +2026-01-02T07:16:27.390504,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:46:27.139781 +2026-01-02T07:16:57.763934,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:46:57.446567 +2026-01-02T07:17:28.177361,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:47:27.831689 +2026-01-02T07:17:58.546708,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:47:58.203855 +2026-01-02T07:18:29.107084,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:48:28.600134 +2026-01-02T07:18:59.476645,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:48:59.146827 +2026-01-02T07:19:29.862600,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:49:29.522965 +2026-01-02T07:20:00.486506,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:49:59.891571 +2026-01-02T07:20:31.022694,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:50:30.533620 +2026-01-02T07:21:01.362285,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:51:01.055094 +2026-01-02T07:21:31.742290,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:51:31.410211 +2026-01-02T07:22:02.258084,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:52:01.784392 +2026-01-02T07:22:32.650896,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:52:32.293440 +2026-01-02T07:23:03.138776,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:53:02.685434 +2026-01-02T07:23:33.441563,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:53:33.174619 +2026-01-02T07:24:03.857707,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:54:03.504691 +2026-01-02T07:24:34.291862,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:54:33.887700 +2026-01-02T07:25:04.553563,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:55:04.315279 +2026-01-02T07:25:34.923003,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:55:34.674190 +2026-01-02T07:26:05.260020,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:56:04.947055 +2026-01-02T07:26:35.600635,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:56:35.288578 +2026-01-02T07:27:06.027215,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:57:05.744054 +2026-01-02T07:27:36.398120,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:57:36.057056 +2026-01-02T07:28:06.928343,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:58:06.598661 +2026-01-02T07:28:37.215033,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:58:36.958288 +2026-01-02T07:29:07.610609,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:59:07.257368 +2026-01-02T07:29:38.007012,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T12:59:37.638780 +2026-01-02T07:30:08.387299,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T13:00:08.051545 +2026-01-02T07:30:39.223424,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T13:00:38.823157 +2026-01-02T07:31:09.557524,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T13:01:09.271203 +2026-01-02T07:31:39.926100,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T13:01:39.598421 +2026-01-02T07:32:10.377919,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T13:02:09.978735 +2026-01-02T07:32:40.736737,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T13:02:40.412221 +2026-01-02T07:33:11.011490,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T13:03:10.763256 +2026-01-02T07:33:41.304918,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T13:03:41.048147 +2026-01-02T07:34:11.668584,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T13:04:11.352171 +2026-01-02T07:34:41.976797,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T13:04:41.704478 +2026-01-02T07:35:12.358022,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T13:05:12.026707 +2026-01-02T07:35:42.769252,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T13:05:42.392460 +2026-01-02T07:36:13.192041,DEBUG_ENTER_TRY_EXECUTE,2026-01-02T13:06:12.917031 +2026-01-07T04:27:43.410112,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T09:57:41.269722 +2026-01-07T04:27:43.852337,DEBUG_EXECUTION_DECISION,True,,2026-01-07T09:57:41.269722 +2026-01-07T04:27:43.915086,SIP_EXECUTED,12.691213823508058,11.046305757752034,295.4800109863281,113.16000366210938,5000.0 +2026-01-07T04:38:14.823897,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T10:08:14.318325 +2026-01-07T04:38:14.825782,DEBUG_EXECUTION_DECISION,False,2026-01-07T09:57:41.269722,2026-01-07T10:08:14.318325 +2026-01-07T04:38:14.844313,SIP_EXECUTED,12.682630184288277,11.050211895771628,295.67999267578125,113.12000274658203,5000.0 +2026-01-07T04:48:45.795744,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T10:18:45.352278 +2026-01-07T04:48:45.799100,DEBUG_EXECUTION_DECISION,False,2026-01-07T10:08:14.318325,2026-01-07T10:18:45.352278 +2026-01-07T04:48:45.837197,SIP_EXECUTED,12.66891891891892,11.05412079730545,296.0,113.08000183105469,5000.0 +2026-01-07T04:59:18.402470,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T10:29:17.772055 +2026-01-07T04:59:18.417897,DEBUG_EXECUTION_DECISION,False,2026-01-07T10:18:45.352278,2026-01-07T10:29:17.772055 +2026-01-07T04:59:18.444827,SIP_EXECUTED,12.690355329949238,11.066844035177757,295.5,112.94999694824219,5000.0 +2026-01-07T05:09:49.045622,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T10:39:48.603141 +2026-01-07T05:09:49.070372,DEBUG_EXECUTION_DECISION,False,2026-01-07T10:29:17.772055,2026-01-07T10:39:48.603141 +2026-01-07T05:09:49.096436,SIP_EXECUTED,12.688208949028466,11.057054102225111,295.54998779296875,113.05000305175781,5000.0 +2026-01-07T05:20:19.685590,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T10:50:19.249841 +2026-01-07T05:20:19.708210,DEBUG_EXECUTION_DECISION,False,2026-01-07T10:39:48.603141,2026-01-07T10:50:19.249841 +2026-01-07T05:20:19.732297,SIP_EXECUTED,12.683487632928637,11.043378152121456,295.6600036621094,113.19000244140625,5000.0 +2026-01-07T05:30:50.511328,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T11:00:49.884030 +2026-01-07T05:30:50.526937,DEBUG_EXECUTION_DECISION,False,2026-01-07T10:50:19.249841,2026-01-07T11:00:49.884030 +2026-01-07T05:30:50.551788,SIP_EXECUTED,12.673200666951937,11.042403124547173,295.8999938964844,113.19999694824219,5000.0 +2026-01-07T05:41:20.968460,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T11:11:20.698065 +2026-01-07T05:41:20.985144,DEBUG_EXECUTION_DECISION,False,2026-01-07T11:00:49.884030,2026-01-07T11:11:20.698065 +2026-01-07T05:41:21.146260,SIP_EXECUTED,12.671487359139526,11.074687424315252,295.94000244140625,112.87000274658203,5000.0 +2026-01-07T05:51:53.585313,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T11:21:51.313563 +2026-01-07T05:51:53.602346,DEBUG_EXECUTION_DECISION,False,2026-01-07T11:11:20.698065,2026-01-07T11:21:51.313563 +2026-01-07T05:51:53.625518,SIP_EXECUTED,12.685633726276894,11.087458077748499,295.6099853515625,112.73999786376953,5000.0 +2026-01-07T06:02:24.232218,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T11:32:23.785980 +2026-01-07T06:02:24.254325,DEBUG_EXECUTION_DECISION,False,2026-01-07T11:21:51.313563,2026-01-07T11:32:23.785980 +2026-01-07T06:02:24.278381,SIP_EXECUTED,12.684774678018549,11.0795958463147,295.6300048828125,112.81999969482422,5000.0 +2026-01-07T06:12:54.723593,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T11:42:54.450207 +2026-01-07T06:12:54.745226,DEBUG_EXECUTION_DECISION,False,2026-01-07T11:32:23.785980,2026-01-07T11:42:54.450207 +2026-01-07T06:12:54.773565,SIP_EXECUTED,12.68177154275691,11.075668910508135,295.70001220703125,112.86000061035156,5000.0 +2026-01-07T06:23:25.229770,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T11:53:24.970738 +2026-01-07T06:23:25.248930,DEBUG_EXECUTION_DECISION,False,2026-01-07T11:42:54.450207,2026-01-07T11:53:24.970738 +2026-01-07T06:23:25.278476,SIP_EXECUTED,12.690355329949238,11.072725722014841,295.5,112.88999938964844,5000.0 +2026-01-07T06:33:59.593423,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T12:03:58.936167 +2026-01-07T06:33:59.653147,DEBUG_EXECUTION_DECISION,False,2026-01-07T11:53:24.970738,2026-01-07T12:03:58.936167 +2026-01-07T06:33:59.684138,SIP_EXECUTED,12.692502437171184,11.094346501414353,295.45001220703125,112.66999816894531,5000.0 +2026-01-07T06:44:30.282688,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T12:14:29.864185 +2026-01-07T06:44:32.021721,DEBUG_EXECUTION_DECISION,False,2026-01-07T12:03:58.936167,2026-01-07T12:14:29.864185 +2026-01-07T06:44:32.046363,SIP_EXECUTED,12.693791312541947,11.094346501414353,295.4200134277344,112.66999816894531,5000.0 +2026-01-07T06:55:02.637614,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T12:25:02.274159 +2026-01-07T06:55:02.656508,DEBUG_EXECUTION_DECISION,False,2026-01-07T12:14:29.864185,2026-01-07T12:25:02.274159 +2026-01-07T06:55:02.690725,SIP_EXECUTED,12.695510657492727,11.102229688760808,295.3800048828125,112.58999633789062,5000.0 +2026-01-07T07:05:39.591389,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T12:35:37.448636 +2026-01-07T07:05:39.608519,DEBUG_EXECUTION_DECISION,False,2026-01-07T12:25:02.274159,2026-01-07T12:35:37.448636 +2026-01-07T07:05:39.632513,SIP_EXECUTED,12.697230468269272,11.098286695217569,295.3399963378906,112.62999725341797,5000.0 +2026-01-07T07:16:10.323455,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T12:46:09.830294 +2026-01-07T07:16:10.340861,DEBUG_EXECUTION_DECISION,False,2026-01-07T12:35:37.448636,2026-01-07T12:46:09.830294 +2026-01-07T07:16:10.366530,SIP_EXECUTED,12.70712678709482,11.091393379310084,295.1099853515625,112.69999694824219,5000.0 +2026-01-07T07:26:40.923012,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T12:56:40.579703 +2026-01-07T07:26:40.943008,DEBUG_EXECUTION_DECISION,False,2026-01-07T12:46:09.830294,2026-01-07T12:56:40.579703 +2026-01-07T07:26:40.974290,SIP_EXECUTED,12.701100762066046,11.076650570683451,295.25,112.8499984741211,5000.0 +2026-01-07T07:37:11.472478,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:07:11.183080 +2026-01-07T07:37:13.284403,DEBUG_EXECUTION_DECISION,False,2026-01-07T12:56:40.579703,2026-01-07T13:07:11.183080 +2026-01-07T07:37:13.315846,SIP_EXECUTED,12.694221432965616,11.068803653786144,295.4100036621094,112.93000030517578,5000.0 +2026-01-07T07:47:43.815060,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:17:43.535913 +2026-01-07T07:47:45.558607,DEBUG_EXECUTION_DECISION,False,2026-01-07T13:07:11.183080,2026-01-07T13:17:43.535913 +2026-01-07T07:47:45.580616,SIP_EXECUTED,12.6916437692736,11.063904787211952,295.4700012207031,112.9800033569336,5000.0 +2026-01-07T07:58:16.115113,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:28:15.804225 +2026-01-07T07:58:46.424701,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:28:46.174050 +2026-01-07T07:59:18.534483,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:29:16.469860 +2026-01-07T07:59:48.861594,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:29:48.570905 +2026-01-07T08:00:19.288717,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:30:19.014374 +2026-01-07T08:00:49.676268,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:30:49.328975 +2026-01-07T08:01:21.724369,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:31:21.445475 +2026-01-07T08:01:52.073009,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:31:51.760682 +2026-01-07T08:02:22.408611,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:32:22.138983 +2026-01-07T08:02:52.746034,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:32:52.445855 +2026-01-07T08:03:23.390341,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:33:22.799139 +2026-01-07T08:03:54.079204,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:33:53.430586 +2026-01-07T08:04:24.422263,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:34:24.136738 +2026-01-07T08:04:54.744168,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:34:54.457071 +2026-01-07T08:05:25.108712,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:35:24.805661 +2026-01-07T08:05:55.408949,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:35:55.144071 +2026-01-07T08:06:25.724394,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:36:25.462751 +2026-01-07T08:06:56.104705,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:36:55.759933 +2026-01-07T08:07:26.435872,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:37:26.155692 +2026-01-07T08:07:56.744751,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:37:56.480207 +2026-01-07T08:08:27.118561,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:38:26.797479 +2026-01-07T08:08:57.674969,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:38:57.171264 +2026-01-07T08:09:28.018561,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:39:27.726367 +2026-01-07T08:09:58.350496,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:39:58.054571 +2026-01-07T08:10:28.693027,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:40:28.408853 +2026-01-07T08:10:59.009310,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:40:58.733310 +2026-01-07T08:11:29.319198,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:41:29.060958 +2026-01-07T08:11:59.730676,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:41:59.356325 +2026-01-07T08:12:30.362071,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:42:29.780721 +2026-01-07T08:13:00.755914,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:43:00.401318 +2026-01-07T08:13:31.093855,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:43:30.819173 +2026-01-07T08:14:01.406035,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:44:01.132816 +2026-01-07T08:14:31.841933,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:44:31.464296 +2026-01-07T08:15:02.161215,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:45:01.880764 +2026-01-07T08:15:32.520193,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:45:32.227531 +2026-01-07T08:16:02.822837,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:46:02.562851 +2026-01-07T08:16:33.366141,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:46:32.874561 +2026-01-07T08:17:04.011011,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:47:03.404636 +2026-01-07T08:17:34.678585,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:47:34.151609 +2026-01-07T08:18:04.974493,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:48:04.716494 +2026-01-07T08:18:35.684084,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:48:35.029121 +2026-01-07T08:19:06.240150,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:49:05.725947 +2026-01-07T08:19:36.648678,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:49:36.291371 +2026-01-07T08:20:07.024704,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:50:06.691356 +2026-01-07T08:20:37.556485,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:50:37.077851 +2026-01-07T08:21:07.993128,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:51:07.595444 +2026-01-07T08:21:38.324194,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:51:38.046784 +2026-01-07T08:22:08.629739,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:52:08.371315 +2026-01-07T08:22:39.243840,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:52:38.767765 +2026-01-07T08:23:09.757149,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:53:09.282615 +2026-01-07T08:23:40.215913,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:53:39.919640 +2026-01-07T08:24:10.528558,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:54:10.254210 +2026-01-07T08:24:40.901119,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:54:40.583353 +2026-01-07T08:25:11.199803,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:55:10.945357 +2026-01-07T08:25:41.659920,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:55:41.348222 +2026-01-07T08:26:13.844706,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:56:13.428835 +2026-01-07T08:26:44.264391,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:56:43.908736 +2026-01-07T08:27:14.612764,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:57:14.306402 +2026-01-07T08:27:45.354303,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:57:44.764489 +2026-01-07T08:28:15.994169,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:58:15.399751 +2026-01-07T08:28:46.630481,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:58:46.047817 +2026-01-07T08:29:16.961290,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:59:16.671282 +2026-01-07T08:29:47.441184,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T13:59:47.150324 +2026-01-07T08:30:17.785286,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:00:17.491260 +2026-01-07T08:30:48.156252,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:00:47.840295 +2026-01-07T08:31:18.484226,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:01:18.198936 +2026-01-07T08:31:48.838503,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:01:48.539878 +2026-01-07T08:32:19.157723,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:02:18.882952 +2026-01-07T08:32:49.480273,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:02:49.212114 +2026-01-07T08:33:19.855629,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:03:19.522932 +2026-01-07T08:33:50.183652,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:03:49.919795 +2026-01-07T08:34:20.962486,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:04:20.225359 +2026-01-07T08:34:51.409359,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:04:51.027480 +2026-01-07T08:35:21.735025,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:05:21.450670 +2026-01-07T08:35:52.058188,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:05:51.789860 +2026-01-07T08:36:22.400106,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:06:22.099387 +2026-01-07T08:36:52.759345,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:06:52.455844 +2026-01-07T08:37:23.126355,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:07:22.802439 +2026-01-07T08:37:53.479056,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:07:53.181533 +2026-01-07T08:38:23.987404,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:08:23.528039 +2026-01-07T08:38:54.353985,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:08:54.044139 +2026-01-07T08:39:24.711718,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:09:24.402128 +2026-01-07T08:39:55.053816,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:09:54.770011 +2026-01-07T08:40:25.385180,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:10:25.095913 +2026-01-07T08:40:55.810395,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:10:55.440501 +2026-01-07T08:41:26.183877,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:11:25.856725 +2026-01-07T08:41:56.537924,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:11:56.239743 +2026-01-07T08:42:26.944636,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:12:26.589402 +2026-01-07T08:42:57.274606,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:12:57.000722 +2026-01-07T08:43:27.597799,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:13:27.316210 +2026-01-07T08:43:57.931465,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:13:57.661041 +2026-01-07T08:44:28.312223,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:14:27.972810 +2026-01-07T08:44:58.625759,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:14:58.368307 +2026-01-07T08:45:28.979282,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:15:28.671262 +2026-01-07T08:45:59.417157,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:15:59.041164 +2026-01-07T08:46:29.826278,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:16:29.460997 +2026-01-07T08:47:00.151270,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:16:59.891626 +2026-01-07T08:47:30.484212,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:17:30.194911 +2026-01-07T08:48:00.844502,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:18:00.552045 +2026-01-07T08:48:31.140554,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:18:30.888290 +2026-01-07T08:49:01.471630,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:19:01.199970 +2026-01-07T08:49:31.799281,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:19:31.514720 +2026-01-07T08:50:04.208560,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:20:03.615125 +2026-01-07T08:50:34.559800,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:20:34.253256 +2026-01-07T08:51:06.600006,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:21:06.334980 +2026-01-07T08:51:36.981380,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:21:36.644931 +2026-01-07T08:52:09.184249,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:22:08.831730 +2026-01-07T08:52:39.531825,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:22:39.227037 +2026-01-07T08:53:09.859882,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:23:09.589167 +2026-01-07T08:53:40.188253,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:23:39.905935 +2026-01-07T08:54:10.543299,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:24:10.256479 +2026-01-07T08:54:40.916994,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:24:40.591786 +2026-01-07T08:55:11.248214,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:25:10.983371 +2026-01-07T08:55:41.566688,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:25:41.291380 +2026-01-07T08:56:11.908030,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:26:11.625105 +2026-01-07T08:56:42.201972,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:26:41.950612 +2026-01-07T08:57:12.521573,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:27:12.260099 +2026-01-07T08:57:42.871067,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:27:42.566689 +2026-01-07T08:58:15.152919,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:28:14.645086 +2026-01-07T08:58:45.711497,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:28:45.201672 +2026-01-07T08:59:16.612523,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:29:16.095113 +2026-01-07T08:59:47.237259,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:29:46.664787 +2026-01-07T09:00:17.580568,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:30:17.295333 +2026-01-07T09:00:47.996846,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:30:47.639789 +2026-01-07T09:01:18.656240,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:31:18.055990 +2026-01-07T09:01:49.195849,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:31:48.704628 +2026-01-07T09:02:19.626526,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:32:19.262928 +2026-01-07T09:02:50.017395,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:32:49.673335 +2026-01-07T09:03:20.446662,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:33:20.086593 +2026-01-07T09:03:50.779637,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:33:50.496507 +2026-01-07T09:04:21.133853,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:34:20.844466 +2026-01-07T09:04:51.464747,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:34:51.186579 +2026-01-07T09:05:21.839527,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:35:21.534288 +2026-01-07T09:05:52.179462,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:35:51.886434 +2026-01-07T09:06:22.773013,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:36:22.244837 +2026-01-07T09:06:53.449669,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:36:52.822286 +2026-01-07T09:07:24.002988,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:37:23.515253 +2026-01-07T09:07:54.579479,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:37:54.052938 +2026-01-07T09:08:24.930486,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:38:24.637657 +2026-01-07T09:08:55.323677,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:38:54.982773 +2026-01-07T09:09:27.544804,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:39:27.112878 +2026-01-07T09:09:57.938271,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:39:57.592552 +2026-01-07T09:10:28.291105,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:40:27.998406 +2026-01-07T09:10:58.658052,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:40:58.339090 +2026-01-07T09:11:29.081308,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:41:28.726271 +2026-01-07T09:11:59.786360,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:41:59.128578 +2026-01-07T09:12:30.130825,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:42:29.850113 +2026-01-07T09:13:00.462803,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:43:00.177193 +2026-01-07T09:13:30.885079,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:43:30.524942 +2026-01-07T09:14:01.222388,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:44:00.937083 +2026-01-07T09:14:31.575419,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:44:31.285756 +2026-01-07T09:15:01.913234,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:45:01.625844 +2026-01-07T09:15:32.460794,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:45:32.072191 +2026-01-07T09:16:02.813897,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:46:02.521637 +2026-01-07T09:16:33.183106,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:46:32.874048 +2026-01-07T09:17:03.708554,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:47:03.235566 +2026-01-07T09:17:34.106918,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:47:33.770560 +2026-01-07T09:18:04.446300,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:48:04.155842 +2026-01-07T09:18:34.828131,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:48:34.508970 +2026-01-07T09:19:05.179299,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:49:04.882488 +2026-01-07T09:19:35.727847,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:49:35.341689 +2026-01-07T09:20:06.267859,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:50:05.781907 +2026-01-07T09:20:36.629931,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:50:36.329453 +2026-01-07T09:21:06.978332,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:51:06.677249 +2026-01-07T09:21:37.419406,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:51:37.047947 +2026-01-07T09:22:07.790143,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:52:07.465936 +2026-01-07T09:22:39.950401,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:52:39.568251 +2026-01-07T09:23:10.277009,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:53:10.005315 +2026-01-07T09:23:40.687636,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:53:40.339480 +2026-01-07T09:24:13.101719,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:54:12.808714 +2026-01-07T09:24:43.561077,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:54:43.163907 +2026-01-07T09:25:13.886679,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:55:13.613283 +2026-01-07T09:25:44.237917,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:55:43.949576 +2026-01-07T09:26:14.601706,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:56:14.289832 +2026-01-07T09:26:45.098096,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:56:44.674862 +2026-01-07T09:27:15.552523,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:57:15.149128 +2026-01-07T09:27:45.988996,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:57:45.627468 +2026-01-07T09:28:16.409822,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:58:16.039488 +2026-01-07T09:28:46.763459,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:58:46.481463 +2026-01-07T09:29:17.208394,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:59:16.813767 +2026-01-07T09:29:47.664059,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T14:59:47.276893 +2026-01-07T09:30:18.203187,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:00:17.717498 +2026-01-07T09:30:48.554331,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:00:48.278536 +2026-01-07T09:31:18.888211,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:01:18.607727 +2026-01-07T09:31:49.320761,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:01:48.967801 +2026-01-07T09:32:19.685527,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:02:19.374123 +2026-01-07T09:32:50.066756,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:02:49.763777 +2026-01-07T09:33:20.546026,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:03:20.117121 +2026-01-07T09:33:50.924119,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:03:50.629608 +2026-01-07T09:34:21.286143,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:04:20.975939 +2026-01-07T09:34:51.838758,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:04:51.457034 +2026-01-07T09:35:22.173581,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:05:21.893830 +2026-01-07T09:35:52.658003,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:05:52.244543 +2026-01-07T09:36:23.126948,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:06:22.724023 +2026-01-07T09:36:53.563709,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:06:53.190970 +2026-01-07T09:37:23.960895,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:07:23.620579 +2026-01-07T09:37:54.483631,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:07:54.028722 +2026-01-07T09:38:24.830000,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:08:24.541359 +2026-01-07T09:38:55.172794,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:08:54.897511 +2026-01-07T09:39:25.623513,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:09:25.224905 +2026-01-07T09:39:55.995074,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:09:55.692820 +2026-01-07T09:40:26.348931,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:10:26.051498 +2026-01-07T09:40:58.478400,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:10:58.125973 +2026-01-07T09:41:28.966832,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:11:28.535212 +2026-01-07T09:41:59.333924,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:11:59.041966 +2026-01-07T09:42:29.698713,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:12:29.388103 +2026-01-07T09:43:00.063643,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:12:59.764519 +2026-01-07T09:43:30.406166,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:13:30.120891 +2026-01-07T09:44:00.847255,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:14:00.473162 +2026-01-07T09:44:31.192335,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:14:30.901099 +2026-01-07T09:45:03.278609,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:15:03.006102 +2026-01-07T09:45:33.637299,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:15:33.331576 +2026-01-07T09:46:05.848385,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:16:05.428889 +2026-01-07T09:46:36.210606,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:16:35.900065 +2026-01-07T09:47:06.754198,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:17:06.282627 +2026-01-07T09:47:37.213471,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:17:36.816809 +2026-01-07T09:48:07.582148,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:18:07.287780 +2026-01-07T09:48:39.763035,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:18:37.639829 +2026-01-07T09:49:10.127931,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:19:09.832361 +2026-01-07T09:49:40.547938,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:19:40.190542 +2026-01-07T09:50:12.680556,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:20:12.345333 +2026-01-07T09:50:43.104788,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:20:42.737711 +2026-01-07T09:51:13.588820,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:21:13.174757 +2026-01-07T09:51:44.013397,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:21:43.644751 +2026-01-07T09:52:14.455854,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:22:14.085635 +2026-01-07T09:52:44.928155,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:22:44.509217 +2026-01-07T09:53:15.303625,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:23:14.998271 +2026-01-07T09:53:45.675126,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:23:45.357485 +2026-01-07T09:54:16.052375,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:24:15.741719 +2026-01-07T09:54:46.408420,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:24:46.109045 +2026-01-07T09:55:16.961849,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:25:16.480650 +2026-01-07T09:55:47.344389,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:25:47.025424 +2026-01-07T09:56:17.759007,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:26:17.416841 +2026-01-07T09:56:48.136219,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:26:47.822932 +2026-01-07T09:57:18.610951,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:27:18.205874 +2026-01-07T09:57:48.967373,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:27:48.665471 +2026-01-07T09:58:19.394248,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:28:19.033762 +2026-01-07T09:58:50.761482,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:28:50.460259 +2026-01-07T09:59:22.973616,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:29:22.546578 +2026-01-07T09:59:53.354210,DEBUG_ENTER_TRY_EXECUTE,2026-01-07T15:29:53.034538 +2026-01-08T04:21:16.478860,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T09:51:15.868927 +2026-01-08T04:21:16.501765,DEBUG_EXECUTION_DECISION,True,,2026-01-08T09:51:15.868927 +2026-01-08T04:21:16.568167,SIP_EXECUTED,12.714881813062679,11.151753419920963,294.92999267578125,112.08999633789062,5000.0 +2026-01-08T04:31:47.877937,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:01:47.425004 +2026-01-08T04:31:47.881271,DEBUG_EXECUTION_DECISION,False,2026-01-08T09:51:15.868927,2026-01-08T10:01:47.425004 +2026-01-08T04:31:47.924244,SIP_EXECUTED,12.714450291445248,11.163704442015103,294.94000244140625,111.97000122070312,5000.0 +2026-01-08T04:42:19.203165,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:12:18.648418 +2026-01-08T04:42:19.205778,DEBUG_EXECUTION_DECISION,False,2026-01-08T10:01:47.425004,2026-01-08T10:12:18.648418 +2026-01-08T04:42:19.331085,SIP_EXECUTED,12.718762876738234,11.158721991243427,294.8399963378906,112.0199966430664,5000.0 +2026-01-08T04:52:50.689765,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:22:50.153371 +2026-01-08T04:52:50.692344,DEBUG_EXECUTION_DECISION,False,2026-01-08T10:12:18.648418,2026-01-08T10:22:50.153371 +2026-01-08T04:53:21.157233,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:23:20.809163 +2026-01-08T04:53:51.573210,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:23:51.255571 +2026-01-08T04:54:22.308438,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:24:21.655062 +2026-01-08T04:54:52.754357,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:24:52.394323 +2026-01-08T04:55:23.242880,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:25:22.876929 +2026-01-08T04:55:53.680341,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:25:53.334280 +2026-01-08T04:56:24.181128,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:26:23.769018 +2026-01-08T04:56:54.624604,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:26:54.292088 +2026-01-08T04:57:25.085322,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:27:24.707345 +2026-01-08T04:57:55.613189,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:27:55.250555 +2026-01-08T04:58:26.034367,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:28:25.702622 +2026-01-08T04:58:56.555372,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:28:56.119652 +2026-01-08T04:59:26.999989,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:29:26.669249 +2026-01-08T04:59:57.527945,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:29:57.087547 +2026-01-08T05:00:28.120949,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:30:27.650938 +2026-01-08T05:00:58.743757,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:30:58.206543 +2026-01-08T05:01:29.184022,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:31:28.823369 +2026-01-08T05:01:59.687323,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:31:59.273525 +2026-01-08T05:02:30.107041,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:32:29.793719 +2026-01-08T05:03:00.543088,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:33:00.197591 +2026-01-08T05:03:30.965546,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:33:30.635982 +2026-01-08T05:04:01.504710,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:34:01.047466 +2026-01-08T05:04:31.936220,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:34:31.595331 +2026-01-08T05:05:02.505959,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:35:02.022949 +2026-01-08T05:05:32.982793,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:35:32.590681 +2026-01-08T05:06:03.501447,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:36:03.067249 +2026-01-08T05:06:33.905843,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:36:33.595267 +2026-01-08T05:07:04.403168,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:37:04.000349 +2026-01-08T05:07:34.962315,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:37:34.490966 +2026-01-08T05:08:05.530545,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:38:05.048407 +2026-01-08T05:08:36.134134,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:38:35.614850 +2026-01-08T05:09:06.764438,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:39:06.224561 +2026-01-08T05:09:37.407057,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:39:36.849298 +2026-01-08T05:10:08.085515,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:40:07.504521 +2026-01-08T05:10:38.662254,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:40:38.169149 +2026-01-08T05:11:09.243290,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:41:08.755772 +2026-01-08T05:11:39.855605,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:41:39.328806 +2026-01-08T05:12:10.432899,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:42:09.951667 +2026-01-08T05:12:40.975772,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:42:40.515020 +2026-01-08T05:13:11.568373,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:43:11.060810 +2026-01-08T05:13:42.155839,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:43:41.687103 +2026-01-08T05:14:12.747109,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:44:12.243221 +2026-01-08T05:14:43.296179,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:44:42.833418 +2026-01-08T05:15:13.936010,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:45:13.451789 +2026-01-08T05:15:44.485707,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:45:44.022254 +2026-01-08T05:16:15.062649,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:46:14.586488 +2026-01-08T05:16:45.687717,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:46:45.149839 +2026-01-08T05:17:16.268938,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:47:15.773874 +2026-01-08T05:17:46.686930,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:47:46.357670 +2026-01-08T05:18:17.131710,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:48:16.775661 +2026-01-08T05:18:47.585475,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:48:47.244391 +2026-01-08T05:19:18.022394,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:49:17.670903 +2026-01-08T05:19:48.467310,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:49:48.119012 +2026-01-08T05:20:18.944419,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:50:18.558596 +2026-01-08T05:20:49.411302,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:50:49.071656 +2026-01-08T05:21:19.846297,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:51:19.499991 +2026-01-08T05:21:50.508384,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:51:49.973906 +2026-01-08T05:22:21.032896,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:52:20.597814 +2026-01-08T05:22:51.683189,DEBUG_ENTER_TRY_EXECUTE,2026-01-08T10:52:51.123451 diff --git a/indian_paper_trading_strategy/storage/mtm_ledger.csv.bak b/indian_paper_trading_strategy/storage/mtm_ledger.csv.bak new file mode 100644 index 0000000..bbc97c5 --- /dev/null +++ b/indian_paper_trading_strategy/storage/mtm_ledger.csv.bak @@ -0,0 +1,17 @@ +timestamp,nifty_units,gold_units,nifty_price,gold_price,nifty_value,gold_value,portfolio_value,total_invested,pnl +2026-01-10T19:55:25.242070,0.0,0.0,291.1400146484375,113.62999725341797,0.0,0.0,0.0,0.0,0.0 +2026-01-10T19:56:26.279661,0.0,0.0,291.1400146484375,113.62999725341797,0.0,0.0,0.0,0.0,0.0 +2026-01-10T19:57:33.001987,0.0,0.0,291.1400146484375,113.62999725341797,0.0,0.0,0.0,0.0,0.0 +2026-01-10T19:58:34.096752,0.0,0.0,291.1400146484375,113.62999725341797,0.0,0.0,0.0,0.0,0.0 +2026-01-10T19:59:35.037266,0.0,0.0,291.1400146484375,113.62999725341797,0.0,0.0,0.0,0.0,0.0 +2026-01-10T20:00:36.100378,0.0,0.0,291.1400146484375,113.62999725341797,0.0,0.0,0.0,0.0,0.0 +2026-01-10T20:01:37.179304,0.0,0.0,291.1400146484375,113.62999725341797,0.0,0.0,0.0,0.0,0.0 +2026-01-10T20:02:39.414103,0.0,0.0,291.1400146484375,113.62999725341797,0.0,0.0,0.0,0.0,0.0 +2026-01-10T20:03:40.337199,0.0,0.0,291.1400146484375,113.62999725341797,0.0,0.0,0.0,0.0,0.0 +2026-01-10T20:04:41.594502,0.0,0.0,291.1400146484375,113.62999725341797,0.0,0.0,0.0,0.0,0.0 +2026-01-10T20:05:42.910014,0.0,0.0,291.1400146484375,113.62999725341797,0.0,0.0,0.0,0.0,0.0 +2026-01-10T20:06:44.115662,0.0,0.0,291.1400146484375,113.62999725341797,0.0,0.0,0.0,0.0,0.0 +2026-01-10T20:07:45.371083,0.0,0.0,291.1400146484375,113.62999725341797,0.0,0.0,0.0,0.0,0.0 +2026-01-10T20:08:46.312195,0.0,0.0,291.1400146484375,113.62999725341797,0.0,0.0,0.0,0.0,0.0 +2026-01-10T20:09:47.432052,0.0,0.0,291.1400146484375,113.62999725341797,0.0,0.0,0.0,0.0,0.0 +2026-01-10T20:10:48.940888,0.0,0.0,291.1400146484375,113.62999725341797,0.0,0.0,0.0,0.0,0.0 diff --git a/indian_paper_trading_strategy/storage/paper_broker.json.bak b/indian_paper_trading_strategy/storage/paper_broker.json.bak new file mode 100644 index 0000000..5aab485 --- /dev/null +++ b/indian_paper_trading_strategy/storage/paper_broker.json.bak @@ -0,0 +1,88 @@ +{ + "cash": 500000.0, + "positions": {}, + "orders": [], + "trades": [], + "equity_curve": [ + { + "timestamp": "2026-01-10T19:55:24.730764", + "equity": 500000.0, + "pnl": 0.0 + }, + { + "timestamp": "2026-01-10T19:56:25.712617", + "equity": 500000.0, + "pnl": 0.0 + }, + { + "timestamp": "2026-01-10T19:57:32.538338", + "equity": 500000.0, + "pnl": 0.0 + }, + { + "timestamp": "2026-01-10T19:58:33.531690", + "equity": 500000.0, + "pnl": 0.0 + }, + { + "timestamp": "2026-01-10T19:59:34.567188", + "equity": 500000.0, + "pnl": 0.0 + }, + { + "timestamp": "2026-01-10T20:00:35.637880", + "equity": 500000.0, + "pnl": 0.0 + }, + { + "timestamp": "2026-01-10T20:01:36.742897", + "equity": 500000.0, + "pnl": 0.0 + }, + { + "timestamp": "2026-01-10T20:02:38.901570", + "equity": 500000.0, + "pnl": 0.0 + }, + { + "timestamp": "2026-01-10T20:03:39.879929", + "equity": 500000.0, + "pnl": 0.0 + }, + { + "timestamp": "2026-01-10T20:04:41.074966", + "equity": 500000.0, + "pnl": 0.0 + }, + { + "timestamp": "2026-01-10T20:05:42.420797", + "equity": 500000.0, + "pnl": 0.0 + }, + { + "timestamp": "2026-01-10T20:06:43.478764", + "equity": 500000.0, + "pnl": 0.0 + }, + { + "timestamp": "2026-01-10T20:07:44.680007", + "equity": 500000.0, + "pnl": 0.0 + }, + { + "timestamp": "2026-01-10T20:08:45.879637", + "equity": 500000.0, + "pnl": 0.0 + }, + { + "timestamp": "2026-01-10T20:09:46.837127", + "equity": 500000.0, + "pnl": 0.0 + }, + { + "timestamp": "2026-01-10T20:10:48.256494", + "equity": 500000.0, + "pnl": 0.0 + } + ] +} \ No newline at end of file diff --git a/indian_paper_trading_strategy/storage/state.json.bak b/indian_paper_trading_strategy/storage/state.json.bak new file mode 100644 index 0000000..cd5d43e --- /dev/null +++ b/indian_paper_trading_strategy/storage/state.json.bak @@ -0,0 +1,7 @@ +{ + "total_invested": 5000.0, + "nifty_units": 12.680485106982822, + "gold_units": 11.335812442673163, + "last_sip_ts": "2026-01-01T11:28:01.549923", + "last_run": "2026-01-01T11:28:01.549923" +} \ No newline at end of file diff --git a/indian_paper_trading_strategy/storage/state_paper.json.bak b/indian_paper_trading_strategy/storage/state_paper.json.bak new file mode 100644 index 0000000..57f17ad --- /dev/null +++ b/indian_paper_trading_strategy/storage/state_paper.json.bak @@ -0,0 +1,13 @@ +{ + "initial_cash": 500000.0, + "cash": 500000.0, + "total_invested": 0.0, + "nifty_units": 0.0, + "gold_units": 0.0, + "last_sip_ts": null, + "last_run": null, + "sip_frequency": { + "value": 10, + "unit": "minutes" + } +} \ No newline at end of file diff --git a/indian_paper_trading_strategy_1/app/streamlit_app.py b/indian_paper_trading_strategy_1/app/streamlit_app.py new file mode 100644 index 0000000..8ce6e63 --- /dev/null +++ b/indian_paper_trading_strategy_1/app/streamlit_app.py @@ -0,0 +1,208 @@ +import streamlit as st +import time +from datetime import datetime +from pathlib import Path +import sys +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from indian_paper_trading_strategy.engine.history import load_monthly_close +from indian_paper_trading_strategy.engine.market import india_market_status +from indian_paper_trading_strategy.engine.data import fetch_live_price +from indian_paper_trading_strategy.engine.strategy import allocation +from indian_paper_trading_strategy.engine.execution import try_execute_sip +from indian_paper_trading_strategy.engine.state import load_state, save_state +from indian_paper_trading_strategy.engine.db import db_connection, insert_engine_event, run_with_retry, get_default_user_id, get_active_run_id, set_context +from indian_paper_trading_strategy.engine.mtm import log_mtm, should_log_mtm + +_STREAMLIT_USER_ID = get_default_user_id() +_STREAMLIT_RUN_ID = get_active_run_id(_STREAMLIT_USER_ID) if _STREAMLIT_USER_ID else None +if _STREAMLIT_USER_ID and _STREAMLIT_RUN_ID: + set_context(_STREAMLIT_USER_ID, _STREAMLIT_RUN_ID) + +def reset_runtime_state(): + def _op(cur, _conn): + cur.execute( + "DELETE FROM mtm_ledger WHERE user_id = %s AND run_id = %s", + (_STREAMLIT_USER_ID, _STREAMLIT_RUN_ID), + ) + cur.execute( + "DELETE FROM event_ledger WHERE user_id = %s AND run_id = %s", + (_STREAMLIT_USER_ID, _STREAMLIT_RUN_ID), + ) + cur.execute( + "DELETE FROM engine_state WHERE user_id = %s AND run_id = %s", + (_STREAMLIT_USER_ID, _STREAMLIT_RUN_ID), + ) + insert_engine_event(cur, "LIVE_RESET", data={}) + + run_with_retry(_op) + +def load_mtm_df(): + with db_connection() as conn: + return pd.read_sql_query( + "SELECT timestamp, pnl FROM mtm_ledger WHERE user_id = %s AND run_id = %s ORDER BY timestamp", + conn, + params=(_STREAMLIT_USER_ID, _STREAMLIT_RUN_ID), + ) + +def is_engine_running(): + state = load_state(mode="LIVE") + return state.get("total_invested", 0) > 0 or \ + state.get("nifty_units", 0) > 0 or \ + state.get("gold_units", 0) > 0 + +if "engine_active" not in st.session_state: + st.session_state.engine_active = is_engine_running() + +NIFTY = "NIFTYBEES.NS" +GOLD = "GOLDBEES.NS" +SMA_MONTHS = 36 + +def get_prices(): + try: + nifty = fetch_live_price(NIFTY) + gold = fetch_live_price(GOLD) + return nifty, gold + except Exception as e: + st.error(e) + return None, None + +SIP_AMOUNT = st.number_input("SIP Amount (\u20B9)", 500, 100000, 5000) +SIP_INTERVAL_SEC = st.number_input("SIP Interval (sec) [TEST]", 30, 3600, 120) +REFRESH_SEC = st.slider("Refresh interval (sec)", 5, 60, 10) + +st.title("SIPXAR INDIA - Phase-1 Safe Engine") + +market_open, market_time = india_market_status() +st.info(f"NSE Market {'OPEN' if market_open else 'CLOSED'} | IST {market_time}") +if not market_open: + st.info("Market is closed. Portfolio values are frozen at last available prices.") + +col1, col2 = st.columns(2) + +with col1: + if st.button("START ENGINE"): + if is_engine_running(): + st.info("Engine already running. Resuming.") + st.session_state.engine_active = True + else: + st.session_state.engine_active = True + + # HARD RESET ONLY ON FIRST START + reset_runtime_state() + + save_state({ + "total_invested": 0.0, + "nifty_units": 0.0, + "gold_units": 0.0, + "last_sip_ts": None, + }, mode="LIVE", emit_event=True, event_meta={"source": "streamlit_start"}) + + st.success("Engine started") + +with col2: + if st.button("KILL ENGINE"): + st.session_state.engine_active = False + + reset_runtime_state() + + st.warning("Engine killed and state wiped") + st.stop() + +if not st.session_state.engine_active: + st.stop() + +state = load_state(mode="LIVE") +nifty_price, gold_price = get_prices() + +if nifty_price is None: + st.stop() + +st.subheader("Latest Market Prices (LTP)") + +c1, c2 = st.columns(2) + +with c1: + st.metric( + label="NIFTYBEES", + value=f"\u20B9{nifty_price:,.2f}", + help="Last traded price (delayed)" + ) + +with c2: + st.metric( + label="GOLDBEES", + value=f"\u20B9{gold_price:,.2f}", + help="Last traded price (delayed)" + ) + +st.caption(f"Price timestamp: {datetime.now().strftime('%H:%M:%S')}") + +nifty_hist = load_monthly_close(NIFTY) +gold_hist = load_monthly_close(GOLD) + +nifty_sma = nifty_hist.rolling(SMA_MONTHS).mean().iloc[-1] +gold_sma = gold_hist.rolling(SMA_MONTHS).mean().iloc[-1] + +eq_w, gd_w = allocation( + sp_price=nifty_price, + gd_price=gold_price, + sp_sma=nifty_sma, + gd_sma=gold_sma +) + +state, executed = try_execute_sip( + now=datetime.now(), + market_open=market_open, + sip_interval=SIP_INTERVAL_SEC, + sip_amount=SIP_AMOUNT, + sp_price=nifty_price, + gd_price=gold_price, + eq_w=eq_w, + gd_w=gd_w, + mode="LIVE", +) + +now = datetime.now() + +if market_open and should_log_mtm(None, now): + portfolio_value, pnl = log_mtm( + nifty_units=state["nifty_units"], + gold_units=state["gold_units"], + nifty_price=nifty_price, + gold_price=gold_price, + total_invested=state["total_invested"], + ) +else: + # Market closed -> freeze valuation (do NOT log) + portfolio_value = ( + state["nifty_units"] * nifty_price + + state["gold_units"] * gold_price + ) + pnl = portfolio_value - state["total_invested"] + +st.subheader("Equity Curve (Unrealized PnL)") + +mtm_df = load_mtm_df() + +if "timestamp" in mtm_df.columns and "pnl" in mtm_df.columns and len(mtm_df) > 1: + mtm_df["timestamp"] = pd.to_datetime(mtm_df["timestamp"]) + mtm_df = mtm_df.sort_values("timestamp").set_index("timestamp") + + st.line_chart(mtm_df["pnl"], height=350) +else: + st.warning("Not enough MTM data or missing columns. Expected: timestamp, pnl.") + +st.metric("Total Invested", f"\u20B9{state['total_invested']:,.0f}") +st.metric("NIFTY Units", round(state["nifty_units"], 4)) +st.metric("Gold Units", round(state["gold_units"], 4)) +st.metric("Portfolio Value", f"\u20B9{portfolio_value:,.0f}") +st.metric("PnL", f"\u20B9{pnl:,.0f}") + +time.sleep(REFRESH_SEC) +st.rerun() + diff --git a/indian_paper_trading_strategy_1/engine/__init__.py b/indian_paper_trading_strategy_1/engine/__init__.py new file mode 100644 index 0000000..bf0d74e --- /dev/null +++ b/indian_paper_trading_strategy_1/engine/__init__.py @@ -0,0 +1 @@ +"""Engine package for the India paper trading strategy.""" diff --git a/indian_paper_trading_strategy_1/engine/broker.py b/indian_paper_trading_strategy_1/engine/broker.py new file mode 100644 index 0000000..cc3ec87 --- /dev/null +++ b/indian_paper_trading_strategy_1/engine/broker.py @@ -0,0 +1,697 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from datetime import datetime, timezone +import hashlib + +from psycopg2.extras import execute_values + +from indian_paper_trading_strategy.engine.data import fetch_live_price +from indian_paper_trading_strategy.engine.db import db_connection, insert_engine_event, run_with_retry, get_context + + +class Broker(ABC): + @abstractmethod + def place_order( + self, + symbol: str, + side: str, + quantity: float, + price: float | None = None, + logical_time: datetime | None = None, + ): + raise NotImplementedError + + @abstractmethod + def get_positions(self): + raise NotImplementedError + + @abstractmethod + def get_orders(self): + raise NotImplementedError + + @abstractmethod + def get_funds(self): + raise NotImplementedError + + +def _local_tz(): + return datetime.now().astimezone().tzinfo + + +def _format_utc_ts(value: datetime | None): + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=_local_tz()) + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _format_local_ts(value: datetime | None): + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=_local_tz()) + return value.astimezone(_local_tz()).replace(tzinfo=None).isoformat() + + +def _parse_ts(value, assume_local: bool = True): + if value is None: + return None + if isinstance(value, datetime): + if value.tzinfo is None: + return value.replace(tzinfo=_local_tz() if assume_local else timezone.utc) + return value + if isinstance(value, str): + text = value.strip() + if not text: + return None + if text.endswith("Z"): + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + return None + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=_local_tz() if assume_local else timezone.utc) + return parsed + return None + + +def _stable_num(value: float) -> str: + return f"{float(value):.12f}" + + +def _normalize_ts_for_id(ts: datetime) -> str: + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + return ts.astimezone(timezone.utc).replace(microsecond=0).isoformat() + + +def _deterministic_id(prefix: str, parts: list[str]) -> str: + payload = "|".join(parts) + digest = hashlib.sha1(payload.encode("utf-8")).hexdigest()[:16] + return f"{prefix}_{digest}" + + +def _resolve_scope(user_id: str | None, run_id: str | None): + return get_context(user_id, run_id) + + +@dataclass +class PaperBroker(Broker): + initial_cash: float + store_path: str | None = None + + def _default_store(self): + return { + "cash": float(self.initial_cash), + "positions": {}, + "orders": [], + "trades": [], + "equity_curve": [], + } + + def _load_store(self, cur=None, for_update: bool = False, user_id: str | None = None, run_id: str | None = None): + scope_user, scope_run = _resolve_scope(user_id, run_id) + if cur is None: + with db_connection() as conn: + with conn.cursor() as cur: + return self._load_store( + cur=cur, + for_update=for_update, + user_id=scope_user, + run_id=scope_run, + ) + + store = self._default_store() + lock_clause = " FOR UPDATE" if for_update else "" + cur.execute( + f"SELECT cash FROM paper_broker_account WHERE user_id = %s AND run_id = %s{lock_clause} LIMIT 1", + (scope_user, scope_run), + ) + row = cur.fetchone() + if row and row[0] is not None: + store["cash"] = float(row[0]) + + cur.execute( + f""" + SELECT symbol, qty, avg_price, last_price + FROM paper_position + WHERE user_id = %s AND run_id = %s{lock_clause} + """ + , + (scope_user, scope_run), + ) + positions = {} + for symbol, qty, avg_price, last_price in cur.fetchall(): + positions[symbol] = { + "qty": float(qty) if qty is not None else 0.0, + "avg_price": float(avg_price) if avg_price is not None else 0.0, + "last_price": float(last_price) if last_price is not None else 0.0, + } + store["positions"] = positions + + cur.execute( + """ + SELECT id, symbol, side, qty, price, status, timestamp, logical_time + FROM paper_order + WHERE user_id = %s AND run_id = %s + ORDER BY timestamp, id + """ + , + (scope_user, scope_run), + ) + orders = [] + for order_id, symbol, side, qty, price, status, ts, logical_ts in cur.fetchall(): + orders.append( + { + "id": order_id, + "symbol": symbol, + "side": side, + "qty": float(qty) if qty is not None else 0.0, + "price": float(price) if price is not None else 0.0, + "status": status, + "timestamp": _format_utc_ts(ts), + "_logical_time": _format_utc_ts(logical_ts), + } + ) + store["orders"] = orders + + cur.execute( + """ + SELECT id, order_id, symbol, side, qty, price, timestamp, logical_time + FROM paper_trade + WHERE user_id = %s AND run_id = %s + ORDER BY timestamp, id + """ + , + (scope_user, scope_run), + ) + trades = [] + for trade_id, order_id, symbol, side, qty, price, ts, logical_ts in cur.fetchall(): + trades.append( + { + "id": trade_id, + "order_id": order_id, + "symbol": symbol, + "side": side, + "qty": float(qty) if qty is not None else 0.0, + "price": float(price) if price is not None else 0.0, + "timestamp": _format_utc_ts(ts), + "_logical_time": _format_utc_ts(logical_ts), + } + ) + store["trades"] = trades + + cur.execute( + """ + SELECT timestamp, logical_time, equity, pnl + FROM paper_equity_curve + WHERE user_id = %s AND run_id = %s + ORDER BY timestamp + """ + , + (scope_user, scope_run), + ) + equity_curve = [] + for ts, logical_ts, equity, pnl in cur.fetchall(): + equity_curve.append( + { + "timestamp": _format_local_ts(ts), + "_logical_time": _format_local_ts(logical_ts), + "equity": float(equity) if equity is not None else 0.0, + "pnl": float(pnl) if pnl is not None else 0.0, + } + ) + store["equity_curve"] = equity_curve + return store + + def _save_store(self, store, cur=None, user_id: str | None = None, run_id: str | None = None): + scope_user, scope_run = _resolve_scope(user_id, run_id) + if cur is None: + def _persist(cur, _conn): + self._save_store(store, cur=cur, user_id=scope_user, run_id=scope_run) + return run_with_retry(_persist) + + cash = store.get("cash") + if cash is not None: + cur.execute( + """ + INSERT INTO paper_broker_account (user_id, run_id, cash) + VALUES (%s, %s, %s) + ON CONFLICT (user_id, run_id) DO UPDATE + SET cash = EXCLUDED.cash + """, + (scope_user, scope_run, float(cash)), + ) + + positions = store.get("positions") + if isinstance(positions, dict): + symbols = [s for s in positions.keys() if s] + if symbols: + cur.execute( + "DELETE FROM paper_position WHERE user_id = %s AND run_id = %s AND symbol NOT IN %s", + (scope_user, scope_run, tuple(symbols)), + ) + else: + cur.execute( + "DELETE FROM paper_position WHERE user_id = %s AND run_id = %s", + (scope_user, scope_run), + ) + + if symbols: + rows = [] + updated_at = datetime.now(timezone.utc) + for symbol, data in positions.items(): + if not symbol or not isinstance(data, dict): + continue + rows.append( + ( + scope_user, + scope_run, + symbol, + float(data.get("qty", 0.0)), + float(data.get("avg_price", 0.0)), + float(data.get("last_price", 0.0)), + updated_at, + ) + ) + if rows: + execute_values( + cur, + """ + INSERT INTO paper_position ( + user_id, run_id, symbol, qty, avg_price, last_price, updated_at + ) + VALUES %s + ON CONFLICT (user_id, run_id, symbol) DO UPDATE + SET qty = EXCLUDED.qty, + avg_price = EXCLUDED.avg_price, + last_price = EXCLUDED.last_price, + updated_at = EXCLUDED.updated_at + """, + rows, + ) + + orders = store.get("orders") + if isinstance(orders, list) and orders: + rows = [] + for order in orders: + if not isinstance(order, dict): + continue + order_id = order.get("id") + if not order_id: + continue + ts = _parse_ts(order.get("timestamp"), assume_local=False) + logical_ts = _parse_ts(order.get("_logical_time"), assume_local=False) or ts + rows.append( + ( + scope_user, + scope_run, + order_id, + order.get("symbol"), + order.get("side"), + float(order.get("qty", 0.0)), + float(order.get("price", 0.0)), + order.get("status"), + ts, + logical_ts, + ) + ) + if rows: + execute_values( + cur, + """ + INSERT INTO paper_order ( + user_id, run_id, id, symbol, side, qty, price, status, timestamp, logical_time + ) + VALUES %s + ON CONFLICT DO NOTHING + """, + rows, + ) + + trades = store.get("trades") + if isinstance(trades, list) and trades: + rows = [] + for trade in trades: + if not isinstance(trade, dict): + continue + trade_id = trade.get("id") + if not trade_id: + continue + ts = _parse_ts(trade.get("timestamp"), assume_local=False) + logical_ts = _parse_ts(trade.get("_logical_time"), assume_local=False) or ts + rows.append( + ( + scope_user, + scope_run, + trade_id, + trade.get("order_id"), + trade.get("symbol"), + trade.get("side"), + float(trade.get("qty", 0.0)), + float(trade.get("price", 0.0)), + ts, + logical_ts, + ) + ) + if rows: + execute_values( + cur, + """ + INSERT INTO paper_trade ( + user_id, run_id, id, order_id, symbol, side, qty, price, timestamp, logical_time + ) + VALUES %s + ON CONFLICT DO NOTHING + """, + rows, + ) + + equity_curve = store.get("equity_curve") + if isinstance(equity_curve, list) and equity_curve: + rows = [] + for point in equity_curve: + if not isinstance(point, dict): + continue + ts = _parse_ts(point.get("timestamp"), assume_local=True) + logical_ts = _parse_ts(point.get("_logical_time"), assume_local=True) or ts + if ts is None: + continue + rows.append( + ( + scope_user, + scope_run, + ts, + logical_ts, + float(point.get("equity", 0.0)), + float(point.get("pnl", 0.0)), + ) + ) + if rows: + execute_values( + cur, + """ + INSERT INTO paper_equity_curve (user_id, run_id, timestamp, logical_time, equity, pnl) + VALUES %s + ON CONFLICT DO NOTHING + """, + rows, + ) + + def get_funds(self, cur=None): + store = self._load_store(cur=cur) + cash = float(store.get("cash", 0)) + positions = store.get("positions", {}) + positions_value = 0.0 + for position in positions.values(): + qty = float(position.get("qty", 0)) + last_price = float(position.get("last_price", position.get("avg_price", 0))) + positions_value += qty * last_price + total_equity = cash + positions_value + return { + "cash_available": cash, + "invested_value": positions_value, + "cash": cash, + "used_margin": 0.0, + "available": cash, + "net": total_equity, + "total_equity": total_equity, + } + + def get_positions(self, cur=None): + store = self._load_store(cur=cur) + positions = store.get("positions", {}) + return [ + { + "symbol": symbol, + "qty": float(data.get("qty", 0)), + "avg_price": float(data.get("avg_price", 0)), + "last_price": float(data.get("last_price", data.get("avg_price", 0))), + } + for symbol, data in positions.items() + ] + + def get_orders(self, cur=None): + store = self._load_store(cur=cur) + orders = [] + for order in store.get("orders", []): + if isinstance(order, dict): + order = {k: v for k, v in order.items() if k != "_logical_time"} + orders.append(order) + return orders + + def get_trades(self, cur=None): + store = self._load_store(cur=cur) + trades = [] + for trade in store.get("trades", []): + if isinstance(trade, dict): + trade = {k: v for k, v in trade.items() if k != "_logical_time"} + trades.append(trade) + return trades + + def get_equity_curve(self, cur=None): + store = self._load_store(cur=cur) + points = [] + for point in store.get("equity_curve", []): + if isinstance(point, dict): + point = {k: v for k, v in point.items() if k != "_logical_time"} + points.append(point) + return points + + def _update_equity_in_tx( + self, + cur, + prices: dict[str, float], + now: datetime, + logical_time: datetime | None = None, + user_id: str | None = None, + run_id: str | None = None, + ): + store = self._load_store(cur=cur, for_update=True, user_id=user_id, run_id=run_id) + positions = store.get("positions", {}) + for symbol, price in prices.items(): + if symbol in positions: + positions[symbol]["last_price"] = float(price) + + cash = float(store.get("cash", 0)) + positions_value = 0.0 + for symbol, position in positions.items(): + qty = float(position.get("qty", 0)) + price = float(position.get("last_price", position.get("avg_price", 0))) + positions_value += qty * price + + equity = cash + positions_value + pnl = equity - float(self.initial_cash) + ts_for_equity = logical_time or now + store.setdefault("equity_curve", []).append( + { + "timestamp": _format_local_ts(ts_for_equity), + "_logical_time": _format_local_ts(ts_for_equity), + "equity": equity, + "pnl": pnl, + } + ) + store["positions"] = positions + self._save_store(store, cur=cur, user_id=user_id, run_id=run_id) + insert_engine_event( + cur, + "EQUITY_UPDATED", + data={ + "timestamp": _format_utc_ts(ts_for_equity), + "equity": equity, + "pnl": pnl, + }, + ) + return equity + + def update_equity( + self, + prices: dict[str, float], + now: datetime, + cur=None, + logical_time: datetime | None = None, + user_id: str | None = None, + run_id: str | None = None, + ): + if cur is not None: + return self._update_equity_in_tx( + cur, + prices, + now, + logical_time=logical_time, + user_id=user_id, + run_id=run_id, + ) + + def _op(cur, _conn): + return self._update_equity_in_tx( + cur, + prices, + now, + logical_time=logical_time, + user_id=user_id, + run_id=run_id, + ) + + return run_with_retry(_op) + + def _place_order_in_tx( + self, + cur, + symbol: str, + side: str, + quantity: float, + price: float | None, + logical_time: datetime | None = None, + user_id: str | None = None, + run_id: str | None = None, + ): + scope_user, scope_run = _resolve_scope(user_id, run_id) + store = self._load_store(cur=cur, for_update=True, user_id=scope_user, run_id=scope_run) + side = side.upper().strip() + qty = float(quantity) + if price is None: + price = fetch_live_price(symbol) + price = float(price) + + logical_ts = logical_time or datetime.utcnow().replace(tzinfo=timezone.utc) + timestamp = logical_ts + timestamp_str = _format_utc_ts(timestamp) + logical_ts_str = _format_utc_ts(logical_ts) + order_id = _deterministic_id( + "ord", + [ + scope_user, + scope_run, + _normalize_ts_for_id(logical_ts), + symbol, + side, + _stable_num(qty), + _stable_num(price), + ], + ) + + order = { + "id": order_id, + "symbol": symbol, + "side": side, + "qty": qty, + "price": price, + "status": "REJECTED", + "timestamp": timestamp_str, + "_logical_time": logical_ts_str, + } + + if qty <= 0 or price <= 0: + store.setdefault("orders", []).append(order) + self._save_store(store, cur=cur, user_id=user_id, run_id=run_id) + insert_engine_event(cur, "ORDER_PLACED", data=order) + return order + + positions = store.get("positions", {}) + cash = float(store.get("cash", 0)) + trade = None + + if side == "BUY": + cost = qty * price + if cash >= cost: + cash -= cost + existing = positions.get(symbol, {"qty": 0.0, "avg_price": 0.0, "last_price": price}) + new_qty = float(existing.get("qty", 0)) + qty + prev_cost = float(existing.get("qty", 0)) * float(existing.get("avg_price", 0)) + avg_price = (prev_cost + cost) / new_qty if new_qty else price + positions[symbol] = { + "qty": new_qty, + "avg_price": avg_price, + "last_price": price, + } + order["status"] = "FILLED" + trade = { + "id": _deterministic_id("trd", [order_id]), + "order_id": order_id, + "symbol": symbol, + "side": side, + "qty": qty, + "price": price, + "timestamp": timestamp_str, + "_logical_time": logical_ts_str, + } + store.setdefault("trades", []).append(trade) + elif side == "SELL": + existing = positions.get(symbol) + if existing and float(existing.get("qty", 0)) >= qty: + cash += qty * price + remaining = float(existing.get("qty", 0)) - qty + if remaining > 0: + existing["qty"] = remaining + existing["last_price"] = price + positions[symbol] = existing + else: + positions.pop(symbol, None) + order["status"] = "FILLED" + trade = { + "id": _deterministic_id("trd", [order_id]), + "order_id": order_id, + "symbol": symbol, + "side": side, + "qty": qty, + "price": price, + "timestamp": timestamp_str, + "_logical_time": logical_ts_str, + } + store.setdefault("trades", []).append(trade) + + store["cash"] = cash + store["positions"] = positions + store.setdefault("orders", []).append(order) + self._save_store(store, cur=cur, user_id=user_id, run_id=run_id) + insert_engine_event(cur, "ORDER_PLACED", data=order) + if trade is not None: + insert_engine_event(cur, "TRADE_EXECUTED", data=trade) + insert_engine_event(cur, "ORDER_FILLED", data={"order_id": order_id}) + return order + + def place_order( + self, + symbol: str, + side: str, + quantity: float, + price: float | None = None, + cur=None, + logical_time: datetime | None = None, + user_id: str | None = None, + run_id: str | None = None, + ): + if cur is not None: + return self._place_order_in_tx( + cur, + symbol, + side, + quantity, + price, + logical_time=logical_time, + user_id=user_id, + run_id=run_id, + ) + + def _op(cur, _conn): + return self._place_order_in_tx( + cur, + symbol, + side, + quantity, + price, + logical_time=logical_time, + user_id=user_id, + run_id=run_id, + ) + + return run_with_retry(_op) + diff --git a/indian_paper_trading_strategy_1/engine/config.py b/indian_paper_trading_strategy_1/engine/config.py new file mode 100644 index 0000000..9321b89 --- /dev/null +++ b/indian_paper_trading_strategy_1/engine/config.py @@ -0,0 +1,150 @@ +import json +from datetime import datetime + +from indian_paper_trading_strategy.engine.db import db_connection, get_context + +DEFAULT_CONFIG = { + "active": False, + "sip_amount": 0, + "sip_frequency": {"value": 30, "unit": "days"}, + "next_run": None +} + +def _maybe_parse_json(value): + if value is None: + return None + if not isinstance(value, str): + return value + text = value.strip() + if not text: + return None + try: + return json.loads(text) + except Exception: + return value + + +def _format_ts(value: datetime | None): + if value is None: + return None + return value.isoformat() + + +def load_strategy_config(user_id: str | None = None, run_id: str | None = None): + scope_user, scope_run = get_context(user_id, run_id) + with db_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT strategy, sip_amount, sip_frequency_value, sip_frequency_unit, + mode, broker, active, frequency, frequency_days, unit, next_run + FROM strategy_config + WHERE user_id = %s AND run_id = %s + LIMIT 1 + """, + (scope_user, scope_run), + ) + row = cur.fetchone() + if not row: + return DEFAULT_CONFIG.copy() + + cfg = DEFAULT_CONFIG.copy() + cfg["strategy"] = row[0] + cfg["strategy_name"] = row[0] + cfg["sip_amount"] = float(row[1]) if row[1] is not None else cfg.get("sip_amount") + cfg["mode"] = row[4] + cfg["broker"] = row[5] + cfg["active"] = row[6] if row[6] is not None else cfg.get("active") + cfg["frequency"] = _maybe_parse_json(row[7]) + cfg["frequency_days"] = row[8] + cfg["unit"] = row[9] + cfg["next_run"] = _format_ts(row[10]) + if row[2] is not None or row[3] is not None: + cfg["sip_frequency"] = {"value": row[2], "unit": row[3]} + else: + value = cfg.get("frequency") + unit = cfg.get("unit") + if isinstance(value, dict): + unit = value.get("unit", unit) + value = value.get("value") + if value is None and cfg.get("frequency_days") is not None: + value = cfg.get("frequency_days") + unit = unit or "days" + if value is not None and unit: + cfg["sip_frequency"] = {"value": value, "unit": unit} + return cfg + +def save_strategy_config(cfg, user_id: str | None = None, run_id: str | None = None): + scope_user, scope_run = get_context(user_id, run_id) + sip_frequency = cfg.get("sip_frequency") + sip_value = None + sip_unit = None + if isinstance(sip_frequency, dict): + sip_value = sip_frequency.get("value") + sip_unit = sip_frequency.get("unit") + + frequency = cfg.get("frequency") + if not isinstance(frequency, str) and frequency is not None: + frequency = json.dumps(frequency) + + next_run = cfg.get("next_run") + next_run_dt = None + if isinstance(next_run, str): + try: + next_run_dt = datetime.fromisoformat(next_run) + except ValueError: + next_run_dt = None + + strategy = cfg.get("strategy") or cfg.get("strategy_name") + + with db_connection() as conn: + with conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO strategy_config ( + user_id, + run_id, + strategy, + sip_amount, + sip_frequency_value, + sip_frequency_unit, + mode, + broker, + active, + frequency, + frequency_days, + unit, + next_run + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (user_id, run_id) DO UPDATE + SET strategy = EXCLUDED.strategy, + sip_amount = EXCLUDED.sip_amount, + sip_frequency_value = EXCLUDED.sip_frequency_value, + sip_frequency_unit = EXCLUDED.sip_frequency_unit, + mode = EXCLUDED.mode, + broker = EXCLUDED.broker, + active = EXCLUDED.active, + frequency = EXCLUDED.frequency, + frequency_days = EXCLUDED.frequency_days, + unit = EXCLUDED.unit, + next_run = EXCLUDED.next_run + """, + ( + scope_user, + scope_run, + strategy, + cfg.get("sip_amount"), + sip_value, + sip_unit, + cfg.get("mode"), + cfg.get("broker"), + cfg.get("active"), + frequency, + cfg.get("frequency_days"), + cfg.get("unit"), + next_run_dt, + ), + ) + diff --git a/indian_paper_trading_strategy_1/engine/data.py b/indian_paper_trading_strategy_1/engine/data.py new file mode 100644 index 0000000..c60aba2 --- /dev/null +++ b/indian_paper_trading_strategy_1/engine/data.py @@ -0,0 +1,81 @@ +# engine/data.py +from datetime import datetime, timezone +from pathlib import Path +import os +import threading + +import pandas as pd +import yfinance as yf + +ENGINE_ROOT = Path(__file__).resolve().parents[1] +HISTORY_DIR = ENGINE_ROOT / "storage" / "history" +ALLOW_PRICE_CACHE = os.getenv("ALLOW_PRICE_CACHE", "0").strip().lower() in {"1", "true", "yes"} + +_LAST_PRICE: dict[str, dict[str, object]] = {} +_LAST_PRICE_LOCK = threading.Lock() + + +def _set_last_price(ticker: str, price: float, source: str): + now = datetime.now(timezone.utc) + with _LAST_PRICE_LOCK: + _LAST_PRICE[ticker] = {"price": float(price), "source": source, "ts": now} + + +def get_price_snapshot(ticker: str) -> dict[str, object] | None: + with _LAST_PRICE_LOCK: + data = _LAST_PRICE.get(ticker) + if not data: + return None + return dict(data) + + +def _get_last_live_price(ticker: str) -> float | None: + with _LAST_PRICE_LOCK: + data = _LAST_PRICE.get(ticker) + if not data: + return None + if data.get("source") == "live": + return float(data.get("price", 0)) + return None + + +def _cached_last_close(ticker: str) -> float | None: + file = HISTORY_DIR / f"{ticker}.csv" + if not file.exists(): + return None + df = pd.read_csv(file) + if df.empty or "Close" not in df.columns: + return None + return float(df["Close"].iloc[-1]) + + +def fetch_live_price(ticker, allow_cache: bool | None = None): + if allow_cache is None: + allow_cache = ALLOW_PRICE_CACHE + try: + df = yf.download( + ticker, + period="1d", + interval="1m", + auto_adjust=True, + progress=False, + timeout=5, + ) + if df is not None and not df.empty: + price = float(df["Close"].iloc[-1]) + _set_last_price(ticker, price, "live") + return price + except Exception: + pass + + if allow_cache: + last_live = _get_last_live_price(ticker) + if last_live is not None: + return last_live + + cached = _cached_last_close(ticker) + if cached is not None: + _set_last_price(ticker, cached, "cache") + return cached + + raise RuntimeError(f"No live data for {ticker}") diff --git a/indian_paper_trading_strategy_1/engine/engine_runner.py b/indian_paper_trading_strategy_1/engine/engine_runner.py new file mode 100644 index 0000000..0be5f65 --- /dev/null +++ b/indian_paper_trading_strategy_1/engine/engine_runner.py @@ -0,0 +1,198 @@ +import time +from datetime import datetime, timezone + +from indian_paper_trading_strategy.engine.db import ( + run_with_retry, + insert_engine_event, + get_default_user_id, + get_active_run_id, + get_running_runs, + engine_context, +) + +def log_event(event: str, data: dict | None = None): + now = datetime.utcnow().replace(tzinfo=timezone.utc) + payload = data or {} + + def _op(cur, _conn): + insert_engine_event(cur, event, data=payload, ts=now) + + run_with_retry(_op) + +def _update_engine_status(user_id: str, run_id: str, status: str): + now = datetime.utcnow().replace(tzinfo=timezone.utc) + + def _op(cur, _conn): + cur.execute( + """ + INSERT INTO engine_status (user_id, run_id, status, last_updated) + VALUES (%s, %s, %s, %s) + ON CONFLICT (user_id, run_id) DO UPDATE + SET status = EXCLUDED.status, + last_updated = EXCLUDED.last_updated + """, + (user_id, run_id, status, now), + ) + + run_with_retry(_op) + +from indian_paper_trading_strategy.engine.config import load_strategy_config, save_strategy_config +from indian_paper_trading_strategy.engine.market import india_market_status +from indian_paper_trading_strategy.engine.execution import try_execute_sip +from indian_paper_trading_strategy.engine.state import load_state +from indian_paper_trading_strategy.engine.broker import PaperBroker +from indian_paper_trading_strategy.engine.data import fetch_live_price +from indian_paper_trading_strategy.engine.mtm import log_mtm, should_log_mtm +from indian_paper_trading_strategy.engine.history import load_monthly_close +from indian_paper_trading_strategy.engine.strategy import allocation +from indian_paper_trading_strategy.engine.time_utils import frequency_to_timedelta, normalize_logical_time + +NIFTY = "NIFTYBEES.NS" +GOLD = "GOLDBEES.NS" +SMA_MONTHS = 36 + +def run_engine(user_id: str | None = None, run_id: str | None = None): + print("Strategy engine started") + active_runs: dict[tuple[str, str], bool] = {} + + if run_id and not user_id: + raise ValueError("user_id is required when run_id is provided") + + while True: + try: + if user_id and run_id: + runs = [(user_id, run_id)] + elif user_id: + runs = get_running_runs(user_id) + else: + runs = get_running_runs() + if not runs: + default_user = get_default_user_id() + if default_user: + runs = get_running_runs(default_user) + + seen = set() + for scope_user, scope_run in runs: + if not scope_user or not scope_run: + continue + seen.add((scope_user, scope_run)) + with engine_context(scope_user, scope_run): + cfg = load_strategy_config(user_id=scope_user, run_id=scope_run) + if not cfg.get("active"): + continue + + strategy_name = cfg.get("strategy_name", "golden_nifty") + sip_amount = cfg.get("sip_amount", 0) + configured_frequency = cfg.get("sip_frequency") or {} + if not isinstance(configured_frequency, dict): + configured_frequency = {} + frequency_value = int(configured_frequency.get("value", cfg.get("frequency", 0))) + frequency_unit = configured_frequency.get("unit", cfg.get("unit", "days")) + frequency_info = {"value": frequency_value, "unit": frequency_unit} + frequency_label = f"{frequency_value} {frequency_unit}" + + if not active_runs.get((scope_user, scope_run)): + log_event( + "ENGINE_START", + { + "strategy": strategy_name, + "sip_amount": sip_amount, + "frequency": frequency_label, + }, + ) + active_runs[(scope_user, scope_run)] = True + + _update_engine_status(scope_user, scope_run, "RUNNING") + + market_open, _ = india_market_status() + if not market_open: + log_event("MARKET_CLOSED", {"reason": "Outside market hours"}) + continue + + now = datetime.now() + mode = (cfg.get("mode") or "PAPER").strip().upper() + if mode not in {"PAPER", "LIVE"}: + mode = "PAPER" + state = load_state(mode=mode) + initial_cash = float(state.get("initial_cash") or 0.0) + broker = PaperBroker(initial_cash=initial_cash) if mode == "PAPER" else None + + nifty_price = fetch_live_price(NIFTY) + gold_price = fetch_live_price(GOLD) + + next_run = cfg.get("next_run") + if next_run is None or now >= datetime.fromisoformat(next_run): + nifty_hist = load_monthly_close(NIFTY) + gold_hist = load_monthly_close(GOLD) + + nifty_sma = nifty_hist.rolling(SMA_MONTHS).mean().iloc[-1] + gold_sma = gold_hist.rolling(SMA_MONTHS).mean().iloc[-1] + + eq_w, gd_w = allocation( + sp_price=nifty_price, + gd_price=gold_price, + sp_sma=nifty_sma, + gd_sma=gold_sma, + ) + + weights = {"equity": eq_w, "gold": gd_w} + state, executed = try_execute_sip( + now=now, + market_open=True, + sip_interval=frequency_to_timedelta(frequency_info).total_seconds(), + sip_amount=sip_amount, + sp_price=nifty_price, + gd_price=gold_price, + eq_w=eq_w, + gd_w=gd_w, + broker=broker, + mode=mode, + ) + + if executed: + log_event( + "SIP_TRIGGERED", + { + "date": now.date().isoformat(), + "allocation": weights, + "cash_used": sip_amount, + }, + ) + portfolio_value = ( + state["nifty_units"] * nifty_price + + state["gold_units"] * gold_price + ) + log_event( + "PORTFOLIO_UPDATED", + { + "nifty_units": state["nifty_units"], + "gold_units": state["gold_units"], + "portfolio_value": portfolio_value, + }, + ) + cfg["next_run"] = (now + frequency_to_timedelta(frequency_info)).isoformat() + save_strategy_config(cfg, user_id=scope_user, run_id=scope_run) + + if should_log_mtm(None, now): + state = load_state(mode=mode) + log_mtm( + nifty_units=state["nifty_units"], + gold_units=state["gold_units"], + nifty_price=nifty_price, + gold_price=gold_price, + total_invested=state["total_invested"], + logical_time=normalize_logical_time(now), + ) + + for key in list(active_runs.keys()): + if key not in seen: + active_runs.pop(key, None) + + time.sleep(30) + except Exception as e: + log_event("ENGINE_ERROR", {"error": str(e)}) + raise + +if __name__ == "__main__": + run_engine() + diff --git a/indian_paper_trading_strategy_1/engine/execution.py b/indian_paper_trading_strategy_1/engine/execution.py new file mode 100644 index 0000000..e135a24 --- /dev/null +++ b/indian_paper_trading_strategy_1/engine/execution.py @@ -0,0 +1,157 @@ +# engine/execution.py +from datetime import datetime, timezone +from indian_paper_trading_strategy.engine.state import load_state, save_state + +from indian_paper_trading_strategy.engine.broker import Broker +from indian_paper_trading_strategy.engine.ledger import log_event, event_exists +from indian_paper_trading_strategy.engine.db import run_with_retry +from indian_paper_trading_strategy.engine.time_utils import compute_logical_time + +def _as_float(value): + if hasattr(value, "item"): + try: + return float(value.item()) + except Exception: + pass + if hasattr(value, "iloc"): + try: + return float(value.iloc[-1]) + except Exception: + pass + return float(value) + +def _local_tz(): + return datetime.now().astimezone().tzinfo + +def try_execute_sip( + now, + market_open, + sip_interval, + sip_amount, + sp_price, + gd_price, + eq_w, + gd_w, + broker: Broker | None = None, + mode: str | None = "LIVE", +): + def _op(cur, _conn): + if now.tzinfo is None: + now_ts = now.replace(tzinfo=_local_tz()) + else: + now_ts = now + event_ts = now_ts + log_event("DEBUG_ENTER_TRY_EXECUTE", { + "now": now_ts.isoformat(), + }, cur=cur, ts=event_ts) + + state = load_state(mode=mode, cur=cur, for_update=True) + + force_execute = state.get("last_sip_ts") is None + + if not market_open: + return state, False + + last = state.get("last_sip_ts") or state.get("last_run") + if last and not force_execute: + try: + last_dt = datetime.fromisoformat(last) + except ValueError: + last_dt = None + if last_dt: + if last_dt.tzinfo is None: + last_dt = last_dt.replace(tzinfo=_local_tz()) + if now_ts.tzinfo and last_dt.tzinfo and last_dt.tzinfo != now_ts.tzinfo: + last_dt = last_dt.astimezone(now_ts.tzinfo) + if last_dt and (now_ts - last_dt).total_seconds() < sip_interval: + return state, False + + logical_time = compute_logical_time(now_ts, last, sip_interval) + if event_exists("SIP_EXECUTED", logical_time, cur=cur): + return state, False + + sp_price_val = _as_float(sp_price) + gd_price_val = _as_float(gd_price) + eq_w_val = _as_float(eq_w) + gd_w_val = _as_float(gd_w) + sip_amount_val = _as_float(sip_amount) + + nifty_qty = (sip_amount_val * eq_w_val) / sp_price_val + gold_qty = (sip_amount_val * gd_w_val) / gd_price_val + + if broker is None: + return state, False + + funds = broker.get_funds(cur=cur) + cash = funds.get("cash") + if cash is not None and float(cash) < sip_amount_val: + return state, False + + log_event("DEBUG_EXECUTION_DECISION", { + "force_execute": force_execute, + "last_sip_ts": state.get("last_sip_ts"), + "now": now_ts.isoformat(), + }, cur=cur, ts=event_ts) + + nifty_order = broker.place_order( + "NIFTYBEES.NS", + "BUY", + nifty_qty, + sp_price_val, + cur=cur, + logical_time=logical_time, + ) + gold_order = broker.place_order( + "GOLDBEES.NS", + "BUY", + gold_qty, + gd_price_val, + cur=cur, + logical_time=logical_time, + ) + orders = [nifty_order, gold_order] + executed = all( + isinstance(order, dict) and order.get("status") == "FILLED" + for order in orders + ) + if not executed: + return state, False + assert len(orders) > 0, "executed=True but no broker orders placed" + + funds_after = broker.get_funds(cur=cur) + cash_after = funds_after.get("cash") + if cash_after is not None: + state["cash"] = float(cash_after) + + state["nifty_units"] += nifty_qty + state["gold_units"] += gold_qty + state["total_invested"] += sip_amount_val + state["last_sip_ts"] = now_ts.isoformat() + state["last_run"] = now_ts.isoformat() + + save_state( + state, + mode=mode, + cur=cur, + emit_event=True, + event_meta={"source": "sip"}, + ) + + log_event( + "SIP_EXECUTED", + { + "nifty_units": nifty_qty, + "gold_units": gold_qty, + "nifty_price": sp_price_val, + "gold_price": gd_price_val, + "amount": sip_amount_val, + }, + cur=cur, + ts=event_ts, + logical_time=logical_time, + ) + + return state, True + + return run_with_retry(_op) + diff --git a/indian_paper_trading_strategy_1/engine/history.py b/indian_paper_trading_strategy_1/engine/history.py new file mode 100644 index 0000000..28e4697 --- /dev/null +++ b/indian_paper_trading_strategy_1/engine/history.py @@ -0,0 +1,34 @@ +# engine/history.py +import yfinance as yf +import pandas as pd +from pathlib import Path + +ENGINE_ROOT = Path(__file__).resolve().parents[1] +STORAGE_DIR = ENGINE_ROOT / "storage" +STORAGE_DIR.mkdir(exist_ok=True) + +CACHE_DIR = STORAGE_DIR / "history" +CACHE_DIR.mkdir(exist_ok=True) + +def load_monthly_close(ticker, years=10): + file = CACHE_DIR / f"{ticker}.csv" + + if file.exists(): + df = pd.read_csv(file, parse_dates=["Date"], index_col="Date") + return df["Close"] + + df = yf.download( + ticker, + period=f"{years}y", + auto_adjust=True, + progress=False, + timeout=5, + ) + + if df.empty: + raise RuntimeError(f"No history for {ticker}") + + series = df["Close"].resample("M").last() + series.to_csv(file, header=["Close"]) + + return series diff --git a/indian_paper_trading_strategy_1/engine/ledger.py b/indian_paper_trading_strategy_1/engine/ledger.py new file mode 100644 index 0000000..874a5fa --- /dev/null +++ b/indian_paper_trading_strategy_1/engine/ledger.py @@ -0,0 +1,113 @@ +# engine/ledger.py +from datetime import datetime, timezone + +from indian_paper_trading_strategy.engine.db import insert_engine_event, run_with_retry, get_context +from indian_paper_trading_strategy.engine.time_utils import normalize_logical_time + + +def _event_exists_in_tx(cur, event, logical_time, user_id: str | None = None, run_id: str | None = None): + scope_user, scope_run = get_context(user_id, run_id) + logical_ts = normalize_logical_time(logical_time) + cur.execute( + """ + SELECT 1 + FROM event_ledger + WHERE user_id = %s AND run_id = %s AND event = %s AND logical_time = %s + LIMIT 1 + """, + (scope_user, scope_run, event, logical_ts), + ) + return cur.fetchone() is not None + + +def event_exists(event, logical_time, *, cur=None, user_id: str | None = None, run_id: str | None = None): + if cur is not None: + return _event_exists_in_tx(cur, event, logical_time, user_id=user_id, run_id=run_id) + + def _op(cur, _conn): + return _event_exists_in_tx(cur, event, logical_time, user_id=user_id, run_id=run_id) + + return run_with_retry(_op) + + +def _log_event_in_tx( + cur, + event, + payload, + ts, + logical_time=None, + user_id: str | None = None, + run_id: str | None = None, +): + scope_user, scope_run = get_context(user_id, run_id) + logical_ts = normalize_logical_time(logical_time or ts) + cur.execute( + """ + INSERT INTO event_ledger ( + user_id, + run_id, + timestamp, + logical_time, + event, + nifty_units, + gold_units, + nifty_price, + gold_price, + amount + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT DO NOTHING + """, + ( + scope_user, + scope_run, + ts, + logical_ts, + event, + payload.get("nifty_units"), + payload.get("gold_units"), + payload.get("nifty_price"), + payload.get("gold_price"), + payload.get("amount"), + ), + ) + if cur.rowcount: + insert_engine_event(cur, event, data=payload, ts=ts) + + +def log_event( + event, + payload, + *, + cur=None, + ts=None, + logical_time=None, + user_id: str | None = None, + run_id: str | None = None, +): + now = ts or logical_time or datetime.utcnow().replace(tzinfo=timezone.utc) + if cur is not None: + _log_event_in_tx( + cur, + event, + payload, + now, + logical_time=logical_time, + user_id=user_id, + run_id=run_id, + ) + return + + def _op(cur, _conn): + _log_event_in_tx( + cur, + event, + payload, + now, + logical_time=logical_time, + user_id=user_id, + run_id=run_id, + ) + + return run_with_retry(_op) + diff --git a/indian_paper_trading_strategy_1/engine/market.py b/indian_paper_trading_strategy_1/engine/market.py new file mode 100644 index 0000000..c16f5de --- /dev/null +++ b/indian_paper_trading_strategy_1/engine/market.py @@ -0,0 +1,42 @@ +# engine/market.py +from datetime import datetime, time as dtime, timedelta +import pytz + +_MARKET_TZ = pytz.timezone("Asia/Kolkata") +_OPEN_T = dtime(9, 15) +_CLOSE_T = dtime(15, 30) + +def _as_market_tz(value: datetime) -> datetime: + if value.tzinfo is None: + return _MARKET_TZ.localize(value) + return value.astimezone(_MARKET_TZ) + +def is_market_open(now: datetime) -> bool: + now = _as_market_tz(now) + return now.weekday() < 5 and _OPEN_T <= now.time() <= _CLOSE_T + +def india_market_status(): + now = datetime.now(_MARKET_TZ) + + return is_market_open(now), now + +def next_market_open_after(value: datetime) -> datetime: + current = _as_market_tz(value) + while current.weekday() >= 5: + current = current + timedelta(days=1) + current = current.replace(hour=_OPEN_T.hour, minute=_OPEN_T.minute, second=0, microsecond=0) + if current.time() < _OPEN_T: + return current.replace(hour=_OPEN_T.hour, minute=_OPEN_T.minute, second=0, microsecond=0) + if current.time() > _CLOSE_T: + current = current + timedelta(days=1) + while current.weekday() >= 5: + current = current + timedelta(days=1) + return current.replace(hour=_OPEN_T.hour, minute=_OPEN_T.minute, second=0, microsecond=0) + return current + +def align_to_market_open(value: datetime) -> datetime: + current = _as_market_tz(value) + aligned = current if is_market_open(current) else next_market_open_after(current) + if value.tzinfo is None: + return aligned.replace(tzinfo=None) + return aligned diff --git a/indian_paper_trading_strategy_1/engine/mtm.py b/indian_paper_trading_strategy_1/engine/mtm.py new file mode 100644 index 0000000..90d6b1a --- /dev/null +++ b/indian_paper_trading_strategy_1/engine/mtm.py @@ -0,0 +1,154 @@ +from datetime import datetime, timezone +from pathlib import Path + +from indian_paper_trading_strategy.engine.db import db_connection, insert_engine_event, run_with_retry, get_context +from indian_paper_trading_strategy.engine.time_utils import normalize_logical_time + +ENGINE_ROOT = Path(__file__).resolve().parents[1] +STORAGE_DIR = ENGINE_ROOT / "storage" +MTM_FILE = STORAGE_DIR / "mtm_ledger.csv" + +MTM_INTERVAL_SECONDS = 60 + +def _log_mtm_in_tx( + cur, + nifty_units, + gold_units, + nifty_price, + gold_price, + total_invested, + ts, + logical_time=None, + user_id: str | None = None, + run_id: str | None = None, +): + scope_user, scope_run = get_context(user_id, run_id) + logical_ts = normalize_logical_time(logical_time or ts) + nifty_value = nifty_units * nifty_price + gold_value = gold_units * gold_price + portfolio_value = nifty_value + gold_value + pnl = portfolio_value - total_invested + + row = { + "timestamp": ts.isoformat(), + "logical_time": logical_ts.isoformat(), + "nifty_units": nifty_units, + "gold_units": gold_units, + "nifty_price": nifty_price, + "gold_price": gold_price, + "nifty_value": nifty_value, + "gold_value": gold_value, + "portfolio_value": portfolio_value, + "total_invested": total_invested, + "pnl": pnl, + } + cur.execute( + """ + INSERT INTO mtm_ledger ( + user_id, + run_id, + timestamp, + logical_time, + nifty_units, + gold_units, + nifty_price, + gold_price, + nifty_value, + gold_value, + portfolio_value, + total_invested, + pnl + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT DO NOTHING + """, + ( + scope_user, + scope_run, + ts, + logical_ts, + row["nifty_units"], + row["gold_units"], + row["nifty_price"], + row["gold_price"], + row["nifty_value"], + row["gold_value"], + row["portfolio_value"], + row["total_invested"], + row["pnl"], + ), + ) + if cur.rowcount: + insert_engine_event(cur, "MTM_UPDATED", data=row, ts=ts) + return portfolio_value, pnl + +def log_mtm( + nifty_units, + gold_units, + nifty_price, + gold_price, + total_invested, + *, + cur=None, + logical_time=None, + user_id: str | None = None, + run_id: str | None = None, +): + ts = logical_time or datetime.now(timezone.utc) + if cur is not None: + return _log_mtm_in_tx( + cur, + nifty_units, + gold_units, + nifty_price, + gold_price, + total_invested, + ts, + logical_time=logical_time, + user_id=user_id, + run_id=run_id, + ) + + def _op(cur, _conn): + return _log_mtm_in_tx( + cur, + nifty_units, + gold_units, + nifty_price, + gold_price, + total_invested, + ts, + logical_time=logical_time, + user_id=user_id, + run_id=run_id, + ) + + return run_with_retry(_op) + +def _get_last_mtm_ts(user_id: str | None = None, run_id: str | None = None): + scope_user, scope_run = get_context(user_id, run_id) + with db_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT MAX(timestamp) FROM mtm_ledger WHERE user_id = %s AND run_id = %s", + (scope_user, scope_run), + ) + row = cur.fetchone() + if not row or row[0] is None: + return None + return row[0].astimezone().replace(tzinfo=None) + +def should_log_mtm(df, now, user_id: str | None = None, run_id: str | None = None): + if df is None: + last_ts = _get_last_mtm_ts(user_id=user_id, run_id=run_id) + if last_ts is None: + return True + return (now - last_ts).total_seconds() >= MTM_INTERVAL_SECONDS + if getattr(df, "empty", False): + return True + try: + last_ts = datetime.fromisoformat(str(df.iloc[-1]["timestamp"])) + except Exception: + return True + return (now - last_ts).total_seconds() >= MTM_INTERVAL_SECONDS + diff --git a/indian_paper_trading_strategy_1/engine/runner.py b/indian_paper_trading_strategy_1/engine/runner.py new file mode 100644 index 0000000..e0e6a6c --- /dev/null +++ b/indian_paper_trading_strategy_1/engine/runner.py @@ -0,0 +1,518 @@ +import os +import threading +import time +from datetime import datetime, timedelta, timezone + +from indian_paper_trading_strategy.engine.market import is_market_open, align_to_market_open +from indian_paper_trading_strategy.engine.execution import try_execute_sip +from indian_paper_trading_strategy.engine.broker import PaperBroker +from indian_paper_trading_strategy.engine.mtm import log_mtm, should_log_mtm +from indian_paper_trading_strategy.engine.state import load_state +from indian_paper_trading_strategy.engine.data import fetch_live_price +from indian_paper_trading_strategy.engine.history import load_monthly_close +from indian_paper_trading_strategy.engine.strategy import allocation +from indian_paper_trading_strategy.engine.time_utils import normalize_logical_time + +from indian_paper_trading_strategy.engine.db import db_transaction, insert_engine_event, run_with_retry, get_context, set_context + + +def _update_engine_status(user_id: str, run_id: str, status: str): + now = datetime.utcnow().replace(tzinfo=timezone.utc) + + def _op(cur, _conn): + cur.execute( + """ + INSERT INTO engine_status (user_id, run_id, status, last_updated) + VALUES (%s, %s, %s, %s) + ON CONFLICT (user_id, run_id) DO UPDATE + SET status = EXCLUDED.status, + last_updated = EXCLUDED.last_updated + """, + (user_id, run_id, status, now), + ) + + run_with_retry(_op) + +NIFTY = "NIFTYBEES.NS" +GOLD = "GOLDBEES.NS" +SMA_MONTHS = 36 + +_DEFAULT_ENGINE_STATE = { + "state": "STOPPED", + "run_id": None, + "user_id": None, + "last_heartbeat_ts": None, +} + +_ENGINE_STATES = {} +_ENGINE_STATES_LOCK = threading.Lock() + +_RUNNERS = {} +_RUNNERS_LOCK = threading.Lock() + +engine_state = _ENGINE_STATES + + +def _state_key(user_id: str, run_id: str): + return (user_id, run_id) + + +def _get_state(user_id: str, run_id: str): + key = _state_key(user_id, run_id) + with _ENGINE_STATES_LOCK: + state = _ENGINE_STATES.get(key) + if state is None: + state = dict(_DEFAULT_ENGINE_STATE) + state["user_id"] = user_id + state["run_id"] = run_id + _ENGINE_STATES[key] = state + return state + + +def _set_state(user_id: str, run_id: str, **updates): + key = _state_key(user_id, run_id) + with _ENGINE_STATES_LOCK: + state = _ENGINE_STATES.get(key) + if state is None: + state = dict(_DEFAULT_ENGINE_STATE) + state["user_id"] = user_id + state["run_id"] = run_id + _ENGINE_STATES[key] = state + state.update(updates) + + +def get_engine_state(user_id: str, run_id: str): + state = _get_state(user_id, run_id) + return dict(state) + +def log_event( + event: str, + data: dict | None = None, + message: str | None = None, + meta: dict | None = None, +): + entry = { + "ts": datetime.utcnow().replace(tzinfo=timezone.utc).isoformat(), + "event": event, + } + if message is not None or meta is not None: + entry["message"] = message or "" + entry["meta"] = meta or {} + else: + entry["data"] = data or {} + event_ts = datetime.fromisoformat(entry["ts"].replace("Z", "+00:00")) + data = entry.get("data") if "data" in entry else None + meta = entry.get("meta") if "meta" in entry else None + + def _op(cur, _conn): + insert_engine_event( + cur, + entry.get("event"), + data=data, + message=entry.get("message"), + meta=meta, + ts=event_ts, + ) + + run_with_retry(_op) + +def sleep_with_heartbeat( + total_seconds: int, + stop_event: threading.Event, + user_id: str, + run_id: str, + step_seconds: int = 5, +): + remaining = total_seconds + while remaining > 0 and not stop_event.is_set(): + time.sleep(min(step_seconds, remaining)) + _set_state(user_id, run_id, last_heartbeat_ts=datetime.utcnow().isoformat() + "Z") + remaining -= step_seconds + +def _clear_runner(user_id: str, run_id: str): + key = _state_key(user_id, run_id) + with _RUNNERS_LOCK: + _RUNNERS.pop(key, None) + +def can_execute(now: datetime) -> tuple[bool, str]: + if not is_market_open(now): + return False, "MARKET_CLOSED" + return True, "OK" + +def _engine_loop(config, stop_event: threading.Event): + print("Strategy engine started with config:", config) + + user_id = config.get("user_id") + run_id = config.get("run_id") + scope_user, scope_run = get_context(user_id, run_id) + set_context(scope_user, scope_run) + + strategy_name = config.get("strategy_name") or config.get("strategy") or "golden_nifty" + sip_amount = config["sip_amount"] + configured_frequency = config.get("sip_frequency") or {} + if not isinstance(configured_frequency, dict): + configured_frequency = {} + frequency_value = int(configured_frequency.get("value", config.get("frequency", 0))) + frequency_unit = configured_frequency.get("unit", config.get("unit", "days")) + frequency_label = f"{frequency_value} {frequency_unit}" + emit_event_cb = config.get("emit_event") + if not callable(emit_event_cb): + emit_event_cb = None + debug_enabled = os.getenv("ENGINE_DEBUG", "1").strip().lower() not in {"0", "false", "no"} + + def debug_event(event: str, message: str, meta: dict | None = None): + if not debug_enabled: + return + try: + log_event(event=event, message=message, meta=meta or {}) + except Exception: + pass + if emit_event_cb: + emit_event_cb(event=event, message=message, meta=meta or {}) + print(f"[ENGINE] {event} {message} {meta or {}}", flush=True) + mode = (config.get("mode") or "LIVE").strip().upper() + if mode not in {"PAPER", "LIVE"}: + mode = "LIVE" + broker_type = config.get("broker") or "paper" + if broker_type != "paper": + broker_type = "paper" + if broker_type == "paper": + mode = "PAPER" + initial_cash = float(config.get("initial_cash", 0)) + broker = PaperBroker(initial_cash=initial_cash) + log_event( + event="DEBUG_PAPER_STORE_PATH", + message="Paper broker store path", + meta={ + "cwd": os.getcwd(), + "paper_store_path": str(broker.store_path) if hasattr(broker, "store_path") else "NO_STORE_PATH", + "abs_store_path": os.path.abspath(str(broker.store_path)) if hasattr(broker, "store_path") else "N/A", + }, + ) + if emit_event_cb: + emit_event_cb( + event="DEBUG_PAPER_STORE_PATH", + message="Paper broker store path", + meta={ + "cwd": os.getcwd(), + "paper_store_path": str(broker.store_path) if hasattr(broker, "store_path") else "NO_STORE_PATH", + "abs_store_path": os.path.abspath(str(broker.store_path)) if hasattr(broker, "store_path") else "N/A", + }, + ) + + log_event("ENGINE_START", { + "strategy": strategy_name, + "sip_amount": sip_amount, + "frequency": frequency_label, + }) + debug_event("ENGINE_START_DEBUG", "engine loop started", {"run_id": scope_run, "user_id": scope_user}) + + _set_state( + scope_user, + scope_run, + state="RUNNING", + last_heartbeat_ts=datetime.utcnow().isoformat() + "Z", + ) + _update_engine_status(scope_user, scope_run, "RUNNING") + + try: + while not stop_event.is_set(): + _set_state(scope_user, scope_run, last_heartbeat_ts=datetime.utcnow().isoformat() + "Z") + _update_engine_status(scope_user, scope_run, "RUNNING") + + state = load_state(mode=mode) + debug_event( + "STATE_LOADED", + "loaded engine state", + { + "last_sip_ts": state.get("last_sip_ts"), + "last_run": state.get("last_run"), + "cash": state.get("cash"), + "total_invested": state.get("total_invested"), + }, + ) + state_frequency = state.get("sip_frequency") + if not isinstance(state_frequency, dict): + state_frequency = {"value": frequency_value, "unit": frequency_unit} + freq = int(state_frequency.get("value", frequency_value)) + unit = state_frequency.get("unit", frequency_unit) + frequency_label = f"{freq} {unit}" + if unit == "minutes": + delta = timedelta(minutes=freq) + else: + delta = timedelta(days=freq) + + # Gate 2: time to SIP + last_run = state.get("last_run") or state.get("last_sip_ts") + is_first_run = last_run is None + now = datetime.now() + debug_event( + "ENGINE_LOOP_TICK", + "engine loop tick", + {"now": now.isoformat(), "frequency": frequency_label}, + ) + + if last_run and not is_first_run: + next_run = datetime.fromisoformat(last_run) + delta + next_run = align_to_market_open(next_run) + if now < next_run: + log_event( + event="SIP_WAITING", + message="Waiting for next SIP window", + meta={ + "last_run": last_run, + "next_eligible": next_run.isoformat(), + "now": now.isoformat(), + "frequency": frequency_label, + }, + ) + if emit_event_cb: + emit_event_cb( + event="SIP_WAITING", + message="Waiting for next SIP window", + meta={ + "last_run": last_run, + "next_eligible": next_run.isoformat(), + "now": now.isoformat(), + "frequency": frequency_label, + }, + ) + sleep_with_heartbeat(60, stop_event, scope_user, scope_run) + continue + + try: + debug_event("PRICE_FETCH_START", "fetching live prices", {"tickers": [NIFTY, GOLD]}) + nifty_price = fetch_live_price(NIFTY) + gold_price = fetch_live_price(GOLD) + debug_event( + "PRICE_FETCHED", + "fetched live prices", + {"nifty_price": float(nifty_price), "gold_price": float(gold_price)}, + ) + except Exception as exc: + debug_event("PRICE_FETCH_ERROR", "live price fetch failed", {"error": str(exc)}) + sleep_with_heartbeat(30, stop_event, scope_user, scope_run) + continue + + try: + nifty_hist = load_monthly_close(NIFTY) + gold_hist = load_monthly_close(GOLD) + except Exception as exc: + debug_event("HISTORY_LOAD_ERROR", "history load failed", {"error": str(exc)}) + sleep_with_heartbeat(30, stop_event, scope_user, scope_run) + continue + + nifty_sma = nifty_hist.rolling(SMA_MONTHS).mean().iloc[-1] + gold_sma = gold_hist.rolling(SMA_MONTHS).mean().iloc[-1] + + eq_w, gd_w = allocation( + sp_price=nifty_price, + gd_price=gold_price, + sp_sma=nifty_sma, + gd_sma=gold_sma + ) + debug_event( + "WEIGHTS_COMPUTED", + "computed allocation weights", + {"equity_weight": float(eq_w), "gold_weight": float(gd_w)}, + ) + + weights = {"equity": eq_w, "gold": gd_w} + allowed, reason = can_execute(now) + executed = False + if not allowed: + log_event( + event="EXECUTION_BLOCKED", + message="Execution blocked by market gate", + meta={ + "reason": reason, + "eligible_since": last_run, + "checked_at": now.isoformat(), + }, + ) + debug_event("MARKET_GATE", "market closed", {"reason": reason}) + if emit_event_cb: + emit_event_cb( + event="EXECUTION_BLOCKED", + message="Execution blocked by market gate", + meta={ + "reason": reason, + "eligible_since": last_run, + "checked_at": now.isoformat(), + }, + ) + else: + log_event( + event="DEBUG_BEFORE_TRY_EXECUTE", + message="About to call try_execute_sip", + meta={ + "last_run": last_run, + "frequency": frequency_label, + "allowed": allowed, + "reason": reason, + "sip_amount": sip_amount, + "broker": type(broker).__name__, + "now": now.isoformat(), + }, + ) + if emit_event_cb: + emit_event_cb( + event="DEBUG_BEFORE_TRY_EXECUTE", + message="About to call try_execute_sip", + meta={ + "last_run": last_run, + "frequency": frequency_label, + "allowed": allowed, + "reason": reason, + "sip_amount": sip_amount, + "broker": type(broker).__name__, + "now": now.isoformat(), + }, + ) + debug_event( + "TRY_EXECUTE_START", + "calling try_execute_sip", + {"sip_interval_sec": delta.total_seconds(), "sip_amount": sip_amount}, + ) + state, executed = try_execute_sip( + now=now, + market_open=True, + sip_interval=delta.total_seconds(), + sip_amount=sip_amount, + sp_price=nifty_price, + gd_price=gold_price, + eq_w=eq_w, + gd_w=gd_w, + broker=broker, + mode=mode, + ) + log_event( + event="DEBUG_AFTER_TRY_EXECUTE", + message="Returned from try_execute_sip", + meta={ + "executed": executed, + "state_last_run": state.get("last_run"), + "state_last_sip_ts": state.get("last_sip_ts"), + }, + ) + if emit_event_cb: + emit_event_cb( + event="DEBUG_AFTER_TRY_EXECUTE", + message="Returned from try_execute_sip", + meta={ + "executed": executed, + "state_last_run": state.get("last_run"), + "state_last_sip_ts": state.get("last_sip_ts"), + }, + ) + debug_event( + "TRY_EXECUTE_DONE", + "try_execute_sip finished", + {"executed": executed, "last_run": state.get("last_run")}, + ) + + if executed: + log_event("SIP_TRIGGERED", { + "date": now.date().isoformat(), + "allocation": weights, + "cash_used": sip_amount + }) + debug_event("SIP_TRIGGERED", "sip executed", {"cash_used": sip_amount}) + portfolio_value = ( + state["nifty_units"] * nifty_price + + state["gold_units"] * gold_price + ) + log_event("PORTFOLIO_UPDATED", { + "nifty_units": state["nifty_units"], + "gold_units": state["gold_units"], + "portfolio_value": portfolio_value + }) + print("SIP executed at", now) + + if should_log_mtm(None, now): + logical_time = normalize_logical_time(now) + with db_transaction() as cur: + log_mtm( + nifty_units=state["nifty_units"], + gold_units=state["gold_units"], + nifty_price=nifty_price, + gold_price=gold_price, + total_invested=state["total_invested"], + cur=cur, + logical_time=logical_time, + ) + broker.update_equity( + {NIFTY: nifty_price, GOLD: gold_price}, + now, + cur=cur, + logical_time=logical_time, + ) + + sleep_with_heartbeat(30, stop_event, scope_user, scope_run) + except Exception as e: + _set_state(scope_user, scope_run, state="ERROR", last_heartbeat_ts=datetime.utcnow().isoformat() + "Z") + _update_engine_status(scope_user, scope_run, "ERROR") + log_event("ENGINE_ERROR", {"error": str(e)}) + raise + + log_event("ENGINE_STOP") + _set_state( + scope_user, + scope_run, + state="STOPPED", + last_heartbeat_ts=datetime.utcnow().isoformat() + "Z", + ) + _update_engine_status(scope_user, scope_run, "STOPPED") + print("Strategy engine stopped") + _clear_runner(scope_user, scope_run) + +def start_engine(config): + user_id = config.get("user_id") + run_id = config.get("run_id") + if not user_id: + raise ValueError("user_id is required to start engine") + if not run_id: + raise ValueError("run_id is required to start engine") + + with _RUNNERS_LOCK: + key = _state_key(user_id, run_id) + runner = _RUNNERS.get(key) + if runner and runner["thread"].is_alive(): + return False + + stop_event = threading.Event() + thread = threading.Thread( + target=_engine_loop, + args=(config, stop_event), + daemon=True, + ) + _RUNNERS[key] = {"thread": thread, "stop_event": stop_event} + thread.start() + return True + +def stop_engine(user_id: str, run_id: str | None = None, timeout: float | None = 10.0): + runners = [] + with _RUNNERS_LOCK: + if run_id: + key = _state_key(user_id, run_id) + runner = _RUNNERS.get(key) + if runner: + runners.append((key, runner)) + else: + for key, runner in list(_RUNNERS.items()): + if key[0] == user_id: + runners.append((key, runner)) + for _key, runner in runners: + runner["stop_event"].set() + stopped_all = True + for key, runner in runners: + thread = runner["thread"] + if timeout is not None: + thread.join(timeout=timeout) + stopped = not thread.is_alive() + if stopped: + _clear_runner(key[0], key[1]) + else: + stopped_all = False + return stopped_all + diff --git a/indian_paper_trading_strategy_1/engine/state.py b/indian_paper_trading_strategy_1/engine/state.py new file mode 100644 index 0000000..9ec4ccc --- /dev/null +++ b/indian_paper_trading_strategy_1/engine/state.py @@ -0,0 +1,303 @@ +# engine/state.py +from datetime import datetime, timezone + +from indian_paper_trading_strategy.engine.db import db_connection, insert_engine_event, run_with_retry, get_context + +DEFAULT_STATE = { + "initial_cash": 0.0, + "cash": 0.0, + "total_invested": 0.0, + "nifty_units": 0.0, + "gold_units": 0.0, + "last_sip_ts": None, + "last_run": None, + "sip_frequency": None, +} + +DEFAULT_PAPER_STATE = { + **DEFAULT_STATE, + "initial_cash": 1_000_000.0, + "cash": 1_000_000.0, + "sip_frequency": {"value": 30, "unit": "days"}, +} + +def _state_key(mode: str | None): + key = (mode or "LIVE").strip().upper() + return "PAPER" if key == "PAPER" else "LIVE" + +def _default_state(mode: str | None): + if _state_key(mode) == "PAPER": + return DEFAULT_PAPER_STATE.copy() + return DEFAULT_STATE.copy() + +def _local_tz(): + return datetime.now().astimezone().tzinfo + +def _format_local_ts(value: datetime | None): + if value is None: + return None + return value.astimezone(_local_tz()).replace(tzinfo=None).isoformat() + +def _parse_ts(value): + if value is None: + return None + if isinstance(value, datetime): + if value.tzinfo is None: + return value.replace(tzinfo=_local_tz()) + return value + if isinstance(value, str): + text = value.strip() + if not text: + return None + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=_local_tz()) + return parsed + return None + +def _resolve_scope(user_id: str | None, run_id: str | None): + return get_context(user_id, run_id) + + +def load_state( + mode: str | None = "LIVE", + *, + cur=None, + for_update: bool = False, + user_id: str | None = None, + run_id: str | None = None, +): + scope_user, scope_run = _resolve_scope(user_id, run_id) + key = _state_key(mode) + if key == "PAPER": + if cur is None: + with db_connection() as conn: + with conn.cursor() as cur: + return load_state( + mode=mode, + cur=cur, + for_update=for_update, + user_id=scope_user, + run_id=scope_run, + ) + lock_clause = " FOR UPDATE" if for_update else "" + cur.execute( + f""" + SELECT initial_cash, cash, total_invested, nifty_units, gold_units, + last_sip_ts, last_run, sip_frequency_value, sip_frequency_unit + FROM engine_state_paper + WHERE user_id = %s AND run_id = %s{lock_clause} + LIMIT 1 + """, + (scope_user, scope_run), + ) + row = cur.fetchone() + if not row: + return _default_state(mode) + merged = _default_state(mode) + merged.update( + { + "initial_cash": float(row[0]) if row[0] is not None else merged["initial_cash"], + "cash": float(row[1]) if row[1] is not None else merged["cash"], + "total_invested": float(row[2]) if row[2] is not None else merged["total_invested"], + "nifty_units": float(row[3]) if row[3] is not None else merged["nifty_units"], + "gold_units": float(row[4]) if row[4] is not None else merged["gold_units"], + "last_sip_ts": _format_local_ts(row[5]), + "last_run": _format_local_ts(row[6]), + } + ) + if row[7] is not None or row[8] is not None: + merged["sip_frequency"] = {"value": row[7], "unit": row[8]} + return merged + + if cur is None: + with db_connection() as conn: + with conn.cursor() as cur: + return load_state( + mode=mode, + cur=cur, + for_update=for_update, + user_id=scope_user, + run_id=scope_run, + ) + lock_clause = " FOR UPDATE" if for_update else "" + cur.execute( + f""" + SELECT total_invested, nifty_units, gold_units, last_sip_ts, last_run + FROM engine_state + WHERE user_id = %s AND run_id = %s{lock_clause} + LIMIT 1 + """, + (scope_user, scope_run), + ) + row = cur.fetchone() + if not row: + return _default_state(mode) + merged = _default_state(mode) + merged.update( + { + "total_invested": float(row[0]) if row[0] is not None else merged["total_invested"], + "nifty_units": float(row[1]) if row[1] is not None else merged["nifty_units"], + "gold_units": float(row[2]) if row[2] is not None else merged["gold_units"], + "last_sip_ts": _format_local_ts(row[3]), + "last_run": _format_local_ts(row[4]), + } + ) + return merged + +def init_paper_state( + initial_cash: float, + sip_frequency: dict | None = None, + *, + cur=None, + user_id: str | None = None, + run_id: str | None = None, +): + state = DEFAULT_PAPER_STATE.copy() + state.update( + { + "initial_cash": float(initial_cash), + "cash": float(initial_cash), + "total_invested": 0.0, + "nifty_units": 0.0, + "gold_units": 0.0, + "last_sip_ts": None, + "last_run": None, + "sip_frequency": sip_frequency or state.get("sip_frequency"), + } + ) + save_state(state, mode="PAPER", cur=cur, emit_event=True, user_id=user_id, run_id=run_id) + return state + +def save_state( + state, + mode: str | None = "LIVE", + *, + cur=None, + emit_event: bool = False, + event_meta: dict | None = None, + user_id: str | None = None, + run_id: str | None = None, +): + scope_user, scope_run = _resolve_scope(user_id, run_id) + key = _state_key(mode) + last_sip_ts = _parse_ts(state.get("last_sip_ts")) + last_run = _parse_ts(state.get("last_run")) + if key == "PAPER": + sip_frequency = state.get("sip_frequency") + sip_value = None + sip_unit = None + if isinstance(sip_frequency, dict): + sip_value = sip_frequency.get("value") + sip_unit = sip_frequency.get("unit") + def _save(cur): + cur.execute( + """ + INSERT INTO engine_state_paper ( + user_id, run_id, initial_cash, cash, total_invested, nifty_units, gold_units, + last_sip_ts, last_run, sip_frequency_value, sip_frequency_unit + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (user_id, run_id) DO UPDATE + SET initial_cash = EXCLUDED.initial_cash, + cash = EXCLUDED.cash, + total_invested = EXCLUDED.total_invested, + nifty_units = EXCLUDED.nifty_units, + gold_units = EXCLUDED.gold_units, + last_sip_ts = EXCLUDED.last_sip_ts, + last_run = EXCLUDED.last_run, + sip_frequency_value = EXCLUDED.sip_frequency_value, + sip_frequency_unit = EXCLUDED.sip_frequency_unit + """, + ( + scope_user, + scope_run, + float(state.get("initial_cash", 0.0)), + float(state.get("cash", 0.0)), + float(state.get("total_invested", 0.0)), + float(state.get("nifty_units", 0.0)), + float(state.get("gold_units", 0.0)), + last_sip_ts, + last_run, + sip_value, + sip_unit, + ), + ) + if emit_event: + insert_engine_event( + cur, + "STATE_UPDATED", + data={ + "mode": "PAPER", + "cash": state.get("cash"), + "total_invested": state.get("total_invested"), + "nifty_units": state.get("nifty_units"), + "gold_units": state.get("gold_units"), + "last_sip_ts": state.get("last_sip_ts"), + "last_run": state.get("last_run"), + }, + meta=event_meta, + ts=datetime.utcnow().replace(tzinfo=timezone.utc), + ) + + if cur is not None: + _save(cur) + return + + def _op(cur, _conn): + _save(cur) + + return run_with_retry(_op) + + def _save(cur): + cur.execute( + """ + INSERT INTO engine_state ( + user_id, run_id, total_invested, nifty_units, gold_units, last_sip_ts, last_run + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (user_id, run_id) DO UPDATE + SET total_invested = EXCLUDED.total_invested, + nifty_units = EXCLUDED.nifty_units, + gold_units = EXCLUDED.gold_units, + last_sip_ts = EXCLUDED.last_sip_ts, + last_run = EXCLUDED.last_run + """, + ( + scope_user, + scope_run, + float(state.get("total_invested", 0.0)), + float(state.get("nifty_units", 0.0)), + float(state.get("gold_units", 0.0)), + last_sip_ts, + last_run, + ), + ) + if emit_event: + insert_engine_event( + cur, + "STATE_UPDATED", + data={ + "mode": "LIVE", + "total_invested": state.get("total_invested"), + "nifty_units": state.get("nifty_units"), + "gold_units": state.get("gold_units"), + "last_sip_ts": state.get("last_sip_ts"), + "last_run": state.get("last_run"), + }, + meta=event_meta, + ts=datetime.utcnow().replace(tzinfo=timezone.utc), + ) + + if cur is not None: + _save(cur) + return + + def _op(cur, _conn): + _save(cur) + + return run_with_retry(_op) + diff --git a/indian_paper_trading_strategy_1/engine/strategy.py b/indian_paper_trading_strategy_1/engine/strategy.py new file mode 100644 index 0000000..504034e --- /dev/null +++ b/indian_paper_trading_strategy_1/engine/strategy.py @@ -0,0 +1,12 @@ +# engine/strategy.py +import numpy as np + +def allocation(sp_price, gd_price, sp_sma, gd_sma, + base=0.6, tilt_mult=1.5, + max_tilt=0.25, min_eq=0.2, max_eq=0.9): + + rd = (sp_price / sp_sma) - (gd_price / gd_sma) + tilt = np.clip(-rd * tilt_mult, -max_tilt, max_tilt) + + eq_w = np.clip(base * (1 + tilt), min_eq, max_eq) + return eq_w, 1 - eq_w diff --git a/indian_paper_trading_strategy_1/engine/time_utils.py b/indian_paper_trading_strategy_1/engine/time_utils.py new file mode 100644 index 0000000..3315338 --- /dev/null +++ b/indian_paper_trading_strategy_1/engine/time_utils.py @@ -0,0 +1,41 @@ +from datetime import datetime, timedelta + + +def frequency_to_timedelta(freq: dict) -> timedelta: + value = int(freq.get("value", 0)) + unit = freq.get("unit") + + if value <= 0: + raise ValueError("Frequency value must be > 0") + + if unit == "minutes": + return timedelta(minutes=value) + if unit == "days": + return timedelta(days=value) + raise ValueError(f"Unsupported frequency unit: {unit}") + + +def normalize_logical_time(ts: datetime) -> datetime: + return ts.replace(microsecond=0) + + +def compute_logical_time( + now: datetime, + last_run: str | None, + interval_seconds: float | None, +) -> datetime: + base = now + if last_run and interval_seconds: + try: + parsed = datetime.fromisoformat(last_run.replace("Z", "+00:00")) + except ValueError: + parsed = None + if parsed is not None: + if now.tzinfo and parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=now.tzinfo) + elif now.tzinfo is None and parsed.tzinfo: + parsed = parsed.replace(tzinfo=None) + candidate = parsed + timedelta(seconds=interval_seconds) + if now >= candidate: + base = candidate + return normalize_logical_time(base)