From 08c6741aaea9c3d09a866df06d83509c42cac55f Mon Sep 17 00:00:00 2001 From: thigazhezhilan Date: Sun, 1 Feb 2026 13:06:44 +0000 Subject: [PATCH] Initial backend commit --- Backend.zip | Bin 0 -> 189579 bytes README.md | 1 + alembic.ini | 117 +++ app/__init__.py | 0 app/admin_auth.py | 71 ++ app/admin_models.py | 163 ++++ app/admin_role_service.py | 109 +++ app/admin_router.py | 151 ++++ app/admin_service.py | 762 ++++++++++++++++++ app/broker_store.py | 296 +++++++ app/db_models.py | 491 +++++++++++ app/main.py | 71 ++ app/models.py | 37 + app/routers/__init__.py | 1 + app/routers/auth.py | 116 +++ app/routers/broker.py | 205 +++++ app/routers/health.py | 12 + app/routers/paper.py | 75 ++ app/routers/password_reset.py | 59 ++ app/routers/strategy.py | 47 ++ app/routers/support_ticket.py | 39 + app/routers/system.py | 41 + app/routers/zerodha.py | 234 ++++++ app/services/__init__.py | 0 app/services/auth_service.py | 280 +++++++ app/services/broker_service.py | 0 app/services/crypto_service.py | 39 + app/services/db.py | 210 +++++ app/services/email_service.py | 28 + app/services/paper_broker_service.py | 191 +++++ app/services/run_lifecycle.py | 22 + app/services/run_service.py | 176 ++++ app/services/strategy_service.py | 650 +++++++++++++++ app/services/support_ticket.py | 70 ++ app/services/system_service.py | 378 +++++++++ app/services/tenant.py | 19 + app/services/zerodha_service.py | 89 ++ app/services/zerodha_storage.py | 125 +++ market.py | 91 +++ migrations/README | 1 + migrations/env.py | 87 ++ migrations/script.py.mako | 26 + .../versions/52abc790351d_initial_schema.py | 674 ++++++++++++++++ paper_mtm.py | 76 ++ requirements.txt | 43 + run_backend.ps1 | 29 + uvicorn.err | 4 + uvicorn.log | 1 + 48 files changed, 6407 insertions(+) create mode 100644 Backend.zip create mode 100644 README.md create mode 100644 alembic.ini create mode 100644 app/__init__.py create mode 100644 app/admin_auth.py create mode 100644 app/admin_models.py create mode 100644 app/admin_role_service.py create mode 100644 app/admin_router.py create mode 100644 app/admin_service.py create mode 100644 app/broker_store.py create mode 100644 app/db_models.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/auth.py create mode 100644 app/routers/broker.py create mode 100644 app/routers/health.py create mode 100644 app/routers/paper.py create mode 100644 app/routers/password_reset.py create mode 100644 app/routers/strategy.py create mode 100644 app/routers/support_ticket.py create mode 100644 app/routers/system.py create mode 100644 app/routers/zerodha.py create mode 100644 app/services/__init__.py create mode 100644 app/services/auth_service.py create mode 100644 app/services/broker_service.py create mode 100644 app/services/crypto_service.py create mode 100644 app/services/db.py create mode 100644 app/services/email_service.py create mode 100644 app/services/paper_broker_service.py create mode 100644 app/services/run_lifecycle.py create mode 100644 app/services/run_service.py create mode 100644 app/services/strategy_service.py create mode 100644 app/services/support_ticket.py create mode 100644 app/services/system_service.py create mode 100644 app/services/tenant.py create mode 100644 app/services/zerodha_service.py create mode 100644 app/services/zerodha_storage.py create mode 100644 market.py create mode 100644 migrations/README create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/52abc790351d_initial_schema.py create mode 100644 paper_mtm.py create mode 100644 requirements.txt create mode 100644 run_backend.ps1 create mode 100644 uvicorn.err create mode 100644 uvicorn.log diff --git a/Backend.zip b/Backend.zip new file mode 100644 index 0000000000000000000000000000000000000000..f008047dc4bec234f5dff92d7f3bbf6cb2e7a763 GIT binary patch literal 189579 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⋙5yD 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 J=W-}Q{~z@SW@Z2Y literal 0 HcmV?d00001 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