mif-dal 0.1.0__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.
- dal/__init__.py +29 -0
- dal/adapters/__init__.py +7 -0
- dal/adapters/dukascopy.py +349 -0
- dal/adapters/in_memory.py +93 -0
- dal/adapters/kraken.py +319 -0
- dal/adapters/yahoo.py +369 -0
- dal/core/__init__.py +6 -0
- dal/core/assembler.py +0 -0
- dal/core/config.py +22 -0
- dal/core/handoff.py +130 -0
- dal/core/pipeline.py +116 -0
- dal/core/sources.py +205 -0
- dal/core/utils.py +0 -0
- dal/dal.py +122 -0
- dal/exceptions.py +82 -0
- dal/interfaces/__init__.py +15 -0
- dal/interfaces/source.py +65 -0
- mif_dal-0.1.0.dist-info/METADATA +285 -0
- mif_dal-0.1.0.dist-info/RECORD +21 -0
- mif_dal-0.1.0.dist-info/WHEEL +4 -0
- mif_dal-0.1.0.dist-info/licenses/LICENSE +21 -0
dal/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""MIF-DAL — Data Abstraction Layer."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version as _pkg_version
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
__version__ = _pkg_version("mif-dal")
|
|
8
|
+
except PackageNotFoundError:
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
from dal.core.config import DALConfig
|
|
12
|
+
from dal.core.handoff import DALHandoff
|
|
13
|
+
from dal.dal import DAL
|
|
14
|
+
from dal.exceptions import (
|
|
15
|
+
DALConfigError,
|
|
16
|
+
DALError,
|
|
17
|
+
DALHandoffError,
|
|
18
|
+
DALVersionError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"DAL",
|
|
23
|
+
"DALConfig",
|
|
24
|
+
"DALConfigError",
|
|
25
|
+
"DALError",
|
|
26
|
+
"DALHandoff",
|
|
27
|
+
"DALHandoffError",
|
|
28
|
+
"DALVersionError",
|
|
29
|
+
]
|
dal/adapters/__init__.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dal/adapters/dukascopy.py
|
|
3
|
+
===========================
|
|
4
|
+
DukascopyAdapter — Source Protocol implementation via dukascopy-node (CLI).
|
|
5
|
+
|
|
6
|
+
Responsibility:
|
|
7
|
+
Download OHLCV data via dukascopy-node CLI (Node.js),
|
|
8
|
+
parse the resulting CSV, compute SHA-256 hash of raw bytes,
|
|
9
|
+
and return a FetchResult.
|
|
10
|
+
|
|
11
|
+
System requirements:
|
|
12
|
+
- Node.js ≥ 16
|
|
13
|
+
- dukascopy-node installed globally: npm install -g dukascopy-node
|
|
14
|
+
- Verify with: npx dukascopy-node --help
|
|
15
|
+
|
|
16
|
+
Documented limitations:
|
|
17
|
+
- PAXG not available → supports("PAXG-USD") returns False
|
|
18
|
+
- Max 12 months history per asset
|
|
19
|
+
- Depends on Node.js subprocess → mandatory timeout
|
|
20
|
+
- Bid price data only (no ask)
|
|
21
|
+
|
|
22
|
+
KB-008 pattern applied:
|
|
23
|
+
subprocess with timeout=300 + stdin=subprocess.DEVNULL
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import hashlib
|
|
29
|
+
import subprocess
|
|
30
|
+
import tempfile
|
|
31
|
+
from datetime import UTC, datetime
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import ClassVar
|
|
34
|
+
|
|
35
|
+
import pandas as pd
|
|
36
|
+
|
|
37
|
+
from dal.interfaces.source import FetchRequest, FetchResult
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DukascopyAdapter:
|
|
41
|
+
"""
|
|
42
|
+
Dukascopy adapter for MIF-DAL.
|
|
43
|
+
|
|
44
|
+
Uses dukascopy-node CLI to download OHLCV data.
|
|
45
|
+
Each download produces a temporary CSV whose raw bytes
|
|
46
|
+
are hashed before parsing.
|
|
47
|
+
|
|
48
|
+
Note: PAXG is not available on Dukascopy. This is not a bug.
|
|
49
|
+
For PAXG, use KrakenAdapter or YahooAdapter.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
# DAL asset_id → dukascopy-node symbol mapping
|
|
53
|
+
SYMBOL_MAP: ClassVar[dict[str, str]] = {
|
|
54
|
+
"BTC-USD": "btcusd",
|
|
55
|
+
"ETH-USD": "ethusd",
|
|
56
|
+
"XRP-USD": "xrpusd",
|
|
57
|
+
"SOL-USD": "solusd",
|
|
58
|
+
"ADA-USD": "adausd",
|
|
59
|
+
"DOT-USD": "dotusd",
|
|
60
|
+
"AVAX-USD": "avaxusd",
|
|
61
|
+
"LINK-USD": "linkusd",
|
|
62
|
+
# PAXG-USD: not available on Dukascopy
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# DAL timeframe → dukascopy-node code mapping
|
|
66
|
+
TIMEFRAME_MAP: ClassVar[dict[str, str]] = {
|
|
67
|
+
"1D": "d1",
|
|
68
|
+
"4H": "h4",
|
|
69
|
+
"1H": "h1",
|
|
70
|
+
"30M": "m30",
|
|
71
|
+
"15M": "m15",
|
|
72
|
+
"5M": "m5",
|
|
73
|
+
"1M": "m1",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
node_timeout: int = 300,
|
|
79
|
+
max_history_days: int = 365,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""
|
|
82
|
+
Args:
|
|
83
|
+
node_timeout: Seconds before Node.js subprocess timeout.
|
|
84
|
+
max_history_days: Dukascopy limit (12 months = 365 days).
|
|
85
|
+
"""
|
|
86
|
+
self._timeout = node_timeout
|
|
87
|
+
self._max_days = max_history_days
|
|
88
|
+
self._node_available: bool | None = None
|
|
89
|
+
|
|
90
|
+
# ── Source Protocol ────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def source_id(self) -> str:
|
|
94
|
+
return "dukascopy"
|
|
95
|
+
|
|
96
|
+
def supports(self, asset_id: str) -> bool:
|
|
97
|
+
"""Return True if the asset is available on Dukascopy."""
|
|
98
|
+
return asset_id in self.SYMBOL_MAP
|
|
99
|
+
|
|
100
|
+
def fetch(self, request: FetchRequest) -> FetchResult:
|
|
101
|
+
"""
|
|
102
|
+
Download OHLCV data via dukascopy-node.
|
|
103
|
+
|
|
104
|
+
Sequence:
|
|
105
|
+
1. Check Node.js and dukascopy-node availability
|
|
106
|
+
2. Launch subprocess with timeout + stdin=DEVNULL
|
|
107
|
+
3. Read raw bytes of produced CSV
|
|
108
|
+
4. Compute SHA-256 on raw bytes
|
|
109
|
+
5. Parse CSV to normalized OHLCV DataFrame
|
|
110
|
+
6. Return FetchResult
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
FetchResult with status "success" | "partial" | "failed".
|
|
114
|
+
"""
|
|
115
|
+
fetched_at = datetime.now(tz=UTC)
|
|
116
|
+
|
|
117
|
+
if not self.supports(request.asset_id):
|
|
118
|
+
return FetchResult(
|
|
119
|
+
data=pd.DataFrame(),
|
|
120
|
+
hash="",
|
|
121
|
+
status="failed",
|
|
122
|
+
source_id=self.source_id,
|
|
123
|
+
fetched_at=fetched_at,
|
|
124
|
+
rows=0,
|
|
125
|
+
timeframe=request.timeframe,
|
|
126
|
+
fallback=False,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if request.timeframe not in self.TIMEFRAME_MAP:
|
|
130
|
+
return FetchResult(
|
|
131
|
+
data=pd.DataFrame(),
|
|
132
|
+
hash="",
|
|
133
|
+
status="failed",
|
|
134
|
+
source_id=self.source_id,
|
|
135
|
+
fetched_at=fetched_at,
|
|
136
|
+
rows=0,
|
|
137
|
+
timeframe=request.timeframe,
|
|
138
|
+
fallback=False,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if not self._is_node_available():
|
|
142
|
+
return FetchResult(
|
|
143
|
+
data=pd.DataFrame(),
|
|
144
|
+
hash="",
|
|
145
|
+
status="failed",
|
|
146
|
+
source_id=self.source_id,
|
|
147
|
+
fetched_at=fetched_at,
|
|
148
|
+
rows=0,
|
|
149
|
+
timeframe=request.timeframe,
|
|
150
|
+
fallback=False,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
duka_symbol = self.SYMBOL_MAP[request.asset_id]
|
|
154
|
+
duka_timeframe = self.TIMEFRAME_MAP[request.timeframe]
|
|
155
|
+
|
|
156
|
+
# Dukascopy limited to 12 months — document truncation in FetchResult
|
|
157
|
+
start_ts = pd.Timestamp(request.start, tz="UTC")
|
|
158
|
+
end_ts = pd.Timestamp(request.end, tz="UTC")
|
|
159
|
+
actual_start = start_ts
|
|
160
|
+
truncated = False
|
|
161
|
+
if (end_ts - start_ts).days > self._max_days:
|
|
162
|
+
actual_start = end_ts - pd.Timedelta(days=self._max_days)
|
|
163
|
+
truncated = True
|
|
164
|
+
|
|
165
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
166
|
+
cmd = [
|
|
167
|
+
"npx",
|
|
168
|
+
"dukascopy-node",
|
|
169
|
+
"-i",
|
|
170
|
+
duka_symbol,
|
|
171
|
+
"-from",
|
|
172
|
+
actual_start.strftime("%Y-%m-%d"),
|
|
173
|
+
"-to",
|
|
174
|
+
end_ts.strftime("%Y-%m-%d"),
|
|
175
|
+
"-t",
|
|
176
|
+
duka_timeframe,
|
|
177
|
+
"-f",
|
|
178
|
+
"csv",
|
|
179
|
+
"-p",
|
|
180
|
+
"bid",
|
|
181
|
+
"-dir",
|
|
182
|
+
tmpdir,
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
proc = subprocess.run(
|
|
187
|
+
cmd,
|
|
188
|
+
capture_output=True,
|
|
189
|
+
text=False, # bytes, not str
|
|
190
|
+
timeout=self._timeout,
|
|
191
|
+
stdin=subprocess.DEVNULL, # KB-008: avoid stdin blocking
|
|
192
|
+
)
|
|
193
|
+
except subprocess.TimeoutExpired:
|
|
194
|
+
return FetchResult(
|
|
195
|
+
data=pd.DataFrame(),
|
|
196
|
+
hash="",
|
|
197
|
+
status="failed",
|
|
198
|
+
source_id=self.source_id,
|
|
199
|
+
fetched_at=fetched_at,
|
|
200
|
+
rows=0,
|
|
201
|
+
timeframe=request.timeframe,
|
|
202
|
+
fallback=False,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
if proc.returncode != 0:
|
|
206
|
+
return FetchResult(
|
|
207
|
+
data=pd.DataFrame(),
|
|
208
|
+
hash="",
|
|
209
|
+
status="failed",
|
|
210
|
+
source_id=self.source_id,
|
|
211
|
+
fetched_at=fetched_at,
|
|
212
|
+
rows=0,
|
|
213
|
+
timeframe=request.timeframe,
|
|
214
|
+
fallback=False,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Find CSV produced in tmpdir
|
|
218
|
+
csv_files = list(Path(tmpdir).glob("*.csv"))
|
|
219
|
+
if not csv_files:
|
|
220
|
+
return FetchResult(
|
|
221
|
+
data=pd.DataFrame(),
|
|
222
|
+
hash="",
|
|
223
|
+
status="failed",
|
|
224
|
+
source_id=self.source_id,
|
|
225
|
+
fetched_at=fetched_at,
|
|
226
|
+
rows=0,
|
|
227
|
+
timeframe=request.timeframe,
|
|
228
|
+
fallback=False,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
csv_path = csv_files[0]
|
|
232
|
+
raw_bytes = csv_path.read_bytes()
|
|
233
|
+
|
|
234
|
+
if len(raw_bytes) < 50:
|
|
235
|
+
return FetchResult(
|
|
236
|
+
data=pd.DataFrame(),
|
|
237
|
+
hash="",
|
|
238
|
+
status="failed",
|
|
239
|
+
source_id=self.source_id,
|
|
240
|
+
fetched_at=fetched_at,
|
|
241
|
+
rows=0,
|
|
242
|
+
timeframe=request.timeframe,
|
|
243
|
+
fallback=False,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Hash on raw bytes before any parsing
|
|
247
|
+
file_hash = hashlib.sha256(raw_bytes).hexdigest()
|
|
248
|
+
|
|
249
|
+
df = self._parse_csv(raw_bytes)
|
|
250
|
+
|
|
251
|
+
if df.empty:
|
|
252
|
+
return FetchResult(
|
|
253
|
+
data=pd.DataFrame(),
|
|
254
|
+
hash=file_hash,
|
|
255
|
+
status="failed",
|
|
256
|
+
source_id=self.source_id,
|
|
257
|
+
fetched_at=fetched_at,
|
|
258
|
+
rows=0,
|
|
259
|
+
timeframe=request.timeframe,
|
|
260
|
+
fallback=False,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
status = "partial" if truncated else "success"
|
|
264
|
+
return FetchResult(
|
|
265
|
+
data=df,
|
|
266
|
+
hash=file_hash,
|
|
267
|
+
status=status,
|
|
268
|
+
source_id=self.source_id,
|
|
269
|
+
fetched_at=fetched_at,
|
|
270
|
+
rows=len(df),
|
|
271
|
+
timeframe=request.timeframe,
|
|
272
|
+
fallback=False,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# ── Internal methods ─────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
def _is_node_available(self) -> bool:
|
|
278
|
+
"""
|
|
279
|
+
Check Node.js + dukascopy-node availability once
|
|
280
|
+
(result cached).
|
|
281
|
+
"""
|
|
282
|
+
if self._node_available is not None:
|
|
283
|
+
return self._node_available
|
|
284
|
+
try:
|
|
285
|
+
r = subprocess.run(
|
|
286
|
+
["npx", "dukascopy-node", "--help"],
|
|
287
|
+
capture_output=True,
|
|
288
|
+
timeout=15,
|
|
289
|
+
stdin=subprocess.DEVNULL,
|
|
290
|
+
)
|
|
291
|
+
self._node_available = r.returncode == 0
|
|
292
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
293
|
+
self._node_available = False
|
|
294
|
+
return self._node_available
|
|
295
|
+
|
|
296
|
+
def _parse_csv(self, raw_bytes: bytes) -> pd.DataFrame:
|
|
297
|
+
"""
|
|
298
|
+
Parse Dukascopy CSV to normalized OHLCV DataFrame.
|
|
299
|
+
|
|
300
|
+
Dukascopy CSV: timestamp (ms Unix), open, high, low, close, volume
|
|
301
|
+
Header may or may not be present depending on dukascopy-node version.
|
|
302
|
+
"""
|
|
303
|
+
import io
|
|
304
|
+
|
|
305
|
+
text = raw_bytes.decode("utf-8", errors="replace")
|
|
306
|
+
lines = text.strip().splitlines()
|
|
307
|
+
|
|
308
|
+
if not lines:
|
|
309
|
+
return pd.DataFrame()
|
|
310
|
+
|
|
311
|
+
# Detect if CSV has header
|
|
312
|
+
has_header = "timestamp" in lines[0].lower() or "time" in lines[0].lower()
|
|
313
|
+
|
|
314
|
+
if has_header:
|
|
315
|
+
df = pd.read_csv(io.StringIO(text))
|
|
316
|
+
df.columns = [c.lower().strip() for c in df.columns]
|
|
317
|
+
else:
|
|
318
|
+
df = pd.read_csv(
|
|
319
|
+
io.StringIO(text),
|
|
320
|
+
header=None,
|
|
321
|
+
names=["timestamp", "open", "high", "low", "close", "volume"],
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
if df.empty:
|
|
325
|
+
return df
|
|
326
|
+
|
|
327
|
+
# Normalize timestamp (milliseconds → DatetimeIndex UTC)
|
|
328
|
+
time_col = "timestamp" if "timestamp" in df.columns else "time"
|
|
329
|
+
if pd.api.types.is_numeric_dtype(df[time_col]):
|
|
330
|
+
df["timestamp"] = pd.to_datetime(df[time_col], unit="ms", utc=True)
|
|
331
|
+
else:
|
|
332
|
+
df["timestamp"] = pd.to_datetime(df[time_col], utc=True)
|
|
333
|
+
|
|
334
|
+
df = df.set_index("timestamp")
|
|
335
|
+
df.index.name = None
|
|
336
|
+
|
|
337
|
+
# Keep only OHLCV columns
|
|
338
|
+
cols = ["open", "high", "low", "close", "volume"]
|
|
339
|
+
available = [c for c in cols if c in df.columns]
|
|
340
|
+
if "volume" not in available:
|
|
341
|
+
df["volume"] = 0.0
|
|
342
|
+
available.append("volume")
|
|
343
|
+
|
|
344
|
+
df = df[cols].astype(float)
|
|
345
|
+
df = df.sort_index()
|
|
346
|
+
df = df[~df.index.duplicated(keep="first")]
|
|
347
|
+
df = df.dropna()
|
|
348
|
+
|
|
349
|
+
return df
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Deterministic in-memory source adapter for tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from dal.interfaces.source import FetchRequest, FetchResult, SourceFetchError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class InMemorySource:
|
|
14
|
+
"""Test adapter: pre-loaded data, optional failure simulation.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
source_id: identifier exposed in source_manifest entries.
|
|
18
|
+
data: mapping asset_id -> raw OHLCV DataFrame.
|
|
19
|
+
fail_attempts: first N fetch() calls raise SourceFetchError;
|
|
20
|
+
subsequent calls succeed. Used to test retry behavior.
|
|
21
|
+
permanent_failure: every fetch() raises (used to test fallback).
|
|
22
|
+
truncate_start_days: drop N rows from the start of returned data
|
|
23
|
+
(simulates a source whose history starts after the request).
|
|
24
|
+
truncate_end_days: drop N rows from the end (simulates source
|
|
25
|
+
ending before the request).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
source_id: str
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
source_id: str,
|
|
33
|
+
data: dict[str, pd.DataFrame],
|
|
34
|
+
*,
|
|
35
|
+
fail_attempts: int = 0,
|
|
36
|
+
permanent_failure: bool = False,
|
|
37
|
+
truncate_start_days: int = 0,
|
|
38
|
+
truncate_end_days: int = 0,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.source_id = source_id
|
|
41
|
+
self._data = data
|
|
42
|
+
self._fail_attempts = fail_attempts
|
|
43
|
+
self._permanent_failure = permanent_failure
|
|
44
|
+
self._truncate_start_days = truncate_start_days
|
|
45
|
+
self._truncate_end_days = truncate_end_days
|
|
46
|
+
self._call_count = 0
|
|
47
|
+
|
|
48
|
+
def supports(self, asset_id: str) -> bool:
|
|
49
|
+
return asset_id in self._data
|
|
50
|
+
|
|
51
|
+
def fetch(self, request: FetchRequest) -> FetchResult:
|
|
52
|
+
self._call_count += 1
|
|
53
|
+
|
|
54
|
+
if self._permanent_failure:
|
|
55
|
+
raise SourceFetchError(
|
|
56
|
+
f"{self.source_id} permanent failure for {request.asset_id}",
|
|
57
|
+
source_id=self.source_id,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if self._call_count <= self._fail_attempts:
|
|
61
|
+
raise SourceFetchError(
|
|
62
|
+
f"{self.source_id} transient failure (attempt " f"{self._call_count})",
|
|
63
|
+
source_id=self.source_id,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if request.asset_id not in self._data:
|
|
67
|
+
raise SourceFetchError(
|
|
68
|
+
f"{self.source_id} does not have {request.asset_id}",
|
|
69
|
+
source_id=self.source_id,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
df = self._data[request.asset_id]
|
|
73
|
+
start_ts = pd.Timestamp(request.start, tz="UTC")
|
|
74
|
+
end_ts = pd.Timestamp(request.end, tz="UTC")
|
|
75
|
+
df = df.loc[(df.index >= start_ts) & (df.index <= end_ts)]
|
|
76
|
+
|
|
77
|
+
if self._truncate_start_days:
|
|
78
|
+
df = df.iloc[self._truncate_start_days :]
|
|
79
|
+
if self._truncate_end_days:
|
|
80
|
+
df = df.iloc[: -self._truncate_end_days]
|
|
81
|
+
|
|
82
|
+
raw_bytes = df.to_parquet()
|
|
83
|
+
digest = hashlib.sha256(raw_bytes).hexdigest()
|
|
84
|
+
|
|
85
|
+
return FetchResult(
|
|
86
|
+
data=df,
|
|
87
|
+
source_id=self.source_id,
|
|
88
|
+
status="success",
|
|
89
|
+
rows=len(df),
|
|
90
|
+
fetched_at=datetime.now(UTC),
|
|
91
|
+
hash=digest,
|
|
92
|
+
timeframe=request.timeframe,
|
|
93
|
+
)
|