firstrate-data 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.
- firstrate_data/__init__.py +38 -0
- firstrate_data/config.py +49 -0
- firstrate_data/domain/__init__.py +33 -0
- firstrate_data/domain/bar_type.py +34 -0
- firstrate_data/domain/enums.py +120 -0
- firstrate_data/domain/ticker_listing.py +78 -0
- firstrate_data/download/__init__.py +0 -0
- firstrate_data/download/bundles.py +201 -0
- firstrate_data/download/client.py +432 -0
- firstrate_data/download/progress.py +45 -0
- firstrate_data/download/requests.py +245 -0
- firstrate_data/store/__init__.py +0 -0
- firstrate_data/store/_parquet_table.py +78 -0
- firstrate_data/store/_sql.py +399 -0
- firstrate_data/store/store.py +630 -0
- firstrate_data-0.1.0.dist-info/METADATA +449 -0
- firstrate_data-0.1.0.dist-info/RECORD +20 -0
- firstrate_data-0.1.0.dist-info/WHEEL +5 -0
- firstrate_data-0.1.0.dist-info/licenses/LICENSE +201 -0
- firstrate_data-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""SQL the store runs."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from datetime import UTC, date, datetime, time, timedelta
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from firstrate_data.domain import AssetType, BarType, OtherData
|
|
9
|
+
|
|
10
|
+
# What a metafile's rows hold, in the vendor's order. They arrive headerless,
|
|
11
|
+
# one file per ticker, so the sniffer has no schema to read. It takes the
|
|
12
|
+
# first data row for a header, and every payload then declares different
|
|
13
|
+
# columns from the last. This is a hard error the moment one read spans more
|
|
14
|
+
# than one of them.
|
|
15
|
+
#
|
|
16
|
+
# ``contin_audit`` is missing on purpose: futures-only, never yet served, so
|
|
17
|
+
# there is no row format to pin. It stays on the sniffer. See issue #15.
|
|
18
|
+
OTHER_DATA_SCHEMA: dict[OtherData, dict[str, str]] = {
|
|
19
|
+
OtherData.SPLITS: {"date": "DATE", "ratio": "DOUBLE"},
|
|
20
|
+
OtherData.DIVIDENDS: {"date": "DATE", "amount": "DOUBLE"},
|
|
21
|
+
# TODO add company profiles and contract dates
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# The ticker is in the payload's *name* and nowhere in its rows, so it's the
|
|
25
|
+
# one column a metafile can't skip. The vendor suffixes the dividends
|
|
26
|
+
# payloads (``AAPL_divs.txt``) and leaves the splits ones bare
|
|
27
|
+
# (``AAPL.txt``). The suffix isn't part of the ticker.
|
|
28
|
+
OTHER_DATA_SUFFIX: dict[OtherData, str] = {OtherData.DIVIDENDS: "_divs"}
|
|
29
|
+
|
|
30
|
+
# What the catalog keeps. The key columns are BarType's own fields, in
|
|
31
|
+
# BarType's order, so a row addresses exactly what a path does.
|
|
32
|
+
CATALOG_SCHEMA: dict[str, str] = {
|
|
33
|
+
**dict.fromkeys(BarType.fields(), "VARCHAR"),
|
|
34
|
+
"first_ts": "TIMESTAMPTZ",
|
|
35
|
+
"last_ts": "TIMESTAMPTZ",
|
|
36
|
+
"rows": "BIGINT",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
TICKER_LISTING_SCHEMA: dict[str, str] = {
|
|
40
|
+
"ticker": "VARCHAR",
|
|
41
|
+
"full_name": "VARCHAR",
|
|
42
|
+
"start_date": "DATE",
|
|
43
|
+
"end_date": "DATE",
|
|
44
|
+
"is_delisted": "BOOLEAN",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# The six columns every bar has, typed as the parquet tree holds them.
|
|
48
|
+
# ``open_interest`` is NULL where the source omits it rather than absent. A
|
|
49
|
+
# tree of divergent schemas resolves columns by whichever file the glob
|
|
50
|
+
# enumerates first, so a stock-first glob would drop open_interest without
|
|
51
|
+
# warning.
|
|
52
|
+
BAR_SCHEMA: dict[str, str] = {
|
|
53
|
+
"ts": "TIMESTAMPTZ",
|
|
54
|
+
"open": "DOUBLE",
|
|
55
|
+
"high": "DOUBLE",
|
|
56
|
+
"low": "DOUBLE",
|
|
57
|
+
"close": "DOUBLE",
|
|
58
|
+
"volume": "BIGINT",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
OPEN_INTEREST = "open_interest"
|
|
62
|
+
|
|
63
|
+
# A bar as the tree hands it back: the payload's columns, then the levels the
|
|
64
|
+
# path carries. Named in this order rather than taken from ``SELECT *``, which
|
|
65
|
+
# appends the Hive keys alphabetically and agrees with nothing else here.
|
|
66
|
+
STORED_BAR_SCHEMA: dict[str, str] = {
|
|
67
|
+
**BAR_SCHEMA,
|
|
68
|
+
OPEN_INTEREST: "BIGINT",
|
|
69
|
+
**dict.fromkeys(BarType.fields(), "VARCHAR"),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# macOS drops these beside the payloads too, where they match ``*.txt``
|
|
73
|
+
SIDECAR_PREFIX = "._"
|
|
74
|
+
|
|
75
|
+
# The vendor packs a plain-text README into the metafile archives
|
|
76
|
+
# (``_splits_readme.txt``) explaining the row format. Read as a ticker's
|
|
77
|
+
# payload, it aborts the scan. No ticker starts with an underscore, which is
|
|
78
|
+
# what makes the prefix a safe rule.
|
|
79
|
+
OTHER_DATA_NOTE_PREFIX = "_"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def is_other_data_payload(name: str) -> bool:
|
|
83
|
+
"""Return whether `name` is a metafile payload containing one ticker's rows.
|
|
84
|
+
|
|
85
|
+
Examples
|
|
86
|
+
--------
|
|
87
|
+
>>> is_other_data_payload("AAPL.txt")
|
|
88
|
+
True
|
|
89
|
+
>>> is_other_data_payload("_splits_readme.txt")
|
|
90
|
+
False
|
|
91
|
+
>>> is_other_data_payload("._AAPL.txt")
|
|
92
|
+
False
|
|
93
|
+
|
|
94
|
+
"""
|
|
95
|
+
return not name.startswith((SIDECAR_PREFIX, OTHER_DATA_NOTE_PREFIX))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def sql_literal(value: str) -> str:
|
|
99
|
+
"""Return `value` as an SQL text literal.
|
|
100
|
+
|
|
101
|
+
Examples
|
|
102
|
+
--------
|
|
103
|
+
>>> sql_literal("BRK.B")
|
|
104
|
+
"'BRK.B'"
|
|
105
|
+
>>> sql_literal("O'Reilly")
|
|
106
|
+
"'O''Reilly'"
|
|
107
|
+
|
|
108
|
+
"""
|
|
109
|
+
escaped = value.replace("'", "''")
|
|
110
|
+
return f"'{escaped}'"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def sql_list(values: Iterable[str]) -> str:
|
|
114
|
+
"""Return `values` as an SQL list literal.
|
|
115
|
+
|
|
116
|
+
Examples
|
|
117
|
+
--------
|
|
118
|
+
>>> sql_list(['/tmp/AAPL.txt', '/tmp/MSFT.txt'])
|
|
119
|
+
"['/tmp/AAPL.txt', '/tmp/MSFT.txt']"
|
|
120
|
+
>>> sql_list([])
|
|
121
|
+
'[]'
|
|
122
|
+
|
|
123
|
+
"""
|
|
124
|
+
return f"[{', '.join(sql_literal(value) for value in values)}]"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def ticker_expression(column: str) -> str:
|
|
128
|
+
"""Return the SQL expression extracting the ticker from the filename in `column`."""
|
|
129
|
+
return f"regexp_extract(parse_filename({column}), '^([^_]+)_', 1)"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def hive_ticker_expression(column: str) -> str:
|
|
133
|
+
"""Return the SQL expression reading the ticker level of the path in `column`."""
|
|
134
|
+
# both separators: DuckDB reports a path on Windows with backslashes, and a
|
|
135
|
+
# level bounded by ``/`` alone would swallow the rest of the path
|
|
136
|
+
return f"regexp_extract({column}, 'ticker=([^/\\\\]+)', 1)"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def payload_tickers_select(directory: Path) -> str:
|
|
140
|
+
"""Return a SELECT of ``(ticker, files)`` for every payload under `directory`."""
|
|
141
|
+
pattern = sql_literal((directory / "*.txt").as_posix())
|
|
142
|
+
ticker = ticker_expression("file")
|
|
143
|
+
return (
|
|
144
|
+
f"SELECT {ticker} AS ticker, list(file) AS files " # noqa: S608
|
|
145
|
+
f"FROM glob({pattern}) "
|
|
146
|
+
# an AppleDouble sidecar matches *.txt and isn't text. The vendor's own
|
|
147
|
+
# archives carry them when the zip came from a Mac
|
|
148
|
+
f"WHERE NOT starts_with(parse_filename(file), {sql_literal(SIDECAR_PREFIX)}) "
|
|
149
|
+
f"GROUP BY 1"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def bars_select(
|
|
154
|
+
payloads: Iterable[Path],
|
|
155
|
+
bar_type: BarType,
|
|
156
|
+
columns_in_payload: int,
|
|
157
|
+
) -> str:
|
|
158
|
+
"""Return a SELECT reading the ``.txt`` `payloads` as the store's bar schema.
|
|
159
|
+
|
|
160
|
+
`bar_type` must leave the ticker unstated, and `columns_in_payload` is the
|
|
161
|
+
count `payload_columns` returns. Timestamps are returned tz-aware.
|
|
162
|
+
|
|
163
|
+
Raises
|
|
164
|
+
------
|
|
165
|
+
ValueError
|
|
166
|
+
If `bar_type` names no asset type.
|
|
167
|
+
|
|
168
|
+
"""
|
|
169
|
+
asset_type = bar_type.asset_type
|
|
170
|
+
# every row's timestamp reflects the zone its asset type trades in, and a
|
|
171
|
+
# wildcarded level answers for none of them
|
|
172
|
+
if asset_type is None:
|
|
173
|
+
msg = "bars need a bar type naming an asset type"
|
|
174
|
+
raise ValueError(msg)
|
|
175
|
+
|
|
176
|
+
# the declared columns are positional, so both ends of the schema move with
|
|
177
|
+
# the payload's width: an index drops volume, futures add open_interest.
|
|
178
|
+
# Declaring six for a five-column file aborts the scan on the sniffer.
|
|
179
|
+
has_volume = columns_in_payload >= len(BAR_SCHEMA)
|
|
180
|
+
has_open_interest = columns_in_payload > len(BAR_SCHEMA)
|
|
181
|
+
|
|
182
|
+
# ``ts`` is the one column the payload doesn't hold as the store does: it
|
|
183
|
+
# arrives naive, and the timezone() below is what makes it an instant
|
|
184
|
+
declared = {**BAR_SCHEMA, "ts": "TIMESTAMP"}
|
|
185
|
+
if not has_volume:
|
|
186
|
+
del declared["volume"]
|
|
187
|
+
if has_open_interest:
|
|
188
|
+
declared[OPEN_INTEREST] = "BIGINT"
|
|
189
|
+
columns = ", ".join(f"'{name}': '{kind}'" for name, kind in declared.items())
|
|
190
|
+
|
|
191
|
+
volume = "volume" if has_volume else "NULL::BIGINT AS volume"
|
|
192
|
+
open_interest = (
|
|
193
|
+
OPEN_INTEREST if has_open_interest else f"NULL::BIGINT AS {OPEN_INTEREST}"
|
|
194
|
+
)
|
|
195
|
+
files = sql_list(str(payload) for payload in payloads)
|
|
196
|
+
stamped = sql_literal(asset_type.timezone())
|
|
197
|
+
ticker = ticker_expression("filename")
|
|
198
|
+
# the tree is the only record of these -- they're nowhere in the payload,
|
|
199
|
+
# so a level this stops selecting here would read back as NULL
|
|
200
|
+
levels = ",\n ".join(
|
|
201
|
+
f"{sql_literal(value)} AS {key}"
|
|
202
|
+
for key, value in bar_type.stated_levels().items()
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
return f"""
|
|
206
|
+
SELECT
|
|
207
|
+
-- the DST fall-back repeats an hour of naive stamps; both copies
|
|
208
|
+
-- land on the standard-time offset, which is ICU's pick
|
|
209
|
+
timezone({stamped}, ts) AS ts,
|
|
210
|
+
open, high, low, close, {volume},
|
|
211
|
+
{open_interest},
|
|
212
|
+
{levels},
|
|
213
|
+
{ticker} AS ticker
|
|
214
|
+
FROM read_csv(
|
|
215
|
+
{files},
|
|
216
|
+
header = false,
|
|
217
|
+
filename = true,
|
|
218
|
+
columns = {{{columns}}},
|
|
219
|
+
-- the vendor mixes line endings within a single payload, which the
|
|
220
|
+
-- sniffer refuses to pick a dialect for; strict mode off parses them
|
|
221
|
+
strict_mode = false
|
|
222
|
+
)
|
|
223
|
+
""" # noqa: S608
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def other_data_ticker_expression(column: str, other_data: OtherData) -> str:
|
|
227
|
+
"""Return the SQL expression extracting `other_data`'s ticker from `column`."""
|
|
228
|
+
stem = f"parse_filename({column}, true)"
|
|
229
|
+
suffix = OTHER_DATA_SUFFIX.get(other_data)
|
|
230
|
+
if suffix is None:
|
|
231
|
+
return stem
|
|
232
|
+
return f"regexp_replace({stem}, {sql_literal(f'{suffix}$')}, '')"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def other_data_select(
|
|
236
|
+
payloads: Iterable[Path],
|
|
237
|
+
other_data: OtherData,
|
|
238
|
+
per_ticker: bool, # noqa: FBT001 - store.py's only caller passes it positionally
|
|
239
|
+
) -> str:
|
|
240
|
+
"""Return a SELECT reading `other_data`'s `payloads` as its own table.
|
|
241
|
+
|
|
242
|
+
`per_ticker` reads one file per ticker and adds a ``ticker`` column taken
|
|
243
|
+
from each filename; otherwise the payloads are read as they come.
|
|
244
|
+
"""
|
|
245
|
+
files = sql_list(str(payload) for payload in payloads)
|
|
246
|
+
declared = OTHER_DATA_SCHEMA.get(other_data)
|
|
247
|
+
if declared is None or not per_ticker:
|
|
248
|
+
return f"SELECT * FROM read_csv({files})" # noqa: S608
|
|
249
|
+
|
|
250
|
+
columns = ", ".join(f"'{name}': '{kind}'" for name, kind in declared.items())
|
|
251
|
+
return f"""
|
|
252
|
+
SELECT
|
|
253
|
+
{other_data_ticker_expression("filename", other_data)} AS ticker,
|
|
254
|
+
{", ".join(declared)}
|
|
255
|
+
FROM read_csv(
|
|
256
|
+
{files},
|
|
257
|
+
header = false,
|
|
258
|
+
filename = true,
|
|
259
|
+
columns = {{{columns}}},
|
|
260
|
+
-- the vendor mixes line endings here as it does in the bars
|
|
261
|
+
strict_mode = false
|
|
262
|
+
)
|
|
263
|
+
""" # noqa: S608
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def ingest_id() -> str:
|
|
267
|
+
"""Return a short id naming one ingest."""
|
|
268
|
+
return uuid4().hex[:8]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def filename_pattern(ingest_id: str) -> str:
|
|
272
|
+
"""Return the filename pattern `ingest_id` writes its parquet files under."""
|
|
273
|
+
# APPEND under PARTITION_BY requires {uuid}: a constant pattern reuses
|
|
274
|
+
# data_0.parquet and overwrites with no error. The date leads so the file
|
|
275
|
+
# sorts by when it landed and no glob mistakes it for an AppleDouble
|
|
276
|
+
# sidecar. The ingest id between them is how an ingest finds its own files.
|
|
277
|
+
return f"{datetime.now(tz=UTC).date().isoformat()}_{ingest_id}_{{uuid}}"
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def empty_select(schema: dict[str, str]) -> str:
|
|
281
|
+
"""Return a SELECT with `schema`'s columns and no rows."""
|
|
282
|
+
columns = ", ".join(f"NULL::{kind} AS {name}" for name, kind in schema.items())
|
|
283
|
+
return f"SELECT {columns} WHERE FALSE"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# keyed by an optional asset type so a read that named none falls into the
|
|
287
|
+
# same "has no session" answer as one that trades around the clock
|
|
288
|
+
EXCHANGE_SESSION: dict[AssetType | None, tuple[time, time]] = dict.fromkeys(
|
|
289
|
+
(AssetType.STOCK, AssetType.ETF, AssetType.INDEX),
|
|
290
|
+
(time(9, 30), time(16, 0)),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def regular_trading_hours_where(asset_type: AssetType | None) -> str:
|
|
295
|
+
"""Return the predicate keeping `asset_type`'s exchange session.
|
|
296
|
+
|
|
297
|
+
The predicate compares in the connection's timezone.
|
|
298
|
+
|
|
299
|
+
Raises
|
|
300
|
+
------
|
|
301
|
+
ValueError
|
|
302
|
+
If `asset_type` is None or names no exchange session.
|
|
303
|
+
|
|
304
|
+
"""
|
|
305
|
+
session = EXCHANGE_SESSION.get(asset_type)
|
|
306
|
+
if session is None:
|
|
307
|
+
msg = (
|
|
308
|
+
f"{asset_type} has no exchange session: it trades around the clock, "
|
|
309
|
+
f"or the read named no asset type to define one"
|
|
310
|
+
)
|
|
311
|
+
raise ValueError(msg)
|
|
312
|
+
|
|
313
|
+
opens, closes = session
|
|
314
|
+
return (
|
|
315
|
+
f"ts::TIMESTAMP::TIME >= '{opens:%H:%M:%S}' "
|
|
316
|
+
f"AND ts::TIMESTAMP::TIME < '{closes:%H:%M:%S}'"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def date_range_where(start: date | None, end: date | None) -> str | None:
|
|
321
|
+
"""Return the predicate keeping days from `start` to `end`, or None for neither.
|
|
322
|
+
|
|
323
|
+
Both bounds name a whole day and both are kept.
|
|
324
|
+
|
|
325
|
+
Examples
|
|
326
|
+
--------
|
|
327
|
+
>>> date_range_where(date(2024, 1, 2), date(2024, 1, 3))
|
|
328
|
+
"ts >= '2024-01-02' AND ts < '2024-01-04'"
|
|
329
|
+
>>> date_range_where(None, None) is None
|
|
330
|
+
True
|
|
331
|
+
|
|
332
|
+
"""
|
|
333
|
+
bounds = []
|
|
334
|
+
if start is not None:
|
|
335
|
+
bounds.append(f"ts >= '{start:%Y-%m-%d}'")
|
|
336
|
+
if end is not None:
|
|
337
|
+
# half-open on the right, so the whole of ``end`` stays in
|
|
338
|
+
bounds.append(f"ts < '{end + timedelta(days=1):%Y-%m-%d}'")
|
|
339
|
+
return " AND ".join(bounds) or None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def stored_bars_select(glob: str | list[str]) -> str:
|
|
343
|
+
"""Return a SELECT reading the parquet files at `glob` as the store's columns.
|
|
344
|
+
|
|
345
|
+
A single glob reads every ticker under it; a list reads exactly the globs
|
|
346
|
+
named.
|
|
347
|
+
"""
|
|
348
|
+
source = sql_literal(glob) if isinstance(glob, str) else sql_list(glob)
|
|
349
|
+
return (
|
|
350
|
+
f"SELECT {', '.join(STORED_BAR_SCHEMA)} " # noqa: S608
|
|
351
|
+
f"FROM read_parquet({source}, hive_partitioning = true)"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def footer_file_spans_select(files: Iterable[str]) -> str:
|
|
356
|
+
"""Return a SELECT of ``(file, first_ts, last_ts, rows)`` from `files`' footers."""
|
|
357
|
+
return f"""
|
|
358
|
+
SELECT
|
|
359
|
+
file_name AS file,
|
|
360
|
+
-- stats_min is VARCHAR ('2010-03-27 07:59:00+00'); the offset
|
|
361
|
+
-- makes the cast unambiguous, but the cast is mandatory
|
|
362
|
+
min(stats_min::TIMESTAMPTZ) AS first_ts,
|
|
363
|
+
max(stats_max::TIMESTAMPTZ) AS last_ts,
|
|
364
|
+
-- one row per row group per column, so the aggregate is not optional
|
|
365
|
+
sum(row_group_num_rows)::BIGINT AS rows
|
|
366
|
+
FROM parquet_metadata({sql_list(files)})
|
|
367
|
+
WHERE path_in_schema = 'ts'
|
|
368
|
+
GROUP BY 1
|
|
369
|
+
""" # noqa: S608
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def bar_type_where(bar_type: BarType) -> str:
|
|
373
|
+
"""Return the predicate matching the catalog rows `bar_type` names.
|
|
374
|
+
|
|
375
|
+
A level left unstated matches every value of it.
|
|
376
|
+
"""
|
|
377
|
+
tests = [
|
|
378
|
+
f"{level} = {sql_literal(value)}"
|
|
379
|
+
for level, value in bar_type.stated_levels().items()
|
|
380
|
+
]
|
|
381
|
+
return " AND ".join(tests) or "TRUE"
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def payload_columns(payloads: Iterable[Path]) -> int:
|
|
385
|
+
"""Return the number of columns the `payloads` have.
|
|
386
|
+
|
|
387
|
+
The first non-empty line of the first non-empty payload decides.
|
|
388
|
+
"""
|
|
389
|
+
# probed rather than ruled from (asset_type, timeframe), which would be a
|
|
390
|
+
# second source of truth about the vendor's file. Empty payloads are
|
|
391
|
+
# routine, so one non-empty payload speaks for the archive.
|
|
392
|
+
for payload in payloads:
|
|
393
|
+
# bytes, not text: a spliced payload holds bytes that aren't a
|
|
394
|
+
# character in the machine's locale encoding
|
|
395
|
+
with payload.open("rb") as lines:
|
|
396
|
+
for line in lines:
|
|
397
|
+
if line.strip():
|
|
398
|
+
return line.count(b",") + 1
|
|
399
|
+
return len(BAR_SCHEMA)
|