commit 08c6741aaea9c3d09a866df06d83509c42cac55f Author: thigazhezhilan Date: Sun Feb 1 13:06:44 2026 +0000 Initial backend commit diff --git a/Backend.zip b/Backend.zip new file mode 100644 index 0000000..f008047 Binary files /dev/null and b/Backend.zip differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..71b5b09 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +Control plane API skeleton. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..14e63b9 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,117 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +# version_path_separator = newline +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/admin_auth.py b/app/admin_auth.py new file mode 100644 index 0000000..f1d4646 --- /dev/null +++ b/app/admin_auth.py @@ -0,0 +1,71 @@ +from fastapi import HTTPException, Request + +from app.services.auth_service import get_user_for_session +from app.services.db import db_connection + +SESSION_COOKIE_NAME = "session_id" + + +def _resolve_role(row) -> str: + role = row[2] + if role: + return role + if row[4]: + return "SUPER_ADMIN" + if row[3]: + return "ADMIN" + return "USER" + + +def require_admin(request: Request): + session_id = request.cookies.get(SESSION_COOKIE_NAME) + if not session_id: + raise HTTPException(status_code=401, detail="Not authenticated") + user = get_user_for_session(session_id) + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + + with db_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT id, username, role, is_admin, is_super_admin FROM app_user WHERE id = %s", + (user["id"],), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=403, detail="Admin access required") + role = _resolve_role(row) + if role not in ("ADMIN", "SUPER_ADMIN"): + raise HTTPException(status_code=403, detail="Admin access required") + return { + "id": row[0], + "username": row[1], + "role": role, + } + + +def require_super_admin(request: Request): + session_id = request.cookies.get(SESSION_COOKIE_NAME) + if not session_id: + raise HTTPException(status_code=401, detail="Not authenticated") + user = get_user_for_session(session_id) + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + + with db_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT id, username, role, is_admin, is_super_admin FROM app_user WHERE id = %s", + (user["id"],), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=403, detail="Super admin access required") + role = _resolve_role(row) + if role != "SUPER_ADMIN": + raise HTTPException(status_code=403, detail="Super admin access required") + return { + "id": row[0], + "username": row[1], + "role": role, + } diff --git a/app/admin_models.py b/app/admin_models.py new file mode 100644 index 0000000..6c9ca86 --- /dev/null +++ b/app/admin_models.py @@ -0,0 +1,163 @@ +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel + + +class TopError(BaseModel): + ts: Optional[datetime] + event: str + message: Optional[str] + source: str + user_id: Optional[str] + run_id: Optional[str] + + +class OverviewResponse(BaseModel): + total_users: int + users_logged_in_last_24h: int + total_runs: int + running_runs: int + stopped_runs: int + error_runs: int + live_runs_count: int + paper_runs_count: int + orders_last_24h: int + trades_last_24h: int + sip_executed_last_24h: int + top_errors: list[TopError] + + +class UserSummary(BaseModel): + user_id: str + username: str + role: str + is_admin: bool + created_at: Optional[datetime] + last_login_at: Optional[datetime] + active_run_id: Optional[str] + active_run_status: Optional[str] + runs_count: int + broker_connected: bool + + +class UsersResponse(BaseModel): + page: int + page_size: int + total: int + users: list[UserSummary] + + +class RunSummary(BaseModel): + run_id: str + user_id: str + status: str + created_at: Optional[datetime] + started_at: Optional[datetime] + stopped_at: Optional[datetime] + strategy: Optional[str] + mode: Optional[str] + broker: Optional[str] + sip_amount: Optional[float] + sip_frequency_value: Optional[int] + sip_frequency_unit: Optional[str] + last_event_time: Optional[datetime] + last_sip_time: Optional[datetime] + next_sip_time: Optional[datetime] + order_count: int + trade_count: int + equity_latest: Optional[float] + pnl_latest: Optional[float] + + +class RunsResponse(BaseModel): + page: int + page_size: int + total: int + runs: list[RunSummary] + + +class EventItem(BaseModel): + ts: Optional[datetime] + source: str + event: str + message: Optional[str] + level: Optional[str] + run_id: Optional[str] + meta: Optional[dict[str, Any]] + + +class CapitalSummary(BaseModel): + cash: Optional[float] + invested: Optional[float] + mtm: Optional[float] + equity: Optional[float] + pnl: Optional[float] + + +class UserDetailResponse(BaseModel): + user: UserSummary + runs: list[RunSummary] + current_config: Optional[dict[str, Any]] + events: list[EventItem] + capital_summary: CapitalSummary + + +class EngineStatusResponse(BaseModel): + status: Optional[str] + last_updated: Optional[datetime] + + +class RunDetailResponse(BaseModel): + run: RunSummary + config: Optional[dict[str, Any]] + engine_status: Optional[EngineStatusResponse] + state_snapshot: Optional[dict[str, Any]] + ledger_events: list[dict[str, Any]] + orders: list[dict[str, Any]] + trades: list[dict[str, Any]] + invariants: dict[str, Any] + + +class InvariantsResponse(BaseModel): + running_runs_per_user_violations: int + orphan_rows: int + duplicate_logical_time: int + negative_cash: int + invalid_qty: int + stale_running_runs: int + + +class SupportTicketSummary(BaseModel): + ticket_id: str + name: str + email: str + subject: str + message: str + status: str + created_at: Optional[datetime] + updated_at: Optional[datetime] + + +class SupportTicketsResponse(BaseModel): + page: int + page_size: int + total: int + tickets: list[SupportTicketSummary] + + +class DeleteSupportTicketResponse(BaseModel): + ticket_id: str + deleted: bool + + +class DeleteUserResponse(BaseModel): + user_id: str + deleted: dict[str, int] + audit_id: int + + +class HardResetResponse(BaseModel): + user_id: str + deleted: dict[str, int] + audit_id: int diff --git a/app/admin_role_service.py b/app/admin_role_service.py new file mode 100644 index 0000000..c1e1995 --- /dev/null +++ b/app/admin_role_service.py @@ -0,0 +1,109 @@ +import os +from app.services.auth_service import create_user, get_user_by_username +from app.services.db import db_connection + +VALID_ROLES = {"USER", "ADMIN", "SUPER_ADMIN"} + + +def _sync_legacy_flags(cur, user_id: str, role: str): + cur.execute( + """ + UPDATE app_user + SET is_admin = %s, is_super_admin = %s + WHERE id = %s + """, + (role in ("ADMIN", "SUPER_ADMIN"), role == "SUPER_ADMIN", user_id), + ) + + +def set_user_role(actor_id: str, target_id: str, new_role: str): + if new_role not in VALID_ROLES: + return {"error": "invalid_role"} + + with db_connection() as conn: + with conn: + with conn.cursor() as cur: + cur.execute( + "SELECT role FROM app_user WHERE id = %s", + (target_id,), + ) + row = cur.fetchone() + if not row: + return None + old_role = row[0] + + if actor_id == target_id and old_role == "SUPER_ADMIN" and new_role != "SUPER_ADMIN": + return {"error": "cannot_demote_self"} + + if old_role == new_role: + return { + "user_id": target_id, + "old_role": old_role, + "new_role": new_role, + } + + cur.execute( + """ + UPDATE app_user + SET role = %s + WHERE id = %s + """, + (new_role, target_id), + ) + _sync_legacy_flags(cur, target_id, new_role) + + cur.execute( + """ + INSERT INTO admin_role_audit + (actor_user_id, target_user_id, old_role, new_role) + VALUES (%s, %s, %s, %s) + """, + (actor_id, target_id, old_role, new_role), + ) + return { + "user_id": target_id, + "old_role": old_role, + "new_role": new_role, + } + + +def bootstrap_super_admin(): + email = (os.getenv("SUPER_ADMIN_EMAIL") or "").strip() + if not email: + return + + existing = get_user_by_username(email) + if existing: + with db_connection() as conn: + with conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE app_user + SET role = 'SUPER_ADMIN' + WHERE id = %s + """, + (existing["id"],), + ) + _sync_legacy_flags(cur, existing["id"], "SUPER_ADMIN") + return + + password = (os.getenv("SUPER_ADMIN_PASSWORD") or "").strip() + if not password: + raise RuntimeError("SUPER_ADMIN_PASSWORD must be set to bootstrap SUPER_ADMIN") + + user = create_user(email, password) + if not user: + return + with db_connection() as conn: + with conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE app_user + SET role = 'SUPER_ADMIN' + WHERE id = %s + """, + (user["id"],), + ) + _sync_legacy_flags(cur, user["id"], "SUPER_ADMIN") diff --git a/app/admin_router.py b/app/admin_router.py new file mode 100644 index 0000000..376e87f --- /dev/null +++ b/app/admin_router.py @@ -0,0 +1,151 @@ +from fastapi import APIRouter, Depends, HTTPException, Query + +from app.admin_auth import require_admin, require_super_admin +from app.admin_models import ( + DeleteUserResponse, + HardResetResponse, + InvariantsResponse, + SupportTicketsResponse, + DeleteSupportTicketResponse, + OverviewResponse, + RunsResponse, + RunDetailResponse, + UsersResponse, + UserDetailResponse, +) +from app.admin_service import ( + delete_user_hard, + hard_reset_user_data, + get_invariants, + get_support_tickets, + delete_support_ticket, + get_overview, + get_run_detail, + get_runs, + get_user_detail, + get_users, +) +from app.admin_role_service import set_user_role + +router = APIRouter(prefix="/api/admin", dependencies=[Depends(require_admin)]) + + +@router.get("/overview", response_model=OverviewResponse) +def admin_overview(): + return get_overview() + + +@router.get("/users", response_model=UsersResponse) +def admin_users( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + query: str | None = None, +): + return get_users(page, page_size, query) + + +@router.get("/users/{user_id}", response_model=UserDetailResponse) +def admin_user_detail(user_id: str): + detail = get_user_detail(user_id) + if not detail: + raise HTTPException(status_code=404, detail="User not found") + return detail + + +@router.delete("/users/{user_id}", response_model=DeleteUserResponse) +def admin_delete_user( + user_id: str, + hard: bool = Query(False), + admin_user: dict = Depends(require_super_admin), +): + if not hard: + raise HTTPException(status_code=400, detail="Hard delete requires hard=true") + result = delete_user_hard(user_id, admin_user) + if result is None: + raise HTTPException(status_code=404, detail="User not found") + return result + + +@router.post("/users/{user_id}/hard-reset", response_model=HardResetResponse) +def admin_hard_reset_user( + user_id: str, + admin_user: dict = Depends(require_super_admin), +): + result = hard_reset_user_data(user_id, admin_user) + if result is None: + raise HTTPException(status_code=404, detail="User not found") + return result + + +@router.post("/users/{user_id}/make-admin") +def admin_make_admin(user_id: str, admin_user: dict = Depends(require_super_admin)): + result = set_user_role(admin_user["id"], user_id, "ADMIN") + if result is None: + raise HTTPException(status_code=404, detail="User not found") + if result.get("error") == "cannot_demote_self": + raise HTTPException(status_code=400, detail="Cannot demote self") + if result.get("error") == "invalid_role": + raise HTTPException(status_code=400, detail="Invalid role") + return result + + +@router.post("/users/{user_id}/revoke-admin") +def admin_revoke_admin(user_id: str, admin_user: dict = Depends(require_super_admin)): + result = set_user_role(admin_user["id"], user_id, "USER") + if result is None: + raise HTTPException(status_code=404, detail="User not found") + if result.get("error") == "cannot_demote_self": + raise HTTPException(status_code=400, detail="Cannot demote self") + if result.get("error") == "invalid_role": + raise HTTPException(status_code=400, detail="Invalid role") + return result + + +@router.post("/users/{user_id}/make-super-admin") +def admin_make_super_admin(user_id: str, admin_user: dict = Depends(require_super_admin)): + result = set_user_role(admin_user["id"], user_id, "SUPER_ADMIN") + if result is None: + raise HTTPException(status_code=404, detail="User not found") + if result.get("error") == "invalid_role": + raise HTTPException(status_code=400, detail="Invalid role") + return result + + +@router.get("/runs", response_model=RunsResponse) +def admin_runs( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + status: str | None = None, + mode: str | None = None, + user_id: str | None = None, +): + return get_runs(page, page_size, status, mode, user_id) + + +@router.get("/runs/{run_id}", response_model=RunDetailResponse) +def admin_run_detail(run_id: str): + detail = get_run_detail(run_id) + if not detail: + raise HTTPException(status_code=404, detail="Run not found") + return detail + + +@router.get("/health/invariants", response_model=InvariantsResponse) +def admin_invariants(): + return get_invariants() + + +@router.get("/support-tickets", response_model=SupportTicketsResponse) +def admin_support_tickets( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), +): + return get_support_tickets(page, page_size) + + +@router.delete("/support-tickets/{ticket_id}", response_model=DeleteSupportTicketResponse) +def admin_delete_support_ticket(ticket_id: str): + result = delete_support_ticket(ticket_id) + if not result: + raise HTTPException(status_code=404, detail="Ticket not found") + return result diff --git a/app/admin_service.py b/app/admin_service.py new file mode 100644 index 0000000..3c6a51d --- /dev/null +++ b/app/admin_service.py @@ -0,0 +1,762 @@ +from datetime import datetime, timedelta, timezone +import hashlib +import os + +from psycopg2.extras import Json +from psycopg2.extras import RealDictCursor + +from app.services.db import db_connection +from app.services.run_service import get_running_run_id +from indian_paper_trading_strategy.engine.runner import stop_engine + + +def _paginate(page: int, page_size: int): + page = max(page, 1) + page_size = max(min(page_size, 200), 1) + offset = (page - 1) * page_size + return page, page_size, offset + + +def get_overview(): + now = datetime.now(timezone.utc) + since = now - timedelta(hours=24) + with db_connection() as conn: + with conn.cursor() as cur: + cur.execute("SELECT COUNT(*) FROM app_user") + total_users = cur.fetchone()[0] + cur.execute( + """ + SELECT COUNT(DISTINCT user_id) + FROM app_session + WHERE COALESCE(last_seen_at, created_at) >= %s + """, + (since,), + ) + users_logged_in_last_24h = cur.fetchone()[0] + cur.execute( + """ + SELECT + COUNT(*) AS total_runs, + COUNT(*) FILTER (WHERE status = 'RUNNING') AS running_runs, + COUNT(*) FILTER (WHERE status = 'STOPPED') AS stopped_runs, + COUNT(*) FILTER (WHERE status = 'ERROR') AS error_runs, + COUNT(*) FILTER (WHERE mode = 'LIVE') AS live_runs_count, + COUNT(*) FILTER (WHERE mode = 'PAPER') AS paper_runs_count + FROM strategy_run + """ + ) + run_row = cur.fetchone() + cur.execute( + """ + SELECT COUNT(*) FROM paper_order WHERE "timestamp" >= %s + """, + (since,), + ) + orders_last_24h = cur.fetchone()[0] + cur.execute( + """ + SELECT COUNT(*) FROM paper_trade WHERE "timestamp" >= %s + """, + (since,), + ) + trades_last_24h = cur.fetchone()[0] + cur.execute( + """ + SELECT COUNT(*) + FROM event_ledger + WHERE event = 'SIP_EXECUTED' AND "timestamp" >= %s + """, + (since,), + ) + sip_executed_last_24h = cur.fetchone()[0] + cur.execute( + """ + SELECT ts, event, message, source, user_id, run_id + FROM ( + SELECT ts, event, message, 'engine_event' AS source, user_id, run_id + FROM engine_event + WHERE event ILIKE '%ERROR%' + UNION ALL + SELECT ts, event, message, 'strategy_log' AS source, user_id, run_id + FROM strategy_log + WHERE level = 'ERROR' + ) t + ORDER BY ts DESC NULLS LAST + LIMIT 10 + """ + ) + top_errors = [ + { + "ts": row[0], + "event": row[1], + "message": row[2], + "source": row[3], + "user_id": row[4], + "run_id": row[5], + } + for row in cur.fetchall() + ] + return { + "total_users": total_users, + "users_logged_in_last_24h": users_logged_in_last_24h, + "total_runs": run_row[0], + "running_runs": run_row[1], + "stopped_runs": run_row[2], + "error_runs": run_row[3], + "live_runs_count": run_row[4], + "paper_runs_count": run_row[5], + "orders_last_24h": orders_last_24h, + "trades_last_24h": trades_last_24h, + "sip_executed_last_24h": sip_executed_last_24h, + "top_errors": top_errors, + } + + +def get_users(page: int, page_size: int, query: str | None): + page, page_size, offset = _paginate(page, page_size) + params = [] + where = "" + if query: + where = "WHERE username ILIKE %s OR user_id = %s" + params = [f"%{query}%", query] + with db_connection() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(f"SELECT COUNT(*) FROM admin_user_metrics {where}", params) + total = cur.fetchone()["count"] + cur.execute( + f""" + SELECT * + FROM admin_user_metrics + {where} + ORDER BY created_at DESC NULLS LAST + LIMIT %s OFFSET %s + """, + (*params, page_size, offset), + ) + rows = cur.fetchall() + return { + "page": page, + "page_size": page_size, + "total": total, + "users": rows, + } + + +def _get_active_run_id(cur, user_id: str): + 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() + return row[0] if row else None + + +def get_user_detail(user_id: str): + with db_connection() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute("SELECT * FROM admin_user_metrics WHERE user_id = %s", (user_id,)) + user = cur.fetchone() + if not user: + return None + + cur.execute( + """ + SELECT * FROM admin_run_metrics + WHERE user_id = %s + ORDER BY created_at DESC NULLS LAST + LIMIT 20 + """, + (user_id,), + ) + runs = cur.fetchall() + + active_run_id = _get_active_run_id(cur, user_id) + config = None + if active_run_id: + 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 + """, + (user_id, active_run_id), + ) + cfg_row = cur.fetchone() + if cfg_row: + config = dict(cfg_row) + + cur.execute( + """ + SELECT ts, event, message, level, run_id, meta, 'strategy_log' AS source + FROM strategy_log + WHERE user_id = %s + UNION ALL + SELECT ts, event, message, NULL AS level, run_id, meta, 'engine_event' AS source + FROM engine_event + WHERE user_id = %s + ORDER BY ts DESC NULLS LAST + LIMIT 50 + """, + (user_id, user_id), + ) + events = [ + { + "ts": row[0], + "event": row[1], + "message": row[2], + "level": row[3], + "run_id": row[4], + "meta": row[5], + "source": row[6], + } + for row in cur.fetchall() + ] + + capital_summary = { + "cash": None, + "invested": None, + "mtm": None, + "equity": None, + "pnl": None, + } + if active_run_id: + cur.execute( + """ + SELECT + (SELECT cash FROM paper_broker_account WHERE user_id = %s AND run_id = %s LIMIT 1) AS cash, + (SELECT total_invested FROM engine_state_paper WHERE user_id = %s AND run_id = %s LIMIT 1) AS invested, + (SELECT portfolio_value FROM mtm_ledger WHERE user_id = %s AND run_id = %s ORDER BY "timestamp" DESC LIMIT 1) AS mtm, + (SELECT equity FROM paper_equity_curve WHERE user_id = %s AND run_id = %s ORDER BY "timestamp" DESC LIMIT 1) AS equity, + (SELECT pnl FROM paper_equity_curve WHERE user_id = %s AND run_id = %s ORDER BY "timestamp" DESC LIMIT 1) AS pnl + """, + ( + user_id, + active_run_id, + user_id, + active_run_id, + user_id, + active_run_id, + user_id, + active_run_id, + user_id, + active_run_id, + ), + ) + row = cur.fetchone() + if row: + capital_summary = { + "cash": row[0], + "invested": row[1], + "mtm": row[2], + "equity": row[3], + "pnl": row[4], + } + + return { + "user": user, + "runs": runs, + "current_config": config, + "events": events, + "capital_summary": capital_summary, + } + + +def get_runs(page: int, page_size: int, status: str | None, mode: str | None, user_id: str | None): + page, page_size, offset = _paginate(page, page_size) + filters = [] + params = [] + if status: + filters.append("status = %s") + params.append(status) + if mode: + filters.append("mode = %s") + params.append(mode) + if user_id: + filters.append("user_id = %s") + params.append(user_id) + where = f"WHERE {' AND '.join(filters)}" if filters else "" + + with db_connection() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(f"SELECT COUNT(*) FROM admin_run_metrics {where}", params) + total = cur.fetchone()["count"] + cur.execute( + f""" + SELECT * + FROM admin_run_metrics + {where} + ORDER BY created_at DESC NULLS LAST + LIMIT %s OFFSET %s + """, + (*params, page_size, offset), + ) + runs = cur.fetchall() + return { + "page": page, + "page_size": page_size, + "total": total, + "runs": runs, + } + + +def get_run_detail(run_id: str): + with db_connection() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute("SELECT * FROM admin_run_metrics WHERE run_id = %s", (run_id,)) + run = cur.fetchone() + if not run: + return None + + user_id = run["user_id"] + + 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 + """, + (user_id, run_id), + ) + config = cur.fetchone() + + cur.execute( + """ + SELECT status, last_updated + FROM engine_status + WHERE user_id = %s AND run_id = %s + LIMIT 1 + """, + (user_id, run_id), + ) + engine_status = cur.fetchone() + + cur.execute( + """ + 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 + LIMIT 1 + """, + (user_id, run_id), + ) + state = cur.fetchone() + state_snapshot = dict(state) if state else None + + cur.execute( + """ + SELECT event, "timestamp", logical_time, nifty_units, gold_units, nifty_price, gold_price, amount + FROM event_ledger + WHERE user_id = %s AND run_id = %s + ORDER BY "timestamp" DESC + LIMIT 100 + """, + (user_id, run_id), + ) + ledger_events = cur.fetchall() + + cur.execute( + """ + SELECT id, symbol, side, qty, price, status, "timestamp" + FROM paper_order + WHERE user_id = %s AND run_id = %s + ORDER BY "timestamp" DESC + LIMIT 50 + """, + (user_id, run_id), + ) + orders = cur.fetchall() + + cur.execute( + """ + SELECT id, order_id, symbol, side, qty, price, "timestamp" + FROM paper_trade + WHERE user_id = %s AND run_id = %s + ORDER BY "timestamp" DESC + LIMIT 50 + """, + (user_id, run_id), + ) + trades = cur.fetchall() + + cur.execute( + """ + SELECT COUNT(*) FROM ( + SELECT logical_time FROM event_ledger + WHERE user_id = %s AND run_id = %s + GROUP BY logical_time, event + HAVING COUNT(*) > 1 + ) t + """, + (user_id, run_id), + ) + dup_event = cur.fetchone()["count"] + + cur.execute( + """ + SELECT COUNT(*) FROM ( + SELECT logical_time FROM mtm_ledger + WHERE user_id = %s AND run_id = %s + GROUP BY logical_time + HAVING COUNT(*) > 1 + ) t + """, + (user_id, run_id), + ) + dup_mtm = cur.fetchone()["count"] + + cur.execute( + """ + SELECT COUNT(*) FROM paper_broker_account + WHERE user_id = %s AND run_id = %s AND cash < 0 + """, + (user_id, run_id), + ) + neg_cash = cur.fetchone()["count"] + + cur.execute( + """ + SELECT COUNT(*) FROM paper_order + WHERE user_id = %s AND run_id = %s AND qty <= 0 + """, + (user_id, run_id), + ) + bad_qty = cur.fetchone()["count"] + + invariants = { + "duplicate_event_logical_time": dup_event, + "duplicate_mtm_logical_time": dup_mtm, + "negative_cash": neg_cash, + "invalid_qty": bad_qty, + } + + return { + "run": run, + "config": dict(config) if config else None, + "engine_status": dict(engine_status) if engine_status else None, + "state_snapshot": state_snapshot, + "ledger_events": ledger_events, + "orders": orders, + "trades": trades, + "invariants": invariants, + } + + +def get_invariants(stale_minutes: int = 30): + cutoff = datetime.now(timezone.utc) - timedelta(minutes=stale_minutes) + with db_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT COUNT(*) FROM ( + SELECT user_id FROM strategy_run + WHERE status = 'RUNNING' + GROUP BY user_id + HAVING COUNT(*) > 1 + ) t + """ + ) + running_runs_per_user_violations = cur.fetchone()[0] + + cur.execute( + """ + SELECT COUNT(*) FROM ( + SELECT user_id, run_id FROM engine_state + UNION ALL + SELECT user_id, run_id FROM engine_status + UNION ALL + SELECT user_id, run_id FROM paper_order + UNION ALL + SELECT user_id, run_id FROM paper_trade + ) t + LEFT JOIN strategy_run sr + ON sr.user_id = t.user_id AND sr.run_id = t.run_id + WHERE sr.run_id IS NULL + """ + ) + orphan_rows = cur.fetchone()[0] + + cur.execute( + """ + SELECT COUNT(*) FROM ( + SELECT user_id, run_id, logical_time, event + FROM event_ledger + GROUP BY user_id, run_id, logical_time, event + HAVING COUNT(*) > 1 + ) t + """ + ) + dup_event = cur.fetchone()[0] + + cur.execute( + """ + SELECT COUNT(*) FROM ( + SELECT user_id, run_id, logical_time + FROM mtm_ledger + GROUP BY user_id, run_id, logical_time + HAVING COUNT(*) > 1 + ) t + """ + ) + dup_mtm = cur.fetchone()[0] + + cur.execute( + "SELECT COUNT(*) FROM paper_broker_account WHERE cash < 0" + ) + negative_cash = cur.fetchone()[0] + + cur.execute( + "SELECT COUNT(*) FROM paper_order WHERE qty <= 0" + ) + invalid_qty = cur.fetchone()[0] + + cur.execute( + """ + SELECT COUNT(*) FROM strategy_run sr + LEFT JOIN ( + SELECT user_id, run_id, MAX(ts) AS last_ts + FROM ( + SELECT user_id, run_id, ts FROM engine_event + UNION ALL + SELECT user_id, run_id, ts FROM strategy_log + UNION ALL + SELECT user_id, run_id, "timestamp" AS ts FROM event_ledger + ) t + GROUP BY user_id, run_id + ) activity + ON activity.user_id = sr.user_id AND activity.run_id = sr.run_id + WHERE sr.status = 'RUNNING' AND (activity.last_ts IS NULL OR activity.last_ts < %s) + """, + (cutoff,), + ) + stale_running_runs = cur.fetchone()[0] + + return { + "running_runs_per_user_violations": running_runs_per_user_violations, + "orphan_rows": orphan_rows, + "duplicate_logical_time": dup_event + dup_mtm, + "negative_cash": negative_cash, + "invalid_qty": invalid_qty, + "stale_running_runs": stale_running_runs, + } + + +def get_support_tickets(page: int, page_size: int): + page, page_size, offset = _paginate(page, page_size) + with db_connection() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute("SELECT COUNT(*) FROM support_ticket") + total = cur.fetchone()["count"] + cur.execute( + """ + SELECT id AS ticket_id, name, email, subject, message, status, created_at, updated_at + FROM support_ticket + ORDER BY created_at DESC NULLS LAST + LIMIT %s OFFSET %s + """, + (page_size, offset), + ) + rows = cur.fetchall() + tickets = [] + for row in rows: + ticket = dict(row) + ticket["ticket_id"] = str(ticket.get("ticket_id")) + if ticket.get("created_at"): + ticket["created_at"] = ticket["created_at"] + if ticket.get("updated_at"): + ticket["updated_at"] = ticket["updated_at"] + tickets.append(ticket) + return { + "page": page, + "page_size": page_size, + "total": total, + "tickets": tickets, + } + + +def delete_support_ticket(ticket_id: str) -> dict | None: + with db_connection() as conn: + with conn: + with conn.cursor() as cur: + cur.execute("DELETE FROM support_ticket WHERE id = %s", (ticket_id,)) + if cur.rowcount == 0: + return None + return {"ticket_id": ticket_id, "deleted": True} + + +def _hash_value(value: str | None) -> str | None: + if value is None: + return None + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def delete_user_hard(user_id: str, admin_user: dict): + table_counts = [ + ("app_user", "SELECT COUNT(*) FROM app_user WHERE id = %s"), + ("app_session", "SELECT COUNT(*) FROM app_session WHERE user_id = %s"), + ("user_broker", "SELECT COUNT(*) FROM user_broker WHERE user_id = %s"), + ("zerodha_session", "SELECT COUNT(*) FROM zerodha_session WHERE user_id = %s"), + ("zerodha_request_token", "SELECT COUNT(*) FROM zerodha_request_token WHERE user_id = %s"), + ("strategy_run", "SELECT COUNT(*) FROM strategy_run WHERE user_id = %s"), + ("strategy_config", "SELECT COUNT(*) FROM strategy_config WHERE user_id = %s"), + ("strategy_log", "SELECT COUNT(*) FROM strategy_log WHERE user_id = %s"), + ("engine_status", "SELECT COUNT(*) FROM engine_status WHERE user_id = %s"), + ("engine_state", "SELECT COUNT(*) FROM engine_state WHERE user_id = %s"), + ("engine_state_paper", "SELECT COUNT(*) FROM engine_state_paper WHERE user_id = %s"), + ("engine_event", "SELECT COUNT(*) FROM engine_event WHERE user_id = %s"), + ("paper_broker_account", "SELECT COUNT(*) FROM paper_broker_account WHERE user_id = %s"), + ("paper_position", "SELECT COUNT(*) FROM paper_position WHERE user_id = %s"), + ("paper_order", "SELECT COUNT(*) FROM paper_order WHERE user_id = %s"), + ("paper_trade", "SELECT COUNT(*) FROM paper_trade WHERE user_id = %s"), + ("paper_equity_curve", "SELECT COUNT(*) FROM paper_equity_curve WHERE user_id = %s"), + ("mtm_ledger", "SELECT COUNT(*) FROM mtm_ledger WHERE user_id = %s"), + ("event_ledger", "SELECT COUNT(*) FROM event_ledger WHERE user_id = %s"), + ] + + with db_connection() as conn: + with conn: + with conn.cursor() as cur: + cur.execute( + "SELECT id, username FROM app_user WHERE id = %s", + (user_id,), + ) + row = cur.fetchone() + if not row: + return None + target_username = row[1] + + counts = {} + for name, query in table_counts: + cur.execute(query, (user_id,)) + counts[name] = cur.fetchone()[0] + + cur.execute("DELETE FROM app_user WHERE id = %s", (user_id,)) + if cur.rowcount == 0: + return None + + audit_meta = {"deleted": counts, "hard": True} + cur.execute( + """ + INSERT INTO admin_audit_log + (actor_user_hash, target_user_hash, target_username_hash, action, meta) + VALUES (%s, %s, %s, %s, %s) + RETURNING id + """, + ( + _hash_value(admin_user["id"]), + _hash_value(user_id), + _hash_value(target_username), + "HARD_DELETE_USER", + Json(audit_meta), + ), + ) + audit_id = cur.fetchone()[0] + + return { + "user_id": user_id, + "deleted": counts, + "audit_id": audit_id, + } + + +def hard_reset_user_data(user_id: str, admin_user: dict): + table_counts = [ + ("strategy_run", "SELECT COUNT(*) FROM strategy_run WHERE user_id = %s"), + ("strategy_config", "SELECT COUNT(*) FROM strategy_config WHERE user_id = %s"), + ("strategy_log", "SELECT COUNT(*) FROM strategy_log WHERE user_id = %s"), + ("engine_status", "SELECT COUNT(*) FROM engine_status WHERE user_id = %s"), + ("engine_state", "SELECT COUNT(*) FROM engine_state WHERE user_id = %s"), + ("engine_state_paper", "SELECT COUNT(*) FROM engine_state_paper WHERE user_id = %s"), + ("engine_event", "SELECT COUNT(*) FROM engine_event WHERE user_id = %s"), + ("paper_broker_account", "SELECT COUNT(*) FROM paper_broker_account WHERE user_id = %s"), + ("paper_position", "SELECT COUNT(*) FROM paper_position WHERE user_id = %s"), + ("paper_order", "SELECT COUNT(*) FROM paper_order WHERE user_id = %s"), + ("paper_trade", "SELECT COUNT(*) FROM paper_trade WHERE user_id = %s"), + ("paper_equity_curve", "SELECT COUNT(*) FROM paper_equity_curve WHERE user_id = %s"), + ("mtm_ledger", "SELECT COUNT(*) FROM mtm_ledger WHERE user_id = %s"), + ("event_ledger", "SELECT COUNT(*) FROM event_ledger WHERE user_id = %s"), + ] + + engine_external = os.getenv("ENGINE_EXTERNAL", "").strip().lower() in {"1", "true", "yes"} + running_run_id = get_running_run_id(user_id) + if running_run_id and not engine_external: + stop_engine(user_id, timeout=15.0) + + with db_connection() as conn: + with conn: + with conn.cursor() as cur: + cur.execute( + "SELECT id, username FROM app_user WHERE id = %s", + (user_id,), + ) + row = cur.fetchone() + if not row: + return None + target_username = row[1] + + counts = {} + for name, query in table_counts: + cur.execute(query, (user_id,)) + counts[name] = cur.fetchone()[0] + + cur.execute("DELETE FROM strategy_log WHERE user_id = %s", (user_id,)) + cur.execute("DELETE FROM engine_event WHERE user_id = %s", (user_id,)) + cur.execute("DELETE FROM paper_equity_curve WHERE user_id = %s", (user_id,)) + cur.execute("DELETE FROM paper_trade WHERE user_id = %s", (user_id,)) + cur.execute("DELETE FROM paper_order WHERE user_id = %s", (user_id,)) + cur.execute("DELETE FROM paper_position WHERE user_id = %s", (user_id,)) + cur.execute("DELETE FROM paper_broker_account WHERE user_id = %s", (user_id,)) + cur.execute("DELETE FROM mtm_ledger WHERE user_id = %s", (user_id,)) + cur.execute("DELETE FROM event_ledger WHERE user_id = %s", (user_id,)) + cur.execute("DELETE FROM engine_state_paper WHERE user_id = %s", (user_id,)) + cur.execute("DELETE FROM engine_state WHERE user_id = %s", (user_id,)) + cur.execute("DELETE FROM engine_status WHERE user_id = %s", (user_id,)) + cur.execute("DELETE FROM strategy_config WHERE user_id = %s", (user_id,)) + cur.execute("DELETE FROM strategy_run WHERE user_id = %s", (user_id,)) + + audit_meta = {"reset": counts, "hard": True} + cur.execute( + """ + INSERT INTO admin_audit_log + (actor_user_hash, target_user_hash, target_username_hash, action, meta) + VALUES (%s, %s, %s, %s, %s) + RETURNING id + """, + ( + _hash_value(admin_user["id"]), + _hash_value(user_id), + _hash_value(target_username), + "HARD_RESET_USER", + Json(audit_meta), + ), + ) + audit_id = cur.fetchone()[0] + + return { + "user_id": user_id, + "deleted": counts, + "audit_id": audit_id, + } diff --git a/app/broker_store.py b/app/broker_store.py new file mode 100644 index 0000000..09d15fa --- /dev/null +++ b/app/broker_store.py @@ -0,0 +1,296 @@ +from datetime import datetime, timezone + +from app.services.crypto_service import decrypt_value, encrypt_value +from app.services.db import db_transaction + + +def _row_to_entry(row): + ( + user_id, + broker, + connected, + access_token, + connected_at, + api_key, + api_secret, + user_name, + broker_user_id, + auth_state, + pending_broker, + pending_api_key, + pending_api_secret, + pending_started_at, + ) = row + entry = { + "broker": broker, + "connected": bool(connected), + "connected_at": connected_at, + "api_key": api_key, + "auth_state": auth_state, + "user_name": user_name, + "broker_user_id": broker_user_id, + } + if pending_broker or pending_api_key or pending_api_secret or pending_started_at: + pending = { + "broker": pending_broker, + "api_key": pending_api_key, + "api_secret": decrypt_value(pending_api_secret) + if pending_api_secret + else None, + "started_at": pending_started_at, + } + entry["pending"] = pending + return entry + + +def load_user_brokers(): + with db_transaction() as cur: + cur.execute( + """ + SELECT user_id, broker, connected, access_token, connected_at, + api_key, api_secret, user_name, broker_user_id, auth_state, + pending_broker, pending_api_key, pending_api_secret, pending_started_at + FROM user_broker + """ + ) + rows = cur.fetchall() + return {row[0]: _row_to_entry(row) for row in rows} + + +def save_user_brokers(data): + with db_transaction() as cur: + for user_id, entry in data.items(): + cur.execute( + """ + INSERT INTO user_broker ( + user_id, broker, connected, access_token, connected_at, + api_key, api_secret, user_name, broker_user_id, auth_state, + pending_broker, pending_api_key, pending_api_secret, pending_started_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (user_id) + DO UPDATE SET + broker = EXCLUDED.broker, + connected = EXCLUDED.connected, + access_token = EXCLUDED.access_token, + connected_at = EXCLUDED.connected_at, + api_key = EXCLUDED.api_key, + api_secret = EXCLUDED.api_secret, + user_name = EXCLUDED.user_name, + broker_user_id = EXCLUDED.broker_user_id, + auth_state = EXCLUDED.auth_state, + pending_broker = EXCLUDED.pending_broker, + pending_api_key = EXCLUDED.pending_api_key, + pending_api_secret = EXCLUDED.pending_api_secret, + pending_started_at = EXCLUDED.pending_started_at + """, + ( + user_id, + entry.get("broker"), + bool(entry.get("connected")), + encrypt_value(entry.get("access_token")) + if entry.get("access_token") + else None, + entry.get("connected_at"), + entry.get("api_key"), + encrypt_value(entry.get("api_secret")) + if entry.get("api_secret") + else None, + entry.get("user_name"), + entry.get("broker_user_id"), + entry.get("auth_state"), + (entry.get("pending") or {}).get("broker"), + (entry.get("pending") or {}).get("api_key"), + encrypt_value((entry.get("pending") or {}).get("api_secret")) + if (entry.get("pending") or {}).get("api_secret") + else None, + (entry.get("pending") or {}).get("started_at"), + ), + ) + + +def now_utc(): + return datetime.now(timezone.utc) + + +def get_user_broker(user_id: str): + with db_transaction() as cur: + cur.execute( + """ + SELECT user_id, broker, connected, access_token, connected_at, + api_key, api_secret, user_name, broker_user_id, auth_state, + pending_broker, pending_api_key, pending_api_secret, pending_started_at + FROM user_broker + WHERE user_id = %s + """, + (user_id,), + ) + row = cur.fetchone() + if not row: + return None + return _row_to_entry(row) + + +def clear_user_broker(user_id: str): + with db_transaction() as cur: + cur.execute("DELETE FROM user_broker WHERE user_id = %s", (user_id,)) + + +def set_pending_broker(user_id: str, broker: str, api_key: str, api_secret: str): + started_at = now_utc() + with db_transaction() as cur: + cur.execute( + """ + INSERT INTO user_broker ( + user_id, pending_broker, pending_api_key, pending_api_secret, pending_started_at, + api_key, api_secret, auth_state + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (user_id) + DO UPDATE SET + pending_broker = EXCLUDED.pending_broker, + pending_api_key = EXCLUDED.pending_api_key, + pending_api_secret = EXCLUDED.pending_api_secret, + pending_started_at = EXCLUDED.pending_started_at, + api_key = EXCLUDED.api_key, + api_secret = EXCLUDED.api_secret, + auth_state = EXCLUDED.auth_state + """, + ( + user_id, + broker, + api_key, + encrypt_value(api_secret), + started_at, + api_key, + encrypt_value(api_secret), + "PENDING", + ), + ) + return { + "broker": broker, + "api_key": api_key, + "api_secret": api_secret, + "started_at": started_at, + } + + +def get_pending_broker(user_id: str): + with db_transaction() as cur: + cur.execute( + """ + SELECT pending_broker, pending_api_key, pending_api_secret, pending_started_at + FROM user_broker + WHERE user_id = %s + """, + (user_id,), + ) + row = cur.fetchone() + if not row: + return None + if not row[0] or not row[1] or not row[2]: + return None + return { + "broker": row[0], + "api_key": row[1], + "api_secret": decrypt_value(row[2]), + "started_at": row[3], + } + + +def get_broker_credentials(user_id: str): + with db_transaction() as cur: + cur.execute( + """ + SELECT api_key, api_secret, pending_api_key, pending_api_secret + FROM user_broker + WHERE user_id = %s + """, + (user_id,), + ) + row = cur.fetchone() + if not row: + return None + api_key, api_secret, pending_key, pending_secret = row + key = api_key or pending_key + secret = api_secret or pending_secret + if not key or not secret: + return None + return { + "api_key": key, + "api_secret": decrypt_value(secret), + } + + +def set_broker_auth_state(user_id: str, auth_state: str): + with db_transaction() as cur: + cur.execute( + """ + UPDATE user_broker + SET auth_state = %s + WHERE user_id = %s + """, + (auth_state, user_id), + ) + + +def set_connected_broker( + user_id: str, + broker: str, + access_token: str, + api_key: str | None = None, + api_secret: str | None = None, + user_name: str | None = None, + broker_user_id: str | None = None, + auth_state: str | None = None, +): + connected_at = now_utc() + with db_transaction() as cur: + cur.execute( + """ + INSERT INTO user_broker ( + user_id, broker, connected, access_token, connected_at, + api_key, api_secret, user_name, broker_user_id, auth_state, + pending_broker, pending_api_key, pending_api_secret, pending_started_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NULL, NULL, NULL, NULL) + ON CONFLICT (user_id) + DO UPDATE SET + broker = EXCLUDED.broker, + connected = EXCLUDED.connected, + access_token = EXCLUDED.access_token, + connected_at = EXCLUDED.connected_at, + api_key = EXCLUDED.api_key, + api_secret = EXCLUDED.api_secret, + user_name = EXCLUDED.user_name, + broker_user_id = EXCLUDED.broker_user_id, + auth_state = EXCLUDED.auth_state, + pending_broker = NULL, + pending_api_key = NULL, + pending_api_secret = NULL, + pending_started_at = NULL + """, + ( + user_id, + broker, + True, + encrypt_value(access_token), + connected_at, + api_key, + encrypt_value(api_secret) if api_secret else None, + user_name, + broker_user_id, + auth_state, + ), + ) + return { + "broker": broker, + "connected": True, + "access_token": access_token, + "connected_at": connected_at, + "api_key": api_key, + "api_secret": api_secret, + "user_name": user_name, + "broker_user_id": broker_user_id, + "auth_state": auth_state, + } diff --git a/app/db_models.py b/app/db_models.py new file mode 100644 index 0000000..9c3c16d --- /dev/null +++ b/app/db_models.py @@ -0,0 +1,491 @@ +from sqlalchemy import ( + BigInteger, + Boolean, + CheckConstraint, + Column, + Date, + DateTime, + ForeignKey, + ForeignKeyConstraint, + Index, + Integer, + Numeric, + String, + Text, + UniqueConstraint, + func, + text, +) +from sqlalchemy.dialects.postgresql import JSONB + +from app.services.db import Base + + +class AppUser(Base): + __tablename__ = "app_user" + + id = Column(String, primary_key=True) + username = Column(String, nullable=False, unique=True) + password_hash = Column(String, nullable=False) + is_admin = Column(Boolean, nullable=False, server_default=text("false")) + is_super_admin = Column(Boolean, nullable=False, server_default=text("false")) + role = Column(String, nullable=False, server_default=text("'USER'")) + + __table_args__ = ( + CheckConstraint("role IN ('USER','ADMIN','SUPER_ADMIN')", name="chk_app_user_role"), + Index("idx_app_user_role", "role"), + Index("idx_app_user_is_admin", "is_admin"), + Index("idx_app_user_is_super_admin", "is_super_admin"), + ) + + +class AppSession(Base): + __tablename__ = "app_session" + + id = Column(String, primary_key=True) + user_id = Column(String, ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False) + created_at = Column(DateTime(timezone=True), nullable=False) + last_seen_at = Column(DateTime(timezone=True)) + expires_at = Column(DateTime(timezone=True), nullable=False) + + __table_args__ = ( + Index("idx_app_session_user_id", "user_id"), + Index("idx_app_session_expires_at", "expires_at"), + ) + + +class UserBroker(Base): + __tablename__ = "user_broker" + + user_id = Column(String, ForeignKey("app_user.id", ondelete="CASCADE"), primary_key=True) + broker = Column(String) + connected = Column(Boolean, nullable=False, server_default=text("false")) + access_token = Column(Text) + connected_at = Column(DateTime(timezone=True)) + api_key = Column(Text) + user_name = Column(Text) + broker_user_id = Column(Text) + pending_broker = Column(Text) + pending_api_key = Column(Text) + pending_api_secret = Column(Text) + pending_started_at = Column(DateTime(timezone=True)) + + __table_args__ = ( + Index("idx_user_broker_broker", "broker"), + Index("idx_user_broker_connected", "connected"), + ) + + +class ZerodhaSession(Base): + __tablename__ = "zerodha_session" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + user_id = Column(String, ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False) + linked_at = Column(DateTime(timezone=True), nullable=False) + api_key = Column(Text) + access_token = Column(Text) + request_token = Column(Text) + user_name = Column(Text) + broker_user_id = Column(Text) + + __table_args__ = ( + Index("idx_zerodha_session_user_id", "user_id"), + Index("idx_zerodha_session_linked_at", "linked_at"), + ) + + +class ZerodhaRequestToken(Base): + __tablename__ = "zerodha_request_token" + + user_id = Column(String, ForeignKey("app_user.id", ondelete="CASCADE"), primary_key=True) + request_token = Column(Text, nullable=False) + + +class StrategyRun(Base): + __tablename__ = "strategy_run" + + run_id = Column(String, primary_key=True) + user_id = Column(String, ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False) + created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) + started_at = Column(DateTime(timezone=True)) + stopped_at = Column(DateTime(timezone=True)) + status = Column(String, nullable=False) + strategy = Column(String) + mode = Column(String) + broker = Column(String) + meta = Column(JSONB) + + __table_args__ = ( + UniqueConstraint("user_id", "run_id", name="uq_strategy_run_user_run"), + CheckConstraint("status IN ('RUNNING','STOPPED','ERROR')", name="chk_strategy_run_status"), + Index("idx_strategy_run_user_status", "user_id", "status"), + Index("idx_strategy_run_user_created", "user_id", "created_at"), + Index( + "uq_one_running_run_per_user", + "user_id", + unique=True, + postgresql_where=text("status = 'RUNNING'"), + ), + ) + + +class StrategyConfig(Base): + __tablename__ = "strategy_config" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + strategy = Column(String) + sip_amount = Column(Numeric) + sip_frequency_value = Column(Integer) + sip_frequency_unit = Column(String) + mode = Column(String) + broker = Column(String) + active = Column(Boolean) + frequency = Column(Text) + frequency_days = Column(Integer) + unit = Column(String) + next_run = Column(DateTime(timezone=True)) + user_id = Column(String, ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False) + run_id = Column(String, ForeignKey("strategy_run.run_id", ondelete="CASCADE"), nullable=False) + + __table_args__ = ( + UniqueConstraint("user_id", "run_id", name="uq_strategy_config_user_run"), + ) + + +class StrategyLog(Base): + __tablename__ = "strategy_log" + + seq = Column(BigInteger, primary_key=True) + ts = Column(DateTime(timezone=True), nullable=False) + level = Column(String) + category = Column(String) + event = Column(String) + message = Column(Text) + user_id = Column(String, ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False) + run_id = Column(String, ForeignKey("strategy_run.run_id", ondelete="CASCADE"), nullable=False) + meta = Column(JSONB) + + __table_args__ = ( + Index("idx_strategy_log_ts", "ts"), + Index("idx_strategy_log_event", "event"), + Index("idx_strategy_log_user_run_ts", "user_id", "run_id", "ts"), + ) + + +class EngineStatus(Base): + __tablename__ = "engine_status" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + user_id = Column(String, ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False) + run_id = Column(String, nullable=False) + status = Column(String, nullable=False) + last_updated = Column(DateTime(timezone=True), nullable=False) + + __table_args__ = ( + UniqueConstraint("user_id", "run_id", name="uq_engine_status_user_run"), + ForeignKeyConstraint( + ["user_id", "run_id"], + ["strategy_run.user_id", "strategy_run.run_id"], + ondelete="CASCADE", + ), + Index("idx_engine_status_user_run", "user_id", "run_id"), + ) + + +class EngineState(Base): + __tablename__ = "engine_state" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + user_id = Column(String, ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False) + run_id = Column(String, nullable=False) + total_invested = Column(Numeric) + nifty_units = Column(Numeric) + gold_units = Column(Numeric) + last_sip_ts = Column(DateTime(timezone=True)) + last_run = Column(DateTime(timezone=True)) + + __table_args__ = ( + UniqueConstraint("user_id", "run_id", name="uq_engine_state_user_run"), + ForeignKeyConstraint( + ["user_id", "run_id"], + ["strategy_run.user_id", "strategy_run.run_id"], + ondelete="CASCADE", + ), + ) + + +class EngineStatePaper(Base): + __tablename__ = "engine_state_paper" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + user_id = Column(String, ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False) + run_id = Column(String, nullable=False) + initial_cash = Column(Numeric) + cash = Column(Numeric) + total_invested = Column(Numeric) + nifty_units = Column(Numeric) + gold_units = Column(Numeric) + last_sip_ts = Column(DateTime(timezone=True)) + last_run = Column(DateTime(timezone=True)) + sip_frequency_value = Column(Integer) + sip_frequency_unit = Column(String) + + __table_args__ = ( + UniqueConstraint("user_id", "run_id", name="uq_engine_state_paper_user_run"), + ForeignKeyConstraint( + ["user_id", "run_id"], + ["strategy_run.user_id", "strategy_run.run_id"], + ondelete="CASCADE", + ), + CheckConstraint("cash >= 0", name="chk_engine_state_paper_cash_non_negative"), + ) + + +class EngineEvent(Base): + __tablename__ = "engine_event" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + ts = Column(DateTime(timezone=True), nullable=False) + event = Column(String) + data = Column(JSONB) + message = Column(Text) + meta = Column(JSONB) + user_id = Column(String, ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False) + run_id = Column(String, ForeignKey("strategy_run.run_id", ondelete="CASCADE"), nullable=False) + + __table_args__ = ( + Index("idx_engine_event_ts", "ts"), + Index("idx_engine_event_user_run_ts", "user_id", "run_id", "ts"), + ) + + +class PaperBrokerAccount(Base): + __tablename__ = "paper_broker_account" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + user_id = Column(String, ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False) + run_id = Column(String, nullable=False) + cash = Column(Numeric, nullable=False) + + __table_args__ = ( + UniqueConstraint("user_id", "run_id", name="uq_paper_broker_account_user_run"), + ForeignKeyConstraint( + ["user_id", "run_id"], + ["strategy_run.user_id", "strategy_run.run_id"], + ondelete="CASCADE", + ), + CheckConstraint("cash >= 0", name="chk_paper_broker_cash_non_negative"), + ) + + +class PaperPosition(Base): + __tablename__ = "paper_position" + + user_id = Column(String, primary_key=True) + run_id = Column(String, primary_key=True) + symbol = Column(String, primary_key=True) + qty = Column(Numeric, nullable=False) + avg_price = Column(Numeric) + last_price = Column(Numeric) + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + __table_args__ = ( + ForeignKeyConstraint( + ["user_id", "run_id"], + ["strategy_run.user_id", "strategy_run.run_id"], + ondelete="CASCADE", + ), + CheckConstraint("qty > 0", name="chk_paper_position_qty_positive"), + UniqueConstraint("user_id", "run_id", "symbol", name="uq_paper_position_scope"), + Index("idx_paper_position_user_run", "user_id", "run_id"), + ) + + +class PaperOrder(Base): + __tablename__ = "paper_order" + + id = Column(String, primary_key=True) + user_id = Column(String, ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False) + run_id = Column(String, nullable=False) + symbol = Column(String, nullable=False) + side = Column(String, nullable=False) + qty = Column(Numeric, nullable=False) + price = Column(Numeric) + status = Column(String, nullable=False) + timestamp = Column("timestamp", DateTime(timezone=True), nullable=False) + logical_time = Column(DateTime(timezone=True), nullable=False) + + __table_args__ = ( + ForeignKeyConstraint( + ["user_id", "run_id"], + ["strategy_run.user_id", "strategy_run.run_id"], + ondelete="CASCADE", + ), + UniqueConstraint("user_id", "run_id", "id", name="uq_paper_order_scope_id"), + UniqueConstraint( + "user_id", + "run_id", + "logical_time", + "symbol", + "side", + name="uq_paper_order_logical_key", + ), + CheckConstraint("qty > 0", name="chk_paper_order_qty_positive"), + CheckConstraint("price >= 0", name="chk_paper_order_price_non_negative"), + Index("idx_paper_order_ts", "timestamp"), + Index("idx_paper_order_user_run_ts", "user_id", "run_id", "timestamp"), + ) + + +class PaperTrade(Base): + __tablename__ = "paper_trade" + + id = Column(String, primary_key=True) + order_id = Column(String) + user_id = Column(String, ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False) + run_id = Column(String, nullable=False) + symbol = Column(String, nullable=False) + side = Column(String, nullable=False) + qty = Column(Numeric, nullable=False) + price = Column(Numeric, nullable=False) + timestamp = Column("timestamp", DateTime(timezone=True), nullable=False) + logical_time = Column(DateTime(timezone=True), nullable=False) + + __table_args__ = ( + ForeignKeyConstraint( + ["user_id", "run_id"], + ["strategy_run.user_id", "strategy_run.run_id"], + ondelete="CASCADE", + ), + ForeignKeyConstraint( + ["user_id", "run_id", "order_id"], + ["paper_order.user_id", "paper_order.run_id", "paper_order.id"], + ondelete="CASCADE", + ), + UniqueConstraint("user_id", "run_id", "id", name="uq_paper_trade_scope_id"), + UniqueConstraint( + "user_id", + "run_id", + "logical_time", + "symbol", + "side", + name="uq_paper_trade_logical_key", + ), + CheckConstraint("qty > 0", name="chk_paper_trade_qty_positive"), + CheckConstraint("price >= 0", name="chk_paper_trade_price_non_negative"), + Index("idx_paper_trade_ts", "timestamp"), + Index("idx_paper_trade_user_run_ts", "user_id", "run_id", "timestamp"), + ) + + +class PaperEquityCurve(Base): + __tablename__ = "paper_equity_curve" + + user_id = Column(String, primary_key=True) + run_id = Column(String, primary_key=True) + timestamp = Column("timestamp", DateTime(timezone=True), nullable=False) + logical_time = Column(DateTime(timezone=True), primary_key=True) + equity = Column(Numeric, nullable=False) + pnl = Column(Numeric) + + __table_args__ = ( + ForeignKeyConstraint( + ["user_id", "run_id"], + ["strategy_run.user_id", "strategy_run.run_id"], + ondelete="CASCADE", + ), + Index("idx_paper_equity_curve_ts", "timestamp"), + Index("idx_paper_equity_curve_user_run_ts", "user_id", "run_id", "timestamp"), + ) + + +class MTMLedger(Base): + __tablename__ = "mtm_ledger" + + user_id = Column(String, primary_key=True) + run_id = Column(String, primary_key=True) + timestamp = Column("timestamp", DateTime(timezone=True), nullable=False) + logical_time = Column(DateTime(timezone=True), primary_key=True) + nifty_units = Column(Numeric) + gold_units = Column(Numeric) + nifty_price = Column(Numeric) + gold_price = Column(Numeric) + nifty_value = Column(Numeric) + gold_value = Column(Numeric) + portfolio_value = Column(Numeric) + total_invested = Column(Numeric) + pnl = Column(Numeric) + + __table_args__ = ( + ForeignKeyConstraint( + ["user_id", "run_id"], + ["strategy_run.user_id", "strategy_run.run_id"], + ondelete="CASCADE", + ), + Index("idx_mtm_ledger_ts", "timestamp"), + Index("idx_mtm_ledger_user_run_ts", "user_id", "run_id", "timestamp"), + ) + + +class EventLedger(Base): + __tablename__ = "event_ledger" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + user_id = Column(String, ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False) + run_id = Column(String, nullable=False) + timestamp = Column("timestamp", DateTime(timezone=True), nullable=False) + logical_time = Column(DateTime(timezone=True), nullable=False) + event = Column(String, nullable=False) + nifty_units = Column(Numeric) + gold_units = Column(Numeric) + nifty_price = Column(Numeric) + gold_price = Column(Numeric) + amount = Column(Numeric) + + __table_args__ = ( + ForeignKeyConstraint( + ["user_id", "run_id"], + ["strategy_run.user_id", "strategy_run.run_id"], + ondelete="CASCADE", + ), + UniqueConstraint("user_id", "run_id", "event", "logical_time", name="uq_event_ledger_event_time"), + Index("idx_event_ledger_user_run_logical", "user_id", "run_id", "logical_time"), + Index("idx_event_ledger_ts", "timestamp"), + Index("idx_event_ledger_user_run_ts", "user_id", "run_id", "timestamp"), + ) + + +class MarketClose(Base): + __tablename__ = "market_close" + + symbol = Column(String, primary_key=True) + date = Column(Date, primary_key=True) + close = Column(Numeric, nullable=False) + + __table_args__ = ( + Index("idx_market_close_symbol", "symbol"), + Index("idx_market_close_date", "date"), + ) + + +class AdminAuditLog(Base): + __tablename__ = "admin_audit_log" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + ts = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) + actor_user_hash = Column(Text, nullable=False) + target_user_hash = Column(Text, nullable=False) + target_username_hash = Column(Text) + action = Column(Text, nullable=False) + meta = Column(JSONB) + + +class AdminRoleAudit(Base): + __tablename__ = "admin_role_audit" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + actor_user_id = Column(String, nullable=False) + target_user_id = Column(String, nullable=False) + old_role = Column(String, nullable=False) + new_role = Column(String, nullable=False) + changed_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..1b616f3 --- /dev/null +++ b/app/main.py @@ -0,0 +1,71 @@ +import os + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.routers.auth import router as auth_router +from app.routers.broker import router as broker_router +from app.routers.health import router as health_router +from app.routers.password_reset import router as password_reset_router +from app.routers.support_ticket import router as support_ticket_router +from app.routers.system import router as system_router +from app.routers.strategy import router as strategy_router +from app.routers.zerodha import router as zerodha_router, public_router as zerodha_public_router +from app.routers.paper import router as paper_router +from market import router as market_router +from paper_mtm import router as paper_mtm_router +from app.services.strategy_service import init_log_state, resume_running_runs +from app.admin_router import router as admin_router +from app.admin_role_service import bootstrap_super_admin + +app = FastAPI( + title="QuantFortune Backend", + version="1.0" +) + +cors_origins = [ + origin.strip() + for origin in os.getenv("CORS_ORIGINS", "").split(",") + if origin.strip() +] +if not cors_origins: + cors_origins = [ + "http://localhost:3000", + "http://127.0.0.1:3000", + ] + +cors_origin_regex = os.getenv("CORS_ORIGIN_REGEX", "").strip() +if not cors_origin_regex: + cors_origin_regex = ( + r"https://.*\\.ngrok-free\\.dev" + r"|https://.*\\.ngrok-free\\.app" + r"|https://.*\\.ngrok\\.io" + ) + +app.add_middleware( + CORSMiddleware, + allow_origins=cors_origins, + allow_origin_regex=cors_origin_regex or None, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(strategy_router) +app.include_router(auth_router) +app.include_router(broker_router) +app.include_router(zerodha_router) +app.include_router(zerodha_public_router) +app.include_router(paper_router) +app.include_router(market_router) +app.include_router(paper_mtm_router) +app.include_router(health_router) +app.include_router(system_router) +app.include_router(admin_router) +app.include_router(support_ticket_router) +app.include_router(password_reset_router) + +@app.on_event("startup") +def init_app_state(): + init_log_state() + bootstrap_super_admin() + resume_running_runs() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..20308dc --- /dev/null +++ b/app/models.py @@ -0,0 +1,37 @@ +from pydantic import BaseModel, validator +from typing import Literal, Optional + + +class SipFrequency(BaseModel): + value: int + unit: Literal["days", "minutes"] + +class StrategyStartRequest(BaseModel): + strategy_name: str + initial_cash: Optional[float] = None + sip_amount: float + sip_frequency: SipFrequency + mode: Literal["PAPER"] + + @validator("initial_cash") + def validate_cash(cls, v): + if v is None: + return v + if v < 10000: + raise ValueError("Initial cash must be at least 10,000") + return v + +class AuthPayload(BaseModel): + email: Optional[str] = None + username: Optional[str] = None + password: Optional[str] = None + + +class PasswordResetRequest(BaseModel): + email: str + + +class PasswordResetConfirm(BaseModel): + email: str + otp: str + new_password: str diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1 @@ + diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..1ac7f16 --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,116 @@ +import os + +from fastapi import APIRouter, HTTPException, Request, Response +from app.models import AuthPayload +from app.services.auth_service import ( + SESSION_TTL_SECONDS, + create_session, + create_user, + delete_session, + get_user_for_session, + get_last_session_meta, + verify_user, +) +from app.services.email_service import send_email + +router = APIRouter(prefix="/api") +SESSION_COOKIE_NAME = "session_id" +COOKIE_SECURE = os.getenv("COOKIE_SECURE", "0") == "1" +COOKIE_SAMESITE = (os.getenv("COOKIE_SAMESITE") or "lax").lower() + + +def _set_session_cookie(response: Response, session_id: str): + same_site = COOKIE_SAMESITE if COOKIE_SAMESITE in {"lax", "strict", "none"} else "lax" + response.set_cookie( + SESSION_COOKIE_NAME, + session_id, + httponly=True, + samesite=same_site, + max_age=SESSION_TTL_SECONDS, + secure=COOKIE_SECURE, + path="/", + ) + + +def _get_identifier(payload: AuthPayload) -> str: + identifier = payload.username or payload.email or "" + return identifier.strip() + + +@router.post("/signup") +def signup(payload: AuthPayload, response: Response): + identifier = _get_identifier(payload) + if not identifier or not payload.password: + raise HTTPException(status_code=400, detail="Email and password are required") + + user = create_user(identifier, payload.password) + if not user: + raise HTTPException(status_code=409, detail="User already exists") + + session_id = create_session(user["id"]) + _set_session_cookie(response, session_id) + try: + body = ( + "Welcome to Quantfortune!\n\n" + "Your account has been created successfully.\n\n" + "You can now log in and start using the platform.\n\n" + "Quantfortune Support" + ) + send_email(user["username"], "Welcome to Quantfortune", body) + except Exception: + pass + return {"id": user["id"], "username": user["username"], "role": user.get("role")} + + +@router.post("/login") +def login(payload: AuthPayload, response: Response, request: Request): + identifier = _get_identifier(payload) + if not identifier or not payload.password: + raise HTTPException(status_code=400, detail="Email and password are required") + + user = verify_user(identifier, payload.password) + if not user: + raise HTTPException(status_code=401, detail="Invalid email or password") + + client_ip = request.client.host if request.client else None + user_agent = request.headers.get("user-agent") + last_meta = get_last_session_meta(user["id"]) + if last_meta.get("ip") and ( + last_meta.get("ip") != client_ip or last_meta.get("user_agent") != user_agent + ): + try: + body = ( + "New login detected on your Quantfortune account.\n\n" + f"IP: {client_ip or 'unknown'}\n" + f"Device: {user_agent or 'unknown'}\n\n" + "If this wasn't you, please reset your password immediately." + ) + send_email(user["username"], "New login detected", body) + except Exception: + pass + + session_id = create_session(user["id"], ip=client_ip, user_agent=user_agent) + _set_session_cookie(response, session_id) + return {"id": user["id"], "username": user["username"], "role": user.get("role")} + + +@router.post("/logout") +def logout(request: Request, response: Response): + session_id = request.cookies.get(SESSION_COOKIE_NAME) + if session_id: + delete_session(session_id) + response.delete_cookie(SESSION_COOKIE_NAME, path="/") + return {"ok": True} + + +@router.get("/me") +def me(request: Request): + session_id = request.cookies.get(SESSION_COOKIE_NAME) + if not session_id: + raise HTTPException(status_code=401, detail="Not authenticated") + + user = get_user_for_session(session_id) + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + + return {"id": user["id"], "username": user["username"], "role": user.get("role")} diff --git a/app/routers/broker.py b/app/routers/broker.py new file mode 100644 index 0000000..8e5f7d6 --- /dev/null +++ b/app/routers/broker.py @@ -0,0 +1,205 @@ +import os + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import RedirectResponse + +from app.broker_store import ( + clear_user_broker, + get_broker_credentials, + get_pending_broker, + get_user_broker, + set_broker_auth_state, + set_connected_broker, + set_pending_broker, +) +from app.services.auth_service import get_user_for_session +from app.services.zerodha_service import build_login_url, exchange_request_token +from app.services.email_service import send_email +from app.services.zerodha_storage import set_session + +router = APIRouter(prefix="/api/broker") + + +def _require_user(request: Request): + session_id = request.cookies.get("session_id") + if not session_id: + raise HTTPException(status_code=401, detail="Not authenticated") + user = get_user_for_session(session_id) + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + return user + + +@router.post("/connect") +async def connect_broker(payload: dict, request: Request): + user = _require_user(request) + broker = (payload.get("broker") or "").strip() + token = (payload.get("token") or "").strip() + user_name = (payload.get("userName") or "").strip() + broker_user_id = (payload.get("brokerUserId") or "").strip() + if not broker or not token: + raise HTTPException(status_code=400, detail="Broker and token are required") + + set_connected_broker( + user["id"], + broker, + token, + user_name=user_name or None, + broker_user_id=broker_user_id or None, + ) + try: + body = ( + "Your broker has been connected to Quantfortune.\n\n" + f"Broker: {broker}\n" + f"Broker User ID: {broker_user_id or 'N/A'}\n" + ) + send_email(user["username"], "Broker connected", body) + except Exception: + pass + return {"connected": True} + + +@router.get("/status") +async def broker_status(request: Request): + user = _require_user(request) + entry = get_user_broker(user["id"]) + if not entry or not entry.get("connected"): + return {"connected": False} + return { + "connected": True, + "broker": entry.get("broker"), + "connected_at": entry.get("connected_at"), + "userName": entry.get("user_name"), + "brokerUserId": entry.get("broker_user_id"), + "authState": entry.get("auth_state"), + } + + +@router.post("/disconnect") +async def disconnect_broker(request: Request): + user = _require_user(request) + clear_user_broker(user["id"]) + set_broker_auth_state(user["id"], "DISCONNECTED") + try: + body = "Your broker connection has been disconnected from Quantfortune." + send_email(user["username"], "Broker disconnected", body) + except Exception: + pass + return {"connected": False} + + +@router.post("/zerodha/login") +async def zerodha_login(payload: dict, request: Request): + user = _require_user(request) + api_key = (payload.get("apiKey") or "").strip() + api_secret = (payload.get("apiSecret") or "").strip() + redirect_url = (payload.get("redirectUrl") or "").strip() + if not api_key or not api_secret: + raise HTTPException(status_code=400, detail="API key and secret are required") + + set_pending_broker(user["id"], "ZERODHA", api_key, api_secret) + return {"loginUrl": build_login_url(api_key, redirect_url=redirect_url or None)} + + +@router.get("/zerodha/callback") +async def zerodha_callback(request: Request, request_token: str = ""): + user = _require_user(request) + token = request_token.strip() + if not token: + raise HTTPException(status_code=400, detail="Missing request_token") + + pending = get_pending_broker(user["id"]) or {} + api_key = (pending.get("api_key") or "").strip() + api_secret = (pending.get("api_secret") or "").strip() + if not api_key or not api_secret: + raise HTTPException(status_code=400, detail="Zerodha login not initialized") + + try: + session_data = exchange_request_token(api_key, api_secret, token) + except Exception as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + access_token = session_data.get("access_token") + if not access_token: + raise HTTPException(status_code=400, detail="Missing access token from Zerodha") + + saved = set_session( + user["id"], + { + "api_key": api_key, + "access_token": access_token, + "request_token": session_data.get("request_token", token), + "user_name": session_data.get("user_name"), + "broker_user_id": session_data.get("user_id"), + }, + ) + set_connected_broker( + user["id"], + "ZERODHA", + access_token, + api_key=api_key, + api_secret=api_secret, + user_name=session_data.get("user_name"), + broker_user_id=session_data.get("user_id"), + auth_state="VALID", + ) + return { + "connected": True, + "userName": saved.get("user_name"), + "brokerUserId": saved.get("broker_user_id"), + } + + +@router.get("/login") +async def broker_login(request: Request): + user = _require_user(request) + 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" + login_url = build_login_url(creds["api_key"], redirect_url=redirect_url) + return RedirectResponse(login_url) + + +@router.get("/callback") +async def broker_callback(request: Request, request_token: str = ""): + user = _require_user(request) + token = request_token.strip() + if not token: + raise HTTPException(status_code=400, detail="Missing request_token") + creds = get_broker_credentials(user["id"]) + if not creds: + raise HTTPException(status_code=400, detail="Broker credentials not configured") + try: + session_data = exchange_request_token(creds["api_key"], creds["api_secret"], token) + except Exception as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + access_token = session_data.get("access_token") + if not access_token: + raise HTTPException(status_code=400, detail="Missing access token from Zerodha") + + set_session( + user["id"], + { + "api_key": creds["api_key"], + "access_token": access_token, + "request_token": session_data.get("request_token", token), + "user_name": session_data.get("user_name"), + "broker_user_id": session_data.get("user_id"), + }, + ) + set_connected_broker( + user["id"], + "ZERODHA", + access_token, + api_key=creds["api_key"], + api_secret=creds["api_secret"], + user_name=session_data.get("user_name"), + broker_user_id=session_data.get("user_id"), + auth_state="VALID", + ) + target_url = os.getenv("BROKER_DASHBOARD_URL") or "/dashboard?armed=false" + return RedirectResponse(target_url) diff --git a/app/routers/health.py b/app/routers/health.py new file mode 100644 index 0000000..9ec315b --- /dev/null +++ b/app/routers/health.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter, HTTPException + +from app.services.db import health_check + +router = APIRouter() + + +@router.get("/health") +def health(): + if not health_check(): + raise HTTPException(status_code=503, detail="db_unavailable") + return {"status": "ok", "db": "ok"} diff --git a/app/routers/paper.py b/app/routers/paper.py new file mode 100644 index 0000000..c7b564c --- /dev/null +++ b/app/routers/paper.py @@ -0,0 +1,75 @@ +from fastapi import APIRouter, HTTPException, Request + +from app.services.paper_broker_service import ( + add_cash, + get_equity_curve, + get_funds, + get_orders, + get_positions, + get_trades, + reset_paper_state, +) +from app.services.tenant import get_request_user_id + +router = APIRouter(prefix="/api/paper") + + +@router.get("/funds") +def funds(request: Request): + user_id = get_request_user_id(request) + return {"funds": get_funds(user_id)} + + +@router.get("/positions") +def positions(request: Request): + user_id = get_request_user_id(request) + return {"positions": get_positions(user_id)} + + +@router.get("/orders") +def orders(request: Request): + user_id = get_request_user_id(request) + return {"orders": get_orders(user_id)} + + +@router.get("/trades") +def trades(request: Request): + user_id = get_request_user_id(request) + return {"trades": get_trades(user_id)} + + +@router.get("/equity-curve") +def equity_curve(request: Request): + user_id = get_request_user_id(request) + return get_equity_curve(user_id) + + +@router.post("/add-cash") +def add_cash_endpoint(request: Request, payload: dict): + try: + amount = float(payload.get("amount", 0)) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="Invalid amount") + if amount <= 0: + raise HTTPException(status_code=400, detail="Amount must be positive") + try: + user_id = get_request_user_id(request) + add_cash(user_id, amount) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"funds": get_funds(user_id)} + + +@router.post("/reset") +def reset_paper(request: Request): + try: + from app.services.strategy_service import stop_strategy + + user_id = get_request_user_id(request) + stop_strategy(user_id) + except Exception: + pass + user_id = get_request_user_id(request) + reset_paper_state(user_id) + + return {"ok": True, "message": "Paper reset completed"} diff --git a/app/routers/password_reset.py b/app/routers/password_reset.py new file mode 100644 index 0000000..e5e0751 --- /dev/null +++ b/app/routers/password_reset.py @@ -0,0 +1,59 @@ +from fastapi import APIRouter, HTTPException + +from app.models import PasswordResetConfirm, PasswordResetRequest +from app.services.auth_service import ( + consume_password_reset_otp, + create_password_reset_otp, + get_user_by_username, + update_user_password, +) +from app.services.email_service import send_email + +router = APIRouter(prefix="/api/password-reset") + + +@router.post("/request") +def request_reset(payload: PasswordResetRequest): + email = payload.email.strip() + if not email: + raise HTTPException(status_code=400, detail="Email is required") + + user = get_user_by_username(email) + if not user: + return {"ok": True} + + otp = create_password_reset_otp(email) + body = ( + "Hi,\n\n" + "We received a request to reset your Quantfortune password.\n\n" + f"Your OTP code is: {otp}\n" + "This code is valid for 10 minutes.\n\n" + "If you did not request this, you can ignore this email.\n\n" + "Quantfortune Support" + ) + try: + ok = send_email(email, "Quantfortune Password Reset OTP", body) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Email send failed: {exc}") from exc + if not ok: + raise HTTPException(status_code=500, detail="Email send failed: SMTP not configured") + return {"ok": True} + + +@router.post("/confirm") +def confirm_reset(payload: PasswordResetConfirm): + email = payload.email.strip() + otp = payload.otp.strip() + new_password = payload.new_password + if not email or not otp or not new_password: + raise HTTPException(status_code=400, detail="Email, OTP, and new password are required") + + user = get_user_by_username(email) + if not user: + raise HTTPException(status_code=400, detail="Invalid OTP or email") + + if not consume_password_reset_otp(email, otp): + raise HTTPException(status_code=400, detail="Invalid or expired OTP") + + update_user_password(user["id"], new_password) + return {"ok": True} diff --git a/app/routers/strategy.py b/app/routers/strategy.py new file mode 100644 index 0000000..5df9b50 --- /dev/null +++ b/app/routers/strategy.py @@ -0,0 +1,47 @@ +from fastapi import APIRouter, Query, Request +from app.models import StrategyStartRequest +from app.services.strategy_service import ( + start_strategy, + stop_strategy, + get_strategy_status, + get_engine_status, + get_market_status, + get_strategy_logs as fetch_strategy_logs, +) +from app.services.tenant import get_request_user_id + +router = APIRouter(prefix="/api") + +@router.post("/strategy/start") +def start(req: StrategyStartRequest, request: Request): + user_id = get_request_user_id(request) + return start_strategy(req, user_id) + +@router.post("/strategy/stop") +def stop(request: Request): + user_id = get_request_user_id(request) + return stop_strategy(user_id) + +@router.get("/strategy/status") +def status(request: Request): + user_id = get_request_user_id(request) + return get_strategy_status(user_id) + +@router.get("/engine/status") +def engine_status(request: Request): + user_id = get_request_user_id(request) + return get_engine_status(user_id) + +@router.get("/market/status") +def market_status(): + return get_market_status() + +@router.get("/logs") +def get_logs(request: Request, since_seq: int = Query(0)): + user_id = get_request_user_id(request) + return fetch_strategy_logs(user_id, since_seq) + +@router.get("/strategy/logs") +def get_strategy_logs_endpoint(request: Request, since_seq: int = Query(0)): + user_id = get_request_user_id(request) + return fetch_strategy_logs(user_id, since_seq) diff --git a/app/routers/support_ticket.py b/app/routers/support_ticket.py new file mode 100644 index 0000000..06db908 --- /dev/null +++ b/app/routers/support_ticket.py @@ -0,0 +1,39 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from app.services.support_ticket import create_ticket, get_ticket_status + + +router = APIRouter(prefix="/api/support") + + +class TicketCreate(BaseModel): + name: str + email: str + subject: str + message: str + + +class TicketStatusRequest(BaseModel): + email: str + + +@router.post("/ticket") +def submit_ticket(payload: TicketCreate): + if not payload.subject.strip() or not payload.message.strip(): + raise HTTPException(status_code=400, detail="Subject and message are required") + ticket = create_ticket( + name=payload.name.strip(), + email=payload.email.strip(), + subject=payload.subject.strip(), + message=payload.message.strip(), + ) + return ticket + + +@router.post("/ticket/status/{ticket_id}") +def ticket_status(ticket_id: str, payload: TicketStatusRequest): + status = get_ticket_status(ticket_id.strip(), payload.email.strip()) + if not status: + raise HTTPException(status_code=404, detail="Ticket not found") + return status diff --git a/app/routers/system.py b/app/routers/system.py new file mode 100644 index 0000000..2609f3d --- /dev/null +++ b/app/routers/system.py @@ -0,0 +1,41 @@ +from fastapi import APIRouter, HTTPException, Request + +from app.services.auth_service import get_user_for_session +from app.services.system_service import arm_system, system_status +from app.services.zerodha_service import KiteApiError + +router = APIRouter(prefix="/api/system") + + +def _require_user(request: Request): + session_id = request.cookies.get("session_id") + if not session_id: + raise HTTPException(status_code=401, detail="Not authenticated") + user = get_user_for_session(session_id) + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + return user + + +@router.post("/arm") +def arm(request: Request): + user = _require_user(request) + try: + result = arm_system(user["id"], client_ip=request.client.host if request.client else None) + except KiteApiError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + if not result.get("ok"): + if result.get("code") == "BROKER_AUTH_REQUIRED": + raise HTTPException( + status_code=401, + detail={"redirect_url": result.get("redirect_url")}, + ) + raise HTTPException(status_code=400, detail="Unable to arm system") + return result + + +@router.get("/status") +def status(request: Request): + user = _require_user(request) + return system_status(user["id"]) diff --git a/app/routers/zerodha.py b/app/routers/zerodha.py new file mode 100644 index 0000000..9aec21f --- /dev/null +++ b/app/routers/zerodha.py @@ -0,0 +1,234 @@ +from datetime import datetime, timedelta + +from fastapi import APIRouter, HTTPException, Query, Request +from fastapi.responses import HTMLResponse + +from app.broker_store import clear_user_broker +from app.services.auth_service import get_user_for_session +from app.services.zerodha_service import ( + KiteApiError, + KiteTokenError, + build_login_url, + exchange_request_token, + fetch_funds, + fetch_holdings, +) +from app.services.zerodha_storage import ( + clear_session, + consume_request_token, + get_session, + set_session, + store_request_token, +) + +router = APIRouter(prefix="/api/zerodha") +public_router = APIRouter() + + +def _require_user(request: Request): + session_id = request.cookies.get("session_id") + if not session_id: + raise HTTPException(status_code=401, detail="Not authenticated") + user = get_user_for_session(session_id) + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + return user + + +def _capture_request_token(request: Request, request_token: str): + user = _require_user(request) + token = request_token.strip() + if not token: + raise HTTPException(status_code=400, detail="Missing request_token") + store_request_token(user["id"], token) + + +def _clear_broker_session(user_id: str): + clear_user_broker(user_id) + clear_session(user_id) + + +def _raise_kite_error(user_id: str, exc: KiteApiError): + if isinstance(exc, KiteTokenError): + _clear_broker_session(user_id) + raise HTTPException( + status_code=401, detail="Zerodha session expired. Please reconnect." + ) from exc + raise HTTPException(status_code=502, detail=str(exc)) from exc + + +@router.post("/login-url") +async def login_url(payload: dict, request: Request): + _require_user(request) + api_key = (payload.get("apiKey") or "").strip() + if not api_key: + raise HTTPException(status_code=400, detail="API key is required") + return {"loginUrl": build_login_url(api_key)} + + +@router.post("/session") +async def create_session(payload: dict, request: Request): + user = _require_user(request) + api_key = (payload.get("apiKey") or "").strip() + api_secret = (payload.get("apiSecret") or "").strip() + request_token = (payload.get("requestToken") or "").strip() + if not api_key or not api_secret or not request_token: + raise HTTPException( + status_code=400, detail="API key, secret, and request token are required" + ) + + try: + session_data = exchange_request_token(api_key, api_secret, request_token) + except Exception as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + saved = set_session( + user["id"], + { + "api_key": api_key, + "access_token": session_data.get("access_token"), + "request_token": session_data.get("request_token", request_token), + "user_name": session_data.get("user_name"), + "broker_user_id": session_data.get("user_id"), + }, + ) + + return { + "connected": True, + "userName": saved.get("user_name"), + "brokerUserId": saved.get("broker_user_id"), + "accessToken": saved.get("access_token"), + } + + +@router.get("/status") +async def status(request: Request): + user = _require_user(request) + session = get_session(user["id"]) + if not session: + return {"connected": False} + + return { + "connected": True, + "broker": "zerodha", + "userName": session.get("user_name"), + "linkedAt": session.get("linked_at"), + } + + +@router.get("/request-token") +async def request_token(request: Request): + user = _require_user(request) + token = consume_request_token(user["id"]) + if not token: + raise HTTPException(status_code=404, detail="No request token available.") + return {"requestToken": token} + + +@router.get("/holdings") +async def holdings(request: Request): + user = _require_user(request) + session = get_session(user["id"]) + if not session: + raise HTTPException(status_code=400, detail="Zerodha is not connected") + try: + data = fetch_holdings(session["api_key"], session["access_token"]) + except KiteApiError as exc: + _raise_kite_error(user["id"], exc) + return {"holdings": data} + + +@router.get("/funds") +async def funds(request: Request): + user = _require_user(request) + session = get_session(user["id"]) + if not session: + raise HTTPException(status_code=400, detail="Zerodha is not connected") + try: + data = fetch_funds(session["api_key"], session["access_token"]) + except KiteApiError as exc: + _raise_kite_error(user["id"], exc) + equity = data.get("equity", {}) if isinstance(data, dict) else {} + return {"funds": {**equity, "raw": data}} + + +@router.get("/equity-curve") +async def equity_curve(request: Request, from_: str = Query("", alias="from")): + user = _require_user(request) + session = get_session(user["id"]) + if not session: + raise HTTPException(status_code=400, detail="Zerodha is not connected") + + try: + holdings = fetch_holdings(session["api_key"], session["access_token"]) + funds_data = fetch_funds(session["api_key"], session["access_token"]) + except KiteApiError as exc: + _raise_kite_error(user["id"], exc) + + equity = funds_data.get("equity", {}) if isinstance(funds_data, dict) else {} + total_holdings_value = 0 + for item in holdings: + qty = float(item.get("quantity") or item.get("qty") or 0) + last = float(item.get("last_price") or item.get("average_price") or 0) + total_holdings_value += qty * last + + total_funds = float(equity.get("cash") or 0) + current_value = max(0, total_holdings_value + total_funds) + + ms_in_day = 86400000 + now = datetime.utcnow() + default_start = now - timedelta(days=90) + if from_: + try: + start_date = datetime.fromisoformat(from_) + except ValueError: + start_date = default_start + else: + start_date = default_start + if start_date > now: + start_date = now + + span_days = max( + 2, + int(((now - start_date).total_seconds() * 1000) // ms_in_day), + ) + start_value = current_value * 0.85 if current_value > 0 else 10000 + points = [] + for i in range(span_days): + day = start_date + timedelta(days=i) + progress = i / (span_days - 1) + trend = start_value + (current_value - start_value) * progress + value = max(0, round(trend)) + points.append({"date": day.isoformat(), "value": value}) + + return { + "startDate": start_date.isoformat(), + "endDate": now.isoformat(), + "accountOpenDate": session.get("linked_at"), + "points": points, + } + + +@router.get("/callback") +async def callback(request: Request, request_token: str = ""): + _capture_request_token(request, request_token) + return { + "status": "ok", + "message": "Request token captured. You can close this tab.", + } + + +@router.get("/login") +async def login_redirect(request: Request, request_token: str = ""): + return await callback(request, request_token=request_token) + + +@public_router.get("/login", response_class=HTMLResponse) +async def login_capture(request: Request, request_token: str = ""): + _capture_request_token(request, request_token) + return ( + "" + "

Request token captured

" + "

You can close this tab and return to QuantFortune.

" + "" + ) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/auth_service.py b/app/services/auth_service.py new file mode 100644 index 0000000..ca6a6b5 --- /dev/null +++ b/app/services/auth_service.py @@ -0,0 +1,280 @@ +import hashlib +import os +import secrets +from datetime import datetime, timedelta, timezone +from uuid import uuid4 + +from app.services.db import db_connection + +SESSION_TTL_SECONDS = int(os.getenv("SESSION_TTL_SECONDS", str(60 * 60 * 24 * 7))) +SESSION_REFRESH_WINDOW_SECONDS = int( + os.getenv("SESSION_REFRESH_WINDOW_SECONDS", str(60 * 60)) +) +RESET_OTP_TTL_MINUTES = int(os.getenv("RESET_OTP_TTL_MINUTES", "10")) +RESET_OTP_SECRET = os.getenv("RESET_OTP_SECRET", "otp_secret") + + +def _now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def _new_expiry(now: datetime) -> datetime: + return now + timedelta(seconds=SESSION_TTL_SECONDS) + + +def _hash_password(password: str) -> str: + return hashlib.sha256(password.encode("utf-8")).hexdigest() + + +def _hash_otp(email: str, otp: str) -> str: + payload = f"{email}:{otp}:{RESET_OTP_SECRET}" + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _row_to_user(row): + if not row: + return None + return { + "id": row[0], + "username": row[1], + "password": row[2], + "role": row[3] if len(row) > 3 else None, + } + + +def get_user_by_username(username: str): + with db_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT id, username, password_hash, role FROM app_user WHERE username = %s", + (username,), + ) + return _row_to_user(cur.fetchone()) + + +def get_user_by_id(user_id: str): + with db_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT id, username, password_hash, role FROM app_user WHERE id = %s", + (user_id,), + ) + return _row_to_user(cur.fetchone()) + + +def create_user(username: str, password: str): + user_id = str(uuid4()) + password_hash = _hash_password(password) + with db_connection() as conn: + with conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO app_user (id, username, password_hash, role) + VALUES (%s, %s, %s, 'USER') + ON CONFLICT (username) DO NOTHING + RETURNING id, username, password_hash, role + """, + (user_id, username, password_hash), + ) + return _row_to_user(cur.fetchone()) + + +def authenticate_user(username: str, password: str): + user = get_user_by_username(username) + if not user: + return None + if user.get("password") != _hash_password(password): + return None + return user + + +def verify_user(username: str, password: str): + return authenticate_user(username, password) + + +def create_session(user_id: str, ip: str | None = None, user_agent: str | None = None) -> str: + session_id = str(uuid4()) + now = _now_utc() + with db_connection() as conn: + with conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO app_session (id, user_id, created_at, last_seen_at, expires_at, ip, user_agent) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + (session_id, user_id, now, now, _new_expiry(now), ip, user_agent), + ) + return session_id + + +def get_last_session_meta(user_id: str): + with db_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT ip, user_agent + FROM app_session + WHERE user_id = %s + ORDER BY created_at DESC + LIMIT 1 + """, + (user_id,), + ) + row = cur.fetchone() + if not row: + return {"ip": None, "user_agent": None} + return {"ip": row[0], "user_agent": row[1]} + + +def update_user_password(user_id: str, new_password: str): + password_hash = _hash_password(new_password) + with db_connection() as conn: + with conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE app_user SET password_hash = %s WHERE id = %s", + (password_hash, user_id), + ) + + +def create_password_reset_otp(email: str): + otp = f"{secrets.randbelow(10000):04d}" + now = _now_utc() + expires_at = now + timedelta(minutes=RESET_OTP_TTL_MINUTES) + otp_hash = _hash_otp(email, otp) + with db_connection() as conn: + with conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO password_reset_otp (id, email, otp_hash, created_at, expires_at, used_at) + VALUES (%s, %s, %s, %s, %s, NULL) + """, + (str(uuid4()), email, otp_hash, now, expires_at), + ) + return otp + + +def consume_password_reset_otp(email: str, otp: str) -> bool: + now = _now_utc() + otp_hash = _hash_otp(email, otp) + with db_connection() as conn: + with conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id + FROM password_reset_otp + WHERE email = %s + AND otp_hash = %s + AND used_at IS NULL + AND expires_at > %s + ORDER BY created_at DESC + LIMIT 1 + """, + (email, otp_hash, now), + ) + row = cur.fetchone() + if not row: + return False + cur.execute( + "UPDATE password_reset_otp SET used_at = %s WHERE id = %s", + (now, row[0]), + ) + return True + + +def get_session(session_id: str): + with db_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, user_id, created_at, last_seen_at, expires_at + FROM app_session + WHERE id = %s + """, + (session_id,), + ) + row = cur.fetchone() + if not row: + return None + created_at = row[2].isoformat() if row[2] else None + last_seen_at = row[3].isoformat() if row[3] else None + expires_at = row[4].isoformat() if row[4] else None + return { + "id": row[0], + "user_id": row[1], + "created_at": created_at, + "last_seen_at": last_seen_at, + "expires_at": expires_at, + } + + +def delete_session(session_id: str): + with db_connection() as conn: + with conn: + with conn.cursor() as cur: + cur.execute("DELETE FROM app_session WHERE id = %s", (session_id,)) + + +def get_user_for_session(session_id: str): + if not session_id: + return None + now = _now_utc() + with db_connection() as conn: + with conn: + with conn.cursor() as cur: + cur.execute( + """ + DELETE FROM app_session + WHERE expires_at IS NOT NULL AND expires_at <= %s + """, + (now,), + ) + cur.execute( + """ + SELECT id, user_id, created_at, last_seen_at, expires_at + FROM app_session + WHERE id = %s + """, + (session_id,), + ) + row = cur.fetchone() + if not row: + return None + + expires_at = row[4] + if expires_at is None: + new_expiry = _new_expiry(now) + cur.execute( + """ + UPDATE app_session + SET expires_at = %s, last_seen_at = %s + WHERE id = %s + """, + (new_expiry, now, session_id), + ) + expires_at = new_expiry + + if expires_at <= now: + cur.execute("DELETE FROM app_session WHERE id = %s", (session_id,)) + return None + + if (expires_at - now).total_seconds() <= SESSION_REFRESH_WINDOW_SECONDS: + new_expiry = _new_expiry(now) + cur.execute( + """ + UPDATE app_session + SET expires_at = %s, last_seen_at = %s + WHERE id = %s + """, + (new_expiry, now, session_id), + ) + + cur.execute( + "SELECT id, username, password_hash, role FROM app_user WHERE id = %s", + (row[1],), + ) + return _row_to_user(cur.fetchone()) diff --git a/app/services/broker_service.py b/app/services/broker_service.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/crypto_service.py b/app/services/crypto_service.py new file mode 100644 index 0000000..d637569 --- /dev/null +++ b/app/services/crypto_service.py @@ -0,0 +1,39 @@ +import os + +from cryptography.fernet import Fernet, InvalidToken + +ENCRYPTION_PREFIX = "enc:" +KEY_ENV_VAR = "BROKER_TOKEN_KEY" + + +def _get_fernet() -> Fernet: + key = (os.getenv(KEY_ENV_VAR) or "").strip() + if not key: + raise RuntimeError(f"{KEY_ENV_VAR} is not set") + try: + return Fernet(key.encode("utf-8")) + except Exception as exc: + raise RuntimeError( + f"{KEY_ENV_VAR} must be a urlsafe base64-encoded 32-byte key" + ) from exc + + +def encrypt_value(value: str | None) -> str | None: + if not value: + return value + if value.startswith(ENCRYPTION_PREFIX): + return value + token = _get_fernet().encrypt(value.encode("utf-8")).decode("utf-8") + return f"{ENCRYPTION_PREFIX}{token}" + + +def decrypt_value(value: str | None) -> str | None: + if not value: + return value + if not value.startswith(ENCRYPTION_PREFIX): + return value + token = value[len(ENCRYPTION_PREFIX) :] + try: + return _get_fernet().decrypt(token.encode("utf-8")).decode("utf-8") + except InvalidToken as exc: + raise RuntimeError("Unable to decrypt token; invalid BROKER_TOKEN_KEY") from exc diff --git a/app/services/db.py b/app/services/db.py new file mode 100644 index 0000000..97796a3 --- /dev/null +++ b/app/services/db.py @@ -0,0 +1,210 @@ +import os +import threading +import time +from contextlib import contextmanager +from typing import Generator + +from sqlalchemy import create_engine, schema, text +from sqlalchemy.engine import Engine, URL +from sqlalchemy.exc import InterfaceError as SAInterfaceError +from sqlalchemy.exc import OperationalError as SAOperationalError +from sqlalchemy.orm import declarative_base, sessionmaker +from psycopg2 import OperationalError as PGOperationalError +from psycopg2 import InterfaceError as PGInterfaceError + +Base = declarative_base() + +_ENGINE: Engine | None = None +_ENGINE_LOCK = threading.Lock() + + +class _ConnectionProxy: + def __init__(self, conn): + self._conn = conn + + def __getattr__(self, name): + return getattr(self._conn, name) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + if exc_type is None: + try: + self._conn.commit() + except Exception: + self._conn.rollback() + raise + else: + try: + self._conn.rollback() + except Exception: + pass + return False + + +def _db_config() -> dict[str, str | int]: + url = os.getenv("DATABASE_URL") + if url: + return {"url": url} + + 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")), + "schema": os.getenv("DB_SCHEMA") or os.getenv("PGSCHEMA") or "quant_app", + } + + +def get_database_url(cfg: dict[str, str | int] | None = None) -> str: + cfg = cfg or _db_config() + if "url" in cfg: + return str(cfg["url"]) + schema_name = cfg.get("schema") + query = {"connect_timeout": str(cfg["connect_timeout"])} + if schema_name: + query["options"] = f"-csearch_path={schema_name},public" + url = URL.create( + "postgresql+psycopg2", + username=str(cfg["user"]), + password=str(cfg["password"]), + host=str(cfg["host"]), + port=int(cfg["port"]), + database=str(cfg["dbname"]), + query=query, + ) + return url.render_as_string(hide_password=False) + + +def _create_engine() -> Engine: + cfg = _db_config() + pool_size = int(os.getenv("DB_POOL_SIZE", os.getenv("DB_POOL_MIN", "5"))) + max_overflow = int(os.getenv("DB_POOL_MAX", "10")) + pool_timeout = int(os.getenv("DB_POOL_TIMEOUT", "30")) + engine = create_engine( + get_database_url(cfg), + pool_size=pool_size, + max_overflow=max_overflow, + pool_timeout=pool_timeout, + pool_pre_ping=True, + future=True, + ) + schema_name = cfg.get("schema") + if schema_name: + try: + with engine.begin() as conn: + conn.execute(schema.CreateSchema(schema_name, if_not_exists=True)) + except Exception: + # Schema creation is best-effort; permissions might be limited in some environments. + pass + return engine + + +def get_engine() -> Engine: + global _ENGINE + if _ENGINE is None: + with _ENGINE_LOCK: + if _ENGINE is None: + _ENGINE = _create_engine() + return _ENGINE + + +SessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + expire_on_commit=False, + bind=get_engine(), +) + + +def _get_connection(): + return get_engine().raw_connection() + + +def _put_connection(conn, close=False): + try: + conn.close() + except Exception: + if not close: + raise + + +@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 _ConnectionProxy(conn) + return + except (SAOperationalError, SAInterfaceError, PGOperationalError, PGInterfaceError) as exc: + last_error = exc + if conn is not None: + _put_connection(conn) + 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 (SAOperationalError, SAInterfaceError, PGOperationalError, PGInterfaceError) 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 get_db() -> Generator: + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def health_check() -> bool: + try: + with get_engine().connect() as conn: + conn.execute(text("SELECT 1")) + return True + except Exception: + return False diff --git a/app/services/email_service.py b/app/services/email_service.py new file mode 100644 index 0000000..f7213b0 --- /dev/null +++ b/app/services/email_service.py @@ -0,0 +1,28 @@ +import os +import smtplib +import ssl +from email.message import EmailMessage + + +def send_email(to_email: str, subject: str, body_text: str) -> bool: + smtp_user = (os.getenv("SMTP_USER") or "").strip() + smtp_pass = (os.getenv("SMTP_PASS") or "").replace(" ", "").strip() + smtp_host = (os.getenv("SMTP_HOST") or "smtp.gmail.com").strip() + smtp_port = int((os.getenv("SMTP_PORT") or "587").strip()) + from_name = (os.getenv("SMTP_FROM_NAME") or "Quantfortune Support").strip() + + if not smtp_user or not smtp_pass: + return False + + msg = EmailMessage() + msg["From"] = f"{from_name} <{smtp_user}>" + msg["To"] = to_email + msg["Subject"] = subject + msg.set_content(body_text) + + context = ssl.create_default_context() + with smtplib.SMTP(smtp_host, smtp_port) as server: + server.starttls(context=context) + server.login(smtp_user, smtp_pass) + server.send_message(msg) + return True diff --git a/app/services/paper_broker_service.py b/app/services/paper_broker_service.py new file mode 100644 index 0000000..db2101b --- /dev/null +++ b/app/services/paper_broker_service.py @@ -0,0 +1,191 @@ +import os +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +if str(PROJECT_ROOT) not in sys.path: + sys.path.append(str(PROJECT_ROOT)) + +from indian_paper_trading_strategy.engine.broker import PaperBroker +from indian_paper_trading_strategy.engine.state import load_state, save_state +from indian_paper_trading_strategy.engine.db import engine_context, insert_engine_event +from app.services.db import run_with_retry +from app.services.run_service import get_active_run_id, get_running_run_id + +_logged_path = False + + +def _broker(): + global _logged_path + state = load_state(mode="PAPER") + initial_cash = float(state.get("initial_cash", 0)) + broker = PaperBroker(initial_cash=initial_cash) + if not _logged_path: + _logged_path = True + print( + "PaperBroker store path:", + { + "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", + }, + ) + return broker + + +def get_paper_broker(user_id: str): + run_id = get_active_run_id(user_id) + with engine_context(user_id, run_id): + return _broker() + + +def get_funds(user_id: str): + run_id = get_active_run_id(user_id) + with engine_context(user_id, run_id): + return _broker().get_funds() + + +def get_positions(user_id: str): + run_id = get_active_run_id(user_id) + with engine_context(user_id, run_id): + positions = _broker().get_positions() + enriched = [] + for item in positions: + qty = float(item.get("qty", 0)) + avg = float(item.get("avg_price", 0)) + ltp = float(item.get("last_price", 0)) + pnl = (ltp - avg) * qty + pnl_pct = ((ltp - avg) / avg * 100) if avg else 0.0 + enriched.append( + { + **item, + "pnl": pnl, + "pnl_pct": pnl_pct, + } + ) + return enriched + + +def get_orders(user_id: str): + run_id = get_active_run_id(user_id) + with engine_context(user_id, run_id): + return _broker().get_orders() + + +def get_trades(user_id: str): + run_id = get_active_run_id(user_id) + with engine_context(user_id, run_id): + return _broker().get_trades() + + +def get_equity_curve(user_id: str): + run_id = get_active_run_id(user_id) + with engine_context(user_id, run_id): + broker = _broker() + points = broker.get_equity_curve() + if not points: + return [] + + state = load_state(mode="PAPER") + initial_cash = float(state.get("initial_cash", 0)) + response = [] + for point in points: + equity = float(point.get("equity", 0)) + pnl = point.get("pnl") + if pnl is None: + pnl = equity - float(initial_cash) + response.append( + { + "timestamp": point.get("timestamp"), + "equity": equity, + "pnl": float(pnl), + } + ) + return response + + +def add_cash(user_id: str, amount: float): + if amount <= 0: + raise ValueError("Amount must be positive") + run_id = get_running_run_id(user_id) + if not run_id: + raise ValueError("Strategy must be running to add cash") + + def _op(cur, _conn): + with engine_context(user_id, run_id): + state = load_state(mode="PAPER", cur=cur, for_update=True) + initial_cash = float(state.get("initial_cash", 0)) + broker = PaperBroker(initial_cash=initial_cash) + store = broker._load_store(cur=cur, for_update=True) + cash = float(store.get("cash", 0)) + amount + store["cash"] = cash + broker._save_store(store, cur=cur) + + state["cash"] = cash + state["initial_cash"] = initial_cash + amount + state["total_invested"] = float(state.get("total_invested", 0)) + amount + save_state( + state, + mode="PAPER", + cur=cur, + emit_event=True, + event_meta={"source": "add_cash"}, + ) + insert_engine_event( + cur, + "CASH_ADDED", + data={"amount": amount, "cash": cash}, + ) + return state + + return run_with_retry(_op) + + +def reset_paper_state(user_id: str): + run_id = get_active_run_id(user_id) + + def _op(cur, _conn): + with engine_context(user_id, run_id): + cur.execute( + "DELETE FROM strategy_log WHERE user_id = %s AND run_id = %s", + (user_id, run_id), + ) + cur.execute( + "DELETE FROM engine_event WHERE user_id = %s AND run_id = %s", + (user_id, run_id), + ) + cur.execute( + "DELETE FROM paper_equity_curve WHERE user_id = %s AND run_id = %s", + (user_id, run_id), + ) + cur.execute( + "DELETE FROM paper_trade WHERE user_id = %s AND run_id = %s", + (user_id, run_id), + ) + cur.execute( + "DELETE FROM paper_order WHERE user_id = %s AND run_id = %s", + (user_id, run_id), + ) + cur.execute( + "DELETE FROM paper_position WHERE user_id = %s AND run_id = %s", + (user_id, run_id), + ) + cur.execute( + "DELETE FROM paper_broker_account WHERE user_id = %s AND run_id = %s", + (user_id, run_id), + ) + cur.execute( + "DELETE FROM mtm_ledger WHERE user_id = %s AND run_id = %s", + (user_id, run_id), + ) + cur.execute( + "DELETE FROM event_ledger WHERE user_id = %s AND run_id = %s", + (user_id, run_id), + ) + cur.execute( + "DELETE FROM engine_state_paper WHERE user_id = %s AND run_id = %s", + (user_id, run_id), + ) + insert_engine_event(cur, "PAPER_RESET", data={}) + + run_with_retry(_op) diff --git a/app/services/run_lifecycle.py b/app/services/run_lifecycle.py new file mode 100644 index 0000000..5606031 --- /dev/null +++ b/app/services/run_lifecycle.py @@ -0,0 +1,22 @@ +class RunLifecycleError(Exception): + pass + + +class RunLifecycleManager: + ARMABLE = {"STOPPED", "PAUSED_AUTH_EXPIRED"} + + @classmethod + def assert_can_arm(cls, status: str): + normalized = (status or "").strip().upper() + if normalized == "RUNNING": + raise RunLifecycleError("Run already RUNNING") + if normalized == "ERROR": + raise RunLifecycleError("Run in ERROR must be reset before arming") + if normalized not in cls.ARMABLE: + raise RunLifecycleError(f"Run cannot be armed from status {normalized}") + return normalized + + @classmethod + def is_armable(cls, status: str) -> bool: + normalized = (status or "").strip().upper() + return normalized in cls.ARMABLE diff --git a/app/services/run_service.py b/app/services/run_service.py new file mode 100644 index 0000000..4e1e578 --- /dev/null +++ b/app/services/run_service.py @@ -0,0 +1,176 @@ +import threading +from datetime import datetime, timezone +from uuid import uuid4 + +from psycopg2.extras import Json + +from app.services.db import run_with_retry + +_DEFAULT_USER_ID = None +_DEFAULT_LOCK = threading.Lock() + + +def _utc_now(): + return datetime.now(timezone.utc) + + +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_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() + return row[0] if row else None + + return run_with_retry(_op) + + +def create_strategy_run(user_id: str, strategy: str | None, mode: str | None, broker: str | None, meta: dict | None): + run_id = str(uuid4()) + + 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) + """, + ( + run_id, + user_id, + now, + now, + None, + "RUNNING", + strategy, + mode, + broker, + Json(meta or {}), + ), + ) + return run_id + + return run_with_retry(_op) + + +def update_run_status(user_id: str, run_id: str, status: str, meta: dict | None = None): + def _op(cur, _conn): + now = _utc_now() + if status == "RUNNING": + cur.execute( + """ + UPDATE strategy_run + SET status = %s, started_at = COALESCE(started_at, %s), meta = COALESCE(meta, '{}'::jsonb) || %s + WHERE run_id = %s AND user_id = %s + """, + (status, now, Json(meta or {}), run_id, user_id), + ) + else: + cur.execute( + """ + UPDATE strategy_run + SET status = %s, stopped_at = %s, meta = COALESCE(meta, '{}'::jsonb) || %s + WHERE run_id = %s AND user_id = %s + """, + (status, now, Json(meta or {}), run_id, user_id), + ) + return True + + return run_with_retry(_op) diff --git a/app/services/strategy_service.py b/app/services/strategy_service.py new file mode 100644 index 0000000..f31c591 --- /dev/null +++ b/app/services/strategy_service.py @@ -0,0 +1,650 @@ +import json +import os +import sys +import threading +from datetime import datetime, timedelta, timezone +from pathlib import Path + +ENGINE_ROOT = Path(__file__).resolve().parents[3] +if str(ENGINE_ROOT) not in sys.path: + sys.path.append(str(ENGINE_ROOT)) + +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.state import init_paper_state, load_state +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.db import engine_context + +from app.services.db import db_connection +from app.services.run_service import ( + create_strategy_run, + get_active_run_id, + get_running_run_id, + update_run_status, +) +from app.services.auth_service import get_user_by_id +from app.services.email_service import send_email +from psycopg2.extras import Json +from psycopg2 import errors + +SEQ_LOCK = threading.Lock() +SEQ = 0 +LAST_WAIT_LOG_TS = {} +WAIT_LOG_INTERVAL = timedelta(seconds=60) + +def init_log_state(): + global SEQ + + with db_connection() as conn: + with conn.cursor() as cur: + cur.execute("SELECT COALESCE(MAX(seq), 0) FROM strategy_log") + row = cur.fetchone() + SEQ = row[0] if row and row[0] is not None else 0 + +def start_new_run(user_id: str, run_id: str): + LAST_WAIT_LOG_TS.pop(run_id, None) + emit_event( + user_id=user_id, + run_id=run_id, + event="STRATEGY_STARTED", + message="Strategy started", + meta={}, + ) + + +def stop_run(user_id: str, run_id: str, reason="user_request"): + emit_event( + user_id=user_id, + run_id=run_id, + event="STRATEGY_STOPPED", + message="Strategy stopped", + meta={"reason": reason}, + ) + + +def emit_event( + *, + user_id: str, + run_id: str, + event: str, + message: str, + level: str = "INFO", + category: str = "ENGINE", + meta: dict | None = None +): + global SEQ, LAST_WAIT_LOG_TS + if not user_id or not run_id: + return + + now = datetime.now(timezone.utc) + if event == "SIP_WAITING": + last_ts = LAST_WAIT_LOG_TS.get(run_id) + if last_ts and (now - last_ts) < WAIT_LOG_INTERVAL: + return + LAST_WAIT_LOG_TS[run_id] = now + + with SEQ_LOCK: + SEQ += 1 + seq = SEQ + + evt = { + "seq": seq, + "ts": now.isoformat().replace("+00:00", "Z"), + "level": level, + "category": category, + "event": event, + "message": message, + "run_id": run_id, + "meta": meta or {} + } + + with db_connection() as conn: + with conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO strategy_log ( + seq, ts, level, category, event, message, user_id, run_id, meta + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (seq) DO NOTHING + """, + ( + evt["seq"], + now, + evt["level"], + evt["category"], + evt["event"], + evt["message"], + user_id, + evt["run_id"], + Json(evt["meta"]), + ), + ) + +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 _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 _load_config(user_id: str, run_id: str): + 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 + """, + (user_id, run_id), + ) + row = cur.fetchone() + if not row: + return {} + cfg = { + "strategy": row[0], + "sip_amount": float(row[1]) if row[1] is not None else None, + "mode": row[4], + "broker": row[5], + "active": row[6], + "frequency": _maybe_parse_json(row[7]), + "frequency_days": row[8], + "unit": row[9], + "next_run": _format_local_ts(row[10]), + } + if row[2] is not None or row[3] is not None: + cfg["sip_frequency"] = { + "value": row[2], + "unit": row[3], + } + return cfg + + +def _save_config(cfg, user_id: str, run_id: str): + 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: + parsed = datetime.fromisoformat(next_run) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=_local_tz()) + next_run_dt = parsed + except ValueError: + next_run_dt = None + + 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 + """, + ( + user_id, + run_id, + cfg.get("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, + ), + ) + +def save_strategy_config(cfg, user_id: str, run_id: str): + _save_config(cfg, user_id, run_id) + +def deactivate_strategy_config(user_id: str, run_id: str): + cfg = _load_config(user_id, run_id) + cfg["active"] = False + _save_config(cfg, user_id, run_id) + +def _write_status(user_id: str, run_id: str, status): + now_local = datetime.now().astimezone() + with db_connection() as conn: + with conn: + with conn.cursor() as cur: + 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_local), + ) + +def validate_frequency(freq: dict, mode: str): + if not isinstance(freq, dict): + raise ValueError("Frequency payload is required") + value = int(freq.get("value", 0)) + unit = freq.get("unit") + + if unit not in {"minutes", "days"}: + raise ValueError(f"Unsupported frequency unit: {unit}") + + if unit == "minutes": + if mode != "PAPER": + raise ValueError("Minute-level frequency allowed only in PAPER mode") + if value < 1: + raise ValueError("Minimum frequency is 1 minute") + + if unit == "days" and value < 1: + raise ValueError("Minimum frequency is 1 day") + +def compute_next_eligible(last_run: str | None, sip_frequency: dict | None): + if not last_run or not sip_frequency: + return None + try: + last_dt = datetime.fromisoformat(last_run) + except ValueError: + return None + try: + delta = frequency_to_timedelta(sip_frequency) + except ValueError: + return None + next_dt = last_dt + delta + next_dt = align_to_market_open(next_dt) + return next_dt.isoformat() + +def start_strategy(req, user_id: str): + engine_external = os.getenv("ENGINE_EXTERNAL", "").strip().lower() in {"1", "true", "yes"} + running_run_id = get_running_run_id(user_id) + if running_run_id: + if engine_external: + return {"status": "already_running", "run_id": running_run_id} + engine_config = _build_engine_config(user_id, running_run_id, req) + if engine_config: + started = start_engine(engine_config) + if started: + _write_status(user_id, running_run_id, "RUNNING") + return {"status": "restarted", "run_id": running_run_id} + return {"status": "already_running", "run_id": running_run_id} + 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) + validate_frequency(frequency_payload, mode) + initial_cash = float(req.initial_cash) if req.initial_cash is not None else 1_000_000.0 + + try: + run_id = create_strategy_run( + user_id, + strategy=req.strategy_name, + mode=mode, + broker="paper", + meta={ + "sip_amount": req.sip_amount, + "sip_frequency": frequency_payload, + "initial_cash": initial_cash, + }, + ) + except errors.UniqueViolation: + return {"status": "already_running"} + + with engine_context(user_id, run_id): + init_paper_state(initial_cash, frequency_payload) + with db_connection() as conn: + with conn: + with conn.cursor() as cur: + 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 + """, + (user_id, run_id, initial_cash), + ) + PaperBroker(initial_cash=initial_cash) + config = { + "strategy": req.strategy_name, + "sip_amount": req.sip_amount, + "sip_frequency": frequency_payload, + "mode": mode, + "broker": "paper", + "active": True, + } + save_strategy_config(config, user_id, run_id) + start_new_run(user_id, run_id) + _write_status(user_id, run_id, "RUNNING") + if not engine_external: + def emit_event_cb(*, event: str, message: str, level: str = "INFO", category: str = "ENGINE", meta: dict | None = None): + emit_event( + user_id=user_id, + run_id=run_id, + event=event, + message=message, + level=level, + category=category, + meta=meta, + ) + + engine_config = dict(config) + engine_config["initial_cash"] = initial_cash + engine_config["run_id"] = run_id + engine_config["user_id"] = user_id + engine_config["emit_event"] = emit_event_cb + start_engine(engine_config) + + try: + user = get_user_by_id(user_id) + if user: + body = ( + "Your strategy has been started.\n\n" + f"Strategy: {req.strategy_name}\n" + f"Mode: {mode}\n" + f"Run ID: {run_id}\n" + ) + send_email(user["username"], "Strategy started", body) + except Exception: + pass + + return {"status": "started", "run_id": run_id} + + +def _build_engine_config(user_id: str, run_id: str, req=None): + cfg = _load_config(user_id, run_id) + sip_frequency = cfg.get("sip_frequency") + if not isinstance(sip_frequency, dict) and req is not None: + sip_frequency = req.sip_frequency.dict() if hasattr(req.sip_frequency, "dict") else dict(req.sip_frequency) + if not isinstance(sip_frequency, dict): + sip_frequency = {"value": cfg.get("frequency_days") or 1, "unit": cfg.get("unit") or "days"} + + sip_amount = cfg.get("sip_amount") + if sip_amount is None and req is not None: + sip_amount = req.sip_amount + + mode = (cfg.get("mode") or (req.mode if req is not None else "PAPER") or "PAPER").strip().upper() + broker = cfg.get("broker") or "paper" + strategy_name = cfg.get("strategy") or cfg.get("strategy_name") or (req.strategy_name if req is not None else None) + + with engine_context(user_id, run_id): + state = load_state(mode=mode) + initial_cash = float(state.get("initial_cash") or 1_000_000.0) + + def emit_event_cb(*, event: str, message: str, level: str = "INFO", category: str = "ENGINE", meta: dict | None = None): + emit_event( + user_id=user_id, + run_id=run_id, + event=event, + message=message, + level=level, + category=category, + meta=meta, + ) + + return { + "strategy": strategy_name or "Golden Nifty", + "sip_amount": sip_amount or 0, + "sip_frequency": sip_frequency, + "mode": mode, + "broker": broker, + "active": cfg.get("active", True), + "initial_cash": initial_cash, + "user_id": user_id, + "run_id": run_id, + "emit_event": emit_event_cb, + } + + +def resume_running_runs(): + engine_external = os.getenv("ENGINE_EXTERNAL", "").strip().lower() in {"1", "true", "yes"} + if engine_external: + return + with db_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT user_id, run_id + FROM strategy_run + WHERE status = 'RUNNING' + ORDER BY created_at DESC + """ + ) + runs = cur.fetchall() + for user_id, run_id in runs: + engine_config = _build_engine_config(user_id, run_id, None) + if not engine_config: + continue + started = start_engine(engine_config) + if started: + _write_status(user_id, run_id, "RUNNING") + +def stop_strategy(user_id: str): + run_id = get_active_run_id(user_id) + engine_external = os.getenv("ENGINE_EXTERNAL", "").strip().lower() in {"1", "true", "yes"} + if not engine_external: + stop_engine(user_id, run_id, timeout=15.0) + deactivate_strategy_config(user_id, run_id) + stop_run(user_id, run_id, reason="user_request") + _write_status(user_id, run_id, "STOPPED") + update_run_status(user_id, run_id, "STOPPED", meta={"reason": "user_request"}) + + try: + user = get_user_by_id(user_id) + if user: + body = "Your strategy has been stopped." + send_email(user["username"], "Strategy stopped", body) + except Exception: + pass + + return {"status": "stopped"} + +def get_strategy_status(user_id: str): + run_id = get_active_run_id(user_id) + with db_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT status, last_updated FROM engine_status WHERE user_id = %s AND run_id = %s", + (user_id, run_id), + ) + row = cur.fetchone() + if not row: + status = {"status": "IDLE", "last_updated": None} + else: + status = { + "status": row[0], + "last_updated": _format_local_ts(row[1]), + } + if status.get("status") == "RUNNING": + cfg = _load_config(user_id, run_id) + mode = (cfg.get("mode") or "LIVE").strip().upper() + with engine_context(user_id, run_id): + state = load_state(mode=mode) + last_execution_ts = state.get("last_run") or state.get("last_sip_ts") + sip_frequency = cfg.get("sip_frequency") + if not isinstance(sip_frequency, dict): + frequency = cfg.get("frequency") + unit = cfg.get("unit") + if isinstance(frequency, dict): + unit = frequency.get("unit", unit) + frequency = frequency.get("value") + if frequency is None and cfg.get("frequency_days") is not None: + frequency = cfg.get("frequency_days") + unit = unit or "days" + if frequency is not None and unit: + sip_frequency = {"value": frequency, "unit": unit} + next_eligible = compute_next_eligible(last_execution_ts, sip_frequency) + status["last_execution_ts"] = last_execution_ts + status["next_eligible_ts"] = next_eligible + if next_eligible: + try: + parsed_next = datetime.fromisoformat(next_eligible) + now_cmp = datetime.now(parsed_next.tzinfo) if parsed_next.tzinfo else datetime.now() + if parsed_next > now_cmp: + status["status"] = "WAITING" + except ValueError: + pass + return status + +def get_engine_status(user_id: str): + run_id = get_active_run_id(user_id) + status = { + "state": "STOPPED", + "run_id": run_id, + "user_id": user_id, + "last_heartbeat_ts": None, + } + with db_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT status, last_updated + FROM engine_status + WHERE user_id = %s AND run_id = %s + ORDER BY last_updated DESC + LIMIT 1 + """, + (user_id, run_id), + ) + row = cur.fetchone() + if row: + status["state"] = row[0] + last_updated = row[1] + if last_updated is not None: + status["last_heartbeat_ts"] = ( + last_updated.astimezone(timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ) + cfg = _load_config(user_id, run_id) + mode = (cfg.get("mode") or "LIVE").strip().upper() + with engine_context(user_id, run_id): + state = load_state(mode=mode) + last_execution_ts = state.get("last_run") or state.get("last_sip_ts") + sip_frequency = cfg.get("sip_frequency") + if isinstance(sip_frequency, dict): + sip_frequency = { + "value": sip_frequency.get("value"), + "unit": sip_frequency.get("unit"), + } + else: + frequency = cfg.get("frequency") + unit = cfg.get("unit") + if isinstance(frequency, dict): + unit = frequency.get("unit", unit) + frequency = frequency.get("value") + if frequency is None and cfg.get("frequency_days") is not None: + frequency = cfg.get("frequency_days") + unit = unit or "days" + if frequency is not None and unit: + sip_frequency = {"value": frequency, "unit": unit} + status["last_execution_ts"] = last_execution_ts + status["next_eligible_ts"] = compute_next_eligible(last_execution_ts, sip_frequency) + status["run_id"] = run_id + return status + + +def get_strategy_logs(user_id: str, since_seq: int): + run_id = get_active_run_id(user_id) + with db_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT seq, ts, level, category, event, message, run_id, meta + FROM strategy_log + WHERE user_id = %s AND run_id = %s AND seq > %s + ORDER BY seq + """, + (user_id, run_id, since_seq), + ) + rows = cur.fetchall() + events = [] + for row in rows: + ts = row[1] + if ts is not None: + ts_str = ts.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + else: + ts_str = None + events.append( + { + "seq": row[0], + "ts": ts_str, + "level": row[2], + "category": row[3], + "event": row[4], + "message": row[5], + "run_id": row[6], + "meta": row[7] if isinstance(row[7], dict) else {}, + } + ) + cur.execute( + "SELECT COALESCE(MAX(seq), 0) FROM strategy_log WHERE user_id = %s AND run_id = %s", + (user_id, run_id), + ) + latest_seq = cur.fetchone()[0] + return {"events": events, "latest_seq": latest_seq} + +def get_market_status(): + now = datetime.now() + return { + "status": "OPEN" if is_market_open(now) else "CLOSED", + "checked_at": now.isoformat(), + } diff --git a/app/services/support_ticket.py b/app/services/support_ticket.py new file mode 100644 index 0000000..fdbfa1c --- /dev/null +++ b/app/services/support_ticket.py @@ -0,0 +1,70 @@ +import os +from datetime import datetime, timezone +from uuid import uuid4 + +from app.services.db import db_connection +from app.services.email_service import send_email + + +def _now(): + return datetime.now(timezone.utc) + + +def create_ticket(name: str, email: str, subject: str, message: str) -> dict: + ticket_id = str(uuid4()) + now = _now() + with db_connection() as conn: + with conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO support_ticket + (id, name, email, subject, message, status, created_at, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """, + (ticket_id, name, email, subject, message, "NEW", now, now), + ) + email_sent = False + try: + email_body = ( + "Hi,\n\n" + "Your support ticket has been created.\n\n" + f"Ticket ID: {ticket_id}\n" + f"Subject: {subject}\n" + "Status: NEW\n\n" + "We will get back to you shortly.\n\n" + "Quantfortune Support" + ) + email_sent = send_email(email, "Quantfortune Support Ticket Created", email_body) + except Exception: + email_sent = False + return { + "ticket_id": ticket_id, + "status": "NEW", + "created_at": now.isoformat(), + "email_sent": email_sent, + } + + +def get_ticket_status(ticket_id: str, email: str) -> dict | None: + with db_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, email, status, created_at, updated_at + FROM support_ticket + WHERE id = %s + """, + (ticket_id,), + ) + row = cur.fetchone() + if not row: + return None + if row[1].lower() != email.lower(): + return None + return { + "ticket_id": row[0], + "status": row[2], + "created_at": row[3].isoformat() if row[3] else None, + "updated_at": row[4].isoformat() if row[4] else None, + } diff --git a/app/services/system_service.py b/app/services/system_service.py new file mode 100644 index 0000000..8681e12 --- /dev/null +++ b/app/services/system_service.py @@ -0,0 +1,378 @@ +import hashlib +import json +import os +from datetime import datetime, timezone + +from psycopg2.extras import Json + +from app.broker_store import get_user_broker, set_broker_auth_state +from app.services.db import db_connection +from app.services.run_lifecycle import RunLifecycleError, RunLifecycleManager +from app.services.strategy_service import compute_next_eligible, resume_running_runs +from app.services.zerodha_service import KiteTokenError, fetch_funds +from app.services.zerodha_storage import get_session + + +def _hash_value(value: str | None) -> str | None: + if value is None: + return None + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _parse_frequency(raw_value): + if raw_value is None: + return None + if isinstance(raw_value, dict): + return raw_value + if isinstance(raw_value, str): + text = raw_value.strip() + if not text: + return None + try: + return json.loads(text) + except Exception: + return None + return None + + +def _resolve_sip_frequency(row: dict): + value = row.get("sip_frequency_value") + unit = row.get("sip_frequency_unit") + if value is not None and unit: + return {"value": int(value), "unit": unit} + + frequency = _parse_frequency(row.get("frequency")) + if isinstance(frequency, dict): + freq_value = frequency.get("value") + freq_unit = frequency.get("unit") + if freq_value is not None and freq_unit: + return {"value": int(freq_value), "unit": freq_unit} + + fallback_value = row.get("frequency_days") + fallback_unit = row.get("unit") or "days" + if fallback_value is not None: + return {"value": int(fallback_value), "unit": fallback_unit} + + return None + + +def _parse_ts(value: str | None): + if not value: + return None + try: + return datetime.fromisoformat(value) + except ValueError: + return None + + +def _validate_broker_session(user_id: str): + session = get_session(user_id) + if not session: + return False + if os.getenv("BROKER_VALIDATION_MODE", "").strip().lower() == "skip": + return True + try: + fetch_funds(session["api_key"], session["access_token"]) + except KiteTokenError: + set_broker_auth_state(user_id, "EXPIRED") + return False + return True + + +def arm_system(user_id: str, client_ip: str | None = None): + if not _validate_broker_session(user_id): + return { + "ok": False, + "code": "BROKER_AUTH_REQUIRED", + "redirect_url": "/api/broker/login", + } + + now = datetime.now(timezone.utc) + armed_runs = [] + failed_runs = [] + next_runs = [] + + with db_connection() as conn: + with conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT sr.run_id, sr.status, sr.strategy, sr.mode, sr.broker, + sc.active, sc.sip_frequency_value, sc.sip_frequency_unit, + sc.frequency, sc.frequency_days, sc.unit, sc.next_run + FROM strategy_run sr + LEFT JOIN strategy_config sc + ON sc.user_id = sr.user_id AND sc.run_id = sr.run_id + WHERE sr.user_id = %s AND COALESCE(sc.active, false) = true + ORDER BY sr.created_at DESC + """, + (user_id,), + ) + rows = cur.fetchall() + + cur.execute("SELECT username FROM app_user WHERE id = %s", (user_id,)) + user_row = cur.fetchone() + username = user_row[0] if user_row else None + + for row in rows: + run = { + "run_id": row[0], + "status": row[1], + "strategy": row[2], + "mode": row[3], + "broker": row[4], + "active": row[5], + "sip_frequency_value": row[6], + "sip_frequency_unit": row[7], + "frequency": row[8], + "frequency_days": row[9], + "unit": row[10], + "next_run": row[11], + } + status = (run["status"] or "").strip().upper() + if status == "RUNNING": + armed_runs.append( + { + "run_id": run["run_id"], + "status": status, + "already_running": True, + } + ) + if run.get("next_run"): + next_runs.append(run["next_run"]) + continue + if status == "ERROR": + failed_runs.append( + { + "run_id": run["run_id"], + "status": status, + "reason": "ERROR", + } + ) + continue + try: + RunLifecycleManager.assert_can_arm(status) + except RunLifecycleError as exc: + failed_runs.append( + { + "run_id": run["run_id"], + "status": status, + "reason": str(exc), + } + ) + continue + + sip_frequency = _resolve_sip_frequency(run) + last_run = now.isoformat() + next_run = compute_next_eligible(last_run, sip_frequency) + next_run_dt = _parse_ts(next_run) + + cur.execute( + """ + UPDATE strategy_run + SET status = 'RUNNING', + started_at = COALESCE(started_at, %s), + stopped_at = NULL, + meta = COALESCE(meta, '{}'::jsonb) || %s + WHERE user_id = %s AND run_id = %s + """, + ( + now, + Json({"armed_at": now.isoformat()}), + user_id, + run["run_id"], + ), + ) + + 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["run_id"], "RUNNING", now), + ) + + if (run.get("mode") or "").strip().upper() == "PAPER": + cur.execute( + """ + INSERT INTO engine_state_paper (user_id, run_id, last_run) + VALUES (%s, %s, %s) + ON CONFLICT (user_id, run_id) DO UPDATE + SET last_run = EXCLUDED.last_run + """, + (user_id, run["run_id"], now), + ) + else: + cur.execute( + """ + INSERT INTO engine_state (user_id, run_id, last_run) + VALUES (%s, %s, %s) + ON CONFLICT (user_id, run_id) DO UPDATE + SET last_run = EXCLUDED.last_run + """, + (user_id, run["run_id"], now), + ) + + cur.execute( + """ + UPDATE strategy_config + SET next_run = %s + WHERE user_id = %s AND run_id = %s + """, + (next_run_dt, user_id, run["run_id"]), + ) + + logical_time = now.replace(microsecond=0) + cur.execute( + """ + INSERT INTO engine_event (user_id, run_id, ts, event, message, meta) + VALUES (%s, %s, %s, %s, %s, %s) + """, + ( + user_id, + run["run_id"], + now, + "SYSTEM_ARMED", + "System armed", + Json({"next_run": next_run}), + ), + ) + cur.execute( + """ + INSERT INTO engine_event (user_id, run_id, ts, event, message, meta) + VALUES (%s, %s, %s, %s, %s, %s) + """, + ( + user_id, + run["run_id"], + now, + "RUN_REARMED", + "Run re-armed", + Json({"next_run": next_run}), + ), + ) + cur.execute( + """ + INSERT INTO event_ledger ( + user_id, run_id, timestamp, logical_time, event + ) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (user_id, run_id, event, logical_time) DO NOTHING + """, + ( + user_id, + run["run_id"], + now, + logical_time, + "SYSTEM_ARMED", + ), + ) + cur.execute( + """ + INSERT INTO event_ledger ( + user_id, run_id, timestamp, logical_time, event + ) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (user_id, run_id, event, logical_time) DO NOTHING + """, + ( + user_id, + run["run_id"], + now, + logical_time, + "RUN_REARMED", + ), + ) + + armed_runs.append( + { + "run_id": run["run_id"], + "status": "RUNNING", + "next_run": next_run, + } + ) + if next_run_dt: + next_runs.append(next_run_dt) + + audit_meta = { + "run_count": len(armed_runs), + "ip": client_ip, + } + cur.execute( + """ + INSERT INTO admin_audit_log + (actor_user_hash, target_user_hash, target_username_hash, action, meta) + VALUES (%s, %s, %s, %s, %s) + """, + ( + _hash_value(user_id), + _hash_value(user_id), + _hash_value(username), + "SYSTEM_ARM", + Json(audit_meta), + ), + ) + + try: + resume_running_runs() + except Exception: + pass + + broker_state = get_user_broker(user_id) or {} + next_execution = min(next_runs).isoformat() if next_runs else None + return { + "ok": True, + "armed_runs": armed_runs, + "failed_runs": failed_runs, + "next_execution": next_execution, + "broker_state": { + "connected": bool(broker_state.get("connected")), + "auth_state": broker_state.get("auth_state"), + "broker": broker_state.get("broker"), + "user_name": broker_state.get("user_name"), + }, + } + + +def system_status(user_id: str): + broker_state = get_user_broker(user_id) or {} + with db_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT sr.run_id, sr.status, sr.strategy, sr.mode, sr.broker, + sc.next_run, sc.active + FROM strategy_run sr + LEFT JOIN strategy_config sc + ON sc.user_id = sr.user_id AND sc.run_id = sr.run_id + WHERE sr.user_id = %s + ORDER BY sr.created_at DESC + """, + (user_id,), + ) + rows = cur.fetchall() + runs = [ + { + "run_id": row[0], + "status": row[1], + "strategy": row[2], + "mode": row[3], + "broker": row[4], + "next_run": row[5].isoformat() if row[5] else None, + "active": bool(row[6]) if row[6] is not None else False, + "lifecycle": row[1], + } + for row in rows + ] + return { + "runs": runs, + "broker_state": { + "connected": bool(broker_state.get("connected")), + "auth_state": broker_state.get("auth_state"), + "broker": broker_state.get("broker"), + "user_name": broker_state.get("user_name"), + }, + } diff --git a/app/services/tenant.py b/app/services/tenant.py new file mode 100644 index 0000000..5270cf0 --- /dev/null +++ b/app/services/tenant.py @@ -0,0 +1,19 @@ +from fastapi import HTTPException, Request + +from app.services.auth_service import get_user_for_session +from app.services.run_service import get_default_user_id + +SESSION_COOKIE_NAME = "session_id" + + +def get_request_user_id(request: Request) -> str: + session_id = request.cookies.get(SESSION_COOKIE_NAME) + if session_id: + user = get_user_for_session(session_id) + if user: + return user["id"] + + default_user_id = get_default_user_id() + if default_user_id: + return default_user_id + raise HTTPException(status_code=401, detail="Not authenticated") diff --git a/app/services/zerodha_service.py b/app/services/zerodha_service.py new file mode 100644 index 0000000..a1d8214 --- /dev/null +++ b/app/services/zerodha_service.py @@ -0,0 +1,89 @@ +import hashlib +import json +import os +import urllib.error +import urllib.parse +import urllib.request + + +KITE_API_BASE = os.getenv("KITE_API_BASE", "https://api.kite.trade") +KITE_LOGIN_URL = os.getenv("KITE_LOGIN_URL", "https://kite.trade/connect/login") +KITE_VERSION = "3" + + +class KiteApiError(Exception): + def __init__(self, status_code: int, error_type: str, message: str): + super().__init__(f"Kite API error {status_code}: {error_type} - {message}") + self.status_code = status_code + self.error_type = error_type + self.message = message + + +class KiteTokenError(KiteApiError): + pass + + +def build_login_url(api_key: str, redirect_url: str | None = None) -> str: + params = {"api_key": api_key, "v": KITE_VERSION} + redirect_url = (redirect_url or os.getenv("ZERODHA_REDIRECT_URL") or "").strip() + if redirect_url: + params["redirect_url"] = redirect_url + query = urllib.parse.urlencode(params) + return f"{KITE_LOGIN_URL}?{query}" + + +def _request(method: str, url: str, data: dict | None = None, headers: dict | None = None): + payload = None + if data is not None: + payload = urllib.parse.urlencode(data).encode("utf-8") + req = urllib.request.Request(url, data=payload, headers=headers or {}, method=method) + try: + with urllib.request.urlopen(req, timeout=20) as resp: + body = resp.read().decode("utf-8") + except urllib.error.HTTPError as err: + error_body = err.read().decode("utf-8") if err.fp else "" + try: + payload = json.loads(error_body) if error_body else {} + except json.JSONDecodeError: + payload = {} + error_type = payload.get("error_type") or payload.get("status") or "unknown_error" + message = payload.get("message") or error_body or err.reason + exc_cls = KiteTokenError if error_type == "TokenException" else KiteApiError + raise exc_cls(err.code, error_type, message) from err + return json.loads(body) + + +def _auth_headers(api_key: str, access_token: str) -> dict: + return { + "X-Kite-Version": KITE_VERSION, + "Authorization": f"token {api_key}:{access_token}", + } + + +def exchange_request_token(api_key: str, api_secret: str, request_token: str) -> dict: + checksum = hashlib.sha256( + f"{api_key}{request_token}{api_secret}".encode("utf-8") + ).hexdigest() + url = f"{KITE_API_BASE}/session/token" + response = _request( + "POST", + url, + data={ + "api_key": api_key, + "request_token": request_token, + "checksum": checksum, + }, + ) + return response.get("data", {}) + + +def fetch_holdings(api_key: str, access_token: str) -> list: + url = f"{KITE_API_BASE}/portfolio/holdings" + response = _request("GET", url, headers=_auth_headers(api_key, access_token)) + return response.get("data", []) + + +def fetch_funds(api_key: str, access_token: str) -> dict: + url = f"{KITE_API_BASE}/user/margins" + response = _request("GET", url, headers=_auth_headers(api_key, access_token)) + return response.get("data", {}) diff --git a/app/services/zerodha_storage.py b/app/services/zerodha_storage.py new file mode 100644 index 0000000..13a291e --- /dev/null +++ b/app/services/zerodha_storage.py @@ -0,0 +1,125 @@ +from datetime import datetime, timezone + +from app.services.crypto_service import decrypt_value, encrypt_value +from app.services.db import db_transaction + + +def _row_to_session(row): + access_token = decrypt_value(row[1]) if row[1] else None + request_token = decrypt_value(row[2]) if row[2] else None + return { + "api_key": row[0], + "access_token": access_token, + "request_token": request_token, + "user_name": row[3], + "broker_user_id": row[4], + "linked_at": row[5], + } + + +def get_session(user_id: str): + with db_transaction() as cur: + cur.execute( + """ + SELECT api_key, access_token, request_token, user_name, broker_user_id, linked_at + FROM zerodha_session + WHERE user_id = %s + ORDER BY linked_at DESC NULLS LAST, id DESC + LIMIT 1 + """, + (user_id,), + ) + row = cur.fetchone() + if row: + return _row_to_session(row) + + with db_transaction() as cur: + cur.execute( + """ + SELECT broker, connected, access_token, api_key, user_name, broker_user_id, connected_at + FROM user_broker + WHERE user_id = %s + """, + (user_id,), + ) + row = cur.fetchone() + if not row: + return None + broker, connected, access_token, api_key, user_name, broker_user_id, connected_at = row + if not connected or not access_token or not api_key: + return None + if (broker or "").strip().upper() != "ZERODHA": + return None + return { + "api_key": api_key, + "access_token": decrypt_value(access_token), + "request_token": None, + "user_name": user_name, + "broker_user_id": broker_user_id, + "linked_at": connected_at, + } + + +def set_session(user_id: str, data: dict): + access_token = data.get("access_token") + request_token = data.get("request_token") + linked_at = datetime.now(timezone.utc) + with db_transaction() as cur: + cur.execute( + """ + INSERT INTO zerodha_session ( + user_id, linked_at, api_key, access_token, request_token, user_name, broker_user_id + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING linked_at + """, + ( + user_id, + linked_at, + data.get("api_key"), + encrypt_value(access_token) if access_token else None, + encrypt_value(request_token) if request_token else None, + data.get("user_name"), + data.get("broker_user_id"), + ), + ) + linked_at = cur.fetchone()[0] + return { + **data, + "user_id": user_id, + "linked_at": linked_at, + "access_token": access_token, + "request_token": request_token, + } + + +def store_request_token(user_id: str, request_token: str): + with db_transaction() as cur: + cur.execute( + """ + INSERT INTO zerodha_request_token (user_id, request_token) + VALUES (%s, %s) + ON CONFLICT (user_id) + DO UPDATE SET request_token = EXCLUDED.request_token + """, + (user_id, encrypt_value(request_token)), + ) + + +def consume_request_token(user_id: str): + with db_transaction() as cur: + cur.execute( + "SELECT request_token FROM zerodha_request_token WHERE user_id = %s", + (user_id,), + ) + row = cur.fetchone() + if not row: + return None + cur.execute("DELETE FROM zerodha_request_token WHERE user_id = %s", (user_id,)) + return decrypt_value(row[0]) + + +def clear_session(user_id: str): + with db_transaction() as cur: + cur.execute("DELETE FROM zerodha_session WHERE user_id = %s", (user_id,)) + cur.execute("DELETE FROM zerodha_request_token WHERE user_id = %s", (user_id,)) diff --git a/market.py b/market.py new file mode 100644 index 0000000..c4b6df9 --- /dev/null +++ b/market.py @@ -0,0 +1,91 @@ +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict +import sys +import time + +from fastapi import APIRouter + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.append(str(PROJECT_ROOT)) + +from indian_paper_trading_strategy.engine.data import fetch_live_price, get_price_snapshot + +NIFTY = "NIFTYBEES.NS" +GOLD = "GOLDBEES.NS" + +router = APIRouter(prefix="/api/market", tags=["market"]) + +_LTP_CACHE: Dict[str, Any] = { + "ts_epoch": 0.0, + "data": None, +} + +CACHE_TTL_SECONDS = 5 +STALE_SECONDS = 60 + + +@router.get("/ltp") +def get_ltp(allow_cache: bool = False): + now_epoch = time.time() + cached = _LTP_CACHE["data"] + if cached is not None and (now_epoch - _LTP_CACHE["ts_epoch"]) < CACHE_TTL_SECONDS: + return cached + + nifty_ltp = None + gold_ltp = None + try: + nifty_ltp = fetch_live_price(NIFTY) + except Exception: + nifty_ltp = None + try: + gold_ltp = fetch_live_price(GOLD) + except Exception: + gold_ltp = None + + nifty_meta = get_price_snapshot(NIFTY) or {} + gold_meta = get_price_snapshot(GOLD) or {} + now = datetime.now(timezone.utc) + + def _is_stale(meta: Dict[str, Any], ltp: float | None) -> bool: + if ltp is None: + return True + source = meta.get("source") + ts = meta.get("ts") + if source != "live": + return True + if isinstance(ts, datetime): + return (now - ts).total_seconds() > STALE_SECONDS + return False + + nifty_source = str(nifty_meta.get("source") or "").lower() + gold_source = str(gold_meta.get("source") or "").lower() + stale_map = { + NIFTY: _is_stale(nifty_meta, nifty_ltp), + GOLD: _is_stale(gold_meta, gold_ltp), + } + stale_any = stale_map[NIFTY] or stale_map[GOLD] + if allow_cache and stale_any: + cache_sources = {"cache", "cached", "history"} + if nifty_source in cache_sources and gold_source in cache_sources: + stale_map = {NIFTY: False, GOLD: False} + stale_any = False + + payload = { + "ts": now.isoformat(), + "ltp": { + NIFTY: float(nifty_ltp) if nifty_ltp is not None else None, + GOLD: float(gold_ltp) if gold_ltp is not None else None, + }, + "source": { + NIFTY: nifty_meta.get("source"), + GOLD: gold_meta.get("source"), + }, + "stale": stale_map, + "stale_any": stale_any, + } + + _LTP_CACHE["ts_epoch"] = now_epoch + _LTP_CACHE["data"] = payload + return payload diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..db75070 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,87 @@ +import os +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context +from app.services.db import Base, get_database_url +import app.db_models # noqa: F401 + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +db_url = get_database_url() +if "%" in db_url: + db_url = db_url.replace("%", "%%") +config.set_main_option("sqlalchemy.url", db_url) +schema_name = os.getenv("DB_SCHEMA") or os.getenv("PGSCHEMA") or "quant_app" + +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + version_table_schema=schema_name, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + configuration = config.get_section(config.config_ini_section, {}) + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + version_table_schema=schema_name, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/52abc790351d_initial_schema.py b/migrations/versions/52abc790351d_initial_schema.py new file mode 100644 index 0000000..d0ee30f --- /dev/null +++ b/migrations/versions/52abc790351d_initial_schema.py @@ -0,0 +1,674 @@ +"""initial_schema + +Revision ID: 52abc790351d +Revises: +Create Date: 2026-01-18 08:34:50.268181 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '52abc790351d' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('admin_audit_log', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('ts', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('actor_user_hash', sa.Text(), nullable=False), + sa.Column('target_user_hash', sa.Text(), nullable=False), + sa.Column('target_username_hash', sa.Text(), nullable=True), + sa.Column('action', sa.Text(), nullable=False), + sa.Column('meta', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('admin_role_audit', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('actor_user_id', sa.String(), nullable=False), + sa.Column('target_user_id', sa.String(), nullable=False), + sa.Column('old_role', sa.String(), nullable=False), + sa.Column('new_role', sa.String(), nullable=False), + sa.Column('changed_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('app_user', + sa.Column('id', sa.String(), nullable=False), + sa.Column('username', sa.String(), nullable=False), + sa.Column('password_hash', sa.String(), nullable=False), + sa.Column('is_admin', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('is_super_admin', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('role', sa.String(), server_default=sa.text("'USER'"), nullable=False), + sa.CheckConstraint("role IN ('USER','ADMIN','SUPER_ADMIN')", name='chk_app_user_role'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + op.create_index('idx_app_user_is_admin', 'app_user', ['is_admin'], unique=False) + op.create_index('idx_app_user_is_super_admin', 'app_user', ['is_super_admin'], unique=False) + op.create_index('idx_app_user_role', 'app_user', ['role'], unique=False) + op.create_table('market_close', + sa.Column('symbol', sa.String(), nullable=False), + sa.Column('date', sa.Date(), nullable=False), + sa.Column('close', sa.Numeric(), nullable=False), + sa.PrimaryKeyConstraint('symbol', 'date') + ) + op.create_index('idx_market_close_date', 'market_close', ['date'], unique=False) + op.create_index('idx_market_close_symbol', 'market_close', ['symbol'], unique=False) + op.create_table('app_session', + sa.Column('id', sa.String(), nullable=False), + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('last_seen_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['app_user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_app_session_expires_at', 'app_session', ['expires_at'], unique=False) + op.create_index('idx_app_session_user_id', 'app_session', ['user_id'], unique=False) + op.create_table('strategy_run', + sa.Column('run_id', sa.String(), nullable=False), + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('started_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('stopped_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('status', sa.String(), nullable=False), + sa.Column('strategy', sa.String(), nullable=True), + sa.Column('mode', sa.String(), nullable=True), + sa.Column('broker', sa.String(), nullable=True), + sa.Column('meta', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.CheckConstraint("status IN ('RUNNING','STOPPED','ERROR')", name='chk_strategy_run_status'), + sa.ForeignKeyConstraint(['user_id'], ['app_user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('run_id'), + sa.UniqueConstraint('user_id', 'run_id', name='uq_strategy_run_user_run') + ) + op.create_index('idx_strategy_run_user_created', 'strategy_run', ['user_id', 'created_at'], unique=False) + op.create_index('idx_strategy_run_user_status', 'strategy_run', ['user_id', 'status'], unique=False) + op.create_index('uq_one_running_run_per_user', 'strategy_run', ['user_id'], unique=True, postgresql_where=sa.text("status = 'RUNNING'")) + op.create_table('user_broker', + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('broker', sa.String(), nullable=True), + sa.Column('connected', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('access_token', sa.Text(), nullable=True), + sa.Column('connected_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('api_key', sa.Text(), nullable=True), + sa.Column('user_name', sa.Text(), nullable=True), + sa.Column('broker_user_id', sa.Text(), nullable=True), + sa.Column('pending_broker', sa.Text(), nullable=True), + sa.Column('pending_api_key', sa.Text(), nullable=True), + sa.Column('pending_api_secret', sa.Text(), nullable=True), + sa.Column('pending_started_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['app_user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('user_id') + ) + op.create_index('idx_user_broker_broker', 'user_broker', ['broker'], unique=False) + op.create_index('idx_user_broker_connected', 'user_broker', ['connected'], unique=False) + op.create_table('zerodha_request_token', + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('request_token', sa.Text(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['app_user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('user_id') + ) + op.create_table('zerodha_session', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('linked_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('api_key', sa.Text(), nullable=True), + sa.Column('access_token', sa.Text(), nullable=True), + sa.Column('request_token', sa.Text(), nullable=True), + sa.Column('user_name', sa.Text(), nullable=True), + sa.Column('broker_user_id', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['app_user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_zerodha_session_linked_at', 'zerodha_session', ['linked_at'], unique=False) + op.create_index('idx_zerodha_session_user_id', 'zerodha_session', ['user_id'], unique=False) + op.create_table('engine_event', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('ts', sa.DateTime(timezone=True), nullable=False), + sa.Column('event', sa.String(), nullable=True), + sa.Column('data', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('message', sa.Text(), nullable=True), + sa.Column('meta', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('run_id', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['run_id'], ['strategy_run.run_id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['app_user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_engine_event_ts', 'engine_event', ['ts'], unique=False) + op.create_index('idx_engine_event_user_run_ts', 'engine_event', ['user_id', 'run_id', 'ts'], unique=False) + op.create_table('engine_state', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('run_id', sa.String(), nullable=False), + sa.Column('total_invested', sa.Numeric(), nullable=True), + sa.Column('nifty_units', sa.Numeric(), nullable=True), + sa.Column('gold_units', sa.Numeric(), nullable=True), + sa.Column('last_sip_ts', sa.DateTime(timezone=True), nullable=True), + sa.Column('last_run', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id', 'run_id'], ['strategy_run.user_id', 'strategy_run.run_id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['app_user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'run_id', name='uq_engine_state_user_run') + ) + op.create_table('engine_state_paper', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('run_id', sa.String(), nullable=False), + sa.Column('initial_cash', sa.Numeric(), nullable=True), + sa.Column('cash', sa.Numeric(), nullable=True), + sa.Column('total_invested', sa.Numeric(), nullable=True), + sa.Column('nifty_units', sa.Numeric(), nullable=True), + sa.Column('gold_units', sa.Numeric(), nullable=True), + sa.Column('last_sip_ts', sa.DateTime(timezone=True), nullable=True), + sa.Column('last_run', sa.DateTime(timezone=True), nullable=True), + sa.Column('sip_frequency_value', sa.Integer(), nullable=True), + sa.Column('sip_frequency_unit', sa.String(), nullable=True), + sa.CheckConstraint('cash >= 0', name='chk_engine_state_paper_cash_non_negative'), + sa.ForeignKeyConstraint(['user_id', 'run_id'], ['strategy_run.user_id', 'strategy_run.run_id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['app_user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'run_id', name='uq_engine_state_paper_user_run') + ) + op.create_table('engine_status', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('run_id', sa.String(), nullable=False), + sa.Column('status', sa.String(), nullable=False), + sa.Column('last_updated', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_id', 'run_id'], ['strategy_run.user_id', 'strategy_run.run_id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['app_user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'run_id', name='uq_engine_status_user_run') + ) + op.create_index('idx_engine_status_user_run', 'engine_status', ['user_id', 'run_id'], unique=False) + op.create_table('event_ledger', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('run_id', sa.String(), nullable=False), + sa.Column('timestamp', sa.DateTime(timezone=True), nullable=False), + sa.Column('logical_time', sa.DateTime(timezone=True), nullable=False), + sa.Column('event', sa.String(), nullable=False), + sa.Column('nifty_units', sa.Numeric(), nullable=True), + sa.Column('gold_units', sa.Numeric(), nullable=True), + sa.Column('nifty_price', sa.Numeric(), nullable=True), + sa.Column('gold_price', sa.Numeric(), nullable=True), + sa.Column('amount', sa.Numeric(), nullable=True), + sa.ForeignKeyConstraint(['user_id', 'run_id'], ['strategy_run.user_id', 'strategy_run.run_id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['app_user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'run_id', 'event', 'logical_time', name='uq_event_ledger_event_time') + ) + op.create_index('idx_event_ledger_ts', 'event_ledger', ['timestamp'], unique=False) + op.create_index('idx_event_ledger_user_run_ts', 'event_ledger', ['user_id', 'run_id', 'timestamp'], unique=False) + op.create_table('mtm_ledger', + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('run_id', sa.String(), nullable=False), + sa.Column('timestamp', sa.DateTime(timezone=True), nullable=False), + sa.Column('logical_time', sa.DateTime(timezone=True), nullable=False), + sa.Column('nifty_units', sa.Numeric(), nullable=True), + sa.Column('gold_units', sa.Numeric(), nullable=True), + sa.Column('nifty_price', sa.Numeric(), nullable=True), + sa.Column('gold_price', sa.Numeric(), nullable=True), + sa.Column('nifty_value', sa.Numeric(), nullable=True), + sa.Column('gold_value', sa.Numeric(), nullable=True), + sa.Column('portfolio_value', sa.Numeric(), nullable=True), + sa.Column('total_invested', sa.Numeric(), nullable=True), + sa.Column('pnl', sa.Numeric(), nullable=True), + sa.ForeignKeyConstraint(['user_id', 'run_id'], ['strategy_run.user_id', 'strategy_run.run_id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('user_id', 'run_id', 'logical_time') + ) + op.create_index('idx_mtm_ledger_ts', 'mtm_ledger', ['timestamp'], unique=False) + op.create_index('idx_mtm_ledger_user_run_ts', 'mtm_ledger', ['user_id', 'run_id', 'timestamp'], unique=False) + op.create_table('paper_broker_account', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('run_id', sa.String(), nullable=False), + sa.Column('cash', sa.Numeric(), nullable=False), + sa.CheckConstraint('cash >= 0', name='chk_paper_broker_cash_non_negative'), + sa.ForeignKeyConstraint(['user_id', 'run_id'], ['strategy_run.user_id', 'strategy_run.run_id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['app_user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'run_id', name='uq_paper_broker_account_user_run') + ) + op.create_table('paper_equity_curve', + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('run_id', sa.String(), nullable=False), + sa.Column('timestamp', sa.DateTime(timezone=True), nullable=False), + sa.Column('logical_time', sa.DateTime(timezone=True), nullable=False), + sa.Column('equity', sa.Numeric(), nullable=False), + sa.Column('pnl', sa.Numeric(), nullable=True), + sa.ForeignKeyConstraint(['user_id', 'run_id'], ['strategy_run.user_id', 'strategy_run.run_id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('user_id', 'run_id', 'logical_time') + ) + op.create_index('idx_paper_equity_curve_ts', 'paper_equity_curve', ['timestamp'], unique=False) + op.create_index('idx_paper_equity_curve_user_run_ts', 'paper_equity_curve', ['user_id', 'run_id', 'timestamp'], unique=False) + op.create_table('paper_order', + sa.Column('id', sa.String(), nullable=False), + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('run_id', sa.String(), nullable=False), + sa.Column('symbol', sa.String(), nullable=False), + sa.Column('side', sa.String(), nullable=False), + sa.Column('qty', sa.Numeric(), nullable=False), + sa.Column('price', sa.Numeric(), nullable=True), + sa.Column('status', sa.String(), nullable=False), + sa.Column('timestamp', sa.DateTime(timezone=True), nullable=False), + sa.Column('logical_time', sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint('price >= 0', name='chk_paper_order_price_non_negative'), + sa.CheckConstraint('qty > 0', name='chk_paper_order_qty_positive'), + sa.ForeignKeyConstraint(['user_id', 'run_id'], ['strategy_run.user_id', 'strategy_run.run_id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['app_user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'run_id', 'id', name='uq_paper_order_scope_id'), + sa.UniqueConstraint('user_id', 'run_id', 'logical_time', 'symbol', 'side', name='uq_paper_order_logical_key') + ) + op.create_index('idx_paper_order_ts', 'paper_order', ['timestamp'], unique=False) + op.create_index('idx_paper_order_user_run_ts', 'paper_order', ['user_id', 'run_id', 'timestamp'], unique=False) + op.create_table('paper_position', + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('run_id', sa.String(), nullable=False), + sa.Column('symbol', sa.String(), nullable=False), + sa.Column('qty', sa.Numeric(), nullable=False), + sa.Column('avg_price', sa.Numeric(), nullable=True), + sa.Column('last_price', sa.Numeric(), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.CheckConstraint('qty > 0', name='chk_paper_position_qty_positive'), + sa.ForeignKeyConstraint(['user_id', 'run_id'], ['strategy_run.user_id', 'strategy_run.run_id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('user_id', 'run_id', 'symbol'), + sa.UniqueConstraint('user_id', 'run_id', 'symbol', name='uq_paper_position_scope') + ) + op.create_index('idx_paper_position_user_run', 'paper_position', ['user_id', 'run_id'], unique=False) + op.create_table('strategy_config', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('strategy', sa.String(), nullable=True), + sa.Column('sip_amount', sa.Numeric(), nullable=True), + sa.Column('sip_frequency_value', sa.Integer(), nullable=True), + sa.Column('sip_frequency_unit', sa.String(), nullable=True), + sa.Column('mode', sa.String(), nullable=True), + sa.Column('broker', sa.String(), nullable=True), + sa.Column('active', sa.Boolean(), nullable=True), + sa.Column('frequency', sa.Text(), nullable=True), + sa.Column('frequency_days', sa.Integer(), nullable=True), + sa.Column('unit', sa.String(), nullable=True), + sa.Column('next_run', sa.DateTime(timezone=True), nullable=True), + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('run_id', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['run_id'], ['strategy_run.run_id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['app_user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'run_id', name='uq_strategy_config_user_run') + ) + op.create_table('strategy_log', + sa.Column('seq', sa.BigInteger(), nullable=False), + sa.Column('ts', sa.DateTime(timezone=True), nullable=False), + sa.Column('level', sa.String(), nullable=True), + sa.Column('category', sa.String(), nullable=True), + sa.Column('event', sa.String(), nullable=True), + sa.Column('message', sa.Text(), nullable=True), + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('run_id', sa.String(), nullable=False), + sa.Column('meta', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.ForeignKeyConstraint(['run_id'], ['strategy_run.run_id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['app_user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('seq') + ) + op.create_index('idx_strategy_log_event', 'strategy_log', ['event'], unique=False) + op.create_index('idx_strategy_log_ts', 'strategy_log', ['ts'], unique=False) + op.create_index('idx_strategy_log_user_run_ts', 'strategy_log', ['user_id', 'run_id', 'ts'], unique=False) + op.create_table('paper_trade', + sa.Column('id', sa.String(), nullable=False), + sa.Column('order_id', sa.String(), nullable=True), + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('run_id', sa.String(), nullable=False), + sa.Column('symbol', sa.String(), nullable=False), + sa.Column('side', sa.String(), nullable=False), + sa.Column('qty', sa.Numeric(), nullable=False), + sa.Column('price', sa.Numeric(), nullable=False), + sa.Column('timestamp', sa.DateTime(timezone=True), nullable=False), + sa.Column('logical_time', sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint('price >= 0', name='chk_paper_trade_price_non_negative'), + sa.CheckConstraint('qty > 0', name='chk_paper_trade_qty_positive'), + sa.ForeignKeyConstraint(['user_id', 'run_id', 'order_id'], ['paper_order.user_id', 'paper_order.run_id', 'paper_order.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id', 'run_id'], ['strategy_run.user_id', 'strategy_run.run_id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['app_user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'run_id', 'id', name='uq_paper_trade_scope_id'), + sa.UniqueConstraint('user_id', 'run_id', 'logical_time', 'symbol', 'side', name='uq_paper_trade_logical_key') + ) + op.create_index('idx_paper_trade_ts', 'paper_trade', ['timestamp'], unique=False) + op.create_index('idx_paper_trade_user_run_ts', 'paper_trade', ['user_id', 'run_id', 'timestamp'], unique=False) + # admin views and protections + op.execute( + """ + CREATE OR REPLACE FUNCTION prevent_super_admin_delete() + RETURNS trigger AS $$ + BEGIN + IF OLD.role = 'SUPER_ADMIN' OR OLD.is_super_admin THEN + RAISE EXCEPTION 'cannot delete super admin user'; + END IF; + RETURN OLD; + END; + $$ LANGUAGE plpgsql; + """ + ) + op.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_prevent_super_admin_delete') THEN + CREATE TRIGGER trg_prevent_super_admin_delete + BEFORE DELETE ON app_user + FOR EACH ROW + EXECUTE FUNCTION prevent_super_admin_delete(); + END IF; + END $$; + """ + ) + op.create_index('idx_event_ledger_user_run_logical', 'event_ledger', ['user_id', 'run_id', 'logical_time'], unique=False) + op.execute( + """ + CREATE OR REPLACE VIEW admin_user_metrics AS + WITH session_stats AS ( + SELECT + user_id, + MIN(created_at) AS first_session_at, + MAX(COALESCE(last_seen_at, created_at)) AS last_login_at + FROM app_session + GROUP BY user_id + ), + run_stats AS ( + SELECT + user_id, + COUNT(*) AS runs_count, + MAX(CASE WHEN status = 'RUNNING' THEN run_id END) AS active_run_id, + MAX(CASE WHEN status = 'RUNNING' THEN status END) AS active_run_status, + MIN(created_at) AS first_run_at + FROM strategy_run + GROUP BY user_id + ), + broker_stats AS ( + SELECT user_id, BOOL_OR(connected) AS broker_connected + FROM user_broker + GROUP BY user_id + ) + SELECT + u.id AS user_id, + u.username, + u.role, + (u.role IN ('ADMIN','SUPER_ADMIN')) AS is_admin, + COALESCE(session_stats.first_session_at, run_stats.first_run_at) AS created_at, + session_stats.last_login_at, + COALESCE(run_stats.runs_count, 0) AS runs_count, + run_stats.active_run_id, + run_stats.active_run_status, + COALESCE(broker_stats.broker_connected, FALSE) AS broker_connected + FROM app_user u + LEFT JOIN session_stats ON session_stats.user_id = u.id + LEFT JOIN run_stats ON run_stats.user_id = u.id + LEFT JOIN broker_stats ON broker_stats.user_id = u.id; + """ + ) + op.execute( + """ + CREATE OR REPLACE VIEW admin_run_metrics AS + WITH order_stats AS ( + SELECT user_id, run_id, COUNT(*) AS order_count, MAX("timestamp") AS last_order_time + FROM paper_order + GROUP BY user_id, run_id + ), + trade_stats AS ( + SELECT user_id, run_id, COUNT(*) AS trade_count, MAX("timestamp") AS last_trade_time + FROM paper_trade + GROUP BY user_id, run_id + ), + event_stats AS ( + SELECT + user_id, + run_id, + MAX("timestamp") AS last_event_time, + MAX(CASE WHEN event = 'SIP_EXECUTED' THEN "timestamp" END) AS last_sip_time + FROM event_ledger + GROUP BY user_id, run_id + ), + equity_latest AS ( + SELECT DISTINCT ON (user_id, run_id) + user_id, + run_id, + equity AS equity_latest, + pnl AS pnl_latest, + "timestamp" AS equity_ts + FROM paper_equity_curve + ORDER BY user_id, run_id, "timestamp" DESC + ), + mtm_latest AS ( + SELECT DISTINCT ON (user_id, run_id) + user_id, + run_id, + "timestamp" AS mtm_ts + FROM mtm_ledger + ORDER BY user_id, run_id, "timestamp" DESC + ), + log_latest AS ( + SELECT user_id, run_id, MAX(ts) AS last_log_time + FROM strategy_log + GROUP BY user_id, run_id + ), + engine_latest AS ( + SELECT user_id, run_id, MAX(ts) AS last_engine_time + FROM engine_event + GROUP BY user_id, run_id + ), + activity AS ( + SELECT user_id, run_id, MAX(ts) AS last_event_time + FROM ( + SELECT user_id, run_id, ts FROM engine_event + UNION ALL + SELECT user_id, run_id, ts FROM strategy_log + UNION ALL + SELECT user_id, run_id, "timestamp" AS ts FROM paper_order + UNION ALL + SELECT user_id, run_id, "timestamp" AS ts FROM paper_trade + UNION ALL + SELECT user_id, run_id, "timestamp" AS ts FROM mtm_ledger + UNION ALL + SELECT user_id, run_id, "timestamp" AS ts FROM paper_equity_curve + UNION ALL + SELECT user_id, run_id, "timestamp" AS ts FROM event_ledger + ) t + GROUP BY user_id, run_id + ) + SELECT + sr.run_id, + sr.user_id, + sr.status, + sr.created_at, + sr.started_at, + sr.stopped_at, + sr.strategy, + sr.mode, + sr.broker, + sc.sip_amount, + sc.sip_frequency_value, + sc.sip_frequency_unit, + sc.next_run AS next_sip_time, + activity.last_event_time, + event_stats.last_sip_time, + COALESCE(order_stats.order_count, 0) AS order_count, + COALESCE(trade_stats.trade_count, 0) AS trade_count, + equity_latest.equity_latest, + equity_latest.pnl_latest + FROM strategy_run sr + LEFT JOIN strategy_config sc + ON sc.user_id = sr.user_id AND sc.run_id = sr.run_id + LEFT JOIN order_stats + ON order_stats.user_id = sr.user_id AND order_stats.run_id = sr.run_id + LEFT JOIN trade_stats + ON trade_stats.user_id = sr.user_id AND trade_stats.run_id = sr.run_id + LEFT JOIN event_stats + ON event_stats.user_id = sr.user_id AND event_stats.run_id = sr.run_id + LEFT JOIN equity_latest + ON equity_latest.user_id = sr.user_id AND equity_latest.run_id = sr.run_id + LEFT JOIN mtm_latest + ON mtm_latest.user_id = sr.user_id AND mtm_latest.run_id = sr.run_id + LEFT JOIN log_latest + ON log_latest.user_id = sr.user_id AND log_latest.run_id = sr.run_id + LEFT JOIN engine_latest + ON engine_latest.user_id = sr.user_id AND engine_latest.run_id = sr.run_id + LEFT JOIN activity + ON activity.user_id = sr.user_id AND activity.run_id = sr.run_id; + """ + ) + op.execute( + """ + CREATE OR REPLACE VIEW admin_engine_health AS + WITH activity AS ( + SELECT user_id, run_id, MAX(ts) AS last_event_time + FROM ( + SELECT user_id, run_id, ts FROM engine_event + UNION ALL + SELECT user_id, run_id, ts FROM strategy_log + UNION ALL + SELECT user_id, run_id, "timestamp" AS ts FROM event_ledger + ) t + GROUP BY user_id, run_id + ) + SELECT + sr.run_id, + sr.user_id, + sr.status, + activity.last_event_time, + es.status AS engine_status, + es.last_updated AS engine_status_ts + FROM strategy_run sr + LEFT JOIN activity + ON activity.user_id = sr.user_id AND activity.run_id = sr.run_id + LEFT JOIN engine_status es + ON es.user_id = sr.user_id AND es.run_id = sr.run_id; + """ + ) + op.execute( + """ + CREATE OR REPLACE VIEW admin_order_stats AS + SELECT + user_id, + run_id, + COUNT(*) AS total_orders, + COUNT(*) FILTER (WHERE "timestamp" >= now() - interval '24 hours') AS orders_last_24h, + COUNT(*) FILTER (WHERE status = 'FILLED') AS filled_orders + FROM paper_order + GROUP BY user_id, run_id; + """ + ) + op.execute( + """ + CREATE OR REPLACE VIEW admin_ledger_stats AS + WITH mtm_latest AS ( + SELECT DISTINCT ON (user_id, run_id) + user_id, + run_id, + portfolio_value, + pnl, + "timestamp" AS mtm_ts + FROM mtm_ledger + ORDER BY user_id, run_id, "timestamp" DESC + ), + equity_latest AS ( + SELECT DISTINCT ON (user_id, run_id) + user_id, + run_id, + equity, + pnl, + "timestamp" AS equity_ts + FROM paper_equity_curve + ORDER BY user_id, run_id, "timestamp" DESC + ) + SELECT + sr.user_id, + sr.run_id, + mtm_latest.portfolio_value AS mtm_value, + mtm_latest.pnl AS mtm_pnl, + mtm_latest.mtm_ts, + equity_latest.equity AS equity_value, + equity_latest.pnl AS equity_pnl, + equity_latest.equity_ts + FROM strategy_run sr + LEFT JOIN mtm_latest + ON mtm_latest.user_id = sr.user_id AND mtm_latest.run_id = sr.run_id + LEFT JOIN equity_latest + ON equity_latest.user_id = sr.user_id AND equity_latest.run_id = sr.run_id; + """ + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.execute("DROP VIEW IF EXISTS admin_ledger_stats;") + op.execute("DROP VIEW IF EXISTS admin_order_stats;") + op.execute("DROP VIEW IF EXISTS admin_engine_health;") + op.execute("DROP VIEW IF EXISTS admin_run_metrics;") + op.execute("DROP VIEW IF EXISTS admin_user_metrics;") + op.execute("DROP TRIGGER IF EXISTS trg_prevent_super_admin_delete ON app_user;") + op.execute("DROP FUNCTION IF EXISTS prevent_super_admin_delete;") + op.drop_index('idx_paper_trade_user_run_ts', table_name='paper_trade') + op.drop_index('idx_paper_trade_ts', table_name='paper_trade') + op.drop_table('paper_trade') + op.drop_index('idx_strategy_log_user_run_ts', table_name='strategy_log') + op.drop_index('idx_strategy_log_ts', table_name='strategy_log') + op.drop_index('idx_strategy_log_event', table_name='strategy_log') + op.drop_table('strategy_log') + op.drop_table('strategy_config') + op.drop_index('idx_paper_position_user_run', table_name='paper_position') + op.drop_table('paper_position') + op.drop_index('idx_paper_order_user_run_ts', table_name='paper_order') + op.drop_index('idx_paper_order_ts', table_name='paper_order') + op.drop_table('paper_order') + op.drop_index('idx_paper_equity_curve_user_run_ts', table_name='paper_equity_curve') + op.drop_index('idx_paper_equity_curve_ts', table_name='paper_equity_curve') + op.drop_table('paper_equity_curve') + op.drop_table('paper_broker_account') + op.drop_index('idx_mtm_ledger_user_run_ts', table_name='mtm_ledger') + op.drop_index('idx_mtm_ledger_ts', table_name='mtm_ledger') + op.drop_table('mtm_ledger') + op.drop_index('idx_event_ledger_user_run_logical', table_name='event_ledger') + op.drop_index('idx_event_ledger_user_run_ts', table_name='event_ledger') + op.drop_index('idx_event_ledger_ts', table_name='event_ledger') + op.drop_table('event_ledger') + op.drop_index('idx_engine_status_user_run', table_name='engine_status') + op.drop_table('engine_status') + op.drop_table('engine_state_paper') + op.drop_table('engine_state') + op.drop_index('idx_engine_event_user_run_ts', table_name='engine_event') + op.drop_index('idx_engine_event_ts', table_name='engine_event') + op.drop_table('engine_event') + op.drop_index('idx_zerodha_session_user_id', table_name='zerodha_session') + op.drop_index('idx_zerodha_session_linked_at', table_name='zerodha_session') + op.drop_table('zerodha_session') + op.drop_table('zerodha_request_token') + op.drop_index('idx_user_broker_connected', table_name='user_broker') + op.drop_index('idx_user_broker_broker', table_name='user_broker') + op.drop_table('user_broker') + op.drop_index('uq_one_running_run_per_user', table_name='strategy_run', postgresql_where=sa.text("status = 'RUNNING'")) + op.drop_index('idx_strategy_run_user_status', table_name='strategy_run') + op.drop_index('idx_strategy_run_user_created', table_name='strategy_run') + op.drop_table('strategy_run') + op.drop_index('idx_app_session_user_id', table_name='app_session') + op.drop_index('idx_app_session_expires_at', table_name='app_session') + op.drop_table('app_session') + op.drop_index('idx_market_close_symbol', table_name='market_close') + op.drop_index('idx_market_close_date', table_name='market_close') + op.drop_table('market_close') + op.drop_index('idx_app_user_role', table_name='app_user') + op.drop_index('idx_app_user_is_super_admin', table_name='app_user') + op.drop_index('idx_app_user_is_admin', table_name='app_user') + op.drop_table('app_user') + op.drop_table('admin_role_audit') + op.drop_table('admin_audit_log') + # ### end Alembic commands ### diff --git a/paper_mtm.py b/paper_mtm.py new file mode 100644 index 0000000..f9192f0 --- /dev/null +++ b/paper_mtm.py @@ -0,0 +1,76 @@ +from typing import Any, Dict +from pathlib import Path +import sys + +from fastapi import APIRouter, Request + +from app.services.paper_broker_service import get_paper_broker +from app.services.tenant import get_request_user_id +from app.services.run_service import get_active_run_id +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.append(str(PROJECT_ROOT)) + +from indian_paper_trading_strategy.engine.db import engine_context +from market import get_ltp + +from indian_paper_trading_strategy.engine.state import load_state + +router = APIRouter(prefix="/api/paper", tags=["paper-mtm"]) + + +@router.get("/mtm") +def paper_mtm(request: Request) -> Dict[str, Any]: + user_id = get_request_user_id(request) + run_id = get_active_run_id(user_id) + with engine_context(user_id, run_id): + broker = get_paper_broker(user_id) + + positions = broker.get_positions() + state = load_state(mode="PAPER") + cash = float(state.get("cash", 0)) + initial_cash = float(state.get("initial_cash", 0)) + + ltp_payload = get_ltp(allow_cache=True) + ltp_map = ltp_payload["ltp"] + + mtm_positions = [] + positions_value = 0.0 + + for pos in positions: + symbol = pos.get("symbol") + if not symbol: + continue + qty = float(pos.get("qty", 0)) + avg_price = float(pos.get("avg_price", 0)) + ltp = ltp_map.get(symbol) + if ltp is None: + continue + + pnl = (ltp - avg_price) * qty + positions_value += qty * ltp + + mtm_positions.append( + { + "symbol": symbol, + "qty": qty, + "avg_price": avg_price, + "ltp": ltp, + "pnl": pnl, + } + ) + + equity = cash + positions_value + unrealized_pnl = equity - float(initial_cash) + + return { + "ts": ltp_payload["ts"], + "initial_cash": initial_cash, + "cash": cash, + "positions_value": positions_value, + "equity": equity, + "unrealized_pnl": unrealized_pnl, + "positions": mtm_positions, + "price_stale": ltp_payload.get("stale_any", False), + "price_source": ltp_payload.get("source", {}), + } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1036150 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,43 @@ +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.1 +beautifulsoup4==4.14.3 +certifi==2026.1.4 +cffi==2.0.0 +charset-normalizer==3.4.4 +click==8.3.1 +colorama==0.4.6 +cryptography==46.0.3 +curl_cffi==0.13.0 +fastapi==0.128.0 +frozendict==2.4.7 +h11==0.16.0 +idna==3.11 +httpx==0.27.2 +multitasking==0.0.12 +numpy==2.4.1 +pandas==2.3.3 +peewee==3.19.0 +platformdirs==4.5.1 +protobuf==6.33.4 +psycopg2-binary==2.9.11 +SQLAlchemy==2.0.36 +pycparser==2.23 +pydantic==2.12.5 +pydantic_core==2.41.5 +python-dateutil==2.9.0.post0 +pytz==2025.2 +requests==2.32.5 +six==1.17.0 +soupsieve==2.8.1 +starlette==0.50.0 +ta==0.11.0 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +tzdata==2025.3 +urllib3==2.6.3 +uvicorn==0.40.0 +websockets==16.0 +yfinance==1.0 +alembic==1.13.3 +pytest==8.3.5 diff --git a/run_backend.ps1 b/run_backend.ps1 new file mode 100644 index 0000000..80ff8a4 --- /dev/null +++ b/run_backend.ps1 @@ -0,0 +1,29 @@ +Set-Location "C:\Users\quantfortune\SIP\SIP_India\backend" +$env:DB_HOST = 'localhost' +$env:DB_PORT = '5432' +$env:DB_NAME = 'trading_db' +$env:DB_USER = 'trader' +$env:DB_PASSWORD = 'traderpass' +$env:DB_SCHEMA = 'quant_app' +$env:DB_CONNECT_TIMEOUT = '5' +$frontendUrlFile = 'C:\Users\quantfortune\SIP\SIP_India\ngrok_frontend_url.txt' +$env:ZERODHA_REDIRECT_URL = 'http://localhost:3000/login' +if (Test-Path $frontendUrlFile) { + $frontendUrl = (Get-Content $frontendUrlFile -Raw).Trim() + if ($frontendUrl) { + $env:CORS_ORIGINS = "http://localhost:3000,http://127.0.0.1:3000,$frontendUrl" + $env:COOKIE_SECURE = '1' + $env:COOKIE_SAMESITE = 'none' + $env:ZERODHA_REDIRECT_URL = "$frontendUrl/login" + } +} +$env:BROKER_TOKEN_KEY = '6SuYLz0n7-KM5nB_Bs6ueYgDXZZvbmf-K-WpFbOMbH4=' +$env:SUPER_ADMIN_EMAIL = 'admin@example.com' +$env:SUPER_ADMIN_PASSWORD = 'AdminPass123!' +$env:SMTP_HOST = 'smtp.gmail.com' +$env:SMTP_PORT = '587' +$env:SMTP_USER = 'quantfortune@gmail.com' +$env:SMTP_PASS = 'wkbk mwbi aiqo yvwl' +$env:SMTP_FROM_NAME = 'Quantfortune Support' +$env:RESET_OTP_SECRET = 'change_this_secret' +.\venv\Scripts\uvicorn.exe app.main:app --host 0.0.0.0 --port 8000 diff --git a/uvicorn.err b/uvicorn.err new file mode 100644 index 0000000..59cac7b --- /dev/null +++ b/uvicorn.err @@ -0,0 +1,4 @@ +INFO: Started server process [5344] +INFO: Waiting for application startup. +INFO: Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) diff --git a/uvicorn.log b/uvicorn.log new file mode 100644 index 0000000..ca76f98 --- /dev/null +++ b/uvicorn.log @@ -0,0 +1 @@ +INFO: 127.0.0.1:60429 - "GET /api/me HTTP/1.1" 401 Unauthorized