diff --git a/src/KArtSell.Host/Jobs/HistoricalBatchShadowRunJob.cs b/src/KArtSell.Host/Jobs/HistoricalBatchShadowRunJob.cs
new file mode 100644
index 00000000..8c13a1bc
--- /dev/null
+++ b/src/KArtSell.Host/Jobs/HistoricalBatchShadowRunJob.cs
@@ -0,0 +1,87 @@
+using Hangfire;
+using KArtSell.BuildingBlocks.Time;
+using KArtSell.Modules.ModelOperations.ShadowRun;
+using Microsoft.Extensions.Logging;
+
+namespace KArtSell.Host.Jobs;
+
+///
+/// Historical batch shadow run: processes full 1-year past data in single job.
+///
+/// Usage: Manually trigger or schedule daily at off-peak (e.g., 21:00 KST).
+/// Invokes ShadowRunJob with 1-year date range to complete Phase 1 validation.
+///
+/// Idempotency: Each run generates unique RunId + IdempotencyKey; safe to retry.
+///
+public sealed class HistoricalBatchShadowRunJob(
+ IBackgroundJobClient backgroundJobClient,
+ ShadowRunQueries queries,
+ IClock clock,
+ ILogger logger)
+{
+ private static readonly Action LogHistoricalBatchStarted =
+ LoggerMessage.Define(
+ LogLevel.Information,
+ new EventId(1, nameof(LogHistoricalBatchStarted)),
+ "Historical batch shadow run initiated: {Start:yyyy-MM-dd} to {End:yyyy-MM-dd}");
+
+ private static readonly Action LogBatchJobEnqueued =
+ LoggerMessage.Define(
+ LogLevel.Information,
+ new EventId(2, nameof(LogBatchJobEnqueued)),
+ "Historical batch job {RunId} enqueued; Hangfire job: {JobId}");
+
+ ///
+ /// Execute historical batch: load past 1 year of data and validate in single job.
+ ///
+ [Queue("q-research")]
+ [DisableConcurrentExecution(timeoutInSeconds: 300)] // 5 min to queue; job itself has 30 min
+ public async Task ExecuteAsync(Guid? modelId = null, CancellationToken cancellationToken = default)
+ {
+ // Use provided modelId or default to all-models sentinel
+ var targetModelId = modelId ?? Guid.Empty;
+
+ // Calculate 1-year window: 252 trading days ≈ 365 calendar days
+ var endDate = DateOnly.FromDateTime(clock.UtcNow.Date);
+ var startDate = endDate.AddDays(-365); // 1 year back
+
+ LogHistoricalBatchStarted(logger, startDate, endDate, null);
+
+ try
+ {
+ // Create unique run
+ var runId = Guid.NewGuid();
+ var idempotencyKey = Guid.NewGuid();
+ var correlationId = Guid.NewGuid();
+
+ // Pre-insert shadow_run with "Queued" status
+ var createdAt = clock.UtcNow;
+ await queries.InsertShadowRunQueuedAsync(
+ runId,
+ targetModelId,
+ startDate,
+ endDate,
+ createdAt,
+ CancellationToken.None);
+
+ // Enqueue ShadowRunJob with 1-year range
+ var command = new ShadowRunCommand(
+ ModelId: targetModelId,
+ CorrelationId: correlationId,
+ IdempotencyKey: idempotencyKey,
+ WindowStartDate: startDate,
+ WindowEndDate: endDate,
+ PhaseFilter: MarketPhaseFilter.All);
+
+ var jobId = backgroundJobClient.Enqueue(
+ job => job.ExecuteAsync(command, CancellationToken.None));
+
+ LogBatchJobEnqueued(logger, runId, jobId, null);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Historical batch shadow run failed to enqueue");
+ throw;
+ }
+ }
+}
diff --git a/src/KArtSell.Host/Jobs/ShadowRunJob.cs b/src/KArtSell.Host/Jobs/ShadowRunJob.cs
index c42adb80..1665f0a7 100644
--- a/src/KArtSell.Host/Jobs/ShadowRunJob.cs
+++ b/src/KArtSell.Host/Jobs/ShadowRunJob.cs
@@ -76,7 +76,7 @@ public sealed class ShadowRunJob(
"Shadow run {RunId} phase 4 (phase segmentation) complete");
[Queue("q-research")]
- [DisableConcurrentExecution(timeoutInSeconds: 3600)] // Max 60 minutes
+ [DisableConcurrentExecution(timeoutInSeconds: 1800)] // 30 min for bulk historical (252+ days)
[AutomaticRetry(Attempts = MaxAttempts, OnAttemptsExceeded = AttemptsExceededAction.Fail)]
public async Task ExecuteAsync(ShadowRunCommand command, CancellationToken cancellationToken = default)
{
diff --git a/src/KArtSell.Host/Program.cs b/src/KArtSell.Host/Program.cs
index 7c2f4b8c..e1a1a190 100644
--- a/src/KArtSell.Host/Program.cs
+++ b/src/KArtSell.Host/Program.cs
@@ -395,6 +395,7 @@ else
// Register other Hangfire jobs
var kstTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Seoul");
+try { RecurringJob.AddOrUpdate("historical-batch-shadow-run", job => job.ExecuteAsync(null, CancellationToken.None), "0 21 * * *", new RecurringJobOptions { TimeZone = kstTimeZone }); logger.LogInformation("✅ historical-batch-shadow-run registered (daily 21:00 KST)"); } catch (Exception ex) { logger.LogWarning(ex, "⚠️ historical-batch-shadow-run error"); }
try { RecurringJob.AddOrUpdate("opendart-daily-batch", job => job.ExecuteAsync(CancellationToken.None), "0 9 * * *", new RecurringJobOptions { TimeZone = kstTimeZone }); logger.LogInformation("✅ opendart-daily-batch registered"); } catch (Exception ex) { logger.LogWarning(ex, "⚠️ opendart-daily-batch error"); }
try { RecurringJob.AddOrUpdate("daily-recommendation", job => job.ExecuteAsync(CancellationToken.None), "0 9 * * *", new RecurringJobOptions { TimeZone = kstTimeZone }); logger.LogInformation("✅ daily-recommendation registered"); } catch (Exception ex) { logger.LogWarning(ex, "⚠️ daily-recommendation error"); }
try { RecurringJob.AddOrUpdate("weekly-recommendation", job => job.ExecuteAsync(CancellationToken.None), "0 9 * * 6", new RecurringJobOptions { TimeZone = kstTimeZone }); logger.LogInformation("✅ weekly-recommendation registered"); } catch (Exception ex) { logger.LogWarning(ex, "⚠️ weekly-recommendation error"); }