今天我們把前幾天的成果串起來,完成 選股工具的資料基礎建設:
IStockApiService
抓取股票的 日 K 線資料(近一年或增量補齊)。以下是最新版本的 Repository,專責管理:
// Repositories/LiteDbStockRepository.cs
using LiteDB;
using MyStockApp.Model;
using WpfTestApp.Model;
public class LiteDbStockRepository : IStockRepository
{
private readonly string _dbPath;
public LiteDbStockRepository(string dbPath = "StockData.db")
{
_dbPath = dbPath;
}
public void SaveStocks(IEnumerable<StockProfile> stocks)
{
using var db = new LiteDatabase(_dbPath);
var col = db.GetCollection<StockProfile>("stocks");
col.DeleteAll();
col.InsertBulk(stocks);
}
public IEnumerable<StockProfile> LoadStocks()
{
using var db = new LiteDatabase(_dbPath);
return db.GetCollection<StockProfile>("stocks").FindAll();
}
public DateTime? LoadLatestQuoteDate(string code)
{
using var db = new LiteDatabase(_dbPath);
var col = db.GetCollection<DailyQuote>("daily_quotes");
var last = col.Find(x => x.Code == code)
.OrderByDescending(x => x.Date)
.FirstOrDefault();
return last?.Date;
}
public void UpsertDailyQuotes(string code, IEnumerable<DailyQuote> quotes)
{
using var db = new LiteDatabase(_dbPath);
var col = db.GetCollection<DailyQuote>("daily_quotes");
col.EnsureIndex(x => new { x.Code, x.Date }, unique: true);
foreach (var q in quotes)
{
col.Upsert(q);
}
}
public void UpsertMissingDays(string code, IEnumerable<MissingQuoteDay> items)
{
using var db = new LiteDatabase(_dbPath);
var col = db.GetCollection<MissingQuoteDay>("missing_days");
col.EnsureIndex(x => new { x.Code, x.Date }, unique: true);
foreach (var item in items)
{
col.Upsert(item);
}
}
public IEnumerable<DailyQuote> LoadDailyQuotes(string code, DateTime from, DateTime to)
{
using var db = new LiteDatabase(_dbPath);
return db.GetCollection<DailyQuote>("daily_quotes")
.Find(x => x.Code == code && x.Date >= from && x.Date <= to)
.OrderBy(x => x.Date);
}
public void UpsertIndicators(string code, IEnumerable<TechIndicator> items)
{
using var db = new LiteDatabase(_dbPath);
var col = db.GetCollection<TechIndicator>("indicators");
col.DeleteMany(x => x.Code == code && x.Date >= DateTime.UtcNow.AddYears(-1));
col.InsertBulk(items);
col.EnsureIndex(x => new { x.Code, x.Date });
}
public TechIndicator? LoadIndicator(string code, DateTime date)
{
using var db = new LiteDatabase(_dbPath);
var col = db.GetCollection<TechIndicator>("indicators");
return col.Find(x => x.Code == code && x.Date <= date)
.OrderByDescending(x => x.Date)
.FirstOrDefault();
}
}
將原本Day 12做的在 ICrawler
的方法改個名稱IStockApiService:
/// <summary>
/// Data crawler capable of fetching stock lists and historical quotes.
/// 資料擷取介面:用於取得股票清單與歷史報價。
/// </summary>
public interface 的方法改個名稱IStockApiService
{
/// <summary>
/// Retrieves all stock profiles.
/// 取得所有股票主檔資料。
/// </summary>
IEnumerable<StockProfile> GetStockList();
/// <summary>
/// Crawls historical daily quotes for a single stock.
/// 取得單一股票於指定期間的每日行情。
/// </summary>
/// <param name="code">Stock code.</param>
/// <param name="startDate">Inclusive start date.</param>
/// <param name="endDate">Inclusive end date.</param>
IEnumerable<DailyQuote> GetDailyQuotes(string code, DateTime startDate, DateTime endDate);
}
}
實作的部分,這邊建議使用FinMind或Fugule的API來取得所有股票的基本資料,因TWSE(台灣證交所)的API資料只有上市公司的股票,並未包含上櫃(要去TPEX的API取),使用上FinMind或Fugule的API較為方便
這個服務會:
public class QuoteFetchService : IQuoteFetchService
{
private readonly IStockApiService _api;
private readonly IStockRepository _repo;
public QuoteFetchService(IStockApiService api, IStockRepository repo)
{
_api = api;
_repo = repo;
}
public Task<int> FetchLastYearAsync(CancellationToken ct = default)
{
int updated = 0;
var stocks = _repo.LoadStocks();
var today = DateTime.UtcNow.Date;
foreach (var s in stocks)
{
ct.ThrowIfCancellationRequested();
var latest = _repo.LoadLatestQuoteDate(s.Code);
DateTime from = latest?.AddDays(1) ?? today.AddYears(-1);
DateTime to = today;
if (from > to) continue;
var quotes = _api.GetDailyQuotes(s.Code, from, to).OrderBy(q => q.Date).ToList();
if (quotes.Count > 0)
{
_repo.UpsertDailyQuotes(s.Code, quotes);
updated++;
}
var expected = EnumerateBusinessDays(from, to).ToHashSet();
var returned = quotes.Select(q => q.Date.Date).ToHashSet();
var missing = expected.Except(returned)
.Select(d => new MissingQuoteDay
{
Code = s.Code,
Date = d,
Reason = "NoQuoteReturned"
});
_repo.UpsertMissingDays(s.Code, missing);
}
return Task.FromResult(updated);
}
private static IEnumerable<DateTime> EnumerateBusinessDays(DateTime from, DateTime to)
{
for (var d = from.Date; d <= to.Date; d = d.AddDays(1))
{
if (d.DayOfWeek != DayOfWeek.Saturday && d.DayOfWeek != DayOfWeek.Sunday)
yield return d;
}
}
}
IndicatorService
提供 BuildIndicators
,根據日 K 計算 KD / MACD。
(程式碼與前一篇相同,這裡不再重複,重點是:算出來的結果存回 DB → UpsertIndicators。)
今天完成了:
到這裡,我們已經有一個完整的「資料流」:
API → Repository → DB → 指標計算。