Refine live strategy execution flow

This commit is contained in:
Thigazhezhilan J 2026-03-24 21:59:17 +05:30
parent 7677895b05
commit c17222ad9c
10 changed files with 1520 additions and 389 deletions

View File

@ -11,7 +11,7 @@ class StrategyStartRequest(BaseModel):
initial_cash: Optional[float] = None initial_cash: Optional[float] = None
sip_amount: float sip_amount: float
sip_frequency: SipFrequency sip_frequency: SipFrequency
mode: Literal["PAPER"] mode: Literal["PAPER", "LIVE"]
@validator("initial_cash") @validator("initial_cash")
def validate_cash(cls, v): def validate_cash(cls, v):

View File

@ -30,6 +30,17 @@ def _require_user(request: Request):
return user return user
def _build_saved_broker_login_url(request: Request, user_id: str) -> str:
creds = get_broker_credentials(user_id)
if not creds:
raise HTTPException(status_code=400, detail="Broker credentials not configured")
redirect_url = (os.getenv("ZERODHA_REDIRECT_URL") or "").strip()
if not redirect_url:
base = str(request.base_url).rstrip("/")
redirect_url = f"{base}/api/broker/callback"
return build_login_url(creds["api_key"], redirect_url=redirect_url)
@router.post("/connect") @router.post("/connect")
async def connect_broker(payload: dict, request: Request): async def connect_broker(payload: dict, request: Request):
user = _require_user(request) user = _require_user(request)
@ -153,17 +164,16 @@ async def zerodha_callback(request: Request, request_token: str = ""):
@router.get("/login") @router.get("/login")
async def broker_login(request: Request): async def broker_login(request: Request):
user = _require_user(request) user = _require_user(request)
creds = get_broker_credentials(user["id"]) login_url = _build_saved_broker_login_url(request, user["id"])
if not creds:
raise HTTPException(status_code=400, detail="Broker credentials not configured")
redirect_url = (os.getenv("ZERODHA_REDIRECT_URL") or "").strip()
if not redirect_url:
base = str(request.base_url).rstrip("/")
redirect_url = f"{base}/api/broker/callback"
login_url = build_login_url(creds["api_key"], redirect_url=redirect_url)
return RedirectResponse(login_url) return RedirectResponse(login_url)
@router.get("/login-url")
async def broker_login_url(request: Request):
user = _require_user(request)
return {"loginUrl": _build_saved_broker_login_url(request, user["id"])}
@router.get("/callback") @router.get("/callback")
async def broker_callback(request: Request, request_token: str = ""): async def broker_callback(request: Request, request_token: str = ""):
user = _require_user(request) user = _require_user(request)

View File

@ -4,6 +4,7 @@ from app.services.strategy_service import (
start_strategy, start_strategy,
stop_strategy, stop_strategy,
get_strategy_status, get_strategy_status,
get_strategy_summary,
get_engine_status, get_engine_status,
get_market_status, get_market_status,
get_strategy_logs as fetch_strategy_logs, get_strategy_logs as fetch_strategy_logs,
@ -27,6 +28,11 @@ def status(request: Request):
user_id = get_request_user_id(request) user_id = get_request_user_id(request)
return get_strategy_status(user_id) return get_strategy_status(user_id)
@router.get("/strategy/summary")
def summary(request: Request):
user_id = get_request_user_id(request)
return get_strategy_summary(user_id)
@router.get("/engine/status") @router.get("/engine/status")
def engine_status(request: Request): def engine_status(request: Request):
user_id = get_request_user_id(request) user_id = get_request_user_id(request)

View File

@ -11,11 +11,12 @@ if str(ENGINE_ROOT) not in sys.path:
from indian_paper_trading_strategy.engine.market import is_market_open, align_to_market_open from indian_paper_trading_strategy.engine.market import is_market_open, align_to_market_open
from indian_paper_trading_strategy.engine.runner import start_engine, stop_engine from indian_paper_trading_strategy.engine.runner import start_engine, stop_engine
from indian_paper_trading_strategy.engine.state import init_paper_state, load_state from indian_paper_trading_strategy.engine.state import init_paper_state, load_state, save_state
from indian_paper_trading_strategy.engine.broker import PaperBroker from indian_paper_trading_strategy.engine.broker import PaperBroker
from indian_paper_trading_strategy.engine.time_utils import frequency_to_timedelta from indian_paper_trading_strategy.engine.time_utils import frequency_to_timedelta
from indian_paper_trading_strategy.engine.db import engine_context from indian_paper_trading_strategy.engine.db import engine_context
from app.broker_store import get_user_broker, set_broker_auth_state
from app.services.db import db_connection from app.services.db import db_connection
from app.services.run_service import ( from app.services.run_service import (
create_strategy_run, create_strategy_run,
@ -25,6 +26,11 @@ from app.services.run_service import (
) )
from app.services.auth_service import get_user_by_id from app.services.auth_service import get_user_by_id
from app.services.email_service import send_email_async from app.services.email_service import send_email_async
from app.services.zerodha_service import (
KiteTokenError,
fetch_funds,
)
from app.services.zerodha_storage import get_session
from psycopg2.extras import Json from psycopg2.extras import Json
from psycopg2 import errors from psycopg2 import errors
@ -298,6 +304,27 @@ def validate_frequency(freq: dict, mode: str):
if unit == "days" and value < 1: if unit == "days" and value < 1:
raise ValueError("Minimum frequency is 1 day") raise ValueError("Minimum frequency is 1 day")
def _validate_live_broker_session(user_id: str):
broker_state = get_user_broker(user_id) or {}
broker_name = (broker_state.get("broker") or "").strip().upper()
if not broker_state.get("connected") or broker_name != "ZERODHA":
return False, broker_state, "broker_not_connected"
session = get_session(user_id)
if not session:
set_broker_auth_state(user_id, "EXPIRED")
return False, broker_state, "broker_auth_required"
try:
fetch_funds(session["api_key"], session["access_token"])
except KiteTokenError:
set_broker_auth_state(user_id, "EXPIRED")
return False, broker_state, "broker_auth_required"
set_broker_auth_state(user_id, "VALID")
return True, broker_state, "ok"
def compute_next_eligible(last_run: str | None, sip_frequency: dict | None): def compute_next_eligible(last_run: str | None, sip_frequency: dict | None):
if not last_run or not sip_frequency: if not last_run or not sip_frequency:
return None return None
@ -313,10 +340,27 @@ def compute_next_eligible(last_run: str | None, sip_frequency: dict | None):
next_dt = align_to_market_open(next_dt) next_dt = align_to_market_open(next_dt)
return next_dt.isoformat() return next_dt.isoformat()
def _last_execution_ts(state: dict, mode: str) -> str | None:
mode_key = (mode or "LIVE").strip().upper()
if mode_key == "LIVE":
return state.get("last_sip_ts")
return state.get("last_run") or state.get("last_sip_ts")
def start_strategy(req, user_id: str): def start_strategy(req, user_id: str):
engine_external = os.getenv("ENGINE_EXTERNAL", "").strip().lower() in {"1", "true", "yes"} engine_external = os.getenv("ENGINE_EXTERNAL", "").strip().lower() in {"1", "true", "yes"}
running_run_id = get_running_run_id(user_id) running_run_id = get_running_run_id(user_id)
if running_run_id: if running_run_id:
running_cfg = _load_config(user_id, running_run_id)
running_mode = (running_cfg.get("mode") or req.mode or "PAPER").strip().upper()
if running_mode == "LIVE":
is_valid, broker_state, failure_reason = _validate_live_broker_session(user_id)
if not is_valid:
return {
"status": "broker_auth_required",
"redirect_url": "/api/broker/login",
"broker": broker_state.get("broker"),
}
if engine_external: if engine_external:
return {"status": "already_running", "run_id": running_run_id} return {"status": "already_running", "run_id": running_run_id}
engine_config = _build_engine_config(user_id, running_run_id, req) engine_config = _build_engine_config(user_id, running_run_id, req)
@ -327,48 +371,77 @@ def start_strategy(req, user_id: str):
return {"status": "restarted", "run_id": running_run_id} return {"status": "restarted", "run_id": running_run_id}
return {"status": "already_running", "run_id": running_run_id} return {"status": "already_running", "run_id": running_run_id}
mode = (req.mode or "PAPER").strip().upper() mode = (req.mode or "PAPER").strip().upper()
if mode != "PAPER":
return {"status": "unsupported_mode"}
frequency_payload = req.sip_frequency.dict() if hasattr(req.sip_frequency, "dict") else dict(req.sip_frequency) frequency_payload = req.sip_frequency.dict() if hasattr(req.sip_frequency, "dict") else dict(req.sip_frequency)
validate_frequency(frequency_payload, mode) validate_frequency(frequency_payload, mode)
initial_cash = float(req.initial_cash) if req.initial_cash is not None else 1_000_000.0 if mode == "PAPER":
initial_cash = float(req.initial_cash) if req.initial_cash is not None else 1_000_000.0
broker_name = "paper"
elif mode == "LIVE":
is_valid, broker_state, failure_reason = _validate_live_broker_session(user_id)
if not is_valid:
return {
"status": "broker_auth_required",
"redirect_url": "/api/broker/login",
"broker": broker_state.get("broker"),
}
initial_cash = None
broker_name = ((broker_state.get("broker") or "ZERODHA").strip().lower())
else:
return {"status": "unsupported_mode"}
meta = {
"sip_amount": req.sip_amount,
"sip_frequency": frequency_payload,
}
if initial_cash is not None:
meta["initial_cash"] = initial_cash
try: try:
run_id = create_strategy_run( run_id = create_strategy_run(
user_id, user_id,
strategy=req.strategy_name, strategy=req.strategy_name,
mode=mode, mode=mode,
broker="paper", broker=broker_name,
meta={ meta=meta,
"sip_amount": req.sip_amount,
"sip_frequency": frequency_payload,
"initial_cash": initial_cash,
},
) )
except errors.UniqueViolation: except errors.UniqueViolation:
return {"status": "already_running"} return {"status": "already_running"}
with engine_context(user_id, run_id): with engine_context(user_id, run_id):
init_paper_state(initial_cash, frequency_payload) if mode == "PAPER":
with db_connection() as conn: init_paper_state(initial_cash, frequency_payload)
with conn: with db_connection() as conn:
with conn.cursor() as cur: with conn:
cur.execute( with conn.cursor() as cur:
""" cur.execute(
INSERT INTO paper_broker_account (user_id, run_id, cash) """
VALUES (%s, %s, %s) INSERT INTO paper_broker_account (user_id, run_id, cash)
ON CONFLICT (user_id, run_id) DO UPDATE VALUES (%s, %s, %s)
SET cash = EXCLUDED.cash ON CONFLICT (user_id, run_id) DO UPDATE
""", SET cash = EXCLUDED.cash
(user_id, run_id, initial_cash), """,
) (user_id, run_id, initial_cash),
PaperBroker(initial_cash=initial_cash) )
PaperBroker(initial_cash=initial_cash)
else:
save_state(
{
"total_invested": 0.0,
"nifty_units": 0.0,
"gold_units": 0.0,
"last_sip_ts": None,
"last_run": None,
},
mode="LIVE",
emit_event=True,
event_meta={"source": "live_start"},
)
config = { config = {
"strategy": req.strategy_name, "strategy": req.strategy_name,
"sip_amount": req.sip_amount, "sip_amount": req.sip_amount,
"sip_frequency": frequency_payload, "sip_frequency": frequency_payload,
"mode": mode, "mode": mode,
"broker": "paper", "broker": broker_name,
"active": True, "active": True,
} }
save_strategy_config(config, user_id, run_id) save_strategy_config(config, user_id, run_id)
@ -387,7 +460,8 @@ def start_strategy(req, user_id: str):
) )
engine_config = dict(config) engine_config = dict(config)
engine_config["initial_cash"] = initial_cash if initial_cash is not None:
engine_config["initial_cash"] = initial_cash
engine_config["run_id"] = run_id engine_config["run_id"] = run_id
engine_config["user_id"] = user_id engine_config["user_id"] = user_id
engine_config["emit_event"] = emit_event_cb engine_config["emit_event"] = emit_event_cb
@ -518,7 +592,7 @@ def get_strategy_status(user_id: str):
mode = (cfg.get("mode") or "LIVE").strip().upper() mode = (cfg.get("mode") or "LIVE").strip().upper()
with engine_context(user_id, run_id): with engine_context(user_id, run_id):
state = load_state(mode=mode) state = load_state(mode=mode)
last_execution_ts = state.get("last_run") or state.get("last_sip_ts") last_execution_ts = _last_execution_ts(state, mode)
sip_frequency = cfg.get("sip_frequency") sip_frequency = cfg.get("sip_frequency")
if not isinstance(sip_frequency, dict): if not isinstance(sip_frequency, dict):
frequency = cfg.get("frequency") frequency = cfg.get("frequency")
@ -578,7 +652,7 @@ def get_engine_status(user_id: str):
mode = (cfg.get("mode") or "LIVE").strip().upper() mode = (cfg.get("mode") or "LIVE").strip().upper()
with engine_context(user_id, run_id): with engine_context(user_id, run_id):
state = load_state(mode=mode) state = load_state(mode=mode)
last_execution_ts = state.get("last_run") or state.get("last_sip_ts") last_execution_ts = _last_execution_ts(state, mode)
sip_frequency = cfg.get("sip_frequency") sip_frequency = cfg.get("sip_frequency")
if isinstance(sip_frequency, dict): if isinstance(sip_frequency, dict):
sip_frequency = { sip_frequency = {
@ -642,6 +716,128 @@ def get_strategy_logs(user_id: str, since_seq: int):
latest_seq = cur.fetchone()[0] latest_seq = cur.fetchone()[0]
return {"events": events, "latest_seq": latest_seq} return {"events": events, "latest_seq": latest_seq}
def _humanize_reason(reason: str | None):
if not reason:
return None
return reason.replace("_", " ").strip().capitalize()
def _issue_message(event: str, message: str | None, data: dict | None, meta: dict | None):
payload = data if isinstance(data, dict) else {}
extra = meta if isinstance(meta, dict) else {}
reason = payload.get("reason") or extra.get("reason")
reason_key = str(reason or "").strip().lower()
if event == "SIP_NO_FILL":
if reason_key == "insufficient_funds":
return "Insufficient funds for this SIP."
if reason_key == "broker_auth_expired":
return "Broker session expired. Reconnect broker."
if reason_key == "no_fill":
return "Order was not filled."
return f"SIP not executed: {_humanize_reason(reason) or 'Unknown reason'}."
if event == "BROKER_AUTH_EXPIRED":
return "Broker session expired. Reconnect broker."
if event == "PRICE_FETCH_ERROR":
return "Could not fetch prices. Retrying."
if event == "HISTORY_LOAD_ERROR":
return "Could not load price history. Retrying."
if event == "ENGINE_ERROR":
return message or "Strategy engine hit an error."
if event == "EXECUTION_BLOCKED":
if reason_key == "market_closed":
return "Market is closed. Execution will resume next session."
return f"Execution blocked: {_humanize_reason(reason) or 'Unknown reason'}."
if event == "ORDER_REJECTED":
return message or payload.get("status_message") or "Broker rejected the order."
if event == "ORDER_CANCELLED":
return message or "Order was cancelled."
return message or _humanize_reason(reason) or "Strategy update available."
def get_strategy_summary(user_id: str):
run_id = get_active_run_id(user_id)
status = get_strategy_status(user_id)
next_eligible_ts = status.get("next_eligible_ts")
summary = {
"run_id": run_id,
"status": status.get("status"),
"tone": "neutral",
"message": "No active strategy.",
"event": None,
"ts": None,
}
issue_row = None
if run_id:
with db_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT event, message, data, meta, ts
FROM engine_event
WHERE user_id = %s
AND run_id = %s
AND event IN (
'SIP_NO_FILL',
'BROKER_AUTH_EXPIRED',
'PRICE_FETCH_ERROR',
'HISTORY_LOAD_ERROR',
'ENGINE_ERROR',
'EXECUTION_BLOCKED',
'ORDER_REJECTED',
'ORDER_CANCELLED'
)
ORDER BY ts DESC
LIMIT 1
""",
(user_id, run_id),
)
issue_row = cur.fetchone()
if issue_row:
event, message, data, meta, ts = issue_row
summary.update(
{
"tone": "error" if event in {"ENGINE_ERROR", "ORDER_REJECTED"} else "warning",
"message": _issue_message(event, message, data, meta),
"event": event,
"ts": _format_local_ts(ts),
}
)
return summary
status_key = (status.get("status") or "IDLE").upper()
if status_key == "WAITING" and next_eligible_ts:
summary.update(
{
"tone": "warning",
"message": f"Waiting until {next_eligible_ts}.",
}
)
return summary
if status_key == "RUNNING":
summary.update(
{
"tone": "success",
"message": "Strategy is running.",
}
)
return summary
if status_key == "STOPPED":
summary.update(
{
"tone": "neutral",
"message": "Strategy is stopped.",
}
)
return summary
return summary
def get_market_status(): def get_market_status():
now = datetime.now() now = datetime.now()
return { return {

View File

@ -23,6 +23,10 @@ class KiteTokenError(KiteApiError):
pass pass
class KitePermissionError(KiteApiError):
pass
def build_login_url(api_key: str, redirect_url: str | None = None) -> str: def build_login_url(api_key: str, redirect_url: str | None = None) -> str:
params = {"api_key": api_key, "v": KITE_VERSION} params = {"api_key": api_key, "v": KITE_VERSION}
redirect_url = (redirect_url or os.getenv("ZERODHA_REDIRECT_URL") or "").strip() redirect_url = (redirect_url or os.getenv("ZERODHA_REDIRECT_URL") or "").strip()
@ -48,7 +52,12 @@ def _request(method: str, url: str, data: dict | None = None, headers: dict | No
payload = {} payload = {}
error_type = payload.get("error_type") or payload.get("status") or "unknown_error" error_type = payload.get("error_type") or payload.get("status") or "unknown_error"
message = payload.get("message") or error_body or err.reason message = payload.get("message") or error_body or err.reason
exc_cls = KiteTokenError if error_type == "TokenException" else KiteApiError if error_type == "TokenException":
exc_cls = KiteTokenError
elif error_type == "PermissionException":
exc_cls = KitePermissionError
else:
exc_cls = KiteApiError
raise exc_cls(err.code, error_type, message) from err raise exc_cls(err.code, error_type, message) from err
return json.loads(body) return json.loads(body)
@ -87,3 +96,112 @@ def fetch_funds(api_key: str, access_token: str) -> dict:
url = f"{KITE_API_BASE}/user/margins" url = f"{KITE_API_BASE}/user/margins"
response = _request("GET", url, headers=_auth_headers(api_key, access_token)) response = _request("GET", url, headers=_auth_headers(api_key, access_token))
return response.get("data", {}) return response.get("data", {})
def fetch_ltp_quotes(api_key: str, access_token: str, instruments: list[str]) -> dict:
symbols = [str(item).strip() for item in instruments if str(item).strip()]
if not symbols:
return {}
query = urllib.parse.urlencode([("i", symbol) for symbol in symbols])
url = f"{KITE_API_BASE}/quote/ltp?{query}"
response = _request("GET", url, headers=_auth_headers(api_key, access_token))
return response.get("data", {})
def fetch_ohlc_quotes(api_key: str, access_token: str, instruments: list[str]) -> dict:
symbols = [str(item).strip() for item in instruments if str(item).strip()]
if not symbols:
return {}
query = urllib.parse.urlencode([("i", symbol) for symbol in symbols])
url = f"{KITE_API_BASE}/quote/ohlc?{query}"
response = _request("GET", url, headers=_auth_headers(api_key, access_token))
return response.get("data", {})
def fetch_historical_candles(
api_key: str,
access_token: str,
instrument_token: int | str,
interval: str,
*,
from_dt,
to_dt,
continuous: bool = False,
oi: bool = False,
) -> list:
params = {
"from": from_dt.strftime("%Y-%m-%d %H:%M:%S"),
"to": to_dt.strftime("%Y-%m-%d %H:%M:%S"),
"continuous": 1 if continuous else 0,
"oi": 1 if oi else 0,
}
query = urllib.parse.urlencode(params)
url = f"{KITE_API_BASE}/instruments/historical/{instrument_token}/{interval}?{query}"
response = _request("GET", url, headers=_auth_headers(api_key, access_token))
return response.get("data", {}).get("candles", [])
def place_order(
api_key: str,
access_token: str,
*,
tradingsymbol: str,
exchange: str,
transaction_type: str,
order_type: str,
quantity: int,
product: str,
price: float | None = None,
validity: str = "DAY",
variety: str = "regular",
market_protection: int | None = None,
tag: str | None = None,
) -> dict:
payload = {
"tradingsymbol": tradingsymbol,
"exchange": exchange,
"transaction_type": transaction_type,
"order_type": order_type,
"quantity": int(quantity),
"product": product,
"validity": validity,
}
if price is not None:
payload["price"] = price
if market_protection is not None:
payload["market_protection"] = market_protection
if tag:
payload["tag"] = tag
url = f"{KITE_API_BASE}/orders/{variety}"
response = _request(
"POST",
url,
data=payload,
headers=_auth_headers(api_key, access_token),
)
return response.get("data", {})
def fetch_orders(api_key: str, access_token: str) -> list:
url = f"{KITE_API_BASE}/orders"
response = _request("GET", url, headers=_auth_headers(api_key, access_token))
return response.get("data", [])
def fetch_order_history(api_key: str, access_token: str, order_id: str) -> list:
url = f"{KITE_API_BASE}/orders/{order_id}"
response = _request("GET", url, headers=_auth_headers(api_key, access_token))
return response.get("data", [])
def cancel_order(
api_key: str,
access_token: str,
*,
order_id: str,
variety: str = "regular",
) -> dict:
url = f"{KITE_API_BASE}/orders/{variety}/{order_id}"
response = _request("DELETE", url, headers=_auth_headers(api_key, access_token))
return response.get("data", {})

View File

@ -1,22 +1,27 @@
from __future__ import annotations from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
import hashlib import hashlib
import math
from psycopg2.extras import execute_values import os
import time
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 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): class Broker(ABC):
@abstractmethod external_orders = False
def place_order(
self, @abstractmethod
symbol: str, def place_order(
side: str, self,
symbol: str,
side: str,
quantity: float, quantity: float,
price: float | None = None, price: float | None = None,
logical_time: datetime | None = None, logical_time: datetime | None = None,
@ -32,8 +37,16 @@ class Broker(ABC):
raise NotImplementedError raise NotImplementedError
@abstractmethod @abstractmethod
def get_funds(self): def get_funds(self):
raise NotImplementedError raise NotImplementedError
class BrokerError(Exception):
pass
class BrokerAuthExpired(BrokerError):
pass
def _local_tz(): def _local_tz():
@ -98,12 +111,332 @@ def _deterministic_id(prefix: str, parts: list[str]) -> str:
return f"{prefix}_{digest}" return f"{prefix}_{digest}"
def _resolve_scope(user_id: str | None, run_id: str | None): def _resolve_scope(user_id: str | None, run_id: str | None):
return get_context(user_id, run_id) return get_context(user_id, run_id)
@dataclass class LiveZerodhaBroker(Broker):
class PaperBroker(Broker): external_orders = True
TERMINAL_STATUSES = {"COMPLETE", "REJECTED", "CANCELLED"}
POLL_TIMEOUT_SECONDS = float(os.getenv("ZERODHA_ORDER_POLL_TIMEOUT", "12"))
POLL_INTERVAL_SECONDS = float(os.getenv("ZERODHA_ORDER_POLL_INTERVAL", "1"))
def __init__(self, user_id: str | None = None, run_id: str | None = None):
self.user_id = user_id
self.run_id = run_id
def _scope(self):
return _resolve_scope(self.user_id, self.run_id)
def _session(self):
from app.services.zerodha_storage import get_session
user_id, _run_id = self._scope()
session = get_session(user_id)
if not session or not session.get("api_key") or not session.get("access_token"):
raise BrokerAuthExpired("Zerodha session missing. Please reconnect broker.")
return session
def _raise_auth_expired(self, exc: Exception):
from app.broker_store import set_broker_auth_state
user_id, _run_id = self._scope()
set_broker_auth_state(user_id, "EXPIRED")
raise BrokerAuthExpired(str(exc)) from exc
def _normalize_symbol(self, symbol: str) -> tuple[str, str]:
cleaned = (symbol or "").strip().upper()
if cleaned.endswith(".NS"):
return cleaned[:-3], "NSE"
if cleaned.endswith(".BO"):
return cleaned[:-3], "BSE"
return cleaned, "NSE"
def _make_tag(self, logical_time: datetime | None, symbol: str, side: str) -> str:
user_id, run_id = self._scope()
logical_ts = logical_time or datetime.utcnow().replace(tzinfo=timezone.utc)
digest = hashlib.sha1(
f"{user_id}|{run_id}|{_normalize_ts_for_id(logical_ts)}|{symbol}|{side}".encode("utf-8")
).hexdigest()[:18]
return f"qf{digest}"
def _normalize_order_payload(
self,
*,
order_id: str,
symbol: str,
side: str,
requested_qty: int,
requested_price: float | None,
history_entry: dict | None,
logical_time: datetime | None,
) -> dict:
entry = history_entry or {}
raw_status = (entry.get("status") or "").upper()
status = raw_status
if raw_status == "COMPLETE":
status = "FILLED"
elif raw_status in {"REJECTED", "CANCELLED"}:
status = raw_status
elif raw_status:
status = "PENDING"
else:
status = "PENDING"
quantity = int(entry.get("quantity") or requested_qty or 0)
filled_qty = int(entry.get("filled_quantity") or 0)
average_price = float(entry.get("average_price") or requested_price or 0.0)
price = float(entry.get("price") or requested_price or average_price or 0.0)
timestamp = (
entry.get("exchange_timestamp")
or entry.get("order_timestamp")
or _format_utc_ts(logical_time or datetime.utcnow().replace(tzinfo=timezone.utc))
)
if timestamp and " " in str(timestamp):
timestamp = str(timestamp).replace(" ", "T")
return {
"id": order_id,
"symbol": symbol,
"side": side.upper().strip(),
"qty": quantity,
"requested_qty": quantity,
"filled_qty": filled_qty,
"price": price,
"requested_price": float(requested_price or price or 0.0),
"average_price": average_price,
"status": status,
"timestamp": timestamp,
"broker_order_id": order_id,
"exchange": entry.get("exchange"),
"tradingsymbol": entry.get("tradingsymbol"),
"status_message": entry.get("status_message") or entry.get("status_message_raw"),
}
def _wait_for_terminal_order(
self,
session: dict,
order_id: str,
*,
symbol: str,
side: str,
requested_qty: int,
requested_price: float | None,
logical_time: datetime | None,
) -> dict:
from app.services.zerodha_service import (
KiteTokenError,
cancel_order,
fetch_order_history,
)
started = time.monotonic()
last_payload = self._normalize_order_payload(
order_id=order_id,
symbol=symbol,
side=side,
requested_qty=requested_qty,
requested_price=requested_price,
history_entry=None,
logical_time=logical_time,
)
while True:
try:
history = fetch_order_history(
session["api_key"],
session["access_token"],
order_id,
)
except KiteTokenError as exc:
self._raise_auth_expired(exc)
if history:
entry = history[-1]
last_payload = self._normalize_order_payload(
order_id=order_id,
symbol=symbol,
side=side,
requested_qty=requested_qty,
requested_price=requested_price,
history_entry=entry,
logical_time=logical_time,
)
raw_status = (entry.get("status") or "").upper()
if raw_status in self.TERMINAL_STATUSES:
return last_payload
if time.monotonic() - started >= self.POLL_TIMEOUT_SECONDS:
try:
cancel_order(
session["api_key"],
session["access_token"],
order_id=order_id,
)
history = fetch_order_history(
session["api_key"],
session["access_token"],
order_id,
)
if history:
return self._normalize_order_payload(
order_id=order_id,
symbol=symbol,
side=side,
requested_qty=requested_qty,
requested_price=requested_price,
history_entry=history[-1],
logical_time=logical_time,
)
except KiteTokenError as exc:
self._raise_auth_expired(exc)
return last_payload
time.sleep(self.POLL_INTERVAL_SECONDS)
def get_funds(self, cur=None):
from app.services.zerodha_service import KiteTokenError, fetch_funds
session = self._session()
try:
data = fetch_funds(session["api_key"], session["access_token"])
except KiteTokenError as exc:
self._raise_auth_expired(exc)
equity = data.get("equity", {}) if isinstance(data, dict) else {}
available = equity.get("available", {}) if isinstance(equity, dict) else {}
cash = (
available.get("live_balance")
or available.get("cash")
or available.get("opening_balance")
or equity.get("net")
or 0.0
)
return {"cash": float(cash), "raw": data}
def get_positions(self):
from app.services.zerodha_service import KiteTokenError, fetch_holdings
session = self._session()
try:
holdings = fetch_holdings(session["api_key"], session["access_token"])
except KiteTokenError as exc:
self._raise_auth_expired(exc)
normalized = []
for item in holdings:
qty = float(item.get("quantity") or item.get("qty") or 0)
avg_price = float(item.get("average_price") or 0)
last_price = float(item.get("last_price") or avg_price or 0)
exchange = item.get("exchange")
suffix = ".NS" if exchange == "NSE" else ".BO" if exchange == "BSE" else ""
normalized.append(
{
"symbol": f"{item.get('tradingsymbol')}{suffix}",
"qty": qty,
"avg_price": avg_price,
"last_price": last_price,
}
)
return normalized
def get_orders(self):
from app.services.zerodha_service import KiteTokenError, fetch_orders
session = self._session()
try:
return fetch_orders(session["api_key"], session["access_token"])
except KiteTokenError as exc:
self._raise_auth_expired(exc)
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,
):
return None
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,
):
from app.services.zerodha_service import KiteTokenError, place_order
if user_id is not None:
self.user_id = user_id
if run_id is not None:
self.run_id = run_id
qty = int(math.floor(float(quantity)))
side = side.upper().strip()
requested_price = float(price) if price is not None else None
if qty <= 0:
return {
"id": _deterministic_id("live_rej", [symbol, side, _stable_num(quantity)]),
"symbol": symbol,
"side": side,
"qty": qty,
"requested_qty": qty,
"filled_qty": 0,
"price": float(price or 0.0),
"requested_price": float(price or 0.0),
"average_price": 0.0,
"status": "REJECTED",
"timestamp": _format_utc_ts(logical_time or datetime.utcnow().replace(tzinfo=timezone.utc)),
"status_message": "Computed quantity is less than 1 share",
}
session = self._session()
tradingsymbol, exchange = self._normalize_symbol(symbol)
tag = self._make_tag(logical_time, symbol, side)
try:
placed = place_order(
session["api_key"],
session["access_token"],
tradingsymbol=tradingsymbol,
exchange=exchange,
transaction_type=side,
order_type="MARKET",
quantity=qty,
product="CNC",
validity="DAY",
variety="regular",
market_protection=-1,
tag=tag,
)
except KiteTokenError as exc:
self._raise_auth_expired(exc)
order_id = placed.get("order_id")
if not order_id:
raise BrokerError("Zerodha order placement did not return an order_id")
return self._wait_for_terminal_order(
session,
order_id,
symbol=symbol,
side=side,
requested_qty=qty,
requested_price=requested_price,
logical_time=logical_time,
)
@dataclass
class PaperBroker(Broker):
initial_cash: float initial_cash: float
store_path: str | None = None store_path: str | None = None
@ -578,16 +911,20 @@ class PaperBroker(Broker):
], ],
) )
order = { order = {
"id": order_id, "id": order_id,
"symbol": symbol, "symbol": symbol,
"side": side, "side": side,
"qty": qty, "qty": qty,
"price": price, "requested_qty": qty,
"status": "REJECTED", "filled_qty": 0.0,
"timestamp": timestamp_str, "price": price,
"_logical_time": logical_ts_str, "requested_price": price,
} "average_price": 0.0,
"status": "REJECTED",
"timestamp": timestamp_str,
"_logical_time": logical_ts_str,
}
if qty <= 0 or price <= 0: if qty <= 0 or price <= 0:
store.setdefault("orders", []).append(order) store.setdefault("orders", []).append(order)
@ -607,16 +944,18 @@ class PaperBroker(Broker):
new_qty = float(existing.get("qty", 0)) + qty new_qty = float(existing.get("qty", 0)) + qty
prev_cost = float(existing.get("qty", 0)) * float(existing.get("avg_price", 0)) 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 avg_price = (prev_cost + cost) / new_qty if new_qty else price
positions[symbol] = { positions[symbol] = {
"qty": new_qty, "qty": new_qty,
"avg_price": avg_price, "avg_price": avg_price,
"last_price": price, "last_price": price,
} }
order["status"] = "FILLED" order["status"] = "FILLED"
trade = { order["filled_qty"] = qty
"id": _deterministic_id("trd", [order_id]), order["average_price"] = price
"order_id": order_id, trade = {
"symbol": symbol, "id": _deterministic_id("trd", [order_id]),
"order_id": order_id,
"symbol": symbol,
"side": side, "side": side,
"qty": qty, "qty": qty,
"price": price, "price": price,
@ -634,12 +973,14 @@ class PaperBroker(Broker):
existing["last_price"] = price existing["last_price"] = price
positions[symbol] = existing positions[symbol] = existing
else: else:
positions.pop(symbol, None) positions.pop(symbol, None)
order["status"] = "FILLED" order["status"] = "FILLED"
trade = { order["filled_qty"] = qty
"id": _deterministic_id("trd", [order_id]), order["average_price"] = price
"order_id": order_id, trade = {
"symbol": symbol, "id": _deterministic_id("trd", [order_id]),
"order_id": order_id,
"symbol": symbol,
"side": side, "side": side,
"qty": qty, "qty": qty,
"price": price, "price": price,

View File

@ -1,81 +1,110 @@
# engine/data.py # engine/data.py
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
import os import os
import threading import threading
import pandas as pd import pandas as pd
import yfinance as yf import yfinance as yf
ENGINE_ROOT = Path(__file__).resolve().parents[1] ENGINE_ROOT = Path(__file__).resolve().parents[1]
HISTORY_DIR = ENGINE_ROOT / "storage" / "history" HISTORY_DIR = ENGINE_ROOT / "storage" / "history"
ALLOW_PRICE_CACHE = os.getenv("ALLOW_PRICE_CACHE", "0").strip().lower() in {"1", "true", "yes"} ALLOW_PRICE_CACHE = os.getenv("ALLOW_PRICE_CACHE", "0").strip().lower() in {"1", "true", "yes"}
_LAST_PRICE: dict[str, dict[str, object]] = {} _LAST_PRICE: dict[str, dict[str, object]] = {}
_LAST_PRICE_LOCK = threading.Lock() _LAST_PRICE_LOCK = threading.Lock()
def _set_last_price(ticker: str, price: float, source: str): def _history_cache_file(ticker: str, provider: str = "yfinance") -> Path:
now = datetime.now(timezone.utc) safe_ticker = (ticker or "").replace(":", "_").replace("/", "_")
with _LAST_PRICE_LOCK: return HISTORY_DIR / f"{safe_ticker}.csv"
_LAST_PRICE[ticker] = {"price": float(price), "source": source, "ts": now}
def _set_last_price(
def get_price_snapshot(ticker: str) -> dict[str, object] | None: ticker: str,
price: float,
source: str,
*,
provider: str | None = None,
instrument_token: int | None = None,
):
now = datetime.now(timezone.utc)
with _LAST_PRICE_LOCK:
payload = {"price": float(price), "source": source, "ts": now}
if provider:
payload["provider"] = provider
if instrument_token is not None:
payload["instrument_token"] = int(instrument_token)
_LAST_PRICE[ticker] = payload
def get_price_snapshot(ticker: str) -> dict[str, object] | None:
with _LAST_PRICE_LOCK: with _LAST_PRICE_LOCK:
data = _LAST_PRICE.get(ticker) data = _LAST_PRICE.get(ticker)
if not data: if not data:
return None return None
return dict(data) return dict(data)
def _get_last_live_price(ticker: str) -> float | None: def _get_last_live_price(ticker: str, provider: str | None = None) -> float | None:
with _LAST_PRICE_LOCK: with _LAST_PRICE_LOCK:
data = _LAST_PRICE.get(ticker) data = _LAST_PRICE.get(ticker)
if not data: if not data:
return None return None
if data.get("source") == "live": if data.get("source") == "live":
return float(data.get("price", 0)) if provider and data.get("provider") not in {None, provider}:
return None return None
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(): def _cached_last_close(ticker: str, provider: str = "yfinance") -> float | None:
return None file = _history_cache_file(ticker, provider=provider)
df = pd.read_csv(file) if not file.exists():
if df.empty or "Close" not in df.columns: return None
return None df = pd.read_csv(file)
return float(df["Close"].iloc[-1]) 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 def fetch_live_price(
try: ticker,
df = yf.download( allow_cache: bool | None = None,
ticker, *,
period="1d", provider: str = "yfinance",
interval="1m", user_id: str | None = None,
run_id: str | None = None,
):
if allow_cache is None:
allow_cache = ALLOW_PRICE_CACHE
try:
df = yf.download(
ticker,
period="1d",
interval="1m",
auto_adjust=True, auto_adjust=True,
progress=False, progress=False,
timeout=5, timeout=5,
) )
if df is not None and not df.empty: if df is not None and not df.empty:
price = float(df["Close"].iloc[-1]) close_value = df["Close"].iloc[-1]
_set_last_price(ticker, price, "live") if hasattr(close_value, "iloc"):
return price close_value = close_value.iloc[-1]
except Exception: price = float(close_value)
pass _set_last_price(ticker, price, "live", provider="yfinance")
return price
if allow_cache: except Exception:
last_live = _get_last_live_price(ticker) pass
if last_live is not None:
return last_live if allow_cache:
last_live = _get_last_live_price(ticker, provider="yfinance")
cached = _cached_last_close(ticker) if last_live is not None:
if cached is not None: return last_live
_set_last_price(ticker, cached, "cache")
return cached cached = _cached_last_close(ticker, provider="yfinance")
if cached is not None:
raise RuntimeError(f"No live data for {ticker}") _set_last_price(ticker, cached, "cache", provider="yfinance")
return cached
raise RuntimeError(f"No live data for {ticker}")

View File

@ -1,11 +1,11 @@
# engine/execution.py # engine/execution.py
from datetime import datetime, timezone from datetime import datetime, timezone
from indian_paper_trading_strategy.engine.state import load_state, save_state 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.broker import Broker, BrokerAuthExpired
from indian_paper_trading_strategy.engine.ledger import log_event, event_exists 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.db import insert_engine_event, run_with_retry
from indian_paper_trading_strategy.engine.time_utils import compute_logical_time from indian_paper_trading_strategy.engine.time_utils import compute_logical_time
def _as_float(value): def _as_float(value):
if hasattr(value, "item"): if hasattr(value, "item"):
@ -20,138 +20,457 @@ def _as_float(value):
pass pass
return float(value) return float(value)
def _local_tz(): def _local_tz():
return datetime.now().astimezone().tzinfo return datetime.now().astimezone().tzinfo
def try_execute_sip(
now, def _normalize_now(now):
market_open, if now.tzinfo is None:
return now.replace(tzinfo=_local_tz())
return now
def _resolve_timing(state, now_ts, sip_interval):
force_execute = state.get("last_sip_ts") is None
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 False, last, None
logical_time = compute_logical_time(now_ts, last, sip_interval)
return True, last, logical_time
def _order_fill(order):
if not isinstance(order, dict):
return 0.0, 0.0
filled_qty = float(order.get("filled_qty") or 0.0)
average_price = float(order.get("average_price") or order.get("price") or 0.0)
return filled_qty, average_price
def _apply_filled_orders_to_state(state, orders):
nifty_filled = 0.0
gold_filled = 0.0
total_spent = 0.0
for order in orders:
filled_qty, average_price = _order_fill(order)
if filled_qty <= 0:
continue
symbol = (order.get("symbol") or "").upper()
if symbol.startswith("NIFTYBEES"):
nifty_filled += filled_qty
elif symbol.startswith("GOLDBEES"):
gold_filled += filled_qty
total_spent += filled_qty * average_price
if nifty_filled:
state["nifty_units"] += nifty_filled
if gold_filled:
state["gold_units"] += gold_filled
if total_spent:
state["total_invested"] += total_spent
return {
"nifty_units": nifty_filled,
"gold_units": gold_filled,
"amount": total_spent,
}
def _record_live_order_events(cur, orders, event_ts):
for order in orders:
insert_engine_event(cur, "ORDER_PLACED", data=order, ts=event_ts)
filled_qty, _average_price = _order_fill(order)
status = (order.get("status") or "").upper()
if filled_qty > 0:
insert_engine_event(
cur,
"TRADE_EXECUTED",
data={
"order_id": order.get("id"),
"symbol": order.get("symbol"),
"side": order.get("side"),
"qty": filled_qty,
"price": order.get("average_price") or order.get("price"),
},
ts=event_ts,
)
insert_engine_event(cur, "ORDER_FILLED", data={"order_id": order.get("id")}, ts=event_ts)
elif status == "REJECTED":
insert_engine_event(cur, "ORDER_REJECTED", data=order, ts=event_ts)
elif status == "CANCELLED":
insert_engine_event(cur, "ORDER_CANCELLED", data=order, ts=event_ts)
elif status == "PENDING":
insert_engine_event(cur, "ORDER_PENDING", data=order, ts=event_ts)
def _try_execute_sip_paper(
now,
market_open,
sip_interval,
sip_amount,
sp_price,
gd_price,
eq_w,
gd_w,
broker: Broker | None,
mode: str | None,
):
def _op(cur, _conn):
now_ts = _normalize_now(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)
if not market_open:
return state, False
should_run, _last, logical_time = _resolve_timing(state, now_ts, sip_interval)
if not should_run:
return state, False
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",
{
"last_sip_ts": state.get("last_sip_ts"),
"now": now_ts.isoformat(),
},
cur=cur,
ts=event_ts,
)
orders = [
broker.place_order(
"NIFTYBEES.NS",
"BUY",
nifty_qty,
sp_price_val,
cur=cur,
logical_time=logical_time,
),
broker.place_order(
"GOLDBEES.NS",
"BUY",
gold_qty,
gd_price_val,
cur=cur,
logical_time=logical_time,
),
]
applied = _apply_filled_orders_to_state(state, orders)
executed = applied["amount"] > 0
if not executed:
return state, False
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["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": applied["nifty_units"],
"gold_units": applied["gold_units"],
"nifty_price": sp_price_val,
"gold_price": gd_price_val,
"amount": applied["amount"],
},
cur=cur,
ts=event_ts,
logical_time=logical_time,
)
return state, True
return run_with_retry(_op)
def _prepare_live_execution(now_ts, sip_interval, sip_amount_val, sp_price_val, gd_price_val, nifty_qty, gold_qty, mode):
def _op(cur, _conn):
state = load_state(mode=mode, cur=cur, for_update=True)
should_run, _last, logical_time = _resolve_timing(state, now_ts, sip_interval)
if not should_run:
return {"ready": False, "state": state}
if event_exists("SIP_EXECUTED", logical_time, cur=cur):
return {"ready": False, "state": state}
if event_exists("SIP_ORDER_ATTEMPTED", logical_time, cur=cur):
return {"ready": False, "state": state}
log_event(
"SIP_ORDER_ATTEMPTED",
{
"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=now_ts,
logical_time=logical_time,
)
return {"ready": True, "state": state, "logical_time": logical_time}
return run_with_retry(_op)
def _finalize_live_execution(
*,
now_ts,
mode,
logical_time,
orders,
funds_after,
sp_price_val,
gd_price_val,
auth_failed: bool = False,
failure_reason: str | None = None,
):
def _op(cur, _conn):
state = load_state(mode=mode, cur=cur, for_update=True)
_record_live_order_events(cur, orders, now_ts)
applied = _apply_filled_orders_to_state(state, orders)
executed = applied["amount"] > 0
if funds_after is not None:
cash_after = funds_after.get("cash")
if cash_after is not None:
state["cash"] = float(cash_after)
if executed:
state["last_run"] = now_ts.isoformat()
state["last_sip_ts"] = now_ts.isoformat()
save_state(
state,
mode=mode,
cur=cur,
emit_event=True,
event_meta={"source": "sip_live"},
)
if executed:
log_event(
"SIP_EXECUTED",
{
"nifty_units": applied["nifty_units"],
"gold_units": applied["gold_units"],
"nifty_price": sp_price_val,
"gold_price": gd_price_val,
"amount": applied["amount"],
},
cur=cur,
ts=now_ts,
logical_time=logical_time,
)
else:
insert_engine_event(
cur,
"SIP_NO_FILL",
data={
"reason": failure_reason or ("broker_auth_expired" if auth_failed else "no_fill"),
"orders": orders,
},
ts=now_ts,
)
return state, executed
return run_with_retry(_op)
def _try_execute_sip_live(
now,
market_open,
sip_interval,
sip_amount,
sp_price,
gd_price,
eq_w,
gd_w,
broker: Broker | None,
mode: str | None,
):
now_ts = _normalize_now(now)
if not market_open or broker is None:
return load_state(mode=mode), 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
prepared = _prepare_live_execution(
now_ts,
sip_interval,
sip_amount_val,
sp_price_val,
gd_price_val,
nifty_qty,
gold_qty,
mode,
)
if not prepared.get("ready"):
return prepared.get("state") or load_state(mode=mode), False
logical_time = prepared["logical_time"]
orders = []
funds_after = None
failure_reason = None
auth_failed = False
try:
funds_before = broker.get_funds()
cash = funds_before.get("cash")
if cash is not None and float(cash) < sip_amount_val:
failure_reason = "insufficient_funds"
else:
if nifty_qty > 0:
orders.append(
broker.place_order(
"NIFTYBEES.NS",
"BUY",
nifty_qty,
sp_price_val,
logical_time=logical_time,
)
)
if gold_qty > 0:
orders.append(
broker.place_order(
"GOLDBEES.NS",
"BUY",
gold_qty,
gd_price_val,
logical_time=logical_time,
)
)
funds_after = broker.get_funds()
except BrokerAuthExpired:
auth_failed = True
try:
funds_after = broker.get_funds()
except Exception:
funds_after = None
except Exception as exc:
failure_reason = str(exc)
try:
funds_after = broker.get_funds()
except Exception:
funds_after = None
state, _executed = _finalize_live_execution(
now_ts=now_ts,
mode=mode,
logical_time=logical_time,
orders=orders,
funds_after=funds_after,
sp_price_val=sp_price_val,
gd_price_val=gd_price_val,
auth_failed=False,
failure_reason=failure_reason,
)
raise
state, executed = _finalize_live_execution(
now_ts=now_ts,
mode=mode,
logical_time=logical_time,
orders=orders,
funds_after=funds_after,
sp_price_val=sp_price_val,
gd_price_val=gd_price_val,
auth_failed=auth_failed,
failure_reason=failure_reason,
)
if auth_failed:
raise BrokerAuthExpired("Broker session expired during live order execution")
return state, executed
def try_execute_sip(
now,
market_open,
sip_interval, sip_interval,
sip_amount, sip_amount,
sp_price, sp_price,
gd_price, gd_price,
eq_w, eq_w,
gd_w, gd_w,
broker: Broker | None = None, broker: Broker | None = None,
mode: str | None = "LIVE", mode: str | None = "LIVE",
): ):
def _op(cur, _conn): if broker is None:
if now.tzinfo is None: return load_state(mode=mode), False
now_ts = now.replace(tzinfo=_local_tz()) if getattr(broker, "external_orders", False):
else: return _try_execute_sip_live(
now_ts = now now,
event_ts = now_ts market_open,
log_event("DEBUG_ENTER_TRY_EXECUTE", { sip_interval,
"now": now_ts.isoformat(), sip_amount,
}, cur=cur, ts=event_ts) sp_price,
gd_price,
state = load_state(mode=mode, cur=cur, for_update=True) eq_w,
gd_w,
force_execute = state.get("last_sip_ts") is None broker,
mode,
if not market_open: )
return state, False return _try_execute_sip_paper(
now,
last = state.get("last_sip_ts") or state.get("last_run") market_open,
if last and not force_execute: sip_interval,
try: sip_amount,
last_dt = datetime.fromisoformat(last) sp_price,
except ValueError: gd_price,
last_dt = None eq_w,
if last_dt: gd_w,
if last_dt.tzinfo is None: broker,
last_dt = last_dt.replace(tzinfo=_local_tz()) mode,
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)

View File

@ -1,34 +1,48 @@
# engine/history.py # engine/history.py
import yfinance as yf import yfinance as yf
import pandas as pd import pandas as pd
from pathlib import Path from pathlib import Path
ENGINE_ROOT = Path(__file__).resolve().parents[1] ENGINE_ROOT = Path(__file__).resolve().parents[1]
STORAGE_DIR = ENGINE_ROOT / "storage" STORAGE_DIR = ENGINE_ROOT / "storage"
STORAGE_DIR.mkdir(exist_ok=True) STORAGE_DIR.mkdir(exist_ok=True)
CACHE_DIR = STORAGE_DIR / "history" CACHE_DIR = STORAGE_DIR / "history"
CACHE_DIR.mkdir(exist_ok=True) CACHE_DIR.mkdir(exist_ok=True)
def load_monthly_close(ticker, years=10): def _cache_file(ticker: str, provider: str = "yfinance") -> Path:
file = CACHE_DIR / f"{ticker}.csv" safe_ticker = (ticker or "").replace(":", "_").replace("/", "_")
return CACHE_DIR / f"{safe_ticker}.csv"
if file.exists():
df = pd.read_csv(file, parse_dates=["Date"], index_col="Date")
return df["Close"] def _read_monthly_close(file: Path):
df = pd.read_csv(file, parse_dates=["Date"], index_col="Date")
df = yf.download( return df["Close"]
ticker, def load_monthly_close(
period=f"{years}y", ticker,
auto_adjust=True, years=10,
*,
provider: str = "yfinance",
user_id: str | None = None,
run_id: str | None = None,
):
file = _cache_file(ticker, provider="yfinance")
if file.exists():
return _read_monthly_close(file)
df = yf.download(
ticker,
period=f"{years}y",
auto_adjust=True,
progress=False, progress=False,
timeout=5, timeout=5,
) )
if df.empty: if df.empty:
raise RuntimeError(f"No history for {ticker}") raise RuntimeError(f"No history for {ticker}")
series = df["Close"].resample("M").last() series = df["Close"].resample("ME").last()
series.to_csv(file, header=["Close"]) series.to_csv(file, header=["Close"])
return series return series

View File

@ -1,17 +1,20 @@
import os import os
import threading import threading
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from indian_paper_trading_strategy.engine.market import is_market_open, align_to_market_open from psycopg2.extras import Json
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.market import is_market_open, align_to_market_open
from indian_paper_trading_strategy.engine.mtm import log_mtm, should_log_mtm 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, LiveZerodhaBroker, BrokerAuthExpired
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.state import load_state
from indian_paper_trading_strategy.engine.strategy import allocation from indian_paper_trading_strategy.engine.data import fetch_live_price
from indian_paper_trading_strategy.engine.time_utils import normalize_logical_time 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 app.services.zerodha_service import KiteTokenError
from indian_paper_trading_strategy.engine.db import db_transaction, insert_engine_event, run_with_retry, get_context, set_context from indian_paper_trading_strategy.engine.db import db_transaction, insert_engine_event, run_with_retry, get_context, set_context
@ -134,12 +137,75 @@ def _clear_runner(user_id: str, run_id: str):
with _RUNNERS_LOCK: with _RUNNERS_LOCK:
_RUNNERS.pop(key, None) _RUNNERS.pop(key, None)
def can_execute(now: datetime) -> tuple[bool, str]: def can_execute(now: datetime) -> tuple[bool, str]:
if not is_market_open(now): if not is_market_open(now):
return False, "MARKET_CLOSED" return False, "MARKET_CLOSED"
return True, "OK" return True, "OK"
def _engine_loop(config, stop_event: threading.Event):
def _last_execution_anchor(state: dict, mode: str) -> str | None:
mode_key = (mode or "LIVE").strip().upper()
if mode_key == "LIVE":
return state.get("last_sip_ts")
return state.get("last_run") or state.get("last_sip_ts")
def _pause_for_auth_expiry(
user_id: str,
run_id: str,
reason: str,
emit_event_cb=None,
):
def _op(cur, _conn):
now = datetime.utcnow().replace(tzinfo=timezone.utc)
cur.execute(
"""
UPDATE strategy_run
SET status = 'STOPPED',
stopped_at = %s,
meta = COALESCE(meta, '{}'::jsonb) || %s
WHERE user_id = %s AND run_id = %s
""",
(
now,
Json({"reason": "auth_expired", "lifecycle": "auth_expired"}),
user_id,
run_id,
),
)
run_with_retry(_op)
_set_state(
user_id,
run_id,
state="STOPPED",
last_heartbeat_ts=datetime.utcnow().isoformat() + "Z",
)
_update_engine_status(user_id, run_id, "STOPPED")
log_event(
event="BROKER_AUTH_EXPIRED",
message="Broker authentication expired",
meta={"reason": reason},
)
log_event(
event="ENGINE_PAUSED",
message="Engine paused until broker reconnect",
meta={"reason": "auth_expired"},
)
if callable(emit_event_cb):
emit_event_cb(
event="BROKER_AUTH_EXPIRED",
message="Broker authentication expired",
meta={"reason": reason},
)
emit_event_cb(
event="STRATEGY_STOPPED",
message="Strategy stopped",
meta={"reason": "broker_auth_expired"},
)
def _engine_loop(config, stop_event: threading.Event):
print("Strategy engine started with config:", config) print("Strategy engine started with config:", config)
user_id = config.get("user_id") user_id = config.get("user_id")
@ -170,35 +236,38 @@ def _engine_loop(config, stop_event: threading.Event):
if emit_event_cb: if emit_event_cb:
emit_event_cb(event=event, message=message, meta=meta or {}) emit_event_cb(event=event, message=message, meta=meta or {})
print(f"[ENGINE] {event} {message} {meta or {}}", flush=True) print(f"[ENGINE] {event} {message} {meta or {}}", flush=True)
mode = (config.get("mode") or "LIVE").strip().upper() mode = (config.get("mode") or "LIVE").strip().upper()
if mode not in {"PAPER", "LIVE"}: if mode not in {"PAPER", "LIVE"}:
mode = "LIVE" mode = "LIVE"
broker_type = config.get("broker") or "paper" broker_type = (config.get("broker") or ("paper" if mode == "PAPER" else "zerodha")).strip().lower()
if broker_type != "paper": initial_cash = float(config.get("initial_cash", 0))
broker_type = "paper" if broker_type == "paper":
if broker_type == "paper": mode = "PAPER"
mode = "PAPER" broker = PaperBroker(initial_cash=initial_cash)
initial_cash = float(config.get("initial_cash", 0)) log_event(
broker = PaperBroker(initial_cash=initial_cash) event="DEBUG_PAPER_STORE_PATH",
log_event( message="Paper broker store path",
event="DEBUG_PAPER_STORE_PATH", meta={
message="Paper broker store path", "cwd": os.getcwd(),
meta={ "paper_store_path": str(broker.store_path) if hasattr(broker, "store_path") else "NO_STORE_PATH",
"cwd": os.getcwd(), "abs_store_path": os.path.abspath(str(broker.store_path)) if hasattr(broker, "store_path") else "N/A",
"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(
if emit_event_cb: event="DEBUG_PAPER_STORE_PATH",
emit_event_cb( message="Paper broker store path",
event="DEBUG_PAPER_STORE_PATH", meta={
message="Paper broker store path", "cwd": os.getcwd(),
meta={ "paper_store_path": str(broker.store_path) if hasattr(broker, "store_path") else "NO_STORE_PATH",
"cwd": os.getcwd(), "abs_store_path": os.path.abspath(str(broker.store_path)) if hasattr(broker, "store_path") else "N/A",
"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", )
}, elif broker_type == "zerodha":
) broker = LiveZerodhaBroker(user_id=scope_user, run_id=scope_run)
else:
raise ValueError(f"Unsupported broker: {broker_type}")
market_data_provider = "yfinance"
log_event("ENGINE_START", { log_event("ENGINE_START", {
"strategy": strategy_name, "strategy": strategy_name,
@ -243,8 +312,8 @@ def _engine_loop(config, stop_event: threading.Event):
delta = timedelta(days=freq) delta = timedelta(days=freq)
# Gate 2: time to SIP # Gate 2: time to SIP
last_run = state.get("last_run") or state.get("last_sip_ts") last_run = _last_execution_anchor(state, mode)
is_first_run = last_run is None is_first_run = last_run is None
now = datetime.now() now = datetime.now()
debug_event( debug_event(
"ENGINE_LOOP_TICK", "ENGINE_LOOP_TICK",
@ -280,25 +349,51 @@ def _engine_loop(config, stop_event: threading.Event):
sleep_with_heartbeat(60, stop_event, scope_user, scope_run) sleep_with_heartbeat(60, stop_event, scope_user, scope_run)
continue continue
try: try:
debug_event("PRICE_FETCH_START", "fetching live prices", {"tickers": [NIFTY, GOLD]}) debug_event("PRICE_FETCH_START", "fetching live prices", {"tickers": [NIFTY, GOLD]})
nifty_price = fetch_live_price(NIFTY) nifty_price = fetch_live_price(
gold_price = fetch_live_price(GOLD) NIFTY,
debug_event( provider=market_data_provider,
"PRICE_FETCHED", user_id=scope_user,
"fetched live prices", run_id=scope_run,
{"nifty_price": float(nifty_price), "gold_price": float(gold_price)}, )
) gold_price = fetch_live_price(
except Exception as exc: GOLD,
debug_event("PRICE_FETCH_ERROR", "live price fetch failed", {"error": str(exc)}) provider=market_data_provider,
user_id=scope_user,
run_id=scope_run,
)
debug_event(
"PRICE_FETCHED",
"fetched live prices",
{"nifty_price": float(nifty_price), "gold_price": float(gold_price)},
)
except KiteTokenError as exc:
_pause_for_auth_expiry(scope_user, scope_run, str(exc), emit_event_cb=emit_event_cb)
break
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) sleep_with_heartbeat(30, stop_event, scope_user, scope_run)
continue continue
try: try:
nifty_hist = load_monthly_close(NIFTY) nifty_hist = load_monthly_close(
gold_hist = load_monthly_close(GOLD) NIFTY,
except Exception as exc: provider=market_data_provider,
debug_event("HISTORY_LOAD_ERROR", "history load failed", {"error": str(exc)}) user_id=scope_user,
run_id=scope_run,
)
gold_hist = load_monthly_close(
GOLD,
provider=market_data_provider,
user_id=scope_user,
run_id=scope_run,
)
except KiteTokenError as exc:
_pause_for_auth_expiry(scope_user, scope_run, str(exc), emit_event_cb=emit_event_cb)
break
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) sleep_with_heartbeat(30, stop_event, scope_user, scope_run)
continue continue
@ -449,6 +544,9 @@ def _engine_loop(config, stop_event: threading.Event):
) )
sleep_with_heartbeat(30, stop_event, scope_user, scope_run) sleep_with_heartbeat(30, stop_event, scope_user, scope_run)
except BrokerAuthExpired as exc:
_pause_for_auth_expiry(scope_user, scope_run, str(exc), emit_event_cb=emit_event_cb)
print(f"[ENGINE] broker auth expired for run {scope_run}: {exc}", flush=True)
except Exception as e: except Exception as e:
_set_state(scope_user, scope_run, state="ERROR", last_heartbeat_ts=datetime.utcnow().isoformat() + "Z") _set_state(scope_user, scope_run, state="ERROR", last_heartbeat_ts=datetime.utcnow().isoformat() + "Z")
_update_engine_status(scope_user, scope_run, "ERROR") _update_engine_status(scope_user, scope_run, "ERROR")