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 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
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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,10 @@
1
+ from datetime import datetime
2
+
3
+
4
+ def parse_date(date_str: str) -> datetime | None:
5
+ for fmt in ("%Y-%m-%d", "%Y%m%d", "%d/%m/%Y"):
6
+ try:
7
+ return datetime.strptime(date_str, fmt)
8
+ except ValueError:
9
+ pass
10
+ return None
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ftmarkets = ftmarkets.cli:main
@@ -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.