简介:面向C#开发者的技术文档,系统讲解如何借助Tu Share开放接口获取股票交易信息。Tu Share是免费开源的财经数据接口,相比传统爬虫具有批量获取、速度快的优势,文档不仅介绍了该接口的基本功能与积分访问机制,还完整演示了从注册Tushare社区、获取个人Token,到理解HTTP API的api_name、token、params、fields输入参数及code、msg、data输出返回格式的全过程,并说明了如何根据返回码判断权限与调用异常。在此基础上,给出了基于HttpClient与Newtonsoft.Json的C#封装实现,包括IHttpClientUtility接口、JsonContent请求体构造,以及TuShareUtility中对post调用和DataTable结果转换的示例代码,配有stock_basic等接口的调用示范与异常处理,便于读者直接迁移到实际项目中。压缩包共1个文件,为212KB的PDF文档,排版清晰,适合随时查阅。该资源已有2346人学习,适合对量化数据接入、金融信息采集感兴趣的C#工程师参考借鉴,能快速落地批量股票数据获取需求。
1. C# 里接入 TuShare:从拿到行情到能落库的完整路径
很多做量化回测的 C# 开发者都有个尴尬:数据源要么贵,要么难调,要么只给了 Python SDK。于是「C# 获取股票交易信息」成了个高频诉求——你只是想拿到日线、复权因子、交易日历,最后却卡在接口认证和 JSON 解析上。TuShare 提供的是 HTTP 接口,本质上任何语言都能调,C# 用它只需要把 token 认证、POST 请求、动态字段解析这三件事理顺。这篇文章把我实际拆过的调用链路整理出来,覆盖从最基础的请求封装到增量落库,再到回测前最容易翻车的字段陷阱。适合写过 C#、想绕开 Python 依赖直接建本地行情库的开发者,也适合刚接触 TuShare、想知道它到底能不能顶住量化场景的人。
2. 请求链路与数据契约:先把 HTTP 调用和动态 JSON 解析打通
2.1 TuShare 的接口风格与 token 认证的注意点
TuShare 不是 RESTful 风格,它所有的接口都走同一个 HTTP 端点,用 POST 提交一个api_name来区分你要调的是日线、分钟线、交易日历还是财务指标。请求体是 JSON,外层固定是token和api_name,内层params才是各个接口的具体参数。这意味着你在 C# 里只需要维护一个统一入口,不必为每个数据接口单独写一个 URL,代价就是你得把接口名和参数表管理好,否则会变成一个巨大的switch。
token 的传递方式我见过不少人踩坑:它不是放在 Header 里,而是放进 POST body 的token字段。很多第一次用的人习惯性地往Authorization头里塞,结果返回错误码。我用 C# 封装时会把 token 读成配置项,而不是硬编码,因为不同积分等级能访问的接口范围不一样,换账号调试时改配置比改代码省事太多。
下面是最小可用的请求代码,只调一个交易日历接口,目的是先把链路跑通:
using System.Text; using System.Text.Json; public class TuShareClient { private readonly HttpClient _http; private readonly string _token; public TuShareClient(string token) { _token = token; _http = new HttpClient(); _http.Timeout = TimeSpan.FromSeconds(30); } public JsonDocument Call(string apiName, Dictionary<string, object> paramsDict) { var body = new Dictionary<string, object> { ["api_name"] = apiName, ["token"] = _token, ["params"] = paramsDict }; var content = new StringContent( JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); var resp = _http.PostAsync("https://api.tushare.pro", content).Result; resp.EnsureSuccessStatusCode(); var json = resp.Content.ReadAsStringAsync().Result; return JsonDocument.Parse(json); } } // 调用示例:取 2024 年交易日历 var client = new TuShareClient("你的token"); using var doc = client.Call("trade_cal", new Dictionary<string, object> { ["exchange"] = "SSE", ["start_date"] = "20240101", ["end_date"] = "20241231" });这段代码里有几个地方值得单独说。HttpClient用了同步的.Result方式,控制台工具和 Windows 服务里问题不大,但在 GUI 或 ASP.NET Core 里一定要换成await,否则高并发下容易死锁。超时时间设置了 30 秒,因为 TuShare 有些接口在数据量大时需要跑几秒甚至更久,默认的 100 秒太长,遇到限频卡住会很煎熬。返回的JsonDocument是逐步解析用的,不能跨线程共享,因为底层的JsonDocument在释放后访问会抛异常。
2.2 返回结构解析:fields与items的映射关系
TuShare 的返回体长这样:顶层是code、msg、data,其中data里又有fields和items。fields是字段名数组,items是二维数组,每一行是一条记录。这种设计对 Python 的 pandas 很友好,对 C# 来说就稍微多一步:你得把字段名和数据行拼成强类型对象或者字典列表。
我第一次写解析时走了弯路,以为items里的顺序和fields一定一致,于是直接按下标匹配。后来发现确实一致,但靠顺序去拿数据,代码很脆——哪天接口加了字段、调整了顺序,所有下游逻辑全都错位。更稳妥的做法是先把fields转成字典、拿到每个字段的下标,再逐行解析。
我一般把这一步封装成独立的转换函数,输出List<Dictionary<string, object>>,这样上层用Newtonsoft.Json或System.Text.Json序列化都行:
public static List<Dictionary<string, object>> ExtractRows(JsonElement data) { var result = new List<Dictionary<string, object>>(); var fields = data.GetProperty("fields") .EnumerateArray() .Select(f => f.GetString()) .ToArray(); foreach (var item in data.GetProperty("items").EnumerateArray()) { var row = new Dictionary<string, object>(); for (int i = 0; i < fields.Length; i++) { var value = item[i]; if (value.ValueKind == JsonValueKind.Null) row[fields[i]] = null; else if (value.ValueKind == JsonValueKind.Number) row[fields[i]] = value.GetDecimal(); else row[fields[i]] = value.GetString(); } result.Add(row); } return result; }这里把数字统一转成了decimal,是有意的。TuShare 里的价格、成交量、复权因子这些字段,很多是浮点数,用double在累加计算中可能累积误差,用decimal更稳妥。
字段值是null的情况也要注意——停牌、未上市、新股前的数据都可能缺字段。我最初直接把null存成了空字符串,导致后续计算把字符串当数字用,直接抛异常。上面代码统一把null保留,往下游传的时候在业务层再决定是跳过还是补默认值。
2.3 常用接口的参数速查:日线、复权因子、交易日历
这几个接口是搭建基础行情库最常用的,参数和返回值我给你列成一张表,方便后面写封装时对参数名。
| 接口名 | 核心参数 | 返回关键字段 | 常见用途 |
|---|---|---|---|
daily | ts_code/trade_date | openhighlowclosevolamount | 原始日线行情 |
adj_factor | ts_code/trade_date | adj_factor | 复权因子,配合 daily 计算后复权价 |
trade_cal | exchange/start_date/end_date | cal_dateis_open | 判断交易日、生成调度周期 |
daily接口要注意:它返回的是未复权价格。做回测和技术指标计算,一般要用后复权价,公式是后复权价 = 原始价 × 最新复权因子 / 当日复权因子。很多人上来直接用daily的close算均线,碰到除权除息日后曲线会出现假跳空,策略信号直接错乱。这个坑在后面避坑章节里我还专门展开。参数方面,ts_code格式是000001.SZ这种带交易所后缀的全代码,trade_date是YYYYMMDD格式的字符串,都没法用DateTime直接传,封装时要做好格式转换。
3. 写一个可复用的 C# TuShare 客户端:多接口统一封装与限频控制
3.1 通用请求封装:把 token 管理、错误码、重试机制收敛起来
上一章的TuShareClient已经能跑通单次请求,但真实项目里会有几个问题:接口调用失败要重试、限频时要有退避策略、错误码需要翻译成可读信息。我会把客户端收敛成一个带重试和日志的通用类,而不是每个业务方法各自拼请求。这里有一个设计取舍:TuShare 的接口参数五花八门,没必要为每个接口写一个强类型方法,统一入口加参数字典反而更灵活。代价是编译期类型检查没了,所以我会用一个静态类把接口名和必填参数集中管理起来,至少保证拼参时不容易打错。
关于限频,TuShare 的规则是按积分等级和接口区分频率的,比如基础积分下daily接口单次最大行数有限制,调用频率也有限制。高频率调用时返回的错误码会变成限频相关错误,这时候不能用固定等待时间,我直接实现一个指数退避:首次失败等 2 秒,第二次等 4 秒,最多等 30 秒。这样临时性的限频能自动恢复,不会因为重试太猛把自己封了。
核心代码是这样的:
public async Task<JsonDocument> CallWithRetryAsync(string apiName, Dictionary<string, object> paramsDict, int maxRetries = 3) { for (int attempt = 0; attempt < maxRetries; attempt++) { try { var doc = await CallAsync(apiName, paramsDict); // TuShare 返回的业务错误码 int code = doc.RootElement.GetProperty("code").GetInt32(); if (code != 0) { string msg = doc.RootElement.GetProperty("msg").GetString(); // 限频错误码一般是 40001 附近,具体看官方文档 if (code == 40001 && attempt < maxRetries - 1) { int waitMs = (int)Math.Pow(2, attempt + 1) * 1000; await Task.Delay(waitMs); continue; } throw new InvalidOperationException($"TuShare error {code}: {msg}"); } return doc; } catch (HttpRequestException ex) when (attempt < maxRetries - 1) { int waitMs = (int)Math.Pow(2, attempt + 1) * 1000; await Task.Delay(waitMs); } } throw new InvalidOperationException("Exhausted retry attempts"); }Math.Pow(2, attempt + 1) * 1000就是退避算法里等待毫秒数:第一次 2 秒、第二次 4 秒、第三次 8 秒。注意网络异常和业务错误码要分开处理:网络层异常(超时、连接中断)和限频错误都可以重试,但参数错误(比如代码不存在、日期格式错误)重试一百次也是同样的错误,直接抛出去更合理。我把参数错误和限频错误用错误码区分了,这是实际调试时效率最高的做法。
日志也建议在这里统一打。我会记录接口名、请求参数(token 脱敏)、耗时、返回码。时间长了你会发现,很多数据缺失问题不是代码逻辑错了,而是某个时间点接口返回了空数据,日志能帮你快速定位是上游问题还是下游问题。
3.2 用 data 类承载数据:把字典流转成强类型结构
有了通用客户端,下一步是数据承载方式。直接拿Dictionary<string, object>往下游传有两个问题:一是 key 拼写错了编译器不报错,二是取出来还要再类型转换。我的做法是定义一些 POCO 类,在客户端内部完成字典到实体的映射。拿日线数据举例:
public class DailyBar { public string TsCode { get; set; } public string TradeDate { get; set; } public decimal Open { get; set; } public decimal High { get; set; } public decimal Low { get; set; } public decimal Close { get; set; } public decimal Vol { get; set; } public decimal Amount { get; set; } } public List<DailyBar> GetDailyBars(string tsCode, string startDate, string endDate) { var param = new Dictionary<string, object> { ["ts_code"] = tsCode, ["start_date"] = startDate, ["end_date"] = endDate }; using var doc = _client.CallWithRetryAsync("daily", param).GetAwaiter().GetResult(); var data = doc.RootElement.GetProperty("data"); var rows = ExtractRows(data); return rows.Select(r => new DailyBar { TsCode = r["ts_code"]?.ToString(), TradeDate = r["trade_date"]?.ToString(), Open = (decimal)(r["open"] ?? 0), High = (decimal)(r["high"] ?? 0), Low = (decimal)(r["low"] ?? 0), Close = (decimal)(r["close"] ?? 0), Vol = (decimal)(r["vol"] ?? 0), Amount = (decimal)(r["amount"] ?? 0) }).ToList(); }DailyBar的属性类型设成decimal而不是double,前面说过了。GetAwaiter().GetResult()这种同步调用方式在控制台和后台任务里是可以接受的,但如果你的程序本身是异步的,直接改成await GetDailyBarsAsync更稳妥。我把同步版本和异步版本分开写了,避免在 GUI 线程里用.Result导致假死。
r["ts_code"]?.ToString()这里用了空传播,因为ExtractRows里已经把null保留下来了。日期字段用string类型而不是DateTime,原因是 TuShare 的日期格式YYYYMMDD虽然可以转成DateTime,但存库时日期字段统一用字符串反而更方便,SQL 比较和索引不受影响。等真正需要算日期差的地方再做转换,避免解析异常。
3.3 单次抓取的数据量与分页策略
TuShare 的限制是单次请求返回最大行数按接口和积分等级不一样,常见是几千行。daily接口按一整年、几百只股票去请求,肯定超过单次行数限制。这时候不能盲目地缩小日期范围去循环,因为每缩小一次就多一次请求,次数一多又会触发频率限制。
我一般先看行数估算:一只股票一年大约 240 条日线,如果你要取 3000 只股票一整年的数据,那就是 72 万行,得分几百次请求。这个数量级下,一次抓一年是合理的,但如果你积分等级低,单次只允许返回几千行,那就得改成按季度甚至按月分段拉取。
分页策略我推荐用日期切片而不是用页数偏移,因为 TuShare 的接口不一定支持页数参数。日期切片的核心是把时间区间切到「每次返回不超过限制行数」,而且要避免重复边界。下面是按季度切片的示例:
public List<DailyBar> GetAllDailyBars(string tsCode, string startDate, string endDate) { var result = new List<DailyBar>(); var current = DateTime.ParseExact(startDate, "yyyyMMdd", null); var end = DateTime.ParseExact(endDate, "yyyyMMdd", null); while (current <= end) { var quarterEnd = current.AddMonths(3).AddDays(-1); if (quarterEnd > end) quarterEnd = end; var bars = GetDailyBars( tsCode, current.ToString("yyyyMMdd"), quarterEnd.ToString("yyyyMMdd")); result.AddRange(bars); current = quarterEnd.AddDays(1); } return result; }这段代码把时间段切成季度,逐段调用前面的GetDailyBars。要注意最后一段的quarterEnd不能超过end,否则会多取数据。切片的粒度不是死的,你可以根据实际单次返回行数调整:如果每季度返回超过限制,就改成按月。这种切片方式代码简单,而且天然避免了重复数据——每段的起始日期是上一段结束日期的下一天。
4. 数据落地与增量更新:把行情快照做成可回查的本地表
4.1 表结构设计:行情表与复权因子表分开存
拿到数据后不建议直接扔内存里用完就丢,量化回测、策略验证都需要反复查历史数据。我建的库是两张核心表:一张存未复权日线,一张存复权因子。不把复权价直接算好存进去,是因为后复权价会随着最新交易日变化而变化:你今天拉到的最新的复权因子和一个月后的不一样,历史后复权价也就跟着变。如果直接存复权价,每次更新都要全表重算;分开存的话,只需要更新因子表,查询时现场算。
建表 SQL 大致是这个样子:
CREATE TABLE daily_bar ( ts_code VARCHAR(12) NOT NULL, trade_date VARCHAR(8) NOT NULL, open DECIMAL(10,4), high DECIMAL(10,4), low DECIMAL(10,4), close DECIMAL(10,4), vol DECIMAL(20,2), amount DECIMAL(20,2), PRIMARY KEY (ts_code, trade_date) ); CREATE TABLE adj_factor ( ts_code VARCHAR(12) NOT NULL, trade_date VARCHAR(8) NOT NULL, adj_factor DECIMAL(20,6), PRIMARY KEY (ts_code, trade_date) );主键用(ts_code, trade_date)的组合主键,这直接决定了增量更新的幂等性:重复插入同一交易日的数据时,用INSERT ... ON DUPLICATE KEY UPDATE或 SQL Server 的MERGE就能覆盖更新,不用先查后删。trade_date用VARCHAR(8)而不直接用DATE,纯粹是为了和 TuShare 的传参格式对齐,省得来回转换。但如果你后续要用 SQL 做日期比较或排序,VARCHAR(8)的字典序和日期序刚好一致,不影响范围查询。
vol 字段是成交量(手),amount 是成交金额(千元),这两个字段在 TuShare 里的单位口径和你在行情软件里看到的可能不一样,存库时不要自己换算,原值入库,业务层再按需处理。DECIMAL(20,2)对 amount 来说可能不够——部分大盘股日成交金额可以到几百亿(单位千元就是几千万),小数位还可能有精度问题。如果你要拿 amount 做分析,给它留大一点的精度。
4.2 全量初始化与增量更新:按交易日边界避免数据空洞
我第一次做全量初始化时,直接从头到尾一把梭,结果跑到中间断网,全部数据要重来。后来改成「全量分片 + 幂等入库」的组合策略:全量初始化时也按季度切片拉,每天维护一个「已更新到哪个交易日」的标记表,断点续跑只从上次停下的日期继续。
增量更新的关键不是查「今天有哪些新数据」,而是按交易日历去查「上次同步之后有哪些交易日」。因为周末、节假日不是交易日,直接拿自然日期去增量拉数据,会在节假日请求空数据,白白浪费次数。所以增量更新的第一步永远是调trade_cal拿到最近的交易日列表,再决定要拉哪些日期。
我按这个思路写了一个增量同步方法:
public void SyncDailyIncremental(string tsCode, string lastSyncDate) { // 1. 获取 lastSyncDate 之后所有交易日 var tradeDays = GetRecentTradeDays(lastSyncDate); foreach (var day in tradeDays) { var bars = GetDailyBars(tsCode, day, day); UpsertDailyBars(bars); // 2. 同步当日复权因子 var factors = GetAdjFactor(tsCode, day, day); UpsertAdjFactors(factors); } }GetRecentTradeDays内部调trade_cal接口,把is_open为 1 的日期筛出来。lastSyncDate初始可以设为某个历史日期,比如20240101,后续每天运行时只传上一次成功同步的最后一天。
这样做有个好处:如果某天接口抖动,只有那天的数据没同步,下次运行从那个日期继续,不会丢数据。坏处是每个交易日都要调两次接口(日线+因子),请求次数翻倍。对于几十上百只股票的同步任务,这个量是可以接受的;如果上万只股票,就需要用批量接口按交易日拉全部股票,那是另一个维度的工作量,需要更高的接口权限。
4.3 批量入库的两种姿势:逐条插与表值参数
增量数据入库时,逐条INSERT在数据量少时没问题,但一次同步几十天的全市场数据,逐条插会很慢。我 SQL Server 用得顺手时用表值参数,MySql 环境则直接拼INSERT INTO ... VALUES (...),(...)。这里注意参数化:直接拼 SQL 有注入风险,用参数化列表更稳妥。数据里没有用户输入,风险低,但这个习惯最好一直保持。
下面是我在 MySql 下批量写入的写法:
public void UpsertDailyBars(IEnumerable<DailyBar> bars) { if (!bars.Any()) return; var sb = new StringBuilder(); sb.Append("INSERT INTO daily_bar (ts_code, trade_date, open, high, low, close, vol, amount) VALUES "); var parameters = new List<MySqlParameter>(); int index = 0; foreach (var bar in bars) { sb.Append($"(@ts_code{index}, @trade_date{index}, @open{index}, @high{index}, @low{index}, @close{index}, @vol{index}, @amount{index}),"); parameters.Add(new MySqlParameter($"@ts_code{index}", bar.TsCode)); parameters.Add(new MySqlParameter($"@trade_date{index}", bar.TradeDate)); parameters.Add(new MySqlParameter($"@open{index}", bar.Open)); // ... 其余字段 index++; } sb.Length--; sb.Append(" ON DUPLICATE KEY UPDATE open=VALUES(open), high=VALUES(high), low=VALUES(low), close=VALUES(close), vol=VALUES(vol), amount=VALUES(amount)"); using var cmd = new MySqlCommand(sb.ToString(), _connection); cmd.Parameters.AddRange(parameters.ToArray()); cmd.ExecuteNonQuery(); }ON DUPLICATE KEY UPDATE里的VALUES(open)是取本次插入的值覆盖旧值,配合前面建表的组合主键,就能达到「有则更新、无则插入」的效果。注意 MySql 8.0.20 及以上版本废弃了VALUES()函数在ON DUPLICATE KEY UPDATE里的用法,更推荐的写法是直接引用列名别名。SQL Server 环境下我一般用MERGE,逻辑一样但语法差异大,这里不给通用万能写法,建议先确认你的数据库版本再选实现。
单批次插入的行数也要控制,我一般控制在 500 到 1000 行一批。批量太大,拼接的 SQL 文本和参数数组都会膨胀,数据库执行计划重建的代价也高;批量太小,又体现不出批量插入的优势。这个数字不需要精确,找到自己环境的稳定区间即可。
5. 避坑:TuShare 调用里我踩过的五个典型问题
5.1 除权除息日数据看起来连续,实际价格跳空了
现象:用daily接口拉某只股票日线,发现某一天收盘价从 20 元直接变成 15 元,K 线上出现巨大跳空,均线也跟着乱拐。
原因:未复权价格在除权除息日会做价格调整,但这个调整非常生硬,不是真实市场交易的跳空。比如 10 送 10,除权后价格直接腰斩,但你拿到的数据里没有标注这是除权日,容易当成暴跌信号。
解决:拉daily的同时一定要拉adj_factor复权因子,在查询端用close × 最新因子 / 当日因子计算后复权价。从那以后我建了条规矩:所有指标计算之前,先检查数据是不是复权后的,不复权就一律先算再说。
5.2 返回结果里大量 null,直接入库导致下游计算报错
现象:某些股票在上市前或停牌期间,daily接口返回的open、close是 null,入库后下游算涨跌幅时直接把 null 当 0,计算出离谱的负百分之百。
原因:TuShare 对无交易日期返回的是一行占位记录,各字段为空。我的入库逻辑最初没做 null 过滤,把占位行也写进去了。
解决:入库前过滤掉open、close、trade_date任一字段为 null 的行。判断条件要放在业务层而不是 SQL 层,因为 SQL 里NULL判断处理起来不直观,还容易误伤正常数据。另外,过滤逻辑只对行情表做,复权因子表的 null 要保留,因为停牌日因子为空属于正常情况。
5.3 单次请求拿不到全部数据,行数被静默截断
现象:拉一年日线,代码不报错,但返回的行数明显少于预期。一开始我以为接口数据缺失,排查了很久。
原因:TuShare 对单个请求返回的数据行数有上限,超过上限会被静默截断,不会报错。我积分等级对应的单次返回行数上限是几千行,直接用一整年做参数,数据被切了。
解决:不要按「自然时间跨度」做单次请求,要按「预期行数」做切片。比如预期一年约 240 行,一次请求取三年的数据比较安全;如果想取五年,把时间切成年或半年一次。每次请求后检查返回行数,如果和预期差异过大,及时缩小时间范围重新请求。
5.4 请求太频繁触发限频,代码不重试直接崩
现象:写了个循环批量抓取 3000 只股票日线,跑了几百次后突然连续报错,程序直接中断。
原因:TuShare 对接口调用频率有限制,超过频率后返回特定错误码。我的代码没有识别这个错误码,把限频错当成普通异常,也没有重试机制。
解决:把「识别限频错误码」和「指数退避重试」写进通用调用层,而不是每次循环单独判断。退避等待时间从 2 秒开始,指数递增,最多等 30 秒。另外,批量任务里建议加一个整体限速,比如每次请求之间固定 sleep 几百毫秒,宁可跑慢一点,也别触发限频。
5.5ts_code格式不统一,同一只票两种写法都能传
现象:取000001不带后缀能返回数据,取000001.SZ也能返回数据,但两者数据量不一致,合并时出现重复记录。
原因:TuShare 里ts_code标准格式是带交易所后缀的完整代码,不带后缀是部分接口的兼容写法。不同接口对代码格式的要求不完全一致,导致我传入的格式不同,返回结果也不同。
解决:全项目统一使用带后缀的ts_code,入库前做一次规范化:如果是纯数字代码,根据交易所规则补全后缀(上交所加.SH、深交所加.SZ、北交所加.BJ)。这个规范化函数写好后,所有入口都走它,避免下游数据重复。
6. 进阶:用交易日历驱动增量调度,顺手解决初始化与断点续跑
增量更新如果只是每天拉一次「今天的数据」,你会发现几个麻烦:节假日拉空、程序重启后不知道拉到哪里了、策略跑批时还想回补历史。这些问题的共同解法是:不按自然日期驱动,按交易日历驱动。把所有同步任务的起点和终点都限定在交易日上,再在本地维护一张「同步进度表」,每个数据接口对应一行记录,标记已同步到的最后交易日。
我把这个思路落成一个简单的调度循环。代码不复杂,但它是整个数据服务的地基,每次运行前先读进度、再决定拉哪些区间。
public class SyncScheduler { private readonly TuShareClient _client; private readonly SyncProgressRepo _repo; public void RunDailySync() { var today = DateTime.Now; var currentDate = today.ToString("yyyyMMdd"); // 1. 拿到最近交易日(含今天) var tradeDays = _client.GetTradeDays("SSE", currentDate, currentDate); if (tradeDays.Count == 0 || tradeDays[0] != currentDate) { return; // 今天不是交易日,跳过 } // 2. 逐个股票增量同步 var stockList = _repo.GetStockWatchlist(); foreach (var tsCode in stockList) { var lastSync = _repo.GetLastSyncDate(tsCode); _client.SyncDailyIncremental(tsCode, lastSync); _repo.SetLastSyncDate(tsCode, currentDate); } } }GetTradeDays传参里SSE表示上交所,这里只判断当天是否开市,交易所选哪个都可以,因为沪深两市交易日基本同步。SyncDailyIncremental内部会拉从lastSync到今日的所有交易日数据,天然具备补数据能力——如果昨天程序挂了,今天运行时lastSync还是前天,就会把昨天和今天的数据一起补上。
断点续跑是这个方案里最有价值的部分。进程崩溃、网络故障、数据库锁竞争,任何异常中断后,下次启动只要从进度表读位置继续跑就行,全量初始化也可以复用这套逻辑。我一般让全量初始化走单独的入口,进度初始值设为某个历史日期,增量入口的初始值设为全量完成后最后写的日期。两者共用同一个进度表,只是初始值不同,不会互相干扰。
在这套结构跑稳定之后,我又加了一步验证:每次同步完成后,随机抽几只股票,用本地库里最近 20 个交易日的收盘价算一组均线值,再取 TuShare 当日的实时数据做对比,误差超过千分之一就告警。数据链路里的问题很多是静默发生的,比如某天接口改了字段单位、某段数据返回了空,没有校验机制根本发现不了。我也把「同步后校验行数」写进了流程,每个接口同步完立刻数行数,行数异常直接抛异常回滚进度,避免带着半截数据往下跑。
从那以后我每次做新的数据接入,都强制走一遍「先确认返回行数、再确认字段精度、最后跑增量校验」的流程,省掉过很多次半夜被数据问题叫醒的麻烦。希望这篇记录能帮你的 C# 行情数据链路少走几步弯路。
本文还有配套的精品资源,点击获取