- 实时行情:东财push2主源 + 腾讯批量报价备用源(自动切换) - 历史K线:东财push2his主源 + 腾讯web子域备用源(含熔断机制) - 增量同步:支持 -incr/-since 参数按存档日期增量入库 - 策略回测:净值曲线/10指标/基准对比,含基本面过滤 - 用户认证:用户名密码 + 邮箱验证码双登录方式 - 前端:三Tab SPA(实时行情/策略回测/数据管理)
167 lines
5.9 KiB
Go
167 lines
5.9 KiB
Go
// Package fallback 实现 datasource.MarketDataSource 接口,提供数据源主备切换能力。
|
||
//
|
||
// 主数据源(如东财)用于实时行情和历史K线;当主数据源历史K线请求失败时,
|
||
// 自动切换到备用数据源(如腾讯),确保数据入库和回测的可用性。
|
||
// 实时行情(FetchConvertibles)支持主备切换:主数据源失败时,
|
||
// 走"东财 datacenter 列表 + 腾讯实时报价"的组合策略补充实时价格。
|
||
//
|
||
// 为提高数据源故障时的整体效率,fallback 内置熔断机制:主数据源K线连续
|
||
// 失败达到阈值后,进入熔断窗口,期间直接使用备用数据源,不再尝试主数据源;
|
||
// 熔断窗口结束后自动放行一次探测,成功则解除熔断,恢复主备切换。
|
||
package fallback
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log"
|
||
"sync"
|
||
"time"
|
||
|
||
"stock-analysis/internal/datasource"
|
||
"stock-analysis/internal/datasource/eastmoney"
|
||
"stock-analysis/internal/datasource/tencent"
|
||
"stock-analysis/internal/model"
|
||
)
|
||
|
||
const (
|
||
// circuitThreshold 主数据源连续失败达到该次数后触发熔断。
|
||
circuitThreshold = 3
|
||
// circuitCooldown 熔断持续时间,之后放行一次探测请求。
|
||
circuitCooldown = 10 * time.Minute
|
||
)
|
||
|
||
// New 创建主备切换数据源。
|
||
// primary 为主数据源(如东财),secondary 为备用数据源(如腾讯)。
|
||
func New(primary, secondary datasource.MarketDataSource) datasource.MarketDataSource {
|
||
return &dataSource{
|
||
primary: primary,
|
||
secondary: secondary,
|
||
}
|
||
}
|
||
|
||
// dataSource 包装主备两个数据源,实现 MarketDataSource 接口。
|
||
type dataSource struct {
|
||
primary datasource.MarketDataSource
|
||
secondary datasource.MarketDataSource
|
||
|
||
mu sync.Mutex
|
||
failCount int // 主数据源连续失败次数
|
||
openSince time.Time // 熔断开启时间(open=true 时有效)
|
||
open bool // 熔断是否开启
|
||
}
|
||
|
||
// FetchConvertibles 拉取全量可转债实时行情:先尝试主数据源 push2 接口,
|
||
// 失败时走"东财 datacenter 列表 + 腾讯批量实时报价"的组合策略补充实时价格。
|
||
//
|
||
// 主数据源 push2 故障是常见场景(东财对该集群限流较严),datacenter 与 push2 分属
|
||
// 不同集群,通常仍可用,故组合策略能保证实时行情可用性。
|
||
func (d *dataSource) FetchConvertibles(ctx context.Context) ([]model.ConvertibleBond, error) {
|
||
bonds, err := d.primary.FetchConvertibles(ctx)
|
||
if err == nil {
|
||
return bonds, nil
|
||
}
|
||
|
||
log.Printf("[fallback] 主数据源实时行情失败: %v,切换到组合策略(东财列表 + 腾讯报价)", err)
|
||
return d.fetchConvertiblesViaCombo(ctx)
|
||
}
|
||
|
||
// fetchConvertiblesViaCombo 组合策略实时行情:
|
||
// 1. 东财 datacenter 拉转债列表(含正股代码、初始转股价、上市日期)
|
||
// 2. 腾讯批量实时接口(qt.gtimg.cn)补充实时价格和涨跌幅
|
||
// 3. 过滤已退市转债(实时价格为 0)
|
||
func (d *dataSource) fetchConvertiblesViaCombo(ctx context.Context) ([]model.ConvertibleBond, error) {
|
||
em := eastmoney.NewClient()
|
||
bonds, err := em.FetchAllBondCodes(ctx)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("datacenter 拉取列表失败: %w", err)
|
||
}
|
||
if len(bonds) == 0 {
|
||
return nil, fmt.Errorf("datacenter 返回空列表")
|
||
}
|
||
log.Printf("[fallback] datacenter 拉取到 %d 只转债,开始腾讯实时报价", len(bonds))
|
||
|
||
if err := tencent.FetchQuotes(ctx, bonds); err != nil {
|
||
// 腾讯报价失败不致命,已退市转债会被 price=0 过滤
|
||
log.Printf("[fallback] 腾讯实时报价异常: %v", err)
|
||
}
|
||
|
||
// 过滤已退市转债(实时价格为 0),与项目硬约束一致
|
||
out := make([]model.ConvertibleBond, 0, len(bonds))
|
||
for _, b := range bonds {
|
||
if b.Price > 0 {
|
||
out = append(out, b)
|
||
}
|
||
}
|
||
log.Printf("[fallback] 组合策略完成:有效转债 %d 只(已过滤退市 %d 只)", len(out), len(bonds)-len(out))
|
||
return out, nil
|
||
}
|
||
|
||
// FetchKline 获取历史K线:先尝试主数据源,失败后自动切换到备用数据源。
|
||
// 熔断开启时直接使用备用数据源。
|
||
func (d *dataSource) FetchKline(ctx context.Context, code, klt, beg, end string) ([]model.Kline, error) {
|
||
// 熔断开启:直接走备用数据源
|
||
if d.circuitOpen() {
|
||
return d.fetchSecondary(ctx, code, klt, beg, end)
|
||
}
|
||
|
||
klines, err := d.primary.FetchKline(ctx, code, klt, beg, end)
|
||
if err == nil {
|
||
// 主数据源恢复,重置失败计数
|
||
d.recordResult(true)
|
||
return klines, nil
|
||
}
|
||
|
||
log.Printf("[fallback] 主数据源 K线失败 (%s %s %s~%s): %v,切换到备用数据源", code, klt, beg, end, err)
|
||
d.recordResult(false)
|
||
return d.fetchSecondary(ctx, code, klt, beg, end)
|
||
}
|
||
|
||
// fetchSecondary 从备用数据源获取K线并记录错误。
|
||
func (d *dataSource) fetchSecondary(ctx context.Context, code, klt, beg, end string) ([]model.Kline, error) {
|
||
klines, err := d.secondary.FetchKline(ctx, code, klt, beg, end)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("备用数据源: %w", err)
|
||
}
|
||
return klines, nil
|
||
}
|
||
|
||
// circuitOpen 判断是否处于熔断开启状态;熔断超时后自动放行探测。
|
||
func (d *dataSource) circuitOpen() bool {
|
||
d.mu.Lock()
|
||
defer d.mu.Unlock()
|
||
if !d.open {
|
||
return false
|
||
}
|
||
if time.Since(d.openSince) >= circuitCooldown {
|
||
// 熔断窗口结束,放行一次探测请求
|
||
d.open = false
|
||
d.failCount = 0
|
||
log.Printf("[fallback] 熔断窗口结束,放行主数据源探测")
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// recordResult 记录主数据源请求结果:失败达到阈值时开启熔断。
|
||
func (d *dataSource) recordResult(ok bool) {
|
||
d.mu.Lock()
|
||
defer d.mu.Unlock()
|
||
if ok {
|
||
d.failCount = 0
|
||
if d.open {
|
||
d.open = false
|
||
log.Printf("[fallback] 主数据源恢复,解除熔断")
|
||
}
|
||
return
|
||
}
|
||
d.failCount++
|
||
if d.open {
|
||
return
|
||
}
|
||
if d.failCount >= circuitThreshold {
|
||
d.open = true
|
||
d.openSince = time.Now()
|
||
log.Printf("[fallback] 主数据源连续失败 %d 次,开启熔断 %v,直接使用备用数据源", d.failCount, circuitCooldown)
|
||
}
|
||
}
|