using Microsoft.Data.Sqlite;
namespace Simulator.Services
{
///
/// Saver SQLite DB에서 1분봉을 읽어 요청한 granularity로 집계합니다.
///
/// DB 경로 구조: {RootPath}/{Exchange}/{ProductType}/{Symbol}/{Year}.db
///
public class CandleReader : IDisposable
{
private readonly string _rootPath;
private readonly Dictionary _connections = new();
public CandleReader(string rootPath)
{
_rootPath = Path.GetFullPath(rootPath);
}
// ════════════════════════════════════════════════════════════════
// 공개 메서드
// ════════════════════════════════════════════════════════════════
///
/// 가상 시각 기준으로 최근 개 봉을 반환합니다.
/// 마지막 봉(candles[^1])은 현재 진행 중인 봉(미완성)입니다.
///
public List 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);
}
/// 가상 시각 직전 1분봉의 종가를 현재 가격으로 반환합니다.
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 AggregateCandles(
List raw,
DateTimeOffset from, DateTimeOffset virtualNow,
int intervalMinutes, int limit)
{
var result = new List();
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 ReadRawCandles(
string exchange, string productType, string symbol,
DateTimeOffset from, DateTimeOffset to)
{
var result = new List();
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; }
}
}