虽然券商的app有更完善交易功能,但有时交易功能受到限制,下面写一个在生产环境跑通、具备完整行情获取、账户管理、风控拦截和订单提交能力的生产级系统。从后端的Python异步架构到前端的Glassmorphism交易界面,从时区自适应的全球化设计到API限频的自适应降级,每一个设计决策背后都有真实的工程考量。本文章仅供用于学术学习使用。完整代码在https://download.csdn.net/download/linweidong/92979866
读完本文,你将获得:
一套可运行的美股交易系统完整架构
券商OpenAPI的深度集成经验(含踩坑记录)
异步Python在金融场景下的工程实践
前后端分离的交易系统设计范式

一、系统架构总览
TradeOS采用经典的前后端分离架构,后端基于FastAPI构建异步API服务,前端是纯原生HTML/CSS/JS的单页应用。整体分为五个核心层:
根据下面信息,输出简约漂亮风格框架图:
这个分层不是随意的。每一层都有明确的职责边界:前端只管展示和交互,Web API层只管HTTP协议转换和鉴权,业务API层封装领域逻辑,基础设施层处理连接和配置。这种设计让每一层都可以独立测试和替换。
1.1 为什么选择FastAPI
在Python Web框架中选FastAPI而非Flask或Django,核心原因是原生异步支持。券商SDK的行情订阅、订单提交都是异步操作,FastAPI的async/await模型可以无缝对接,不会在I/O等待时阻塞事件循环。实测中,单个FastAPI worker可以同时处理数百个并发的行情轮询请求,而同步框架在这种场景下需要多线程才能达到同等吞吐。
另一个关键优势是自动生成的OpenAPI文档。交易系统的API接口就是给前端和策略引擎用的"合同",文档即代码,省去了手动维护接口文档的负担。
1.2 为什么不用React/Vue
前端选择原生JS而非框架,是有意为之的权衡。交易界面的交互模式相对固定——查行情、看持仓、下订单,不需要复杂的状态管理。原生JS的不到400行代码就能实现全部功能,而引入React后光是脚手架和依赖就有数百个文件。对于一个量化交易系统来说,前端的职责是"能用",核心价值在后端的策略和风控。
二、券商API深度集成
券商OpenAPI提供了Python SDK,封装了行情、交易、账户三大类API。但SDK只是工具,如何正确、高效、稳定地使用它,才是工程的核心挑战。
2.1 连接管理:单例模式与生命周期
BrokerClient采用单例模式,确保全局只有一个行情上下文(QuoteContext)和一个交易上下文(TradeContext)。这不是过度设计——券商SDK的连接是有状态的,重复创建会导致WebSocket连接泄漏和资源浪费。
class BrokerClient:_instance = None_initialized = Falsedef __new__(cls, *args, **kwargs):if cls._instance is None:cls._instance = super().__new__(cls)return cls._instance
连接初始化时有一个容易忽略的细节:enable_overnight参数。美股有盘前(4:00-9:30 ET)和盘后(16:00-20:00 ET)交易时段,如果这个参数不开启,SDK在非常规交易时段会断开推送。系统根据配置的region自动判断——US市场默认开启夜盘:
api_region = str(getattr(self.settings.api, "region", "")).upper()enable_overnight = api_region == "US"self.config = Config(app_key=self.app_key,app_secret=self.app_secret,access_token=self.access_token,enable_overnight=True,)
2.2 API限频:自适应降级策略
券商API有严格的频率限制,交易读接口30秒30次,行情接口也有类似约束。频繁触发限频会导致API返回429错误,严重时会被临时封禁。
系统在两个层面应对限频:
第一层:主动限流。TradingAPI内置了AsyncRateLimiter,在每次API调用前获取令牌:
class AsyncRateLimiter:def __init__(self, max_calls: int, period: float, min_interval: float = 0.0):self.max_calls = max_calls # 30次self.period = period # 30秒self.min_interval = min_interval # 最小间隔0.05秒
第二层:被动降级。当API返回限频错误时,AccountAPI的更新循环会自动将轮询间隔加倍,最高到120秒,连续成功后逐步恢复:
async def _update_loop(self):current_interval = base_intervalwhile self._running:try:await self._load_account_info()if current_interval > base_interval:current_interval = max(base_interval, current_interval / 2)except Exception as e:if "429002" in str(e) or "limited" in str(e).lower():current_interval = min(120.0, current_interval * 2)
这种指数退避+线性恢复的策略,比简单的固定间隔重试更优雅——它能在限频时快速退让,在恢复正常后不浪费等待时间。
2.3 在途请求复用
启动阶段或轮询高峰期,多个模块可能同时请求同一个API(比如get_order_detail)。如果不做处理,同一秒内会发出多个完全相同的请求,既浪费配额又增加限频风险。
BrokerClient实现了在途请求复用机制
self._inflight_requests: Dict[str, asyncio.Future] = {}async def _deduplicated_call(self, key, coro_fn, *args, **kwargs):async with self._inflight_lock:if key in self._inflight_requests:return await self._inflight_requests[key]future = asyncio.get_event_loop().create_future()self._inflight_requests[key] = futuretry:result = await coro_fn(*args, **kwargs)future.set_result(result)return resultexcept Exception as e:future.set_exception(e)raisefinally:async with self._inflight_lock:self._inflight_requests.pop(key, None)
第一个请求正常执行,后续的并发请求直接等待同一个Future,结果共享。这在高频轮询场景下能减少50%以上的重复API调用。
三、行情系统:从数据获取到前端展示
3.1 MarketAPI的设计
行情是交易系统的眼睛。MarketAPI封装了实时报价、K线数据、市场状态三大功能,核心设计原则是缓存优先、按需刷新。
实时报价的缓存策略是5秒过期:
async def get_quote(self, symbol: str, use_cache: bool = True) -> Optional[Quote]:if use_cache and symbol in self._quotes_cache:cached_quote = self._quotes_cache[symbol]if (get_current_time() - cached_quote.timestamp).total_seconds() < 5:return cached_quote# 缓存过期,调用API刷新response = await self.client.get_quote(symbol)
5秒是一个经过验证的平衡点——对于美股的实时行情,1秒太频繁(浪费API配额),10秒太迟钝(价格可能已经跳了几个tick)。前端每3秒轮询一次/api/quote/{symbol},配合5秒的服务端缓存,实际API调用频率约为每5秒1次。
3.2 时区自适应:全球化部署的关键
这是一个在开发阶段容易被忽略、但在生产环境会致命的问题。系统最初在中国开发,所有时间函数默认使用东八区(Asia/Shanghai)。当部署到美国服务器后,K线查询的锚点时间偏移了13小时,交易时间判断完全错误——系统认为美股已经收盘,实际上还没开盘。
修复方案是在utils/common.py中引入基于配置的时区推导:
_REGION_TZ_MAP = {"US": "America/New_York","HK": "Asia/Hong_Kong","CN": "Asia/Shanghai",}def _get_default_timezone() -> str:try:from config import get_settingssettings = get_settings()region = settings.api.regionif region.upper() in _REGION_TZ_MAP:return _REGION_TZ_MAP[region.upper()]except Exception:passreturn "UTC"def get_current_time(tz: str = None) -> datetime:if tz is None:tz = _get_default_timezone()return datetime.now(pytz.timezone(tz))
这样,配置文件中api.region: "US"就会让所有时间函数自动使用美东时区。is_trading_time函数也做了对应修改——判断美股交易时间时,先将当前时间转换到America/New_York时区,再与9:30-16:00比较:
def is_trading_time(market: str = "US", dt: datetime = None) -> bool:market_tz_map = {"US": "America/New_York","HK": "Asia/Hong_Kong","CN": "Asia/Shanghai",}if dt is None:dt = get_current_time(market_tz_map.get(market, _get_default_timezone()))else:if dt.tzinfo is not None:dt = dt.astimezone(pytz.timezone(market_tz_map.get(market, ...))) # 此时dt已经是市场本地时间,直接比较小时数
日志系统同样需要适配。loguru的格式化指令!UTC+8硬编码了东八区偏移,改为!local后自动跟随服务器本地时区。这个改动看似微小,但在排查线上问题时,日志时间与服务器时间不一致会导致巨大的混乱。

研报速递
发表评论
发表评论: