Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 0 additions & 42 deletions .dockerignore

This file was deleted.

44 changes: 0 additions & 44 deletions CryptoBotCore.sln

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
NotificationEntity::class,
WithdrawalThresholdEntity::class
],
version = 14,
version = 15,
exportSchema = true
)
@TypeConverters(Converters::class)
Expand Down Expand Up @@ -186,6 +186,13 @@ abstract class DcaDatabase : RoomDatabase() {
}
}

// Migration from version 14 to 15: Add [fiat, status] index on transactions
private val MIGRATION_14_15 = object : Migration(14, 15) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE INDEX IF NOT EXISTS index_transactions_fiat_status ON transactions (fiat, status)")
}
}

// Migration from version 9 to 10: Add notifications and withdrawal_thresholds tables
private val MIGRATION_9_10 = object : Migration(9, 10) {
override fun migrate(database: SupportSQLiteDatabase) {
Expand Down Expand Up @@ -288,7 +295,7 @@ abstract class DcaDatabase : RoomDatabase() {
DcaDatabase::class.java,
databaseName
)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15)
// Only allow destructive migration on app downgrade, never on failed upgrade
// This protects user's transaction history from accidental deletion
.fallbackToDestructiveMigrationOnDowngrade()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@ data class DcaPlanEntity(
Index(value = ["status"]),
Index(value = ["executedAt"]),
Index(value = ["planId", "status"]),
Index(value = ["crypto", "fiat", "status"])
Index(value = ["crypto", "fiat", "status"]),
Index(value = ["fiat", "status"])
]
)
@TypeConverters(Converters::class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,13 @@ class MarketDataService @Inject constructor(

private val cryptoDataCache = ConcurrentHashMap<String, CryptoDataCache>()
private val fetchMutexes = ConcurrentHashMap<String, Mutex>()
private var fearGreedCache: Pair<FearGreedData, Long>? = null
companion object {
private const val TAG = "MarketDataService"
private const val PRICE_CACHE_TTL_MS = 60 * 60 * 1000L // 1 hour
private const val ATH_CACHE_TTL_MS = 24 * 60 * 60 * 1000L // 24 hours
private const val ATH_NEAR_THRESHOLD = 0.10f // refresh ATH when price is within 10%
private const val FEAR_GREED_CACHE_TTL_MS = 60 * 60 * 1000L // 1 hour

// CoinGecko API (free, no auth required)
private const val COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"
Expand Down Expand Up @@ -161,8 +163,25 @@ class MarketDataService @Inject constructor(
return getCachedCryptoData(crypto, fiat)?.currentPrice
}

/**
* Get cached Fear & Greed index (1h TTL).
* Falls back to [getFearGreedIndex] on cache miss.
*/
suspend fun getCachedFearGreedIndex(): FearGreedData? {
val cached = fearGreedCache
if (cached != null && System.currentTimeMillis() - cached.second < FEAR_GREED_CACHE_TTL_MS) {
return cached.first
}
val data = getFearGreedIndex()
if (data != null) {
fearGreedCache = data to System.currentTimeMillis()
}
return data ?: cached?.first
}

fun invalidateCache() {
cryptoDataCache.clear()
fearGreedCache = null
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,19 @@ class SyncDailyPricesUseCase @Inject constructor(
delay(RATE_LIMIT_DELAY_MS)
}

// ── Phase 1b: Upsert today's real-time price (same source as Dashboard) ──
val realtimePrice = marketDataService.getCachedPrice(crypto, fiat)
if (realtimePrice != null) {
dailyPriceDao.insertPrices(listOf(
DailyPriceEntity(
crypto = crypto,
fiat = fiat,
dateEpochDay = today.toEpochDay(),
price = realtimePrice
)
))
}

// ── Phase 2: Historical backfill (CryptoCompare, one-time, backwards in chunks) ──
val earliestCachedDay = dailyPriceDao.getEarliestDay(crypto, fiat)
if (earliestCachedDay != null && earliestCachedDay > desiredStartDate.toEpochDay() + 1) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ fun DashboardScreen(
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
viewModel.refreshPreferences()
viewModel.refreshIfStale()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ class DashboardViewModel @Inject constructor(

companion object {
private const val TAG = "DashboardViewModel"
private const val STALENESS_THRESHOLD_MS = 5 * 60 * 1000L // 5 minutes
}

private val _uiState = MutableStateFlow(DashboardUiState())
Expand All @@ -106,6 +107,8 @@ class DashboardViewModel @Inject constructor(
private var lastServiceRunning: Boolean? = null
private var loadDataJob: Job? = null
private var refreshPricesJob: Job? = null
private var lastLoadedAt: Long = 0
private var lastMarketDataFetchedAt: Long = 0

init {
loadData()
Expand Down Expand Up @@ -177,11 +180,14 @@ class DashboardViewModel @Inject constructor(

// collectLatest cancels previous block on new emission,
// so these child coroutines are automatically cancelled
launch { fetchPricesForHoldings(mergedHoldings) }
launch { fetchBalancesForPlans(plans, isSandbox) }
// Fetch market indicators first (sequentially) to warm CryptoDataCache,
// so fetchPricesForHoldings gets cache hits via getCachedPrice
if (showMarketPulse) {
launch { fetchMarketIndicators(plans) }
}
launch { fetchPricesForHoldings(mergedHoldings) }
launch { fetchBalancesForPlans(plans, isSandbox) }
lastLoadedAt = System.currentTimeMillis()
}
}
}
Expand Down Expand Up @@ -351,12 +357,14 @@ class DashboardViewModel @Inject constructor(
}
}

private suspend fun fetchMarketIndicators(plans: List<DcaPlan>) {
private suspend fun fetchMarketIndicators(plans: List<DcaPlan>, force: Boolean = false) {
val now = System.currentTimeMillis()
if (!force && now - lastMarketDataFetchedAt < STALENESS_THRESHOLD_MS) return
_uiState.update { it.copy(isMarketDataLoading = true) }
try {
// Fetch Fear & Greed index
val fearGreed = try {
marketDataService.getFearGreedIndex()
marketDataService.getCachedFearGreedIndex()
} catch (e: Exception) {
Log.e(TAG, "Error fetching Fear & Greed index", e)
null
Expand All @@ -377,6 +385,7 @@ class DashboardViewModel @Inject constructor(
}

coroutineContext.ensureActive()
lastMarketDataFetchedAt = System.currentTimeMillis()
val currentCryptos = plans.map { it.crypto }.toSet()
_uiState.update { it.copy(
fearGreedData = fearGreed ?: it.fearGreedData,
Expand Down Expand Up @@ -414,6 +423,14 @@ class DashboardViewModel @Inject constructor(
}
}

fun refreshIfStale() {
refreshPreferences()
if (System.currentTimeMillis() - lastLoadedAt > STALENESS_THRESHOLD_MS) {
marketDataService.invalidateCache()
loadData()
}
}

fun refreshPreferences() {
val wasEnabled = _uiState.value.showMarketPulse
val nowEnabled = userPreferences.isMarketPulseEnabled()
Expand All @@ -425,7 +442,7 @@ class DashboardViewModel @Inject constructor(
}
if (!wasEnabled && nowEnabled) {
viewModelScope.launch {
fetchMarketIndicators(_uiState.value.activePlans.map { it.plan })
fetchMarketIndicators(_uiState.value.activePlans.map { it.plan }, force = true)
}
}
}
Expand All @@ -440,6 +457,7 @@ class DashboardViewModel @Inject constructor(
viewModelScope.launch {
_uiState.update { it.copy(isRefreshing = true, isPriceLoading = true) }
marketDataService.invalidateCache()
lastMarketDataFetchedAt = 0
refreshPricesJob?.cancel()
try {
val state = _uiState.value
Expand All @@ -448,7 +466,7 @@ class DashboardViewModel @Inject constructor(
launch { loadData() }
launch { fetchPricesForHoldings(state.holdings, manageLoadingState = false) }
if (state.showMarketPulse) {
launch { fetchMarketIndicators(plans) }
launch { fetchMarketIndicators(plans, force = true) }
}
}
} finally {
Expand Down
Loading
Loading