tradingapi 0.2.1__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tradingapi-0.2.1 → tradingapi-0.3.0}/PKG-INFO +1 -1
- {tradingapi-0.2.1 → tradingapi-0.3.0}/pyproject.toml +1 -1
- tradingapi-0.3.0/tradingapi/allocation.py +163 -0
- {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/broker_base.py +1 -1
- {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/config.py +16 -0
- {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/dhan.py +3 -3
- {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/fivepaisa.py +179 -0
- {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/flattrade.py +82 -1
- {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/shoonya.py +100 -16
- tradingapi-0.3.0/tradingapi/span.py +837 -0
- {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/utils.py +223 -50
- {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi.egg-info/PKG-INFO +1 -1
- {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi.egg-info/SOURCES.txt +2 -0
- {tradingapi-0.2.1 → tradingapi-0.3.0}/README.md +0 -0
- {tradingapi-0.2.1 → tradingapi-0.3.0}/setup.cfg +0 -0
- {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/__init__.py +0 -0
- {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/attribution.py +0 -0
- {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/config/commissions_20241216.yaml +0 -0
- {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/config/config_sample.yaml +0 -0
- {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/error_handling.py +0 -0
- {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/exceptions.py +0 -0
- {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/globals.py +0 -0
- {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/icicidirect.py +0 -0
- {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/icicidirect_generate_session.py +0 -0
- {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/market_data_exchanges.py +0 -0
- {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/proxy_utils.py +0 -0
- {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi.egg-info/dependency_links.txt +0 -0
- {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi.egg-info/entry_points.txt +0 -0
- {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi.egg-info/requires.txt +0 -0
- {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Allocation helpers for budget-based position sizing.
|
|
3
|
+
|
|
4
|
+
Strategies read their daily pct from ~/onedrive/.tradingapi/allocations/master_allocation_today.yaml,
|
|
5
|
+
then call these helpers at each entry decision to derive a live per-entry target margin.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as dt
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
_ALLOCATIONS_DIR = Path(os.path.expanduser("~/onedrive/.tradingapi/allocations"))
|
|
21
|
+
_TODAY_SYMLINK = _ALLOCATIONS_DIR / "master_allocation_today.yaml"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_today_allocation(broker_name: str, strategy_name: str) -> float:
|
|
25
|
+
"""Return the pct (0.0–1.0) allocated to strategy_name on broker_name today.
|
|
26
|
+
|
|
27
|
+
Reads master_allocation_today.yaml (a symlink to today's dated file).
|
|
28
|
+
Raises FileNotFoundError if the symlink/file is missing.
|
|
29
|
+
Raises ValueError if the date in the file does not match today.
|
|
30
|
+
Returns 0.0 if the strategy is listed with pct == 0 (inactive today).
|
|
31
|
+
Raises KeyError if the broker/strategy is not found in the file at all.
|
|
32
|
+
"""
|
|
33
|
+
if not _TODAY_SYMLINK.exists():
|
|
34
|
+
raise FileNotFoundError(
|
|
35
|
+
f"Master allocation file not found: {_TODAY_SYMLINK}. "
|
|
36
|
+
"Run generate_master_allocation.py before starting strategies."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
with open(_TODAY_SYMLINK) as f:
|
|
40
|
+
data = yaml.safe_load(f)
|
|
41
|
+
|
|
42
|
+
file_date = str(data.get("date", "")).strip()
|
|
43
|
+
today_str = dt.date.today().isoformat()
|
|
44
|
+
if file_date != today_str:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"Master allocation file is stale: file date={file_date!r}, today={today_str!r}. "
|
|
47
|
+
"Regenerate with generate_master_allocation.py."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
broker_key = broker_name.strip().upper()
|
|
51
|
+
brokers = data.get("brokers", {})
|
|
52
|
+
if broker_key not in brokers:
|
|
53
|
+
raise KeyError(
|
|
54
|
+
f"Broker {broker_key!r} not found in master allocation. "
|
|
55
|
+
f"Available brokers: {list(brokers.keys())}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
strategies = brokers[broker_key].get("strategies", {})
|
|
59
|
+
strategy_key = strategy_name.strip().upper()
|
|
60
|
+
if strategy_key not in strategies:
|
|
61
|
+
raise KeyError(
|
|
62
|
+
f"Strategy {strategy_key!r} not found under broker {broker_key!r} in master allocation. "
|
|
63
|
+
f"Available strategies: {list(strategies.keys())}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
entry = strategies[strategy_key]
|
|
67
|
+
return float(entry.get("pct", 0.0))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def broker_capital(broker) -> float:
|
|
71
|
+
"""Fetch live total capital from broker: cash + collateral."""
|
|
72
|
+
caps = broker.get_available_capital()
|
|
73
|
+
return float(caps.get("cash", 0.0)) + float(caps.get("collateral", 0.0))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def current_utilisation(broker, strategy_name: str) -> float:
|
|
77
|
+
"""Sum of margin currently consumed by open positions of this strategy.
|
|
78
|
+
|
|
79
|
+
Calls broker.get_margin_requirement() for each open position (net=False).
|
|
80
|
+
Exchange is read from Redis per position; defaults to NSE if missing.
|
|
81
|
+
"""
|
|
82
|
+
from tradingapi.utils import get_pnl_table, hget_with_default
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
pnl = get_pnl_table(broker, strategy_name, refresh_status=True)
|
|
86
|
+
except Exception:
|
|
87
|
+
return 0.0
|
|
88
|
+
|
|
89
|
+
if pnl.empty:
|
|
90
|
+
return 0.0
|
|
91
|
+
|
|
92
|
+
open_mask = (pnl["entry_quantity"].fillna(0) + pnl["exit_quantity"].fillna(0)) != 0
|
|
93
|
+
exit_empty = pnl["exit_time"].fillna("").astype(str).str.strip().isin(["", "0", "NaT"])
|
|
94
|
+
open_pnl = pnl.loc[open_mask | exit_empty]
|
|
95
|
+
|
|
96
|
+
total = 0.0
|
|
97
|
+
for _, row in open_pnl.iterrows():
|
|
98
|
+
combo_symbol = str(row.get("symbol", "")).strip()
|
|
99
|
+
if not combo_symbol:
|
|
100
|
+
continue
|
|
101
|
+
net_qty = float(row.get("entry_quantity", 0) or 0) + float(row.get("exit_quantity", 0) or 0)
|
|
102
|
+
if net_qty == 0:
|
|
103
|
+
continue
|
|
104
|
+
# Signed: positive = long, negative = short.
|
|
105
|
+
# parse_combo_symbol handles both simple and combo symbols uniformly.
|
|
106
|
+
order_size = int(net_qty)
|
|
107
|
+
|
|
108
|
+
entry_keys_str = str(row.get("entry_keys", "")).strip()
|
|
109
|
+
first_key = entry_keys_str.split()[0] if entry_keys_str else ""
|
|
110
|
+
exchange = hget_with_default(broker, first_key, "exchange", "NSE") if first_key else "NSE"
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
margin = broker.get_margin_requirement(combo_symbol, order_size, exchange)
|
|
114
|
+
if margin is not None:
|
|
115
|
+
total += float(margin)
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
return total
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def remaining_budget(budget: float, utilised: float) -> float:
|
|
122
|
+
return max(0.0, budget - utilised)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _remaining_factor_sum(open_positions: int, max_positions: int, decay: float, min_frac: float) -> float:
|
|
126
|
+
"""Sum of decay fractions for future legs [open_positions, max_positions)."""
|
|
127
|
+
return float(sum(
|
|
128
|
+
max(min_frac, decay ** k)
|
|
129
|
+
for k in range(open_positions, max_positions)
|
|
130
|
+
))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def scalping_entry_target(
|
|
134
|
+
remaining: float,
|
|
135
|
+
open_positions: int,
|
|
136
|
+
max_positions: int,
|
|
137
|
+
decay: float,
|
|
138
|
+
min_frac: float,
|
|
139
|
+
) -> float:
|
|
140
|
+
"""Target margin for the next scalping entry given remaining budget.
|
|
141
|
+
|
|
142
|
+
Entry 1 (0 open): remaining / sum([1.0, 0.75, 0.5625]) ≈ remaining / 2.3125
|
|
143
|
+
Entry 2 (1 open): remaining / sum([0.75, 0.5625]) ≈ remaining / 1.3125
|
|
144
|
+
Entry 3 (2 open): remaining / 0.5625 → clamped to remaining (last leg gets all that's left)
|
|
145
|
+
"""
|
|
146
|
+
if open_positions >= max_positions:
|
|
147
|
+
return 0.0
|
|
148
|
+
factor_sum = _remaining_factor_sum(open_positions, max_positions, decay, min_frac)
|
|
149
|
+
if factor_sum <= 0:
|
|
150
|
+
return remaining
|
|
151
|
+
raw = remaining / factor_sum
|
|
152
|
+
return min(raw, remaining)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def strangle_entry_target(budget_now: float, remaining: float, scheduled_entries: int) -> float:
|
|
156
|
+
"""Target margin for the next strangle entry.
|
|
157
|
+
|
|
158
|
+
Equal weighting across scheduled entries, clamped to what's actually left.
|
|
159
|
+
"""
|
|
160
|
+
if scheduled_entries <= 0:
|
|
161
|
+
return remaining
|
|
162
|
+
equal_share = budget_now / scheduled_entries
|
|
163
|
+
return min(equal_share, remaining)
|
|
@@ -1076,7 +1076,7 @@ class BrokerBase(ABC):
|
|
|
1076
1076
|
pass
|
|
1077
1077
|
|
|
1078
1078
|
def get_margin_requirement(
|
|
1079
|
-
self, combo_symbol: str, order_size: int, exchange: str = "NSE", mds: Optional[str] = None
|
|
1079
|
+
self, combo_symbol: str, order_size: int, exchange: str = "NSE", mds: Optional[str] = None, net: bool = False
|
|
1080
1080
|
) -> Optional[float]:
|
|
1081
1081
|
"""Default broker margin hook. Brokers can override if supported."""
|
|
1082
1082
|
_get_trading_logger().log_warning(
|
|
@@ -541,6 +541,22 @@ def get_config() -> Config:
|
|
|
541
541
|
return cast(Config, _config_instance)
|
|
542
542
|
|
|
543
543
|
|
|
544
|
+
def get_fno_freeze_limit(broker_name: str, underlying: str) -> Optional[int]:
|
|
545
|
+
"""Return max contracts per FNO order for (broker, underlying).
|
|
546
|
+
|
|
547
|
+
Values in tradingapi.yaml are total contracts (= lots × lot_size).
|
|
548
|
+
Returns None if no limit is configured for the broker/underlying.
|
|
549
|
+
"""
|
|
550
|
+
try:
|
|
551
|
+
cfg = get_config()
|
|
552
|
+
freeze_cfg = cfg.get("freeze_limits") or {}
|
|
553
|
+
broker_map = (freeze_cfg.get("brokers") or {}).get(str(broker_name).strip().upper(), {})
|
|
554
|
+
val = broker_map.get(str(underlying).strip().upper())
|
|
555
|
+
return int(val) if val is not None else None
|
|
556
|
+
except Exception:
|
|
557
|
+
return None
|
|
558
|
+
|
|
559
|
+
|
|
544
560
|
# Example usage with enhanced error handling
|
|
545
561
|
if __name__ == "__main__":
|
|
546
562
|
try:
|
|
@@ -778,7 +778,7 @@ class Dhan(BrokerBase):
|
|
|
778
778
|
combo_symbol=lambda x: isinstance(x, str) and len(x.strip()) > 0,
|
|
779
779
|
order_size=lambda x: isinstance(x, (int, float)) and int(x) != 0,
|
|
780
780
|
)
|
|
781
|
-
def get_margin_requirement(self, combo_symbol: str, order_size: int, exchange: str = "NSE", mds=None) -> float:
|
|
781
|
+
def get_margin_requirement(self, combo_symbol: str, order_size: int, exchange: str = "NSE", mds=None, net: bool = False) -> float:
|
|
782
782
|
"""Return basket margin required for a single-leg or multi-leg combo."""
|
|
783
783
|
try:
|
|
784
784
|
if self.api is None:
|
|
@@ -826,8 +826,8 @@ class Dhan(BrokerBase):
|
|
|
826
826
|
|
|
827
827
|
payload = {
|
|
828
828
|
"dhanClientId": self.api.client_id,
|
|
829
|
-
"includePosition":
|
|
830
|
-
"includeOrder":
|
|
829
|
+
"includePosition": net,
|
|
830
|
+
"includeOrder": net,
|
|
831
831
|
"includeOrders": False,
|
|
832
832
|
"scripList": scripts,
|
|
833
833
|
"scripts": scripts,
|
|
@@ -35,10 +35,65 @@ def _filter_epoch_historical_rows(rows: List["HistoricalData"]) -> List["Histori
|
|
|
35
35
|
return [row for row in rows if getattr(row, "date", None) != dt.datetime(1970, 1, 1)]
|
|
36
36
|
from py5paisa import FivePaisaClient
|
|
37
37
|
|
|
38
|
+
|
|
39
|
+
def _patch_py5paisa_for_mom(api) -> None:
|
|
40
|
+
"""Fix three upstream py5paisa bugs that break multi_order_Margin:
|
|
41
|
+
|
|
42
|
+
1. session is httpx.Client with 5s default timeout. Bump to 10s as a
|
|
43
|
+
safety margin (normal MOM responses are sub-second).
|
|
44
|
+
2. self.payload = GENERIC_PAYLOAD is a reference assignment, so each
|
|
45
|
+
call accumulates stale body keys across calls; the polluted body
|
|
46
|
+
makes the 5paisa server hang past 5s. Reset to a fresh deep copy
|
|
47
|
+
before each MOM call.
|
|
48
|
+
3. order_request() does log_response(res["body"]["Message"]) for the
|
|
49
|
+
MOM branch, but the MOM response has no "Message" key — the KeyError
|
|
50
|
+
is raised at the subscript (not inside log_response), is swallowed
|
|
51
|
+
by the bare except, and the function silently returns None. Inject
|
|
52
|
+
an empty "Message" into the response by wrapping session.post for
|
|
53
|
+
the duration of the MOM call so the subscript succeeds and
|
|
54
|
+
res["body"] is returned normally.
|
|
55
|
+
"""
|
|
56
|
+
if getattr(api, "_tradingapi_patched", False):
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
import copy
|
|
60
|
+
from py5paisa.const import GENERIC_PAYLOAD
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
api.session.timeout = 10
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
original_mom = api.multi_order_Margin
|
|
68
|
+
original_post = api.session.post
|
|
69
|
+
|
|
70
|
+
def post_with_message_key(*args, **kwargs):
|
|
71
|
+
response = original_post(*args, **kwargs)
|
|
72
|
+
try:
|
|
73
|
+
data = response.json()
|
|
74
|
+
if isinstance(data, dict) and isinstance(data.get("body"), dict):
|
|
75
|
+
data["body"].setdefault("Message", "")
|
|
76
|
+
response.json = lambda: data # type: ignore[method-assign]
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
return response
|
|
80
|
+
|
|
81
|
+
def fixed_multi_order_Margin(**order):
|
|
82
|
+
api.payload = copy.deepcopy(GENERIC_PAYLOAD)
|
|
83
|
+
api.session.post = post_with_message_key # type: ignore[method-assign]
|
|
84
|
+
try:
|
|
85
|
+
return original_mom(**order)
|
|
86
|
+
finally:
|
|
87
|
+
api.session.post = original_post # type: ignore[method-assign]
|
|
88
|
+
|
|
89
|
+
api.multi_order_Margin = fixed_multi_order_Margin
|
|
90
|
+
api._tradingapi_patched = True
|
|
91
|
+
|
|
38
92
|
from .broker_base import BrokerBase, Brokers, HistoricalData, Order, OrderInfo, OrderStatus, Price, _normalize_as_of_date
|
|
39
93
|
from .config import get_config
|
|
40
94
|
from .utils import (
|
|
41
95
|
delete_broker_order_id,
|
|
96
|
+
get_price,
|
|
42
97
|
json_serializer_default,
|
|
43
98
|
parse_combo_symbol,
|
|
44
99
|
set_starting_internal_ids_int,
|
|
@@ -47,6 +102,7 @@ from .utils import (
|
|
|
47
102
|
from .exceptions import (
|
|
48
103
|
ConfigurationError,
|
|
49
104
|
DataError,
|
|
105
|
+
MarginError,
|
|
50
106
|
RedisError,
|
|
51
107
|
SymbolError,
|
|
52
108
|
TradingAPIError,
|
|
@@ -840,6 +896,129 @@ class FivePaisa(BrokerBase):
|
|
|
840
896
|
except Exception:
|
|
841
897
|
pass
|
|
842
898
|
|
|
899
|
+
@log_execution_time
|
|
900
|
+
@validate_inputs(
|
|
901
|
+
combo_symbol=lambda x: isinstance(x, str) and len(x.strip()) > 0,
|
|
902
|
+
order_size=lambda x: isinstance(x, (int, float)) and int(x) != 0,
|
|
903
|
+
)
|
|
904
|
+
def get_margin_requirement(self, combo_symbol: str, order_size: int, exchange: str = "NSE", mds=None, net: bool = False) -> float:
|
|
905
|
+
"""Return margin requirement via py5paisa multi_order_Margin API."""
|
|
906
|
+
try:
|
|
907
|
+
if self.api is None:
|
|
908
|
+
raise BrokerConnectionError("FivePaisa API not connected")
|
|
909
|
+
|
|
910
|
+
exch_key = str(exchange or "").upper()[0]
|
|
911
|
+
if exch_key not in self.exchange_mappings:
|
|
912
|
+
raise MarginError(f"Exchange {exchange!r} not loaded in symbol map")
|
|
913
|
+
|
|
914
|
+
symbol_map = self.exchange_mappings[exch_key]["symbol_map"]
|
|
915
|
+
exchange_map = self.exchange_mappings[exch_key]["exchange_map"]
|
|
916
|
+
exchangetype_map = self.exchange_mappings[exch_key]["exchangetype_map"]
|
|
917
|
+
|
|
918
|
+
order_requests = []
|
|
919
|
+
for long_symbol, leg_ratio in parse_combo_symbol(combo_symbol).items():
|
|
920
|
+
net_qty = int(order_size) * int(leg_ratio)
|
|
921
|
+
if net_qty == 0:
|
|
922
|
+
continue
|
|
923
|
+
|
|
924
|
+
scrip_code = symbol_map.get(long_symbol)
|
|
925
|
+
if scrip_code is None:
|
|
926
|
+
raise MarginError(f"ScripCode not found for {long_symbol!r}")
|
|
927
|
+
|
|
928
|
+
exch = exchange_map.get(long_symbol, "N")
|
|
929
|
+
exch_type = exchangetype_map.get(long_symbol, "D")
|
|
930
|
+
order_type = "B" if net_qty > 0 else "S"
|
|
931
|
+
|
|
932
|
+
quote = get_price(self, long_symbol, checks=["bid", "ask"], exchange=exchange, mds=mds)
|
|
933
|
+
price_candidates = [quote.last, quote.ask, quote.bid, quote.prior_close]
|
|
934
|
+
price = next((float(v) for v in price_candidates if not pd.isna(v) and float(v) > 0), 0.0)
|
|
935
|
+
|
|
936
|
+
order_requests.append({
|
|
937
|
+
"Exch": exch,
|
|
938
|
+
"ExchType": exch_type,
|
|
939
|
+
"ScripCode": int(scrip_code),
|
|
940
|
+
"ScripData": "",
|
|
941
|
+
"PlaceModifyCancel": "P",
|
|
942
|
+
"OrderType": order_type,
|
|
943
|
+
"Price": price,
|
|
944
|
+
"Qty": abs(net_qty),
|
|
945
|
+
"IsIntraday": False,
|
|
946
|
+
})
|
|
947
|
+
|
|
948
|
+
if not order_requests:
|
|
949
|
+
raise MarginError(f"No valid legs resolved from combo_symbol={combo_symbol!r}")
|
|
950
|
+
|
|
951
|
+
_patch_py5paisa_for_mom(self.api)
|
|
952
|
+
body = self.api.multi_order_Margin(
|
|
953
|
+
CoverPositions="Y" if net else "N",
|
|
954
|
+
Orders=order_requests,
|
|
955
|
+
)
|
|
956
|
+
if body is None:
|
|
957
|
+
raise MarginError("multi_order_Margin returned None")
|
|
958
|
+
total = body.get("TotalMarginRequired")
|
|
959
|
+
if total is None:
|
|
960
|
+
raise MarginError(f"Unexpected MultiOrderMargin response: {body}")
|
|
961
|
+
if float(total) < 0:
|
|
962
|
+
raise MarginError(f"MultiOrderMargin returned {total} (market may be closed or scrip invalid)")
|
|
963
|
+
|
|
964
|
+
return float(total)
|
|
965
|
+
|
|
966
|
+
except (ValidationError, MarginError):
|
|
967
|
+
raise
|
|
968
|
+
except Exception as e:
|
|
969
|
+
context = create_error_context(
|
|
970
|
+
combo_symbol=combo_symbol,
|
|
971
|
+
order_size=order_size,
|
|
972
|
+
exchange=exchange,
|
|
973
|
+
error=str(e),
|
|
974
|
+
)
|
|
975
|
+
raise MarginError(f"Failed to calculate FivePaisa margin requirement: {str(e)}", context)
|
|
976
|
+
|
|
977
|
+
@log_execution_time
|
|
978
|
+
@validate_inputs(
|
|
979
|
+
combo_symbol=lambda x: isinstance(x, str) and len(x.strip()) > 0,
|
|
980
|
+
order_size=lambda x: isinstance(x, (int, float)) and int(x) != 0,
|
|
981
|
+
)
|
|
982
|
+
def get_margin_requirement_new(self, combo_symbol: str, order_size: int, exchange: str = "NSE", mds=None) -> float:
|
|
983
|
+
"""Return local NSE SPAN margin for a single-leg or multi-leg combo."""
|
|
984
|
+
try:
|
|
985
|
+
if str(exchange or "").upper() not in ("NSE", "N"):
|
|
986
|
+
raise MarginError(f"FivePaisa SPAN margin currently supports NSE only, got exchange={exchange}")
|
|
987
|
+
|
|
988
|
+
from .span import SpanEngine
|
|
989
|
+
|
|
990
|
+
span_cache_dir = os.path.join(config.get("datapath", os.getcwd()), "span_cache")
|
|
991
|
+
engine = SpanEngine(
|
|
992
|
+
download_dir=span_cache_dir,
|
|
993
|
+
lot_size_lookup=lambda symbol: self.get_min_lot_size(symbol, exchange=exchange),
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
for long_symbol, leg_ratio in parse_combo_symbol(combo_symbol).items():
|
|
997
|
+
net_qty = int(order_size) * int(leg_ratio)
|
|
998
|
+
if net_qty == 0:
|
|
999
|
+
continue
|
|
1000
|
+
|
|
1001
|
+
quote = self.get_quote(long_symbol, exchange=exchange)
|
|
1002
|
+
price_candidates = [quote.last, quote.ask, quote.bid, quote.prior_close]
|
|
1003
|
+
price = next((float(v) for v in price_candidates if not pd.isna(v) and float(v) > 0), None)
|
|
1004
|
+
if price is None:
|
|
1005
|
+
raise MarginError(f"No usable market price found for {long_symbol}")
|
|
1006
|
+
|
|
1007
|
+
engine.add_position(long_symbol, net_qty, price)
|
|
1008
|
+
|
|
1009
|
+
return float(engine.calculate_margin()["total_margin"])
|
|
1010
|
+
|
|
1011
|
+
except (ValidationError, MarginError):
|
|
1012
|
+
raise
|
|
1013
|
+
except Exception as e:
|
|
1014
|
+
context = create_error_context(
|
|
1015
|
+
combo_symbol=combo_symbol,
|
|
1016
|
+
order_size=order_size,
|
|
1017
|
+
exchange=exchange,
|
|
1018
|
+
error=str(e),
|
|
1019
|
+
)
|
|
1020
|
+
raise MarginError(f"Failed to calculate FivePaisa margin requirement: {str(e)}", context)
|
|
1021
|
+
|
|
843
1022
|
@log_execution_time
|
|
844
1023
|
@retry_on_error(max_retries=2, delay=1.0, backoff_factor=2.0)
|
|
845
1024
|
def is_connected(self):
|
|
@@ -40,7 +40,7 @@ def _validate_datetime_input(date_input):
|
|
|
40
40
|
|
|
41
41
|
def _filter_epoch_historical_rows(rows: List["HistoricalData"]) -> List["HistoricalData"]:
|
|
42
42
|
return [row for row in rows if getattr(row, "date", None) != dt.datetime(1970, 1, 1)]
|
|
43
|
-
from NorenRestApiPy.NorenApi import NorenApi
|
|
43
|
+
from NorenRestApiPy.NorenApi import NorenApi, position as FlatTradePosition
|
|
44
44
|
|
|
45
45
|
from .broker_base import BrokerBase, Brokers, HistoricalData, Order, OrderInfo, OrderStatus, Price, _normalize_as_of_date
|
|
46
46
|
from .config import get_config
|
|
@@ -52,6 +52,7 @@ from .error_handling import validate_inputs, log_execution_time, retry_on_error
|
|
|
52
52
|
from .exceptions import (
|
|
53
53
|
ConfigurationError,
|
|
54
54
|
DataError,
|
|
55
|
+
MarginError,
|
|
55
56
|
RedisError,
|
|
56
57
|
SymbolError,
|
|
57
58
|
TradingAPIError,
|
|
@@ -1014,6 +1015,86 @@ class FlatTrade(BrokerBase):
|
|
|
1014
1015
|
trading_logger.log_error("Error getting available capital", e, {"broker": self.broker.name})
|
|
1015
1016
|
raise MarketDataError(f"Failed to get available capital: {str(e)}")
|
|
1016
1017
|
|
|
1018
|
+
def get_margin_requirement(self, combo_symbol: str, order_size: int, exchange: str = "NSE", mds=None, net: bool = False) -> float:
|
|
1019
|
+
"""Return margin requirement via FlatTrade ``span_calculator`` (Noren API)."""
|
|
1020
|
+
if self.api is None:
|
|
1021
|
+
raise BrokerConnectionError("FlatTrade API not connected")
|
|
1022
|
+
|
|
1023
|
+
exch_key = str(exchange or "").upper()[0]
|
|
1024
|
+
if exch_key not in self.exchange_mappings:
|
|
1025
|
+
raise MarginError(f"Exchange {exchange!r} not loaded in symbol map")
|
|
1026
|
+
|
|
1027
|
+
exchange_map = self.exchange_mappings[exch_key]["exchange_map"]
|
|
1028
|
+
|
|
1029
|
+
# Build set of index base symbols from _IND___ entries across all exchanges
|
|
1030
|
+
index_bases: set[str] = set()
|
|
1031
|
+
for mapping in self.exchange_mappings.values():
|
|
1032
|
+
for sym in mapping["symbol_map"]:
|
|
1033
|
+
if sym.endswith("_IND___"):
|
|
1034
|
+
index_bases.add(sym.split("_")[0])
|
|
1035
|
+
|
|
1036
|
+
positions = []
|
|
1037
|
+
for long_symbol, leg_ratio in parse_combo_symbol(combo_symbol).items():
|
|
1038
|
+
net_qty = int(order_size) * int(leg_ratio)
|
|
1039
|
+
if net_qty == 0:
|
|
1040
|
+
continue
|
|
1041
|
+
|
|
1042
|
+
parts = long_symbol.split("_")
|
|
1043
|
+
if len(parts) < 5:
|
|
1044
|
+
raise MarginError(f"Cannot parse long_symbol {long_symbol!r} for span_calculator")
|
|
1045
|
+
|
|
1046
|
+
base, inst_type, expiry_str = parts[0], parts[1], parts[2]
|
|
1047
|
+
opt_side, strike = parts[3], parts[4]
|
|
1048
|
+
|
|
1049
|
+
try:
|
|
1050
|
+
dt.datetime.strptime(expiry_str, "%Y%m%d")
|
|
1051
|
+
exd_int = int(expiry_str)
|
|
1052
|
+
except ValueError:
|
|
1053
|
+
raise MarginError(f"Cannot parse expiry date in {long_symbol!r}")
|
|
1054
|
+
|
|
1055
|
+
is_index = base in index_bases
|
|
1056
|
+
if inst_type == "FUT":
|
|
1057
|
+
instname = "FUTIDX" if is_index else "FUTSTK"
|
|
1058
|
+
optt = "XX"
|
|
1059
|
+
strprc_val = -1.0
|
|
1060
|
+
elif inst_type == "OPT":
|
|
1061
|
+
instname = "OPTIDX" if is_index else "OPTSTK"
|
|
1062
|
+
optt = "PE" if opt_side == "PUT" else "CE"
|
|
1063
|
+
strprc_val = float(strike)
|
|
1064
|
+
else:
|
|
1065
|
+
raise MarginError(f"Unknown instrument type {inst_type!r} in {long_symbol!r}")
|
|
1066
|
+
|
|
1067
|
+
exch = exchange_map.get(long_symbol)
|
|
1068
|
+
if exch is None:
|
|
1069
|
+
raise MarginError(f"Exchange not found for {long_symbol!r}")
|
|
1070
|
+
|
|
1071
|
+
pos = FlatTradePosition()
|
|
1072
|
+
pos.prd = "M"
|
|
1073
|
+
pos.exch = exch
|
|
1074
|
+
pos.instname = instname
|
|
1075
|
+
pos.symname = base
|
|
1076
|
+
pos.exd = exd_int
|
|
1077
|
+
pos.optt = optt
|
|
1078
|
+
pos.strprc = strprc_val
|
|
1079
|
+
pos.buyqty = abs(net_qty) if net_qty > 0 else 0
|
|
1080
|
+
pos.sellqty = abs(net_qty) if net_qty < 0 else 0
|
|
1081
|
+
pos.netqty = net_qty
|
|
1082
|
+
positions.append(pos)
|
|
1083
|
+
|
|
1084
|
+
if not positions:
|
|
1085
|
+
raise MarginError(f"No valid legs resolved from combo_symbol={combo_symbol!r}")
|
|
1086
|
+
|
|
1087
|
+
actid = config.get(f"{self.account_key}.USER") or ""
|
|
1088
|
+
result = self.api.span_calculator(actid, positions)
|
|
1089
|
+
|
|
1090
|
+
if result is None or result.get("stat") != "Ok":
|
|
1091
|
+
raise MarginError(f"span_calculator failed: {result}")
|
|
1092
|
+
|
|
1093
|
+
span = float(result.get("span", 0) or 0)
|
|
1094
|
+
expo = float(result.get("expo", 0) or 0)
|
|
1095
|
+
return span + expo
|
|
1096
|
+
|
|
1097
|
+
|
|
1017
1098
|
@log_execution_time
|
|
1018
1099
|
@retry_on_error(max_retries=2, delay=1.0, backoff_factor=2.0)
|
|
1019
1100
|
def disconnect(self):
|