Skip to content

Commit 43ec128

Browse files
authored
Improve/Implement alarms handling (#209)
* Improve/Implement alarms handling get_price_alarms can now list either all alarms or specific alarms for a given ISIN. Alarms can also be written to a csv file. set_price_alarms now allows you to set alarms for an ISIN or for a list of ISINs. The input can be specified through a csv file as well.
1 parent 2d91cc0 commit 43ec128

File tree

3 files changed

+209
-58
lines changed

3 files changed

+209
-58
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ Commands:
7777
events (events_with_documents.json and other_events.json)
7878
portfolio Show current portfolio
7979
details Get details for an ISIN
80-
get_price_alarms Get overview of current price alarms
81-
set_price_alarms Set price alarms based on diff from current price
80+
get_price_alarms Get current price alarms
81+
set_price_alarms Set new price alarms
8282
export_transactions Create a CSV with the deposits and removals ready for importing into Portfolio
8383
Performance
8484
completion Print shell tab completion

pytr/alarms.py

Lines changed: 150 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,38 @@
11
import asyncio
2-
from datetime import datetime
2+
import bisect
3+
import csv
4+
import platform
5+
import sys
6+
from collections import defaultdict
7+
from decimal import Decimal, InvalidOperation
8+
from typing import Any
39

410
from pytr.utils import get_logger, preview
511

612

13+
def alarms_dict_from_alarms_row(isin, alarms, max_values) -> dict[str, Any]:
14+
alarmRow = {
15+
"ISIN": isin,
16+
}
17+
for i in range(1, max_values + 1):
18+
alarmRow[f"alarm{i}"] = alarms[i - 1] if i <= len(alarms) else None
19+
return alarmRow
20+
21+
722
class Alarms:
8-
def __init__(self, tr):
23+
def __init__(self, tr, input=[], fp=None, remove_current_alarms=True):
924
self.tr = tr
25+
self.input = input
26+
self.fp = fp
27+
self.remove_current_alarms = remove_current_alarms
1028
self.log = get_logger(__name__)
29+
self.data = {}
1130

1231
async def alarms_loop(self):
1332
recv = 0
1433
await self.tr.price_alarm_overview()
1534
while True:
16-
_subscription_id, subscription, response = await self.tr.recv()
35+
_, subscription, response = await self.tr.recv()
1736

1837
if subscription["type"] == "priceAlarms":
1938
recv += 1
@@ -24,48 +43,143 @@ async def alarms_loop(self):
2443
if recv == 1:
2544
return
2645

27-
async def ticker_loop(self):
28-
recv = 0
29-
await self.tr.price_alarm_overview()
30-
while True:
31-
_subscription_id, subscription, response = await self.tr.recv()
46+
async def set_alarms(self):
47+
current_alarms = {}
48+
new_alarms = {}
49+
alarms_to_keep = {}
50+
isins = self.data.keys()
3251

33-
if subscription["type"] == "priceAlarms":
34-
recv += 1
35-
self.alarms = response
36-
else:
37-
print(f"unmatched subscription of type '{subscription['type']}':\n{preview(response)}")
52+
if not isins:
53+
print("No instruments given to set alarms for")
54+
return
3855

39-
if recv == 1:
40-
return
56+
for isin in isins:
57+
current_alarms.setdefault(isin, {})
58+
new_alarms.setdefault(isin, [])
59+
alarms_to_keep.setdefault(isin, [])
60+
61+
for a in self.alarms:
62+
if a["instrumentId"] in isins:
63+
current_alarms[a["instrumentId"]][Decimal(a["targetPrice"])] = a["id"]
64+
65+
for isin in isins:
66+
for a in self.data[isin]:
67+
if a in current_alarms[isin]:
68+
alarms_to_keep[isin].append(a)
69+
del current_alarms[isin][a]
70+
else:
71+
new_alarms[isin].append(a)
72+
73+
if not self.remove_current_alarms:
74+
current_alarms.clear()
75+
76+
messages = []
77+
if alarms_to_keep[isin]:
78+
messages.append(f"Keeping {', '.join(str(v) for v in alarms_to_keep[isin])}")
79+
if new_alarms[isin]:
80+
messages.append(f"Adding {', '.join(str(v) for v in new_alarms[isin])}")
81+
if current_alarms[isin]:
82+
messages.append(f"Removing {', '.join(str(v) for v in sorted(current_alarms[isin].keys()))}")
83+
if not messages:
84+
messages.append("Nothing to do.")
85+
86+
print(f"{isin}: {'; '.join(messages)}")
87+
88+
action_count = 0
89+
for isin in isins:
90+
for a in new_alarms[isin]:
91+
await self.tr.create_price_alarm(isin, float(a))
92+
action_count += 1
93+
94+
for a in current_alarms[isin]:
95+
await self.tr.cancel_price_alarm(current_alarms[isin].get(a))
96+
action_count += 1
97+
98+
while action_count > 0:
99+
await self.tr.recv()
100+
action_count -= 1
101+
return
41102

42103
def overview(self):
43-
print("ISIN status created target diff% createdAt triggeredAT")
44-
self.log.debug(f"Processing {len(self.alarms)} alarms")
45-
46-
for a in self.alarms: # sorted(positions, key=lambda x: x['netValue'], reverse=True):
47-
self.log.debug(f" Processing {a} alarm")
48-
ts = int(a["createdAt"]) / 1000.0
49-
target_price = float(a["targetPrice"])
50-
created_price = float(a["createdPrice"])
51-
created = datetime.fromtimestamp(ts).isoformat(sep=" ", timespec="minutes")
52-
if a["triggeredAt"] is None:
53-
triggered = "-"
54-
else:
55-
ts = int(a["triggeredAt"]) / 1000.0
56-
triggered = datetime.fromtimestamp(ts).isoformat(sep=" ", timespec="minutes")
104+
alarms_per_ISIN = defaultdict(list)
105+
isins = self.data.keys()
106+
for a in self.alarms:
107+
if a["status"] != "active":
108+
continue
109+
if isins and a["instrumentId"] not in isins:
110+
continue
111+
bisect.insort(alarms_per_ISIN[a["instrumentId"]], a["targetPrice"])
57112

58-
if a["createdPrice"] == 0:
59-
diffP = 0.0
60-
else:
61-
diffP = (target_price / created_price) * 100 - 100
113+
for isin in isins:
114+
if isin not in alarms_per_ISIN:
115+
alarms_per_ISIN[isin] = []
62116

63-
print(
64-
f"{a['instrumentId']} {a['status']} {created_price:>7.2f} {target_price:>7.2f} "
65-
+ f"{diffP:>5.1f}% {created} {triggered}"
117+
max_values = max(len(v) for v in alarms_per_ISIN.values())
118+
if self.fp == sys.stdout:
119+
print(f"ISIN {' '.join(f'Alarm{i}' for i in range(1, max_values + 1))}")
120+
for isin, alarms in alarms_per_ISIN.items():
121+
print(f"{isin} {' '.join(f'{float(x):>7.2f}' for x in alarms)}")
122+
else:
123+
print(f"Writing alarms to file {self.fp.name}...")
124+
lineterminator = "\n" if platform.system() == "Windows" else "\r\n"
125+
writer = csv.DictWriter(
126+
self.fp,
127+
fieldnames=["ISIN"] + [f"alarm{i}" for i in range(1, max_values + 1)],
128+
delimiter=";",
129+
lineterminator=lineterminator,
130+
)
131+
writer.writeheader()
132+
writer.writerows(
133+
[alarms_dict_from_alarms_row(key, value, max_values) for key, value in alarms_per_ISIN.items()]
66134
)
67135

68136
def get(self):
137+
cur_isin = None
138+
for token in self.input:
139+
if len(token) == 12 and "." not in token:
140+
cur_isin = token
141+
self.data.setdefault(cur_isin, [])
142+
else:
143+
try:
144+
cur_alarm = Decimal(token)
145+
if cur_isin is not None:
146+
bisect.insort(self.data[cur_isin], cur_alarm)
147+
except InvalidOperation:
148+
raise ValueError(f"{token} is no valid ISIN or decimal value that could represent an alarm.")
149+
69150
asyncio.get_event_loop().run_until_complete(self.alarms_loop())
70151

71152
self.overview()
153+
154+
def set(self):
155+
if self.fp == sys.stdin:
156+
cur_isin = None
157+
for token in self.input:
158+
if len(token) == 12 and "." not in token:
159+
cur_isin = token
160+
self.data.setdefault(cur_isin, [])
161+
else:
162+
try:
163+
cur_alarm = Decimal(token)
164+
if cur_isin is not None:
165+
bisect.insort(self.data[cur_isin], cur_alarm)
166+
except InvalidOperation:
167+
raise ValueError(f"{token} is no valid ISIN or decimal value that could represent an alarm.")
168+
else:
169+
lineterminator = "\n" if platform.system() == "Windows" else "\r\n"
170+
reader = csv.DictReader(self.fp, delimiter=";", lineterminator=lineterminator)
171+
fieldnames = reader.fieldnames
172+
fieldnum = len(fieldnames)
173+
for row in list(reader):
174+
isin = row[fieldnames[0]]
175+
self.data.setdefault(isin, [])
176+
for i in range(1, fieldnum):
177+
value = row[fieldnames[i]]
178+
if value is not None and value != "":
179+
bisect.insort(self.data[isin], Decimal(value.replace(",", "")))
180+
181+
# get current alarms
182+
asyncio.get_event_loop().run_until_complete(self.alarms_loop())
183+
184+
# set/remove alarms
185+
asyncio.get_event_loop().run_until_complete(self.set_alarms())

pytr/main.py

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -204,17 +204,27 @@ def formatter(prog):
204204
parser_details.add_argument("isin", help="ISIN of intrument")
205205

206206
# get_price_alarms
207-
info = "Get overview of current price alarms"
208-
parser_cmd.add_parser(
207+
info = "Get current price alarms"
208+
parser_get_price_alarms = parser_cmd.add_parser(
209209
"get_price_alarms",
210210
formatter_class=formatter,
211211
parents=[parser_login_args],
212212
help=info,
213213
description=info,
214214
)
215+
parser_get_price_alarms.add_argument(
216+
"input", nargs="*", help="Input data in the form of <ISIN1> <ISIN2> ...", default=[]
217+
)
218+
parser_get_price_alarms.add_argument(
219+
"--outputfile",
220+
help="Output file path",
221+
type=argparse.FileType("w", encoding="utf-8"),
222+
default="-",
223+
nargs="?",
224+
)
215225

216226
# set_price_alarms
217-
info = "Set price alarms based on diff from current price"
227+
info = "Set new price alarms"
218228
parser_set_price_alarms = parser_cmd.add_parser(
219229
"set_price_alarms",
220230
formatter_class=formatter,
@@ -223,12 +233,20 @@ def formatter(prog):
223233
description=info,
224234
)
225235
parser_set_price_alarms.add_argument(
226-
"-%",
227-
"--percent",
228-
help="Percentage +/-",
229-
metavar="[-1000 ... 1000]",
230-
type=int,
231-
default=-10,
236+
"input", nargs="*", help="Input data in the form of <ISIN> <alarm1> <alarm2> ...", default=[]
237+
)
238+
parser_set_price_alarms.add_argument(
239+
"--remove-current-alarms",
240+
action=argparse.BooleanOptionalAction,
241+
default=True,
242+
help="Whether to remove current alarms.",
243+
)
244+
parser_set_price_alarms.add_argument(
245+
"--inputfile",
246+
help="Input file path",
247+
type=argparse.FileType("r", encoding="utf-8"),
248+
default="-",
249+
nargs="?",
232250
)
233251

234252
# export_transactions
@@ -334,18 +352,37 @@ def main():
334352
format_export=args.export_format,
335353
)
336354
asyncio.get_event_loop().run_until_complete(dl.dl_loop())
337-
elif args.command == "set_price_alarms":
338-
# TODO
339-
print("Not implemented yet")
340355
elif args.command == "get_price_alarms":
341-
Alarms(
342-
login(
343-
phone_no=args.phone_no,
344-
pin=args.pin,
345-
web=not args.applogin,
346-
store_credentials=args.store_credentials,
347-
)
348-
).get()
356+
try:
357+
Alarms(
358+
login(
359+
phone_no=args.phone_no,
360+
pin=args.pin,
361+
web=not args.applogin,
362+
store_credentials=args.store_credentials,
363+
),
364+
args.input,
365+
args.outputfile,
366+
).get()
367+
except ValueError as e:
368+
print(e)
369+
return -1
370+
elif args.command == "set_price_alarms":
371+
try:
372+
Alarms(
373+
login(
374+
phone_no=args.phone_no,
375+
pin=args.pin,
376+
web=not args.applogin,
377+
store_credentials=args.store_credentials,
378+
),
379+
args.input,
380+
args.inputfile,
381+
args.remove_current_alarms,
382+
).set()
383+
except ValueError as e:
384+
print(e)
385+
return -1
349386
elif args.command == "details":
350387
Details(
351388
login(

0 commit comments

Comments
 (0)