外汇行情还在更新吗?用 Python 订阅 EURUSD 并记录双时间

TickDB Research ·

适合正在开发外汇报价监控、需要确认脚本持续收到行情的 Python 开发者。使用 TickDB 的 WebSocket 行情订阅,记录 EURUSD 报价、数据时间和本机接收时间,生成原始消息与 CSV。通过目标报价数量、接收间隔和数据时间推进情况,区分连接仍在、消息到达与数据更新。本次30秒订阅观察收到38条目标报价;代码支持有限运行和自动保存结果,方便在自己的环境复查。

先把“还在更新”变成一份记录

你正在做一个外汇监控页面,EURUSD价格已经显示出来,终端也没报错。几秒后价格不动了:市场报价没变,还是脚本已经收不到消息?如果只留屏幕上最后一个价格,排查时很难还原发生了什么。

做法是给每条目标报价留下价格、数据时间和接收时间,再检查观察期内消息是否继续到达、数据时间是否推进。TickDB在这条流程中提供持续的外汇行情推送,Python脚本负责筛选EURUSD、记录本机时间并保存文件。这样能把页面上一个静止的数字,追溯到具体的接收记录。

TickDB在这里提供什么

TickDB为开发者提供统一的市场数据接口。本篇使用其中的WebSocket持续订阅:连接一次,订阅EURUSD的ticker频道,接收后续报价消息。一次性的REST或MCP查询可以取得快照;需要观察连续更新时,应使用对应的持续订阅通道。

官方ticker文档列出EURUSD消息中的last_price和毫秒级Unix时间戳timestamp,以及按数据可用性返回的买卖报价。数据时间来自消息,本机接收时间由脚本在收到消息后立即记录;文件保存和后面的判断由应用代码实现。

这次实际收到了什么

2026年10月28日,脚本在UTC 02:10:30至02:11:02之间完成连接和观察;连接后设置30秒接收窗口,收到38条EURUSD报价。相邻目标消息的数据时间变化了37次,没有观察到时间倒退。以下为前三条记录:

数据时间 UTC接收时间 UTClast_price
2026-10-28T02:10:33.305+00:002026-10-28T02:10:33.405+00:001.11242
2026-10-28T02:10:33.706+00:002026-10-28T02:10:33.816+00:001.11245
2026-10-28T02:10:33.803+00:002026-10-28T02:10:33.905+00:001.11240

38条是这次短窗口的实际计数,不是固定推送频率。再次运行时,记录数和价格会变化。

两个时间分别怎么用

保存字段来源用途
last_priceTickDB目标报价消息保留当条消息的价格,不先转成浮点数
data_time_ms、data_time_utc消息timestamp及其UTC显示检查数据时间是否继续变化或出现倒退
received_time_ms、received_time_utc本机收到消息后的系统时钟回查脚本何时读到这条消息
receive_gap_seconds本机单调时钟观察相邻目标报价的接收间隔,不受系统时钟校正直接影响

两个UTC时间相减可能包含源端时间定义、传输、缓冲、调度和本机时钟差异,不能直接当成网络延迟。这里只记录它们,不生成延迟指标。

运行代码,拿到自己的CSV

需要Python 3.11及以上版本。安装依赖:

python3 -m pip install websockets certifi

将下面代码保存为eurusd_monitor.py。运行时隐藏输入自己的TickDB API Key;已有TICKDB_API_KEY环境变量也可以直接读取。不要把Key写进代码或截图,账户是否具备目标标的权限以实际账户为准。

python3 eurusd_monitor.py --seconds 30 --out eurusd_run_01

输出目录必须尚不存在,以免覆盖上次记录。完整代码:

"""Python 3.11+; pip install websockets certifi. Bounded observation, not trading."""
import argparse
import asyncio
import csv
import getpass
import json
import os
import ssl
import time
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlencode
import certifi
import websockets


def utc(ms):
    return datetime.fromtimestamp(ms / 1000, timezone.utc).isoformat(timespec='milliseconds')


async def observe(seconds, out):
    key = os.environ.get('TICKDB_API_KEY') or getpass.getpass('TickDB API key (hidden): ')
    if not key.strip():
        raise ValueError('API key required')
    out.mkdir(parents=True, exist_ok=False)
    fields = ['symbol', 'last_price', 'bid_price', 'ask_price', 'data_time_ms',
              'data_time_utc', 'received_time_ms', 'received_time_utc', 'receive_gap_seconds']
    summary = {'started_utc': utc(time.time_ns() // 1000000), 'requested_seconds': seconds,
               'symbol': 'EURUSD', 'status': 'starting', 'quote_count': 0,
               'data_time_changes': 0, 'backward_data_times': 0, 'last_receive_age_seconds': None,
               'max_receive_gap_seconds': None, 'handshake_http_status': None}
    previous_mono = previous_data = last_receive_mono = None
    gaps = []
    try:
        uri = 'wss://api.tickdb.ai/v1/realtime?' + urlencode({'api_key': key})
        context = ssl.create_default_context(cafile=certifi.where())
        async with websockets.connect(uri, ssl=context, open_timeout=10, close_timeout=3,
                                      ping_interval=20, ping_timeout=20) as ws:
            summary['status'] = 'connected'
            summary['handshake_http_status'] = 101
            await ws.send(json.dumps({'cmd': 'subscribe', 'data': {
                'channel': 'ticker', 'symbols': ['EURUSD'], 'type': 'forex'}}))
            deadline = time.monotonic() + seconds
            with (out / 'messages.jsonl').open('w') as raw, (out / 'quotes.csv').open('w', newline='') as f:
                writer = csv.DictWriter(f, fieldnames=fields)
                writer.writeheader()
                while time.monotonic() < deadline:
                    try:
                        message = await asyncio.wait_for(ws.recv(), timeout=min(1, max(.001, deadline-time.monotonic())))
                    except asyncio.TimeoutError:
                        continue
                    received_ms = time.time_ns() // 1000000
                    received_mono = time.monotonic()
                    try:
                        decoded = json.loads(message)
                    except (ValueError, UnicodeDecodeError):
                        summary['invalid_json_count'] = summary.get('invalid_json_count', 0) + 1
                        continue
                    raw.write(json.dumps({'received_time_ms': received_ms, 'message': decoded}).replace(key, '[REDACTED]')+'\n')
                    raw.flush()
                    for item in decoded if isinstance(decoded, list) else [decoded]:
                        if not isinstance(item, dict):
                            continue
                        if item.get('cmd') == 'error':
                            summary['api_error_code'] = item.get('code')
                            raise RuntimeError('API subscription error')
                        data = item.get('data')
                        if item.get('cmd') != 'ticker' or not isinstance(data, dict) or data.get('symbol') != 'EURUSD':
                            continue
                        stamp = data.get('timestamp')
                        if isinstance(stamp, bool) or not isinstance(stamp, int) or not isinstance(data.get('last_price'), str):
                            summary['invalid_quote_count'] = summary.get('invalid_quote_count', 0) + 1
                            continue
                        gap = None if previous_mono is None else round(received_mono-previous_mono, 6)
                        if gap is not None:
                            gaps.append(gap)
                        if previous_data is not None:
                            summary['data_time_changes'] += int(stamp != previous_data)
                            summary['backward_data_times'] += int(stamp < previous_data)
                        row = dict(symbol='EURUSD', last_price=data['last_price'],
                                   bid_price=data.get('bid_price'), ask_price=data.get('ask_price'),
                                   data_time_ms=stamp, data_time_utc=utc(stamp),
                                   received_time_ms=received_ms, received_time_utc=utc(received_ms),
                                   receive_gap_seconds=gap)
                        writer.writerow(row)
                        f.flush()
                        summary['quote_count'] += 1
                        previous_mono = last_receive_mono = received_mono
                        previous_data = stamp
            summary['status'] = 'observed_quotes' if summary['quote_count'] else 'connected_without_target_quotes'
    except Exception as exc:
        summary['status'] = 'failed'
        summary['error_type'] = type(exc).__name__
        response = getattr(exc, 'response', None)
        summary['handshake_http_status'] = getattr(response, 'status_code', None)
        # Never print exception text: it may contain the authenticated URL.
    finally:
        summary['ended_utc'] = utc(time.time_ns() // 1000000)
        summary['last_receive_age_seconds'] = None if last_receive_mono is None else round(time.monotonic()-last_receive_mono, 6)
        summary['max_receive_gap_seconds'] = max(gaps) if gaps else None
        (out / 'summary.json').write_text(json.dumps(summary, indent=2)+'\n')
        print(json.dumps(summary, indent=2))
    return summary['status'] == 'observed_quotes'


if __name__ == '__main__':
    p = argparse.ArgumentParser()
    p.add_argument('--seconds', type=int, default=30)
    p.add_argument('--out', type=Path, default=Path('eurusd_observation'))
    a = p.parse_args()
    if not 1 <= a.seconds <= 300:
        p.error('--seconds must be 1..300')
    raise SystemExit(0 if asyncio.run(observe(a.seconds, a.out)) else 1)

程序最多接收指定时长,然后关闭连接。它使用WebSocket协议层的Ping/Pong检查连接;没有沿用旧示例中每秒发送应用ping的方式。当前官方连接建议也区分了协议层心跳与附加应用心跳。

运行后有三份文件:messages.jsonl保留带接收时间的消息,quotes.csv保存有效EURUSD报价,summary.json保存计数、状态和运行时间。握手失败时可能只生成摘要;查看status和错误类型,不要把没有CSV当成“收到零条正常行情”。脚本不会输出带Key的连接URL。

用结果判断下一步

observed_quotes表示观察期内确实读到了目标报价。结合quote_count、data_time_changes和CSV看更新是否持续出现;消息时间变化而价格不变仍可能是有效更新,不必要求每次价格都变化。

connected_without_target_quotes表示连接建立了,但窗口内没有有效EURUSD报价。先确认订阅对象、权限和原始控制消息,再考虑该时段是否有报价活动。连接心跳正常只帮助判断连接,不能替代目标报价更新的检查。

failed表示连接、订阅或接收过程出现错误。若有部分报价保存,仍应保留失败状态并定位中断。握手权限问题先处理权限;不要靠无限重试掩盖错误。

短窗口内连续收到消息,也不能据此确定中间没有遗漏。正式告警还需要结合自己的场景设置静默阈值、检查时间推进、处理重连和重新订阅;本脚本提供的是排查入口,不承接自动恢复或交易执行。

先到TickDB获取自己的接入凭据,用这段代码保存一份EURUSD记录,再检查CSV中最后几条数据时间是否推进、接收时间是否接近观察结束。把这一步接到你的监控页面,页面最后价格就有了可回查的来源。

相关文章

ChatGPT 查行情:用 MCP 对比美股、黄金、原油和外汇走势

本文通过 ChatGPT 网页版的一段实际对话,介绍 TickDB MCP 在美股实时行情、外汇实时行情及黄金走势查询中的用法,涵盖历史 K 线、跨市场比较、连续追问和报价更新,并以原油相关数据说明如何扩展观察名单。

TickDB Research ·

一个 API 同时接 A 股、港股、美股、加密和外汇,怎样验证才算数?

直接答案 多数人把行情 API 的覆盖列表当成菜单,以为列出来的就能点。实际上,那是一份意向书,不是交付承诺。判断一个 API 是否真正实现了多资产统一接入,你需要挑 A 股、港股、美股、加密货币和外汇各一个真实品种,用同一次查询验证五件事:同一次调用能否通过鉴权并返回结果,五个品种代码能否被正确解析,返回结构是否共享一套核心字段,时间戳格式是否明确且一致,以及无效 symbol 是报错、忽略

TickDB Research ·

Codex股票分析实测:用TickDB MCP查A股行情、财报数据和估值

用Codex分析A股,怎样把行情、财报数据和估值放在一起看?本文以寒武纪为例,实测通过TickDB MCP获取历史行情、单季财务与PE估值,结合公司公告核对期间和口径,再用Python生成事实卡、整理个股研究笔记。附可复制的任务指令、真实输入和可运行代码,适合个人研究者与AI投研产品团队,减少重复查数和整理。

TickDB Research ·

A股历史数据下载:回测结果变了,先别急着改策略

同一只股票,换一份历史数据,研究结果为什么对不上?这篇写给定期做A股研究、准备回测输入的小团队,以及负责研究功能的产品经理。我们用寒武纪的真实日线对照,拆开价格口径、样本范围和数据更新带来的差异,再说明怎样借助TickDB的历史行情、股票信息与复权查询,让研究表、脚本和AI工具有共同的数据依据。

TickDB Research ·

A股行情接口怎么选?AKShare、Tushare、TickDB按用途比较

给A股看板、研究页或AI助手选行情接口,产品经理首先要明确数据用于什么功能、需要怎样更新。本文按用途比较AKShare、Tushare与TickDB的具体入口,结合寒武纪的真实查询结果,给出候选选择表和一张填好的试用需求卡,方便与技术同事讨论接入。

TickDB Research ·