207 lines
8.9 KiB
C#
207 lines
8.9 KiB
C#
using Microsoft.Data.Sqlite;
|
|
|
|
namespace Simulator.Services
|
|
{
|
|
/// <summary>
|
|
/// Saver SQLite DB에서 1분봉을 읽어 요청한 granularity로 집계합니다.
|
|
///
|
|
/// DB 경로 구조: {RootPath}/{Exchange}/{ProductType}/{Symbol}/{Year}.db
|
|
/// </summary>
|
|
public class CandleReader : IDisposable
|
|
{
|
|
private readonly string _rootPath;
|
|
private readonly Dictionary<string, SqliteConnection> _connections = new();
|
|
|
|
public CandleReader(string rootPath)
|
|
{
|
|
_rootPath = Path.GetFullPath(rootPath);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// 공개 메서드
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
/// <summary>
|
|
/// 가상 시각 기준으로 최근 <paramref name="limit"/>개 봉을 반환합니다.
|
|
/// 마지막 봉(candles[^1])은 현재 진행 중인 봉(미완성)입니다.
|
|
/// </summary>
|
|
public List<SimCandle> GetCandles(
|
|
string exchange, string productType, string symbol,
|
|
string granularity, DateTimeOffset virtualNow, int limit)
|
|
{
|
|
int intervalMinutes = ParseGranularityMinutes(granularity);
|
|
|
|
// 현재 진행 중인 봉의 시작 시각
|
|
var currentBarOpen = FloorToInterval(virtualNow, intervalMinutes);
|
|
|
|
// limit개 봉의 시작 시각
|
|
var fetchFrom = currentBarOpen.AddMinutes(-(limit - 1) * intervalMinutes);
|
|
var fetchTo = virtualNow; // 현재 진행 봉은 virtualNow까지만 집계
|
|
|
|
var raw = ReadRawCandles(exchange, productType, symbol, fetchFrom, fetchTo);
|
|
|
|
return AggregateCandles(raw, fetchFrom, virtualNow, intervalMinutes, limit);
|
|
}
|
|
|
|
/// <summary>가상 시각 직전 1분봉의 종가를 현재 가격으로 반환합니다.</summary>
|
|
public double GetCurrentPrice(
|
|
string exchange, string productType, string symbol, DateTimeOffset virtualNow)
|
|
{
|
|
var raw = ReadRawCandles(exchange, productType, symbol,
|
|
virtualNow.AddMinutes(-2), virtualNow.AddMinutes(1));
|
|
|
|
return raw.Count > 0 ? raw[^1].c : 0;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// 집계
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
private static List<SimCandle> AggregateCandles(
|
|
List<RawCandle> raw,
|
|
DateTimeOffset from, DateTimeOffset virtualNow,
|
|
int intervalMinutes, int limit)
|
|
{
|
|
var result = new List<SimCandle>();
|
|
|
|
for (int i = 0; i < limit; i++)
|
|
{
|
|
var periodStart = from.AddMinutes((long)i * intervalMinutes);
|
|
var periodEnd = periodStart.AddMinutes(intervalMinutes);
|
|
|
|
// 현재 진행 봉은 virtualNow까지만
|
|
var effectiveEnd = periodEnd > virtualNow ? virtualNow : periodEnd;
|
|
|
|
long startMs = periodStart.ToUnixTimeMilliseconds();
|
|
long endMs = effectiveEnd.ToUnixTimeMilliseconds();
|
|
|
|
var slice = raw
|
|
.Where(c => c.tsMs >= startMs && c.tsMs < endMs)
|
|
.ToList();
|
|
|
|
if (slice.Count == 0) continue;
|
|
|
|
result.Add(new SimCandle
|
|
{
|
|
OpenTime = periodStart.ToUnixTimeMilliseconds(),
|
|
Open = slice[0].o,
|
|
High = slice.Max(c => c.h),
|
|
Low = slice.Min(c => c.l),
|
|
Close = slice[^1].c,
|
|
Volume = slice.Sum(c => c.v),
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// SQLite 읽기
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
private List<RawCandle> ReadRawCandles(
|
|
string exchange, string productType, string symbol,
|
|
DateTimeOffset from, DateTimeOffset to)
|
|
{
|
|
var result = new List<RawCandle>();
|
|
long fromMs = from.ToUnixTimeMilliseconds();
|
|
long toMs = to.ToUnixTimeMilliseconds();
|
|
|
|
for (int year = from.Year; year <= to.Year; year++)
|
|
{
|
|
var conn = GetConnection(exchange, productType, symbol, year);
|
|
if (conn == null) continue;
|
|
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = @"
|
|
SELECT open_time, open, high, low, close, volume
|
|
FROM candles
|
|
WHERE open_time >= @from AND open_time < @to
|
|
ORDER BY open_time ASC";
|
|
cmd.Parameters.AddWithValue("@from", fromMs);
|
|
cmd.Parameters.AddWithValue("@to", toMs);
|
|
|
|
using var reader = cmd.ExecuteReader();
|
|
while (reader.Read())
|
|
{
|
|
result.Add(new RawCandle(
|
|
reader.GetInt64(0),
|
|
reader.GetDouble(1),
|
|
reader.GetDouble(2),
|
|
reader.GetDouble(3),
|
|
reader.GetDouble(4),
|
|
reader.GetDouble(5)
|
|
));
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private SqliteConnection? GetConnection(
|
|
string exchange, string productType, string symbol, int year)
|
|
{
|
|
string path = Path.Combine(_rootPath, exchange, productType, symbol, $"{year}.db");
|
|
if (!File.Exists(path)) return null;
|
|
|
|
if (_connections.TryGetValue(path, out var existing))
|
|
return existing;
|
|
|
|
var conn = new SqliteConnection($"Data Source={path};Mode=ReadOnly");
|
|
conn.Open();
|
|
_connections[path] = conn;
|
|
return conn;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// 헬퍼
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
private static DateTimeOffset FloorToInterval(DateTimeOffset time, int intervalMinutes)
|
|
{
|
|
long totalMinutes = (long)(time - DateTimeOffset.UnixEpoch).TotalMinutes;
|
|
long floored = (totalMinutes / intervalMinutes) * intervalMinutes;
|
|
return DateTimeOffset.UnixEpoch.AddMinutes(floored);
|
|
}
|
|
|
|
public static int ParseGranularityMinutes(string granularity) => granularity.ToUpperInvariant() switch
|
|
{
|
|
"1M" => 1,
|
|
"3M" => 3,
|
|
"5M" => 5,
|
|
"15M" => 15,
|
|
"30M" => 30,
|
|
"1H" => 60,
|
|
"2H" => 120,
|
|
"4H" => 240,
|
|
"6H" => 360,
|
|
"12H" => 720,
|
|
"1D" => 1440,
|
|
"1W" => 10080,
|
|
_ => 30
|
|
};
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var c in _connections.Values) c.Dispose();
|
|
_connections.Clear();
|
|
}
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// 모델
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
public record RawCandle(long tsMs, double o, double h, double l, double c, double v);
|
|
|
|
public class SimCandle
|
|
{
|
|
public long OpenTime { get; set; }
|
|
public double Open { get; set; }
|
|
public double High { get; set; }
|
|
public double Low { get; set; }
|
|
public double Close { get; set; }
|
|
public double Volume { get; set; }
|
|
}
|
|
}
|