Price reminder and init approval

* Start price reminder grain

* Add config and init grain at startup

* Save init wallet when already init
This commit is contained in:
Oda
2025-09-13 02:29:14 +07:00
committed by GitHub
parent da50b30344
commit 56b4f14eb3
69 changed files with 2373 additions and 701 deletions

View File

@@ -0,0 +1,215 @@
using Managing.Application.Abstractions.Grains;
using Managing.Application.Abstractions.Repositories;
using Managing.Domain.Candles;
using Microsoft.Extensions.Logging;
using Orleans.Streams;
using static Managing.Common.Enums;
namespace Managing.Application.Grains;
/// <summary>
/// Grain for managing in-memory historical candle data with Orleans state persistence.
/// Subscribes to price streams and maintains a rolling window of 500 candles.
/// </summary>
public class CandleStoreGrain : Grain, ICandleStoreGrain, IAsyncObserver<Candle>
{
private readonly IPersistentState<CandleStoreGrainState> _state;
private readonly ILogger<CandleStoreGrain> _logger;
private readonly ICandleRepository _candleRepository;
private const int MaxCandleCount = 500;
private IAsyncStream<Candle> _priceStream;
private StreamSubscriptionHandle<Candle> _streamSubscription;
public CandleStoreGrain(
[PersistentState("candle-store-state", "candle-store")]
IPersistentState<CandleStoreGrainState> state,
ILogger<CandleStoreGrain> logger,
ICandleRepository candleRepository)
{
_state = state;
_logger = logger;
_candleRepository = candleRepository;
}
public override async Task OnActivateAsync(CancellationToken cancellationToken)
{
var grainKey = this.GetPrimaryKeyString();
_logger.LogInformation("CandleStoreGrain activated for key: {GrainKey}", grainKey);
// Parse the grain key to extract exchange, ticker, and timeframe
var parts = grainKey.Split('-');
if (parts.Length != 3)
{
_logger.LogError("Invalid grain key format: {GrainKey}. Expected format: Exchange-Ticker-Timeframe", grainKey);
return;
}
if (!Enum.TryParse<TradingExchanges>(parts[0], out var exchange) ||
!Enum.TryParse<Ticker>(parts[1], out var ticker) ||
!Enum.TryParse<Timeframe>(parts[2], out var timeframe))
{
_logger.LogError("Failed to parse grain key components: {GrainKey}", grainKey);
return;
}
// Initialize state if empty
if (_state.State.Candles == null || _state.State.Candles.Count == 0)
{
await LoadInitialCandlesAsync(exchange, ticker, timeframe);
}
// Subscribe to the price stream
await SubscribeToPriceStreamAsync(grainKey);
await base.OnActivateAsync(cancellationToken);
}
public override async Task OnDeactivateAsync(DeactivationReason reason, CancellationToken cancellationToken)
{
// Unsubscribe from the stream
if (_streamSubscription != null)
{
await _streamSubscription.UnsubscribeAsync();
_streamSubscription = null;
}
await base.OnDeactivateAsync(reason, cancellationToken);
}
public Task<List<Candle>> GetCandlesAsync()
{
try
{
return Task.FromResult(_state.State.Candles?.ToList() ?? new List<Candle>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving candles for grain {GrainKey}", this.GetPrimaryKeyString());
return Task.FromResult(new List<Candle>());
}
}
// Stream observer implementation
public async Task OnNextAsync(Candle candle, StreamSequenceToken token = null)
{
try
{
_logger.LogDebug("Received new candle for {GrainKey} at {Date}",
this.GetPrimaryKeyString(), candle.Date);
// Initialize state if needed
if (_state.State.Candles == null)
{
_state.State.Candles = new List<Candle>();
}
// Add the new candle
_state.State.Candles.Add(candle);
// Maintain rolling window of 500 candles
if (_state.State.Candles.Count > MaxCandleCount)
{
// Sort by date and keep the most recent 500
_state.State.Candles = _state.State.Candles
.OrderBy(c => c.Date)
.TakeLast(MaxCandleCount)
.ToList();
}
// Persist the updated state
await _state.WriteStateAsync();
_logger.LogTrace("Updated candle store for {GrainKey}, total candles: {Count}",
this.GetPrimaryKeyString(), _state.State.Candles.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing new candle for grain {GrainKey}", this.GetPrimaryKeyString());
}
}
public Task OnCompletedAsync()
{
_logger.LogInformation("Stream completed for grain {GrainKey}", this.GetPrimaryKeyString());
return Task.CompletedTask;
}
public Task OnErrorAsync(Exception ex)
{
_logger.LogError(ex, "Stream error for grain {GrainKey}", this.GetPrimaryKeyString());
return Task.CompletedTask;
}
private async Task LoadInitialCandlesAsync(TradingExchanges exchange, Ticker ticker, Timeframe timeframe)
{
try
{
_logger.LogInformation("Loading initial candles for {Exchange}-{Ticker}-{Timeframe}",
exchange, ticker, timeframe);
// Load the last 500 candles from the database
var endDate = DateTime.UtcNow;
var startDate = endDate.AddDays(-30); // Look back 30 days to ensure we get enough data
var candles = await _candleRepository.GetCandles(exchange, ticker, timeframe, startDate, endDate, MaxCandleCount);
if (candles?.Any() == true)
{
_state.State.Candles = candles
.OrderBy(c => c.Date)
.TakeLast(MaxCandleCount)
.ToList();
await _state.WriteStateAsync();
_logger.LogInformation("Loaded {Count} initial candles for {Exchange}-{Ticker}-{Timeframe}",
_state.State.Candles.Count, exchange, ticker, timeframe);
}
else
{
_state.State.Candles = new List<Candle>();
await _state.WriteStateAsync();
_logger.LogWarning("No initial candles found for {Exchange}-{Ticker}-{Timeframe}",
exchange, ticker, timeframe);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading initial candles for {Exchange}-{Ticker}-{Timeframe}",
exchange, ticker, timeframe);
// Initialize empty state on error
_state.State.Candles = new List<Candle>();
await _state.WriteStateAsync();
}
}
private async Task SubscribeToPriceStreamAsync(string streamKey)
{
try
{
var streamProvider = this.GetStreamProvider("DefaultStreamProvider");
_priceStream = streamProvider.GetStream<Candle>(streamKey);
_streamSubscription = await _priceStream.SubscribeAsync(this);
_logger.LogInformation("Subscribed to price stream for {StreamKey}", streamKey);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error subscribing to price stream for {StreamKey}", streamKey);
}
}
}
/// <summary>
/// State object for CandleStoreGrain containing the rolling window of candles
/// </summary>
[GenerateSerializer]
public class CandleStoreGrainState
{
[Id(0)]
public List<Candle> Candles { get; set; } = new();
}

View File

@@ -0,0 +1,172 @@
using Managing.Application.Abstractions.Grains;
using Managing.Application.Abstractions.Repositories;
using Managing.Application.Abstractions.Services;
using Managing.Common;
using Managing.Domain.Accounts;
using Managing.Domain.Candles;
using Microsoft.Extensions.Logging;
using Orleans.Concurrency;
using Orleans.Streams;
using static Managing.Common.Enums;
namespace Managing.Application.Grains;
/// <summary>
/// StatelessWorker grain for fetching 5-minute price data from external APIs and publishing to Orleans streams.
/// This grain runs every 5 minutes and processes all exchange/ticker combinations for the 5-minute timeframe.
/// </summary>
[StatelessWorker]
public class PriceFetcher5MinGrain : Grain, IPriceFetcher5MinGrain, IRemindable
{
private readonly ILogger<PriceFetcher5MinGrain> _logger;
private readonly IExchangeService _exchangeService;
private readonly ICandleRepository _candleRepository;
private readonly IGrainFactory _grainFactory;
private const string FetchPricesReminderName = "FetchPricesReminder";
// Predefined lists of trading parameters to fetch
private static readonly TradingExchanges[] SupportedExchanges =
{
TradingExchanges.GmxV2
};
private static readonly Ticker[] SupportedTickers = Constants.GMX.Config.SupportedTickers;
private static readonly Timeframe TargetTimeframe = Timeframe.FiveMinutes;
public PriceFetcher5MinGrain(
ILogger<PriceFetcher5MinGrain> logger,
IExchangeService exchangeService,
ICandleRepository candleRepository,
IGrainFactory grainFactory)
{
_logger = logger;
_exchangeService = exchangeService;
_candleRepository = candleRepository;
_grainFactory = grainFactory;
}
public override async Task OnActivateAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("PriceFetcher5MinGrain activated");
// Register a reminder to fetch prices every 5 minutes
await this.RegisterOrUpdateReminder(
FetchPricesReminderName,
TimeSpan.FromMinutes(5),
TimeSpan.FromMinutes(5));
await base.OnActivateAsync(cancellationToken);
}
public async Task<bool> FetchAndPublishPricesAsync()
{
try
{
_logger.LogInformation("Starting 5-minute price fetch cycle");
var fetchTasks = new List<Task>();
// Create fetch tasks for all exchange/ticker combinations for 5-minute timeframe
foreach (var exchange in SupportedExchanges)
{
foreach (var ticker in SupportedTickers)
{
fetchTasks.Add(FetchAndPublish(exchange, ticker, TargetTimeframe));
}
}
// Execute all fetch operations in parallel
await Task.WhenAll(fetchTasks);
_logger.LogInformation("Completed 5-minute price fetch cycle for {TotalCombinations} combinations",
fetchTasks.Count);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during price fetch cycle");
return false;
}
}
private async Task FetchAndPublish(TradingExchanges exchange, Ticker ticker, Timeframe timeframe)
{
try
{
// Create a dummy account for API calls (this may need to be adjusted based on your implementation)
var account = new Account
{
Name = "PriceFetcher",
Exchange = exchange,
Type = AccountType.Watch
};
// Get the last candle date from database
var existingCandles = await _candleRepository.GetCandles(exchange, ticker, timeframe,
DateTime.UtcNow.AddDays(-7), 1);
var startDate = existingCandles.Any()
? existingCandles.Max(c => c.Date).AddMinutes(GetTimeframeMinutes(timeframe))
: DateTime.UtcNow.AddDays(-1);
// Fetch new candles from external API
var newCandles = await _exchangeService.GetCandles(account, ticker, startDate, timeframe, true);
if (newCandles?.Any() == true)
{
var streamProvider = this.GetStreamProvider("DefaultStreamProvider");
var streamKey = $"{exchange}-{ticker}-{timeframe}";
var stream = streamProvider.GetStream<Candle>(streamKey);
_logger.LogDebug("Fetched {CandleCount} new candles for {StreamKey}",
newCandles.Count, streamKey);
// Process each new candle
foreach (var candle in newCandles.OrderBy(c => c.Date))
{
// Ensure candle has correct metadata
candle.Exchange = exchange;
candle.Ticker = ticker;
candle.Timeframe = timeframe;
// Save to database
await _candleRepository.InsertCandle(candle);
// Publish to stream
await stream.OnNextAsync(candle);
_logger.LogTrace("Published candle for {StreamKey} at {Date}",
streamKey, candle.Date);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching prices for {Exchange}-{Ticker}-{Timeframe}",
exchange, ticker, timeframe);
}
}
private static int GetTimeframeMinutes(Timeframe timeframe) => timeframe switch
{
Timeframe.OneMinute => 1,
Timeframe.FiveMinutes => 5,
Timeframe.FifteenMinutes => 15,
Timeframe.ThirtyMinutes => 30,
Timeframe.OneHour => 60,
Timeframe.FourHour => 240,
Timeframe.OneDay => 1440,
_ => 1
};
public async Task ReceiveReminder(string reminderName, TickStatus status)
{
if (reminderName == FetchPricesReminderName)
{
await FetchAndPublishPricesAsync();
}
}
}

View File

@@ -0,0 +1,25 @@
using Managing.Application.Abstractions.Grains;
using Microsoft.Extensions.Hosting;
namespace Managing.Application.Grains;
public class PriceFetcherInitializer : IHostedService
{
private readonly IClusterClient _clusterClient;
public PriceFetcherInitializer(IClusterClient clusterClient)
{
_clusterClient = clusterClient;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_clusterClient.GetGrain<IPriceFetcher5MinGrain>(0);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}