Upload 42 files
Browse files- __pycache__/app.cpython-311.pyc +0 -0
- __pycache__/kotak_neo.cpython-311.pyc +0 -0
- app.py +17 -0
- kotak_neo.py +157 -4
__pycache__/app.cpython-311.pyc
CHANGED
|
Binary files a/__pycache__/app.cpython-311.pyc and b/__pycache__/app.cpython-311.pyc differ
|
|
|
__pycache__/kotak_neo.cpython-311.pyc
CHANGED
|
Binary files a/__pycache__/kotak_neo.cpython-311.pyc and b/__pycache__/kotak_neo.cpython-311.pyc differ
|
|
|
app.py
CHANGED
|
@@ -450,6 +450,23 @@ def kotak_account() -> dict:
|
|
| 450 |
raise HTTPException(status_code=401, detail=str(exc)) from exc
|
| 451 |
except KotakNeoError as exc:
|
| 452 |
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
|
| 454 |
|
| 455 |
@app.get("/cron/keepalive")
|
|
|
|
| 450 |
raise HTTPException(status_code=401, detail=str(exc)) from exc
|
| 451 |
except KotakNeoError as exc:
|
| 452 |
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
@app.get("/kotak/activity-log")
|
| 456 |
+
def kotak_activity_log() -> dict:
|
| 457 |
+
try:
|
| 458 |
+
snapshot = kotak_neo_manager.fetch_account_snapshot()
|
| 459 |
+
return {
|
| 460 |
+
"activity_log": snapshot.get("activity_log", {}),
|
| 461 |
+
"trade_history": snapshot.get("trade_history", []),
|
| 462 |
+
"order_book": snapshot.get("order_book", []),
|
| 463 |
+
}
|
| 464 |
+
except KotakNeoConfigError as exc:
|
| 465 |
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
| 466 |
+
except KotakNeoSessionRequired as exc:
|
| 467 |
+
raise HTTPException(status_code=401, detail=str(exc)) from exc
|
| 468 |
+
except KotakNeoError as exc:
|
| 469 |
+
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
| 470 |
|
| 471 |
|
| 472 |
@app.get("/cron/keepalive")
|
kotak_neo.py
CHANGED
|
@@ -4,6 +4,7 @@ import os
|
|
| 4 |
import threading
|
| 5 |
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 6 |
from datetime import datetime, timezone
|
|
|
|
| 7 |
from typing import Any
|
| 8 |
from urllib.parse import quote
|
| 9 |
|
|
@@ -18,6 +19,8 @@ TOTP_LOGIN_PATH = "login/1.0/tradeApiLogin"
|
|
| 18 |
TOTP_VALIDATE_PATH = "login/1.0/tradeApiValidate"
|
| 19 |
DEFAULT_TIMEOUT_SECONDS = 20
|
| 20 |
ACCOUNT_TIMEOUT_SECONDS = 7
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
class KotakNeoError(Exception):
|
|
@@ -125,8 +128,29 @@ class KotakNeoManager:
|
|
| 125 |
self.neo_fin_key = os.getenv("KOTAK_NEO_FIN_KEY", "neotradeapi")
|
| 126 |
|
| 127 |
self._lock = threading.RLock()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
self._clear_session_locked()
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
def _clear_session_locked(self) -> None:
|
| 131 |
self.view_token: str | None = None
|
| 132 |
self.sid: str | None = None
|
|
@@ -281,6 +305,12 @@ class KotakNeoManager:
|
|
| 281 |
|
| 282 |
normalized_holdings = [self._normalize_holding(item, quote_map) for item in holdings]
|
| 283 |
normalized_positions = [self._normalize_position(item, quote_map) for item in positions]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
|
| 285 |
holdings_market_value = sum(item["market_value"] or 0.0 for item in normalized_holdings)
|
| 286 |
holdings_cost = sum(item["cost_value"] or 0.0 for item in normalized_holdings)
|
|
@@ -317,15 +347,20 @@ class KotakNeoManager:
|
|
| 317 |
"live_pnl": holdings_pnl + positions_pnl,
|
| 318 |
"open_positions": sum(1 for item in normalized_positions if item["net_quantity"]),
|
| 319 |
"holdings_count": len(normalized_holdings),
|
| 320 |
-
"orders_count": len(
|
| 321 |
-
"trades_count": len(
|
| 322 |
},
|
| 323 |
"limits_summary": limits_summary,
|
| 324 |
"limits_raw": limits_raw,
|
| 325 |
"holdings": normalized_holdings,
|
| 326 |
"positions": normalized_positions,
|
| 327 |
-
"trade_history":
|
| 328 |
-
"order_book":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
"quotes": list(quote_map.values()),
|
| 330 |
}
|
| 331 |
|
|
@@ -701,5 +736,123 @@ class KotakNeoManager:
|
|
| 701 |
"updated_at": _first_text(item.get("hsUpTm"), item.get("exTm"), item.get("flDtTm")),
|
| 702 |
}
|
| 703 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 704 |
|
| 705 |
kotak_neo_manager = KotakNeoManager()
|
|
|
|
| 4 |
import threading
|
| 5 |
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 6 |
from datetime import datetime, timezone
|
| 7 |
+
from pathlib import Path
|
| 8 |
from typing import Any
|
| 9 |
from urllib.parse import quote
|
| 10 |
|
|
|
|
| 19 |
TOTP_VALIDATE_PATH = "login/1.0/tradeApiValidate"
|
| 20 |
DEFAULT_TIMEOUT_SECONDS = 20
|
| 21 |
ACCOUNT_TIMEOUT_SECONDS = 7
|
| 22 |
+
DATA_DIR = Path(__file__).resolve().parent / "data"
|
| 23 |
+
KOTAK_ACTIVITY_LOG_PATH = DATA_DIR / "kotak_activity_log.txt"
|
| 24 |
|
| 25 |
|
| 26 |
class KotakNeoError(Exception):
|
|
|
|
| 128 |
self.neo_fin_key = os.getenv("KOTAK_NEO_FIN_KEY", "neotradeapi")
|
| 129 |
|
| 130 |
self._lock = threading.RLock()
|
| 131 |
+
self.activity_log_path = KOTAK_ACTIVITY_LOG_PATH
|
| 132 |
+
self.activity_log_path.parent.mkdir(parents=True, exist_ok=True)
|
| 133 |
+
self._seen_activity_keys: set[str] = set()
|
| 134 |
+
self._load_existing_activity_keys()
|
| 135 |
self._clear_session_locked()
|
| 136 |
|
| 137 |
+
def _load_existing_activity_keys(self) -> None:
|
| 138 |
+
if not self.activity_log_path.exists():
|
| 139 |
+
return
|
| 140 |
+
try:
|
| 141 |
+
for line in self.activity_log_path.read_text(encoding="utf-8").splitlines():
|
| 142 |
+
if not line.strip():
|
| 143 |
+
continue
|
| 144 |
+
try:
|
| 145 |
+
payload = json.loads(line)
|
| 146 |
+
except json.JSONDecodeError:
|
| 147 |
+
continue
|
| 148 |
+
key = str(payload.get("activity_key") or "").strip()
|
| 149 |
+
if key:
|
| 150 |
+
self._seen_activity_keys.add(key)
|
| 151 |
+
except Exception:
|
| 152 |
+
pass
|
| 153 |
+
|
| 154 |
def _clear_session_locked(self) -> None:
|
| 155 |
self.view_token: str | None = None
|
| 156 |
self.sid: str | None = None
|
|
|
|
| 305 |
|
| 306 |
normalized_holdings = [self._normalize_holding(item, quote_map) for item in holdings]
|
| 307 |
normalized_positions = [self._normalize_position(item, quote_map) for item in positions]
|
| 308 |
+
normalized_trades = [self._normalize_trade(item) for item in trades]
|
| 309 |
+
normalized_orders = [self._normalize_order(item) for item in orders]
|
| 310 |
+
self._append_activity_entries(normalized_trades, normalized_orders)
|
| 311 |
+
journal = self._read_activity_journal()
|
| 312 |
+
merged_trades = self._merge_activity(normalized_trades, journal["trades"])
|
| 313 |
+
merged_orders = self._merge_activity(normalized_orders, journal["orders"])
|
| 314 |
|
| 315 |
holdings_market_value = sum(item["market_value"] or 0.0 for item in normalized_holdings)
|
| 316 |
holdings_cost = sum(item["cost_value"] or 0.0 for item in normalized_holdings)
|
|
|
|
| 347 |
"live_pnl": holdings_pnl + positions_pnl,
|
| 348 |
"open_positions": sum(1 for item in normalized_positions if item["net_quantity"]),
|
| 349 |
"holdings_count": len(normalized_holdings),
|
| 350 |
+
"orders_count": len(merged_orders),
|
| 351 |
+
"trades_count": len(merged_trades),
|
| 352 |
},
|
| 353 |
"limits_summary": limits_summary,
|
| 354 |
"limits_raw": limits_raw,
|
| 355 |
"holdings": normalized_holdings,
|
| 356 |
"positions": normalized_positions,
|
| 357 |
+
"trade_history": merged_trades[:100],
|
| 358 |
+
"order_book": merged_orders[:100],
|
| 359 |
+
"activity_log": {
|
| 360 |
+
"path": str(self.activity_log_path),
|
| 361 |
+
"trades_count": len(journal["trades"]),
|
| 362 |
+
"orders_count": len(journal["orders"]),
|
| 363 |
+
},
|
| 364 |
"quotes": list(quote_map.values()),
|
| 365 |
}
|
| 366 |
|
|
|
|
| 736 |
"updated_at": _first_text(item.get("hsUpTm"), item.get("exTm"), item.get("flDtTm")),
|
| 737 |
}
|
| 738 |
|
| 739 |
+
def _normalize_trade(self, item: dict[str, Any]) -> dict[str, Any]:
|
| 740 |
+
return {
|
| 741 |
+
"activity_type": "trade",
|
| 742 |
+
"activity_key": self._trade_key(item),
|
| 743 |
+
"order_no": _first_text(item.get("nOrdNo")),
|
| 744 |
+
"trade_id": _first_text(item.get("flId")),
|
| 745 |
+
"exchange_order_id": _first_text(item.get("exOrdId")),
|
| 746 |
+
"symbol": _first_text(item.get("sym"), item.get("trdSym")),
|
| 747 |
+
"trading_symbol": _first_text(item.get("trdSym"), item.get("sym")),
|
| 748 |
+
"exchange_segment": _first_text(item.get("exSeg")),
|
| 749 |
+
"transaction_type": _first_text(item.get("trnsTp")),
|
| 750 |
+
"product": _first_text(item.get("prod")),
|
| 751 |
+
"quantity": _first_number(item.get("fldQty"), item.get("qty")),
|
| 752 |
+
"price": _first_number(item.get("avgPrc"), item.get("prc")),
|
| 753 |
+
"average_price": _first_number(item.get("avgPrc")),
|
| 754 |
+
"status": _first_text(item.get("rptTp"), item.get("ordSt"), item.get("stat")),
|
| 755 |
+
"trade_time": _first_text(item.get("flDtTm"), item.get("exTm"), item.get("flTm"), item.get("flDt")),
|
| 756 |
+
"raw": item,
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
def _normalize_order(self, item: dict[str, Any]) -> dict[str, Any]:
|
| 760 |
+
return {
|
| 761 |
+
"activity_type": "order",
|
| 762 |
+
"activity_key": self._order_key(item),
|
| 763 |
+
"order_no": _first_text(item.get("nOrdNo")),
|
| 764 |
+
"request_id": _first_text(item.get("reqId"), item.get("nReqId")),
|
| 765 |
+
"exchange_order_id": _first_text(item.get("exOrdId")),
|
| 766 |
+
"symbol": _first_text(item.get("sym"), item.get("trdSym")),
|
| 767 |
+
"trading_symbol": _first_text(item.get("trdSym"), item.get("sym")),
|
| 768 |
+
"exchange_segment": _first_text(item.get("exSeg")),
|
| 769 |
+
"transaction_type": _first_text(item.get("trnsTp")),
|
| 770 |
+
"product": _first_text(item.get("prod")),
|
| 771 |
+
"quantity": _first_number(item.get("qty")),
|
| 772 |
+
"filled_quantity": _first_number(item.get("fldQty")),
|
| 773 |
+
"unfilled_size": _first_number(item.get("unFldSz")),
|
| 774 |
+
"price": _first_number(item.get("prc")),
|
| 775 |
+
"trigger_price": _first_number(item.get("trgPrc")),
|
| 776 |
+
"order_type": _first_text(item.get("prcTp")),
|
| 777 |
+
"status": _first_text(item.get("ordSt"), item.get("stat")),
|
| 778 |
+
"order_time": _first_text(item.get("ordDtTm"), item.get("exCfmTm"), item.get("hsUpTm")),
|
| 779 |
+
"rejection_reason": _first_text(item.get("rejRsn")),
|
| 780 |
+
"raw": item,
|
| 781 |
+
}
|
| 782 |
+
|
| 783 |
+
def _trade_key(self, item: dict[str, Any]) -> str:
|
| 784 |
+
return "|".join(
|
| 785 |
+
[
|
| 786 |
+
"trade",
|
| 787 |
+
str(_first_text(item.get("nOrdNo")) or ""),
|
| 788 |
+
str(_first_text(item.get("flId")) or ""),
|
| 789 |
+
str(_first_text(item.get("flDtTm"), item.get("exTm"), item.get("flTm")) or ""),
|
| 790 |
+
]
|
| 791 |
+
)
|
| 792 |
+
|
| 793 |
+
def _order_key(self, item: dict[str, Any]) -> str:
|
| 794 |
+
return "|".join(
|
| 795 |
+
[
|
| 796 |
+
"order",
|
| 797 |
+
str(_first_text(item.get("nOrdNo")) or ""),
|
| 798 |
+
str(_first_text(item.get("ordSt"), item.get("stat")) or ""),
|
| 799 |
+
str(_first_text(item.get("ordDtTm"), item.get("exCfmTm"), item.get("hsUpTm")) or ""),
|
| 800 |
+
]
|
| 801 |
+
)
|
| 802 |
+
|
| 803 |
+
def _append_activity_entries(self, trades: list[dict[str, Any]], orders: list[dict[str, Any]]) -> None:
|
| 804 |
+
lines: list[str] = []
|
| 805 |
+
for entry in [*trades, *orders]:
|
| 806 |
+
key = str(entry.get("activity_key") or "").strip()
|
| 807 |
+
if not key or key in self._seen_activity_keys:
|
| 808 |
+
continue
|
| 809 |
+
payload = {
|
| 810 |
+
"activity_key": key,
|
| 811 |
+
"activity_type": entry.get("activity_type"),
|
| 812 |
+
"captured_at": _utc_now_iso(),
|
| 813 |
+
**entry,
|
| 814 |
+
}
|
| 815 |
+
lines.append(json.dumps(payload, ensure_ascii=True))
|
| 816 |
+
self._seen_activity_keys.add(key)
|
| 817 |
+
if not lines:
|
| 818 |
+
return
|
| 819 |
+
with self.activity_log_path.open("a", encoding="utf-8") as handle:
|
| 820 |
+
handle.write("\n".join(lines) + "\n")
|
| 821 |
+
|
| 822 |
+
def _read_activity_journal(self) -> dict[str, list[dict[str, Any]]]:
|
| 823 |
+
trades: list[dict[str, Any]] = []
|
| 824 |
+
orders: list[dict[str, Any]] = []
|
| 825 |
+
if not self.activity_log_path.exists():
|
| 826 |
+
return {"trades": trades, "orders": orders}
|
| 827 |
+
for line in self.activity_log_path.read_text(encoding="utf-8").splitlines():
|
| 828 |
+
if not line.strip():
|
| 829 |
+
continue
|
| 830 |
+
try:
|
| 831 |
+
item = json.loads(line)
|
| 832 |
+
except json.JSONDecodeError:
|
| 833 |
+
continue
|
| 834 |
+
if item.get("activity_type") == "trade":
|
| 835 |
+
trades.append(item)
|
| 836 |
+
elif item.get("activity_type") == "order":
|
| 837 |
+
orders.append(item)
|
| 838 |
+
trades.sort(key=lambda item: str(item.get("trade_time") or item.get("captured_at") or ""), reverse=True)
|
| 839 |
+
orders.sort(key=lambda item: str(item.get("order_time") or item.get("captured_at") or ""), reverse=True)
|
| 840 |
+
return {"trades": trades, "orders": orders}
|
| 841 |
+
|
| 842 |
+
def _merge_activity(
|
| 843 |
+
self,
|
| 844 |
+
live_entries: list[dict[str, Any]],
|
| 845 |
+
journal_entries: list[dict[str, Any]],
|
| 846 |
+
) -> list[dict[str, Any]]:
|
| 847 |
+
merged: list[dict[str, Any]] = []
|
| 848 |
+
seen: set[str] = set()
|
| 849 |
+
for entry in [*live_entries, *journal_entries]:
|
| 850 |
+
key = str(entry.get("activity_key") or "").strip()
|
| 851 |
+
if not key or key in seen:
|
| 852 |
+
continue
|
| 853 |
+
merged.append(entry)
|
| 854 |
+
seen.add(key)
|
| 855 |
+
return merged
|
| 856 |
+
|
| 857 |
|
| 858 |
kotak_neo_manager = KotakNeoManager()
|