degiro-cli 0.1.1__tar.gz → 0.1.3__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.
- {degiro-cli-0.1.1/degiro_cli.egg-info → degiro_cli-0.1.3}/PKG-INFO +22 -6
- {degiro-cli-0.1.1 → degiro_cli-0.1.3/degiro_cli.egg-info}/PKG-INFO +22 -6
- degiro_cli-0.1.3/degiro_cli.egg-info/SOURCES.txt +14 -0
- degiro_cli-0.1.3/degiro_cli.egg-info/entry_points.txt +4 -0
- degiro_cli-0.1.3/degiro_cli.egg-info/requires.txt +14 -0
- degiro_cli-0.1.3/degiro_cli.egg-info/top_level.txt +1 -0
- degiro_cli-0.1.3/degirocli/helpers.py +61 -0
- degiro_cli-0.1.3/degirocli/history.py +200 -0
- degiro_cli-0.1.3/degirocli/login.py +177 -0
- degiro_cli-0.1.3/degirocli/search.py +174 -0
- degiro_cli-0.1.3/pyproject.toml +59 -0
- degiro-cli-0.1.1/bin/degiro-history +0 -8
- degiro-cli-0.1.1/bin/degiro-login +0 -8
- degiro-cli-0.1.1/bin/degiro-portfolio +0 -0
- degiro-cli-0.1.1/bin/degiro-search +0 -8
- degiro-cli-0.1.1/degiro_cli.egg-info/SOURCES.txt +0 -14
- degiro-cli-0.1.1/degiro_cli.egg-info/requires.txt +0 -9
- degiro-cli-0.1.1/degiro_cli.egg-info/top_level.txt +0 -1
- degiro-cli-0.1.1/pyproject.toml +0 -4
- degiro-cli-0.1.1/setup.py +0 -67
- {degiro-cli-0.1.1 → degiro_cli-0.1.3}/LICENSE +0 -0
- {degiro-cli-0.1.1 → degiro_cli-0.1.3}/README.md +0 -0
- {degiro-cli-0.1.1 → degiro_cli-0.1.3}/degiro_cli.egg-info/dependency_links.txt +0 -0
- /degiro-cli-0.1.1/bin/degiro-logout → /degiro_cli-0.1.3/degirocli/__init__.py +0 -0
- {degiro-cli-0.1.1 → degiro_cli-0.1.3}/setup.cfg +0 -0
|
@@ -1,19 +1,35 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: degiro-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Unofficial command line tools for Degiro
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
Author-email: ohmajesticlama <ohmajesticlama@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/OhMajesticLama/degiro-cli
|
|
7
8
|
Project-URL: Documentation, https://ohmajesticlama.github.io/degiro-cli
|
|
9
|
+
Keywords: degiro,cli,finance,investment
|
|
8
10
|
Classifier: Programming Language :: Python :: 3
|
|
9
11
|
Classifier: License :: OSI Approved :: MIT License
|
|
10
12
|
Classifier: Operating System :: OS Independent
|
|
11
|
-
Classifier: Development Status ::
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
14
|
Classifier: Intended Audience :: End Users/Desktop
|
|
13
15
|
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
16
|
+
Requires-Python: >=3.8
|
|
14
17
|
Description-Content-Type: text/markdown
|
|
15
|
-
Provides-Extra: dev
|
|
16
18
|
License-File: LICENSE
|
|
19
|
+
Requires-Dist: degiroasync>=1.0.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7.0.1; extra == "dev"
|
|
22
|
+
Requires-Dist: coverage>=6.3; extra == "dev"
|
|
23
|
+
Requires-Dist: flake8>=4.0.1; extra == "dev"
|
|
24
|
+
Requires-Dist: mypy>=0.931; extra == "dev"
|
|
25
|
+
Requires-Dist: build>=0.7.0; extra == "dev"
|
|
26
|
+
Requires-Dist: twine>=3.8.0; extra == "dev"
|
|
27
|
+
Requires-Dist: sphinx>=4.4.0; extra == "dev"
|
|
28
|
+
Requires-Dist: sphinx_rtd_theme>=1.0.0; extra == "dev"
|
|
29
|
+
Requires-Dist: myst-parser>=0.17.0; extra == "dev"
|
|
30
|
+
Requires-Dist: ipython; extra == "dev"
|
|
31
|
+
Requires-Dist: ipdb; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
17
33
|
|
|
18
34
|
# DegiroAsync Command Line Interface
|
|
19
35
|
|
|
@@ -1,19 +1,35 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: degiro-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Unofficial command line tools for Degiro
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
Author-email: ohmajesticlama <ohmajesticlama@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/OhMajesticLama/degiro-cli
|
|
7
8
|
Project-URL: Documentation, https://ohmajesticlama.github.io/degiro-cli
|
|
9
|
+
Keywords: degiro,cli,finance,investment
|
|
8
10
|
Classifier: Programming Language :: Python :: 3
|
|
9
11
|
Classifier: License :: OSI Approved :: MIT License
|
|
10
12
|
Classifier: Operating System :: OS Independent
|
|
11
|
-
Classifier: Development Status ::
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
14
|
Classifier: Intended Audience :: End Users/Desktop
|
|
13
15
|
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
16
|
+
Requires-Python: >=3.8
|
|
14
17
|
Description-Content-Type: text/markdown
|
|
15
|
-
Provides-Extra: dev
|
|
16
18
|
License-File: LICENSE
|
|
19
|
+
Requires-Dist: degiroasync>=1.0.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7.0.1; extra == "dev"
|
|
22
|
+
Requires-Dist: coverage>=6.3; extra == "dev"
|
|
23
|
+
Requires-Dist: flake8>=4.0.1; extra == "dev"
|
|
24
|
+
Requires-Dist: mypy>=0.931; extra == "dev"
|
|
25
|
+
Requires-Dist: build>=0.7.0; extra == "dev"
|
|
26
|
+
Requires-Dist: twine>=3.8.0; extra == "dev"
|
|
27
|
+
Requires-Dist: sphinx>=4.4.0; extra == "dev"
|
|
28
|
+
Requires-Dist: sphinx_rtd_theme>=1.0.0; extra == "dev"
|
|
29
|
+
Requires-Dist: myst-parser>=0.17.0; extra == "dev"
|
|
30
|
+
Requires-Dist: ipython; extra == "dev"
|
|
31
|
+
Requires-Dist: ipdb; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
17
33
|
|
|
18
34
|
# DegiroAsync Command Line Interface
|
|
19
35
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
degiro_cli.egg-info/PKG-INFO
|
|
5
|
+
degiro_cli.egg-info/SOURCES.txt
|
|
6
|
+
degiro_cli.egg-info/dependency_links.txt
|
|
7
|
+
degiro_cli.egg-info/entry_points.txt
|
|
8
|
+
degiro_cli.egg-info/requires.txt
|
|
9
|
+
degiro_cli.egg-info/top_level.txt
|
|
10
|
+
degirocli/__init__.py
|
|
11
|
+
degirocli/helpers.py
|
|
12
|
+
degirocli/history.py
|
|
13
|
+
degirocli/login.py
|
|
14
|
+
degirocli/search.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
degirocli
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
import logging
|
|
3
|
+
import asyncio
|
|
4
|
+
import collections
|
|
5
|
+
from typing import Callable
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
from typing import Any
|
|
8
|
+
from typing import Sequence
|
|
9
|
+
from typing import Union
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
LOGGER = logging.getLogger('degirocli')
|
|
13
|
+
|
|
14
|
+
class ERROR_CODES(enum.IntEnum):
|
|
15
|
+
SESSION_EXISTS = 1
|
|
16
|
+
|
|
17
|
+
async def run_concurrent(
|
|
18
|
+
worker: Callable,
|
|
19
|
+
jobs: Iterable[Any],
|
|
20
|
+
*,
|
|
21
|
+
max_concurrents: int = 10,
|
|
22
|
+
) -> Sequence[collections.abc.Awaitable]:
|
|
23
|
+
"""
|
|
24
|
+
Run worker on jobs with a max of max_concurrents at the same time.
|
|
25
|
+
|
|
26
|
+
This is a helper to avoid sending too many queries at once to the producer
|
|
27
|
+
queried by worker.
|
|
28
|
+
|
|
29
|
+
>>> import asyncio
|
|
30
|
+
>>> async def worker(a, b):
|
|
31
|
+
... await asyncio.sleep(1)
|
|
32
|
+
... return a + b
|
|
33
|
+
...
|
|
34
|
+
>>> awaitables = await run_concurrent(worker,
|
|
35
|
+
... ((1, 2), (2, 3)))
|
|
36
|
+
...
|
|
37
|
+
>>> res1 = await awaitables[0]
|
|
38
|
+
>>> res1
|
|
39
|
+
3
|
|
40
|
+
|
|
41
|
+
Arguments
|
|
42
|
+
---------
|
|
43
|
+
|
|
44
|
+
worker
|
|
45
|
+
Callable to apply to jobs.
|
|
46
|
+
|
|
47
|
+
jobs
|
|
48
|
+
Iterable of arguments that will be passed to worker.
|
|
49
|
+
|
|
50
|
+
max_concurrents
|
|
51
|
+
Maximum number of jobs that will be run concurrently.
|
|
52
|
+
"""
|
|
53
|
+
LOGGER.debug('run_concurrent| worker %s', worker)
|
|
54
|
+
work_sem = asyncio.Semaphore(max_concurrents)
|
|
55
|
+
|
|
56
|
+
async def _wrapper(attrs):
|
|
57
|
+
async with work_sem:
|
|
58
|
+
res = await worker(*attrs)
|
|
59
|
+
return res
|
|
60
|
+
|
|
61
|
+
return [asyncio.create_task(_wrapper(attrs)) for attrs in jobs]
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import argparse
|
|
3
|
+
import io
|
|
4
|
+
import sys
|
|
5
|
+
import asyncio
|
|
6
|
+
import csv
|
|
7
|
+
from typing import Tuple, Iterable
|
|
8
|
+
import getpass
|
|
9
|
+
|
|
10
|
+
import more_itertools
|
|
11
|
+
import degiroasync.api as dapi
|
|
12
|
+
|
|
13
|
+
from .helpers import run_concurrent
|
|
14
|
+
from .login import get_session_from_cache
|
|
15
|
+
|
|
16
|
+
LOGGER = logging.getLogger()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def run_one(
|
|
20
|
+
session: dapi.Session,
|
|
21
|
+
exchange: str,
|
|
22
|
+
symbol: str,
|
|
23
|
+
period: dapi.PRICE.PERIOD
|
|
24
|
+
):
|
|
25
|
+
"""
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
products = await dapi.search_product(
|
|
29
|
+
session,
|
|
30
|
+
by_symbol=symbol,
|
|
31
|
+
by_exchange=exchange,
|
|
32
|
+
product_type_id=dapi.PRODUCT.TYPEID.STOCK,
|
|
33
|
+
)
|
|
34
|
+
if len(products) > 1:
|
|
35
|
+
raise AssertionError("More than one product for {}.{}".format(
|
|
36
|
+
symbol,
|
|
37
|
+
exchange))
|
|
38
|
+
elif len(products) == 0:
|
|
39
|
+
LOGGER.error(
|
|
40
|
+
"No product found for exchange {}, symbol {}. Ignore.".format(
|
|
41
|
+
exchange, symbol
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
# Abort this one
|
|
45
|
+
return
|
|
46
|
+
product = products[0]
|
|
47
|
+
price_data: dapi.PriceSeriesTime = await dapi.get_price_data(
|
|
48
|
+
session,
|
|
49
|
+
product=product,
|
|
50
|
+
resolution=dapi.PRICE.RESOLUTION.PT1D,
|
|
51
|
+
period=period,
|
|
52
|
+
data_type=dapi.PRICE.TYPE.OHLC
|
|
53
|
+
)
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
print(f"Error on symbol {exchange}.{symbol}: {exc}", file=sys.stderr)
|
|
56
|
+
return exc
|
|
57
|
+
|
|
58
|
+
writer = csv.writer(sys.stdout, delimiter=',')
|
|
59
|
+
writer.writerows(
|
|
60
|
+
(
|
|
61
|
+
exchange,
|
|
62
|
+
symbol,
|
|
63
|
+
date,
|
|
64
|
+
product.info.currency,
|
|
65
|
+
*price,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
for date, price in zip(price_data.date, price_data.price)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def prepare_inputs(input_stream: Iterable[str]) -> Iterable[Tuple[str, str]]:
|
|
73
|
+
"""
|
|
74
|
+
Filter & split inputs from input_stream. Ignore lines starting with #
|
|
75
|
+
|
|
76
|
+
Example
|
|
77
|
+
-------
|
|
78
|
+
|
|
79
|
+
>>> inputs = ['EPA.AIR', 'EPA.BNP', ' #EPA.VIE']
|
|
80
|
+
>>> list(prepare_inputs(inputs))
|
|
81
|
+
[('EPA', 'AIR'), ('EPA', 'BNP')]
|
|
82
|
+
|
|
83
|
+
"""
|
|
84
|
+
# filter-out lines starting with #
|
|
85
|
+
inputs = (line.strip() for line in input_stream)
|
|
86
|
+
inputs = filter(lambda l: len(l) > 0, inputs)
|
|
87
|
+
inputs = filter(lambda l: l[0] != '#', inputs)
|
|
88
|
+
inputs = more_itertools.unique_everseen(inputs)
|
|
89
|
+
inputs = (line.split('.')[:2] for line in inputs)
|
|
90
|
+
return inputs # type: ignore , correct type.
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def run_price_pipeline(
|
|
94
|
+
session: dapi.Session,
|
|
95
|
+
*,
|
|
96
|
+
symbols: Iterable[str],
|
|
97
|
+
period: dapi.PRICE.PERIOD = dapi.PRICE.PERIOD.P1YEAR,
|
|
98
|
+
max_workers: int = 5
|
|
99
|
+
):
|
|
100
|
+
"""
|
|
101
|
+
Execute script: read symbols from standard input and output fetched price
|
|
102
|
+
data on standard output.
|
|
103
|
+
"""
|
|
104
|
+
inputs = prepare_inputs(symbols)
|
|
105
|
+
inputs = ((session, *t, period) for t in inputs)
|
|
106
|
+
#inputs = ((session, t) for t in inputs)
|
|
107
|
+
|
|
108
|
+
queries = await run_concurrent(run_one, inputs, max_concurrents=max_workers)
|
|
109
|
+
|
|
110
|
+
# ensure all queries completed before quitting
|
|
111
|
+
return await asyncio.gather(*queries)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def main():
|
|
115
|
+
handler = logging.StreamHandler()
|
|
116
|
+
|
|
117
|
+
parser = argparse.ArgumentParser(
|
|
118
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
119
|
+
description="Get history for provided symbols stocks. Symbols are read "
|
|
120
|
+
"from standard input, and expected in format "
|
|
121
|
+
"EXCHANGE.SYMBOL "
|
|
122
|
+
"with EXCHANGE being product exchange on DEGIRO platform. "
|
|
123
|
+
)
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
'-p', '--period',
|
|
126
|
+
default='1y',
|
|
127
|
+
dest='period',
|
|
128
|
+
help="Period on which to request data. Must be one of: "
|
|
129
|
+
"1d, 1m, 3m, 6m, 1y, 3y, 5y, 50y"
|
|
130
|
+
)
|
|
131
|
+
parser.add_argument(
|
|
132
|
+
'-H',
|
|
133
|
+
'--no-header-row',
|
|
134
|
+
dest='no_headers',
|
|
135
|
+
default=False,
|
|
136
|
+
action='store_true',
|
|
137
|
+
required=False,
|
|
138
|
+
help="Do not print header line"
|
|
139
|
+
)
|
|
140
|
+
parser.add_argument(
|
|
141
|
+
'--debug',
|
|
142
|
+
default=False,
|
|
143
|
+
action='store_true',
|
|
144
|
+
dest='debug',
|
|
145
|
+
help="Enable debug logging."
|
|
146
|
+
)
|
|
147
|
+
parser.add_argument(
|
|
148
|
+
'symbols',
|
|
149
|
+
nargs='*',
|
|
150
|
+
default=[],
|
|
151
|
+
help="Symbols should be in the form of EXCHANGE.PRODUCT_SYMBOL"
|
|
152
|
+
)
|
|
153
|
+
args = parser.parse_args()
|
|
154
|
+
logging_level = logging.ERROR
|
|
155
|
+
if args.debug:
|
|
156
|
+
logging_level = logging.DEBUG
|
|
157
|
+
handler.setLevel(logging_level)
|
|
158
|
+
LOGGER.setLevel(logging_level)
|
|
159
|
+
LOGGER.addHandler(handler)
|
|
160
|
+
|
|
161
|
+
print_headers = not args.no_headers
|
|
162
|
+
|
|
163
|
+
period = {
|
|
164
|
+
'1d': dapi.PRICE.PERIOD.P1DAY,
|
|
165
|
+
'1m': dapi.PRICE.PERIOD.P1MONTH,
|
|
166
|
+
'3m': dapi.PRICE.PERIOD.P3MONTH,
|
|
167
|
+
'6m': dapi.PRICE.PERIOD.P6MONTH,
|
|
168
|
+
'1y': dapi.PRICE.PERIOD.P1YEAR,
|
|
169
|
+
'3y': dapi.PRICE.PERIOD.P3YEAR,
|
|
170
|
+
'5y': dapi.PRICE.PERIOD.P5YEAR,
|
|
171
|
+
'50y': dapi.PRICE.PERIOD.P50YEAR
|
|
172
|
+
}[args.period.lower()]
|
|
173
|
+
|
|
174
|
+
session = get_session_from_cache()
|
|
175
|
+
session.update_throttling(max_requests=7, period_seconds=1)
|
|
176
|
+
|
|
177
|
+
symbols = sys.stdin
|
|
178
|
+
if len(args.symbols) > 0:
|
|
179
|
+
symbols = args.symbols
|
|
180
|
+
|
|
181
|
+
if print_headers:
|
|
182
|
+
writer = csv.writer(sys.stdout)
|
|
183
|
+
writer.writerow((
|
|
184
|
+
"exchange",
|
|
185
|
+
"symbol",
|
|
186
|
+
"date",
|
|
187
|
+
"currency",
|
|
188
|
+
"open",
|
|
189
|
+
"high",
|
|
190
|
+
"low",
|
|
191
|
+
"close",
|
|
192
|
+
))
|
|
193
|
+
asyncio.run(
|
|
194
|
+
run_price_pipeline(
|
|
195
|
+
session,
|
|
196
|
+
symbols=symbols,
|
|
197
|
+
period=period,
|
|
198
|
+
max_workers=5
|
|
199
|
+
)
|
|
200
|
+
)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
import getpass
|
|
3
|
+
import functools
|
|
4
|
+
import multiprocessing
|
|
5
|
+
import stat
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
import pickle
|
|
10
|
+
import sys
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Union
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
import degiroasync.api as dapi
|
|
17
|
+
import degiroasync.webapi as wapi
|
|
18
|
+
import degiroasync.core as dcore
|
|
19
|
+
|
|
20
|
+
from .helpers import ERROR_CODES
|
|
21
|
+
from .helpers import LOGGER
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_credentials() -> dapi.Credentials:
|
|
26
|
+
"""
|
|
27
|
+
Helper to get credentials for degiroasync provider.
|
|
28
|
+
"""
|
|
29
|
+
DEGIRO_USERNAME = 'DEGIRO_USERNAME'
|
|
30
|
+
DEGIRO_PASSWORD = 'DEGIRO_PASSWORD'
|
|
31
|
+
DEGIRO_TOTP_SECRET = 'DEGIRO_TOTP_SECRET'
|
|
32
|
+
if (username := os.environ.get(DEGIRO_USERNAME)) is None:
|
|
33
|
+
username = input('Username: ')
|
|
34
|
+
if len(username.strip()) == 0:
|
|
35
|
+
raise AssertionError(
|
|
36
|
+
"{} not set and not provided".format(DEGIRO_USERNAME))
|
|
37
|
+
if (password := os.environ.get(DEGIRO_PASSWORD)) is None:
|
|
38
|
+
password = getpass.getpass('Password: ')
|
|
39
|
+
if len(password.strip()) == 0:
|
|
40
|
+
raise AssertionError("{} not set and not provided".format(DEGIRO_PASSWORD))
|
|
41
|
+
totp_secret: Optional[str] = os.environ.get(DEGIRO_TOTP_SECRET) or None # could be ''
|
|
42
|
+
totp = None
|
|
43
|
+
if totp_secret is None:
|
|
44
|
+
# Ask user for TOTP
|
|
45
|
+
totp: Optional[str] = input("One Time Password (Enter to ignore): ")
|
|
46
|
+
if len(totp.strip()) == 0:
|
|
47
|
+
totp = None
|
|
48
|
+
|
|
49
|
+
return dapi.Credentials(
|
|
50
|
+
username=username,
|
|
51
|
+
password=password,
|
|
52
|
+
totp_secret=totp_secret,
|
|
53
|
+
one_time_password=totp
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_tmp_path():
|
|
58
|
+
tmpdir = Path(tempfile.gettempdir())
|
|
59
|
+
return tmpdir / 'degirocli'
|
|
60
|
+
|
|
61
|
+
def _get_hash(path: Union[Path, str]) -> str:
|
|
62
|
+
with open(path, 'rb') as fh:
|
|
63
|
+
fhash = hash(fh)
|
|
64
|
+
return fhash
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def expire_path(
|
|
68
|
+
path: Union[Path, str],
|
|
69
|
+
lifetime_seconds: Union[int, float]=60*60*2,
|
|
70
|
+
start_time_seconds: Optional[Union[int, float]] = None,
|
|
71
|
+
force: bool = False,
|
|
72
|
+
):
|
|
73
|
+
"""
|
|
74
|
+
Will remove file at path after 'lifetime_seconds'.
|
|
75
|
+
|
|
76
|
+
Parameters
|
|
77
|
+
----------
|
|
78
|
+
|
|
79
|
+
path
|
|
80
|
+
Path to remove when expired. By default, `path` will only be removed
|
|
81
|
+
if it hasn't changed since watcher started.
|
|
82
|
+
|
|
83
|
+
lifetime_seconds
|
|
84
|
+
File at path will be expired at start_time_seconds + lifetime_seconds.
|
|
85
|
+
|
|
86
|
+
start_time_seconds
|
|
87
|
+
File at path will be expired at start_time_seconds + lifetime_seconds.
|
|
88
|
+
Defaults to time.time().
|
|
89
|
+
|
|
90
|
+
force
|
|
91
|
+
If true, file at `path` will be removed even if it has changed since
|
|
92
|
+
this function has been called.
|
|
93
|
+
"""
|
|
94
|
+
# Let's detach this process.
|
|
95
|
+
if os.fork() != 0: # If os.fork == 0 this is the detached process.
|
|
96
|
+
return
|
|
97
|
+
if not isinstance(path, Path):
|
|
98
|
+
assert isinstance(path, str)
|
|
99
|
+
path = Path(path)
|
|
100
|
+
fhash = _get_hash(path)
|
|
101
|
+
start_time_seconds = start_time_seconds or time.time()
|
|
102
|
+
|
|
103
|
+
while time.time() - start_time_seconds < lifetime_seconds:
|
|
104
|
+
time.sleep(time.time() - start_time_seconds)
|
|
105
|
+
|
|
106
|
+
tmp_path = get_tmp_path()
|
|
107
|
+
if tmp_path.exists():
|
|
108
|
+
if tmp_path.is_file():
|
|
109
|
+
fhash_new = _get_hash(path)
|
|
110
|
+
if fhash_new == fhash or force:
|
|
111
|
+
Path.unlink(tmp_path)
|
|
112
|
+
else:
|
|
113
|
+
print(f'{path} has changed since it has been set to expire. '
|
|
114
|
+
f'Abort deletion.', file=sys.stderr)
|
|
115
|
+
return 1
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
#@functools.wraps(_expire_path)
|
|
120
|
+
#def expire_path(*args):
|
|
121
|
+
# # Start event loop to
|
|
122
|
+
# proc = multiprocessing.Process(
|
|
123
|
+
# target=_expire_path,
|
|
124
|
+
# args=args,
|
|
125
|
+
# daemon=False)
|
|
126
|
+
# proc.start()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def login():
|
|
130
|
+
tmp_path = get_tmp_path()
|
|
131
|
+
|
|
132
|
+
if tmp_path.exists():
|
|
133
|
+
ans = input("Existing session found, delete? (y/N)").strip().lower()
|
|
134
|
+
if ans in ('y', 'yes'):
|
|
135
|
+
tmp_path.unlink()
|
|
136
|
+
else:
|
|
137
|
+
print('Abort.')
|
|
138
|
+
return ERROR_CODES.SESSION_EXISTS
|
|
139
|
+
|
|
140
|
+
# Get sessionID and write it
|
|
141
|
+
credentials = get_credentials()
|
|
142
|
+
session = await wapi.login(credentials)
|
|
143
|
+
tmp_path.touch()
|
|
144
|
+
tmp_path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
145
|
+
with open(tmp_path, 'w') as fh:
|
|
146
|
+
json.dump({
|
|
147
|
+
'version': 1,
|
|
148
|
+
'format': 'degirocli',
|
|
149
|
+
'session': dict(
|
|
150
|
+
cookies=session.cookies,
|
|
151
|
+
),
|
|
152
|
+
}, fh)
|
|
153
|
+
# Delete file in 3 hour
|
|
154
|
+
expire_path(tmp_path, 3*60*60)
|
|
155
|
+
|
|
156
|
+
async def _get_session_from_cache() -> dapi.Session:
|
|
157
|
+
cache_path = get_tmp_path()
|
|
158
|
+
if not cache_path.exists():
|
|
159
|
+
raise AssertionError(
|
|
160
|
+
f"{cache_path} not found. Abort. Have you tried degiro-login?")
|
|
161
|
+
with open(cache_path, 'r') as fh:
|
|
162
|
+
session_d = json.load(fh)
|
|
163
|
+
session = dcore.SessionCore()
|
|
164
|
+
session._cookies = session_d['session']['cookies']
|
|
165
|
+
await wapi.get_config(session)
|
|
166
|
+
await wapi.get_client_info(session)
|
|
167
|
+
exc_dict = await dapi.get_dictionary(session)
|
|
168
|
+
return dapi.Session(session, exc_dict)
|
|
169
|
+
|
|
170
|
+
def get_session_from_cache() -> dapi.Session:
|
|
171
|
+
"""
|
|
172
|
+
Helper to get Session when already logged in.
|
|
173
|
+
"""
|
|
174
|
+
return asyncio.run(_get_session_from_cache())
|
|
175
|
+
|
|
176
|
+
def main():
|
|
177
|
+
asyncio.run(login())
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging
|
|
4
|
+
import argparse
|
|
5
|
+
import csv
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import degiroasync.api as dapi
|
|
9
|
+
|
|
10
|
+
from .helpers import LOGGER
|
|
11
|
+
from .login import get_session_from_cache
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def run_search_pipeline(
|
|
15
|
+
session: dapi.Session,
|
|
16
|
+
*,
|
|
17
|
+
search_txt: Optional[str],
|
|
18
|
+
exchange_txt: Optional[str],
|
|
19
|
+
country_txt: Optional[str],
|
|
20
|
+
index_txt: Optional[str]
|
|
21
|
+
):
|
|
22
|
+
"""
|
|
23
|
+
Execute script: read symbols from standard input and output fetched price
|
|
24
|
+
data on standard output.
|
|
25
|
+
"""
|
|
26
|
+
exc_dict = session.dictionary
|
|
27
|
+
products = await dapi.search_product(
|
|
28
|
+
session=session,
|
|
29
|
+
by_text=search_txt,
|
|
30
|
+
by_exchange=exchange_txt,
|
|
31
|
+
by_country=country_txt,
|
|
32
|
+
by_index=index_txt,
|
|
33
|
+
)
|
|
34
|
+
writer = csv.writer(sys.stdout)
|
|
35
|
+
exc_dict: dapi.ExchangeDictionary = session.dictionary
|
|
36
|
+
for product in products:
|
|
37
|
+
exchange = exc_dict.exchange_by(id=product.info.exchange_id)
|
|
38
|
+
writer.writerow((
|
|
39
|
+
exchange.hiq_abbr,
|
|
40
|
+
product.info.symbol,
|
|
41
|
+
product.info.name,
|
|
42
|
+
product.info.currency,
|
|
43
|
+
product.info.isin,
|
|
44
|
+
))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main():
|
|
49
|
+
handler = logging.StreamHandler()
|
|
50
|
+
|
|
51
|
+
parser = argparse.ArgumentParser(
|
|
52
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
53
|
+
description="Get history for provided symbols stocks. Symbols are read "
|
|
54
|
+
"from standard input, and expected in format "
|
|
55
|
+
"EXCHANGE.SYMBOL "
|
|
56
|
+
"with EXCHANGE being product exchange on DEGIRO platform. "
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
'-t',
|
|
60
|
+
dest='search',
|
|
61
|
+
default=None,
|
|
62
|
+
required=False,
|
|
63
|
+
help="Search for SEARCH_TXT and returns products."
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
'--exchange',
|
|
67
|
+
dest='exchange',
|
|
68
|
+
default=None,
|
|
69
|
+
required=False,
|
|
70
|
+
help="Search for products by exchange."
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
'--country',
|
|
74
|
+
dest='country',
|
|
75
|
+
default=None,
|
|
76
|
+
required=False,
|
|
77
|
+
help="Search products on exchange places in COUNTRY"
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
'--list-countries',
|
|
81
|
+
dest='list_countries',
|
|
82
|
+
default=False,
|
|
83
|
+
action='store_true',
|
|
84
|
+
required=False,
|
|
85
|
+
help="Print the available country codes on the platform."
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
'--index',
|
|
89
|
+
dest='index',
|
|
90
|
+
default=None,
|
|
91
|
+
required=False,
|
|
92
|
+
help="Search products on exchange places in INDEX"
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
'--list-indices',
|
|
96
|
+
dest='list_indices',
|
|
97
|
+
default=False,
|
|
98
|
+
action='store_true',
|
|
99
|
+
required=False,
|
|
100
|
+
help="Print the available incides codes on the platform."
|
|
101
|
+
)
|
|
102
|
+
parser.add_argument(
|
|
103
|
+
'-H',
|
|
104
|
+
'--no-header-row',
|
|
105
|
+
dest='no_headers',
|
|
106
|
+
default=False,
|
|
107
|
+
action='store_true',
|
|
108
|
+
required=False,
|
|
109
|
+
help="Do not print header line"
|
|
110
|
+
)
|
|
111
|
+
parser.add_argument(
|
|
112
|
+
'--debug',
|
|
113
|
+
default=False,
|
|
114
|
+
action='store_true',
|
|
115
|
+
dest='debug',
|
|
116
|
+
help="Enable debug logging."
|
|
117
|
+
)
|
|
118
|
+
args = parser.parse_args()
|
|
119
|
+
|
|
120
|
+
print_headers = not args.no_headers
|
|
121
|
+
search_txt = args.search
|
|
122
|
+
exchange_txt = args.exchange
|
|
123
|
+
country_txt = args.country
|
|
124
|
+
index_txt = args.index
|
|
125
|
+
list_indices = args.list_indices
|
|
126
|
+
|
|
127
|
+
logging_level = logging.ERROR
|
|
128
|
+
if args.debug:
|
|
129
|
+
logging_level = logging.DEBUG
|
|
130
|
+
handler.setLevel(logging_level)
|
|
131
|
+
LOGGER.setLevel(logging_level)
|
|
132
|
+
LOGGER.addHandler(handler)
|
|
133
|
+
|
|
134
|
+
session = get_session_from_cache()
|
|
135
|
+
# Lower throttling limit here as there is a good chance we'll
|
|
136
|
+
# bigger period limits: it seems degiro sends browser checks
|
|
137
|
+
# after many requests have been issued.
|
|
138
|
+
session.update_throttling(max_requests=10, period_seconds=1)
|
|
139
|
+
|
|
140
|
+
if args.list_countries:
|
|
141
|
+
for country in sorted(
|
|
142
|
+
session.dictionary.countries,
|
|
143
|
+
key=lambda x: x.name):
|
|
144
|
+
print(country.name)
|
|
145
|
+
return 0
|
|
146
|
+
if args.list_indices:
|
|
147
|
+
for index in sorted(
|
|
148
|
+
session.dictionary.indices,
|
|
149
|
+
key=lambda x: x.name):
|
|
150
|
+
if index.product_id is not None:
|
|
151
|
+
# 202307
|
|
152
|
+
# There is currently no support to pull products from an index
|
|
153
|
+
# where product_id not set by the platform, so don't show them.
|
|
154
|
+
print(index.name)
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
if print_headers:
|
|
158
|
+
writer = csv.writer(sys.stdout)
|
|
159
|
+
writer.writerow((
|
|
160
|
+
"exchange",
|
|
161
|
+
"symbol",
|
|
162
|
+
"name",
|
|
163
|
+
"currency",
|
|
164
|
+
"isin",
|
|
165
|
+
))
|
|
166
|
+
asyncio.run(
|
|
167
|
+
run_search_pipeline(
|
|
168
|
+
session,
|
|
169
|
+
search_txt=search_txt,
|
|
170
|
+
exchange_txt=exchange_txt,
|
|
171
|
+
country_txt=country_txt,
|
|
172
|
+
index_txt=index_txt
|
|
173
|
+
)
|
|
174
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "degiro-cli"
|
|
7
|
+
version = "0.1.3"
|
|
8
|
+
authors = [{ name = "ohmajesticlama", email = "ohmajesticlama@gmail.com" }]
|
|
9
|
+
description = "Unofficial command line tools for Degiro"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
license = { text = "MIT" }
|
|
13
|
+
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Development Status :: 5 - Production/Stable",
|
|
19
|
+
"Intended Audience :: End Users/Desktop",
|
|
20
|
+
"Topic :: Office/Business :: Financial :: Investment"
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
keywords = ["degiro", "cli", "finance", "investment"]
|
|
24
|
+
|
|
25
|
+
dependencies = [
|
|
26
|
+
"degiroasync >= 1.0.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
"Homepage" = "https://github.com/OhMajesticLama/degiro-cli"
|
|
31
|
+
"Documentation" = "https://ohmajesticlama.github.io/degiro-cli"
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
degiro-login = "degirocli.login:main"
|
|
35
|
+
degiro-history = "degirocli.history:main"
|
|
36
|
+
degiro-search = "degirocli.search:main"
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
dev = [
|
|
40
|
+
# Tests
|
|
41
|
+
"pytest >= 7.0.1",
|
|
42
|
+
"coverage >= 6.3",
|
|
43
|
+
# Code quality
|
|
44
|
+
"flake8 >= 4.0.1",
|
|
45
|
+
"mypy >= 0.931",
|
|
46
|
+
# For shipping
|
|
47
|
+
"build >= 0.7.0",
|
|
48
|
+
"twine >= 3.8.0",
|
|
49
|
+
# Documentation
|
|
50
|
+
"sphinx >= 4.4.0",
|
|
51
|
+
"sphinx_rtd_theme >= 1.0.0",
|
|
52
|
+
"myst-parser >= 0.17.0",
|
|
53
|
+
# Other dev tools
|
|
54
|
+
"ipython",
|
|
55
|
+
"ipdb",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
[tool.setuptools]
|
|
59
|
+
packages = ["degirocli"]
|
|
File without changes
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
LICENSE
|
|
2
|
-
README.md
|
|
3
|
-
pyproject.toml
|
|
4
|
-
setup.py
|
|
5
|
-
bin/degiro-history
|
|
6
|
-
bin/degiro-login
|
|
7
|
-
bin/degiro-logout
|
|
8
|
-
bin/degiro-portfolio
|
|
9
|
-
bin/degiro-search
|
|
10
|
-
degiro_cli.egg-info/PKG-INFO
|
|
11
|
-
degiro_cli.egg-info/SOURCES.txt
|
|
12
|
-
degiro_cli.egg-info/dependency_links.txt
|
|
13
|
-
degiro_cli.egg-info/requires.txt
|
|
14
|
-
degiro_cli.egg-info/top_level.txt
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
|
degiro-cli-0.1.1/pyproject.toml
DELETED
degiro-cli-0.1.1/setup.py
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
setup file for degiroasync
|
|
3
|
-
"""
|
|
4
|
-
import sys
|
|
5
|
-
import os
|
|
6
|
-
|
|
7
|
-
import setuptools
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if __name__ == '__main__':
|
|
11
|
-
description = "Unofficial command line tools for Degiro"
|
|
12
|
-
readme_path = os.path.join(os.path.dirname(__file__), 'README.md')
|
|
13
|
-
with open(readme_path, "r") as fh:
|
|
14
|
-
long_description = fh.read()
|
|
15
|
-
|
|
16
|
-
setuptools.setup(
|
|
17
|
-
name="degiro-cli",
|
|
18
|
-
version="0.1.1",
|
|
19
|
-
author_email="ohmajesticlama@gmail.com",
|
|
20
|
-
description=description,
|
|
21
|
-
long_description=long_description,
|
|
22
|
-
long_description_content_type='text/markdown',
|
|
23
|
-
url="https://github.com/OhMajesticLama/degiro-cli",
|
|
24
|
-
project_urls={
|
|
25
|
-
'Documentation':
|
|
26
|
-
'https://ohmajesticlama.github.io/degiro-cli'
|
|
27
|
-
},
|
|
28
|
-
packages=setuptools.find_packages(),
|
|
29
|
-
scripts=[
|
|
30
|
-
os.path.join('bin', 'degiro-login'),
|
|
31
|
-
os.path.join('bin', 'degiro-history'),
|
|
32
|
-
os.path.join('bin', 'degiro-search'),
|
|
33
|
-
],
|
|
34
|
-
install_requires=[
|
|
35
|
-
'degiroasync >= 0.20.0',
|
|
36
|
-
],
|
|
37
|
-
extras_require={
|
|
38
|
-
'dev': [
|
|
39
|
-
# Tests
|
|
40
|
-
'pytest >= 7.0.1',
|
|
41
|
-
'coverage >= 6.3',
|
|
42
|
-
# Code quality
|
|
43
|
-
'flake8 >= 4.0.1',
|
|
44
|
-
'mypy >= 0.931',
|
|
45
|
-
# For shipping
|
|
46
|
-
#'build >= 0.7.0',
|
|
47
|
-
#'twine >= 3.8.0',
|
|
48
|
-
# Documentation
|
|
49
|
-
#'sphinx >= 4.4.0',
|
|
50
|
-
#'sphinx_rtd_theme >= 1.0.0',
|
|
51
|
-
#'myst-parser >= 0.17.0', # markdown imports
|
|
52
|
-
# Other dev tools
|
|
53
|
-
'ipython',
|
|
54
|
-
'ipdb',
|
|
55
|
-
]
|
|
56
|
-
},
|
|
57
|
-
classifiers=[
|
|
58
|
-
"Programming Language :: Python :: 3",
|
|
59
|
-
"License :: OSI Approved :: MIT License",
|
|
60
|
-
"Operating System :: OS Independent",
|
|
61
|
-
"Development Status :: 4 - Beta",
|
|
62
|
-
"Intended Audience :: End Users/Desktop",
|
|
63
|
-
"Topic :: Office/Business :: Financial :: Investment"
|
|
64
|
-
],
|
|
65
|
-
test_suite='pytest',
|
|
66
|
-
tests_require=['pytest']
|
|
67
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|