py-ftmarkets 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.
- ftmarkets/__init__.py +8 -0
- ftmarkets/__main__.py +4 -0
- ftmarkets/api.py +510 -0
- ftmarkets/cli.py +23 -0
- ftmarkets/client.py +55 -0
- ftmarkets/commands/__init__.py +0 -0
- ftmarkets/commands/history.py +84 -0
- ftmarkets/commands/lookup.py +70 -0
- ftmarkets/py.typed +0 -0
- ftmarkets/utils.py +10 -0
- py_ftmarkets-0.1.0.dist-info/METADATA +103 -0
- py_ftmarkets-0.1.0.dist-info/RECORD +15 -0
- py_ftmarkets-0.1.0.dist-info/WHEEL +4 -0
- py_ftmarkets-0.1.0.dist-info/entry_points.txt +2 -0
- py_ftmarkets-0.1.0.dist-info/licenses/LICENSE +21 -0
ftmarkets/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from pydantic_market_data.models import OHLCV, History, Symbol
|
|
2
|
+
|
|
3
|
+
from .api import FTDataSource as FTDataSource
|
|
4
|
+
from .api import history as history
|
|
5
|
+
from .api import resolve_ticker as resolve_ticker
|
|
6
|
+
from .api import search as search
|
|
7
|
+
|
|
8
|
+
__all__ = ["OHLCV", "History", "Symbol", "FTDataSource", "history", "resolve_ticker", "search"]
|
ftmarkets/__main__.py
ADDED
ftmarkets/api.py
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import html as html_lib
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import parse_qs, urlparse
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
from lxml import html
|
|
10
|
+
from pydantic_market_data.interfaces import DataSource
|
|
11
|
+
from pydantic_market_data.models import OHLCV, History, SecurityCriteria, Symbol
|
|
12
|
+
|
|
13
|
+
from .client import client
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def search(query: str) -> list[Symbol]:
|
|
17
|
+
"""
|
|
18
|
+
Search for a security by ISIN, symbol, or name.
|
|
19
|
+
"""
|
|
20
|
+
url = "/data/search"
|
|
21
|
+
response = client.get(url, params={"query": query})
|
|
22
|
+
response.raise_for_status()
|
|
23
|
+
|
|
24
|
+
tree = html.fromstring(response.content)
|
|
25
|
+
|
|
26
|
+
# Mapping for FT tab IDs/names to standard types
|
|
27
|
+
asset_class_map = {
|
|
28
|
+
"etf-panel": "ETF",
|
|
29
|
+
"equity-panel": "Equity",
|
|
30
|
+
"fund-panel": "Fund",
|
|
31
|
+
"index-panel": "Index",
|
|
32
|
+
"ETFs": "ETF",
|
|
33
|
+
"Equities": "Equity",
|
|
34
|
+
"Funds": "Fund",
|
|
35
|
+
"Indices": "Index",
|
|
36
|
+
"Indicies": "Index",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
results = []
|
|
40
|
+
|
|
41
|
+
# The search results are grouped by asset class in tab panels or sections
|
|
42
|
+
xpath_query = '//div[@role="tabpanel"] | //div[contains(@class, "mod-search-results__section")]'
|
|
43
|
+
panels = tree.xpath(xpath_query)
|
|
44
|
+
if not panels:
|
|
45
|
+
# Fallback for direct redirect or simple structure
|
|
46
|
+
if "tearsheet" in response.url:
|
|
47
|
+
# ... existing redirect logic ...
|
|
48
|
+
parsed = urlparse(response.url)
|
|
49
|
+
qs = parse_qs(parsed.query)
|
|
50
|
+
symbol_code = qs.get("s", [None])[0]
|
|
51
|
+
if symbol_code:
|
|
52
|
+
name_el = tree.xpath('//h1[@class="mod-tearsheet-overview__header__name"]')
|
|
53
|
+
name = name_el[0].text.strip() if name_el else query
|
|
54
|
+
return [Symbol(ticker=symbol_code, name=name)]
|
|
55
|
+
panels = [tree]
|
|
56
|
+
|
|
57
|
+
for panel in panels:
|
|
58
|
+
# Identify asset class from ID or header
|
|
59
|
+
panel_id = panel.get("id")
|
|
60
|
+
asset_type = asset_class_map.get(panel_id)
|
|
61
|
+
|
|
62
|
+
if not asset_type:
|
|
63
|
+
header = panel.xpath(".//h3")
|
|
64
|
+
ft_name = header[0].text.strip() if header else None
|
|
65
|
+
asset_type = asset_class_map.get(ft_name, ft_name)
|
|
66
|
+
|
|
67
|
+
rows = panel.xpath('.//table[contains(@class, "mod-ui-table")]/tbody/tr')
|
|
68
|
+
for row in rows:
|
|
69
|
+
cols = row.xpath("./td")
|
|
70
|
+
if len(cols) >= 2:
|
|
71
|
+
name = cols[0].text_content().strip()
|
|
72
|
+
ticker = cols[1].text_content().strip()
|
|
73
|
+
exchange = cols[2].text_content().strip() if len(cols) > 2 else None
|
|
74
|
+
country = cols[3].text_content().strip() if len(cols) > 3 else None
|
|
75
|
+
|
|
76
|
+
if ticker:
|
|
77
|
+
country_code = _map_country_to_code(country)
|
|
78
|
+
|
|
79
|
+
# Currency extraction from ticker: "4GLD:GER:EUR" -> "EUR"
|
|
80
|
+
# Note: FT tickers are variable format, often SYMBOL:EXCHANGE:CURRENCY
|
|
81
|
+
currency = None
|
|
82
|
+
ticker_parts = ticker.split(":")
|
|
83
|
+
if len(ticker_parts) >= 3:
|
|
84
|
+
# If there are 3 parts, the last one is likely the currency
|
|
85
|
+
last_part = ticker_parts[-1].upper()
|
|
86
|
+
if len(last_part) == 3 and last_part.isalpha():
|
|
87
|
+
currency = last_part
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
sym = Symbol(
|
|
91
|
+
ticker=ticker,
|
|
92
|
+
name=name,
|
|
93
|
+
exchange=exchange,
|
|
94
|
+
country=country_code,
|
|
95
|
+
currency=currency,
|
|
96
|
+
asset_class=asset_type,
|
|
97
|
+
)
|
|
98
|
+
except Exception:
|
|
99
|
+
# If validation fails, try a more lenient approach
|
|
100
|
+
sym = Symbol(
|
|
101
|
+
ticker=ticker,
|
|
102
|
+
name=name,
|
|
103
|
+
exchange=exchange,
|
|
104
|
+
country=country_code,
|
|
105
|
+
currency=None,
|
|
106
|
+
asset_class=asset_type,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
results.append(sym)
|
|
110
|
+
|
|
111
|
+
return results
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _map_country_to_code(country_name: str | None) -> str | None:
|
|
115
|
+
if not country_name:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
# Common mappings
|
|
119
|
+
mapping = {
|
|
120
|
+
"United Kingdom": "GB",
|
|
121
|
+
"United States": "US",
|
|
122
|
+
"France": "FR",
|
|
123
|
+
"Germany": "DE",
|
|
124
|
+
"Canada": "CA",
|
|
125
|
+
"Italy": "IT",
|
|
126
|
+
"Spain": "ES",
|
|
127
|
+
"Netherlands": "NL",
|
|
128
|
+
"Australia": "AU",
|
|
129
|
+
"Japan": "JP",
|
|
130
|
+
"Switzerland": "CH",
|
|
131
|
+
"Sweden": "SE",
|
|
132
|
+
"Belgium": "BE",
|
|
133
|
+
"Ireland": "IE",
|
|
134
|
+
"Denmark": "DK",
|
|
135
|
+
"Finland": "FI",
|
|
136
|
+
"Norway": "NO",
|
|
137
|
+
"Portugal": "PT",
|
|
138
|
+
"Hong Kong": "HK",
|
|
139
|
+
"Singapore": "SG",
|
|
140
|
+
"China": "CN",
|
|
141
|
+
"India": "IN",
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
# Return None if not mapped to avoid validation error for unknown names
|
|
145
|
+
return mapping.get(country_name, None)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def resolve_ticker(
|
|
149
|
+
isin: str | None = None,
|
|
150
|
+
symbol: str | None = None,
|
|
151
|
+
description: str | None = None,
|
|
152
|
+
preferred_exchanges: list[str] | None = None,
|
|
153
|
+
target_price: float | None = None,
|
|
154
|
+
target_date: str | None = None,
|
|
155
|
+
currency: str | None = None,
|
|
156
|
+
country: str | None = None,
|
|
157
|
+
asset_class: str | None = None,
|
|
158
|
+
limit: int = 1,
|
|
159
|
+
) -> list[Symbol]:
|
|
160
|
+
"""
|
|
161
|
+
Enhanced resolve_ticker that returns a list of matching Symbols.
|
|
162
|
+
"""
|
|
163
|
+
candidates = []
|
|
164
|
+
|
|
165
|
+
# 1. Search Strategy
|
|
166
|
+
if isin:
|
|
167
|
+
candidates = search(isin)
|
|
168
|
+
if not candidates and symbol:
|
|
169
|
+
candidates = search(symbol)
|
|
170
|
+
if not candidates and description:
|
|
171
|
+
candidates = search(description)
|
|
172
|
+
|
|
173
|
+
if not candidates:
|
|
174
|
+
return []
|
|
175
|
+
|
|
176
|
+
# 2. Filtering
|
|
177
|
+
filtered = []
|
|
178
|
+
for cand in candidates:
|
|
179
|
+
# Currency Filter
|
|
180
|
+
if currency:
|
|
181
|
+
if not cand.currency or str(cand.currency).upper() != currency.upper():
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
# Country Filter
|
|
185
|
+
if country:
|
|
186
|
+
if not cand.country or str(cand.country).upper() != country.upper():
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
# Asset Class Filter
|
|
190
|
+
if asset_class:
|
|
191
|
+
cand_asset = getattr(cand, "asset_class", None)
|
|
192
|
+
if not cand_asset or cand_asset.upper() != asset_class.upper():
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
filtered.append(cand)
|
|
196
|
+
|
|
197
|
+
if not filtered:
|
|
198
|
+
return []
|
|
199
|
+
|
|
200
|
+
# 3. Sorting (Preferences) - Moved before validation for early exit optimization
|
|
201
|
+
def match_score(cand):
|
|
202
|
+
score = 0
|
|
203
|
+
# Factor A: Exchange Priority
|
|
204
|
+
if preferred_exchanges:
|
|
205
|
+
prefs_lower = [p.lower() for p in preferred_exchanges]
|
|
206
|
+
cand_exc = (cand.exchange or "").lower()
|
|
207
|
+
cand_sym = cand.ticker.lower()
|
|
208
|
+
found_exc = False
|
|
209
|
+
for i, p in enumerate(prefs_lower):
|
|
210
|
+
if p in cand_exc or p in cand_sym:
|
|
211
|
+
score += i
|
|
212
|
+
found_exc = True
|
|
213
|
+
break
|
|
214
|
+
if not found_exc:
|
|
215
|
+
score += len(prefs_lower)
|
|
216
|
+
|
|
217
|
+
# Factor B: Currency Match (Highest priority after exchange if not already filtered)
|
|
218
|
+
if currency:
|
|
219
|
+
if cand.ticker.upper().endswith(f":{currency.upper()}"):
|
|
220
|
+
score -= 100
|
|
221
|
+
|
|
222
|
+
return score
|
|
223
|
+
|
|
224
|
+
filtered.sort(key=match_score)
|
|
225
|
+
|
|
226
|
+
# 4. Validation (Price)
|
|
227
|
+
# If price and date are provided, we only keep candidates that traded near that price.
|
|
228
|
+
if target_price and target_date:
|
|
229
|
+
validated = []
|
|
230
|
+
|
|
231
|
+
# Determine necessary history period
|
|
232
|
+
val_period = "3mo"
|
|
233
|
+
try:
|
|
234
|
+
target_dt = None
|
|
235
|
+
for fmt in ("%Y-%m-%d", "%Y%m%d"):
|
|
236
|
+
try:
|
|
237
|
+
target_dt = datetime.strptime(target_date, fmt)
|
|
238
|
+
break
|
|
239
|
+
except ValueError:
|
|
240
|
+
pass
|
|
241
|
+
if target_dt:
|
|
242
|
+
days_diff = (datetime.now() - target_dt).days
|
|
243
|
+
if days_diff > 365 * 5:
|
|
244
|
+
val_period = "10y"
|
|
245
|
+
elif days_diff > 365 * 2:
|
|
246
|
+
val_period = "5y"
|
|
247
|
+
elif days_diff > 365:
|
|
248
|
+
val_period = "3y"
|
|
249
|
+
elif days_diff > 180:
|
|
250
|
+
val_period = "1y"
|
|
251
|
+
elif days_diff > 90:
|
|
252
|
+
val_period = "6mo"
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
for cand in filtered:
|
|
257
|
+
try:
|
|
258
|
+
hist = history(cand.ticker, period=val_period)
|
|
259
|
+
dt = target_dt
|
|
260
|
+
if not dt:
|
|
261
|
+
for fmt in ("%Y-%m-%d", "%Y%m%d"):
|
|
262
|
+
try:
|
|
263
|
+
dt = datetime.strptime(target_date, fmt)
|
|
264
|
+
break
|
|
265
|
+
except ValueError:
|
|
266
|
+
pass
|
|
267
|
+
|
|
268
|
+
if not dt:
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
match = _check_price_match_logic(hist, dt, target_price)
|
|
272
|
+
if match:
|
|
273
|
+
validated.append(cand)
|
|
274
|
+
# Early Exit: If we reached the limit, we can stop
|
|
275
|
+
if limit > 0 and len(validated) >= limit:
|
|
276
|
+
break
|
|
277
|
+
except Exception:
|
|
278
|
+
continue
|
|
279
|
+
filtered = validated
|
|
280
|
+
|
|
281
|
+
if not filtered:
|
|
282
|
+
return []
|
|
283
|
+
|
|
284
|
+
# 5. Limit
|
|
285
|
+
if limit > 0:
|
|
286
|
+
return filtered[:limit]
|
|
287
|
+
|
|
288
|
+
return filtered
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def history(ticker: str, period: str = "1mo") -> History:
|
|
292
|
+
"""
|
|
293
|
+
Fetch historical data for a ticker.
|
|
294
|
+
Period options: '1d', '1mo', '3mo', '6mo', '1y', '3y', '5y', '10y', 'max'
|
|
295
|
+
"""
|
|
296
|
+
# 1. Get Summary to find Internal ID (xid)
|
|
297
|
+
# Note: Optimization - we could cache this mapping
|
|
298
|
+
url_summary = f"/data/equities/tearsheet/summary?s={ticker}"
|
|
299
|
+
resp = client.get(url_summary)
|
|
300
|
+
resp.raise_for_status()
|
|
301
|
+
|
|
302
|
+
tree = html.fromstring(resp.content)
|
|
303
|
+
|
|
304
|
+
# Extract xid
|
|
305
|
+
xid = None
|
|
306
|
+
# Method A: data-mod-config
|
|
307
|
+
divs = tree.xpath("//div[@data-mod-config]")
|
|
308
|
+
for div in divs:
|
|
309
|
+
try:
|
|
310
|
+
raw_cfg = div.get("data-mod-config")
|
|
311
|
+
if raw_cfg:
|
|
312
|
+
decoded_cfg = html_lib.unescape(raw_cfg)
|
|
313
|
+
cfg = json.loads(decoded_cfg)
|
|
314
|
+
if "xid" in cfg:
|
|
315
|
+
xid = cfg["xid"]
|
|
316
|
+
break
|
|
317
|
+
except (ValueError, KeyError, json.JSONDecodeError):
|
|
318
|
+
pass
|
|
319
|
+
|
|
320
|
+
if not xid:
|
|
321
|
+
# Method B: Regex (Handle potentially escaped quotes)
|
|
322
|
+
# Matches: xid:"123" or xid="123" or "xid":"123"
|
|
323
|
+
# Also handle "
|
|
324
|
+
# Look for xid followed by colon/equals, optional quotes, and digits
|
|
325
|
+
regex = r'(?:xid|"xid")\s*[:=]\s*(?:["\']|")?(\d+)(?:["\']|")?'
|
|
326
|
+
match = re.search(regex, resp.text)
|
|
327
|
+
if match:
|
|
328
|
+
xid = match.group(1)
|
|
329
|
+
|
|
330
|
+
if not xid:
|
|
331
|
+
raise ValueError(f"Could not determine internal FT ID for ticker {ticker}")
|
|
332
|
+
|
|
333
|
+
# 2. Fetch Series Data
|
|
334
|
+
# Map period to days
|
|
335
|
+
days_map = {
|
|
336
|
+
"1d": 2, # safety
|
|
337
|
+
"1mo": 35,
|
|
338
|
+
"3mo": 100,
|
|
339
|
+
"6mo": 200,
|
|
340
|
+
"1y": 365,
|
|
341
|
+
"3y": 365 * 3,
|
|
342
|
+
"5y": 365 * 5,
|
|
343
|
+
"10y": 365 * 10,
|
|
344
|
+
"max": 365 * 30,
|
|
345
|
+
}
|
|
346
|
+
days = days_map.get(period, 365)
|
|
347
|
+
|
|
348
|
+
payload = {
|
|
349
|
+
"days": days,
|
|
350
|
+
"dataNormalized": False,
|
|
351
|
+
"dataPeriod": "Day",
|
|
352
|
+
"dataInterval": 1,
|
|
353
|
+
"realtime": False,
|
|
354
|
+
"yFormat": "0.###",
|
|
355
|
+
"timeServiceFormat": "JSON",
|
|
356
|
+
"returnDateType": "ISO8601",
|
|
357
|
+
"elements": [{"Type": "price", "Symbol": xid}, {"Type": "volume", "Symbol": xid}],
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
resp_series = client.post("/data/chartapi/series", json=payload)
|
|
361
|
+
resp_series.raise_for_status()
|
|
362
|
+
|
|
363
|
+
data = resp_series.json()
|
|
364
|
+
|
|
365
|
+
# 3. Parse Response
|
|
366
|
+
candles = []
|
|
367
|
+
dates = data.get("Dates", [])
|
|
368
|
+
elements = data.get("Elements", [])
|
|
369
|
+
|
|
370
|
+
if not dates or not elements:
|
|
371
|
+
return History(symbol=Symbol(ticker=ticker, name=ticker), candles=[])
|
|
372
|
+
|
|
373
|
+
# Find Price and Volume components
|
|
374
|
+
price_comp = next((e for e in elements if e.get("Type") == "price"), None)
|
|
375
|
+
vol_comp = next((e for e in elements if e.get("Type") == "volume"), None)
|
|
376
|
+
|
|
377
|
+
if not price_comp:
|
|
378
|
+
return History(symbol=Symbol(ticker=ticker, name=ticker), candles=[])
|
|
379
|
+
|
|
380
|
+
ohlc = price_comp.get("ComponentSeries", [])
|
|
381
|
+
volumes = vol_comp.get("ComponentSeries", []) if vol_comp else []
|
|
382
|
+
|
|
383
|
+
# Let's extract vectors first
|
|
384
|
+
highs = next((s["Values"] for s in ohlc if s["Type"] == "High"), [])
|
|
385
|
+
lows = next((s["Values"] for s in ohlc if s["Type"] == "Low"), [])
|
|
386
|
+
opens = next((s["Values"] for s in ohlc if s["Type"] == "Open"), [])
|
|
387
|
+
closes = next((s["Values"] for s in ohlc if s["Type"] == "Close"), [])
|
|
388
|
+
vols = next((s["Values"] for s in volumes if s["Type"] == "Volume"), [])
|
|
389
|
+
|
|
390
|
+
for i, d_str in enumerate(dates):
|
|
391
|
+
dt = datetime.fromisoformat(d_str)
|
|
392
|
+
c_open = opens[i] if i < len(opens) else None
|
|
393
|
+
c_high = highs[i] if i < len(highs) else None
|
|
394
|
+
c_low = lows[i] if i < len(lows) else None
|
|
395
|
+
c_close = closes[i] if i < len(closes) else None
|
|
396
|
+
c_vol = vols[i] if i < len(vols) else None
|
|
397
|
+
|
|
398
|
+
candles.append(
|
|
399
|
+
OHLCV(date=dt, open=c_open, high=c_high, low=c_low, close=c_close, volume=c_vol)
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
# Try to extract real name from earlier response?
|
|
403
|
+
# We don't have it easily here without another request or parsing summary more.
|
|
404
|
+
# Ticker defaults to just ticker string for now.
|
|
405
|
+
sym = Symbol(ticker=ticker, name=ticker)
|
|
406
|
+
|
|
407
|
+
return History(symbol=sym, candles=candles)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _check_price_match_logic(history: History, target_dt: datetime, target_price: float) -> bool:
|
|
411
|
+
"""Internal helper for validation logic"""
|
|
412
|
+
# Convert to DataFrame for easier lookups or iterate
|
|
413
|
+
# Let's iterate efficiently
|
|
414
|
+
# Find exact date
|
|
415
|
+
match_row = None
|
|
416
|
+
target_ts = pd.Timestamp(target_dt)
|
|
417
|
+
|
|
418
|
+
# Check exact date (ignoring time)
|
|
419
|
+
for c in history.candles:
|
|
420
|
+
if pd.Timestamp(c.date).date() == target_ts.date():
|
|
421
|
+
match_row = c
|
|
422
|
+
break
|
|
423
|
+
|
|
424
|
+
# Fallback: Nearest (within 3 days)
|
|
425
|
+
if not match_row:
|
|
426
|
+
# Sort by diff
|
|
427
|
+
candidates = []
|
|
428
|
+
for c in history.candles:
|
|
429
|
+
diff = abs((pd.Timestamp(c.date).date() - target_ts.date()).days)
|
|
430
|
+
if diff <= 3:
|
|
431
|
+
candidates.append((diff, c))
|
|
432
|
+
|
|
433
|
+
if candidates:
|
|
434
|
+
# Sort by diff
|
|
435
|
+
candidates.sort(key=lambda x: x[0])
|
|
436
|
+
match_row = candidates[0][1]
|
|
437
|
+
|
|
438
|
+
if not match_row:
|
|
439
|
+
return False
|
|
440
|
+
|
|
441
|
+
# Range Check
|
|
442
|
+
if match_row.high is not None and match_row.low is not None:
|
|
443
|
+
if match_row.low <= target_price <= match_row.high:
|
|
444
|
+
return True
|
|
445
|
+
else:
|
|
446
|
+
return False # Strict range check
|
|
447
|
+
|
|
448
|
+
# Close Check
|
|
449
|
+
if match_row.close is not None:
|
|
450
|
+
diff = abs(match_row.close - target_price)
|
|
451
|
+
pct = diff / target_price
|
|
452
|
+
return pct < 0.05
|
|
453
|
+
|
|
454
|
+
return False
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
class FTDataSource(DataSource):
|
|
458
|
+
"""
|
|
459
|
+
Financial Times (markets.ft.com) data source implementation.
|
|
460
|
+
"""
|
|
461
|
+
|
|
462
|
+
def search(self, query: str) -> list[Symbol]:
|
|
463
|
+
return search(query)
|
|
464
|
+
|
|
465
|
+
def resolve(self, criteria: SecurityCriteria) -> Symbol | None:
|
|
466
|
+
# Note: SecurityCriteria in pydantic-market-data might not
|
|
467
|
+
# have preferred_exchanges yet. We use what's available.
|
|
468
|
+
results = resolve_ticker(
|
|
469
|
+
isin=criteria.isin,
|
|
470
|
+
symbol=criteria.symbol,
|
|
471
|
+
description=criteria.description,
|
|
472
|
+
preferred_exchanges=None,
|
|
473
|
+
target_price=criteria.target_price,
|
|
474
|
+
target_date=str(criteria.target_date) if criteria.target_date else None,
|
|
475
|
+
currency=criteria.currency,
|
|
476
|
+
limit=1,
|
|
477
|
+
)
|
|
478
|
+
if results:
|
|
479
|
+
return results[0]
|
|
480
|
+
return None
|
|
481
|
+
|
|
482
|
+
def history(self, ticker: str, period: str = "1mo") -> History:
|
|
483
|
+
return history(ticker, period=period)
|
|
484
|
+
|
|
485
|
+
def validate(self, ticker: str, target_date: Any, target_price: float) -> bool:
|
|
486
|
+
"""
|
|
487
|
+
Validates if the ticker traded near the target price on the target date.
|
|
488
|
+
"""
|
|
489
|
+
try:
|
|
490
|
+
hist = self.history(ticker, period="3mo")
|
|
491
|
+
# Convert target_date to datetime if it's a date or str
|
|
492
|
+
if isinstance(target_date, str):
|
|
493
|
+
dt = None
|
|
494
|
+
for fmt in ("%Y-%m-%d", "%Y%m%d"):
|
|
495
|
+
try:
|
|
496
|
+
dt = datetime.strptime(target_date, fmt)
|
|
497
|
+
break
|
|
498
|
+
except ValueError:
|
|
499
|
+
pass
|
|
500
|
+
elif isinstance(target_date, date):
|
|
501
|
+
dt = datetime.combine(target_date, datetime.min.time())
|
|
502
|
+
else:
|
|
503
|
+
dt = target_date
|
|
504
|
+
|
|
505
|
+
if not dt:
|
|
506
|
+
return False
|
|
507
|
+
|
|
508
|
+
return _check_price_match_logic(hist, dt, target_price)
|
|
509
|
+
except Exception:
|
|
510
|
+
return False
|
ftmarkets/cli.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
from .commands import history, lookup
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main():
|
|
7
|
+
parser = argparse.ArgumentParser(description="Financial Times Markets CLI Tool")
|
|
8
|
+
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
|
9
|
+
|
|
10
|
+
# Register commands
|
|
11
|
+
lookup.setup_parser(subparsers)
|
|
12
|
+
history.setup_parser(subparsers)
|
|
13
|
+
|
|
14
|
+
args = parser.parse_args()
|
|
15
|
+
|
|
16
|
+
if hasattr(args, "func"):
|
|
17
|
+
args.func(args)
|
|
18
|
+
else:
|
|
19
|
+
parser.print_help()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
main()
|
ftmarkets/client.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
from requests.adapters import HTTPAdapter
|
|
5
|
+
from urllib3.util.retry import Retry
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FTClient:
|
|
9
|
+
"""
|
|
10
|
+
Stateless client for markets.ft.com.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
BASE_URL = "https://markets.ft.com"
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.session = requests.Session()
|
|
17
|
+
self.session.headers.update(
|
|
18
|
+
{
|
|
19
|
+
"User-Agent": (
|
|
20
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
21
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
22
|
+
),
|
|
23
|
+
"Accept": "application/json, text/plain, */*",
|
|
24
|
+
"Referer": "https://markets.ft.com/data/equities",
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Retry strategy
|
|
29
|
+
retries = Retry(
|
|
30
|
+
total=3,
|
|
31
|
+
backoff_factor=1,
|
|
32
|
+
status_forcelist=[429, 500, 502, 503, 504],
|
|
33
|
+
allowed_methods=["HEAD", "GET", "POST", "OPTIONS"],
|
|
34
|
+
)
|
|
35
|
+
adapter = HTTPAdapter(max_retries=retries)
|
|
36
|
+
self.session.mount("https://", adapter)
|
|
37
|
+
self.session.mount("http://", adapter)
|
|
38
|
+
|
|
39
|
+
def get(self, path: str, params: dict[str, Any] | None = None, **kwargs) -> requests.Response:
|
|
40
|
+
url = f"{self.BASE_URL}{path}" if path.startswith("/") else path
|
|
41
|
+
# If full URL passed (e.g. for scraping), handle it
|
|
42
|
+
if path.startswith("http"):
|
|
43
|
+
url = path
|
|
44
|
+
|
|
45
|
+
kwargs.setdefault("timeout", 10) # default 10s timeout
|
|
46
|
+
return self.session.get(url, params=params, **kwargs)
|
|
47
|
+
|
|
48
|
+
def post(self, path: str, json: dict[str, Any] | None = None, **kwargs) -> requests.Response:
|
|
49
|
+
url = f"{self.BASE_URL}{path}"
|
|
50
|
+
kwargs.setdefault("timeout", 10)
|
|
51
|
+
return self.session.post(url, json=json, **kwargs)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Singleton instance
|
|
55
|
+
client = FTClient()
|
|
File without changes
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
from .. import api
|
|
6
|
+
from ..utils import parse_date
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def setup_parser(subparsers):
|
|
10
|
+
parser = subparsers.add_parser("history", help="Fetch history and validate")
|
|
11
|
+
parser.add_argument("--isin", help="ISIN of the security")
|
|
12
|
+
parser.add_argument("--symbol", help="Symbol")
|
|
13
|
+
parser.add_argument("--desc", help="Description")
|
|
14
|
+
parser.add_argument("--exchange", help="Preferred exchange")
|
|
15
|
+
parser.add_argument("--period", default="1mo", help="Period (e.g. 1mo, 1y)")
|
|
16
|
+
parser.add_argument("--price", type=float, help="Validation price")
|
|
17
|
+
parser.add_argument("--date", help="Validation date")
|
|
18
|
+
parser.set_defaults(func=handle)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def handle(args):
|
|
22
|
+
preferred = [args.exchange] if args.exchange else None
|
|
23
|
+
|
|
24
|
+
ticker_str = api.resolve_ticker(
|
|
25
|
+
isin=args.isin,
|
|
26
|
+
symbol=args.symbol,
|
|
27
|
+
description=args.desc,
|
|
28
|
+
preferred_exchanges=preferred,
|
|
29
|
+
target_price=args.price,
|
|
30
|
+
target_date=args.date,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if not ticker_str:
|
|
34
|
+
print("Could not resolve ticker.", file=sys.stderr)
|
|
35
|
+
sys.exit(1)
|
|
36
|
+
|
|
37
|
+
print(f"Resolved to: {ticker_str}")
|
|
38
|
+
hist = api.history(ticker_str, period=args.period)
|
|
39
|
+
df = hist.to_pandas()
|
|
40
|
+
|
|
41
|
+
if df.empty:
|
|
42
|
+
print("No history found.", file=sys.stderr)
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
|
|
45
|
+
print(df.tail())
|
|
46
|
+
|
|
47
|
+
if args.price and args.date:
|
|
48
|
+
target_dt = parse_date(args.date)
|
|
49
|
+
if not target_dt:
|
|
50
|
+
print(f"Invalid date: {args.date}", file=sys.stderr)
|
|
51
|
+
sys.exit(1)
|
|
52
|
+
|
|
53
|
+
target_ts = pd.Timestamp(target_dt)
|
|
54
|
+
row = None
|
|
55
|
+
if target_ts in df.index:
|
|
56
|
+
row = df.loc[target_ts]
|
|
57
|
+
else:
|
|
58
|
+
idx = df.index.get_indexer([target_ts], method="nearest")[0]
|
|
59
|
+
if idx != -1:
|
|
60
|
+
actual_ts = df.index[idx]
|
|
61
|
+
if abs((actual_ts - target_ts).days) <= 3:
|
|
62
|
+
row = df.iloc[idx]
|
|
63
|
+
print(f"Using nearest data from {actual_ts.date()}")
|
|
64
|
+
|
|
65
|
+
if row is None:
|
|
66
|
+
print(f"Date {args.date} not found in history.", file=sys.stderr)
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
|
|
69
|
+
high, low, close_ = row.get("High"), row.get("Low"), row.get("Close")
|
|
70
|
+
passed = False
|
|
71
|
+
if pd.notna(high) and pd.notna(low):
|
|
72
|
+
passed = low <= args.price <= high
|
|
73
|
+
elif pd.notna(close_):
|
|
74
|
+
passed = (abs(close_ - args.price) / args.price) < 0.05
|
|
75
|
+
|
|
76
|
+
if passed:
|
|
77
|
+
print("VALIDATION PASSED")
|
|
78
|
+
else:
|
|
79
|
+
msg = (
|
|
80
|
+
f"VALIDATION FAILED (Open={row.get('Open')}, "
|
|
81
|
+
f"High={high}, Low={low}, Close={close_})"
|
|
82
|
+
)
|
|
83
|
+
print(msg)
|
|
84
|
+
sys.exit(1)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from .. import api
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def setup_parser(subparsers):
|
|
8
|
+
parser = subparsers.add_parser("lookup", help="Lookup a ticker symbol")
|
|
9
|
+
parser.add_argument("--isin", help="ISIN of the security")
|
|
10
|
+
parser.add_argument("--symbol", help="Symbol")
|
|
11
|
+
parser.add_argument("--desc", help="Description")
|
|
12
|
+
parser.add_argument("--exchange", help="Preferred exchange")
|
|
13
|
+
parser.add_argument("--currency", help="Filter by currency (e.g. EUR, USD)")
|
|
14
|
+
parser.add_argument("--country", help="Filter by country (e.g. DE, US)")
|
|
15
|
+
parser.add_argument("--asset-class", help="Filter by asset class (ETF, Equity, Fund, Index)")
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"--limit", type=int, default=100, help="Limit number of results (0 for all)"
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--format",
|
|
21
|
+
choices=["text", "json", "xml"],
|
|
22
|
+
default="text",
|
|
23
|
+
help="Output format",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument("--price", type=float, help="Validation price")
|
|
26
|
+
parser.add_argument("--date", help="Validation date")
|
|
27
|
+
parser.set_defaults(func=handle)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def handle(args):
|
|
31
|
+
preferred = [args.exchange] if args.exchange else None
|
|
32
|
+
|
|
33
|
+
symbols = api.resolve_ticker(
|
|
34
|
+
isin=args.isin,
|
|
35
|
+
symbol=args.symbol,
|
|
36
|
+
description=args.desc,
|
|
37
|
+
preferred_exchanges=preferred,
|
|
38
|
+
target_price=args.price,
|
|
39
|
+
target_date=args.date,
|
|
40
|
+
currency=args.currency,
|
|
41
|
+
country=args.country,
|
|
42
|
+
asset_class=args.asset_class,
|
|
43
|
+
limit=args.limit,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if not symbols:
|
|
47
|
+
print("Ticker not found", file=sys.stderr)
|
|
48
|
+
sys.exit(1)
|
|
49
|
+
|
|
50
|
+
if args.format == "json":
|
|
51
|
+
data = []
|
|
52
|
+
for s in symbols:
|
|
53
|
+
d = s.model_dump()
|
|
54
|
+
data.append(d)
|
|
55
|
+
print(json.dumps(data, indent=2, default=str))
|
|
56
|
+
elif args.format == "xml":
|
|
57
|
+
print("<Results>")
|
|
58
|
+
for s in symbols:
|
|
59
|
+
print(" <Symbol>")
|
|
60
|
+
print(f" <Ticker>{s.ticker}</Ticker>")
|
|
61
|
+
print(f" <Name>{s.name}</Name>")
|
|
62
|
+
print(f" <Exchange>{s.exchange or ''}</Exchange>")
|
|
63
|
+
print(f" <Country>{s.country or ''}</Country>")
|
|
64
|
+
print(f" <Currency>{s.currency or ''}</Currency>")
|
|
65
|
+
print(f" <AssetClass>{s.asset_class or ''}</AssetClass>")
|
|
66
|
+
print(" </Symbol>")
|
|
67
|
+
print("</Results>")
|
|
68
|
+
else:
|
|
69
|
+
for s in symbols:
|
|
70
|
+
print(s.ticker)
|
ftmarkets/py.typed
ADDED
|
File without changes
|
ftmarkets/utils.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py-ftmarkets
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Financial Times (markets.ft.com) data source for Python
|
|
5
|
+
Project-URL: Homepage, https://github.com/romamo/ftfinance
|
|
6
|
+
Project-URL: Repository, https://github.com/romamo/ftfinance
|
|
7
|
+
Project-URL: Issues, https://github.com/romamo/ftfinance/issues
|
|
8
|
+
Author-email: Roman Medvedev <pypi@romavm.dev>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
17
|
+
Requires-Python: >=3.13
|
|
18
|
+
Requires-Dist: html5lib>=1.1
|
|
19
|
+
Requires-Dist: lxml>=6.0.2
|
|
20
|
+
Requires-Dist: pandas>=2.3.3
|
|
21
|
+
Requires-Dist: pydantic-market-data
|
|
22
|
+
Requires-Dist: pydantic>=2.12.5
|
|
23
|
+
Requires-Dist: requests>=2.32.5
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# py-ftmarkets
|
|
27
|
+
|
|
28
|
+
Financial Times (markets.ft.com) data source for Python. Provides a high-level API and CLI to search for securities, fetch historical data, and validate prices.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uv add py-ftmarkets
|
|
34
|
+
# or
|
|
35
|
+
pip install py-ftmarkets
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## CLI Usage
|
|
39
|
+
|
|
40
|
+
The package provides a CLI tool named `ftmarkets`.
|
|
41
|
+
|
|
42
|
+
### Lookup a Ticker
|
|
43
|
+
|
|
44
|
+
Resolve an ISIN or Symbol to the Financial Times ticker format (e.g., `AAPL:NSQ`).
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# Basic lookup by ISIN
|
|
48
|
+
ftmarkets lookup --isin DE000A0S9GB0
|
|
49
|
+
|
|
50
|
+
# Lookup with price and date validation (Returns 1 best matching ticker)
|
|
51
|
+
ftmarkets lookup --isin DE000A0S9GB0 --price 117.81 --date 2025-12-12 --limit 1
|
|
52
|
+
|
|
53
|
+
# Lookup with filters (currency, country, asset-class)
|
|
54
|
+
ftmarkets lookup --isin DE000A0S9GB0 --currency EUR --country DE --asset-class ETF
|
|
55
|
+
|
|
56
|
+
# Return all matching results in JSON format
|
|
57
|
+
ftmarkets lookup --isin DE000A0S9GB0 --limit 0 --format json
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Fetch History and Validate
|
|
61
|
+
|
|
62
|
+
Fetch historical data for a resolved ticker and optionally validate a trade price on a specific date.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# Fetch 1 month of history for an ISIN
|
|
66
|
+
ftmarkets history --isin DE000A0S9GB0
|
|
67
|
+
|
|
68
|
+
# Fetch 1 year of history and validate a price
|
|
69
|
+
ftmarkets history --isin DE000A0S9GB0 --period 1y --price 120.50 --date 2025-01-15
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Library Usage
|
|
73
|
+
|
|
74
|
+
`py-ftmarkets` implements the `DataSource` interface from `pydantic-market-data`.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from ftmarkets.api import FTDataSource
|
|
78
|
+
from pydantic_market_data.models import SecurityCriteria
|
|
79
|
+
|
|
80
|
+
source = FTDataSource()
|
|
81
|
+
|
|
82
|
+
# Resolve a security
|
|
83
|
+
criteria = SecurityCriteria(isin="DE000A0S9GB0")
|
|
84
|
+
symbol = source.resolve(criteria)
|
|
85
|
+
print(f"Ticker: {symbol.ticker}")
|
|
86
|
+
|
|
87
|
+
# Fetch history
|
|
88
|
+
history = source.history(symbol.ticker, period="1mo")
|
|
89
|
+
df = history.to_pandas()
|
|
90
|
+
print(df.tail())
|
|
91
|
+
|
|
92
|
+
# Validate price
|
|
93
|
+
is_valid = source.validate(symbol.ticker, target_date="2025-01-15", target_price=120.50)
|
|
94
|
+
print(f"Price valid: {is_valid}")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Features
|
|
98
|
+
|
|
99
|
+
- **Robust Resolution**: Searches by ISIN, Symbol, or Description.
|
|
100
|
+
- **Smart Mapping**: Prioritizes results based on preferred exchanges and currency.
|
|
101
|
+
- **Price Validation**: Verifies if a security traded within a range or near a specific price on a given date.
|
|
102
|
+
- **Pandas Integration**: Historical data is easily convertible to Pandas DataFrames.
|
|
103
|
+
- **Modern Python**: Built with Pydantic v2 and async-ready architecture (though currently synchronous).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ftmarkets/__init__.py,sha256=7l7ygcfvXNxrxn9D-h60NM4fKIgJAKC6IJ02niJZMVk,327
|
|
2
|
+
ftmarkets/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
|
|
3
|
+
ftmarkets/api.py,sha256=f_v3w2QKev6UaHBVLAvWbVxOFe_--60emNJFu5_RS6w,16809
|
|
4
|
+
ftmarkets/cli.py,sha256=1GALiNLDt7f5TrRC2BTgj6n5Jf2G0pf0ab-HVsmhshc,495
|
|
5
|
+
ftmarkets/client.py,sha256=ae2FCZE66ZF4vQwB8mLPrxFmitPQ9oVV-l0QryDNv14,1799
|
|
6
|
+
ftmarkets/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
ftmarkets/utils.py,sha256=OsW-UqCfyPpMZB3FUajBtBK7KNIRIh4HaN-VAdQ9f8k,258
|
|
8
|
+
ftmarkets/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
ftmarkets/commands/history.py,sha256=TP_QEDni0QDqu8jaHRb-ExE0k5GVxGYXvRrx6ok3WXQ,2671
|
|
10
|
+
ftmarkets/commands/lookup.py,sha256=WlAef8iQvVZYL7ErT3aOl663HmeHc-HC7rEmZiotfVk,2392
|
|
11
|
+
py_ftmarkets-0.1.0.dist-info/METADATA,sha256=eTk0DDuU3AlYjLdKRSQezatsOkZckReqOgEFPzp7aTQ,3287
|
|
12
|
+
py_ftmarkets-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
13
|
+
py_ftmarkets-0.1.0.dist-info/entry_points.txt,sha256=Hh8APLa1bUvfbx60Zzi9j6f8QD-8haNshxcfoAODz7c,49
|
|
14
|
+
py_ftmarkets-0.1.0.dist-info/licenses/LICENSE,sha256=s07DUb5DYqzsOxq1d1k3AJvmVxjRl4BHMrKeu-ekeNc,1071
|
|
15
|
+
py_ftmarkets-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Roman Medvedev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|