第4章 日历模块开发:构建可编程的经济日历

做外汇交易,最怕什么?

怕数据出来那一刻,行情瞬间打穿你的止损。我见过太多人,技术分析画得天花乱坠,结果非农数据一出,直接爆仓。说白了,宏观事件才是外汇市场的真正推手。

这一章,我们就来搞定经济日历模块。它能自动抓取ForexFactory等网站的事件时间与预期值。嗯,这是整个系统的「情报部门」。

4.1 为什么需要自己的经济日历?

你可能会问:「网上免费日历那么多,干嘛自己写?」

我刚开始也这么想。直到有一次,我的策略因为某个小国央行利率决议被打了个措手不及。那个事件在主流日历上根本没显示。你想想看,如果你的交易系统连事件都不知道,怎么提前规避风险?

自己写日历有几个好处:

  • 可控性:想抓什么事件就抓什么,不受第三方限制
  • 实时性:数据更新频率自己定,毫秒级响应
  • 可编程:直接对接交易策略,事件触发自动执行
  • 历史数据:积累自己的事件数据库,做回测分析

核心思路:经济日历不是给你看的,是给机器看的。它要能直接驱动交易决策。

4.2 整体架构设计

我们先画一张图,看看这个模块长什么样。

经济日历模块架构图 数据源层 ForexFactory / Investing.com 抓取层 Requests + BeautifulSoup 解析层 HTML解析 / 正则 存储层 SQLite / MongoDB 调度层 APScheduler 定时任务 接口层 REST API / WebSocket 策略层 事件驱动交易策略

这张图把模块分成了5层。从上往下看,数据从网站流进来,经过抓取、解析、存储,最后通过接口喂给交易策略。每一层各司其职,互不干扰。

4.3 数据抓取:跟ForexFactory打交道

ForexFactory的日历页面结构其实挺规整的。我抓了三年多,它的HTML结构基本没大变过。嗯,这里要注意一点——别太频繁请求,不然你的IP会被封。

来看核心代码:

import requests
from bs4 import BeautifulSoup
from datetime import datetime, timedelta
import time

class ForexFactoryCalendar:
    """ForexFactory经济日历抓取器"""
    
    def __init__(self):
        self.base_url = "https://www.forexfactory.com/calendar"
        self.headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
                          'AppleWebKit/537.36 (KHTML, like Gecko) '
                          'Chrome/91.0.4472.124 Safari/537.36'
        }
    
    def fetch_events(self, date=None):
        """
        抓取指定日期的事件
        :param date: datetime对象,默认今天
        :return: 事件列表
        """
        if date is None:
            date = datetime.now()
        
        # 构造URL参数
        params = {
            'day': date.strftime('%b%d.%Y').lower()
        }
        
        try:
            response = requests.get(
                self.base_url, 
                params=params,
                headers=self.headers,
                timeout=10
            )
            response.raise_for_status()
            
            # 解析HTML
            soup = BeautifulSoup(response.text, 'html.parser')
            return self._parse_events(soup)
            
        except requests.RequestException as e:
            print(f"抓取失败: {e}")
            return []
    
    def _parse_events(self, soup):
        """解析事件表格"""
        events = []
        table = soup.find('table', class_='calendar__table')
        
        if not table:
            return events
        
        rows = table.find_all('tr', class_='calendar__row')
        
        for row in rows:
            event = self._parse_row(row)
            if event:
                events.append(event)
            
            # 礼貌性延迟
            time.sleep(0.1)
        
        return events
    
    def _parse_row(self, row):
        """解析单行事件"""
        try:
            # 时间
            time_cell = row.find('td', class_='calendar__time')
            if not time_cell:
                return None
            event_time = time_cell.get_text(strip=True)
            
            # 货币
            currency_cell = row.find('td', class_='calendar__currency')
            currency = currency_cell.get_text(strip=True) if currency_cell else ''
            
            # 事件名称
            event_cell = row.find('td', class_='calendar__event')
            event_name = event_cell.get_text(strip=True) if event_cell else ''
            
            # 预期值
            forecast_cell = row.find('td', class_='calendar__forecast')
            forecast = forecast_cell.get_text(strip=True) if forecast_cell else ''
            
            # 前值
            previous_cell = row.find('td', class_='calendar__previous')
            previous = previous_cell.get_text(strip=True) if previous_cell else ''
            
            # 重要性(1-3星)
            impact_cell = row.find('td', class_='calendar__impact')
            impact = len(impact_cell.find_all('span', class_='impact--high')) if impact_cell else 0
            
            return {
                'time': event_time,
                'currency': currency,
                'event': event_name,
                'forecast': forecast,
                'previous': previous,
                'impact': impact,
                'timestamp': datetime.now()
            }
            
        except Exception as e:
            print(f"解析行失败: {e}")
            return None

避坑指南:我曾经因为没加User-Agent,被ForexFactory封了整整一周。后来发现,模拟浏览器请求头是最基本的反爬措施。另外,每次请求间隔0.1秒,别太贪心。

4.4 数据存储:让事件持久化

抓下来的数据不能丢内存里吧?得存起来。我个人习惯用SQLite,轻量、零配置,单机跑完全够用。

import sqlite3
from datetime import datetime

class CalendarStorage:
    """经济日历数据存储"""
    
    def __init__(self, db_path='calendar.db'):
        self.conn = sqlite3.connect(db_path)
        self._create_tables()
    
    def _create_tables(self):
        """建表"""
        cursor = self.conn.cursor()
        
        cursor.execute('''
            CREATE TABLE IF NOT EXISTS economic_events (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                event_date DATE NOT NULL,
                event_time TEXT,
                currency TEXT,
                event_name TEXT,
                forecast TEXT,
                previous TEXT,
                actual TEXT,
                impact INTEGER DEFAULT 0,
                source TEXT DEFAULT 'forexfactory',
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                UNIQUE(event_date, event_time, event_name, currency)
            )
        ''')
        
        # 索引加速查询
        cursor.execute('''
            CREATE INDEX IF NOT EXISTS idx_event_date 
            ON economic_events(event_date)
        ''')
        
        cursor.execute('''
            CREATE INDEX IF NOT EXISTS idx_currency 
            ON economic_events(currency)
        ''')
        
        self.conn.commit()
    
    def save_events(self, events, event_date):
        """批量保存事件"""
        cursor = self.conn.cursor()
        
        for event in events:
            try:
                cursor.execute('''
                    INSERT OR IGNORE INTO economic_events 
                    (event_date, event_time, currency, event_name, 
                     forecast, previous, impact)
                    VALUES (?, ?, ?, ?, ?, ?, ?)
                ''', (
                    event_date.strftime('%Y-%m-%d'),
                    event.get('time'),
                    event.get('currency'),
                    event.get('event'),
                    event.get('forecast'),
                    event.get('previous'),
                    event.get('impact')
                ))
            except Exception as e:
                print(f"保存事件失败: {e}")
        
        self.conn.commit()
    
    def get_events(self, date=None, currency=None, min_impact=0):
        """查询事件"""
        cursor = self.conn.cursor()
        
        query = "SELECT * FROM economic_events WHERE 1=1"
        params = []
        
        if date:
            query += " AND event_date = ?"
            params.append(date.strftime('%Y-%m-%d'))
        
        if currency:
            query += " AND currency = ?"
            params.append(currency)
        
        if min_impact > 0:
            query += " AND impact >= ?"
            params.append(min_impact)
        
        query += " ORDER BY event_time"
        
        cursor.execute(query, params)
        return cursor.fetchall()
    
    def close(self):
        self.conn.close()

注意:UNIQUE约束很重要。同一时间、同一货币、同一事件,只存一条。不然你的数据库里会塞满重复数据,查询起来慢得要命。

4.5 定时调度:让日历自动更新

手动跑脚本?太low了。我们要的是自动化。APScheduler这个库,我用下来感觉最顺手。

from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
import logging

class CalendarScheduler:
    """日历自动更新调度器"""
    
    def __init__(self):
        self.scheduler = BackgroundScheduler()
        self.calendar = ForexFactoryCalendar()
        self.storage = CalendarStorage()
        
        # 配置日志
        logging.basicConfig(
            level=logging.INFO,
            format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
        )
        self.logger = logging.getLogger('CalendarScheduler')
    
    def start(self):
        """启动调度"""
        
        # 每天凌晨2点更新当天数据
        self.scheduler.add_job(
            self._update_today,
            CronTrigger(hour=2, minute=0),
            id='update_today',
            name='更新当天日历'
        )
        
        # 每天凌晨3点更新未来7天数据
        self.scheduler.add_job(
            self._update_week,
            CronTrigger(hour=3, minute=0),
            id='update_week',
            name='更新未来一周日历'
        )
        
        # 每整点检查是否有新数据发布
        self.scheduler.add_job(
            self._check_updates,
            CronTrigger(minute=0),
            id='check_updates',
            name='检查数据更新'
        )
        
        self.scheduler.start()
        self.logger.info("日历调度器已启动")
    
    def _update_today(self):
        """更新今天的数据"""
        self.logger.info("开始更新今天的数据")
        events = self.calendar.fetch_events()
        self.storage.save_events(events, datetime.now())
        self.logger.info(f"今天的数据更新完成,共{len(events)}条")
    
    def _update_week(self):
        """更新未来一周的数据"""
        self.logger.info("开始更新未来一周的数据")
        today = datetime.now()
        
        for i in range(7):
            date = today + timedelta(days=i)
            events = self.calendar.fetch_events(date)
            self.storage.save_events(events, date)
            self.logger.info(f"{date.date()} 更新完成,共{len(events)}条")
    
    def _check_updates(self):
        """检查是否有新数据发布"""
        # 这里可以对比数据库中的最新时间和当前时间
        # 如果有新数据,触发更新
        pass
    
    def stop(self):
        """停止调度"""
        self.scheduler.shutdown()
        self.storage.close()
        self.logger.info("日历调度器已停止")

你想想看,有了这个调度器,你的日历模块就像个永不停歇的情报员。每天自动抓取、自动更新,你只管写策略就行。

4.6 事件驱动:让策略感知风险

数据存好了,怎么用?我设计了一个事件监听器,策略可以订阅特定事件。

import asyncio
from typing import Callable, Dict, List

class EventListener:
    """事件监听器,策略订阅特定事件"""
    
    def __init__(self):
        self.subscribers: Dict[str, List[Callable]] = {}
        self.running = False
    
    def subscribe(self, event_type: str, callback: Callable):
        """
        订阅事件
        :param event_type: 事件类型,如 'NFP', 'FOMC', 'CPI'
        :param callback: 回调函数
        """
        if event_type not in self.subscribers:
            self.subscribers[event_type] = []
        self.subscribers[event_type].append(callback)
        print(f"已订阅事件: {event_type}")
    
    def unsubscribe(self, event_type: str, callback: Callable):
        """取消订阅"""
        if event_type in self.subscribers:
            self.subscribers[event_type].remove(callback)
    
    async def notify(self, event: dict):
        """
        通知所有订阅者
        :param event: 事件数据
        """
        event_name = event.get('event', '')
        
        # 检查是否有匹配的订阅
        for event_type, callbacks in self.subscribers.items():
            if event_type in event_name.upper():
                for callback in callbacks:
                    try:
                        await callback(event)
                    except Exception as e:
                        print(f"回调执行失败: {e}")
    
    async def start_monitoring(self, check_interval=60):
        """开始监控新事件"""
        self.running = True
        storage = CalendarStorage()
        
        while self.running:
            # 获取未来1小时内的事件
            now = datetime.now()
            one_hour_later = now + timedelta(hours=1)
            
            events = storage.get_events(
                date=now,
                min_impact=2  # 只关注高重要性事件
            )
            
            for event in events:
                await self.notify({
                    'event': event[4],  # event_name
                    'currency': event[3],
                    'time': event[2],
                    'forecast': event[5],
                    'previous': event[6],
                    'impact': event[8]
                })
            
            await asyncio.sleep(check_interval)
        
        storage.close()
    
    def stop(self):
        self.running = False

实战用法:比如你的策略只做非农数据。订阅'NFP'事件,数据发布前5分钟自动平仓,发布后30秒根据实际值开仓。整个过程全自动,不需要你盯盘。

4.7 完整集成示例

最后,把上面所有模块串起来:

def main():
    """启动日历模块"""
    
    # 1. 初始化调度器
    scheduler = CalendarScheduler()
    
    # 2. 启动定时任务
    scheduler.start()
    
    # 3. 初始化事件监听
    listener = EventListener()
    
    # 4. 策略订阅事件
    async def on_nfp(event):
        print(f"非农数据来了: {event}")
        # 这里写你的交易逻辑
    
    listener.subscribe('NFP', on_nfp)
    
    # 5. 启动监控
    asyncio.run(listener.start_monitoring())
    
    try:
        # 保持程序运行
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        scheduler.stop()
        listener.stop()
        print("日历模块已关闭")

if __name__ == '__main__':
    main()

嗯,到这里,日历模块的核心就讲完了。说白了,就是抓数据、存数据、用数据这三步。但每一步都有坑,我踩过的那些坑都写在代码注释里了。你照着写,应该能少走不少弯路。

记住一点:经济日历不是摆设,它是你交易系统的眼睛。眼睛亮了,策略才能看得清方向。


公众号:蓝海资料掘金营,微信deep3321