finmiti 0.0.1__py3-none-any.whl
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.
- finmiti/__init__.py +12 -0
- finmiti/backtest_handler/__init__.py +2 -0
- finmiti/backtest_handler/base.py +19 -0
- finmiti/clients/__init__.py +2 -0
- finmiti/clients/client_5paisa.py +242 -0
- finmiti/constants.py +133 -0
- finmiti/executioners.py +49 -0
- finmiti/portfolio_handler/__init__.py +2 -0
- finmiti/portfolio_handler/base.py +231 -0
- finmiti/stocks_handler/__init__.py +3 -0
- finmiti/stocks_handler/stock.py +209 -0
- finmiti/stocks_handler/stockdict.py +109 -0
- finmiti/strategy_handler/__init__.py +2 -0
- finmiti/strategy_handler/base.py +36 -0
- finmiti/utils.py +7 -0
- finmiti-0.0.1.dist-info/METADATA +136 -0
- finmiti-0.0.1.dist-info/RECORD +19 -0
- finmiti-0.0.1.dist-info/WHEEL +5 -0
- finmiti-0.0.1.dist-info/top_level.txt +1 -0
finmiti/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
__version__ = version("finmetry")
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
from . import clients
|
|
6
|
+
from .stocks_handler import Stock, StockDict
|
|
7
|
+
from . import constants
|
|
8
|
+
from .strategy_handler import StrategyBase, StgDataLoader
|
|
9
|
+
from .portfolio_handler import Portfolio, Account
|
|
10
|
+
from .backtest_handler import Backtester
|
|
11
|
+
|
|
12
|
+
from .utils import *
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from ..portfolio_handler import Portfolio
|
|
2
|
+
from ..executioners import ExecutionModel
|
|
3
|
+
from ..strategy_handler import StgDataLoader, StrategyBase
|
|
4
|
+
|
|
5
|
+
class Backtester:
|
|
6
|
+
def __init__(self, data_loader: StgDataLoader, strategy: StrategyBase, portfolio: Portfolio):
|
|
7
|
+
self.data_loader = data_loader
|
|
8
|
+
self.strategy = strategy
|
|
9
|
+
self.portfolio = portfolio
|
|
10
|
+
|
|
11
|
+
def run(self):
|
|
12
|
+
for market_data in self.data_loader:
|
|
13
|
+
entry_orders = self.strategy(market_data)
|
|
14
|
+
exit_orders = self.portfolio.get_exit_orders(market_data)
|
|
15
|
+
### keeping exit_orders first to avoid cash going negative
|
|
16
|
+
all_orders = exit_orders + entry_orders
|
|
17
|
+
for order in all_orders:
|
|
18
|
+
self.portfolio.on_order(order)
|
|
19
|
+
self.portfolio.mark_to_market(market_data)
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module contains the objects related to 5paisa client
|
|
3
|
+
|
|
4
|
+
@author: Rathod Darshan
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import py5paisa as p5
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import datetime as dtm
|
|
10
|
+
from typing import Union, TypedDict
|
|
11
|
+
|
|
12
|
+
from ..stocks_handler import Stock, StockDict
|
|
13
|
+
|
|
14
|
+
from ..constants import INTERVAL
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ScripMaster:
|
|
18
|
+
"""ScripMaster contains all the scipts of 5paisa client.
|
|
19
|
+
|
|
20
|
+
To get the name and symbol of any script, this class needs to be accessed. This class just filters the data from single .csv.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, filepath: str = None) -> None:
|
|
24
|
+
"""Initializes the ScripMaster class.
|
|
25
|
+
|
|
26
|
+
Loads the .csv file into data attribute.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
filepath : str, optional
|
|
31
|
+
filepath to .csv file. If filepath is given then it reads the file, else it will download the file. by default None
|
|
32
|
+
"""
|
|
33
|
+
if filepath is not None:
|
|
34
|
+
self.data = pd.read_pickle(filepath)
|
|
35
|
+
else:
|
|
36
|
+
self.data = pd.read_csv("https://images.5paisa.com/website/scripmaster-csv-format.csv")
|
|
37
|
+
|
|
38
|
+
self.data["Expiry"] = pd.to_datetime(self.data["Expiry"], format="%Y-%m-%d %H:%M:%S")
|
|
39
|
+
self.data["Name"] = self.data["Name"].apply(str.upper)
|
|
40
|
+
self.data["Symbol"] = self.data["Name"]
|
|
41
|
+
|
|
42
|
+
def __repr__(self):
|
|
43
|
+
return f"scrip master data"
|
|
44
|
+
|
|
45
|
+
def __call__(self):
|
|
46
|
+
return self.data
|
|
47
|
+
|
|
48
|
+
def save(self, filepath: str) -> None:
|
|
49
|
+
"""saves the scrip master data
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
filepath : str
|
|
54
|
+
filepath with filename with .pkl extention
|
|
55
|
+
|
|
56
|
+
Returns
|
|
57
|
+
-------
|
|
58
|
+
_type_
|
|
59
|
+
None
|
|
60
|
+
"""
|
|
61
|
+
return self.data.to_pickle(filepath)
|
|
62
|
+
|
|
63
|
+
def get_scrip(self, stock: Stock) -> pd.DataFrame:
|
|
64
|
+
"""returns the scrips of the stock
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
stock : Stock
|
|
69
|
+
a stock object
|
|
70
|
+
|
|
71
|
+
Returns
|
|
72
|
+
-------
|
|
73
|
+
pd.DataFrame
|
|
74
|
+
Scrip data of a given stock
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
return stock.scrip
|
|
78
|
+
except:
|
|
79
|
+
pass
|
|
80
|
+
d1 = self.data
|
|
81
|
+
f1 = (d1["Exch"] == stock.exchange) & (d1["ExchType"] == stock.exchange_type) & (d1["Symbol"] == stock.symbol)
|
|
82
|
+
f2 = (d1["Series"] == "EQ") | (d1["Series"] == "XX")
|
|
83
|
+
d2 = d1[f1 & f2]
|
|
84
|
+
if d2.empty:
|
|
85
|
+
raise ValueError(f"No Scrip found for {stock.symbol} in scrip_master")
|
|
86
|
+
d2 = d2.set_index("Name")
|
|
87
|
+
### setting the scrip to stock object
|
|
88
|
+
stock.scrip = d2
|
|
89
|
+
return d2
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Client5paisaCred(TypedDict):
|
|
93
|
+
APP_NAME: str
|
|
94
|
+
APP_SOURCE: str
|
|
95
|
+
USER_ID: str
|
|
96
|
+
PASSWORD: str
|
|
97
|
+
USER_KEY: str
|
|
98
|
+
ENCRYPTION_KEY: str
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Client5paisa(p5.FivePaisaClient):
|
|
102
|
+
def __init__(self, totp: str, mpin: str, client_code: str, cred: Client5paisaCred, scrip_master: ScripMaster = None, **kwargs):
|
|
103
|
+
super().__init__(cred=cred)
|
|
104
|
+
self.get_totp_session(client_code, f"{totp}", mpin)
|
|
105
|
+
|
|
106
|
+
print("downloading the scrip-master")
|
|
107
|
+
|
|
108
|
+
self.scrip_master = ScripMaster() if scrip_master is None else scrip_master
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
def download_historical_data(
|
|
112
|
+
self,
|
|
113
|
+
stock: Stock,
|
|
114
|
+
interval: INTERVAL = INTERVAL.one_day,
|
|
115
|
+
start: Union[str, dtm.datetime] = "2023-01-01",
|
|
116
|
+
end: Union[str, dtm.datetime] = "2023-03-30",
|
|
117
|
+
) -> pd.DataFrame:
|
|
118
|
+
"""Downloads the historical data and saves it to local drive or in Stock.data variable.
|
|
119
|
+
|
|
120
|
+
Parameters
|
|
121
|
+
----------
|
|
122
|
+
stock : Stock
|
|
123
|
+
Stock object
|
|
124
|
+
interval : str, optional
|
|
125
|
+
time interval of data. it should be within [1m,5m,10m,15m,30m,60m,1d], by default "1d"
|
|
126
|
+
start : Union[str, dtm.datetime], optional
|
|
127
|
+
start date of the data. The data for this date will be downloaded, by default "2023-01-01"
|
|
128
|
+
end : Union[str, dtm.datetime], optional
|
|
129
|
+
end date of the data. The data for this date will be downloaded, by default "2023-03-30"
|
|
130
|
+
|
|
131
|
+
Returns
|
|
132
|
+
----------
|
|
133
|
+
pd.DataFrame
|
|
134
|
+
A dataframe containing a historical data.
|
|
135
|
+
|
|
136
|
+
"""
|
|
137
|
+
# scrip = self.scrip_master.get_scrip(stock)
|
|
138
|
+
|
|
139
|
+
# if isinstance(start, dtm.datetime):
|
|
140
|
+
# start = start.strftime("%Y-%m-%d")
|
|
141
|
+
# if isinstance(end, dtm.datetime):
|
|
142
|
+
# end = end.strftime("%Y-%m-%d")
|
|
143
|
+
|
|
144
|
+
# df = self.historical_data(stock.exchange, stock.exchange_type, scrip.loc[stock.symbol, "Scripcode"], interval.value, start, end)
|
|
145
|
+
# df.columns = ["Datetime", "Open", "High", "Low", "Close", "Volume"]
|
|
146
|
+
# df["Datetime"] = pd.to_datetime(df["Datetime"])
|
|
147
|
+
# df = df.set_index("Datetime")
|
|
148
|
+
|
|
149
|
+
# return df
|
|
150
|
+
|
|
151
|
+
scrip = self.scrip_master.get_scrip(stock)
|
|
152
|
+
if isinstance(start, str):
|
|
153
|
+
start_dt = dtm.datetime.strptime(start, "%Y-%m-%d")
|
|
154
|
+
else:
|
|
155
|
+
start_dt = start
|
|
156
|
+
|
|
157
|
+
if isinstance(end, str):
|
|
158
|
+
end_dt = dtm.datetime.strptime(end, "%Y-%m-%d")
|
|
159
|
+
else:
|
|
160
|
+
end_dt = end
|
|
161
|
+
|
|
162
|
+
def _fetch_chunk(s: dtm.datetime, e: dtm.datetime) -> pd.DataFrame:
|
|
163
|
+
df = self.historical_data(stock.exchange, stock.exchange_type, scrip.loc[stock.symbol, "Scripcode"], interval.value, s.strftime("%Y-%m-%d"), e.strftime("%Y-%m-%d"))
|
|
164
|
+
if df is None or df.empty:
|
|
165
|
+
return pd.DataFrame()
|
|
166
|
+
df.columns = ["Datetime", "Open", "High", "Low", "Close", "Volume"]
|
|
167
|
+
df["Datetime"] = pd.to_datetime(df["Datetime"])
|
|
168
|
+
return df.set_index("Datetime")
|
|
169
|
+
|
|
170
|
+
dfs = []
|
|
171
|
+
curr_start = start_dt
|
|
172
|
+
|
|
173
|
+
while curr_start < end_dt:
|
|
174
|
+
curr_end = min(curr_start + dtm.timedelta(days=90), end_dt)
|
|
175
|
+
chunk = _fetch_chunk(curr_start, curr_end)
|
|
176
|
+
if not chunk.empty:
|
|
177
|
+
dfs.append(chunk)
|
|
178
|
+
|
|
179
|
+
curr_start = curr_end
|
|
180
|
+
|
|
181
|
+
if not dfs:
|
|
182
|
+
return pd.DataFrame()
|
|
183
|
+
|
|
184
|
+
df = pd.concat(dfs).sort_index()
|
|
185
|
+
df = df.loc[~df.index.duplicated(keep="first")]
|
|
186
|
+
|
|
187
|
+
return df
|
|
188
|
+
|
|
189
|
+
def get_market_depth(self, stockdict: Union[list[Stock], StockDict]) -> pd.DataFrame:
|
|
190
|
+
"""Gets the market depth for given list of Stocks
|
|
191
|
+
|
|
192
|
+
Parameters
|
|
193
|
+
----------
|
|
194
|
+
StockDict : list[Stock]|StockDict
|
|
195
|
+
list of Stock class instances or StockDict type object.
|
|
196
|
+
|
|
197
|
+
Returns
|
|
198
|
+
-------
|
|
199
|
+
_pd.DataFrame
|
|
200
|
+
Live Market Depth for all the Stocks in list.
|
|
201
|
+
"""
|
|
202
|
+
scrips = pd.concat(self.scrip_master.get_scrip(stock) for stock in stockdict)
|
|
203
|
+
a = scrips.rename(columns={"Exch": "Exchange", "ExchType": "ExchangeType"})[["Exchange", "ExchangeType", "Symbol"]].to_dict(orient="records")
|
|
204
|
+
d1 = pd.DataFrame(self.fetch_market_depth_by_symbol(a)["Data"])
|
|
205
|
+
|
|
206
|
+
d1.set_index("ScripCode", inplace=True)
|
|
207
|
+
d1["Datetime"] = dtm.datetime.now()
|
|
208
|
+
scrips.set_index("Scripcode", inplace=True)
|
|
209
|
+
d1["Symbol"] = scrips["Symbol"]
|
|
210
|
+
d1.set_index("Symbol", inplace=True)
|
|
211
|
+
d1.rename(columns={"Close": "PrevClose"}, inplace=True)
|
|
212
|
+
d1.rename(columns={"LastTradedPrice": "Close"}, inplace=True)
|
|
213
|
+
return d1
|
|
214
|
+
|
|
215
|
+
def update_stock_histdata0_to_ltp(self, stockdict: Union[list[Stock], StockDict]) -> None:
|
|
216
|
+
"""Updates the market depth to stock.hist_data0 attribute
|
|
217
|
+
|
|
218
|
+
Parameters
|
|
219
|
+
----------
|
|
220
|
+
StockDict : list[Stock]|StockDict
|
|
221
|
+
list of Stock class instances or StockDict type object.
|
|
222
|
+
|
|
223
|
+
Returns
|
|
224
|
+
-------
|
|
225
|
+
None
|
|
226
|
+
"""
|
|
227
|
+
d1 = self.get_market_depth(stockdict)
|
|
228
|
+
d1 = d1[["Datetime", "Open", "High", "Low", "Close", "Volume"]]
|
|
229
|
+
d1["Datetime"] = d1["Datetime"].dt.normalize()
|
|
230
|
+
### Convert datatypes of specific columns
|
|
231
|
+
float_cols = ["Open", "High", "Low", "Close"]
|
|
232
|
+
d1[float_cols] = d1[float_cols].astype(float)
|
|
233
|
+
d1["Volume"] = d1["Volume"].astype(int)
|
|
234
|
+
|
|
235
|
+
for s1 in stockdict:
|
|
236
|
+
try:
|
|
237
|
+
d = d1.loc[s1.symbol]
|
|
238
|
+
s1.hist_data0.loc[d.Datetime] = d
|
|
239
|
+
except:
|
|
240
|
+
print(f"Error in {s1.symbol}")
|
|
241
|
+
continue
|
|
242
|
+
return
|
finmiti/constants.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from dataclasses import dataclass, field, asdict
|
|
3
|
+
from typing import TypedDict, Optional, get_type_hints, Dict, Tuple
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EXCHANGE(Enum):
|
|
12
|
+
nse = "N"
|
|
13
|
+
bse = "B"
|
|
14
|
+
mcx = "MCX"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EXCHANGE_TYPE(Enum):
|
|
18
|
+
cash = "C"
|
|
19
|
+
derivative = "D"
|
|
20
|
+
currency = "U"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class INTERVAL(Enum):
|
|
24
|
+
one_day = "1d"
|
|
25
|
+
one_min = "1m"
|
|
26
|
+
five_min = "5m"
|
|
27
|
+
fifteen_min = "15m"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ORDERTYPE(Enum):
|
|
31
|
+
buy = "buy"
|
|
32
|
+
sell = "sell"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Order:
|
|
37
|
+
timestamp: datetime | np.datetime64
|
|
38
|
+
symbol: str
|
|
39
|
+
price: float
|
|
40
|
+
order_type: ORDERTYPE
|
|
41
|
+
### most of the below attributes are for backtesting and evaluation purpose.
|
|
42
|
+
### value_frac decides how much fraction of the total cash goes into this order
|
|
43
|
+
value_frac: float = None
|
|
44
|
+
id: Optional[str] = None
|
|
45
|
+
target: Optional[float] = None
|
|
46
|
+
stop_loss: Optional[float] = None
|
|
47
|
+
hold_uptill: Optional[datetime] = None
|
|
48
|
+
remarks: Optional[str] = None
|
|
49
|
+
### items for handling order fill related noise. These will be handlerd by executioner object. They will fill these values hence they are None initialized.
|
|
50
|
+
fill_price: float = None
|
|
51
|
+
fill_qty: float = None
|
|
52
|
+
fill_timestamp: datetime | np.datetime64 = None
|
|
53
|
+
fill_remarks: Optional[str] = None
|
|
54
|
+
brokerage_cost: float = 0
|
|
55
|
+
total_cost: float = None
|
|
56
|
+
### the account id is for isolating the cash.
|
|
57
|
+
account_idx: int = 0
|
|
58
|
+
|
|
59
|
+
def __post_init__(self):
|
|
60
|
+
if self.id is None:
|
|
61
|
+
self.id = str(uuid.uuid4())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class StockData:
|
|
66
|
+
"""
|
|
67
|
+
Strategy input data (TorchGeometric-style Data object).
|
|
68
|
+
All arrays must be aligned on the last dimension. Here, the data could be only the last value or it could be the array of historical data. The timestamps must match those values as well.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
symbol: str
|
|
72
|
+
### market data entry
|
|
73
|
+
timestamp: datetime | np.datetime64
|
|
74
|
+
open: float
|
|
75
|
+
high: float
|
|
76
|
+
low: float
|
|
77
|
+
close: float
|
|
78
|
+
volume: float
|
|
79
|
+
### anything else you want
|
|
80
|
+
features: Optional[Dict[str, np.ndarray]] = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class DiEdgeData:
|
|
85
|
+
"""Directed edge from one stock to another."""
|
|
86
|
+
|
|
87
|
+
start_node_symbol: str
|
|
88
|
+
end_node_symbol: str
|
|
89
|
+
features: Optional[Dict[str, np.ndarray]] = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class MarketGraphData:
|
|
94
|
+
"""
|
|
95
|
+
Multi-asset market snapshot.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
### the time of the data. The StockData could have historical data upto this timestamp.
|
|
99
|
+
timestamp: datetime | np.datetime64
|
|
100
|
+
### nodes, named after its symbol
|
|
101
|
+
stocks: Dict[str, StockData]
|
|
102
|
+
### edges: (src, dst) --> edge feature vector. from one node to other.
|
|
103
|
+
edges: Optional[DiEdgeData] = None
|
|
104
|
+
### optional global features (VIX, index returns, liquidity, etc.)
|
|
105
|
+
global_features: Optional[Dict[str, np.ndarray]] = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
### Error class
|
|
109
|
+
class StockDataNotAvailableError(Exception):
|
|
110
|
+
"""Raised when stock data is missing for a given timestamp."""
|
|
111
|
+
def __init__(self, timestamp:datetime=None, symbol: str=None):
|
|
112
|
+
message = f"Stock data not available. No data for {symbol} on {timestamp}."
|
|
113
|
+
super().__init__(message)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class NegativeCashError(Exception):
|
|
117
|
+
"""Raised when the cash of an account goes negative."""
|
|
118
|
+
def __init__(self, account_idx:int=None):
|
|
119
|
+
message=f"The cash in an account cannot go negative. Negative cash registered in account - {account_idx}"
|
|
120
|
+
super().__init__(message)
|
|
121
|
+
|
|
122
|
+
class OrderTypeError(Exception):
|
|
123
|
+
"""Raised when the ordertype is out of pre-defined orders"""
|
|
124
|
+
def __init__(self, order: Order=None):
|
|
125
|
+
message=f"The ordertype must be from finmetry.constants.ORDERTYPE. For {order.symbol} on {order.timestamp} got {order.order_type}."
|
|
126
|
+
super().__init__(message)
|
|
127
|
+
|
|
128
|
+
class NotEnoughData(Exception):
|
|
129
|
+
"""Raised when there are not enough historical data for feature computation"""
|
|
130
|
+
def __init__(self, timestamp:datetime=None, symbol: str=None):
|
|
131
|
+
message = f"Not enough data available. for {symbol} on {timestamp}."
|
|
132
|
+
super().__init__(message)
|
|
133
|
+
|
finmiti/executioners.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
from .constants import Order, ORDERTYPE
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ExecutionModel:
|
|
6
|
+
"""ExecutionModel is a simulator of a real market. Slippage, volume limits, liquidity etc. kind of real-market scenarios should be implemented here. This could have also been done in portfolio handler but we apply it here for separating the responsibilities.
|
|
7
|
+
|
|
8
|
+
Thus ExecutionModel introduces real-market noise. And portfolio simply stores the order.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, brokerage_perc: float = 0.0):
|
|
12
|
+
self.brokerage = 0.0
|
|
13
|
+
|
|
14
|
+
def fill_buy_order(self, order: Order, available_cash: float) -> Order:
|
|
15
|
+
total_cost = available_cash * order.value_frac
|
|
16
|
+
### the total cost includes brokerage cost. Thus the amount available to buy is obtained after deducting brokerage from the total cost.
|
|
17
|
+
### the formula is total_cost = net_cost * (1 + self.brokerage), thus
|
|
18
|
+
net_cost = total_cost / (1 + self.brokerage)
|
|
19
|
+
|
|
20
|
+
order.fill_price = order.price
|
|
21
|
+
order.fill_qty = net_cost / order.fill_price
|
|
22
|
+
order.fill_timestamp = order.timestamp
|
|
23
|
+
order.brokerage_cost = total_cost - net_cost
|
|
24
|
+
order.total_cost = total_cost
|
|
25
|
+
order.fill_remarks = "No added noise"
|
|
26
|
+
|
|
27
|
+
return order
|
|
28
|
+
|
|
29
|
+
def fill_sell_order(self, order: Order) -> Order:
|
|
30
|
+
gross_receivable = order.fill_qty * order.price
|
|
31
|
+
net_receivable = gross_receivable / (1 + self.brokerage)
|
|
32
|
+
|
|
33
|
+
order.fill_price = order.price
|
|
34
|
+
order.fill_timestamp = order.timestamp
|
|
35
|
+
order.brokerage_cost = gross_receivable - net_receivable
|
|
36
|
+
order.total_cost = net_receivable
|
|
37
|
+
order.fill_remarks = "No added noise"
|
|
38
|
+
|
|
39
|
+
return order
|
|
40
|
+
|
|
41
|
+
def fill_order(self, order: Order, available_cash: float) -> Order:
|
|
42
|
+
"""
|
|
43
|
+
Naive execution: fill immediately at order.price
|
|
44
|
+
"""
|
|
45
|
+
if order.order_type == ORDERTYPE.buy:
|
|
46
|
+
order = self.fill_buy_order(order=order, available_cash=available_cash)
|
|
47
|
+
elif order.order_type == ORDERTYPE.sell:
|
|
48
|
+
order = self.fill_sell_order(order=order)
|
|
49
|
+
return order
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
from typing import Dict, List, Optional, TypedDict
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from ..executioners import ExecutionModel
|
|
7
|
+
from ..constants import Order, MarketGraphData, ORDERTYPE, NegativeCashError, OrderTypeError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PorfolioSnapshot(TypedDict):
|
|
11
|
+
"""A snapshot of the portfolio at a given Datetime."""
|
|
12
|
+
|
|
13
|
+
timestamp: str | datetime | np.datetime64
|
|
14
|
+
value: float
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Portfolio:
|
|
18
|
+
def __init__(self, executioner: ExecutionModel, starting_cash: float = 100.0, total_accounts: int = 1):
|
|
19
|
+
self.executioner = executioner
|
|
20
|
+
self.total_accounts = total_accounts
|
|
21
|
+
|
|
22
|
+
self.accounts: List[Account] = [Account(account_idx=i, starting_cash=starting_cash / self.total_accounts, executioner=self.executioner) for i in range(total_accounts)]
|
|
23
|
+
self.history: List[PorfolioSnapshot] = []
|
|
24
|
+
|
|
25
|
+
def mark_to_market(self, market_data: MarketGraphData):
|
|
26
|
+
value = 0.0
|
|
27
|
+
for account in self.accounts:
|
|
28
|
+
account.mark_to_market(market_data=market_data)
|
|
29
|
+
account_snapshot = account.account_history[-1]
|
|
30
|
+
value += account_snapshot["cash"] + account_snapshot["holdings_value"]
|
|
31
|
+
self.history.append(PorfolioSnapshot(timestamp=market_data.timestamp, value=value))
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
def on_order(self, order: Order):
|
|
35
|
+
account = self.accounts[order.account_idx]
|
|
36
|
+
account.on_order(order=order)
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
def get_exit_orders(self, market: MarketGraphData) -> List[Order]:
|
|
40
|
+
exit_orders: List[Order] = []
|
|
41
|
+
for account in self.accounts:
|
|
42
|
+
exit_orders += account.get_exit_orders(market=market)
|
|
43
|
+
return exit_orders
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def order_book(self) -> pd.DataFrame:
|
|
47
|
+
d1 = pd.DataFrame()
|
|
48
|
+
for account in self.accounts:
|
|
49
|
+
account_ob = pd.DataFrame(account.order_book)
|
|
50
|
+
d1 = pd.concat([d1, account_ob], ignore_index=True)
|
|
51
|
+
d1["order_type"] = d1["order_type"].apply(lambda x: x.value)
|
|
52
|
+
return d1
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def arranged_order_book(self) -> pd.DataFrame:
|
|
56
|
+
keys = ["symbol", "fill_price", "fill_qty", "fill_timestamp", "total_cost", "account_idx", "hold_uptill", "remarks"]
|
|
57
|
+
d1 = self.order_book
|
|
58
|
+
d2 = d1.groupby("order_type").get_group("buy").set_index("id")[keys].add_prefix("buy_")
|
|
59
|
+
d3 = d1.groupby("order_type").get_group("sell").set_index("id")[keys].add_prefix("sell_")
|
|
60
|
+
d4 = pd.concat([d2, d3], axis=1).sort_values(by="buy_fill_timestamp")
|
|
61
|
+
return d4
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def account_history(self) -> pd.DataFrame:
|
|
65
|
+
return pd.DataFrame(self.history).set_index("timestamp")
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def holdings(self) -> pd.DataFrame:
|
|
69
|
+
d1 = pd.DataFrame()
|
|
70
|
+
for account in self.accounts:
|
|
71
|
+
account_hd = pd.DataFrame(account.holdings.values())
|
|
72
|
+
d1 = pd.concat([d1, account_hd], axis=0, ignore_index=True)
|
|
73
|
+
return d1
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Holding(TypedDict):
|
|
77
|
+
"""A holding entry."""
|
|
78
|
+
|
|
79
|
+
order_id: str
|
|
80
|
+
account_idx: int
|
|
81
|
+
symbol: str
|
|
82
|
+
qty: float
|
|
83
|
+
entry_price: float
|
|
84
|
+
ltp: float
|
|
85
|
+
target: Optional[float] = None
|
|
86
|
+
stop_loss: Optional[float] = None
|
|
87
|
+
holding_start_date: Optional[datetime]
|
|
88
|
+
holding_end_date: Optional[datetime]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class AccountSnapshot(TypedDict):
|
|
92
|
+
"""A snapshot of the portfolio at a given Datetime."""
|
|
93
|
+
|
|
94
|
+
timestamp: str | datetime | np.datetime64
|
|
95
|
+
holdings_value: float
|
|
96
|
+
cash: float
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Account:
|
|
100
|
+
def __init__(self, account_idx: int, executioner: ExecutionModel, starting_cash: float = 100.0) -> None:
|
|
101
|
+
self.executioner = executioner
|
|
102
|
+
self.idx = account_idx
|
|
103
|
+
self.cash = starting_cash
|
|
104
|
+
|
|
105
|
+
### holdings are indexed by its holding_id, which is order_id.
|
|
106
|
+
self.holdings: Dict[str, Holding] = {}
|
|
107
|
+
self.order_book: List[Order] = []
|
|
108
|
+
self.account_history: List[AccountSnapshot] = []
|
|
109
|
+
|
|
110
|
+
def mark_to_market(self, market_data: MarketGraphData):
|
|
111
|
+
"""
|
|
112
|
+
Update the ltp of holdings from the latest market_data
|
|
113
|
+
Also records portfolio value and cash at this point in time.
|
|
114
|
+
"""
|
|
115
|
+
holdings_value = 0.0
|
|
116
|
+
for _, holding in self.holdings.items():
|
|
117
|
+
symbol = holding["symbol"]
|
|
118
|
+
try:
|
|
119
|
+
holding["ltp"] = market_data.stocks[symbol].close
|
|
120
|
+
except KeyError:
|
|
121
|
+
print(f"Cannot update the data for {symbol}, due to missing data on {market_data.timestamp}")
|
|
122
|
+
holdings_value += holding["qty"] * holding["ltp"]
|
|
123
|
+
|
|
124
|
+
self.account_history.append(AccountSnapshot(timestamp=market_data.timestamp, holdings_value=holdings_value, cash=self.cash))
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
def on_order(self, order: Order) -> None:
|
|
128
|
+
order = self.executioner.fill_order(available_cash=self.cash, order=order)
|
|
129
|
+
|
|
130
|
+
qty = order.fill_qty
|
|
131
|
+
price = order.fill_price
|
|
132
|
+
total_cost = order.total_cost
|
|
133
|
+
|
|
134
|
+
if order.order_type == ORDERTYPE.buy:
|
|
135
|
+
qty *= 1
|
|
136
|
+
total_cost *= 1
|
|
137
|
+
elif order.order_type == ORDERTYPE.sell:
|
|
138
|
+
qty *= -1
|
|
139
|
+
total_cost *= -1
|
|
140
|
+
else:
|
|
141
|
+
raise OrderTypeError(order=order)
|
|
142
|
+
|
|
143
|
+
### change the cash available in the portfolio. If it is sell order then total_cost will be negative and thus that amount will be added to self.cash.
|
|
144
|
+
self.cash = self.cash - total_cost
|
|
145
|
+
if self.cash < 0:
|
|
146
|
+
raise NegativeCashError(account_idx=self.idx)
|
|
147
|
+
|
|
148
|
+
### add new holding or edit the holding
|
|
149
|
+
holding = self.holdings.get(order.id, None)
|
|
150
|
+
if holding is None:
|
|
151
|
+
holding = Holding(
|
|
152
|
+
order_id=order.id,
|
|
153
|
+
account_idx=self.idx,
|
|
154
|
+
symbol=order.symbol,
|
|
155
|
+
qty=qty,
|
|
156
|
+
entry_price=price,
|
|
157
|
+
ltp=price,
|
|
158
|
+
target=order.target,
|
|
159
|
+
stop_loss=order.stop_loss,
|
|
160
|
+
holding_start_date=order.timestamp,
|
|
161
|
+
holding_end_date=order.hold_uptill,
|
|
162
|
+
)
|
|
163
|
+
self.holdings[order.id] = holding
|
|
164
|
+
else:
|
|
165
|
+
assert (holding["qty"] + qty) == 0, "If you are trying to exit a position then quantity must match with the order_id. Partial fills shall be applied as separate orders."
|
|
166
|
+
self.holdings.pop(order.id, None)
|
|
167
|
+
|
|
168
|
+
### add order to orderbook
|
|
169
|
+
self.order_book.append(order)
|
|
170
|
+
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
def get_exit_orders(self, market: MarketGraphData) -> List[Order]:
|
|
174
|
+
"""
|
|
175
|
+
Check stops, targets, expiry.
|
|
176
|
+
Emits exit Orders if needed.
|
|
177
|
+
"""
|
|
178
|
+
exit_orders: List[Order] = []
|
|
179
|
+
|
|
180
|
+
for order_id, holding in self.holdings.items():
|
|
181
|
+
try:
|
|
182
|
+
close = market.stocks[holding["symbol"]].close
|
|
183
|
+
low = market.stocks[holding["symbol"]].low
|
|
184
|
+
high = market.stocks[holding["symbol"]].high
|
|
185
|
+
### sometimes the data for any particular stock may be missing on a given timestamp. It will thus hault the backtest. To avoid haulting, bypass it.
|
|
186
|
+
except KeyError:
|
|
187
|
+
print(f"Cannot check for exit orders for {holding['symbol']}, due to missing data on {market.timestamp}")
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
# reason = None
|
|
191
|
+
# if holding["stop_loss"] and close <= holding["stop_loss"]:
|
|
192
|
+
# reason = "stop_loss"
|
|
193
|
+
# # exit_price = holding["stop_loss"]
|
|
194
|
+
# elif holding["target"] and close >= holding["target"]:
|
|
195
|
+
# reason = "target"
|
|
196
|
+
# # exit_price = holding["target"]
|
|
197
|
+
# elif holding["holding_end_date"] and market.timestamp >= holding["holding_end_date"]:
|
|
198
|
+
# reason = "expiry"
|
|
199
|
+
# else:
|
|
200
|
+
# continue
|
|
201
|
+
# exit_price = close
|
|
202
|
+
|
|
203
|
+
reason = None
|
|
204
|
+
if holding["stop_loss"] and low<= holding["stop_loss"]:
|
|
205
|
+
reason = "stop_loss"
|
|
206
|
+
exit_price = holding["stop_loss"]
|
|
207
|
+
elif holding["target"] and high >= holding["target"]:
|
|
208
|
+
reason = "target"
|
|
209
|
+
exit_price = holding["target"]
|
|
210
|
+
elif holding["holding_end_date"] and market.timestamp >= holding["holding_end_date"]:
|
|
211
|
+
reason = "expiry"
|
|
212
|
+
exit_price = close
|
|
213
|
+
else:
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
exit_order_type = ORDERTYPE.sell if holding["qty"] > 0 else ORDERTYPE.buy
|
|
218
|
+
exit_orders.append(
|
|
219
|
+
Order(
|
|
220
|
+
timestamp=market.timestamp,
|
|
221
|
+
symbol=holding["symbol"],
|
|
222
|
+
price=exit_price,
|
|
223
|
+
order_type=exit_order_type,
|
|
224
|
+
fill_qty=abs(holding["qty"]),
|
|
225
|
+
remarks=reason,
|
|
226
|
+
account_idx=self.idx,
|
|
227
|
+
id=order_id,
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
return exit_orders
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Module consisting of base class of stock"""
|
|
2
|
+
|
|
3
|
+
import os as _os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import datetime as _dtm
|
|
6
|
+
import numpy as _np
|
|
7
|
+
import pandas as _pd
|
|
8
|
+
|
|
9
|
+
from typing import TypeVar, Union, Optional
|
|
10
|
+
|
|
11
|
+
from enum import Enum
|
|
12
|
+
|
|
13
|
+
import duckdb
|
|
14
|
+
|
|
15
|
+
from ..constants import EXCHANGE, EXCHANGE_TYPE, INTERVAL
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def append_it(data: _pd.DataFrame, filepath: str) -> None:
|
|
19
|
+
"""Appends the data on the given filepath after comparing Indexes of both the data.
|
|
20
|
+
|
|
21
|
+
This compares the data already at the given filepath, and then appends only the data not already present.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
data : _pd.DataFrame
|
|
26
|
+
data frame with Datetime like index
|
|
27
|
+
filepath : str
|
|
28
|
+
filepath, where the dataframe will be appended.
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
df1 = data.combine_first(_pd.read_parquet(filepath)).sort_index()
|
|
32
|
+
df1.to_parquet(filepath)
|
|
33
|
+
except FileNotFoundError as e:
|
|
34
|
+
print(f"Creating the file - {filepath}")
|
|
35
|
+
data.to_parquet(filepath)
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Stock:
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
symbol: str,
|
|
43
|
+
exchange: EXCHANGE = EXCHANGE.nse,
|
|
44
|
+
exchange_type: EXCHANGE_TYPE = EXCHANGE_TYPE.cash,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Manages the data for list of stocks.
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
----------
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
symbol : str
|
|
53
|
+
Stock symbol as available online.
|
|
54
|
+
exchange : EXCHANGE
|
|
55
|
+
Stock Exchange. can be N, B, M for Nifty, BSE and MCX respectively. By default N
|
|
56
|
+
exchange_type : EXCHANGE_TYPE
|
|
57
|
+
Type of Stock Exchange. can be C, D or U for Cash, Derivative or Currency respectively. By default C.
|
|
58
|
+
"""
|
|
59
|
+
self.symbol = symbol
|
|
60
|
+
if isinstance(exchange, EXCHANGE):
|
|
61
|
+
self.exchange = exchange.value
|
|
62
|
+
else:
|
|
63
|
+
raise ValueError("exchange can only be of type EXCHANGE enum")
|
|
64
|
+
|
|
65
|
+
if isinstance(exchange_type, EXCHANGE_TYPE):
|
|
66
|
+
self.exchange_type = exchange_type.value
|
|
67
|
+
else:
|
|
68
|
+
raise ValueError("exchange_type can only be of type EXCHANGE_TYPE enum")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
self.hist_data0: Optional[_pd.DataFrame] = None
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def foldname(self):
|
|
75
|
+
return self.exchange + "_" + self.exchange_type + "_" + self.symbol
|
|
76
|
+
|
|
77
|
+
def __repr__(self):
|
|
78
|
+
return f"{self.symbol} stock class"
|
|
79
|
+
|
|
80
|
+
def get_filename(self, date: _dtm.datetime, interval: INTERVAL):
|
|
81
|
+
return f"{date.year}{str(date.month).zfill(2)}_{interval.value}.parquet"
|
|
82
|
+
|
|
83
|
+
def save_historical_data(
|
|
84
|
+
self,
|
|
85
|
+
data: _pd.DataFrame,
|
|
86
|
+
interval: INTERVAL,
|
|
87
|
+
local_data_foldpath: str,
|
|
88
|
+
overwrite: bool = False,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""saves the historical stock data.
|
|
91
|
+
|
|
92
|
+
Multiple files, each for saparate month data is created.
|
|
93
|
+
|
|
94
|
+
Parameters
|
|
95
|
+
----------
|
|
96
|
+
data : _pd.DataFrame
|
|
97
|
+
Data to be saved. It should be indexed with Datetime values.
|
|
98
|
+
interval : str
|
|
99
|
+
time interval of the data
|
|
100
|
+
local_data_foldpath : str
|
|
101
|
+
path to folder where the data will be stored. Inside this folder, multiple folders of individual stocks are created and inside that stock folder, historical data and other data is stored.
|
|
102
|
+
overwrite : bool, False
|
|
103
|
+
wheather to overwrite the existing file or just append the new data. default False. If True then it will overwrite the present data.
|
|
104
|
+
"""
|
|
105
|
+
### creating the folder
|
|
106
|
+
data_foldpath = Path(local_data_foldpath) / self.foldname
|
|
107
|
+
Path(data_foldpath).mkdir(exist_ok=True)
|
|
108
|
+
|
|
109
|
+
### writing the data
|
|
110
|
+
data["filename"] = data.index.to_series().apply(lambda x: self.get_filename(x, interval))
|
|
111
|
+
for fnm, df in data.groupby("filename"):
|
|
112
|
+
df = df.drop(columns="filename")
|
|
113
|
+
filepath = data_foldpath / fnm
|
|
114
|
+
|
|
115
|
+
if overwrite:
|
|
116
|
+
print("Overwriting:-", filepath)
|
|
117
|
+
df.to_parquet(filepath)
|
|
118
|
+
else:
|
|
119
|
+
append_it(df, filepath)
|
|
120
|
+
pass
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
def load_historical_data(
|
|
124
|
+
self,
|
|
125
|
+
start: Union[str, _dtm.datetime],
|
|
126
|
+
end: Union[str, _dtm.datetime],
|
|
127
|
+
local_data_foldpath: str,
|
|
128
|
+
interval: INTERVAL = INTERVAL.one_day,
|
|
129
|
+
fill_holidays: bool = False,
|
|
130
|
+
remove_weekends: bool = True,
|
|
131
|
+
) -> _pd.DataFrame:
|
|
132
|
+
"""Loads the data from local_directory
|
|
133
|
+
|
|
134
|
+
Parameters
|
|
135
|
+
----------
|
|
136
|
+
start : Union[str, _dtm.datetime]
|
|
137
|
+
start date of the data. The data for this date will be downloaded
|
|
138
|
+
end : Union[str, _dtm.datetime]
|
|
139
|
+
end date of the data. The data for this date will be downloaded
|
|
140
|
+
local_data_foldpath : str
|
|
141
|
+
path to folder where the data is stored. inside this folder there are multiple sub-folders of individual stock. You only have to give the parent folder path.
|
|
142
|
+
interval : INTERVAL, Optional
|
|
143
|
+
time interval of data. it should be of type INTERVAL enum. Defaults to one day interval
|
|
144
|
+
fill_holidays : bool, Default is False
|
|
145
|
+
The data is not available for the holidays. If this is made True then the previous day data will be filled in as that day's data and that missing holiday row will be inserted.
|
|
146
|
+
remove_weekends : bool, Default is True
|
|
147
|
+
Sometimes the markets are open on saturdays and sundays. These are vary rare and thus are removed from historical data while loading.
|
|
148
|
+
|
|
149
|
+
Returns
|
|
150
|
+
-------
|
|
151
|
+
_pd.DataFrame
|
|
152
|
+
data
|
|
153
|
+
|
|
154
|
+
Raises
|
|
155
|
+
------
|
|
156
|
+
ValueError
|
|
157
|
+
if no data is found
|
|
158
|
+
"""
|
|
159
|
+
data_foldpath = Path(local_data_foldpath) / self.foldname
|
|
160
|
+
Path(data_foldpath).mkdir(exist_ok=True)
|
|
161
|
+
|
|
162
|
+
if isinstance(start, _dtm.datetime):
|
|
163
|
+
start = start.strftime("%Y-%m-%d")
|
|
164
|
+
if isinstance(end, _dtm.datetime):
|
|
165
|
+
end = end.strftime("%Y-%m-%d")
|
|
166
|
+
|
|
167
|
+
# DuckDB SQL
|
|
168
|
+
query = f"""
|
|
169
|
+
SELECT *
|
|
170
|
+
FROM read_parquet('{data_foldpath}/*_{interval.value}.parquet')
|
|
171
|
+
WHERE Datetime BETWEEN TIMESTAMP '{start}' AND TIMESTAMP '{end}'
|
|
172
|
+
ORDER BY Datetime
|
|
173
|
+
"""
|
|
174
|
+
with duckdb.connect() as con:
|
|
175
|
+
df = con.execute(query).df()
|
|
176
|
+
d1 = df.set_index("Datetime")
|
|
177
|
+
if fill_holidays:
|
|
178
|
+
# Create full business day range
|
|
179
|
+
full_range = _pd.date_range(start=d1.index.min(), end=d1.index.max(), freq="B")
|
|
180
|
+
d1 = d1.reindex(full_range).ffill()
|
|
181
|
+
d1.index.name = "Datetime"
|
|
182
|
+
if remove_weekends:
|
|
183
|
+
d1 = d1[d1.index.weekday < 5]
|
|
184
|
+
return d1
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def scrip(self) -> _pd.DataFrame:
|
|
188
|
+
"""scrip for client 5paisa.
|
|
189
|
+
|
|
190
|
+
Returns
|
|
191
|
+
-------
|
|
192
|
+
_pd.DataFrame
|
|
193
|
+
scrip data.
|
|
194
|
+
"""
|
|
195
|
+
return self._scrip
|
|
196
|
+
|
|
197
|
+
@scrip.setter
|
|
198
|
+
def scrip(self, data: _pd.DataFrame) -> None:
|
|
199
|
+
"""saves the scrip
|
|
200
|
+
|
|
201
|
+
Parameters
|
|
202
|
+
----------
|
|
203
|
+
data : _pd.DataFrame
|
|
204
|
+
scrip data. This can be optained by ScripMaster.get_scrip() method.
|
|
205
|
+
"""
|
|
206
|
+
self._scrip = data
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
|
|
6
|
+
from .stock import Stock
|
|
7
|
+
import duckdb
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StockDict:
|
|
11
|
+
"""Class to handle functionalities related to multiple stocks, using symbol as keys."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, stocks: list[Stock]=None) -> None:
|
|
14
|
+
self.stockdict: dict[str, Stock] = {}
|
|
15
|
+
if stocks is not None:
|
|
16
|
+
for stock in stocks:
|
|
17
|
+
self.add(stock)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def __repr__(self) -> str:
|
|
22
|
+
return f"StockList object with {len(self.stockdict)} Stocks"
|
|
23
|
+
|
|
24
|
+
def __type__(self) -> str:
|
|
25
|
+
return "StockDict"
|
|
26
|
+
|
|
27
|
+
def __getitem__(self, i: str|int):
|
|
28
|
+
if isinstance(i, str):
|
|
29
|
+
return self.stockdict[i]
|
|
30
|
+
elif isinstance(i, int):
|
|
31
|
+
return list(self.stockdict.values())[i]
|
|
32
|
+
else:
|
|
33
|
+
raise ValueError("can only give string or integer values")
|
|
34
|
+
|
|
35
|
+
def __len__(self):
|
|
36
|
+
return len(self.stockdict)
|
|
37
|
+
|
|
38
|
+
def __iter__(self):
|
|
39
|
+
return iter(self.stockdict.values())
|
|
40
|
+
|
|
41
|
+
def __contains__(self, symbol: str) -> bool:
|
|
42
|
+
return symbol in self.stockdict
|
|
43
|
+
|
|
44
|
+
def add(self, stock: Stock) -> None:
|
|
45
|
+
"""Adds or replaces a Stock object using its ticker symbol."""
|
|
46
|
+
self.stockdict[stock.symbol] = stock
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
def remove(self, symbol: str) -> None:
|
|
50
|
+
"""Removes a stock by its symbol, if it exists."""
|
|
51
|
+
self.stockdict.pop(symbol, None)
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
def get(self, symbol: str) -> Stock | None:
|
|
55
|
+
return self.stockdict.get(symbol)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def symbols(self) -> list[str]:
|
|
59
|
+
return list(self.stockdict.keys())
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def stocklist(self) -> list[Stock]:
|
|
63
|
+
return list(self.stockdict.values())
|
|
64
|
+
|
|
65
|
+
def load_multiple_stocks(self, symbols: list[str], *args, **kwargs) -> None:
|
|
66
|
+
"""Loads multiple stocks using their symbols and adds them to the stockdict."""
|
|
67
|
+
for symbol in symbols:
|
|
68
|
+
try:
|
|
69
|
+
stock = Stock(symbol, *args, **kwargs)
|
|
70
|
+
self.add(stock)
|
|
71
|
+
except Exception as e:
|
|
72
|
+
print(f"[{symbol}] Error loading stock:\n{e}")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
def apply(self, method_name: str, *args, **kwargs) -> dict[str, any]:
|
|
76
|
+
"""Applies a method to all Stock objects and returns a dict of results."""
|
|
77
|
+
results = {}
|
|
78
|
+
for symbol, stock in self.stockdict.items():
|
|
79
|
+
method = getattr(stock, method_name, None)
|
|
80
|
+
if callable(method):
|
|
81
|
+
results[symbol] = method(*args, **kwargs)
|
|
82
|
+
else:
|
|
83
|
+
results[symbol] = None
|
|
84
|
+
return results
|
|
85
|
+
|
|
86
|
+
def load_historical_data(self, *args, max_workers: int = 5, remove_error_stocks: bool = True, print_info: bool = True, **kwargs) -> None:
|
|
87
|
+
"""Calls stock.load_historical_data() for all stocks using multithreading."""
|
|
88
|
+
|
|
89
|
+
def _load(stock: Stock):
|
|
90
|
+
try:
|
|
91
|
+
if print_info:
|
|
92
|
+
print(f"[{stock.symbol}] Loading historical data...")
|
|
93
|
+
stock.hist_data0 = stock.load_historical_data(*args, **kwargs)
|
|
94
|
+
stock.hist_data0 = stock.hist_data0[~stock.hist_data0.index.duplicated(keep="first")] # removing duplicate timestamps
|
|
95
|
+
if print_info:
|
|
96
|
+
print(f"[{stock.symbol}] Data loaded successfully.")
|
|
97
|
+
except Exception as e:
|
|
98
|
+
print(f"[{stock.symbol}] Error loading data:\n{e}")
|
|
99
|
+
errors.append(stock.symbol)
|
|
100
|
+
|
|
101
|
+
errors = []
|
|
102
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
103
|
+
executor.map(_load, self.stockdict.values())
|
|
104
|
+
if remove_error_stocks:
|
|
105
|
+
for symbol in errors:
|
|
106
|
+
print(f"[{symbol}] Removing stock due to loading error.")
|
|
107
|
+
self.remove(symbol)
|
|
108
|
+
|
|
109
|
+
return
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import List, Iterator
|
|
3
|
+
import datetime as dtm
|
|
4
|
+
|
|
5
|
+
from ..stocks_handler import StockDict
|
|
6
|
+
from ..constants import Order
|
|
7
|
+
from ..constants import MarketGraphData, StockData
|
|
8
|
+
from ..utils import str_to_dtm
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StgDataLoader(ABC):
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def __getitem__(self, idx: str | dtm.datetime) -> MarketGraphData:
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def __len__(self) -> int:
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def __iter__(self) -> Iterator[MarketGraphData]:
|
|
22
|
+
raise NotImplementedError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class StrategyBase(ABC):
|
|
26
|
+
def __call__(self, data: MarketGraphData) -> List[Order]:
|
|
27
|
+
orders = self.forward(data)
|
|
28
|
+
|
|
29
|
+
if not isinstance(orders, list):
|
|
30
|
+
raise TypeError("Strategy.forward must return List[Order]")
|
|
31
|
+
|
|
32
|
+
return orders
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def forward(self, data: MarketGraphData) -> List[Order]:
|
|
36
|
+
raise NotImplementedError
|
finmiti/utils.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: finmiti
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Persnal project for stock market data analysis
|
|
5
|
+
Author-email: Darshan Rathod <darshan.rathod1994@gmail.com>
|
|
6
|
+
Maintainer-email: Darshan Rathod <darshan.rathod1994@gmail.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://dev-ddr.github.io/finmiti/
|
|
9
|
+
Project-URL: Repository, https://github.com/dev-ddr/finmiti
|
|
10
|
+
Project-URL: Issues, https://github.com/dev-ddr/finmiti/issues
|
|
11
|
+
Keywords: finance,stock-market,quant,research,personal
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: numpy==2.3.5
|
|
18
|
+
Requires-Dist: pandas==2.3.3
|
|
19
|
+
Requires-Dist: pyyaml==6.0.3
|
|
20
|
+
Requires-Dist: duckdb==1.4.2
|
|
21
|
+
Requires-Dist: pyarrow==22.0.0
|
|
22
|
+
Requires-Dist: py5paisa==0.7.21.2
|
|
23
|
+
Requires-Dist: pydantic
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest; extra == "dev"
|
|
26
|
+
Requires-Dist: ruff; extra == "dev"
|
|
27
|
+
Requires-Dist: jupyter; extra == "dev"
|
|
28
|
+
Requires-Dist: notebook; extra == "dev"
|
|
29
|
+
Requires-Dist: sphinx; extra == "dev"
|
|
30
|
+
Requires-Dist: mkdocs; extra == "dev"
|
|
31
|
+
Requires-Dist: mkdocs-material; extra == "dev"
|
|
32
|
+
Requires-Dist: mkdocstrings[python]; extra == "dev"
|
|
33
|
+
Requires-Dist: nbconvert; extra == "dev"
|
|
34
|
+
Requires-Dist: matplotlib; extra == "dev"
|
|
35
|
+
Requires-Dist: plotly; extra == "dev"
|
|
36
|
+
|
|
37
|
+
# Finmiti
|
|
38
|
+
|
|
39
|
+
**This project is developed for my personal use.** I am developing this to keep the documentation, architecture and pipeline consistent so that I can focus more on developing strategies instead of developing pipelines.
|
|
40
|
+
|
|
41
|
+
Visit [Finmiti](https://dev-ddr.github.io/finmetry/) guide for further steps.
|
|
42
|
+
|
|
43
|
+
> This project is solely developed for my personal use. I am publishing this only to keep myself updated and to remove the headache of setting up the framework again and again.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
**Finmiti is a research-first quantitative trading framework** designed to keep
|
|
48
|
+
strategy logic, execution logic, and accounting logic strictly separated.
|
|
49
|
+
|
|
50
|
+
It exists to eliminate repeated reinvention of trading pipelines, so you can
|
|
51
|
+
focus on **researching strategies**, not rebuilding infrastructure.
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
## What Finmiti Is (and Is Not)
|
|
55
|
+
|
|
56
|
+
Finmiti is:
|
|
57
|
+
|
|
58
|
+
- a framework for systematic trading research
|
|
59
|
+
- equally suited for backtesting and live trading
|
|
60
|
+
- opinionated by design
|
|
61
|
+
- built around explicit, auditable abstractions
|
|
62
|
+
|
|
63
|
+
Finmiti is **not**:
|
|
64
|
+
|
|
65
|
+
- a strategy library
|
|
66
|
+
- a signal generator
|
|
67
|
+
- a black-box trading system
|
|
68
|
+
|
|
69
|
+
If you want flexibility at the cost of correctness, this framework will feel restrictive.
|
|
70
|
+
That restriction is intentional.
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
## The Core Trading Loop
|
|
74
|
+
|
|
75
|
+
Every strategy in finmiti follows the same explicit loop:
|
|
76
|
+
|
|
77
|
+
```text
|
|
78
|
+
Market Data → Strategy → Orders → Portfolio → Execution → Accounting
|
|
79
|
+
````
|
|
80
|
+
|
|
81
|
+
Each stage is implemented as a **separate module** with strict responsibilities.
|
|
82
|
+
|
|
83
|
+
This guarantees that:
|
|
84
|
+
|
|
85
|
+
* strategies remain stateless
|
|
86
|
+
* execution assumptions are explicit
|
|
87
|
+
* accounting is consistent
|
|
88
|
+
* backtests can be trusted
|
|
89
|
+
* live trading reuses the same abstractions
|
|
90
|
+
|
|
91
|
+
## Major Modules
|
|
92
|
+
|
|
93
|
+
Finmiti is organized into the following conceptual modules:
|
|
94
|
+
|
|
95
|
+
### [Client Handling](https://dev-ddr.github.io/finmetry/concepts/client_handling_module/)
|
|
96
|
+
|
|
97
|
+
Handles interaction with external systems such as broker APIs and live data feeds.
|
|
98
|
+
Keeps the rest of the framework broker-agnostic.
|
|
99
|
+
|
|
100
|
+
### [Stocks](https://dev-ddr.github.io/finmetry/concepts/stocks_handling_module/)
|
|
101
|
+
|
|
102
|
+
Manages symbols, historical data, and OHLCV storage.
|
|
103
|
+
Acts as the foundation for all market data access.
|
|
104
|
+
|
|
105
|
+
### [Strategy](https://dev-ddr.github.io/finmetry/concepts/strategy_handling_module/)
|
|
106
|
+
|
|
107
|
+
Consumes immutable market snapshots and emits **order intent only**.
|
|
108
|
+
Strategies never manage cash, positions, or execution details.
|
|
109
|
+
|
|
110
|
+
### [Orders](https://dev-ddr.github.io/finmetry/concepts/order/)
|
|
111
|
+
|
|
112
|
+
Orders are the contract between strategy, portfolio, and execution.
|
|
113
|
+
They represent intent, not outcome.
|
|
114
|
+
|
|
115
|
+
### [Executioners](https://dev-ddr.github.io/finmetry/concepts/executioners/)
|
|
116
|
+
|
|
117
|
+
Simulate (or connect to) market reality:
|
|
118
|
+
slippage, brokerage, partial fills, or live execution.
|
|
119
|
+
|
|
120
|
+
### [Portfolio](https://dev-ddr.github.io/finmetry/concepts/portfolio_handling_module/)
|
|
121
|
+
|
|
122
|
+
The single source of truth for positions, cash, and PnL.
|
|
123
|
+
All state mutation happens here.
|
|
124
|
+
|
|
125
|
+
### [Backtesting](https://dev-ddr.github.io/finmetry/concepts/backtesting_handling_module/)
|
|
126
|
+
|
|
127
|
+
Pure orchestration.
|
|
128
|
+
Iterates over time and wires everything together without adding logic.
|
|
129
|
+
|
|
130
|
+
## Final Note
|
|
131
|
+
|
|
132
|
+
> I have built this for my personal use in mind.
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
finmiti/__init__.py,sha256=yyUQrRvmxjIo9Zc2zAFjv-VddqMDjg5md9zYB12uq4c,349
|
|
2
|
+
finmiti/constants.py,sha256=d7MwCQ1w2FhH_nYfwWu11FrL_urECIkH0MaeRGOMPt8,4143
|
|
3
|
+
finmiti/executioners.py,sha256=NCDlL-YgRIYWkR5OusFD7GWAqWgcn7MFl8ptOP3637c,2088
|
|
4
|
+
finmiti/utils.py,sha256=LhBtCJuPTqXEmz1eARrNhNFUjD8K5TD78-lyAB7mLOA,214
|
|
5
|
+
finmiti/backtest_handler/__init__.py,sha256=9ewjO3EbFqBpOx42fHZgH_w1FFvkXSjsmuFalsQSiKQ,29
|
|
6
|
+
finmiti/backtest_handler/base.py,sha256=bCxMNm-UVuUbUjPtqksiBNuiuFZLQzCyYRsCIZN1ADM,818
|
|
7
|
+
finmiti/clients/__init__.py,sha256=lw_3EbWD7lgWB3_-A88reMb3MNrOQJtCI0CI_W8PwJw,28
|
|
8
|
+
finmiti/clients/client_5paisa.py,sha256=Nzkld-T24qKX0ja5lx3hkh88XEGmCWTJfeC7U5HP8bA,8084
|
|
9
|
+
finmiti/portfolio_handler/__init__.py,sha256=lo09jpUvIurBeKii8KOL3rhRyJBmo3dZC4J-XHzD1D0,38
|
|
10
|
+
finmiti/portfolio_handler/base.py,sha256=MZhesSyPTcH_UVaSqzpL_Nlfoik9xpz6rGn3SJGQtO4,8794
|
|
11
|
+
finmiti/stocks_handler/__init__.py,sha256=XtfClx4hgqQsxNJsgCS4vUC1MhdMfmBfezRZC1IEHeI,58
|
|
12
|
+
finmiti/stocks_handler/stock.py,sha256=5ijFo0NNnp8XlLrnv-2413ek0p30eL-NKqKf6PzBjVI,7087
|
|
13
|
+
finmiti/stocks_handler/stockdict.py,sha256=BhjaT_DRYRLh3OHA9Nga8Ga2-w1gkRDPiit560Tx4mA,3796
|
|
14
|
+
finmiti/strategy_handler/__init__.py,sha256=-xdmRKzfBh0DFk7EshRjSAvv0rCwcfBGXa1KxH52Qys,46
|
|
15
|
+
finmiti/strategy_handler/base.py,sha256=WuPaHSPebtr_JqFNm5MBnv2eB7jy0zQhWvKJs4GDoUI,981
|
|
16
|
+
finmiti-0.0.1.dist-info/METADATA,sha256=_e-ZgOUBh1UVBI2O0589i8fU8-RMcjUuaULB6rl15P8,4528
|
|
17
|
+
finmiti-0.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
18
|
+
finmiti-0.0.1.dist-info/top_level.txt,sha256=ufoAcQ5Q6CpgwLLsoR_wah292RWfdiIBshfpZBTY_Nc,8
|
|
19
|
+
finmiti-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
finmiti
|