技能创建器
skill-creator
创建、改进和评估自定义技能,把专业流程转化为可重复使用的 AI 能力。
market-data-engineering【数据与研究】Use when building financial data pipelines, processing OHLCV data, cleaning market time-series, handling gaps and resampling, storing signals in TimescaleDB, or deduplicating tick and bar data. Also owns the wire-layer ingestion path upstream of cleaning - provider/envelope decode boundary, immutable raw capture with append-before-ack, per-channel se…
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
Financial time-series data is fundamentally different from regular data: it has gaps (weekends, halts), irregular timestamps (tick data), corporate actions (splits, dividends), and strict point-in-time requirements. Treat it with more care than typical data engineering.
clean_ohlcv and everything below it assume a trustworthy, ordered, replayable
stream of raw events. Producing that stream is a separate layer that sits UPSTREAM of
cleaning. Capture, checkpointing, and replay are one transactional story, not three
independent features: you cannot replay what you did not durably capture, and you cannot
resume safely from a checkpoint that does not reference durable capture offsets.
def ingest_one(envelope, raw_log, checkpoint, decode, process):
"""Append-before-ack: durability precedes acknowledgement and processing."""
offset = raw_log.append(envelope) # 1. durable capture FIRST
raw_log.fsync() # 2. survive a crash before we ack
event = decode(envelope) # 3. decode at the single boundary
process(event) # 4. downstream sees normalized events
checkpoint.advance_atomic(offset) # 5. checkpoint references the raw offset
return offset # ack only after capture is durable
Timestamp-gap handling (weekends, halts — see fill_gaps) is not sequence integrity.
Sequence integrity uses the source's own sequence numbers / checksums:
corrupt and requires a mandatory snapshot resync — never continue processing
deltas onto an invalid book.def check_channel_sequence(channel_state, msg):
"""Gap detection on ONE demuxed channel. Returns 'ok' | 'gapped' | 'reset'."""
if msg.epoch != channel_state.epoch:
return "reset" # new epoch: re-baseline, not a gap
expected = channel_state.last_seq + 1
if msg.seq == expected:
channel_state.last_seq = msg.seq
return "ok"
if msg.seq > expected:
return "gapped" # missing [expected, msg.seq): recover
return "ok" # <= last_seq: duplicate/reorder, ignore
On reconnect, backfill the missing range from the provider's historical endpoint and reconcile the seam so the resumed live stream and the backfilled range converge on identical bars (same dedup, same sequence checks) — a resumed feed must not produce a bar that disagrees with the backfill for the same interval.
The wire layer emits one versioned, per-stream / per-channel measurement schema. These are policy-free FACTS with defined semantics; every consumer owns its own REACTION policy and never re-derives the facts or pushes policy into the schema (one definition, many policies — the guard-drift lesson):
from dataclasses import dataclass
@dataclass(frozen=True)
class FeedHealthMeasurement:
"""Versioned per-stream/per-channel feed-health FACTS. Defined once, here.
Consumers: the runtime aggregates the streams a strategy requires and applies
fail-closed admission thresholds (see trading-automation-runtime); the dashboard
displays these per-stream/per-symbol facts while keeping emergency controls live
(see trading-dashboard-ux). No consumer redefines these fields or embeds policy.
"""
schema_version: int # bump on any field/semantics change
source: str # provider identity
channel: str # demuxed channel identity (symbol/book/trades)
integrity_state: str # 'ok' | 'gapped' | 'resyncing' | 'corrupt'
sequence_epoch: int # current channel epoch
last_verified_sequence: int # last in-order sequence accepted
last_event_time_ns: int # event age (source clock) — NOT the transport age
last_receive_time_ns: int # receive/transport age (local clock)
heartbeat_age_ns: int # since last heartbeat; quiet-but-healthy is tolerated
session_state: str # expected-activity: a quiet healthy feed is not stale
completeness: str # 'complete' | 'backfilling' | 'resyncing'
clock_domain: str # which clock last_event_time_ns is measured in
The integrity_state, the split between event age and transport age, the
sequence epoch, and the session/expected-activity state are the load-bearing facts: a
quiet-but-healthy feed (no ticks because the session is closed) is not stale, and a
feed that is silently reordering is unhealthy even if ticks keep arriving.
Record a live session as raw envelopes plus arrival order, timestamps, and the decoder / config / schema versions. Replay re-feeds that log timestamp- or speed-controlled through the SAME decode path as live. Determinism requires:
def replay_session(raw_log, decode, process, clock):
"""Re-feed a recorded session through the SAME decode path, on a virtual clock."""
for envelope in raw_log.in_recorded_arrival_order():
clock.set(envelope.recorded_time_ns) # virtual clock, not wall clock
process(decode(envelope)) # identical path to live ingest_one
return raw_log.output_hash() # compare to the live-session hash
import pandas as pd
import numpy as np
from typing import Optional
def clean_ohlcv(df: pd.DataFrame) -> pd.DataFrame:
"""
Standardises OHLCV dataframe. Expects columns: open, high, low, close, volume.
Returns cleaned, sorted, deduplicated dataframe indexed by UTC datetime.
"""
df = df.copy()
# Ensure UTC datetime index
if not isinstance(df.index, pd.DatetimeIndex):
df.index = pd.to_datetime(df.index, utc=True)
elif df.index.tz is None:
df.index = df.index.tz_localize('UTC')
else:
df.index = df.index.tz_convert('UTC')
# Sort ascending
df = df.sort_index()
# Remove exact duplicate timestamps (keep last)
df = df[~df.index.duplicated(keep='last')]
# Validate OHLCV constraints
# High must be >= Open, Close, Low
invalid_high = (df['high'] < df[['open', 'close', 'low']].max(axis=1))
df.loc[invalid_high, 'high'] = df.loc[invalid_high, ['open', 'close', 'low']].max(axis=1)
# Low must be <= Open, Close, High
invalid_low = (df['low'] > df[['open', 'close', 'high']].min(axis=1))
df.loc[invalid_low, 'low'] = df.loc[invalid_low, ['open', 'close', 'high']].min(axis=1)
# Remove zero/negative prices
price_cols = ['open', 'high', 'low', 'close']
df = df[(df[price_cols] > 0).all(axis=1)]
# Remove zero volume (for crypto, volume 0 = no trades, often data error)
df = df[df['volume'] > 0]
return df
def fill_gaps(df: pd.DataFrame, freq: str = '1h',
method: str = 'forward_fill',
max_gap_periods: int = 3) -> pd.DataFrame:
"""
Fill missing periods in time series.
method: 'forward_fill' | 'zero_volume' | 'interpolate'
max_gap_periods: gaps larger than this are left as NaN (don't fill large outages)
"""
full_index = pd.date_range(df.index[0], df.index[-1], freq=freq, tz='UTC')
df = df.reindex(full_index)
if method == 'forward_fill':
# Forward fill price, zero out volume for synthetic bars
df[['open', 'high', 'low', 'close']] = df[['open', 'high', 'low', 'close']].ffill(
limit=max_gap_periods
)
df['volume'] = df['volume'].fillna(0)
elif method == 'zero_volume':
# Fill close price only, set OHLC to close
df['close'] = df['close'].ffill(limit=max_gap_periods)
for col in ['open', 'high', 'low']:
df[col] = df[col].fillna(df['close'])
df['volume'] = df['volume'].fillna(0)
return df
def resample_ticks_to_ohlcv(ticks: pd.DataFrame, freq: str = '1min') -> pd.DataFrame:
"""
Convert tick data (price, volume per tick) to OHLCV bars.
ticks must have DatetimeIndex with 'price' and 'size' columns.
"""
resampled = ticks['price'].resample(freq).ohlc()
resampled['volume'] = ticks['size'].resample(freq).sum()
# Drop empty bars
resampled = resampled.dropna(subset=['open'])
return resampled
def resample_ohlcv(df: pd.DataFrame, from_freq: str, to_freq: str) -> pd.DataFrame:
"""Downsample OHLCV bars (e.g., 1m -> 1h)."""
resampled = df['open'].resample(to_freq).first().to_frame()
resampled['high'] = df['high'].resample(to_freq).max()
resampled['low'] = df['low'].resample(to_freq).min()
resampled['close'] = df['close'].resample(to_freq).last()
resampled['volume'] = df['volume'].resample(to_freq).sum()
return resampled.dropna()
-- TimescaleDB hypertable for OHLCV data
CREATE TABLE ohlcv (
time TIMESTAMPTZ NOT NULL,
symbol TEXT NOT NULL,
exchange TEXT NOT NULL,
open DOUBLE PRECISION,
high DOUBLE PRECISION,
low DOUBLE PRECISION,
close DOUBLE PRECISION,
volume DOUBLE PRECISION,
PRIMARY KEY (time, symbol, exchange)
);
-- Convert to hypertable (partition by time, 1-day chunks for tick data)
SELECT create_hypertable('ohlcv', 'time', chunk_time_interval => INTERVAL '1 day');
-- Index for symbol lookups
CREATE INDEX ON ohlcv (symbol, time DESC);
-- Continuous aggregate for hourly OHLCV from minute data
CREATE MATERIALIZED VIEW ohlcv_1h
WITH (timescaledb.continuous) AS
SELECT
time_bucket('1 hour', time) AS bucket,
symbol,
exchange,
first(open, time) AS open,
max(high) AS high,
min(low) AS low,
last(close, time) AS close,
sum(volume) AS volume
FROM ohlcv
GROUP BY bucket, symbol, exchange
WITH NO DATA;
SELECT add_continuous_aggregate_policy('ohlcv_1h',
start_offset => INTERVAL '2 hours',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 hour');
import asyncpg
import pandas as pd
from datetime import datetime
async def upsert_ohlcv(conn: asyncpg.Connection, df: pd.DataFrame,
symbol: str, exchange: str):
"""Efficient bulk upsert to TimescaleDB."""
records = [
(row.Index.to_pydatetime(), symbol, exchange,
row.open, row.high, row.low, row.close, row.volume)
for row in df.itertuples()
]
await conn.executemany(
"""
INSERT INTO ohlcv (time, symbol, exchange, open, high, low, close, volume)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (time, symbol, exchange) DO UPDATE
SET open=EXCLUDED.open, high=EXCLUDED.high, low=EXCLUDED.low,
close=EXCLUDED.close, volume=EXCLUDED.volume
""",
records
)
async def fetch_ohlcv(conn: asyncpg.Connection, symbol: str,
start: datetime, end: datetime,
timeframe: str = '1h') -> pd.DataFrame:
"""Fetch OHLCV from TimescaleDB continuous aggregate."""
view = f'ohlcv_{timeframe}'
rows = await conn.fetch(
f"SELECT * FROM {view} WHERE symbol=$1 AND bucket BETWEEN $2 AND $3 ORDER BY bucket",
symbol, start, end
)
return pd.DataFrame(rows, columns=['time', 'symbol', 'exchange',
'open', 'high', 'low', 'close', 'volume']
).set_index('time')
def deduplicate_ticks(ticks: pd.DataFrame,
timestamp_col: str = 'timestamp',
dedupe_window_ms: int = 100) -> pd.DataFrame:
"""
Remove near-duplicate ticks within a time window.
Exchange feeds often re-send ticks on reconnect.
"""
ticks = ticks.sort_values(timestamp_col)
# Round timestamps to dedupe window
ticks['_bucket'] = (ticks[timestamp_col].astype('int64') //
(dedupe_window_ms * 1_000_000))
# Keep first tick per (symbol, price, bucket)
deduped = ticks.drop_duplicates(
subset=['symbol', 'price', '_bucket'],
keep='first'
).drop(columns=['_bucket'])
return deduped
| Operation | Tool | Notes |
|---|---|---|
| OHLCV storage | TimescaleDB | Use hypertable + continuous aggregates |
| Tick storage | TimescaleDB | 1-day chunk interval |
| Resampling | pandas .resample() | Always sort first |
| Gap filling | ffill with limit | Cap limit to avoid fantasy data |
| Deduplication | drop_duplicates | Per-symbol, with time bucket |
| Adjustments (splits) | pandas_market_calendars | Use adjusted prices for signals |
ON CONFLICT| Anti-Pattern | Why It Fails | Correct Approach |
|---|---|---|
| Storing timestamps in local timezone instead of UTC | DST transitions cause duplicate or missing bars; cross-market analysis becomes a timezone conversion nightmare | Always store in UTC; convert to display timezone at query time; store original exchange timezone as metadata |
| Forward-filling gaps without a maximum fill limit | Weekend/holiday gaps get filled with 1000+ phantom bars; indicators calculated on fake data produce false signals | Cap ffill to a maximum number of bars (e.g., 5 for 1-minute data); mark filled bars with a flag column |
| Using adjusted prices for live trading signals | Adjusted prices are recalculated historically; live prices are unadjusted — mixing them creates ghost signals | Use raw prices for live trading; adjusted prices only for backtesting; store both and document which is which |
| No deduplication on data ingestion | Duplicate ticks from multiple feeds corrupt volume calculations and OHLCV aggregation | Use ON CONFLICT (upsert) on insert; deduplicate by timestamp + source; validate row counts after ingestion |
| Storing computed indicators in the database | Indicator parameters change frequently; stored indicators become stale; storage bloats | Store raw OHLCV only; compute indicators on-the-fly or cache in Redis; database is for source-of-truth data |
| Acknowledging a message before durably appending it to the raw log | A crash between ack and append silently loses events; capture, checkpoint, and replay diverge | Append-before-ack: durable raw-log append + fsync BEFORE the upstream ack and any downstream processing |
| Silent drops when the ingest→process queue saturates | Phantom gaps that look like market inactivity; signals computed on incomplete data | Bounded queue with an explicit, observable drop/degrade policy tied to feed-health facts |
| Replaying through a different code path than live | The replay proves nothing about live behaviour; bugs hide in the divergence | One decode/process path for both; validate replay by output hashes against the recorded live session |
| Treating timestamp continuity as sequence integrity | Reordered or dropped messages with plausible timestamps corrupt books undetected | Check the source sequence/checksum per demuxed channel; timestamp gaps are a separate concern |
| Continuing to process order-book deltas after an unrepairable drop | Every subsequent book state is wrong; strategies trade on a corrupt book | Mark the book corrupt and force a snapshot resync; never apply deltas to an invalid book |
See also: this skill owns tick/bar cleaning, dedup, and resampling — for US equity realtime market-STRUCTURE semantics (NBBO/SIP, L2, LULD/halt acceptance criteria), use equity-broker-execution §7.