diff --git a/accbot-android/app/build.gradle.kts b/accbot-android/app/build.gradle.kts index 6d0137f..9c0e897 100644 --- a/accbot-android/app/build.gradle.kts +++ b/accbot-android/app/build.gradle.kts @@ -73,6 +73,13 @@ android { buildConfig = true } + testOptions { + unitTests { + isIncludeAndroidResources = true + isReturnDefaultValues = true + } + } + experimentalProperties["android.experimental.enableScreenshotTest"] = true packaging { @@ -83,12 +90,19 @@ android { } +// Export Room schemas so future migrations can be tested against real prior versions +// with MigrationTestHelper. (Schemas land in app/schemas/.) +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} + dependencies { // Core Android implementation("androidx.appcompat:appcompat:1.7.0") implementation("androidx.core:core-ktx:1.17.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0") + implementation("androidx.lifecycle:lifecycle-process:2.10.0") implementation("androidx.activity:activity-compose:1.12.3") // Jetpack Compose @@ -148,6 +162,15 @@ dependencies { // Testing testImplementation("junit:junit:4.13.2") + // JVM unit tests: Robolectric + in-memory Room + coroutines/WorkManager test helpers + testImplementation("org.robolectric:robolectric:4.14.1") + testImplementation("androidx.test:core-ktx:1.6.1") + testImplementation("androidx.test.ext:junit-ktx:1.2.1") + testImplementation("androidx.room:room-testing:2.8.4") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") + testImplementation("androidx.work:work-testing:2.11.0") + testImplementation("androidx.arch.core:core-testing:2.2.0") + testImplementation("com.squareup.okhttp3:mockwebserver:5.3.0") androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0") diff --git a/accbot-android/app/src/main/java/com/accbot/dca/AccBotApplication.kt b/accbot-android/app/src/main/java/com/accbot/dca/AccBotApplication.kt index 947a8cc..96668c5 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/AccBotApplication.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/AccBotApplication.kt @@ -5,12 +5,19 @@ import android.util.Log import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat import androidx.hilt.work.HiltWorkerFactory +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner import androidx.work.Configuration import com.accbot.dca.data.local.CredentialsStore import com.accbot.dca.data.local.DcaDatabase import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.domain.usecase.ResolvePendingTransactionsUseCase import dagger.hilt.android.HiltAndroidApp +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import javax.inject.Inject @@ -31,6 +38,16 @@ class AccBotApplication : Application(), Configuration.Provider { @Inject lateinit var credentialsStore: CredentialsStore + @Inject + lateinit var resolvePendingTransactionsUseCase: ResolvePendingTransactionsUseCase + + /** + * App-scope coroutine scope for work that outlives any single ViewModel or + * Activity (lifecycle-observer callbacks, one-off startup tasks). SupervisorJob + * so a single failure doesn't cancel the whole scope. + */ + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + override val workManagerConfiguration: Configuration get() = Configuration.Builder() .setWorkerFactory(workerFactory) @@ -69,6 +86,22 @@ class AccBotApplication : Application(), Configuration.Provider { Log.e(TAG, "Failed to run CredentialsStore migration", e) } } + + // Poll for PENDING sell-order resolutions every time the app comes to the + // foreground. This gives users near-instant updates when they open the app + // after a sell has filled, without waiting for the next SellPollingWorker + // tick. Fire-and-forget; the use case is a no-op when there are no open sells. + ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + appScope.launch { + try { + resolvePendingTransactionsUseCase() + } catch (e: Exception) { + Log.w(TAG, "Polling on app start failed", e) + } + } + } + }) } companion object { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/MainActivity.kt b/accbot-android/app/src/main/java/com/accbot/dca/MainActivity.kt index 412b145..bdee31a 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/MainActivity.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/MainActivity.kt @@ -7,6 +7,7 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.os.PowerManager +import android.os.SystemClock import android.provider.Settings import androidx.appcompat.app.AppCompatActivity import androidx.activity.compose.setContent @@ -71,9 +72,14 @@ import com.accbot.dca.service.NotificationService import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.lifecycleScope import javax.inject.Inject +/** How long the app may stay in background before the biometric lock re-engages. */ +private const val BIOMETRIC_RELOCK_GRACE_MS = 30_000L + @AndroidEntryPoint class MainActivity : AppCompatActivity() { @@ -112,8 +118,35 @@ class MainActivity : AppCompatActivity() { setContent { val isSandboxMode = userPreferences.isSandboxMode() - var isUnlocked by rememberSaveable { mutableStateOf(false) } + // Plain remember, NOT rememberSaveable: saved instance state survives process + // death (system kills the app in background, task stays in recents), so a + // saveable flag would restore isUnlocked=true and silently skip the lock. + var isUnlocked by remember { mutableStateOf(false) } val biometricEnabled = userPreferences.isBiometricLockEnabled() + + // Re-lock when the app spends longer than a short grace period in background, + // so a quick app switch doesn't re-prompt but a real walk-away does. + if (biometricEnabled) { + DisposableEffect(Unit) { + var backgroundedAt = 0L + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_STOP -> + backgroundedAt = SystemClock.elapsedRealtime() + Lifecycle.Event.ON_START -> { + if (backgroundedAt != 0L && + SystemClock.elapsedRealtime() - backgroundedAt > BIOMETRIC_RELOCK_GRACE_MS + ) { + isUnlocked = false + } + } + else -> {} + } + } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } + } // Theme: collect reactive flow so changes apply immediately val appTheme by userPreferences.appThemeFlow.collectAsState() val darkTheme = when (appTheme) { @@ -433,7 +466,7 @@ fun AccBotApp( navController.navigate(Screen.EditPlan.createRoute(planId)) }, onNavigateToHistory = { crypto, fiat -> - navController.navigate(Screen.History.createRoute(crypto, fiat)) + navController.navigate(Screen.History.createRoute(crypto, fiat, planId)) }, onNavigateToTransactionDetails = { transactionId -> navController.navigate(Screen.TransactionDetails.createRoute(transactionId)) @@ -503,7 +536,8 @@ fun AccBotApp( route = Screen.History.route, arguments = listOf( navArgument("crypto") { type = NavType.StringType; nullable = true; defaultValue = null }, - navArgument("fiat") { type = NavType.StringType; nullable = true; defaultValue = null } + navArgument("fiat") { type = NavType.StringType; nullable = true; defaultValue = null }, + navArgument("planId") { type = NavType.StringType; nullable = true; defaultValue = null } ) ) { HistoryScreen( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataCollector.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataCollector.kt index 76fedfc..fb90c8f 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataCollector.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataCollector.kt @@ -130,7 +130,9 @@ class BackupDataCollector @Inject constructor( lastExecutedAt = lastExecutedAt?.toEpochMilli(), nextExecutionAt = nextExecutionAt?.toEpochMilli(), targetAmount = targetAmount?.toPlainString(), - connectionId = connectionId + connectionId = connectionId, + allowSells = allowSells, + targetProfitAmount = targetProfitAmount?.toPlainString() ) private fun TransactionEntity.toBackup() = BackupTransaction( @@ -149,7 +151,10 @@ class BackupDataCollector @Inject constructor( errorMessage = errorMessage, warningMessage = warningMessage, executedAt = executedAt.toEpochMilli(), - connectionId = connectionId + connectionId = connectionId, + side = side.name, + limitPrice = limitPrice?.toPlainString(), + requestedCryptoAmount = requestedCryptoAmount?.toPlainString() ) private fun NotificationEntity.toBackup() = BackupNotification( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt index 5489b46..bef27bb 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt @@ -343,7 +343,9 @@ class BackupDataRestorer @Inject constructor( createdAt = Instant.ofEpochMilli(createdAt), lastExecutedAt = lastExecutedAt?.let { Instant.ofEpochMilli(it) }, nextExecutionAt = effectiveNext, - targetAmount = targetAmount?.let { BigDecimal(it) } + targetAmount = targetAmount?.let { BigDecimal(it) }, + allowSells = allowSells, + targetProfitAmount = targetProfitAmount?.let { BigDecimal(it) } ) } @@ -363,7 +365,10 @@ class BackupDataRestorer @Inject constructor( exchangeOrderId = exchangeOrderId, errorMessage = errorMessage, warningMessage = warningMessage, - executedAt = Instant.ofEpochMilli(executedAt) + executedAt = Instant.ofEpochMilli(executedAt), + side = try { com.accbot.dca.domain.model.TransactionSide.valueOf(side) } catch (e: Exception) { com.accbot.dca.domain.model.TransactionSide.BUY }, + limitPrice = limitPrice?.let { BigDecimal(it) }, + requestedCryptoAmount = requestedCryptoAmount?.let { BigDecimal(it) } ) private fun BackupWithdrawal.toEntity(remappedPlanId: Long, connectionId: Long?) = WithdrawalEntity( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt index 076a406..3813a07 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt @@ -2,6 +2,7 @@ package com.accbot.dca.data.local import androidx.room.* import com.accbot.dca.domain.model.Exchange +import com.accbot.dca.domain.model.TransactionStatus import kotlinx.coroutines.flow.Flow import java.math.BigDecimal import java.time.Instant @@ -115,12 +116,18 @@ interface DcaPlanDao { @Query("UPDATE dca_plans SET networkRetryCount = 0, nextNetworkRetryAt = NULL, originalScheduledAt = NULL WHERE id = :planId") suspend fun resetNetworkRetry(planId: Long) + @Query("UPDATE dca_plans SET networkRetryCount = 0, nextNetworkRetryAt = NULL, originalScheduledAt = NULL WHERE id = :planId") + fun resetNetworkRetrySync(planId: Long) + @Query("UPDATE dca_plans SET missedPurchaseCount = :count WHERE id = :planId") suspend fun setMissedPurchaseCount(planId: Long, count: Int) @Query("UPDATE dca_plans SET missedPurchaseCount = 0 WHERE id = :planId") suspend fun resetMissedPurchaseCount(planId: Long) + @Query("UPDATE dca_plans SET missedPurchaseCount = MAX(missedPurchaseCount - 1, 0) WHERE id = :planId") + suspend fun decrementMissedPurchaseCount(planId: Long) + @Query("UPDATE dca_plans SET name = :name WHERE id = :planId") suspend fun renamePlan(planId: Long, name: String) @@ -209,6 +216,13 @@ interface TransactionDao { @Query("SELECT * FROM transactions WHERE planId = :planId ORDER BY executedAt DESC") fun getTransactionsByPlan(planId: Long): Flow> + /** + * One-shot snapshot of all transactions for a plan. Used by PnL / validation + * logic where a Flow isn't practical (suspend call from use case). + */ + @Query("SELECT * FROM transactions WHERE planId = :planId ORDER BY executedAt DESC") + suspend fun getTransactionsByPlanSync(planId: Long): List + @Query("SELECT * FROM transactions WHERE crypto = :crypto ORDER BY executedAt DESC") fun getTransactionsByCrypto(crypto: String): Flow> @@ -226,12 +240,14 @@ interface TransactionDao { WHERE (:crypto IS NULL OR crypto = :crypto) AND (:exchange IS NULL OR exchange = :exchange) AND (:status IS NULL OR status = :status) + AND (:planId IS NULL OR planId = :planId) ORDER BY executedAt DESC """) fun getFilteredTransactions( crypto: String?, exchange: String?, - status: String? + status: String?, + planId: Long? ): Flow> @Query("SELECT * FROM transactions ORDER BY executedAt DESC LIMIT :limit") @@ -240,12 +256,15 @@ interface TransactionDao { @Query("SELECT * FROM transactions WHERE id = :id") suspend fun getTransactionById(id: Long): TransactionEntity? - // Returns sum as String to avoid Double precision loss for monetary values - @Query("SELECT CAST(COALESCE(SUM(CAST(fiatAmount AS REAL)), 0) AS TEXT) FROM transactions WHERE fiat = :fiat AND status = 'COMPLETED'") + // Returns sum as String to avoid Double precision loss for monetary values. + // SELL excluded: this query represents money INVESTED via BUYs, not net cash flow. + @Query("SELECT CAST(COALESCE(SUM(CAST(fiatAmount AS REAL)), 0) AS TEXT) FROM transactions WHERE fiat = :fiat AND status = 'COMPLETED' AND side = 'BUY'") suspend fun getTotalInvestedByFiat(fiat: String): String - // Returns sum as String to avoid Double precision loss for crypto amounts - @Query("SELECT CAST(COALESCE(SUM(CAST(cryptoAmount AS REAL)), 0) AS TEXT) FROM transactions WHERE crypto = :crypto AND status = 'COMPLETED'") + // Returns sum as String to avoid Double precision loss for crypto amounts. + // SELL excluded: represents accumulated BUY volume, mirroring the dashboard's + // "total accumulated" KPI. + @Query("SELECT CAST(COALESCE(SUM(CAST(cryptoAmount AS REAL)), 0) AS TEXT) FROM transactions WHERE crypto = :crypto AND status = 'COMPLETED' AND side = 'BUY'") suspend fun getTotalCryptoBySymbol(crypto: String): String @Query("SELECT COUNT(*) FROM transactions WHERE status = 'COMPLETED'") @@ -275,9 +294,70 @@ interface TransactionDao { @Query("SELECT * FROM transactions WHERE status = 'PENDING' AND exchangeOrderId IS NOT NULL") suspend fun getPendingTransactionsWithOrderId(): List + @Query("SELECT * FROM transactions WHERE status IN ('PENDING', 'PARTIAL') AND exchangeOrderId IS NOT NULL") + suspend fun getResolvablePendingTransactions(): List + + @Query("SELECT COUNT(*) FROM transactions WHERE side = 'SELL' AND status IN ('PENDING', 'PARTIAL')") + suspend fun countOpenSells(): Int + + @Query(""" + SELECT * FROM transactions + WHERE planId = :planId + AND side = 'SELL' + AND status IN ('PENDING', 'PARTIAL') + ORDER BY executedAt DESC + """) + fun observeOpenSellsForPlan(planId: Long): Flow> + + @Query(""" + SELECT * FROM transactions + WHERE side = 'SELL' AND status IN ('PENDING', 'PARTIAL') + ORDER BY executedAt DESC + """) + fun observeAllOpenSells(): Flow> + + /** + * Guarded update for resolving an order. Only updates rows still in PENDING/PARTIAL + * state - prevents races with concurrent user cancel (which sets status=FAILED). + * Returns number of rows updated (0 = race lost, order state already changed). + */ + @Query(""" + UPDATE transactions + SET status = :newStatus, + cryptoAmount = :cryptoAmount, + fiatAmount = :fiatAmount, + price = :price, + fee = :fee + WHERE id = :id + AND status IN ('PENDING', 'PARTIAL') + """) + suspend fun updateResolvedTransaction( + id: Long, + newStatus: TransactionStatus, + cryptoAmount: BigDecimal, + fiatAmount: BigDecimal, + price: BigDecimal, + fee: BigDecimal + ): Int + @Query("SELECT exchangeOrderId FROM transactions WHERE planId = :planId AND exchangeOrderId IS NOT NULL") suspend fun getExchangeOrderIdsByPlan(planId: Long): List + /** + * All recorded exchange order IDs for a connection. Used by buy reconciliation to avoid + * re-recording an order that another plan on the same account already captured. + */ + @Query("SELECT exchangeOrderId FROM transactions WHERE connectionId = :connectionId AND exchangeOrderId IS NOT NULL") + suspend fun getExchangeOrderIdsByConnection(connectionId: Long): List + + /** + * Number of completed BUY transactions for a plan executed at or after [since]. + * Used by the runaway circuit breaker - counts real spend, including buys recovered + * via reconciliation, so it reflects what actually hit the exchange. + */ + @Query("SELECT COUNT(*) FROM transactions WHERE planId = :planId AND side = 'BUY' AND status = 'COMPLETED' AND executedAt >= :since") + fun countCompletedBuysSinceSync(planId: Long, since: Instant): Int + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertTransaction(transaction: TransactionEntity): Long @@ -299,12 +379,19 @@ interface TransactionDao { @Query("DELETE FROM transactions WHERE exchange = :exchange") suspend fun deleteTransactionsByExchange(exchange: Exchange) + /** + * Per-pair holdings used for dashboard / portfolio summaries. + * Sell-extension: SELL rows are excluded so the displayed "total accumulated" + * and "total invested" reflect BUY-only DCA activity. Realized P&L from sells + * is surfaced separately in the portfolio summary, not subtracted from these + * totals (avoids double-counting and keeps the historical chart logic stable). + */ @Query(""" SELECT crypto || '/' || fiat as pair, crypto, fiat, CAST(COALESCE(SUM(CAST(cryptoAmount AS REAL)), 0) AS TEXT) as totalCrypto, CAST(COALESCE(SUM(CAST(fiatAmount AS REAL)), 0) AS TEXT) as totalFiat, COUNT(*) as transactionCount - FROM transactions WHERE status = 'COMPLETED' + FROM transactions WHERE status = 'COMPLETED' AND side = 'BUY' GROUP BY crypto, fiat ORDER BY SUM(CAST(fiatAmount AS REAL)) DESC """) @@ -315,7 +402,7 @@ interface TransactionDao { CAST(COALESCE(SUM(CAST(cryptoAmount AS REAL)), 0) AS TEXT) as totalCrypto, CAST(COALESCE(SUM(CAST(fiatAmount AS REAL)), 0) AS TEXT) as totalFiat, COUNT(*) as transactionCount - FROM transactions WHERE status = 'COMPLETED' + FROM transactions WHERE status = 'COMPLETED' AND side = 'BUY' GROUP BY crypto, fiat ORDER BY SUM(CAST(fiatAmount AS REAL)) DESC """) @@ -327,18 +414,37 @@ interface TransactionDao { @Query("DELETE FROM transactions") suspend fun deleteAllTransactions() + /** + * Used by the portfolio chart pipeline. SELL excluded so the historical + * "invested / accumulated" series stays monotonic (sells would create + * counterintuitive dips in cumulative volume). Realized P&L is reported + * separately in the portfolio summary, see [getRealizedFiatByFiat]. + */ @Query(""" SELECT * FROM transactions - WHERE status = 'COMPLETED' + WHERE status = 'COMPLETED' AND side = 'BUY' AND (:exchange IS NULL OR exchange = :exchange) ORDER BY executedAt ASC """) suspend fun getCompletedTransactionsOrdered(exchange: String? = null): List - @Query("SELECT CAST(COALESCE(SUM(CAST(cryptoAmount AS REAL)), 0) AS TEXT) FROM transactions WHERE exchange = :exchange AND crypto = :crypto AND status = 'COMPLETED'") + /** + * Completed SELL transactions, ordered by execution. Used to draw chart timeline + * markers; kept separate from [getCompletedTransactionsOrdered] so the BUY-only + * invariant of the chart series pipeline isn't disturbed. + */ + @Query(""" + SELECT * FROM transactions + WHERE status = 'COMPLETED' AND side = 'SELL' + AND (:exchange IS NULL OR exchange = :exchange) + ORDER BY executedAt ASC + """) + suspend fun getCompletedSellsOrdered(exchange: String? = null): List + + @Query("SELECT CAST(COALESCE(SUM(CAST(cryptoAmount AS REAL)), 0) AS TEXT) FROM transactions WHERE exchange = :exchange AND crypto = :crypto AND status = 'COMPLETED' AND side = 'BUY'") suspend fun getTotalCryptoByExchangeAndCrypto(exchange: String, crypto: String): String - @Query("SELECT CAST(COALESCE(SUM(CAST(cryptoAmount AS REAL)), 0) AS TEXT) FROM transactions WHERE connectionId = :connectionId AND crypto = :crypto AND status = 'COMPLETED'") + @Query("SELECT CAST(COALESCE(SUM(CAST(cryptoAmount AS REAL)), 0) AS TEXT) FROM transactions WHERE connectionId = :connectionId AND crypto = :crypto AND status = 'COMPLETED' AND side = 'BUY'") suspend fun getTotalCryptoByConnectionAndCrypto(connectionId: Long, crypto: String): String @Query("SELECT * FROM transactions WHERE exchangeOrderId = :orderId LIMIT 1") @@ -353,8 +459,33 @@ interface TransactionDao { @Query("SELECT * FROM transactions WHERE exchangeOrderId = :orderId AND connectionId = :connectionId LIMIT 1") suspend fun getByExchangeOrderIdAndConnection(orderId: String, connectionId: Long): TransactionEntity? - @Query("SELECT CAST(COALESCE(SUM(CAST(cryptoAmount AS REAL)), 0) AS TEXT) FROM transactions WHERE planId = :planId AND status = 'COMPLETED'") + @Query("SELECT CAST(COALESCE(SUM(CAST(cryptoAmount AS REAL)), 0) AS TEXT) FROM transactions WHERE planId = :planId AND status = 'COMPLETED' AND side = 'BUY'") suspend fun getAccumulatedCryptoByPlan(planId: Long): String + + /** + * Total realized fiat from completed/partial SELL orders for a given fiat + * currency. Used by the portfolio summary to surface "Realized P&L" alongside + * the existing BUY-based totals. PARTIAL is included because partial fills + * have already booked their fiatAmount (= filled amount * avg fill price). + */ + @Query(""" + SELECT CAST(COALESCE(SUM(CAST(fiatAmount AS REAL)), 0) AS TEXT) + FROM transactions + WHERE fiat = :fiat AND side = 'SELL' AND status IN ('COMPLETED', 'PARTIAL') + """) + suspend fun getRealizedFiatByFiat(fiat: String): String + + /** + * Per-plan variant of [getRealizedFiatByFiat]. Same semantics, scoped to a + * single plan id so the per-plan portfolio summary can show realized P&L + * for that plan only. + */ + @Query(""" + SELECT CAST(COALESCE(SUM(CAST(fiatAmount AS REAL)), 0) AS TEXT) + FROM transactions + WHERE planId = :planId AND side = 'SELL' AND status IN ('COMPLETED', 'PARTIAL') + """) + suspend fun getRealizedFiatByPlan(planId: Long): String } data class CryptoFiatHolding( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt index 4227cfe..3e89b41 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt @@ -20,7 +20,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase WithdrawalThresholdEntity::class, ExchangeConnectionEntity::class ], - version = 20, + version = 22, exportSchema = true ) @TypeConverters(Converters::class) @@ -374,6 +374,41 @@ abstract class DcaDatabase : RoomDatabase() { } } + // Migration from version 20 to 21: Add sell extension fields to dca_plans and transactions. + // Enables opt-in limit sell orders, P&L tracking, and optional profit targets. + // + // Defaults: allowSells/side use ColumnInfo defaultValue (must match SQL DEFAULT exactly). + // Nullable columns intentionally OMIT `DEFAULT NULL` - Room schema validator treats + // nullable-with-no-Kotlin-default as "no SQL default", and explicit DEFAULT NULL + // would cause a schema mismatch. + private val MIGRATION_20_21 = object : Migration(20, 21) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE dca_plans ADD COLUMN allowSells INTEGER NOT NULL DEFAULT 0") + database.execSQL("ALTER TABLE dca_plans ADD COLUMN targetProfitAmount TEXT") + database.execSQL("ALTER TABLE transactions ADD COLUMN side TEXT NOT NULL DEFAULT 'BUY'") + database.execSQL("ALTER TABLE transactions ADD COLUMN limitPrice TEXT") + database.execSQL("ALTER TABLE transactions ADD COLUMN requestedCryptoAmount TEXT") + database.execSQL("CREATE INDEX IF NOT EXISTS index_transactions_planId_side_status ON transactions(planId, side, status)") + } + } + + // Migration 21 -> 22: backfill transactions.connectionId from the parent plan. + // Trade-history imports (and pre-v19 rows) left connectionId NULL, which breaks + // per-connection aggregation and backup-restore dedup. Only backfill real + // connections (> 0); leave NULL where the plan has no connection. + internal val MIGRATION_21_22 = object : Migration(21, 22) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + UPDATE transactions + SET connectionId = (SELECT p.connectionId FROM dca_plans p WHERE p.id = transactions.planId) + WHERE connectionId IS NULL + AND EXISTS (SELECT 1 FROM dca_plans p WHERE p.id = transactions.planId AND p.connectionId > 0) + """.trimIndent() + ) + } + } + // 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) { @@ -476,7 +511,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, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20) + .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, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22) // Only allow destructive migration on app downgrade, never on failed upgrade // This protects user's transaction history from accidental deletion .fallbackToDestructiveMigrationOnDowngrade() diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt index d98b377..4cc7e28 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt @@ -1,6 +1,7 @@ package com.accbot.dca.data.local import android.util.Log +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey @@ -9,6 +10,7 @@ import androidx.room.TypeConverters import com.accbot.dca.domain.model.DcaFrequency import com.accbot.dca.domain.model.DcaStrategy import com.accbot.dca.domain.model.Exchange +import com.accbot.dca.domain.model.TransactionSide import com.accbot.dca.domain.model.TransactionStatus import com.accbot.dca.domain.model.WithdrawalStatus import java.math.BigDecimal @@ -17,7 +19,7 @@ import java.time.Instant /** * Notification type for in-app notification history */ -enum class NotificationType { PURCHASE, ERROR, LOW_BALANCE, WITHDRAWAL_THRESHOLD, NETWORK_RETRY, MISSED_PURCHASES } +enum class NotificationType { PURCHASE, ERROR, LOW_BALANCE, WITHDRAWAL_THRESHOLD, NETWORK_RETRY, MISSED_PURCHASES, SELL_FILLED } /** * Room type converters @@ -99,6 +101,17 @@ class Converters { Log.w(TAG, "Unknown NotificationType '$value', falling back to ERROR") NotificationType.ERROR } + + @TypeConverter + fun fromTransactionSide(value: TransactionSide): String = value.name + + @TypeConverter + fun toTransactionSide(value: String): TransactionSide = try { + TransactionSide.valueOf(value) + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Unknown TransactionSide '$value', falling back to BUY") + TransactionSide.BUY + } } /** @@ -181,7 +194,18 @@ data class DcaPlanEntity( val originalScheduledAt: Instant? = null, val missedPurchaseCount: Int = 0, /** Order for Dashboard display. Lower values shown first. */ - val displayOrder: Int = 0 + val displayOrder: Int = 0, + /** + * Opt-in per-plan toggle for sell extension. When true (and global trading is enabled), + * plan-detail shows P&L card, open sell orders list, and sell wizard button. + */ + @ColumnInfo(defaultValue = "0") + val allowSells: Boolean = false, + /** + * Optional profit goal (in [fiat]). When set, plan-detail shows progress bar toward this. + * Null when user didn't specify a target. + */ + val targetProfitAmount: BigDecimal? = null ) /** @@ -198,7 +222,8 @@ data class DcaPlanEntity( Index(value = ["executedAt"]), Index(value = ["planId", "status"]), Index(value = ["crypto", "fiat", "status"]), - Index(value = ["fiat", "status"]) + Index(value = ["fiat", "status"]), + Index(value = ["planId", "side", "status"]) ] ) @TypeConverters(Converters::class) @@ -225,7 +250,21 @@ data class TransactionEntity( val exchangeOrderId: String? = null, val errorMessage: String? = null, val warningMessage: String? = null, - val executedAt: Instant = Instant.now() + val executedAt: Instant = Instant.now(), + /** + * BUY for DCA purchases (default), SELL for limit sell orders placed via sell extension. + */ + @ColumnInfo(defaultValue = "BUY") + val side: TransactionSide = TransactionSide.BUY, + /** + * Requested limit price for SELL orders; null for market BUYs. + */ + val limitPrice: BigDecimal? = null, + /** + * Original requested crypto amount for SELL orders (fixed across lifecycle). + * [cryptoAmount] tracks filled amount (progresses 0 -> requested). Null for BUYs. + */ + val requestedCryptoAmount: BigDecimal? = null ) /** diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/EntityMappers.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/EntityMappers.kt index f051652..4514dd9 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/EntityMappers.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/EntityMappers.kt @@ -23,7 +23,32 @@ fun DcaPlanEntity.toDomain() = DcaPlan( lastExecutedAt = lastExecutedAt, nextExecutionAt = nextExecutionAt, targetAmount = targetAmount, - displayOrder = displayOrder + displayOrder = displayOrder, + allowSells = allowSells, + targetProfitAmount = targetProfitAmount +) + +fun DcaPlan.toEntity() = DcaPlanEntity( + id = id, + exchange = exchange, + connectionId = connectionId, + name = name, + crypto = crypto, + fiat = fiat, + amount = amount, + frequency = frequency, + cronExpression = cronExpression, + strategy = strategy, + isEnabled = isEnabled, + withdrawalEnabled = withdrawalEnabled, + withdrawalAddress = withdrawalAddress, + createdAt = createdAt, + lastExecutedAt = lastExecutedAt, + nextExecutionAt = nextExecutionAt, + targetAmount = targetAmount, + displayOrder = displayOrder, + allowSells = allowSells, + targetProfitAmount = targetProfitAmount ) fun TransactionEntity.toDomain() = Transaction( @@ -42,7 +67,32 @@ fun TransactionEntity.toDomain() = Transaction( exchangeOrderId = exchangeOrderId, errorMessage = errorMessage, warningMessage = warningMessage, - executedAt = executedAt + executedAt = executedAt, + side = side, + limitPrice = limitPrice, + requestedCryptoAmount = requestedCryptoAmount +) + +fun Transaction.toEntity() = TransactionEntity( + id = id, + planId = planId, + exchange = exchange, + connectionId = connectionId, + crypto = crypto, + fiat = fiat, + fiatAmount = fiatAmount, + cryptoAmount = cryptoAmount, + price = price, + fee = fee, + feeAsset = feeAsset, + status = status, + exchangeOrderId = exchangeOrderId, + errorMessage = errorMessage, + warningMessage = warningMessage, + executedAt = executedAt, + side = side, + limitPrice = limitPrice, + requestedCryptoAmount = requestedCryptoAmount ) fun NotificationEntity.toDomain() = AppNotification( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/NotificationTemplateArgs.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/NotificationTemplateArgs.kt index 973b844..5771009 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/NotificationTemplateArgs.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/NotificationTemplateArgs.kt @@ -177,6 +177,24 @@ sealed class NotificationTemplateArgs { }.toString() } + /** Limit sell order filled (PENDING/PARTIAL -> COMPLETED). */ + data class SellFilled( + val cryptoAmount: String, + val crypto: String, + val fiatAmount: String, + val fiat: String, + val price: String + ) : NotificationTemplateArgs() { + override fun toJson(): String = JSONObject().apply { + put(KEY_TYPE, TYPE_SELL_FILLED) + put("cryptoAmount", cryptoAmount) + put("crypto", crypto) + put("fiatAmount", fiatAmount) + put("fiat", fiat) + put("price", price) + }.toString() + } + companion object { private const val KEY_TYPE = "type" private const val TYPE_PURCHASE = "purchase" @@ -189,6 +207,7 @@ sealed class NotificationTemplateArgs { private const val TYPE_NETWORK_RETRY = "network_retry" private const val TYPE_MISSED_PURCHASES = "missed_purchases" private const val TYPE_MISSING_CREDENTIALS = "missing_credentials" + private const val TYPE_SELL_FILLED = "sell_filled" fun fromJson(json: String): NotificationTemplateArgs? = try { val obj = JSONObject(json) @@ -253,6 +272,13 @@ sealed class NotificationTemplateArgs { exchangeName = obj.getString("exchangeName"), connectionName = obj.optString("connectionName", "") ) + TYPE_SELL_FILLED -> SellFilled( + cryptoAmount = obj.getString("cryptoAmount"), + crypto = obj.getString("crypto"), + fiatAmount = obj.getString("fiatAmount"), + fiat = obj.getString("fiat"), + price = obj.getString("price") + ) else -> null } } catch (_: Exception) { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/UserPreferences.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/UserPreferences.kt index 0f428b4..40972c7 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/UserPreferences.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/UserPreferences.kt @@ -2,6 +2,7 @@ package com.accbot.dca.data.local import android.content.Context import android.content.SharedPreferences +import com.accbot.dca.domain.model.DcaFrequency import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -226,6 +227,87 @@ class UserPreferences @Inject constructor( prefs.edit().putString(KEY_PORTFOLIO_SELECTED_PAGE, pageId).apply() } + // ==================== Trading (Sell Extension) ==================== + + /** + * Master trading switch. When false, all sell-order flows (take-profit, + * trailing, manual limit-sell) are disabled regardless of per-plan settings. + * Defaults to false so upgrading users must explicitly opt in. + */ + fun isTradingEnabled(): Boolean { + return prefs.getBoolean(KEY_TRADING_ENABLED, false) + } + + fun setTradingEnabled(enabled: Boolean) { + prefs.edit().putBoolean(KEY_TRADING_ENABLED, enabled).apply() + } + + // ==================== Sell Polling ==================== + + /** + * Whether the background [SellPollingWorker] is enabled. + * Independent from [isTradingEnabled] so users can keep polling on for + * historical fills even after disabling new sell orders, and off by default. + */ + fun isPeriodicSellPollingEnabled(): Boolean { + return prefs.getBoolean(KEY_SELL_POLLING_ENABLED, false) + } + + /** + * Polling frequency. For CUSTOM, [getSellPollingCronExpression] provides + * the cron string. Falls back to HOURLY if the stored enum name can't be + * parsed (e.g. after downgrading from a build that added a new frequency). + */ + fun getSellPollingFrequency(): DcaFrequency { + val name = prefs.getString(KEY_SELL_POLLING_FREQUENCY, DcaFrequency.HOURLY.name) + ?: return DcaFrequency.HOURLY + return try { + DcaFrequency.valueOf(name) + } catch (e: IllegalArgumentException) { + DcaFrequency.HOURLY + } + } + + /** + * Cron expression used when [getSellPollingFrequency] returns CUSTOM. + * Null for preset frequencies. + */ + fun getSellPollingCronExpression(): String? { + return prefs.getString(KEY_SELL_POLLING_CRON, null) + } + + /** + * Serialized visual schedule builder state (JSON). Null for preset frequencies. + * The worker ignores this - it's only used by the UI to re-hydrate the + * schedule picker without having to reverse-engineer the cron string. + */ + fun getSellPollingScheduleConfig(): String? { + return prefs.getString(KEY_SELL_POLLING_SCHEDULE_CONFIG, null) + } + + /** + * Update all sell-polling settings in one edit so readers never see a + * half-applied state (e.g. CUSTOM frequency with a stale cron from a + * previous save). + */ + fun setPeriodicSellPolling( + enabled: Boolean, + frequency: DcaFrequency, + cron: String?, + scheduleConfig: String? + ) { + prefs.edit() + .putBoolean(KEY_SELL_POLLING_ENABLED, enabled) + .putString(KEY_SELL_POLLING_FREQUENCY, frequency.name) + .apply { + if (cron != null) putString(KEY_SELL_POLLING_CRON, cron) + else remove(KEY_SELL_POLLING_CRON) + if (scheduleConfig != null) putString(KEY_SELL_POLLING_SCHEDULE_CONFIG, scheduleConfig) + else remove(KEY_SELL_POLLING_SCHEDULE_CONFIG) + } + .apply() + } + companion object { private const val PREFS_NAME = "accbot_user_prefs" private const val KEY_APP_THEME = "app_theme" @@ -242,5 +324,10 @@ class UserPreferences @Inject constructor( private const val KEY_MARKET_PULSE_EXPANDED = "market_pulse_expanded" private const val KEY_EXPERIMENTAL_EXCHANGES = "experimental_exchanges_enabled" private const val KEY_PORTFOLIO_SELECTED_PAGE = "portfolio_selected_page" + private const val KEY_TRADING_ENABLED = "trading_enabled" + private const val KEY_SELL_POLLING_ENABLED = "sell_polling_enabled" + private const val KEY_SELL_POLLING_FREQUENCY = "sell_polling_frequency" + private const val KEY_SELL_POLLING_CRON = "sell_polling_cron" + private const val KEY_SELL_POLLING_SCHEDULE_CONFIG = "sell_polling_schedule_config" } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/di/AppModule.kt b/accbot-android/app/src/main/java/com/accbot/dca/di/AppModule.kt index 8b47deb..75ad4ec 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/di/AppModule.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/di/AppModule.kt @@ -129,6 +129,20 @@ object AppModule { .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) + // Hard ceiling on a whole call (connect + write + read). Without this, each + // individual request only times out per-phase, so a slow exchange call can hang + // well past the worker's own timeout. Kept strictly BELOW the worker's + // withTimeoutOrNull around marketBuy so OkHttp aborts the in-flight request + // first - the worker must never start reconciliation while the order POST is + // still on the wire (that would risk a false "not found" and a duplicate buy). + .callTimeout(30, TimeUnit.SECONDS) + // OkHttp's default (true) silently re-sends a request - including a POST - + // when a pooled connection turns out to be stale or a route fails. For a + // non-idempotent order POST that is a duplicate-buy vector BELOW the app's + // reconcile logic (the worker never sees the first attempt). Disabled: + // such failures surface as IOException -> retryable -> the worker + // reconciles against the exchange before ever re-issuing the buy. + .retryOnConnectionFailure(false) .build() } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/BackupModels.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/BackupModels.kt index 0469b2d..f3f7eb3 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/BackupModels.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/BackupModels.kt @@ -88,7 +88,11 @@ data class BackupPlan( @SerializedName("nextExecutionAt") val nextExecutionAt: Long? = null, @SerializedName("targetAmount") val targetAmount: String? = null, // BigDecimal.toPlainString() /** v2+: source connection id (backup-local). Null for legacy v1 backups. */ - @SerializedName("connectionId") val connectionId: Long? = null + @SerializedName("connectionId") val connectionId: Long? = null, + /** v3+: sell extension - opt-in per-plan. */ + @SerializedName("allowSells") val allowSells: Boolean = false, + /** v3+: sell extension - optional profit target (BigDecimal.toPlainString). */ + @SerializedName("targetProfitAmount") val targetProfitAmount: String? = null ) /** @@ -142,7 +146,13 @@ data class BackupTransaction( @SerializedName("warningMessage") val warningMessage: String? = null, @SerializedName("executedAt") val executedAt: Long = 0, /** v2+: source connection id (backup-local). Null for legacy v1 backups. */ - @SerializedName("connectionId") val connectionId: Long? = null + @SerializedName("connectionId") val connectionId: Long? = null, + /** v3+: sell extension - BUY or SELL. Default BUY for legacy transactions. */ + @SerializedName("side") val side: String = "BUY", + /** v3+: sell extension - limit price for SELL orders. */ + @SerializedName("limitPrice") val limitPrice: String? = null, + /** v3+: sell extension - original requested crypto amount for SELL orders. */ + @SerializedName("requestedCryptoAmount") val requestedCryptoAmount: String? = null ) /** diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt index 8f0485d..def6d76 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt @@ -171,7 +171,15 @@ data class DcaPlan( val nextExecutionAt: Instant? = null, val targetAmount: BigDecimal? = null, /** Order for Dashboard display. Lower values shown first. */ - val displayOrder: Int = 0 + val displayOrder: Int = 0, + /** + * Opt-in per-plan toggle for sell extension. + */ + val allowSells: Boolean = false, + /** + * Optional profit goal in [fiat]. Null when user didn't specify a target. + */ + val targetProfitAmount: BigDecimal? = null ) /** @@ -209,16 +217,38 @@ data class Transaction( val exchangeOrderId: String? = null, val errorMessage: String? = null, val warningMessage: String? = null, - val executedAt: Instant = Instant.now() + val executedAt: Instant = Instant.now(), + /** + * BUY for DCA purchases (default), SELL for limit sell orders. + */ + val side: TransactionSide = TransactionSide.BUY, + /** + * Requested limit price for SELL orders; null for market BUYs. + */ + val limitPrice: BigDecimal? = null, + /** + * Original requested crypto amount for SELL orders (fixed across lifecycle). + * [cryptoAmount] tracks filled amount progressively. Null for BUYs. + */ + val requestedCryptoAmount: BigDecimal? = null ) enum class TransactionStatus { PENDING, COMPLETED, FAILED, - PARTIAL + PARTIAL, + /** User or exchange-side cancellation with no fills. Distinct from FAILED (which + * means the exchange rejected the order outright). PARTIAL covers cancellation + * after some fills happened. */ + CANCELLED } +/** + * Direction of a transaction - BUY for DCA purchases, SELL for user-initiated limit sell orders. + */ +enum class TransactionSide { BUY, SELL } + /** * Withdrawal record */ diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/PlanPnL.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/PlanPnL.kt new file mode 100644 index 0000000..63576b7 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/PlanPnL.kt @@ -0,0 +1,36 @@ +package com.accbot.dca.domain.model + +import java.math.BigDecimal + +/** + * Profit & loss snapshot for a single DCA plan. + * + * Derived fields (the ones ending in `?`) are null when the inputs to compute them + * aren't available - e.g. `currentValueFiat` is null when the caller didn't provide a + * live market price, `avgBuyPrice` is null when the plan has no completed BUYs yet. + * + * @property totalBoughtFiat Sum of fiat spent on COMPLETED/PARTIAL BUYs. + * @property totalBoughtCrypto Sum of crypto filled on COMPLETED/PARTIAL BUYs. + * @property totalSoldFiat Sum of fiat received from COMPLETED/PARTIAL SELLs (filled). + * @property totalSoldCrypto Sum of crypto delivered on COMPLETED/PARTIAL SELLs (filled). + * @property currentCryptoHeld totalBoughtCrypto - totalSoldCrypto. + * @property avgBuyPrice Volume-weighted avg buy price (fiat per crypto), or null if no buys. + * @property currentValueFiat currentCryptoHeld * currentMarketPrice, or null if no spot given. + * @property realizedPnL totalSoldFiat - (totalSoldCrypto * avgBuyPrice), or null. + * @property unrealizedPnL currentValueFiat - (currentCryptoHeld * avgBuyPrice), or null. + * @property netPnL realizedPnL + unrealizedPnL, or null. + * @property targetProgressPct netPnL / plan.targetProfitAmount as 0..1+ ratio, or null. + */ +data class PlanPnL( + val totalBoughtFiat: BigDecimal, + val totalBoughtCrypto: BigDecimal, + val totalSoldFiat: BigDecimal, + val totalSoldCrypto: BigDecimal, + val currentCryptoHeld: BigDecimal, + val avgBuyPrice: BigDecimal?, + val currentValueFiat: BigDecimal?, + val realizedPnL: BigDecimal?, + val unrealizedPnL: BigDecimal?, + val netPnL: BigDecimal?, + val targetProgressPct: Double? +) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/RemainingInventory.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/RemainingInventory.kt new file mode 100644 index 0000000..7135852 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/RemainingInventory.kt @@ -0,0 +1,28 @@ +package com.accbot.dca.domain.model + +import java.math.BigDecimal + +/** + * Result of [com.accbot.dca.domain.usecase.CalculatePlanCostBasisUseCase] - timestamp-aware + * cheapest-first remaining inventory of buys after applying past sells (including PENDING + * reservations). + */ +data class RemainingInventory( + /** Sum of remaining crypto across all buys with non-consumed portion. */ + val available: BigDecimal, + + /** Volume-weighted avg buy price of [perBuyDetail]. Null when [available] == 0. */ + val weightedAvgPrice: BigDecimal?, + + /** Per-buy remaining state (only buys with > 0 remaining). For debug / future features. */ + val perBuyDetail: List, + + /** > 0 when historical sells exceed buys (data inconsistency, e.g. CSV import edge case). */ + val deficit: BigDecimal +) + +data class RemainingBuy( + val transactionId: Long, + val price: BigDecimal, + val remaining: BigDecimal +) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/BuySafetyPolicy.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/BuySafetyPolicy.kt new file mode 100644 index 0000000..80daad7 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/BuySafetyPolicy.kt @@ -0,0 +1,32 @@ +package com.accbot.dca.domain.usecase + +/** + * Pure guard-rails that bound how often a single DCA plan can place orders, independent of + * the root cause. Two layers: + * - [shouldRetryAfterConfirmedFailure] caps the 5-minute network-retry loop. + * - [isRunaway] is a circuit breaker: if a plan has bought far more than its schedule + * allows in the last 24h, something is wrong and the plan should be auto-disabled. + */ +object BuySafetyPolicy { + + /** Max times a single slot will retry in 5-minute steps before giving up to the next interval. */ + const val MAX_NETWORK_RETRIES = 3 + + /** A plan may legitimately buy up to this multiple of its expected daily count (catch-up). */ + const val RUNAWAY_FACTOR = 2 + + fun shouldRetryAfterConfirmedFailure(currentRetryCount: Int): Boolean = + currentRetryCount < MAX_NETWORK_RETRIES + + fun expectedBuysPerDay(intervalMinutes: Long): Int { + if (intervalMinutes <= 0) return 1 + return (MINUTES_PER_DAY / intervalMinutes).coerceAtLeast(1).toInt() + } + + fun isRunaway(buysLast24h: Int, expectedBuysPerDay: Int): Boolean { + val cap = maxOf(expectedBuysPerDay * RUNAWAY_FACTOR, expectedBuysPerDay + 2) + return buysLast24h > cap + } + + private const val MINUTES_PER_DAY = 1440L +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCase.kt new file mode 100644 index 0000000..eedbd1d --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCase.kt @@ -0,0 +1,105 @@ +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.TransactionEntity +import com.accbot.dca.domain.model.RemainingBuy +import com.accbot.dca.domain.model.RemainingInventory +import com.accbot.dca.domain.model.TransactionSide +import com.accbot.dca.domain.model.TransactionStatus +import java.math.BigDecimal +import java.math.RoundingMode +import javax.inject.Inject + +/** + * Compute remaining inventory and weighted-average buy price for a plan using the + * timestamp-aware cheapest-first algorithm. + * + * Each historical sell (chronologically ordered) consumes from buys that preceded it, + * cheapest first; pending/partial sells reserve the unfilled portion. The result is + * stable against new cheap buys after a sell, because such buys are not in scope for + * past sells (timestamp filter). + * + * Pure logic in [computeCostBasis] for unit testing without DB / Hilt. + */ +class CalculatePlanCostBasisUseCase @Inject constructor( + private val database: DcaDatabase +) { + suspend operator fun invoke(planId: Long): RemainingInventory { + val transactions = database.transactionDao().getTransactionsByPlanSync(planId) + return computeCostBasis(transactions) + } + + companion object { + fun computeCostBasis(transactions: List): RemainingInventory { + val buys = transactions.filter { + it.side == TransactionSide.BUY && + (it.status == TransactionStatus.COMPLETED || it.status == TransactionStatus.PARTIAL) + } + + val sells = transactions.filter { + it.side == TransactionSide.SELL && + (it.status == TransactionStatus.COMPLETED || + it.status == TransactionStatus.PARTIAL || + it.status == TransactionStatus.PENDING) + }.sortedBy { it.executedAt } + + val consumed = HashMap(buys.size) + for (b in buys) consumed[b.id] = BigDecimal.ZERO + + var totalDeficit = BigDecimal.ZERO + + for (sell in sells) { + val toConsume = effectiveConsumption(sell) + if (toConsume <= BigDecimal.ZERO) continue + var remaining = toConsume + + val eligible = buys + .filter { it.executedAt.isBefore(sell.executedAt) } + .filter { + (it.cryptoAmount - (consumed[it.id] ?: BigDecimal.ZERO)) > BigDecimal.ZERO + } + .sortedWith(compareBy({ it.price }, { it.executedAt })) + + for (b in eligible) { + if (remaining <= BigDecimal.ZERO) break + val available = b.cryptoAmount - (consumed[b.id] ?: BigDecimal.ZERO) + val take = remaining.min(available) + consumed[b.id] = (consumed[b.id] ?: BigDecimal.ZERO) + take + remaining -= take + } + + if (remaining > BigDecimal.ZERO) totalDeficit = totalDeficit + remaining + } + + val perBuyDetail = buys.mapNotNull { b -> + val left = b.cryptoAmount - (consumed[b.id] ?: BigDecimal.ZERO) + if (left > BigDecimal.ZERO) RemainingBuy(b.id, b.price, left) else null + } + + val available = perBuyDetail.fold(BigDecimal.ZERO) { acc, rb -> acc + rb.remaining } + val weightedAvg = if (available > BigDecimal.ZERO) { + val sumCost = perBuyDetail.fold(BigDecimal.ZERO) { acc, rb -> + acc + rb.remaining * rb.price + } + sumCost.divide(available, 8, RoundingMode.HALF_UP) + } else null + + return RemainingInventory( + available = available, + weightedAvgPrice = weightedAvg, + perBuyDetail = perBuyDetail, + deficit = totalDeficit + ) + } + + /** + * Crypto reserved/consumed by a sell. PENDING/PARTIAL: full requested amount (filled + * + still-reserved unfilled portion). COMPLETED: cryptoAmount (= requested when fully + * filled). max() guards against rare overflow if filled > requested. + */ + private fun effectiveConsumption(sell: TransactionEntity): BigDecimal { + val requested = sell.requestedCryptoAmount ?: BigDecimal.ZERO + return requested.max(sell.cryptoAmount) + } + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculatePlanPnLUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculatePlanPnLUseCase.kt new file mode 100644 index 0000000..d448d68 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculatePlanPnLUseCase.kt @@ -0,0 +1,75 @@ +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.domain.model.PlanPnL +import com.accbot.dca.domain.model.TransactionSide +import com.accbot.dca.domain.model.TransactionStatus +import java.math.BigDecimal +import java.math.RoundingMode +import javax.inject.Inject + +/** + * Compute [PlanPnL] from the plan's COMPLETED/PARTIAL transactions. + * + * Only counts filled amounts (`cryptoAmount` / `fiatAmount`) on SELLs - open (PENDING) + * SELLs do not affect realized PnL; they only reduce the effective free crypto in + * [ValidateSellOrderUseCase]. + */ +class CalculatePlanPnLUseCase @Inject constructor( + private val database: DcaDatabase +) { + suspend operator fun invoke( + planId: Long, + currentMarketPrice: BigDecimal? + ): PlanPnL { + val plan = database.dcaPlanDao().getPlanById(planId) + ?: error("Plan $planId neexistuje") + + val transactions = database.transactionDao().getTransactionsByPlanSync(planId) + + val relevant = transactions.filter { + it.status == TransactionStatus.COMPLETED || it.status == TransactionStatus.PARTIAL + } + + val buys = relevant.filter { it.side == TransactionSide.BUY } + val sells = relevant.filter { it.side == TransactionSide.SELL } + + val totalBoughtFiat = buys.fold(BigDecimal.ZERO) { acc, tx -> acc + tx.fiatAmount } + val totalBoughtCrypto = buys.fold(BigDecimal.ZERO) { acc, tx -> acc + tx.cryptoAmount } + val totalSoldFiat = sells.fold(BigDecimal.ZERO) { acc, tx -> acc + tx.fiatAmount } + val totalSoldCrypto = sells.fold(BigDecimal.ZERO) { acc, tx -> acc + tx.cryptoAmount } + val currentCryptoHeld = totalBoughtCrypto - totalSoldCrypto + + val avgBuyPrice = if (totalBoughtCrypto > BigDecimal.ZERO) { + totalBoughtFiat.divide(totalBoughtCrypto, 8, RoundingMode.HALF_UP) + } else null + + val currentValueFiat = currentMarketPrice?.let { currentCryptoHeld * it } + val realizedPnL = avgBuyPrice?.let { totalSoldFiat - (totalSoldCrypto * it) } + val unrealizedPnL = if (avgBuyPrice != null && currentValueFiat != null) { + currentValueFiat - (currentCryptoHeld * avgBuyPrice) + } else null + val netPnL = if (realizedPnL != null && unrealizedPnL != null) { + realizedPnL + unrealizedPnL + } else null + + val target = plan.targetProfitAmount + val targetProgressPct = if (netPnL != null && target != null && target > BigDecimal.ZERO) { + netPnL.toDouble() / target.toDouble() + } else null + + return PlanPnL( + totalBoughtFiat = totalBoughtFiat, + totalBoughtCrypto = totalBoughtCrypto, + totalSoldFiat = totalSoldFiat, + totalSoldCrypto = totalSoldCrypto, + currentCryptoHeld = currentCryptoHeld, + avgBuyPrice = avgBuyPrice, + currentValueFiat = currentValueFiat, + realizedPnL = realizedPnL, + unrealizedPnL = unrealizedPnL, + netPnL = netPnL, + targetProgressPct = targetProgressPct + ) + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CancelSellOrderUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CancelSellOrderUseCase.kt new file mode 100644 index 0000000..652fb02 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CancelSellOrderUseCase.kt @@ -0,0 +1,64 @@ +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.CredentialsStore +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.domain.model.TransactionStatus +import com.accbot.dca.exchange.ExchangeApiFactory +import java.math.BigDecimal +import javax.inject.Inject + +/** + * Cancel an open limit sell order. + * + * On exchange-cancel success locally marks the transaction as: + * - PARTIAL if some fills already happened (cryptoAmount > 0) + * - CANCELLED otherwise + * + * On exchange-cancel failure, tries to re-resolve the order status so the UI reflects + * the true state (the order may have filled between the user pressing cancel and the + * exchange receiving the request). + */ +class CancelSellOrderUseCase @Inject constructor( + private val database: DcaDatabase, + private val credentialsStore: CredentialsStore, + private val exchangeApiFactory: ExchangeApiFactory, + private val userPreferences: UserPreferences, + private val resolvePendingTransactionsUseCase: ResolvePendingTransactionsUseCase +) { + suspend operator fun invoke(txId: Long): Result { + val tx = database.transactionDao().getTransactionById(txId) + ?: return Result.failure(IllegalArgumentException("Transakce $txId neexistuje")) + + val orderId = tx.exchangeOrderId + ?: return Result.failure(IllegalStateException("Transakce nema exchangeOrderId")) + + val credentials = tx.connectionId?.let { + credentialsStore.getCredentials(it, userPreferences.isSandboxMode()) + } ?: return Result.failure(IllegalStateException("Chybi credentials")) + + val api = exchangeApiFactory.create(credentials) + val cancelResult = api.cancelOrder(orderId, tx.crypto, tx.fiat) + + return if (cancelResult.isSuccess) { + val newStatus = if (tx.cryptoAmount > BigDecimal.ZERO) + TransactionStatus.PARTIAL else TransactionStatus.CANCELLED + database.transactionDao().updateResolvedTransaction( + id = txId, + newStatus = newStatus, + cryptoAmount = tx.cryptoAmount, + fiatAmount = tx.fiatAmount, + price = tx.price, + fee = tx.fee + ) + Result.success(Unit) + } else { + try { + resolvePendingTransactionsUseCase() + } catch (_: Exception) { + // Best effort. + } + cancelResult + } + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt index 998b48e..31668c3 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt @@ -44,7 +44,9 @@ class CreateDcaPlanUseCase @Inject constructor( withdrawalAddress: String? = null, targetAmount: BigDecimal? = null, connectionId: Long? = null, - name: String = "" + name: String = "", + allowSells: Boolean = false, + targetProfitAmount: BigDecimal? = null ) { val now = Instant.now() val nextExecution = if (frequency == DcaFrequency.CUSTOM && cronExpression != null) { @@ -78,7 +80,9 @@ class CreateDcaPlanUseCase @Inject constructor( createdAt = now, nextExecutionAt = nextExecution, targetAmount = targetAmount, - displayOrder = nextDisplayOrder + displayOrder = nextDisplayOrder, + allowSells = allowSells, + targetProfitAmount = targetProfitAmount ) dcaPlanDao.insertPlan(plan) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ImportTradeHistoryUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ImportTradeHistoryUseCase.kt index 4262a8b..f7b8098 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ImportTradeHistoryUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ImportTradeHistoryUseCase.kt @@ -1,5 +1,6 @@ package com.accbot.dca.domain.usecase +import com.accbot.dca.data.local.DcaPlanDao import com.accbot.dca.data.local.TransactionDao import com.accbot.dca.data.local.TransactionEntity import com.accbot.dca.domain.model.Exchange @@ -25,7 +26,8 @@ sealed class ApiImportProgress { } class ImportTradeHistoryUseCase @Inject constructor( - private val transactionDao: TransactionDao + private val transactionDao: TransactionDao, + private val dcaPlanDao: DcaPlanDao ) { fun importFromApi( api: ExchangeApi, @@ -90,11 +92,21 @@ class ImportTradeHistoryUseCase @Inject constructor( emit(ApiImportProgress.Importing(newTrades.size)) + // Resolve the plan's connectionId so imported rows are attributed to the right + // account. Leaving it null breaks per-connection aggregation and backup-restore + // dedup (which keys on (orderId, connectionId)). Use the suspend DAO variant - + // the flow runs on the caller's (main) thread and a blocking Room query there + // throws "cannot access database on the main thread". + // Mirror MIGRATION_21_22: only attribute a real connection (> 0); leave null + // for plans without one, so fresh imports and the backfill stay consistent. + val connectionId = dcaPlanDao.getPlanById(planId)?.connectionId?.takeIf { it > 0 } + // Map to TransactionEntity and batch insert val entities = newTrades.map { trade -> TransactionEntity( planId = planId, exchange = exchange, + connectionId = connectionId, crypto = trade.crypto, fiat = trade.fiat, fiatAmount = trade.fiatAmount, diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLadderSellUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLadderSellUseCase.kt new file mode 100644 index 0000000..eec2cd7 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLadderSellUseCase.kt @@ -0,0 +1,83 @@ +package com.accbot.dca.domain.usecase + +import android.util.Log +import com.accbot.dca.data.local.CredentialsStore +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.data.local.toEntity +import com.accbot.dca.domain.model.DcaResult +import com.accbot.dca.exchange.ExchangeApiFactory +import java.math.BigDecimal +import javax.inject.Inject + +data class LadderOrder(val cryptoAmount: BigDecimal, val limitPrice: BigDecimal) + +sealed class LadderResult { + data class AllPlaced(val placedTxIds: List) : LadderResult() + data class PartialFailure( + val placedTxIds: List, + val failedAtIndex: Int, + val totalCount: Int, + val reason: String + ) : LadderResult() +} + +/** + * Place a sequence of limit sell orders for a single plan. On the first failure we stop + * and report partial success - already placed orders stay on the exchange and as PENDING + * transactions in the DB. The caller (UI) can ask the user whether to retry the rest or + * cancel placed orders manually via the existing cancel flow on plan detail. + */ +class PlaceLadderSellUseCase @Inject constructor( + private val database: DcaDatabase, + private val credentialsStore: CredentialsStore, + private val exchangeApiFactory: ExchangeApiFactory, + private val userPreferences: UserPreferences, + private val resolvePendingTransactionsUseCase: ResolvePendingTransactionsUseCase +) { + suspend operator fun invoke(planId: Long, orders: List): LadderResult { + if (orders.size < 2) return LadderResult.PartialFailure( + emptyList(), 0, orders.size, "Ladder requires at least 2 orders" + ) + + val plan = database.dcaPlanDao().getPlanById(planId) + ?: return LadderResult.PartialFailure(emptyList(), 0, orders.size, "Plan not found") + val credentials = credentialsStore.getCredentials( + plan.connectionId, userPreferences.isSandboxMode() + ) ?: return LadderResult.PartialFailure(emptyList(), 0, orders.size, "Missing credentials") + + val api = exchangeApiFactory.create(credentials) + val placed = mutableListOf() + + orders.forEachIndexed { idx, order -> + val result = api.limitSell(plan.crypto, plan.fiat, order.cryptoAmount, order.limitPrice) + when (result) { + is DcaResult.Success -> { + val tx = result.transaction.copy(planId = planId, connectionId = plan.connectionId) + val id = database.transactionDao().insertTransaction(tx.toEntity()) + placed += id + } + is DcaResult.Error -> { + // NOTE (orphan-order caveat): like market buys, limitSell is not idempotent. + // A retryable error means the order MIGHT have been placed server-side even + // though we got no orderId back. We deliberately do NOT auto-retry here, so + // there is no runaway, but a blind user retry could create a duplicate sell. + // Full idempotency needs a per-exchange "list open orders" reconciliation + // (no such API yet) - tracked as a follow-up. + return LadderResult.PartialFailure(placed, idx, orders.size, result.message) + } + } + } + + try { + resolvePendingTransactionsUseCase() + } catch (e: Exception) { + Log.w(TAG, "resolvePending after ladder failed: ${e.message}") + } + return LadderResult.AllPlaced(placed) + } + + companion object { + private const val TAG = "PlaceLadderSellUseCase" + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLimitSellUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLimitSellUseCase.kt new file mode 100644 index 0000000..31e4b15 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLimitSellUseCase.kt @@ -0,0 +1,66 @@ +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.CredentialsStore +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.data.local.toEntity +import com.accbot.dca.domain.model.DcaResult +import com.accbot.dca.exchange.ExchangeApiFactory +import java.math.BigDecimal +import javax.inject.Inject + +/** + * Place a limit sell order for an existing DCA plan. + * + * On success inserts a PENDING SELL transaction (filled fields=0, requestedCryptoAmount + * preserved) and returns the new row id. The caller (UI / worker) is expected to refresh + * the open-sells list after. + * + * Also kicks off the resolver best-effort after insert so if the order filled instantly + * it's already marked COMPLETED when the UI reads back. + */ +class PlaceLimitSellUseCase @Inject constructor( + private val database: DcaDatabase, + private val credentialsStore: CredentialsStore, + private val exchangeApiFactory: ExchangeApiFactory, + private val userPreferences: UserPreferences, + private val resolvePendingTransactionsUseCase: ResolvePendingTransactionsUseCase +) { + suspend operator fun invoke( + planId: Long, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal + ): Result { + val plan = database.dcaPlanDao().getPlanById(planId) + ?: return Result.failure(IllegalArgumentException("Plan $planId neexistuje")) + + val credentials = credentialsStore.getCredentials( + plan.connectionId, + userPreferences.isSandboxMode() + ) ?: return Result.failure( + IllegalStateException("Chybi credentials pro connection ${plan.connectionId}") + ) + + val api = exchangeApiFactory.create(credentials) + val result = api.limitSell(plan.crypto, plan.fiat, cryptoAmount, limitPrice) + + return when (result) { + is DcaResult.Success -> { + val tx = result.transaction.copy( + planId = planId, + connectionId = plan.connectionId + ) + val txId = database.transactionDao().insertTransaction(tx.toEntity()) + try { + resolvePendingTransactionsUseCase() + } catch (_: Exception) { + // Best effort - resolver runs periodically anyway. + } + Result.success(txId) + } + is DcaResult.Error -> Result.failure( + IllegalStateException(result.message) + ) + } + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ReconcileRecentBuyUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ReconcileRecentBuyUseCase.kt new file mode 100644 index 0000000..6f7dbe8 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ReconcileRecentBuyUseCase.kt @@ -0,0 +1,120 @@ +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.DcaPlanEntity +import com.accbot.dca.data.local.TransactionDao +import com.accbot.dca.domain.model.HistoricalTrade +import com.accbot.dca.exchange.ExchangeApi +import kotlinx.coroutines.delay +import java.math.BigDecimal +import java.time.Instant + +/** + * Outcome of trying to reconcile a possibly-placed buy against the exchange's trade history. + * + * Distinguishes the dangerous "I don't know" case ([Unknown]) from a confirmed absence + * ([NotFound]) so callers never retry a market order on uncertainty. + */ +sealed interface ReconcileResult { + data class Found(val trade: HistoricalTrade) : ReconcileResult + object NotFound : ReconcileResult + object Unknown : ReconcileResult +} + +/** + * After a buy times out client-side, the order may still have been placed on the exchange. + * This use case asks the exchange "did a matching buy actually happen since [since]?" so the + * worker can record it instead of blindly retrying (which would double-spend). + */ +class ReconcileRecentBuyUseCase( + private val transactionDao: TransactionDao +) { + suspend operator fun invoke( + api: ExchangeApi, + plan: DcaPlanEntity, + since: Instant, + expectedFiat: BigDecimal? + ): ReconcileResult { + // Dedup against the whole connection, not just this plan: two plans on the same + // account+pair must not both claim the same order. + val alreadyRecorded = ( + if (plan.connectionId > 0) transactionDao.getExchangeOrderIdsByConnection(plan.connectionId) + else transactionDao.getExchangeOrderIdsByPlan(plan.id) + ).toSet() + + // Allow a small slack before `since`: the exchange stamps fills with its own clock, + // which can be a few seconds behind the device that captured attemptStart. + val cutoff = since.minusSeconds(LOOKBACK_BUFFER_SECONDS) + + // The fill may not appear in trade history instantly, so look a few times with a + // settlement pause (mirrors CoinmateApi.getTradeDetailsByOrderId). A query that + // throws means we genuinely don't know - return Unknown so the caller stays + // conservative and never retries on uncertainty. + repeat(SETTLEMENT_ATTEMPTS) { attempt -> + if (attempt > 0) delay(SETTLEMENT_DELAY_MS) + + val page = try { + api.getTradeHistory( + crypto = plan.crypto, + fiat = plan.fiat, + sinceTimestamp = cutoff, + limit = PAGE_LIMIT + ) + } catch (_: Exception) { + return ReconcileResult.Unknown + } + + // A single market buy can fill across multiple trade rows, so aggregate by + // orderId before matching the amount - otherwise each partial looks too small. + val match = page.trades + .filter { it.side == "BUY" && it.orderId.isNotEmpty() && it.orderId !in alreadyRecorded } + .groupBy { it.orderId } + .map { (orderId, fills) -> aggregateOrder(orderId, fills) } + .filter { !it.timestamp.isBefore(cutoff) } + .filter { expectedFiat == null || withinTolerance(it.fiatAmount, expectedFiat) } + .maxByOrNull { it.timestamp } + + if (match != null) return ReconcileResult.Found(match) + } + + return ReconcileResult.NotFound + } + + /** Collapse all fills of one order into a single trade with summed amounts. */ + private fun aggregateOrder(orderId: String, fills: List): HistoricalTrade { + val totalCrypto = fills.fold(BigDecimal.ZERO) { acc, f -> acc + f.cryptoAmount } + val totalFiat = fills.fold(BigDecimal.ZERO) { acc, f -> acc + f.fiatAmount } + val totalFee = fills.fold(BigDecimal.ZERO) { acc, f -> acc + f.fee } + val price = if (totalCrypto.signum() > 0) { + totalFiat.divide(totalCrypto, 2, java.math.RoundingMode.HALF_UP) + } else { + fills.first().price + } + val ref = fills.first() + return HistoricalTrade( + orderId = orderId, + timestamp = fills.maxOf { it.timestamp }, + crypto = ref.crypto, + fiat = ref.fiat, + cryptoAmount = totalCrypto, + fiatAmount = totalFiat, + price = price, + fee = totalFee, + feeAsset = ref.feeAsset, + side = "BUY" + ) + } + + private fun withinTolerance(actual: BigDecimal, expected: BigDecimal): Boolean { + if (expected.signum() == 0) return true + val ratio = actual.toDouble() / expected.toDouble() + return ratio in (1.0 - AMOUNT_TOLERANCE)..(1.0 + AMOUNT_TOLERANCE) + } + + private companion object { + const val SETTLEMENT_ATTEMPTS = 3 + const val SETTLEMENT_DELAY_MS = 2_000L + const val LOOKBACK_BUFFER_SECONDS = 5L + const val PAGE_LIMIT = 50 + const val AMOUNT_TOLERANCE = 0.30 + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ResolvePendingTransactionsUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ResolvePendingTransactionsUseCase.kt index acb5508..b9424ad 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ResolvePendingTransactionsUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ResolvePendingTransactionsUseCase.kt @@ -4,25 +4,34 @@ import android.util.Log import com.accbot.dca.data.local.CredentialsStore import com.accbot.dca.data.local.DcaDatabase import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.domain.model.TransactionSide +import com.accbot.dca.domain.model.TransactionStatus import com.accbot.dca.exchange.ExchangeApiFactory +import com.accbot.dca.service.NotificationService import javax.inject.Inject /** - * Resolves PENDING transactions by querying exchange APIs for fill details. + * Resolves PENDING/PARTIAL transactions by querying exchange APIs for fill details. * - * When Kraken or Coinbase returns a PENDING status (fill details not available within - * the initial 3-second polling window), the transaction is saved with cryptoAmount=0. - * This use case finds those transactions and queries the exchange to get the actual - * fill details, updating them to COMPLETED with real values. + * Two scenarios produce resolvable rows: + * - BUY: Kraken/Coinbase sometimes return PENDING at place-order time because fill + * details weren't available within the initial 3-second polling window. + * - SELL: limit sell orders are always PENDING until (partially) filled or cancelled. + * + * Uses the guarded [TransactionDao.updateResolvedTransaction] UPDATE so a concurrent + * user cancel (which sets status=FAILED) is never clobbered. + * + * Fires a system notification for each SELL that transitions to COMPLETED in this run. */ class ResolvePendingTransactionsUseCase @Inject constructor( private val database: DcaDatabase, private val credentialsStore: CredentialsStore, private val exchangeApiFactory: ExchangeApiFactory, - private val userPreferences: UserPreferences + private val userPreferences: UserPreferences, + private val notificationService: NotificationService ) { suspend operator fun invoke(): Int { - val pendingTransactions = database.transactionDao().getPendingTransactionsWithOrderId() + val pendingTransactions = database.transactionDao().getResolvablePendingTransactions() if (pendingTransactions.isEmpty()) return 0 val isSandbox = userPreferences.isSandboxMode() @@ -30,9 +39,6 @@ class ResolvePendingTransactionsUseCase @Inject constructor( for (tx in pendingTransactions) { try { - // Use the transaction's connectionId (set by migration); fall back to a - // legacy lookup by exchange enum if it's null (very old transactions - // somehow not backfilled by v18→v19 migration). @Suppress("DEPRECATION") val credentials = if (tx.connectionId != null) { credentialsStore.getCredentials(tx.connectionId, isSandbox) @@ -42,21 +48,37 @@ class ResolvePendingTransactionsUseCase @Inject constructor( val api = exchangeApiFactory.create(credentials) val orderId = tx.exchangeOrderId ?: continue - val filledOrder = api.getOrderStatus(orderId) ?: continue + val result = api.getOrderStatus(orderId, tx.crypto, tx.fiat) ?: continue - // Update the transaction with real fill details - val updatedTx = tx.copy( - cryptoAmount = filledOrder.cryptoAmount, - fiatAmount = filledOrder.fiatAmount, - price = filledOrder.price, - fee = filledOrder.fee, - status = filledOrder.status + val newPrice = result.avgFillPrice ?: tx.price + val rows = database.transactionDao().updateResolvedTransaction( + id = tx.id, + newStatus = result.status, + cryptoAmount = result.filledCryptoAmount, + fiatAmount = result.filledFiatAmount, + price = newPrice, + fee = result.fee ?: tx.fee ) - database.transactionDao().updateTransaction(updatedTx) - resolvedCount++ - - Log.d(TAG, "Resolved pending transaction ${tx.id}: " + - "${updatedTx.cryptoAmount} ${tx.crypto} for ${updatedTx.fiatAmount} ${tx.fiat}") + if (rows > 0) { + resolvedCount++ + if (tx.side == TransactionSide.SELL && result.status == TransactionStatus.COMPLETED) { + try { + notificationService.showSellFilledNotification( + crypto = tx.crypto, + cryptoAmount = result.filledCryptoAmount, + fiatAmount = result.filledFiatAmount, + fiat = tx.fiat, + price = newPrice, + transactionId = tx.id, + planId = tx.planId ?: 0, + exchange = tx.exchange, + connectionId = tx.connectionId + ) + } catch (e: Exception) { + Log.w(TAG, "Sell-filled notification failed for tx ${tx.id}", e) + } + } + } } catch (e: Exception) { Log.w(TAG, "Failed to resolve pending transaction ${tx.id}", e) } @@ -65,7 +87,6 @@ class ResolvePendingTransactionsUseCase @Inject constructor( if (resolvedCount > 0) { Log.d(TAG, "Resolved $resolvedCount/${pendingTransactions.size} pending transactions") } - return resolvedCount } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt new file mode 100644 index 0000000..3821db2 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt @@ -0,0 +1,120 @@ +package com.accbot.dca.domain.usecase + +import java.math.BigDecimal +import javax.inject.Inject + +/** + * Validation outcome for a prospective sell order. Multiple items may be returned + * (e.g. InstantFillInfo and no hard error), so callers should render each item. + * An empty list means "valid". + * + * Hard errors are locale-free: each subtype maps to a string resource the UI resolves. + */ +sealed class SellValidation { + /** Field this validation result attaches to (lets UI render it under the right input). */ + enum class Field { AMOUNT, PRICE, NET, GENERIC } + + sealed class HardError(val field: Field) : SellValidation() { + object AmountMustBePositive : HardError(Field.AMOUNT) + object PriceMustBePositive : HardError(Field.PRICE) + data class MinOrderTooLow(val minOrderFiat: BigDecimal) : HardError(Field.AMOUNT) + data class InsufficientInventory(val available: BigDecimal) : HardError(Field.AMOUNT) + } + + data class InstantFillInfo(val spot: BigDecimal) : SellValidation() + data class FarFromMarketWarning(val spot: BigDecimal) : SellValidation() + /** + * Net profit after exchange fee would be negative for this order. Triggered both when + * the limit price is below avg buy and when the price is just above avg buy but fee + * pushes the result into negative territory. Caller (UI) shows a warning banner; + * the wizard still allows the user to proceed - the user makes the final decision. + */ + data class LossWarning(val lossFiat: BigDecimal, val lossPct: BigDecimal) : SellValidation() +} + +/** + * Validate a prospective limit sell order against plan state (held crypto minus + * reservations from open sells) and optional spot price. + * + * Checks: + * - amount > 0, limitPrice > 0 + * - amount >= minOrderSize (exchange-specific, passed by caller) + * - amount <= available crypto (held - unfilled reservations on other open sells) + * - limitPrice <= spot -> InstantFillInfo (UI shows warning; order will fill immediately) + * - limitPrice > 3x spot -> FarFromMarketWarning (typo protection) + */ +class ValidateSellOrderUseCase @Inject constructor( + private val calculatePlanCostBasisUseCase: CalculatePlanCostBasisUseCase +) { + suspend operator fun invoke( + planId: Long, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal, + /** Minimum order size **in fiat** (Coinmate ~50 CZK, Binance NOTIONAL filter, etc.). */ + minOrderFiat: BigDecimal, + currentSpot: BigDecimal?, + avgBuyPrice: BigDecimal? = null, + feeRate: BigDecimal = BigDecimal.ZERO + ): List { + val result = mutableListOf() + + if (cryptoAmount <= BigDecimal.ZERO) { + result += SellValidation.HardError.AmountMustBePositive + return result + } + if (limitPrice <= BigDecimal.ZERO) { + result += SellValidation.HardError.PriceMustBePositive + return result + } + if (minOrderFiat > BigDecimal.ZERO && cryptoAmount * limitPrice < minOrderFiat) { + result += SellValidation.HardError.MinOrderTooLow(minOrderFiat) + } + + // Single source of truth for "available to sell" - the cost basis use case already + // accounts for filled buys, filled sells, and full reservation of PENDING/PARTIAL sells. + val available = calculatePlanCostBasisUseCase(planId).available + if (cryptoAmount > available) { + result += SellValidation.HardError.InsufficientInventory(available) + } + + if (currentSpot != null) { + if (limitPrice <= currentSpot) { + result += SellValidation.InstantFillInfo(currentSpot) + } + if (limitPrice > currentSpot.multiply(BigDecimal("3"))) { + result += SellValidation.FarFromMarketWarning(currentSpot) + } + } + + checkLoss(cryptoAmount, limitPrice, avgBuyPrice, feeRate)?.let { result += it } + + return result + } + + companion object { + /** + * Pure helper: returns LossWarning when the net-of-fee profit would be negative. + * Triggers also when [limitPrice] is just above [avgBuyPrice] but fee pushes the + * result negative. Returns null when [avgBuyPrice] is unknown. + */ + internal fun checkLoss( + cryptoAmount: BigDecimal, + limitPrice: BigDecimal, + avgBuyPrice: BigDecimal?, + feeRate: BigDecimal + ): SellValidation.LossWarning? { + if (avgBuyPrice == null || avgBuyPrice <= BigDecimal.ZERO) return null + if (cryptoAmount <= BigDecimal.ZERO || limitPrice <= BigDecimal.ZERO) return null + val grossFiat = cryptoAmount * limitPrice + val netFiat = grossFiat * (BigDecimal.ONE - feeRate) + val costBasis = cryptoAmount * avgBuyPrice + val netProfit = netFiat - costBasis + if (netProfit >= BigDecimal.ZERO) return null + val lossFiat = netProfit.negate() + val lossPct = if (costBasis > BigDecimal.ZERO) { + lossFiat.divide(costBasis, 4, java.math.RoundingMode.HALF_UP) + } else BigDecimal.ZERO + return SellValidation.LossWarning(lossFiat = lossFiat, lossPct = lossPct) + } + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/exchange/BinanceApi.kt b/accbot-android/app/src/main/java/com/accbot/dca/exchange/BinanceApi.kt index 7f74bf5..41ed382 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/exchange/BinanceApi.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/exchange/BinanceApi.kt @@ -29,6 +29,10 @@ class BinanceApi( override val exchange = Exchange.BINANCE + override val supportsLimitSell: Boolean = true + + override val estimatedTakerFeeRate: BigDecimal = BigDecimal("0.001") + private val baseUrl = ExchangeConfig.getBaseUrl(Exchange.BINANCE, isSandbox) /** Offset in ms: serverTime - localTime. Add to System.currentTimeMillis() to get server time. */ @@ -180,6 +184,206 @@ class BinanceApi( } } + override suspend fun limitSell( + crypto: String, + fiat: String, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal + ): DcaResult = withContext(Dispatchers.IO) { + try { + ensureTimeSynced() + val symbol = "$crypto$fiat" + val timestamp = serverTimestamp() + + val params = buildString { + append("symbol=$symbol") + append("&side=SELL") + append("&type=LIMIT") + append("&timeInForce=GTC") + append("&quantity=${cryptoAmount.setScale(8, RoundingMode.DOWN).toPlainString()}") + append("&price=${limitPrice.setScale(2, RoundingMode.HALF_UP).toPlainString()}") + append("×tamp=$timestamp") + append("&recvWindow=60000") + } + + val signature = CryptoUtils.hmacSha256Hex(params, credentials.apiSecret) + val signedParams = "$params&signature=$signature" + + val request = Request.Builder() + .url("$baseUrl/api/v3/order?$signedParams") + .header("X-MBX-APIKEY", credentials.apiKey) + .post("".toRequestBody()) + .build() + + client.newCall(request).execute().use { response -> + val body = response.body?.string() ?: throw Exception("Empty response") + + if (!response.isSuccessful) { + val isRetryable = response.code in 500..599 || response.code == 429 + val errorMsg = try { + JSONObject(body).optString("msg", "HTTP ${response.code}") + } catch (_: Exception) { "HTTP ${response.code}" } + return@withContext DcaResult.Error(errorMsg, retryable = isRetryable) + } + + val json = JSONObject(body) + + if (json.has("code")) { + val errorMessage = json.optString("msg", "Unknown error") + return@withContext DcaResult.Error(errorMessage, retryable = false) + } + + // Binance returns orderId as a Long; convert to String for our domain + val orderId = json.get("orderId").toString() + + DcaResult.Success( + Transaction( + planId = 0, // caller fills in + connectionId = null, // caller fills in + exchange = Exchange.BINANCE, + crypto = crypto, + fiat = fiat, + fiatAmount = BigDecimal.ZERO, + cryptoAmount = BigDecimal.ZERO, + price = limitPrice, + fee = BigDecimal.ZERO, + feeAsset = "", + status = TransactionStatus.PENDING, + exchangeOrderId = orderId, + executedAt = Instant.now(), + side = TransactionSide.SELL, + limitPrice = limitPrice, + requestedCryptoAmount = cryptoAmount + ) + ) + } + } catch (e: java.io.IOException) { + DcaResult.Error(e.message ?: "Network error", retryable = true) + } catch (e: Exception) { + DcaResult.Error(e.message ?: "Unknown error", retryable = false) + } + } + + override suspend fun cancelOrder( + orderId: String, + crypto: String, + fiat: String + ): Result = withContext(Dispatchers.IO) { + try { + ensureTimeSynced() + val symbol = "$crypto$fiat" + val timestamp = serverTimestamp() + + val params = buildString { + append("symbol=$symbol") + append("&orderId=$orderId") + append("×tamp=$timestamp") + append("&recvWindow=60000") + } + + val signature = CryptoUtils.hmacSha256Hex(params, credentials.apiSecret) + val signedParams = "$params&signature=$signature" + + val request = Request.Builder() + .url("$baseUrl/api/v3/order?$signedParams") + .header("X-MBX-APIKEY", credentials.apiKey) + .delete() + .build() + + client.newCall(request).execute().use { response -> + val body = response.body?.string() + ?: return@withContext Result.failure(java.io.IOException("Empty response")) + + if (!response.isSuccessful) { + val errorMsg = try { + JSONObject(body).optString("msg", "HTTP ${response.code}") + } catch (_: Exception) { "HTTP ${response.code}" } + return@withContext Result.failure(java.io.IOException(errorMsg)) + } + + val json = JSONObject(body) + if (json.has("code")) { + val errorMessage = json.optString("msg", "Cancel failed") + return@withContext Result.failure(java.io.IOException(errorMessage)) + } + + Result.success(Unit) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun getOrderStatus( + orderId: String, + crypto: String, + fiat: String + ): OrderStatusResult? = withContext(Dispatchers.IO) { + try { + ensureTimeSynced() + val symbol = "$crypto$fiat" + val timestamp = serverTimestamp() + + val params = buildString { + append("symbol=$symbol") + append("&orderId=$orderId") + append("×tamp=$timestamp") + append("&recvWindow=60000") + } + + val signature = CryptoUtils.hmacSha256Hex(params, credentials.apiSecret) + + val request = Request.Builder() + .url("$baseUrl/api/v3/order?$params&signature=$signature") + .header("X-MBX-APIKEY", credentials.apiKey) + .get() + .build() + + client.newCall(request).execute().use { response -> + val body = response.body?.string() ?: return@withContext null + if (!response.isSuccessful) return@withContext null + + val json = JSONObject(body) + if (json.has("code")) return@withContext null + + val status = json.optString("status", "") + val executedQty = BigDecimal(json.optString("executedQty", "0")) + val cummulativeQuoteQty = BigDecimal(json.optString("cummulativeQuoteQty", "0")) + + val avgFillPrice = if (executedQty > BigDecimal.ZERO) { + cummulativeQuoteQty.divide(executedQty, 8, RoundingMode.HALF_UP) + } else null + + val mappedStatus = when (status) { + "FILLED" -> TransactionStatus.COMPLETED + "NEW", "PENDING_NEW" -> if (executedQty > BigDecimal.ZERO) { + TransactionStatus.PARTIAL + } else TransactionStatus.PENDING + "PARTIALLY_FILLED" -> TransactionStatus.PARTIAL + "CANCELED", "CANCELLED", "EXPIRED", "PENDING_CANCEL" -> + if (executedQty > BigDecimal.ZERO) TransactionStatus.PARTIAL + else TransactionStatus.CANCELLED + "REJECTED" -> + if (executedQty > BigDecimal.ZERO) TransactionStatus.PARTIAL + else TransactionStatus.FAILED + else -> return@withContext null + } + + OrderStatusResult( + status = mappedStatus, + filledCryptoAmount = executedQty, + filledFiatAmount = cummulativeQuoteQty, + avgFillPrice = avgFillPrice, + // Binance fees are per-trade (need /api/v3/myTrades). MVP: leave null. + fee = null, + feeAsset = null + ) + } + } catch (_: Exception) { + null + } + } + override suspend fun getBalance(currency: String): BigDecimal? = withContext(Dispatchers.IO) { try { ensureTimeSynced() diff --git a/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinbaseApi.kt b/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinbaseApi.kt index f2d4a68..6b0b4a3 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinbaseApi.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinbaseApi.kt @@ -31,6 +31,8 @@ class CoinbaseApi( override val exchange = Exchange.COINBASE + override val estimatedTakerFeeRate: BigDecimal = BigDecimal("0.0040") + private val baseUrl = ExchangeConfig.getBaseUrl(Exchange.COINBASE, isSandbox) private val jsonMediaType = "application/json".toMediaType() @@ -230,7 +232,11 @@ class CoinbaseApi( val fee: BigDecimal ) - override suspend fun getOrderStatus(orderId: String): Transaction? = withContext(Dispatchers.IO) { + override suspend fun getOrderStatus( + orderId: String, + crypto: String, + fiat: String + ): OrderStatusResult? = withContext(Dispatchers.IO) { try { val request = buildGetRequest("/api/v3/brokerage/orders/historical/$orderId") client.newCall(request).execute().use { response -> @@ -241,30 +247,36 @@ class CoinbaseApi( val order = json.optJSONObject("order") ?: return@withContext null val status = order.optString("status", "") - if (status == "FILLED") { - val filledSize = BigDecimal(order.optString("filled_size", "0")) - val avgPrice = BigDecimal(order.optString("average_filled_price", "0")) - val totalFees = BigDecimal(order.optString("total_fees", "0")) - val cost = if (filledSize > BigDecimal.ZERO && avgPrice > BigDecimal.ZERO) { - filledSize.multiply(avgPrice).setScale(8, RoundingMode.HALF_UP) - } else BigDecimal.ZERO - - Transaction( - planId = 0, - exchange = Exchange.COINBASE, - crypto = "", - fiat = "", - fiatAmount = cost, - cryptoAmount = filledSize, - price = avgPrice, - fee = totalFees, - status = TransactionStatus.COMPLETED, - exchangeOrderId = orderId, - executedAt = Instant.now() - ) - } else { - null + val filledSize = BigDecimal(order.optString("filled_size", "0")) + val avgPrice = BigDecimal(order.optString("average_filled_price", "0")) + val totalFees = BigDecimal(order.optString("total_fees", "0")) + val filledFiat = if (filledSize > BigDecimal.ZERO && avgPrice > BigDecimal.ZERO) { + filledSize.multiply(avgPrice).setScale(8, RoundingMode.HALF_UP) + } else BigDecimal.ZERO + + val mappedStatus = when (status) { + "FILLED" -> TransactionStatus.COMPLETED + "OPEN", "PENDING" -> if (filledSize > BigDecimal.ZERO) { + TransactionStatus.PARTIAL + } else TransactionStatus.PENDING + "PARTIALLY_FILLED" -> TransactionStatus.PARTIAL + "CANCELLED", "CANCEL_QUEUED", "EXPIRED" -> + if (filledSize > BigDecimal.ZERO) TransactionStatus.PARTIAL + else TransactionStatus.CANCELLED + "FAILED", "REJECTED" -> + if (filledSize > BigDecimal.ZERO) TransactionStatus.PARTIAL + else TransactionStatus.FAILED + else -> return@withContext null } + + OrderStatusResult( + status = mappedStatus, + filledCryptoAmount = filledSize, + filledFiatAmount = filledFiat, + avgFillPrice = if (filledSize > BigDecimal.ZERO) avgPrice else null, + fee = totalFees, + feeAsset = fiat + ) } } catch (_: Exception) { null diff --git a/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt b/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt index 0cbf639..382abd8 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt @@ -26,9 +26,13 @@ class CoinmateApi( override val exchange = Exchange.COINMATE + override val supportsLimitSell: Boolean = true + // Coinmate taker fee: 0.35% (same as .NET CoinmateAPI.getTakerFee()) private val takerFeeRate = BigDecimal("0.0035") + override val estimatedTakerFeeRate: BigDecimal = takerFeeRate + private val baseUrl = ExchangeConfig.getBaseUrl(Exchange.COINMATE, isSandbox) private val clientId: String = credentials.clientId @@ -202,6 +206,193 @@ class CoinmateApi( return null } + override suspend fun limitSell( + crypto: String, + fiat: String, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal + ): DcaResult = withContext(Dispatchers.IO) { + try { + val pair = "${crypto}_${fiat}" + val nonce = System.currentTimeMillis() + val signature = createSignature(nonce) + + val formBody = FormBody.Builder() + .add("clientId", clientId) + .add("publicKey", credentials.apiKey) + .add("nonce", nonce.toString()) + .add("signature", signature) + .add("currencyPair", pair) + // Coinmate expects the crypto amount (not fiat total) for sellLimit + .add("amount", cryptoAmount.setScale(8, RoundingMode.DOWN).toPlainString()) + .add("price", limitPrice.setScale(2, RoundingMode.HALF_UP).toPlainString()) + .build() + + val request = Request.Builder() + .url("$baseUrl/sellLimit") + .post(formBody) + .build() + + client.newCall(request).execute().use { response -> + val body = response.body?.string() ?: throw Exception("Empty response") + + if (!response.isSuccessful) { + val isRetryable = response.code in 500..599 || response.code == 429 + return@withContext DcaResult.Error("HTTP ${response.code}", retryable = isRetryable) + } + + val json = JSONObject(body) + + if (json.optBoolean("error", true)) { + val errorMessage = json.optString("errorMessage", "Unknown error") + return@withContext DcaResult.Error(errorMessage, retryable = false) + } + + // sellLimit returns the order ID in "data" + val orderId = json.get("data").toString() + + DcaResult.Success( + Transaction( + planId = 0, // caller fills in + connectionId = null, // caller fills in + exchange = Exchange.COINMATE, + crypto = crypto, + fiat = fiat, + fiatAmount = BigDecimal.ZERO, + cryptoAmount = BigDecimal.ZERO, + price = limitPrice, + fee = BigDecimal.ZERO, + feeAsset = "", + status = TransactionStatus.PENDING, + exchangeOrderId = orderId, + executedAt = Instant.now(), + side = TransactionSide.SELL, + limitPrice = limitPrice, + requestedCryptoAmount = cryptoAmount + ) + ) + } + } catch (e: java.io.IOException) { + DcaResult.Error(e.message ?: "Network error", retryable = true) + } catch (e: Exception) { + DcaResult.Error(e.message ?: "Unknown error", retryable = false) + } + } + + override suspend fun cancelOrder( + orderId: String, + crypto: String, + fiat: String + ): Result = withContext(Dispatchers.IO) { + try { + val nonce = System.currentTimeMillis() + val signature = createSignature(nonce) + + val formBody = FormBody.Builder() + .add("clientId", clientId) + .add("publicKey", credentials.apiKey) + .add("nonce", nonce.toString()) + .add("signature", signature) + .add("orderId", orderId) + .build() + + val request = Request.Builder() + .url("$baseUrl/cancelOrder") + .post(formBody) + .build() + + client.newCall(request).execute().use { response -> + val body = response.body?.string() + ?: return@withContext Result.failure(java.io.IOException("Empty response")) + + if (!response.isSuccessful) { + return@withContext Result.failure(java.io.IOException("HTTP ${response.code}")) + } + + val json = JSONObject(body) + if (json.optBoolean("error", true)) { + val errorMessage = json.optString("errorMessage", "Cancel failed") + return@withContext Result.failure(java.io.IOException(errorMessage)) + } + + Result.success(Unit) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun getOrderStatus( + orderId: String, + crypto: String, + fiat: String + ): OrderStatusResult? = withContext(Dispatchers.IO) { + try { + val nonce = System.currentTimeMillis() + val signature = createSignature(nonce) + + val formBody = FormBody.Builder() + .add("clientId", clientId) + .add("publicKey", credentials.apiKey) + .add("nonce", nonce.toString()) + .add("signature", signature) + .add("orderId", orderId) + .build() + + val request = Request.Builder() + .url("$baseUrl/orderById") + .post(formBody) + .build() + + client.newCall(request).execute().use { response -> + val body = response.body?.string() ?: return@withContext null + if (!response.isSuccessful) return@withContext null + + val json = JSONObject(body) + if (json.optBoolean("error", true)) return@withContext null + + val data = json.optJSONObject("data") ?: return@withContext null + val status = data.optString("status", "") + + val originalAmount = BigDecimal(data.optString("originalAmount", "0")) + val remainingAmount = BigDecimal(data.optString("remainingAmount", "0")) + val avgPriceStr = data.optString("avgPrice", "") + val avgPrice = if (avgPriceStr.isNotEmpty()) { + try { BigDecimal(avgPriceStr) } catch (_: Exception) { null } + } else null + + val filledAmount = (originalAmount - remainingAmount).max(BigDecimal.ZERO) + val filledFiat = if (avgPrice != null && filledAmount > BigDecimal.ZERO) { + filledAmount.multiply(avgPrice).setScale(2, RoundingMode.HALF_UP) + } else BigDecimal.ZERO + + val mappedStatus = when (status.uppercase()) { + "FILLED" -> TransactionStatus.COMPLETED + "OPEN" -> if (filledAmount > BigDecimal.ZERO) { + TransactionStatus.PARTIAL + } else TransactionStatus.PENDING + "PARTIALLY_FILLED" -> TransactionStatus.PARTIAL + "CANCELLED", "CANCELED", "EXPIRED" -> + if (filledAmount > BigDecimal.ZERO) TransactionStatus.PARTIAL + else TransactionStatus.CANCELLED + else -> return@withContext null + } + + OrderStatusResult( + status = mappedStatus, + filledCryptoAmount = filledAmount, + filledFiatAmount = filledFiat, + avgFillPrice = if (filledAmount > BigDecimal.ZERO) avgPrice else null, + // Coinmate doesn't return aggregate fee on orderById - caller can fetch from tradeHistory if needed + fee = null, + feeAsset = null + ) + } + } catch (_: Exception) { + null + } + } + override suspend fun getBalance(currency: String): BigDecimal? = withContext(Dispatchers.IO) { try { val nonce = System.currentTimeMillis() @@ -382,7 +573,28 @@ class CoinmateApi( override suspend fun validateCredentials(): Boolean = withContext(Dispatchers.IO) { try { - getBalance("BTC") != null + val nonce = System.currentTimeMillis() + val signature = createSignature(nonce) + + val formBody = FormBody.Builder() + .add("clientId", clientId) + .add("publicKey", credentials.apiKey) + .add("nonce", nonce.toString()) + .add("signature", signature) + .build() + + val request = Request.Builder() + .url("$baseUrl/balances") + .post(formBody) + .build() + + client.newCall(request).execute().use { response -> + val body = response.body?.string() ?: return@use false + val json = JSONObject(body) + // Valid credentials = server accepted the signed request and returned a data object. + // Don't probe a specific currency - new accounts may not have it yet. + !json.optBoolean("error", true) && json.opt("data") is JSONObject + } } catch (e: Exception) { false } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/exchange/ExchangeApi.kt b/accbot-android/app/src/main/java/com/accbot/dca/exchange/ExchangeApi.kt index ee66a83..c6c6a53 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/exchange/ExchangeApi.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/exchange/ExchangeApi.kt @@ -63,11 +63,69 @@ interface ExchangeApi { /** * Query the status and fill details of a previously placed order. - * Used to resolve PENDING transactions whose fill details weren't available at order time. + * Used to resolve PENDING/PARTIAL transactions whose fill details weren't available at + * order time, and to track progress of open limit sell orders. + * + * Binance requires the trading pair as `symbol=${crypto}${fiat}` context; other + * exchanges (Coinmate, Coinbase, Kraken) ignore the crypto/fiat params. + * * @param orderId The exchange order ID - * @return Filled transaction details, or null if still pending/unknown + * @param crypto Cryptocurrency symbol (e.g., "BTC") - used by Binance + * @param fiat Fiat currency (e.g., "EUR") - used by Binance + * @return Current order status + fill details, or null if unknown / parse failure. */ - suspend fun getOrderStatus(orderId: String): Transaction? = null + suspend fun getOrderStatus(orderId: String, crypto: String, fiat: String): OrderStatusResult? = null + + /** + * Place a limit sell order. + * + * On success returns a [DcaResult.Success] with a PENDING [Transaction] that has: + * - side = SELL + * - status = PENDING + * - cryptoAmount = ZERO (filled amount, updated later by resolver) + * - fiatAmount = ZERO (filled fiat, updated later by resolver) + * - limitPrice = [limitPrice] + * - requestedCryptoAmount = [cryptoAmount] + * - price = [limitPrice] (initial value; updated to avg fill price when resolved) + * - exchangeOrderId = order id returned by exchange + * + * The caller is responsible for setting planId and connectionId. + * + * Default: unsupported. + */ + suspend fun limitSell( + crypto: String, + fiat: String, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal + ): DcaResult = throw UnsupportedOperationException( + "AccBot zatim nepodporuje limit sell pro ${exchange.displayName}" + ) + + /** + * Cancel an open order. + * + * Binance requires the trading pair as context; other exchanges ignore crypto/fiat. + * + * Default: unsupported. + */ + suspend fun cancelOrder(orderId: String, crypto: String, fiat: String): Result = + Result.failure(UnsupportedOperationException( + "AccBot zatim nepodporuje cancel order pro ${exchange.displayName}" + )) + + /** + * Whether this exchange implementation supports placing limit sell orders. + * Used by the UI to hide the "Prodat" action for unsupported exchanges. + */ + val supportsLimitSell: Boolean get() = false + + /** + * Estimated taker fee rate (e.g. 0.0035 = 0.35%) for decision support in the sell wizard. + * Approximate; actual user fee may be lower with VIP tier or fee discounts (e.g. BNB on + * Binance). Default 0.002 used for exchanges where the actual rate is unknown. + */ + val estimatedTakerFeeRate: BigDecimal get() = BigDecimal("0.002") /** * Get trade history for a currency pair. diff --git a/accbot-android/app/src/main/java/com/accbot/dca/exchange/OrderStatusResult.kt b/accbot-android/app/src/main/java/com/accbot/dca/exchange/OrderStatusResult.kt new file mode 100644 index 0000000..71b6eec --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/exchange/OrderStatusResult.kt @@ -0,0 +1,27 @@ +package com.accbot.dca.exchange + +import com.accbot.dca.domain.model.TransactionStatus +import java.math.BigDecimal + +/** + * Result of an order status query. + * + * Used by the ResolvePendingTransactionsUseCase to update PENDING/PARTIAL transactions + * with fill information from the exchange. + * + * @property status Current order status mapped to our TransactionStatus enum. + * @property filledCryptoAmount Crypto amount filled so far (0 if not yet filled). + * @property filledFiatAmount Fiat amount of fills so far (0 if not yet filled). + * @property avgFillPrice Volume-weighted average fill price, or null if not yet filled. + * @property fee Total fee accrued by the order so far, or null if the exchange doesn't + * report fees on the order object (e.g. Binance - fees are per-trade). + * @property feeAsset Asset in which the fee is denominated, or null if [fee] is null. + */ +data class OrderStatusResult( + val status: TransactionStatus, + val filledCryptoAmount: BigDecimal, + val filledFiatAmount: BigDecimal, + val avgFillPrice: BigDecimal?, + val fee: BigDecimal?, + val feeAsset: String? +) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/exchange/OtherExchanges.kt b/accbot-android/app/src/main/java/com/accbot/dca/exchange/OtherExchanges.kt index d3b4649..dde2d1d 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/exchange/OtherExchanges.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/exchange/OtherExchanges.kt @@ -24,6 +24,8 @@ class KrakenApi( ) : ExchangeApi { override val exchange = Exchange.KRAKEN + override val estimatedTakerFeeRate: BigDecimal = BigDecimal("0.0026") + private val baseUrl = ExchangeConfig.getBaseUrl(Exchange.KRAKEN, isSandbox) private val formMediaType = "application/x-www-form-urlencoded".toMediaType() @@ -107,7 +109,21 @@ class KrakenApi( withContext(Dispatchers.IO) { try { val pair = mapPair(crypto, fiat) - val params = "ordertype=market&type=buy&pair=$pair&oflags=viqc&volume=${fiatAmount.toPlainString()}" + // Kraken's AddOrder takes volume in BASE currency - the 'viqc' + // (volume-in-quote) flag was removed from the spot API, so we size the + // order from the current price and reserve the taker fee so that + // cost + fee stays within the plan's fiat amount. + val price = getCurrentPrice(crypto, fiat) + ?: return@withContext DcaResult.Error( + "Could not fetch $pair price to size the order", + retryable = true + ) + val volume = fiatAmount.divide( + price.multiply(BigDecimal.ONE.plus(estimatedTakerFeeRate)), + 8, + RoundingMode.DOWN + ) + val params = "ordertype=market&type=buy&pair=$pair&volume=${volume.toPlainString()}" val (isSuccessful, code, body) = executePrivateRequest("/0/private/AddOrder", params) @@ -211,7 +227,11 @@ class KrakenApi( val price: BigDecimal ) - override suspend fun getOrderStatus(orderId: String): Transaction? = withContext(Dispatchers.IO) { + override suspend fun getOrderStatus( + orderId: String, + crypto: String, + fiat: String + ): OrderStatusResult? = withContext(Dispatchers.IO) { try { val (_, _, body) = executePrivateRequest("/0/private/QueryOrders", "txid=$orderId&trades=true") val json = JSONObject(body) @@ -222,34 +242,32 @@ class KrakenApi( val order = result.optJSONObject(orderId) ?: return@withContext null val status = order.optString("status") - if (status == "closed") { - val volExec = BigDecimal(order.optString("vol_exec", "0")) - val cost = BigDecimal(order.optString("cost", "0")) - val fee = BigDecimal(order.optString("fee", "0")) - val price = if (volExec > BigDecimal.ZERO) { - cost.divide(volExec, 8, RoundingMode.HALF_UP) - } else BigDecimal.ZERO - - // Parse pair info from order description - val descr = order.optJSONObject("descr") - val pair = descr?.optString("pair", "") ?: "" - - Transaction( - planId = 0, - exchange = Exchange.KRAKEN, - crypto = "", - fiat = "", - fiatAmount = cost, - cryptoAmount = volExec, - price = price, - fee = fee, - status = TransactionStatus.COMPLETED, - exchangeOrderId = orderId, - executedAt = Instant.now() - ) - } else { - null + val volExec = BigDecimal(order.optString("vol_exec", "0")) + val cost = BigDecimal(order.optString("cost", "0")) + val fee = BigDecimal(order.optString("fee", "0")) + val price = if (volExec > BigDecimal.ZERO) { + cost.divide(volExec, 8, RoundingMode.HALF_UP) + } else BigDecimal.ZERO + + val mappedStatus = when (status) { + "closed" -> TransactionStatus.COMPLETED + "open", "pending" -> if (volExec > BigDecimal.ZERO) { + TransactionStatus.PARTIAL + } else TransactionStatus.PENDING + "canceled", "cancelled", "expired" -> + if (volExec > BigDecimal.ZERO) TransactionStatus.PARTIAL + else TransactionStatus.FAILED + else -> return@withContext null } + + OrderStatusResult( + status = mappedStatus, + filledCryptoAmount = volExec, + filledFiatAmount = cost, + avgFillPrice = if (volExec > BigDecimal.ZERO) price else null, + fee = fee, + feeAsset = fiat + ) } catch (_: Exception) { null } @@ -433,6 +451,8 @@ class KuCoinApi( ) : ExchangeApi { override val exchange = Exchange.KUCOIN + override val estimatedTakerFeeRate: BigDecimal = BigDecimal("0.001") + private val baseUrl = ExchangeConfig.getBaseUrl(Exchange.KUCOIN, isSandbox) private fun signedRequest(method: String, endpoint: String, body: String? = null): Request.Builder { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt index 8970b23..dc0e898 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt @@ -8,6 +8,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventType @@ -57,8 +58,15 @@ import java.time.format.DateTimeFormatter import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisGuidelineComponent import com.patrykandpatrick.vico.compose.cartesian.marker.rememberShowOnPress import com.patrykandpatrick.vico.compose.common.component.rememberShapeComponent +import com.patrykandpatrick.vico.compose.common.component.rememberLineComponent +import com.patrykandpatrick.vico.core.cartesian.data.CartesianLayerRangeProvider +import com.patrykandpatrick.vico.core.cartesian.decoration.HorizontalLine import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarkerController +import com.patrykandpatrick.vico.core.common.data.ExtraStore import com.patrykandpatrick.vico.core.common.shape.CorneredShape +import com.patrykandpatrick.vico.core.common.shape.DashedShape +import com.patrykandpatrick.vico.core.common.shape.Shape +import androidx.compose.ui.graphics.toArgb private val chartAccentColor = Primary private val costBasisColor = Color(0xFF888888) @@ -151,6 +159,50 @@ fun InteractiveChartLegend( } } +/** + * Static (non-interactive) legend entry describing the dashed red horizontal lines + * that mark open sell-order limit prices on the per-plan chart. Shown only when + * the chart actually renders such lines (i.e. plan allows sells, FIAT mode, and + * at least one open sell exists). Mirrors the dashed look used on the chart with + * a tiny dashed mini-line as the colour swatch. + */ +@Composable +fun LimitOrderLegendItem( + label: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onClick: () -> Unit = {}, +) { + val baseColor = MaterialTheme.colorScheme.error + val swatchColor = if (enabled) baseColor else baseColor.copy(alpha = 0.3f) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .clickable(role = Role.Button, onClick = onClick) + .padding(4.dp) + ) { + // Mini dashed line: 3 short segments to evoke the chart's dash pattern. + Row(verticalAlignment = Alignment.CenterVertically) { + repeat(3) { i -> + if (i > 0) Spacer(Modifier.width(2.dp)) + Box( + Modifier + .size(width = 4.dp, height = 2.dp) + .background(swatchColor) + ) + } + } + Spacer(Modifier.width(6.dp)) + Text( + label, + style = MaterialTheme.typography.bodySmall, + color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + textDecoration = if (enabled) null else TextDecoration.LineThrough + ) + } +} + @Composable private fun LegendItem(color: Color, label: String, enabled: Boolean = true, onClick: () -> Unit = {}) { Row( @@ -196,6 +248,13 @@ fun PortfolioLineChart( visibleCryptoGroupLines: Set> = emptySet(), zoomLevel: ChartZoomLevel = ChartZoomLevel.Overview, onScrub: (Int?) -> Unit = {}, + /** + * Limit prices of currently open (PENDING / PARTIAL) sell orders for the + * displayed plan. Each value yields a horizontal line on the left (fiat) axis + * to give the user a visual reference for where their orders will fill. + * Empty for aggregate pages or plans with sells disabled. + */ + openSellLimitPrices: List = emptyList(), modifier: Modifier = Modifier ) { if (chartData.isEmpty()) return @@ -465,6 +524,51 @@ fun PortfolioLineChart( if (isEmpty()) add(hiddenLine) } + // Open-sell limit-price horizontal lines (per plan, on the left/fiat axis). + // Each value renders a thin dashed red line so the user can visually compare + // their pending sell targets against the current portfolio value / crypto price. + val sellLineColor = MaterialTheme.colorScheme.error + val dashedSellShape = remember { + DashedShape( + shape = Shape.Rectangle, + dashLengthDp = 6f, + gapLengthDp = 4f, + fitStrategy = DashedShape.FitStrategy.Resize + ) + } + val sellLineComponent = rememberLineComponent( + fill = fill(sellLineColor), + thickness = 1.5.dp, + shape = dashedSellShape + ) + val sellDecorations = remember(openSellLimitPrices, sellLineComponent) { + openSellLimitPrices.map { price -> + val v = price.toDouble() + HorizontalLine( + y = { v }, + line = sellLineComponent + ) + } + } + + // Y-axis range provider: when there are open-sell limit lines, expand the + // auto-calculated Y range (left/fiat axis) so all limit prices remain visible + // even when they sit far above/below the actual portfolio/price series. + val leftRangeProvider = remember(openSellLimitPrices) { + if (openSellLimitPrices.isEmpty()) { + CartesianLayerRangeProvider.auto() + } else { + object : CartesianLayerRangeProvider { + private val limitMax = openSellLimitPrices.maxOf { it.toDouble() } + private val limitMin = openSellLimitPrices.minOf { it.toDouble() } + override fun getMaxY(minY: Double, maxY: Double, extraStore: ExtraStore): Double = + maxOf(maxY, limitMax * 1.05) + override fun getMinY(minY: Double, maxY: Double, extraStore: ExtraStore): Double = + minOf(minY, limitMin * 0.95) + } + } + } + // Tap-to-inspect marker – scrub fires onScrub to update KPI cards, no tooltip text val indicatorComponent = rememberShapeComponent( fill = fill(chartAccentColor), @@ -523,48 +627,52 @@ fun PortfolioLineChart( } } ) { - CartesianChartHost( - chart = rememberCartesianChart( - rememberLineCartesianLayer( - lineProvider = LineCartesianLayer.LineProvider.series(leftLines) - ), - rememberLineCartesianLayer( - lineProvider = LineCartesianLayer.LineProvider.series(rightLines), - verticalAxisPosition = Axis.Position.Vertical.End - ), - startAxis = VerticalAxis.rememberStart( - label = axisLabelComponent, - title = unitSuffix, - titleComponent = axisTitleComponent, - itemPlacer = remember { VerticalAxis.ItemPlacer.count(count = { 5 }) }, - valueFormatter = { _, value, _ -> - val bd = BigDecimal.valueOf(value) - when { - value >= 1 -> NumberFormatters.compactFiat(bd) - else -> NumberFormatters.cryptoCompact(bd) + key(openSellLimitPrices) { + CartesianChartHost( + chart = rememberCartesianChart( + rememberLineCartesianLayer( + lineProvider = LineCartesianLayer.LineProvider.series(leftLines), + rangeProvider = leftRangeProvider + ), + rememberLineCartesianLayer( + lineProvider = LineCartesianLayer.LineProvider.series(rightLines), + verticalAxisPosition = Axis.Position.Vertical.End + ), + startAxis = VerticalAxis.rememberStart( + label = axisLabelComponent, + title = unitSuffix, + titleComponent = axisTitleComponent, + itemPlacer = remember { VerticalAxis.ItemPlacer.count(count = { 5 }) }, + valueFormatter = { _, value, _ -> + val bd = BigDecimal.valueOf(value) + when { + value >= 1 -> NumberFormatters.compactFiat(bd) + else -> NumberFormatters.cryptoCompact(bd) + } } - } - ), - endAxis = if (hasRightAxis) endAxisComponent else null, - bottomAxis = HorizontalAxis.rememberBottom( - label = axisLabelComponent, - valueFormatter = { _, value, _ -> - val index = value.toInt().coerceIn(0, xLabels.size - 1) - xLabels.getOrElse(index) { "" } - }, - itemPlacer = remember(chartData.size, xAxisSpacing) { - HorizontalAxis.ItemPlacer.aligned( - spacing = { xAxisSpacing } - ) - } + ), + endAxis = if (hasRightAxis) endAxisComponent else null, + bottomAxis = HorizontalAxis.rememberBottom( + label = axisLabelComponent, + valueFormatter = { _, value, _ -> + val index = value.toInt().coerceIn(0, xLabels.size - 1) + xLabels.getOrElse(index) { "" } + }, + itemPlacer = remember(chartData.size, xAxisSpacing) { + HorizontalAxis.ItemPlacer.aligned( + spacing = { xAxisSpacing } + ) + } + ), + marker = marker, + markerController = CartesianMarkerController.rememberShowOnPress(), + decorations = sellDecorations ), - marker = marker, - markerController = CartesianMarkerController.rememberShowOnPress() - ), - modelProducer = modelProducer, - scrollState = rememberVicoScrollState(scrollEnabled = false), - zoomState = rememberVicoZoomState(zoomEnabled = false, initialZoom = remember { Zoom.Content }), - modifier = Modifier.fillMaxSize() - ) + modelProducer = modelProducer, + scrollState = rememberVicoScrollState(scrollEnabled = false), + zoomState = rememberVicoZoomState(zoomEnabled = false, initialZoom = remember { Zoom.Content }), + modifier = Modifier.fillMaxSize() + ) + } } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/CommonComponents.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/CommonComponents.kt index 442091c..f24d4c4 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/CommonComponents.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/CommonComponents.kt @@ -296,6 +296,7 @@ fun TransactionStatusIcon(status: TransactionStatus) { TransactionStatus.FAILED -> Icons.Default.Error to Error TransactionStatus.PENDING -> Icons.Default.Schedule to accentCol TransactionStatus.PARTIAL -> Icons.Default.RemoveCircle to Color(0xFFFFA500) + TransactionStatus.CANCELLED -> Icons.Default.Cancel to MaterialTheme.colorScheme.onSurfaceVariant } Box( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ReusableComponents.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ReusableComponents.kt index b900ac5..45f2b7e 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ReusableComponents.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ReusableComponents.kt @@ -242,6 +242,11 @@ fun getTransactionStatusStyle(status: TransactionStatus): TransactionStatusStyle color = WarningOrange, label = stringResource(R.string.transaction_status_partial) ) + TransactionStatus.CANCELLED -> TransactionStatusStyle( + icon = Icons.Default.Cancel, + color = MaterialTheme.colorScheme.onSurfaceVariant, + label = stringResource(R.string.transaction_status_cancelled) + ) } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/navigation/Screen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/navigation/Screen.kt index 1847da2..3e71710 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/navigation/Screen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/navigation/Screen.kt @@ -53,11 +53,12 @@ sealed class Screen(val route: String) { } // History screens - data object History : Screen("history?crypto={crypto}&fiat={fiat}") { - fun createRoute(crypto: String? = null, fiat: String? = null): String { + data object History : Screen("history?crypto={crypto}&fiat={fiat}&planId={planId}") { + fun createRoute(crypto: String? = null, fiat: String? = null, planId: Long? = null): String { val params = buildList { if (crypto != null) add("crypto=$crypto") if (fiat != null) add("fiat=$fiat") + if (planId != null) add("planId=$planId") } return if (params.isEmpty()) "history" else "history?${params.joinToString("&")}" } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormContent.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormContent.kt index 800c6e6..ef5d799 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormContent.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormContent.kt @@ -59,7 +59,13 @@ fun PlanFormContent( exchange: Exchange? = null, showCryptoFiatSelection: Boolean = true, showNameField: Boolean = true, - errorMessage: String? = null + errorMessage: String? = null, + // Sell extension (Task 21/22) - gated by the hosting screen's global trading flag. + // When [showSellSection] is false the section is hidden entirely; when true it + // renders the allow-sells switch and (when allowed) the profit-target field. + showSellSection: Boolean = false, + onAllowSellsChanged: (Boolean) -> Unit = {}, + onTargetProfitAmountChanged: (String) -> Unit = {} ) { Column( modifier = modifier, @@ -241,6 +247,61 @@ fun PlanFormContent( ) } + // Sell extension section (gated by global trading switch in Settings) + if (showSellSection) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + SectionTitle(stringResource(R.string.plan_form_sell_section_title)) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(role = Role.Switch) { onAllowSellsChanged(!state.allowSells) } + .semantics(mergeDescendants = true) { role = Role.Switch }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.plan_form_allow_sells_title), + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + Switch( + checked = state.allowSells, + onCheckedChange = null, + modifier = Modifier.clearAndSetSemantics {} + ) + } + + if (state.allowSells) { + OutlinedTextField( + value = state.targetProfitAmount, + onValueChange = onTargetProfitAmountChanged, + modifier = Modifier.fillMaxWidth(), + label = { + Text(stringResource(R.string.plan_form_target_profit_label, state.selectedFiat)) + }, + isError = state.targetProfitAmountError != null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + supportingText = { + val error = state.targetProfitAmountError + if (error != null) { + // Translate the internal sentinel into the localized resource. + val localized = when (error) { + "Zadej platne cislo" -> stringResource(R.string.plan_form_target_profit_error_invalid) + "Cil musi byt kladny" -> stringResource(R.string.plan_form_target_profit_error_positive) + else -> error + } + Text(localized, color = MaterialTheme.colorScheme.error) + } else { + Text(stringResource(R.string.plan_form_target_profit_supporting)) + } + } + ) + } + } + } + // Error message if (errorMessage != null) { Text( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormDelegate.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormDelegate.kt index d448e08..66df9e8 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormDelegate.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormDelegate.kt @@ -35,7 +35,11 @@ data class PlanFormState( val addressError: String? = null, val targetAmount: String = "", val minOrderSize: BigDecimal? = null, - val monthlyCostEstimate: MonthlyCostEstimate? = null + val monthlyCostEstimate: MonthlyCostEstimate? = null, + // Sell-extension (opt-in, gated by global tradingEnabled in the hosting screen) + val allowSells: Boolean = false, + val targetProfitAmount: String = "", + val targetProfitAmountError: String? = null ) { val amountBelowMinimum: Boolean get() { @@ -54,6 +58,7 @@ data class PlanFormState( if (amountBelowMinimum) return false if (withdrawalEnabled && !isAddressValid) return false if (selectedFrequency == DcaFrequency.CUSTOM && !CronUtils.isValidCron(cronExpression)) return false + if (allowSells && targetProfitAmountError != null) return false return true } } @@ -159,6 +164,23 @@ class PlanFormDelegate( _state.update { it.copy(targetAmount = value) } } + fun setAllowSells(value: Boolean) { + _state.update { it.copy(allowSells = value) } + } + + fun setTargetProfitAmount(raw: String) { + val trimmed = raw.trim() + val error = when { + trimmed.isBlank() -> null // empty = no target, valid + trimmed.toBigDecimalOrNull() == null -> "Zadej platne cislo" + trimmed.toBigDecimal() <= BigDecimal.ZERO -> "Cil musi byt kladny" + else -> null + } + _state.update { + it.copy(targetProfitAmount = raw, targetProfitAmountError = error) + } + } + /** Initialize form defaults from an exchange (used when exchange is selected). */ fun initFromExchange(exchange: Exchange) { currentExchange = exchange @@ -186,7 +208,9 @@ class PlanFormDelegate( strategy: DcaStrategy, withdrawalEnabled: Boolean, withdrawalAddress: String, - targetAmount: String + targetAmount: String, + allowSells: Boolean = false, + targetProfitAmount: String = "" ) { currentExchange = exchange val cronDesc = if (cronExpression.isNotBlank()) CronUtils.describeCron(cronExpression) else null @@ -202,7 +226,9 @@ class PlanFormDelegate( selectedStrategy = strategy, withdrawalEnabled = withdrawalEnabled, withdrawalAddress = withdrawalAddress, - targetAmount = targetAmount + targetAmount = targetAmount, + allowSells = allowSells, + targetProfitAmount = targetProfitAmount ) } updateMinOrderSize() diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt index b07cf6b..53ee6f2 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt @@ -320,7 +320,10 @@ fun AddPlanScreen( onWithdrawalAddressChanged = viewModel.planForm::setWithdrawalAddress, onTargetAmountChanged = viewModel.planForm::setTargetAmount, exchange = cred.selectedExchange, - errorMessage = uiState.errorMessage + errorMessage = uiState.errorMessage, + showSellSection = uiState.tradingEnabled, + onAllowSellsChanged = viewModel.planForm::setAllowSells, + onTargetProfitAmountChanged = viewModel.planForm::setTargetProfitAmount ) // Create Button diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanViewModel.kt index 70044bb..5d13826 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanViewModel.kt @@ -35,6 +35,10 @@ data class AddPlanUiState( // Change tracking val hasChanges: Boolean = false, + // Global trading master switch (from UserPreferences). Gates whether the + // Sells section is shown in the plan form. + val tradingEnabled: Boolean = false, + // Action state val isLoading: Boolean = false, val isSuccess: Boolean = false, @@ -78,7 +82,9 @@ class AddPlanViewModel @Inject constructor( dcaPlanDao = dcaPlanDao ) - private val _localState = MutableStateFlow(AddPlanUiState()) + private val _localState = MutableStateFlow( + AddPlanUiState(tradingEnabled = userPreferences.isTradingEnabled()) + ) val uiState: StateFlow = combine( _localState, @@ -88,7 +94,11 @@ class AddPlanViewModel @Inject constructor( val hasChanges = cred.selectedExchange != null local.copy(planForm = form, credentialForm = cred, hasChanges = hasChanges) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AddPlanUiState()) + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + AddPlanUiState(tradingEnabled = userPreferences.isTradingEnabled()) + ) init { credentialForm.initialize() @@ -145,6 +155,12 @@ class AddPlanViewModel @Inject constructor( } } + val tradingGloballyEnabled = userPreferences.isTradingEnabled() + val allowSells = tradingGloballyEnabled && form.allowSells + val targetProfit = if (allowSells) { + form.targetProfitAmount.trim().takeIf { it.isNotEmpty() }?.toBigDecimalOrNull() + } else null + createDcaPlanUseCase.execute( exchange = exchange, connectionId = targetConnectionId, @@ -157,7 +173,9 @@ class AddPlanViewModel @Inject constructor( withdrawalEnabled = form.withdrawalEnabled, withdrawalAddress = if (form.withdrawalEnabled) form.withdrawalAddress.trim() else null, targetAmount = form.targetAmount.toBigDecimalOrNull(), - name = form.name.trim() + name = form.name.trim(), + allowSells = allowSells, + targetProfitAmount = targetProfit ) // Only offer the API import flow when this was a freshly created connection. diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt index 6541d74..0c9495d 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt @@ -65,6 +65,7 @@ import com.accbot.dca.data.remote.CryptoData import com.accbot.dca.data.remote.FearGreedData import com.accbot.dca.domain.model.DcaFrequency import com.accbot.dca.domain.model.DcaStrategy +import com.accbot.dca.domain.model.Transaction import com.accbot.dca.domain.util.CronUtils import com.accbot.dca.presentation.components.CryptoIcon import com.accbot.dca.presentation.components.EmptyState @@ -325,6 +326,7 @@ fun DashboardScreen( onToggle = { viewModel.togglePlan(planId) }, onClick = { onNavigateToPlanDetails?.invoke(planId) }, currentTime = currentTime, + openSellCount = uiState.openSellsByPlan[planId]?.size ?: 0, isDragging = landscapeDragState.isDragging(planId), dragOffset = if (landscapeDragState.isDragging(planId)) landscapeDragState.dragOffset else 0f, onDragStart = { heightPx -> landscapeDragState.startDrag(planId, heightPx) }, @@ -444,6 +446,7 @@ fun DashboardScreen( onToggle = { viewModel.togglePlan(planId) }, onClick = { onNavigateToPlanDetails?.invoke(planId) }, currentTime = currentTime, + openSellCount = uiState.openSellsByPlan[planId]?.size ?: 0, isDragging = portraitDragState.isDragging(planId), dragOffset = if (portraitDragState.isDragging(planId)) portraitDragState.dragOffset else 0f, onDragStart = { heightPx -> portraitDragState.startDrag(planId, heightPx) }, @@ -1037,6 +1040,7 @@ internal fun DcaPlanCard( onToggle: () -> Unit, onClick: (() -> Unit)? = null, currentTime: Long = System.currentTimeMillis(), + openSellCount: Int = 0, isDragging: Boolean = false, dragOffset: Float = 0f, onDragStart: ((heightPx: Int) -> Unit)? = null, @@ -1317,6 +1321,28 @@ internal fun DcaPlanCard( fontWeight = FontWeight.Medium ) } + if (openSellCount > 0) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = Icons.Default.TrendingDown, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.error + ) + Text( + text = stringResource( + R.string.dashboard_plan_open_sells, + openSellCount + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Medium + ) + } + } } } Column( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt index b5290f3..c530f53 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt @@ -14,6 +14,7 @@ import com.accbot.dca.data.local.TransactionDao import com.accbot.dca.data.local.UserPreferences import com.accbot.dca.data.local.WithdrawalThresholdDao import com.accbot.dca.data.local.toDomain +import com.accbot.dca.domain.model.Transaction import com.accbot.dca.data.remote.CryptoData import com.accbot.dca.data.remote.FearGreedData import com.accbot.dca.data.remote.MarketDataService @@ -110,7 +111,12 @@ data class DashboardUiState( val showMarketPulse: Boolean = true, val isMarketPulseExpanded: Boolean = true, val networkRetryInfo: NetworkRetryInfo = NetworkRetryInfo(), - val missedPurchases: List = emptyList() + val missedPurchases: List = emptyList(), + /** + * Open SELL orders grouped by plan id. Empty when trading is off or no + * pending sells exist; the dashboard renders one card per non-empty group. + */ + val openSellsByPlan: Map> = emptyMap() ) @HiltViewModel @@ -152,6 +158,23 @@ class DashboardViewModel @Inject constructor( init { loadData() + observeOpenSells() + } + + /** + * Continuously observe open (PENDING / PARTIAL) SELL orders so the dashboard + * "Open sells" cards update reactively when an order fills, the user cancels, + * or a new sell wizard submits a fresh order. Only emits when global trading + * is enabled - avoids surfacing the cards for users who haven't opted in. + */ + private fun observeOpenSells() { + if (!userPreferences.isTradingEnabled()) return + viewModelScope.launch { + transactionDao.observeAllOpenSells().collect { entities -> + val grouped = entities.map { it.toDomain() }.groupBy { it.planId } + _uiState.update { it.copy(openSellsByPlan = grouped) } + } + } } private fun loadData() { @@ -674,10 +697,10 @@ class DashboardViewModel @Inject constructor( missedPurchases = it.missedPurchases.filter { m -> m.planId != planId } ) } - viewModelScope.launch { - dcaPlanDao.resetMissedPurchaseCount(planId) - DcaWorker.runMissedPurchases(application, planId, count) - } + // missedPurchaseCount is NOT reset here - the worker consumes it as a persisted + // checkpoint (one decrement per completed catch-up buy), so a worker replayed + // after process death resumes instead of re-buying from the start. + DcaWorker.runMissedPurchases(application, planId, count) } fun dismissMissedPurchases(planId: Long) { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt index b82bb9d..d6115f8 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt @@ -29,6 +29,7 @@ import androidx.core.content.FileProvider import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.accbot.dca.R import com.accbot.dca.data.local.TransactionEntity +import com.accbot.dca.domain.model.TransactionSide import com.accbot.dca.domain.model.TransactionStatus import com.accbot.dca.presentation.components.AccBotTopAppBar import com.accbot.dca.presentation.components.EmptyState @@ -101,6 +102,7 @@ fun HistoryScreen( currentFilter = uiState.filter, availableCryptos = uiState.availableCryptos, availableExchanges = uiState.availableExchanges, + availablePlans = uiState.availablePlans, onApplyFilter = { filter -> viewModel.setFilter(filter) viewModel.hideFilterSheet() @@ -140,6 +142,7 @@ fun HistoryScreen( val hasActiveFilter = uiState.filter.crypto != null || uiState.filter.exchange != null || uiState.filter.status != null || + uiState.filter.planId != null || uiState.filter.dateFrom != null || uiState.filter.dateTo != null var showSearchBar by rememberSaveable { mutableStateOf(false) } @@ -231,10 +234,17 @@ fun HistoryScreen( ) } + // Side filter chips (Vse / Nakupy / Prodeje / Pending) + SideFilterChipsRow( + selected = uiState.filter.sideFilter, + onSelect = { viewModel.setSideFilter(it) } + ) + // Active filter chips if (hasActiveFilter) { ActiveFilterChips( filter = uiState.filter, + availablePlans = uiState.availablePlans, onUpdateFilter = { viewModel.setFilter(it) }, onClearFilter = { viewModel.clearFilter() } ) @@ -333,6 +343,7 @@ private fun SortDropdownMenu( @Composable private fun ActiveFilterChips( filter: HistoryFilter, + availablePlans: List, onUpdateFilter: (HistoryFilter) -> Unit, onClearFilter: () -> Unit ) { @@ -344,6 +355,20 @@ private fun ActiveFilterChips( .padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { + filter.planId?.let { planId -> + item { + val label = availablePlans.firstOrNull { it.id == planId }?.label + ?: stringResource(R.string.history_filter_plan) + SelectableChip( + text = label, + selected = true, + onClick = { onUpdateFilter(filter.copy(planId = null)) }, + trailingIcon = { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.common_remove), modifier = Modifier.size(16.dp)) + } + ) + } + } filter.crypto?.let { crypto -> item { SelectableChip( @@ -472,6 +497,7 @@ private fun FilterBottomSheet( currentFilter: HistoryFilter, availableCryptos: List, availableExchanges: List, + availablePlans: List, onApplyFilter: (HistoryFilter) -> Unit, onClearFilter: () -> Unit, onDismiss: () -> Unit @@ -479,6 +505,7 @@ private fun FilterBottomSheet( var selectedCrypto by rememberSaveable { mutableStateOf(currentFilter.crypto) } var selectedExchange by rememberSaveable { mutableStateOf(currentFilter.exchange) } var selectedStatus by rememberSaveable { mutableStateOf(currentFilter.status) } + var selectedPlanId by rememberSaveable { mutableStateOf(currentFilter.planId) } var selectedDateFrom by rememberSaveable { mutableStateOf(currentFilter.dateFrom) } var selectedDateTo by rememberSaveable { mutableStateOf(currentFilter.dateTo) } var showDateFromPicker by rememberSaveable { mutableStateOf(false) } @@ -576,6 +603,33 @@ private fun FilterBottomSheet( Spacer(modifier = Modifier.height(16.dp)) } + // Plan filter + if (availablePlans.isNotEmpty()) { + Text( + text = stringResource(R.string.history_filter_plan), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(8.dp)) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + item { + SelectableChip( + text = stringResource(R.string.common_all), + selected = selectedPlanId == null, + onClick = { selectedPlanId = null } + ) + } + items(availablePlans, key = { it.id }) { plan -> + SelectableChip( + text = plan.label, + selected = selectedPlanId == plan.id, + onClick = { selectedPlanId = plan.id } + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + } + // Exchange filter if (availableExchanges.isNotEmpty()) { Text( @@ -682,10 +736,11 @@ private fun FilterBottomSheet( Button( onClick = { onApplyFilter( - HistoryFilter( + currentFilter.copy( crypto = selectedCrypto, exchange = selectedExchange, status = selectedStatus, + planId = selectedPlanId, dateFrom = selectedDateFrom, dateTo = selectedDateTo ) @@ -702,12 +757,40 @@ private fun FilterBottomSheet( } } +@Composable +private fun SideFilterChipsRow( + selected: HistorySideFilter, + onSelect: (HistorySideFilter) -> Unit +) { + val entries = listOf( + HistorySideFilter.ALL to stringResource(R.string.history_side_all), + HistorySideFilter.BUYS to stringResource(R.string.history_side_buys), + HistorySideFilter.SELLS to stringResource(R.string.history_side_sells), + HistorySideFilter.PENDING to stringResource(R.string.history_side_pending) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + entries.forEach { (side, label) -> + FilterChip( + selected = selected == side, + onClick = { onSelect(side) }, + label = { Text(label) } + ) + } + } +} + @Composable internal fun TransactionCard( transaction: TransactionEntity, onClick: () -> Unit ) { val dateFormatter = DateFormatters.transactionDateTime + val isSell = transaction.side == TransactionSide.SELL Card( modifier = Modifier @@ -726,12 +809,23 @@ internal fun TransactionCard( ) { Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { + // Direction badge: green ArrowDownward for BUY, red ArrowUpward for SELL. + Icon( + imageVector = if (isSell) Icons.AutoMirrored.Filled.TrendingUp else Icons.AutoMirrored.Filled.TrendingDown, + contentDescription = stringResource( + if (isSell) R.string.history_side_sell_label else R.string.history_side_buy_label + ), + tint = if (isSell) Error else successColor(), + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) Icon( imageVector = when (transaction.status) { TransactionStatus.COMPLETED -> Icons.Default.CheckCircle TransactionStatus.FAILED -> Icons.Default.Error TransactionStatus.PENDING -> Icons.Default.Schedule TransactionStatus.PARTIAL -> Icons.Default.Warning + TransactionStatus.CANCELLED -> Icons.Default.Cancel }, contentDescription = null, tint = when (transaction.status) { @@ -784,11 +878,16 @@ internal fun TransactionCard( } Column(horizontalAlignment = Alignment.End) { - if (transaction.status == TransactionStatus.COMPLETED) { + // Amount signs: BUY = +crypto/-fiat, SELL = -crypto/+fiat. + // Only show filled crypto for COMPLETED and PARTIAL (in-flight PENDING has 0). + val showCryptoLine = transaction.status == TransactionStatus.COMPLETED || + (transaction.status == TransactionStatus.PARTIAL && transaction.cryptoAmount.signum() > 0) + if (showCryptoLine) { + val cryptoSign = if (isSell) "-" else "+" Text( - text = "+${NumberFormatters.crypto(transaction.cryptoAmount)}", + text = "$cryptoSign${NumberFormatters.crypto(transaction.cryptoAmount)}", fontWeight = FontWeight.SemiBold, - color = successColor() + color = if (isSell) Error else successColor() ) Text( text = transaction.crypto, @@ -799,10 +898,11 @@ internal fun TransactionCard( Spacer(modifier = Modifier.height(4.dp)) + val fiatSign = if (isSell) "+" else "-" Text( - text = "-${NumberFormatters.fiat(transaction.fiatAmount)} ${transaction.fiat}", + text = "$fiatSign${NumberFormatters.fiat(transaction.fiatAmount)} ${transaction.fiat}", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = if (isSell) successColor() else MaterialTheme.colorScheme.onSurfaceVariant ) // Chevron to indicate clickable diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryViewModel.kt index 6673a54..d168174 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryViewModel.kt @@ -3,8 +3,10 @@ package com.accbot.dca.presentation.screens import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.accbot.dca.data.local.DcaPlanDao import com.accbot.dca.data.local.TransactionDao import com.accbot.dca.data.local.TransactionEntity +import com.accbot.dca.domain.model.TransactionSide import com.accbot.dca.domain.model.TransactionStatus import com.accbot.dca.domain.usecase.CsvExportResult import com.accbot.dca.domain.usecase.ExportTransactionsToCsvUseCase @@ -27,13 +29,27 @@ enum class SortOption { PRICE_LOWEST } +/** + * Primary BUY/SELL/PENDING filter chips shown at the top of HistoryScreen. + * Applied in memory over the result of the SQL-level HistoryFilter below. + */ +enum class HistorySideFilter { ALL, BUYS, SELLS, PENDING } + data class HistoryFilter( val crypto: String? = null, val exchange: String? = null, val status: TransactionStatus? = null, + val planId: Long? = null, val dateFrom: Long? = null, val dateTo: Long? = null, - val searchQuery: String = "" + val searchQuery: String = "", + val sideFilter: HistorySideFilter = HistorySideFilter.ALL +) + +/** A plan the user can filter history by, shown in the filter sheet. */ +data class HistoryPlanOption( + val id: Long, + val label: String ) /** @@ -51,6 +67,7 @@ data class HistoryUiState( val sortOption: SortOption = SortOption.DATE_NEWEST, val availableCryptos: List = emptyList(), val availableExchanges: List = emptyList(), + val availablePlans: List = emptyList(), val showFilterSheet: Boolean = false, val isExporting: Boolean = false, val exportSuccess: Boolean = false, @@ -64,13 +81,17 @@ data class HistoryUiState( class HistoryViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val transactionDao: TransactionDao, + private val dcaPlanDao: DcaPlanDao, private val exportTransactionsToCsvUseCase: ExportTransactionsToCsvUseCase ) : ViewModel() { private val initialCrypto: String? = savedStateHandle["crypto"] private val initialFiat: String? = savedStateHandle["fiat"] + // planId arrives as a string query param; treat <= 0 / missing as "no plan filter". + private val initialPlanId: Long? = + savedStateHandle.get("planId")?.toLongOrNull()?.takeIf { it > 0 } - private val _filterState = MutableStateFlow(HistoryFilter(crypto = initialCrypto)) + private val _filterState = MutableStateFlow(HistoryFilter(crypto = initialCrypto, planId = initialPlanId)) private val _searchQuery = MutableStateFlow("") private val _sortOption = MutableStateFlow(SortOption.DATE_NEWEST) @@ -79,7 +100,7 @@ class HistoryViewModel @Inject constructor( ) // Extract SQL-pushable chip filters and switch DAO query only when they change - private data class ChipFilter(val crypto: String?, val exchange: String?, val status: String?) + private data class ChipFilter(val crypto: String?, val exchange: String?, val status: String?, val planId: Long?) @OptIn(FlowPreview::class) private val _debouncedSearch = _searchQuery.debounce(300) @@ -87,10 +108,10 @@ class HistoryViewModel @Inject constructor( // Stage 1: SQL-filtered data + in-memory date/search/sort @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) private val _computedTransactions: Flow = _filterState - .map { ChipFilter(it.crypto, it.exchange, it.status?.name) } + .map { ChipFilter(it.crypto, it.exchange, it.status?.name, it.planId) } .distinctUntilChanged() .flatMapLatest { chip -> - transactionDao.getFilteredTransactions(chip.crypto, chip.exchange, chip.status) + transactionDao.getFilteredTransactions(chip.crypto, chip.exchange, chip.status, chip.planId) } .combine(_filterState) { transactions, filter -> transactions to filter } .combine(_debouncedSearch) { (transactions, filter), searchQuery -> @@ -106,7 +127,14 @@ class HistoryViewModel @Inject constructor( NumberFormatters.fiat(tx.fiatAmount), NumberFormatters.crypto(tx.cryptoAmount), tx.exchangeOrderId ?: "", tx.errorMessage ?: "" ).any { it.contains(filter.searchQuery, ignoreCase = true) } - matchesDates && matchesSearch + val matchesSide = when (filter.sideFilter) { + HistorySideFilter.ALL -> true + HistorySideFilter.BUYS -> tx.side == TransactionSide.BUY + HistorySideFilter.SELLS -> tx.side == TransactionSide.SELL + HistorySideFilter.PENDING -> tx.status == TransactionStatus.PENDING || + tx.status == TransactionStatus.PARTIAL + } + matchesDates && matchesSearch && matchesSide } val sorted = when (sortOption) { @@ -129,6 +157,7 @@ class HistoryViewModel @Inject constructor( computed.copy( availableCryptos = extras.availableCryptos, availableExchanges = extras.availableExchanges, + availablePlans = extras.availablePlans, showFilterSheet = extras.showFilterSheet, isExporting = extras.isExporting, exportSuccess = extras.exportSuccess, @@ -136,7 +165,7 @@ class HistoryViewModel @Inject constructor( exportData = extras.exportData, snackbarMessage = extras.snackbarMessage ) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HistoryUiState(filter = HistoryFilter(crypto = initialCrypto))) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HistoryUiState(filter = HistoryFilter(crypto = initialCrypto, planId = initialPlanId))) init { loadFilterOptions() @@ -146,11 +175,20 @@ class HistoryViewModel @Inject constructor( viewModelScope.launch { val cryptosDeferred = async { transactionDao.getDistinctCryptos() } val exchangesDeferred = async { transactionDao.getDistinctExchanges() } + val plansDeferred = async { + dcaPlanDao.getAllPlansOnceOrdered().map { plan -> + HistoryPlanOption( + id = plan.id, + label = plan.name.ifBlank { "${plan.crypto}/${plan.fiat}" } + ) + } + } _uiExtras.update { it.copy( availableCryptos = cryptosDeferred.await(), - availableExchanges = exchangesDeferred.await() + availableExchanges = exchangesDeferred.await(), + availablePlans = plansDeferred.await() ) } } @@ -164,8 +202,14 @@ class HistoryViewModel @Inject constructor( _filterState.value = filter } + fun setSideFilter(side: HistorySideFilter) { + _filterState.value = _filterState.value.copy(sideFilter = side) + } + fun clearFilter() { - _filterState.value = HistoryFilter() + // Reset everything *except* the top-level BUY/SELL/PENDING chip - users + // typically want that chip as a persistent mode, not a one-off filter. + _filterState.value = HistoryFilter(sideFilter = _filterState.value.sideFilter) } fun setSortOption(option: SortOption) { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsScreen.kt index 63afca4..3417b4e 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsScreen.kt @@ -41,8 +41,12 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import com.accbot.dca.BuildConfig import com.accbot.dca.R import com.accbot.dca.data.local.AppTheme +import com.accbot.dca.domain.model.DcaFrequency import com.accbot.dca.domain.model.Exchange import com.accbot.dca.domain.model.WithdrawalThreshold +import com.accbot.dca.domain.util.CronUtils +import com.accbot.dca.presentation.components.FrequencyDropdown +import com.accbot.dca.presentation.components.ScheduleBuilder import java.math.BigDecimal import com.accbot.dca.presentation.changelog.ChangelogData import com.accbot.dca.presentation.components.AccBotTopAppBar @@ -690,6 +694,59 @@ fun SettingsScreen( ) } + // ── ADVANCED (Sell extension) ────────────────────────── + item { + Text( + text = stringResource(R.string.settings_advanced), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(top = 24.dp, bottom = 8.dp) + .semantics { heading() } + ) + } + + item { + TradingToggleCard( + isEnabled = uiState.tradingEnabled, + onToggle = { viewModel.setTradingEnabled(it) } + ) + } + + if (uiState.tradingEnabled) { + item { + SellPollingCard( + isEnabled = uiState.periodicSellPollingEnabled, + frequency = uiState.sellPollingFrequency, + cronExpression = uiState.sellPollingCronExpression, + onToggle = { enabled -> + viewModel.setPeriodicSellPolling( + enabled = enabled, + frequency = uiState.sellPollingFrequency, + cron = uiState.sellPollingCronExpression, + scheduleConfig = null + ) + }, + onFrequencySelected = { newFreq -> + viewModel.setPeriodicSellPolling( + enabled = true, + frequency = newFreq, + cron = null, + scheduleConfig = null + ) + }, + onCronExpressionChange = { newCron -> + viewModel.setPeriodicSellPolling( + enabled = true, + frequency = DcaFrequency.CUSTOM, + cron = newCron.ifBlank { null }, + scheduleConfig = null + ) + } + ) + } + } + // ── DANGER ZONE (collapsible) ────────────────────────── item { Row( @@ -1111,6 +1168,132 @@ private fun WithdrawalThresholdDialog( ) } +@Composable +internal fun TradingToggleCard( + isEnabled: Boolean, + onToggle: (Boolean) -> Unit +) { + val accent = successColor() + val haptic = LocalHapticFeedback.current + SettingsCardBase( + title = stringResource(R.string.settings_trading_enabled_title), + subtitle = stringResource(R.string.settings_trading_enabled_subtitle), + icon = Icons.AutoMirrored.Filled.TrendingUp, + iconTint = if (isEnabled) accent else MaterialTheme.colorScheme.onSurfaceVariant, + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onToggle(!isEnabled) + }, + cardModifier = Modifier.semantics(mergeDescendants = true) { role = Role.Switch }, + trailing = { + Switch( + checked = isEnabled, + onCheckedChange = null, + modifier = Modifier.clearAndSetSemantics {}, + colors = SwitchDefaults.colors( + checkedThumbColor = accent, + checkedTrackColor = accent.copy(alpha = 0.5f) + ) + ) + } + ) +} + +@Composable +internal fun SellPollingCard( + isEnabled: Boolean, + frequency: DcaFrequency, + cronExpression: String?, + onToggle: (Boolean) -> Unit, + onFrequencySelected: (DcaFrequency) -> Unit, + onCronExpressionChange: (String) -> Unit +) { + val accent = successColor() + val haptic = LocalHapticFeedback.current + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(role = Role.Switch) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onToggle(!isEnabled) + } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Sync, + contentDescription = null, + tint = if (isEnabled) accent else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp) + ) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_sell_polling_title), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium + ) + Text( + text = stringResource(R.string.settings_sell_polling_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = isEnabled, + onCheckedChange = null, + modifier = Modifier.clearAndSetSemantics {}, + colors = SwitchDefaults.colors( + checkedThumbColor = accent, + checkedTrackColor = accent.copy(alpha = 0.5f) + ) + ) + } + AnimatedVisibility(visible = isEnabled) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + HorizontalDivider() + Text( + text = stringResource(R.string.settings_sell_polling_frequency), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold + ) + FrequencyDropdown( + selectedFrequency = frequency, + onFrequencySelected = onFrequencySelected + ) + if (frequency == DcaFrequency.CUSTOM) { + val cronExpr = cronExpression ?: "" + val cronDesc = CronUtils.describeCron(cronExpr) + ScheduleBuilder( + cronExpression = cronExpr, + cronDescription = cronDesc, + cronError = null, + onCronExpressionChange = onCronExpressionChange + ) + } + Text( + text = stringResource(R.string.settings_sell_polling_battery_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + @Composable internal fun SandboxToggleCard( isEnabled: Boolean, diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsViewModel.kt index a5ee07e..42854c5 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsViewModel.kt @@ -21,6 +21,7 @@ import com.accbot.dca.data.local.WithdrawalDao import com.accbot.dca.data.local.WithdrawalThresholdDao import com.accbot.dca.data.local.WithdrawalThresholdEntity import com.accbot.dca.data.local.toDomain +import com.accbot.dca.domain.model.DcaFrequency import com.accbot.dca.domain.model.Exchange import com.accbot.dca.domain.model.WithdrawalThreshold import java.math.BigDecimal @@ -28,6 +29,7 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat import com.accbot.dca.service.DcaForegroundService import com.accbot.dca.worker.DcaWorker +import com.accbot.dca.worker.SellPollingScheduler import androidx.compose.runtime.Immutable import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.* @@ -61,7 +63,12 @@ data class SettingsUiState( val purchaseNotificationsEnabled: Boolean = true, val errorNotificationsEnabled: Boolean = true, val weeklySummaryEnabled: Boolean = false, - val isExperimentalExchangesEnabled: Boolean = false + val isExperimentalExchangesEnabled: Boolean = false, + // Sell-extension (Pokrocile) + val tradingEnabled: Boolean = false, + val periodicSellPollingEnabled: Boolean = false, + val sellPollingFrequency: DcaFrequency = DcaFrequency.HOURLY, + val sellPollingCronExpression: String? = null ) @HiltViewModel @@ -78,7 +85,8 @@ class SettingsViewModel @Inject constructor( private val dailyPriceDao: DailyPriceDao, private val withdrawalDao: WithdrawalDao, private val withdrawalThresholdDao: WithdrawalThresholdDao, - private val exchangeConnectionDao: ExchangeConnectionDao + private val exchangeConnectionDao: ExchangeConnectionDao, + private val sellPollingScheduler: SellPollingScheduler ) : AndroidViewModel(application) { private val _uiState = MutableStateFlow(SettingsUiState()) @@ -133,7 +141,11 @@ class SettingsViewModel @Inject constructor( appTheme = userPreferences.getAppTheme(), isBiometricLockEnabled = userPreferences.isBiometricLockEnabled(), isMarketPulseEnabled = userPreferences.isMarketPulseEnabled(), - isExperimentalExchangesEnabled = userPreferences.areExperimentalExchangesEnabled() + isExperimentalExchangesEnabled = userPreferences.areExperimentalExchangesEnabled(), + tradingEnabled = userPreferences.isTradingEnabled(), + periodicSellPollingEnabled = userPreferences.isPeriodicSellPollingEnabled(), + sellPollingFrequency = userPreferences.getSellPollingFrequency(), + sellPollingCronExpression = userPreferences.getSellPollingCronExpression() ) } } @@ -198,6 +210,44 @@ class SettingsViewModel @Inject constructor( _uiState.update { it.copy(isMarketPulseEnabled = enabled) } } + /** + * Master trading switch. When turned off, also disables background sell polling + * and cancels the worker so leftover settings don't silently keep it alive. + */ + fun setTradingEnabled(enabled: Boolean) { + userPreferences.setTradingEnabled(enabled) + if (!enabled) { + userPreferences.setPeriodicSellPolling( + enabled = false, + frequency = DcaFrequency.HOURLY, + cron = null, + scheduleConfig = null + ) + sellPollingScheduler.cancel() + } + loadSettings() + } + + /** + * Enable / reschedule / disable periodic sell-order polling. Callers pass the full + * scheduling config in one shot (see [UserPreferences.setPeriodicSellPolling]) so + * readers never see a half-applied state. + */ + fun setPeriodicSellPolling( + enabled: Boolean, + frequency: DcaFrequency = _uiState.value.sellPollingFrequency, + cron: String? = null, + scheduleConfig: String? = null + ) { + userPreferences.setPeriodicSellPolling(enabled, frequency, cron, scheduleConfig) + if (enabled) { + sellPollingScheduler.rescheduleIfEnabled() + } else { + sellPollingScheduler.cancel() + } + loadSettings() + } + fun setBiometricLockEnabled(enabled: Boolean) { userPreferences.setBiometricLockEnabled(enabled) _uiState.update { it.copy(isBiometricLockEnabled = enabled) } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/history/TransactionDetailsScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/history/TransactionDetailsScreen.kt index 56fe5b7..f1ce9de 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/history/TransactionDetailsScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/history/TransactionDetailsScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.accbot.dca.R +import com.accbot.dca.domain.model.TransactionSide import com.accbot.dca.domain.model.TransactionStatus import com.accbot.dca.presentation.components.AccBotTopAppBar import com.accbot.dca.presentation.components.ErrorState @@ -37,6 +38,8 @@ import com.accbot.dca.presentation.ui.theme.accentColor import com.accbot.dca.presentation.ui.theme.successColor import com.accbot.dca.presentation.utils.DateFormatters import com.accbot.dca.presentation.utils.NumberFormatters +import java.math.BigDecimal +import java.math.RoundingMode @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -47,12 +50,18 @@ fun TransactionDetailsScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(transactionId) { viewModel.loadTransaction(transactionId) } + LaunchedEffect(Unit) { + viewModel.snackbar.collect { msg -> snackbarHostState.showSnackbar(msg) } + } + Scaffold( + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, topBar = { AccBotTopAppBar( title = stringResource(R.string.transaction_details_title), @@ -189,6 +198,62 @@ fun TransactionDetailsScreen( } } + // SELL-specific section (limit price, fill progress, cancel) + if (transaction.side == TransactionSide.SELL) { + Spacer(modifier = Modifier.height(16.dp)) + SellDetailsCard(transaction = transaction) + + if (transaction.status == TransactionStatus.PENDING || + transaction.status == TransactionStatus.PARTIAL + ) { + Spacer(modifier = Modifier.height(16.dp)) + var showConfirm by remember { mutableStateOf(false) } + Button( + onClick = { showConfirm = true }, + enabled = !uiState.isCancelling, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = Error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { + if (uiState.isCancelling) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onError + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(stringResource(R.string.transaction_details_cancel_order)) + } + + if (showConfirm) { + AlertDialog( + onDismissRequest = { showConfirm = false }, + title = { Text(stringResource(R.string.transaction_details_cancel_confirm_title)) }, + text = { Text(stringResource(R.string.transaction_details_cancel_confirm_text)) }, + confirmButton = { + TextButton(onClick = { + showConfirm = false + viewModel.cancelOrder(transaction.id) + }) { + Text( + stringResource(R.string.transaction_details_cancel_order), + color = Error + ) + } + }, + dismissButton = { + TextButton(onClick = { showConfirm = false }) { + Text(stringResource(R.string.common_back)) + } + } + ) + } + } + } + // Error message card (if failed) if (transaction.status == TransactionStatus.FAILED && transaction.errorMessage != null) { Spacer(modifier = Modifier.height(16.dp)) @@ -279,6 +344,7 @@ private fun StatusHeader(status: TransactionStatus) { TransactionStatus.FAILED -> Triple(Icons.Default.Error, Error, stringResource(R.string.transaction_status_failed)) TransactionStatus.PENDING -> Triple(Icons.Default.Schedule, accentCol, stringResource(R.string.transaction_status_pending)) TransactionStatus.PARTIAL -> Triple(Icons.Default.RemoveCircle, Color(0xFFFFA500), stringResource(R.string.transaction_status_partial)) + TransactionStatus.CANCELLED -> Triple(Icons.Default.Cancel, MaterialTheme.colorScheme.onSurfaceVariant, stringResource(R.string.transaction_status_cancelled)) } Row( @@ -341,6 +407,59 @@ private fun DetailRow( } } +@Composable +private fun SellDetailsCard(transaction: com.accbot.dca.domain.model.Transaction) { + val requested = transaction.requestedCryptoAmount ?: BigDecimal.ZERO + val filled = transaction.cryptoAmount + val progressPct = if (requested > BigDecimal.ZERO) { + filled.divide(requested, 4, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)) + .toInt() + } else 0 + val showAvgFill = filled.signum() > 0 && transaction.price.signum() > 0 + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.transaction_details_sell_section), + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleSmall + ) + + DetailRow( + icon = Icons.Default.PriceCheck, + label = stringResource(R.string.transaction_details_limit_price), + value = transaction.limitPrice?.let { + "${NumberFormatters.fiat(it)} ${transaction.fiat}/${transaction.crypto}" + } ?: "-" + ) + + DetailRow( + icon = Icons.Default.Done, + label = stringResource(R.string.transaction_details_filled), + value = "${NumberFormatters.crypto(filled)} / ${NumberFormatters.crypto(requested)} ${transaction.crypto} (${progressPct}%)" + ) + + if (showAvgFill) { + DetailRow( + icon = Icons.AutoMirrored.Filled.TrendingUp, + label = stringResource(R.string.transaction_details_avg_fill_price), + value = "${NumberFormatters.fiat(transaction.price)} ${transaction.fiat}/${transaction.crypto}" + ) + } + } + } +} + @Composable private fun DetailRowWithCopy( icon: ImageVector, diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/history/TransactionDetailsViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/history/TransactionDetailsViewModel.kt index 0ea8161..e60a702 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/history/TransactionDetailsViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/history/TransactionDetailsViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.accbot.dca.data.local.TransactionDao import com.accbot.dca.data.local.toDomain import com.accbot.dca.domain.model.Transaction +import com.accbot.dca.domain.usecase.CancelSellOrderUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -15,17 +16,22 @@ import javax.inject.Inject data class TransactionDetailsUiState( val transaction: Transaction? = null, val isLoading: Boolean = true, - val error: String? = null + val error: String? = null, + val isCancelling: Boolean = false ) @HiltViewModel class TransactionDetailsViewModel @Inject constructor( - private val transactionDao: TransactionDao + private val transactionDao: TransactionDao, + private val cancelSellOrderUseCase: CancelSellOrderUseCase ) : ViewModel() { private val _uiState = MutableStateFlow(TransactionDetailsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val _snackbar = MutableSharedFlow(extraBufferCapacity = 4) + val snackbar: SharedFlow = _snackbar.asSharedFlow() + fun loadTransaction(transactionId: Long) { viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } @@ -53,4 +59,34 @@ class TransactionDetailsViewModel @Inject constructor( } } } + + /** + * Cancel an open limit sell order. On success the underlying Flow (DAO) will + * update status to FAILED/COMPLETED per the use case and the next loadTransaction + * call picks that up. We also re-fetch here to keep the already-open detail + * screen in sync without waiting for a user navigation round trip. + */ + fun cancelOrder(txId: Long) { + if (_uiState.value.isCancelling) return + _uiState.update { it.copy(isCancelling = true) } + viewModelScope.launch { + val result = cancelSellOrderUseCase(txId) + if (result.isFailure) { + _snackbar.emit( + "Zruseni orderu selhalo: ${result.exceptionOrNull()?.message ?: "neznama chyba"}" + ) + } + // Refresh after cancel attempt (success or failure) so displayed status is accurate. + try { + val entity = transactionDao.getTransactionById(txId) + if (entity != null) { + _uiState.update { it.copy(transaction = entity.toDomain(), isCancelling = false) } + } else { + _uiState.update { it.copy(isCancelling = false) } + } + } catch (_: Exception) { + _uiState.update { it.copy(isCancelling = false) } + } + } + } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationRenderer.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationRenderer.kt index f3765f5..3588aaf 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationRenderer.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationRenderer.kt @@ -146,6 +146,19 @@ object NotificationRenderer { ) title to message } + + is NotificationTemplateArgs.SellFilled -> { + val title = context.getString(R.string.notification_sell_filled_title) + val message = context.getString( + R.string.notification_sell_filled_text, + NumberFormatters.crypto(BigDecimal(args.cryptoAmount)), + args.crypto, + NumberFormatters.fiat(BigDecimal(args.fiatAmount)), + args.fiat, + NumberFormatters.fiat(BigDecimal(args.price)) + ) + title to message + } } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationsScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationsScreen.kt index 1621dc6..8a3385d 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationsScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationsScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.CallMade +import androidx.compose.material.icons.automirrored.filled.TrendingUp import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -281,6 +282,7 @@ private fun NotificationTypeIcon(type: NotificationType) { NotificationType.WITHDRAWAL_THRESHOLD -> Icons.AutoMirrored.Filled.CallMade to Warning NotificationType.NETWORK_RETRY -> Icons.Default.WifiOff to Error NotificationType.MISSED_PURCHASES -> Icons.Default.EventBusy to Warning + NotificationType.SELL_FILLED -> Icons.AutoMirrored.Filled.TrendingUp to successCol } Box( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanScreen.kt index f150259..a632995 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanScreen.kt @@ -67,6 +67,28 @@ fun EditPlanScreen( ) } + // Confirmation dialog when disabling sells while open sell orders exist on the exchange. + // See EditPlanViewModel.setAllowSells for the trigger. + uiState.showDisableSellsDialog?.let { openCount -> + AlertDialog( + onDismissRequest = { viewModel.dismissDisableSellsDialog() }, + title = { Text(stringResource(R.string.edit_plan_disable_sells_dialog_title)) }, + text = { + Text(stringResource(R.string.edit_plan_disable_sells_dialog_text, openCount)) + }, + confirmButton = { + TextButton(onClick = { viewModel.confirmDisableSells() }) { + Text(stringResource(R.string.edit_plan_disable_sells_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.dismissDisableSellsDialog() }) { + Text(stringResource(R.string.common_cancel)) + } + } + ) + } + Scaffold( topBar = { AccBotTopAppBar( @@ -165,7 +187,10 @@ fun EditPlanScreen( onWithdrawalAddressChanged = viewModel.planForm::setWithdrawalAddress, onTargetAmountChanged = viewModel.planForm::setTargetAmount, exchange = uiState.exchange, - errorMessage = if (uiState.isSaving) uiState.error else null + errorMessage = if (uiState.isSaving) uiState.error else null, + showSellSection = uiState.tradingEnabled, + onAllowSellsChanged = viewModel::setAllowSells, + onTargetProfitAmountChanged = viewModel.planForm::setTargetProfitAmount ) } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanViewModel.kt index 3091bd4..175ec0e 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.accbot.dca.data.local.DcaPlanDao import com.accbot.dca.data.local.DcaPlanEntity +import com.accbot.dca.data.local.TransactionDao import com.accbot.dca.domain.model.DcaFrequency import com.accbot.dca.domain.model.DcaStrategy import com.accbot.dca.domain.model.Exchange @@ -38,6 +39,14 @@ data class EditPlanUiState( // Change tracking val hasChanges: Boolean = false, + // Global trading master switch (snapshot at VM construction). Gates whether + // the Sells section is visible in the plan form. + val tradingEnabled: Boolean = false, + + // If non-null, the user tried to turn allowSells off but has that many open + // sell orders - UI should show a confirmation dialog. + val showDisableSellsDialog: Int? = null, + // Action state val isLoading: Boolean = true, val isSaving: Boolean = false, @@ -52,6 +61,7 @@ data class EditPlanUiState( class EditPlanViewModel @Inject constructor( private val application: Application, private val dcaPlanDao: DcaPlanDao, + private val transactionDao: TransactionDao, private val userPreferences: UserPreferences, calculateMonthlyCost: CalculateMonthlyCostUseCase, minOrderSizeRepository: MinOrderSizeRepository @@ -59,7 +69,9 @@ class EditPlanViewModel @Inject constructor( val planForm = PlanFormDelegate(calculateMonthlyCost, minOrderSizeRepository, viewModelScope) - private val _localState = MutableStateFlow(EditPlanUiState()) + private val _localState = MutableStateFlow( + EditPlanUiState(tradingEnabled = userPreferences.isTradingEnabled()) + ) private val _originalFormState = MutableStateFlow(null) @@ -76,13 +88,54 @@ class EditPlanViewModel @Inject constructor( || form.withdrawalEnabled != original.withdrawalEnabled || form.withdrawalAddress != original.withdrawalAddress || form.targetAmount != original.targetAmount + || form.allowSells != original.allowSells + || form.targetProfitAmount != original.targetProfitAmount ) local.copy(planForm = form, hasChanges = hasChanges) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), EditPlanUiState()) + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + EditPlanUiState(tradingEnabled = userPreferences.isTradingEnabled()) + ) private var originalPlan: DcaPlanEntity? = null + /** + * Intercept the allow-sells toggle: when the user tries to turn it OFF and there + * are open sell orders for this plan, show a confirmation dialog first. Otherwise + * apply the change immediately via the form delegate. + */ + fun setAllowSells(value: Boolean) { + val currentForm = planForm.state.value + if (!value && currentForm.allowSells) { + val planId = _localState.value.planId + if (planId <= 0L) { + planForm.setAllowSells(false) + return + } + viewModelScope.launch { + val openCount = transactionDao.observeOpenSellsForPlan(planId).first().size + if (openCount > 0) { + _localState.update { it.copy(showDisableSellsDialog = openCount) } + } else { + planForm.setAllowSells(false) + } + } + } else { + planForm.setAllowSells(value) + } + } + + fun confirmDisableSells() { + planForm.setAllowSells(false) + _localState.update { it.copy(showDisableSellsDialog = null) } + } + + fun dismissDisableSellsDialog() { + _localState.update { it.copy(showDisableSellsDialog = null) } + } + fun loadPlan(planId: Long) { viewModelScope.launch { _localState.update { it.copy(isLoading = true, error = null) } @@ -117,7 +170,9 @@ class EditPlanViewModel @Inject constructor( strategy = plan.strategy, withdrawalEnabled = plan.withdrawalEnabled, withdrawalAddress = plan.withdrawalAddress ?: "", - targetAmount = plan.targetAmount?.toPlainString() ?: "" + targetAmount = plan.targetAmount?.toPlainString() ?: "", + allowSells = plan.allowSells, + targetProfitAmount = plan.targetProfitAmount?.toPlainString() ?: "" ) // Snapshot the original form state for change tracking @@ -179,6 +234,12 @@ class EditPlanViewModel @Inject constructor( plan.nextExecutionAt } + val tradingGloballyEnabled = userPreferences.isTradingEnabled() + val allowSells = tradingGloballyEnabled && form.allowSells + val targetProfit = if (allowSells) { + form.targetProfitAmount.trim().takeIf { it.isNotEmpty() }?.toBigDecimalOrNull() + } else null + val updatedPlan = plan.copy( amount = amount, frequency = form.selectedFrequency, @@ -187,7 +248,9 @@ class EditPlanViewModel @Inject constructor( withdrawalEnabled = form.withdrawalEnabled, withdrawalAddress = if (form.withdrawalEnabled) form.withdrawalAddress.trim() else null, nextExecutionAt = nextExecution, - targetAmount = form.targetAmount.toBigDecimalOrNull() + targetAmount = form.targetAmount.toBigDecimalOrNull(), + allowSells = allowSells, + targetProfitAmount = targetProfit ) dcaPlanDao.updatePlan(updatedPlan) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt index 1841d36..aa825f5 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.automirrored.filled.TrendingDown import androidx.compose.material.icons.automirrored.filled.TrendingUp import androidx.compose.material3.* +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -30,6 +31,9 @@ import com.accbot.dca.domain.model.DcaStrategy import com.accbot.dca.domain.model.Exchange import com.accbot.dca.domain.model.supportsApiImport import com.accbot.dca.presentation.components.* +import com.accbot.dca.presentation.screens.plans.components.OpenSellsList +import com.accbot.dca.presentation.screens.plans.components.PnLCard +import com.accbot.dca.presentation.screens.plans.sell.SellWizardBottomSheet import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardType @@ -50,6 +54,10 @@ fun PlanDetailsScreen( viewModel: PlanDetailsViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val sellUiVisible by viewModel.sellUiVisible.collectAsStateWithLifecycle() + val planPnL by viewModel.planPnL.collectAsStateWithLifecycle() + val openSells by viewModel.openSells.collectAsStateWithLifecycle() + val refreshing by viewModel.refreshing.collectAsStateWithLifecycle() val context = LocalContext.current val coroutineScope = rememberCoroutineScope() var showDeleteDialog by rememberSaveable { mutableStateOf(false) } @@ -59,12 +67,24 @@ fun PlanDetailsScreen( var showDeleteTransactionsDialog by rememberSaveable { mutableStateOf(false) } var deleteTransactionsConfirmText by rememberSaveable { mutableStateOf("") } var dangerZoneExpanded by rememberSaveable { mutableStateOf(false) } + var sellWizardOpen by rememberSaveable { mutableStateOf(false) } val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(planId) { viewModel.loadPlan(planId) } + LaunchedEffect(Unit) { + viewModel.snackbar.collect { msg -> snackbarHostState.showSnackbar(msg) } + } + + if (sellWizardOpen) { + SellWizardBottomSheet( + planId = planId, + onDismiss = { sellWizardOpen = false } + ) + } + // Delete confirmation dialog if (showDeleteDialog) { val plan = uiState.plan @@ -187,6 +207,20 @@ fun PlanDetailsScreen( ApiImportResultDialog(result = result, onDismiss = { viewModel.dismissImportResult() }) } + // Delete blocked: plan still has open sell orders. User must cancel them first. + uiState.deleteBlockedOpenSells?.let { count -> + AlertDialog( + onDismissRequest = { viewModel.dismissDeleteBlockedDialog() }, + title = { Text(stringResource(R.string.plan_details_delete_blocked_title)) }, + text = { Text(stringResource(R.string.plan_details_delete_blocked_text, count)) }, + confirmButton = { + TextButton(onClick = { viewModel.dismissDeleteBlockedDialog() }) { + Text(stringResource(R.string.common_done)) + } + } + ) + } + Scaffold( snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, topBar = { @@ -228,10 +262,16 @@ fun PlanDetailsScreen( uiState.plan != null -> { val plan = uiState.plan!! - LazyColumn( + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = { viewModel.refresh() }, modifier = Modifier .fillMaxSize() .padding(paddingValues) + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { @@ -713,6 +753,48 @@ fun PlanDetailsScreen( } } + // 4.5 Sell section (P&L card, open orders, create-sell button). + // Shown only when plan opted in + global trading enabled + exchange supports it. + if (sellUiVisible) { + planPnL?.let { pnl -> + item { + PnLCard( + pnl = pnl, + fiat = plan.fiat, + crypto = plan.crypto, + targetAmount = plan.targetProfitAmount + ) + } + } + + if (openSells.isNotEmpty()) { + item { + OpenSellsList( + openSells = openSells, + onCancelClick = viewModel::cancelSell, + onCancelAllClick = viewModel::cancelAllOpenSells + ) + } + } + + item { + val heldCrypto = planPnL?.currentCryptoHeld ?: BigDecimal.ZERO + Button( + onClick = { sellWizardOpen = true }, + enabled = heldCrypto > BigDecimal.ZERO, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.Sell, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.plan_details_create_sell_order)) + } + } + } + // 5. Transactions section (with Import API in header) item { Row( @@ -888,6 +970,7 @@ fun PlanDetailsScreen( item { Spacer(modifier = Modifier.height(32.dp)) } } + } // PullToRefreshBox } } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt index 0f252c4..42d8e0c 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt @@ -8,12 +8,16 @@ import androidx.room.withTransaction import com.accbot.dca.data.local.* import com.accbot.dca.data.remote.MarketDataService import com.accbot.dca.domain.model.DcaPlan +import com.accbot.dca.domain.model.PlanPnL import com.accbot.dca.domain.model.Transaction import com.accbot.dca.scheduler.DcaAlarmScheduler import com.accbot.dca.domain.model.TransactionStatus import com.accbot.dca.domain.usecase.ApiImportProgress import com.accbot.dca.domain.usecase.ApiImportResultState +import com.accbot.dca.domain.usecase.CalculatePlanPnLUseCase +import com.accbot.dca.domain.usecase.CancelSellOrderUseCase import com.accbot.dca.domain.usecase.ImportTradeHistoryUseCase +import com.accbot.dca.domain.usecase.ResolvePendingTransactionsUseCase import com.accbot.dca.exchange.ExchangeApiFactory import com.accbot.dca.presentation.utils.NumberFormatters import com.accbot.dca.R @@ -57,7 +61,13 @@ data class PlanDetailsUiState( val showImportDialog: Boolean = false, val importSinceMillis: Long? = null, /** Number of OTHER plans on the same connection. When > 0, import dialog shows a warning. */ - val otherPlansOnSameConnection: Int = 0 + val otherPlansOnSameConnection: Int = 0, + /** + * When non-null, indicates a plan-delete attempt was blocked because the plan still has + * the given number of open sell orders. UI should show a blocking dialog explaining the + * user must cancel them first. + */ + val deleteBlockedOpenSells: Int? = null ) @HiltViewModel @@ -70,20 +80,47 @@ class PlanDetailsViewModel @Inject constructor( private val exchangeApiFactory: ExchangeApiFactory, private val credentialsStore: CredentialsStore, private val userPreferences: UserPreferences, - private val importTradeHistoryUseCase: ImportTradeHistoryUseCase + private val importTradeHistoryUseCase: ImportTradeHistoryUseCase, + private val calculatePlanPnLUseCase: CalculatePlanPnLUseCase, + private val cancelSellOrderUseCase: CancelSellOrderUseCase, + private val resolvePendingTransactionsUseCase: ResolvePendingTransactionsUseCase ) : ViewModel() { private val _uiState = MutableStateFlow(PlanDetailsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val _planPnL = MutableStateFlow(null) + val planPnL: StateFlow = _planPnL.asStateFlow() + + private val _openSells = MutableStateFlow>(emptyList()) + val openSells: StateFlow> = _openSells.asStateFlow() + + private val _sellUiVisible = MutableStateFlow(false) + val sellUiVisible: StateFlow = _sellUiVisible.asStateFlow() + + private val _snackbar = MutableSharedFlow(extraBufferCapacity = 4) + val snackbar: SharedFlow = _snackbar.asSharedFlow() + + private val _refreshing = MutableStateFlow(false) + val refreshing: StateFlow = _refreshing.asStateFlow() + private var planId: Long = 0 private var transactionCollectionJob: Job? = null + private var openSellsJob: Job? = null private var priceJob: Job? = null private var balanceJob: Job? = null fun loadPlan(planId: Long) { this.planId = planId transactionCollectionJob?.cancel() + openSellsJob?.cancel() + + // Observe open sells for the plan independently of the main transactions flow. + openSellsJob = viewModelScope.launch { + transactionDao.observeOpenSellsForPlan(planId).collect { entities -> + _openSells.value = entities.map { it.toDomain() } + } + } transactionCollectionJob = viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } @@ -98,6 +135,9 @@ class PlanDetailsViewModel @Inject constructor( val plan = planEntity.toDomain() + // Compute sell UI visibility: plan opt-in + master switch + exchange capability. + _sellUiVisible.value = computeSellUiVisible(plan) + // Check how many OTHER plans share the same connection (for import warning) val totalPlansOnConnection = dcaPlanDao.countPlansByConnection(planEntity.connectionId) val otherPlans = (totalPlansOnConnection - 1).coerceAtLeast(0) @@ -142,6 +182,9 @@ class PlanDetailsViewModel @Inject constructor( priceJob = fetchCurrentPrice(plan, totalCrypto, totalInvested) balanceJob?.cancel() balanceJob = fetchFiatBalance(plan) + + // Recompute PnL whenever transactions change (uses last known spot price). + recomputePnL(planId, _uiState.value.currentPrice) } } catch (e: Exception) { _uiState.update { @@ -176,6 +219,8 @@ class PlanDetailsViewModel @Inject constructor( isPriceLoading = false ) } } + // Refresh PnL with the (possibly new) spot price. + recomputePnL(plan.id, price) } catch (e: Exception) { Log.w(TAG, "Failed to fetch price: ${e.message}") _uiState.update { it.copy(isPriceLoading = false) } @@ -183,6 +228,86 @@ class PlanDetailsViewModel @Inject constructor( } } + private suspend fun recomputePnL(planId: Long, spot: BigDecimal?) { + try { + _planPnL.value = calculatePlanPnLUseCase(planId, spot) + } catch (e: Exception) { + Log.w(TAG, "Failed to compute PnL: ${e.message}") + } + } + + /** + * Sell UI is shown only when plan opted in, global trading is enabled, and the + * exchange implementation actually supports limit sells. + */ + private suspend fun computeSellUiVisible(plan: DcaPlan): Boolean { + if (!plan.allowSells) return false + if (!userPreferences.isTradingEnabled()) return false + return try { + val credentials = credentialsStore.getCredentials( + plan.connectionId, + userPreferences.isSandboxMode() + ) ?: return false + exchangeApiFactory.create(credentials).supportsLimitSell + } catch (e: Exception) { + Log.w(TAG, "Failed to check limit-sell support: ${e.message}") + false + } + } + + /** + * Pull-to-refresh on plan-detail. Polls the exchange for fill status of any + * pending sell (or pending buy) orders for this plan; the underlying Flow + * collectors then push the updated rows back to the UI automatically. + */ + fun refresh() { + viewModelScope.launch { + _refreshing.value = true + try { + resolvePendingTransactionsUseCase() + } catch (e: Exception) { + Log.w(TAG, "Pull-to-refresh failed", e) + } finally { + _refreshing.value = false + } + } + } + + fun cancelSell(txId: Long) { + viewModelScope.launch { + val result = cancelSellOrderUseCase(txId) + if (result.isFailure) { + _snackbar.emit( + "Zrušení příkazu selhalo: ${result.exceptionOrNull()?.message ?: "neznámá chyba"}" + ) + } + } + } + + /** + * Cancel every open (PENDING/PARTIAL) sell order on the current plan. Iterates + * sequentially to avoid hammering the exchange. Reports a single aggregate snackbar + * (success count / failure count) once done. + */ + fun cancelAllOpenSells() { + viewModelScope.launch { + val open = _openSells.value + if (open.isEmpty()) return@launch + var ok = 0 + var failed = 0 + for (tx in open) { + val r = cancelSellOrderUseCase(tx.id) + if (r.isSuccess) ok++ else failed++ + } + val msg = when { + failed == 0 -> "Zrušeno $ok příkazů" + ok == 0 -> "Zrušení selhalo u všech ${open.size} příkazů" + else -> "Zrušeno $ok z ${open.size} příkazů ($failed selhalo)" + } + _snackbar.emit(msg) + } + } + private fun fetchFiatBalance(plan: DcaPlan): Job { return viewModelScope.launch { _uiState.update { it.copy(isBalanceLoading = true) } @@ -254,6 +379,14 @@ class PlanDetailsViewModel @Inject constructor( fun deletePlan(onDeleted: () -> Unit) { viewModelScope.launch { try { + // Block delete when the plan still has open sell orders on the exchange. + // Without this guard, the user would lose the FK link to the order rows and + // any subsequent fill polling would silently fail. + val openSellsCount = transactionDao.observeOpenSellsForPlan(planId).first().size + if (openSellsCount > 0) { + _uiState.update { it.copy(deleteBlockedOpenSells = openSellsCount) } + return@launch + } database.withTransaction { transactionDao.deleteTransactionsByPlanId(planId) dcaPlanDao.deletePlanById(planId) @@ -266,6 +399,10 @@ class PlanDetailsViewModel @Inject constructor( } } + fun dismissDeleteBlockedDialog() { + _uiState.update { it.copy(deleteBlockedOpenSells = null) } + } + fun showImportDialog() { _uiState.update { it.copy(showImportDialog = true, importSinceMillis = null) } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/OpenSellsList.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/OpenSellsList.kt new file mode 100644 index 0000000..061c351 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/OpenSellsList.kt @@ -0,0 +1,210 @@ +package com.accbot.dca.presentation.screens.plans.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.accbot.dca.R +import com.accbot.dca.domain.model.Transaction +import com.accbot.dca.domain.model.TransactionStatus +import com.accbot.dca.presentation.ui.theme.Error +import com.accbot.dca.presentation.utils.NumberFormatters +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * Card listing all open (PENDING / PARTIAL) sell orders for a plan with per-row + * cancel action and a "Cancel all" header button. Hidden entirely when there are no + * open sells. + */ +@Composable +fun OpenSellsList( + openSells: List, + onCancelClick: (Long) -> Unit, + onCancelAllClick: (() -> Unit)? = null, + modifier: Modifier = Modifier +) { + if (openSells.isEmpty()) return + + var showCancelAllConfirm by remember { mutableStateOf(false) } + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.open_sells_title, openSells.size), + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.semantics { heading() } + ) + if (onCancelAllClick != null && openSells.size > 1) { + TextButton(onClick = { showCancelAllConfirm = true }) { + Text( + stringResource(R.string.open_sells_cancel_all), + color = Error + ) + } + } + } + Spacer(Modifier.height(8.dp)) + openSells.forEach { tx -> + OpenSellRow(tx = tx, onCancelClick = onCancelClick) + } + } + } + + if (showCancelAllConfirm && onCancelAllClick != null) { + AlertDialog( + onDismissRequest = { showCancelAllConfirm = false }, + title = { Text(stringResource(R.string.open_sells_cancel_all_confirm_title)) }, + text = { + Text(stringResource(R.string.open_sells_cancel_all_confirm_text, openSells.size)) + }, + confirmButton = { + TextButton(onClick = { + showCancelAllConfirm = false + onCancelAllClick() + }) { + Text(stringResource(R.string.open_sells_cancel_all), color = Error) + } + }, + dismissButton = { + TextButton(onClick = { showCancelAllConfirm = false }) { + Text(stringResource(R.string.open_sells_cancel_back)) + } + } + ) + } +} + +/** + * Single row used by [OpenSellsList]. Exposed as `internal` so other screens + * (e.g. the Pozice tab's collapsible section) can render the same row layout + * with its built-in cancel-confirmation dialog without re-implementing it. + */ +@Composable +internal fun OpenSellRow( + tx: Transaction, + onCancelClick: (Long) -> Unit +) { + val requested = tx.requestedCryptoAmount ?: BigDecimal.ZERO + val filled = tx.cryptoAmount + val progressPct = if (requested > BigDecimal.ZERO) { + filled.divide(requested, 4, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)) + .toInt() + } else 0 + + var showConfirm by remember(tx.id) { mutableStateOf(false) } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + val priceText = tx.limitPrice?.let { NumberFormatters.fiat(it) } ?: "-" + Text( + text = "${NumberFormatters.crypto(requested)} ${tx.crypto} @ $priceText ${tx.fiat}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + if (tx.status == TransactionStatus.PARTIAL) { + Text( + text = stringResource( + R.string.open_sells_status_partial, + progressPct.toString(), + NumberFormatters.crypto(filled), + NumberFormatters.crypto(requested) + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary + ) + } else { + Text( + text = stringResource(R.string.open_sells_status_pending), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + IconButton(onClick = { showConfirm = true }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.open_sells_cancel_action), + tint = Error + ) + } + } + + if (showConfirm) { + val priceText = tx.limitPrice?.let { NumberFormatters.fiat(it) } ?: "-" + AlertDialog( + onDismissRequest = { showConfirm = false }, + title = { Text(stringResource(R.string.open_sells_cancel_confirm_title)) }, + text = { + Text( + stringResource( + R.string.open_sells_cancel_confirm_text, + NumberFormatters.crypto(requested), + tx.crypto, + priceText, + tx.fiat + ) + ) + }, + confirmButton = { + TextButton(onClick = { + showConfirm = false + onCancelClick(tx.id) + }) { + Text(stringResource(R.string.open_sells_cancel_action), color = Error) + } + }, + dismissButton = { + TextButton(onClick = { showConfirm = false }) { + Text(stringResource(R.string.open_sells_cancel_back)) + } + } + ) + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/PnLCard.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/PnLCard.kt new file mode 100644 index 0000000..1ab24b1 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/PnLCard.kt @@ -0,0 +1,147 @@ +package com.accbot.dca.presentation.screens.plans.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.accbot.dca.domain.model.PlanPnL +import com.accbot.dca.presentation.ui.theme.Error +import com.accbot.dca.presentation.ui.theme.successColor +import com.accbot.dca.presentation.utils.NumberFormatters +import java.math.BigDecimal + +/** + * Plan-level profit & loss card with realized / unrealized / net breakdown and + * optional target-progress bar when the plan has `targetProfitAmount` configured. + * + * Null-valued PnL fields render as "-" (no spot price available / no buys yet). + */ +@Composable +fun PnLCard( + pnl: PlanPnL, + fiat: String, + crypto: String, + targetAmount: BigDecimal?, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "P&L", + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.semantics { heading() } + ) + Spacer(Modifier.height(12.dp)) + + val heldValue = if (pnl.currentValueFiat != null) { + "${NumberFormatters.crypto(pnl.currentCryptoHeld)} $crypto (${NumberFormatters.fiat(pnl.currentValueFiat)} $fiat)" + } else { + "${NumberFormatters.crypto(pnl.currentCryptoHeld)} $crypto" + } + PnLRow(label = "Drzeno:", value = heldValue) + + PnLRow( + label = "Prum. nakup:", + value = pnl.avgBuyPrice?.let { "${NumberFormatters.fiat(it)} $fiat" } ?: "-" + ) + + PnLRow( + label = "Realizovany:", + value = formatOptionalPnL(pnl.realizedPnL, fiat), + color = colorForPnL(pnl.realizedPnL) + ) + PnLRow( + label = "Nerealizovany:", + value = formatOptionalPnL(pnl.unrealizedPnL, fiat), + color = colorForPnL(pnl.unrealizedPnL) + ) + PnLRow( + label = "Net:", + value = formatOptionalPnL(pnl.netPnL, fiat), + color = colorForPnL(pnl.netPnL), + bold = true + ) + + if (targetAmount != null && pnl.targetProgressPct != null) { + Spacer(Modifier.height(12.dp)) + LinearProgressIndicator( + progress = { pnl.targetProgressPct.toFloat().coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(4.dp)) + Text( + text = "Cil: ${NumberFormatters.fiat(targetAmount)} $fiat (${(pnl.targetProgressPct * 100).toInt()}%)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun PnLRow( + label: String, + value: String, + color: Color? = null, + bold: Boolean = false +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 3.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = color ?: LocalContentColor.current, + fontWeight = if (bold) FontWeight.Bold else FontWeight.Medium + ) + } +} + +@Composable +private fun colorForPnL(value: BigDecimal?): Color? = when { + value == null -> null + value > BigDecimal.ZERO -> successColor() + value < BigDecimal.ZERO -> Error + else -> null +} + +private fun formatOptionalPnL(value: BigDecimal?, fiat: String): String { + if (value == null) return "-" + val prefix = if (value >= BigDecimal.ZERO) "+" else "" + return "$prefix${NumberFormatters.fiat(value)} $fiat" +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/LadderGenerator.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/LadderGenerator.kt new file mode 100644 index 0000000..1460035 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/LadderGenerator.kt @@ -0,0 +1,82 @@ +package com.accbot.dca.presentation.screens.plans.sell + +import com.accbot.dca.domain.usecase.LadderOrder +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * Generates N limit-sell orders linearly distributed across a price range. + * + * Two amount distribution modes: + * - [AmountMode.EQUAL_CRYPTO]: each order sells `total / N` BTC. Simplest and most + * predictable for min-order-size validation. + * - [AmountMode.EQUAL_FIAT]: each order generates the same gross fiat. Cheaper orders + * sell larger crypto amounts. After per-order rounding the sum is rescaled to match + * `total` so the user actually sells the requested total. + */ +object LadderGenerator { + + enum class AmountMode { EQUAL_CRYPTO, EQUAL_FIAT } + + /** `pct = (price - avg) / avg * 100`. Returns null when avg is missing or zero. */ + fun priceToProfitPct(price: BigDecimal, avg: BigDecimal?): BigDecimal? { + if (avg == null || avg <= BigDecimal.ZERO) return null + return (price - avg).divide(avg, 6, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)) + .setScale(2, RoundingMode.HALF_UP) + } + + /** `price = avg * (1 + pct/100)`. Returns null when avg is missing or zero. */ + fun profitPctToPrice(pct: BigDecimal, avg: BigDecimal?): BigDecimal? { + if (avg == null || avg <= BigDecimal.ZERO) return null + return (avg * (BigDecimal.ONE + pct.divide(BigDecimal(100), 8, RoundingMode.HALF_UP))) + .setScale(2, RoundingMode.HALF_UP) + } + + fun generate( + totalAmount: BigDecimal, + from: BigDecimal, + to: BigDecimal, + count: Int, + mode: AmountMode + ): List { + require(count >= 2) { "count >= 2" } + require(totalAmount > BigDecimal.ZERO) { "totalAmount > 0" } + require(from > BigDecimal.ZERO && to > BigDecimal.ZERO) { "prices > 0" } + + val n = BigDecimal(count) + val prices = (0 until count).map { i -> + val step = (to - from) * BigDecimal(i) / BigDecimal(count - 1) + (from + step).setScale(2, RoundingMode.HALF_UP) + } + + return when (mode) { + AmountMode.EQUAL_CRYPTO -> { + val per = totalAmount.divide(n, 8, RoundingMode.DOWN) + val remainder = totalAmount - per * n + prices.mapIndexed { i, p -> + val a = if (i == count - 1) per + remainder else per + LadderOrder(a, p) + } + } + AmountMode.EQUAL_FIAT -> { + // Target gross per order = totalAmount * avgPrice / N. Use the simple + // arithmetic mean of prices as the fair "expected price" so the resulting + // crypto amounts sum close to total before rescaling. + val avgPrice = prices.fold(BigDecimal.ZERO) { acc, x -> acc + x } + .divide(n, 8, RoundingMode.HALF_UP) + val perOrderGross = (totalAmount * avgPrice).divide(n, 8, RoundingMode.HALF_UP) + val rawAmounts = prices.map { p -> + perOrderGross.divide(p, 8, RoundingMode.DOWN) + } + val sumRaw = rawAmounts.fold(BigDecimal.ZERO) { acc, x -> acc + x } + val scale = if (sumRaw > BigDecimal.ZERO) + totalAmount.divide(sumRaw, 12, RoundingMode.HALF_UP) + else BigDecimal.ONE + rawAmounts.mapIndexed { i, a -> + LadderOrder((a * scale).setScale(8, RoundingMode.DOWN), prices[i]) + } + } + } + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellCalculatorMath.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellCalculatorMath.kt new file mode 100644 index 0000000..21b0132 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellCalculatorMath.kt @@ -0,0 +1,60 @@ +package com.accbot.dca.presentation.screens.plans.sell + +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * Pure logic for the three-field sell calculator (Amount / Price / Net). + * + * Relationship: `N = A * P * (1 - feeRate)` + * + * The wizard ViewModel records the two most-recently edited fields. When the user types in + * the third field it becomes the "newest"; the field that drops out of the recent-edits pair + * is the one we recompute. [recompute] does this in one shot. + */ +object SellCalculatorMath { + + enum class Field { AMOUNT, PRICE, NET } + + /** + * @param a current amount value (parsed from input, null when blank) + * @param p current price value + * @param n current net value + * @param feeRate exchange fee, e.g. 0.0035 for Coinmate taker + * @param lastTwoEdited fields most recently edited in newest-first order + * @return updated triple with the third field recomputed when possible + */ + fun recompute( + a: BigDecimal?, + p: BigDecimal?, + n: BigDecimal?, + feeRate: BigDecimal, + lastTwoEdited: List + ): Triple { + if (lastTwoEdited.size < 2) return Triple(a, p, n) + val factor = BigDecimal.ONE - feeRate + val toCompute = Field.values().firstOrNull { it !in lastTwoEdited } + ?: return Triple(a, p, n) + + return when (toCompute) { + Field.NET -> { + val newN = if (a != null && p != null && a > BigDecimal.ZERO && p > BigDecimal.ZERO) + (a * p * factor).setScale(2, RoundingMode.HALF_UP) + else null + Triple(a, p, newN) + } + Field.PRICE -> { + val newP = if (a != null && n != null && a > BigDecimal.ZERO && factor > BigDecimal.ZERO) + n.divide(a * factor, 2, RoundingMode.HALF_UP) + else null + Triple(a, newP, n) + } + Field.AMOUNT -> { + val newA = if (p != null && n != null && p > BigDecimal.ZERO && factor > BigDecimal.ZERO) + n.divide(p * factor, 8, RoundingMode.HALF_UP) + else null + Triple(newA, p, n) + } + } + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt new file mode 100644 index 0000000..d0ff91f --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt @@ -0,0 +1,977 @@ +package com.accbot.dca.presentation.screens.plans.sell + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.accbot.dca.R +import com.accbot.dca.domain.usecase.SellValidation +import com.accbot.dca.presentation.ui.theme.Error +import com.accbot.dca.presentation.ui.theme.successColor +import com.accbot.dca.presentation.utils.NumberFormatters +import java.math.BigDecimal +import java.math.RoundingMode + +@Composable +private fun SellValidation.HardError.localizedText(): String = when (this) { + SellValidation.HardError.AmountMustBePositive -> + stringResource(R.string.sell_validation_amount_must_be_positive) + SellValidation.HardError.PriceMustBePositive -> + stringResource(R.string.sell_validation_price_must_be_positive) + is SellValidation.HardError.MinOrderTooLow -> + stringResource(R.string.sell_validation_min_order_too_low, NumberFormatters.fiat(minOrderFiat)) + is SellValidation.HardError.InsufficientInventory -> + stringResource(R.string.sell_validation_insufficient_inventory, NumberFormatters.crypto(available)) +} + +@Composable +private fun LadderError.localizedText(): String = when (this) { + LadderError.AvgRequired -> + stringResource(R.string.sell_wizard_ladder_error_avg_required) + LadderError.AmountMustBePositive -> + stringResource(R.string.sell_wizard_ladder_error_amount_positive) + LadderError.CountMin2 -> + stringResource(R.string.sell_wizard_ladder_error_count_min2) + LadderError.ToMustExceedFrom -> + stringResource(R.string.sell_wizard_ladder_error_to_must_exceed_from) + LadderError.Insufficient -> + stringResource(R.string.sell_wizard_ladder_error_insufficient) + is LadderError.BelowMin -> stringResource( + R.string.sell_wizard_ladder_error_below_min, + NumberFormatters.fiat(smallest), fiat, NumberFormatters.fiat(min) + ) +} + +/** + * Two-step bottom sheet for placing a limit sell order: + * 1. INPUT - amount + price with quick-set chips, live validations and summary + * 2. CONFIRM - read-only summary + warning + submit + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SellWizardBottomSheet( + planId: Long, + onDismiss: () -> Unit, + viewModel: SellWizardViewModel = hiltViewModel() +) { + LaunchedEffect(planId) { viewModel.init(planId) } + val state by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(state.dismissRequested) { + if (state.dismissRequested) { + viewModel.consumeDismiss() + onDismiss() + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true, + dismissOnClickOutside = false + ) + ) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Box(modifier = Modifier.statusBarsPadding()) { + when (state.step) { + SellWizardViewModel.Step.INPUT -> SellInputStep(state, viewModel, onDismiss) + SellWizardViewModel.Step.CONFIRM -> SellConfirmStep(state, viewModel) + } + } + } + } + + state.ladderOutcome?.let { outcome -> + AlertDialog( + onDismissRequest = viewModel::consumeLadderOutcome, + title = { Text(stringResource(R.string.sell_wizard_ladder_dialog_title)) }, + text = { + Text( + if (outcome.reason == null) { + stringResource(R.string.sell_wizard_ladder_outcome_all, outcome.placed) + } else { + stringResource( + R.string.sell_wizard_ladder_outcome_partial, + outcome.placed, outcome.total, outcome.reason + ) + } + ) + }, + confirmButton = { + Button(onClick = viewModel::consumeLadderOutcome) { Text("OK") } + } + ) + } +} + +@Composable +private fun SellInputStep( + state: SellWizardViewModel.UiState, + vm: SellWizardViewModel, + onDismiss: () -> Unit +) { + val amountError = state.validations.filterIsInstance() + .firstOrNull { it.field == SellValidation.Field.AMOUNT } + val priceError = state.validations.filterIsInstance() + .firstOrNull { it.field == SellValidation.Field.PRICE } + val netError = state.validations.filterIsInstance() + .firstOrNull { it.field == SellValidation.Field.NET } + + Column( + modifier = Modifier + .fillMaxWidth() + .imePadding() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) + ) { + SellWizardHeader(state, onDismiss) + Spacer(Modifier.height(12.dp)) + SellInfoBlock(state) + Spacer(Modifier.height(12.dp)) + AvgBuyField(state, vm) + Spacer(Modifier.height(16.dp)) + AmountField(state, vm, amountError) + Spacer(Modifier.height(12.dp)) + LadderModeRow(state, vm) + if (state.ladderEnabled) { + LadderControls(state = state, vm = vm) + } else { + Spacer(Modifier.height(16.dp)) + PriceField(state, vm, priceError) + } + Spacer(Modifier.height(16.dp)) + NetField(state, vm, netError) + if (!state.ladderEnabled) { + OrderSummary(state) + LossBannerSection(state) + } + Spacer(Modifier.height(8.dp)) + ValidationsList(state) + Spacer(Modifier.height(16.dp)) + Button( + onClick = vm::proceedToConfirm, + enabled = state.canProceed, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.sell_wizard_proceed)) + } + Spacer(Modifier.height(32.dp)) + } +} + +@Composable +private fun SellWizardHeader(state: SellWizardViewModel.UiState, onDismiss: () -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = null) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.sell_wizard_title, state.crypto, state.fiat), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = state.exchangeName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun SellInfoBlock(state: SellWizardViewModel.UiState) { + InfoRow( + stringResource(R.string.sell_wizard_spot_price), + state.spotPrice?.let { "${NumberFormatters.fiat(it)} ${state.fiat}" } ?: "-" + ) + InfoRow( + stringResource(R.string.sell_wizard_available), + "${NumberFormatters.crypto(state.availableToSell)} ${state.crypto}" + ) + if (state.inventoryDeficit > BigDecimal.ZERO) { + Spacer(Modifier.height(4.dp)) + WarningBanner( + stringResource( + R.string.sell_wizard_inventory_deficit, + "${NumberFormatters.crypto(state.inventoryDeficit)} ${state.crypto}" + ) + ) + } +} + +@Composable +private fun AvgBuyField(state: SellWizardViewModel.UiState, vm: SellWizardViewModel) { + Text( + stringResource(R.string.sell_wizard_avg_buy), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Spacer(Modifier.height(4.dp)) + OutlinedTextField( + value = state.avgBuyPriceInput, + onValueChange = vm::setAvgBuyPrice, + visualTransformation = ThousandSeparator, + trailingIcon = { + Row(verticalAlignment = Alignment.CenterVertically) { + if (state.avgBuyPriceManual && state.avgBuyPriceAuto != null) { + AssistChip( + onClick = vm::resetAvgBuyPrice, + label = { Text(stringResource(R.string.sell_wizard_avg_buy_reset)) }, + modifier = Modifier.padding(end = 8.dp) + ) + } + Text( + text = state.fiat, + modifier = Modifier.padding(horizontal = 12.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + supportingText = { + Text( + stringResource( + when { + state.avgBuyPriceAuto == null && state.avgBuyPriceInput.isBlank() -> + R.string.sell_wizard_avg_buy_helper_required + state.avgBuyPriceManual -> + R.string.sell_wizard_avg_buy_helper_manual + else -> R.string.sell_wizard_avg_buy_helper_auto + } + ) + ) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) +} + +@Composable +private fun AmountField( + state: SellWizardViewModel.UiState, + vm: SellWizardViewModel, + amountError: SellValidation.HardError? +) { + Text( + stringResource(R.string.sell_wizard_amount), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Spacer(Modifier.height(4.dp)) + OutlinedTextField( + value = state.amountInput, + onValueChange = vm::setAmount, + isError = amountError != null, + supportingText = { + val pctText = state.summary?.amountPct + when { + amountError != null -> Text(amountError.localizedText(), color = MaterialTheme.colorScheme.error) + pctText != null -> Text(stringResource(R.string.sell_wizard_amount_pct_hint, pctText.toPlainString())) + } + }, + trailingIcon = { + Text( + text = state.crypto, + modifier = Modifier.padding(horizontal = 12.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Row( + modifier = Modifier.padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val allLabel = stringResource(R.string.sell_wizard_amount_all) + listOf(25 to "25 %", 50 to "50 %", 75 to "75 %", 100 to allLabel).forEach { (pct, label) -> + AssistChip( + onClick = { vm.setAmountPct(pct) }, + label = { Text(label, maxLines = 1, softWrap = false) } + ) + } + } +} + +@Composable +private fun LadderModeRow(state: SellWizardViewModel.UiState, vm: SellWizardViewModel) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { vm.setLadderEnabled(!state.ladderEnabled) } + ) { + Checkbox( + checked = state.ladderEnabled, + onCheckedChange = vm::setLadderEnabled + ) + Text(stringResource(R.string.sell_wizard_ladder_enable)) + } +} + +@Composable +private fun PriceField( + state: SellWizardViewModel.UiState, + vm: SellWizardViewModel, + priceError: SellValidation.HardError? +) { + Text( + stringResource(R.string.sell_wizard_limit_price), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(4.dp)) + OutlinedTextField( + value = state.priceInput, + onValueChange = vm::setPrice, + visualTransformation = ThousandSeparator, + isError = priceError != null, + supportingText = priceError?.let { err -> + { Text(err.localizedText(), color = MaterialTheme.colorScheme.error) } + }, + trailingIcon = { + Text( + text = state.fiat, + modifier = Modifier.padding(horizontal = 12.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Row( + modifier = Modifier.padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + AssistChip( + onClick = vm::setPriceSpot, + label = { Text(stringResource(R.string.sell_wizard_chip_spot), maxLines = 1, softWrap = false) }, + enabled = state.spotPrice != null + ) + listOf(10, 25, 50).forEach { pct -> + AssistChip( + onClick = { vm.setPriceAvgPlus(pct) }, + label = { Text("+$pct %", maxLines = 1, softWrap = false) }, + enabled = state.avgBuyPrice != null + ) + } + } +} + +@Composable +private fun NetField( + state: SellWizardViewModel.UiState, + vm: SellWizardViewModel, + netError: SellValidation.HardError? +) { + Text( + stringResource(R.string.sell_wizard_net_fiat), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Spacer(Modifier.height(4.dp)) + OutlinedTextField( + value = state.netInput, + onValueChange = if (state.ladderEnabled) vm::setLadderNetTarget else vm::setNetFiat, + visualTransformation = ThousandSeparator, + isError = !state.ladderEnabled && netError != null, + supportingText = netError?.takeIf { !state.ladderEnabled }?.let { err -> + { Text(err.localizedText(), color = MaterialTheme.colorScheme.error) } + }, + trailingIcon = { + Text( + text = state.fiat, + modifier = Modifier.padding(horizontal = 12.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + if (!state.ladderEnabled) { + Text( + stringResource(R.string.sell_wizard_net_preset_label), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) + ) + Row( + modifier = Modifier.padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val presetEnabled = state.avgBuyPrice != null && state.amountInput.toBigDecimalOrNull() != null + listOf(0.10 to "+10 %", 0.20 to "+20 %", 0.50 to "+50 %", 1.00 to "+100 %").forEach { (factor, label) -> + AssistChip( + onClick = { vm.applyNetProfitPreset(factor) }, + label = { Text(label, maxLines = 1, softWrap = false) }, + enabled = presetEnabled + ) + } + } + } +} + +@Composable +private fun OrderSummary(state: SellWizardViewModel.UiState) { + val s = state.summary ?: return + Spacer(Modifier.height(16.dp)) + Text( + stringResource(R.string.sell_wizard_summary), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Spacer(Modifier.height(4.dp)) + InfoRow( + stringResource(R.string.sell_wizard_proceeds), + "${NumberFormatters.fiat(s.proceeds)} ${state.fiat}" + ) + if (state.feeRate > BigDecimal.ZERO) { + val feePct = state.feeRate.multiply(BigDecimal(100)) + .setScale(2, RoundingMode.HALF_UP).toPlainString() + InfoRow( + stringResource(R.string.sell_wizard_summary_fee), + "-${NumberFormatters.fiat(s.feeAmount)} ${state.fiat} ($feePct %)" + ) + } + if (s.netProfit != null && s.netProfitPct != null) { + val sign = if (s.netProfit >= BigDecimal.ZERO) "+" else "" + InfoRow( + label = stringResource(R.string.sell_wizard_summary_net_profit), + value = "$sign${NumberFormatters.fiat(s.netProfit)} ${state.fiat} ($sign${s.netProfitPct.toPlainString()} %)", + color = when { + s.netProfit > BigDecimal.ZERO -> successColor() + s.netProfit < BigDecimal.ZERO -> Error + else -> null + } + ) + val target = state.targetProfitAmount + if (target != null && target > BigDecimal.ZERO && s.totalProgress != null && s.targetProgressPct != null) { + InfoRow( + stringResource(R.string.sell_wizard_summary_target_progress), + "${NumberFormatters.fiat(s.totalProgress)} / ${NumberFormatters.fiat(target)} ${state.fiat} (${s.targetProgressPct} %)" + ) + } + } +} + +@Composable +private fun LossBannerSection(state: SellWizardViewModel.UiState) { + val loss = state.validations.filterIsInstance().firstOrNull() ?: return + Spacer(Modifier.height(8.dp)) + val isPriceBelowAvg = state.priceInput.toBigDecimalOrNull()?.let { p -> + state.avgBuyPrice?.let { avg -> p < avg } + } ?: false + LossBanner( + stringResource( + if (isPriceBelowAvg) R.string.sell_wizard_loss_below_buy + else R.string.sell_wizard_loss_after_fee, + NumberFormatters.fiat(loss.lossFiat), + state.fiat + ) + ) +} + +/** + * Generic-tagged hard errors render here; field-tagged errors live under their input. + * LossWarning is rendered by [LossBannerSection], not in the generic list. + */ +@Composable +private fun ValidationsList(state: SellWizardViewModel.UiState) { + state.validations.forEach { v -> + when (v) { + is SellValidation.HardError -> if (v.field == SellValidation.Field.GENERIC) { + Text( + text = v.localizedText(), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + is SellValidation.InstantFillInfo -> InfoBanner( + stringResource(R.string.sell_wizard_instant_fill_warning, NumberFormatters.fiat(v.spot), state.fiat) + ) + is SellValidation.FarFromMarketWarning -> WarningBanner( + stringResource(R.string.sell_wizard_far_from_market_warning) + ) + is SellValidation.LossWarning -> { /* shown via LossBanner in summary section */ } + } + } +} + +@Composable +private fun SellConfirmStep( + state: SellWizardViewModel.UiState, + vm: SellWizardViewModel +) { + val amountBD = state.amountInput.toBigDecimalOrNull() ?: BigDecimal.ZERO + val priceBD = state.priceInput.toBigDecimalOrNull() ?: BigDecimal.ZERO + val proceeds = (amountBD * priceBD).setScale(2, RoundingMode.HALF_UP) + + Column( + modifier = Modifier + .fillMaxWidth() + .imePadding() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = vm::back) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + Text( + stringResource(R.string.sell_wizard_confirm_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + + Spacer(Modifier.height(12.dp)) + + SummaryRow(stringResource(R.string.sell_wizard_confirm_exchange), state.exchangeName) + SummaryRow(stringResource(R.string.sell_wizard_confirm_plan), state.planName) + SummaryRow(stringResource(R.string.sell_wizard_confirm_side), stringResource(R.string.sell_wizard_confirm_side_sell)) + if (state.ladderEnabled) { + SummaryRow( + stringResource(R.string.sell_wizard_ladder_count), + "${state.ladderPreview.size}" + ) + SummaryRow(stringResource(R.string.sell_wizard_confirm_amount), "${NumberFormatters.crypto(amountBD)} ${state.crypto}") + Spacer(Modifier.height(8.dp)) + LadderPreviewTable( + preview = state.ladderPreview, + avg = state.avgBuyPrice, + feeRate = state.feeRate, + fiat = state.fiat + ) + } else { + SummaryRow(stringResource(R.string.sell_wizard_confirm_amount), "${NumberFormatters.crypto(amountBD)} ${state.crypto}") + SummaryRow(stringResource(R.string.sell_wizard_confirm_limit_price), "${NumberFormatters.fiat(priceBD)} ${state.fiat}") + SummaryRow(stringResource(R.string.sell_wizard_confirm_proceeds), "${NumberFormatters.fiat(proceeds)} ${state.fiat}") + } + + Spacer(Modifier.height(16.dp)) + WarningBanner( + stringResource(R.string.sell_wizard_confirm_warning, state.exchangeName) + ) + + state.submitError?.let { err -> + Spacer(Modifier.height(8.dp)) + Text( + text = err, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = vm::back, + enabled = !state.submitting, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(R.string.sell_wizard_back)) + } + Button( + onClick = { vm.submit() }, + enabled = !state.submitting, + modifier = Modifier.weight(1f) + ) { + if (state.submitting) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = LocalContentColor.current + ) + } else { + Text(stringResource(R.string.sell_wizard_submit)) + } + } + } + + Spacer(Modifier.height(32.dp)) + } + + if (state.showTimeoutDialog) { + AlertDialog( + onDismissRequest = vm::dismissTimeoutDialog, + title = { Text(stringResource(R.string.sell_wizard_timeout_title)) }, + text = { + Text(stringResource(R.string.sell_wizard_timeout_text)) + }, + confirmButton = { + Button(onClick = vm::dismissTimeoutDialog) { Text("OK") } + } + ) + } +} + +@Composable +private fun SummaryRow(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + } +} + +@Composable +internal fun InfoRow( + label: String, + value: String, + color: Color? = null +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = color ?: LocalContentColor.current, + fontWeight = FontWeight.Medium + ) + } +} + +@Composable +internal fun InfoBanner(text: String) { + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.Top) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } +} + +@Composable +private fun LadderControls(state: SellWizardViewModel.UiState, vm: SellWizardViewModel) { + val avg = state.avgBuyPrice + Spacer(Modifier.height(12.dp)) + + // Range mode toggle + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf( + SellWizardViewModel.LadderRangeMode.PROFIT_PCT to stringResource(R.string.sell_wizard_ladder_range_profit), + SellWizardViewModel.LadderRangeMode.PRICE to stringResource(R.string.sell_wizard_ladder_range_price) + ).forEach { (mode, label) -> + com.accbot.dca.presentation.components.SelectableChip( + text = label, + selected = state.ladderRangeMode == mode, + onClick = { vm.setLadderRangeMode(mode) } + ) + } + } + + // From / To + Spacer(Modifier.height(8.dp)) + val rangeTransform = if (state.ladderRangeMode == SellWizardViewModel.LadderRangeMode.PRICE) + ThousandSeparator else VisualTransformation.None + val rangeError = state.ladderHardError != null + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = state.ladderFromInput, + onValueChange = vm::setLadderFrom, + label = { Text(stringResource(R.string.sell_wizard_ladder_from)) }, + visualTransformation = rangeTransform, + isError = rangeError, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.weight(1f), + singleLine = true + ) + OutlinedTextField( + value = state.ladderToInput, + onValueChange = vm::setLadderTo, + label = { Text(stringResource(R.string.sell_wizard_ladder_to)) }, + visualTransformation = rangeTransform, + isError = rangeError, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.weight(1f), + singleLine = true + ) + } + state.ladderHardError?.let { err -> + Text( + err.localizedText(), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp, start = 16.dp) + ) + } + + // Count + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = state.ladderCountInput, + onValueChange = vm::setLadderCount, + label = { Text(stringResource(R.string.sell_wizard_ladder_count)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + // Amount mode toggle + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf( + LadderGenerator.AmountMode.EQUAL_CRYPTO to stringResource(R.string.sell_wizard_ladder_amount_equal_crypto), + LadderGenerator.AmountMode.EQUAL_FIAT to stringResource(R.string.sell_wizard_ladder_amount_equal_fiat) + ).forEach { (mode, label) -> + com.accbot.dca.presentation.components.SelectableChip( + text = label, + selected = state.ladderAmountMode == mode, + onClick = { vm.setLadderAmountMode(mode) } + ) + } + } + + if (state.ladderPreview.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + LadderPreviewTable( + preview = state.ladderPreview, + avg = avg, + feeRate = state.feeRate, + fiat = state.fiat + ) + } +} + +@Composable +private fun LadderPreviewTable( + preview: List, + avg: BigDecimal?, + feeRate: BigDecimal, + fiat: String +) { + Column(modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.fillMaxWidth()) { + Text("#", modifier = Modifier.weight(0.4f), style = MaterialTheme.typography.labelSmall) + Text(stringResource(R.string.sell_wizard_amount), modifier = Modifier.weight(1.4f), style = MaterialTheme.typography.labelSmall) + Text(stringResource(R.string.sell_wizard_limit_price), modifier = Modifier.weight(1.6f), style = MaterialTheme.typography.labelSmall) + Text(stringResource(R.string.sell_wizard_ladder_table_profit), modifier = Modifier.weight(1f), style = MaterialTheme.typography.labelSmall) + Text(stringResource(R.string.sell_wizard_ladder_table_net), modifier = Modifier.weight(1.6f), style = MaterialTheme.typography.labelSmall) + } + androidx.compose.material3.HorizontalDivider() + var totalNet = BigDecimal.ZERO + preview.forEachIndexed { i, o -> + val gross = o.cryptoAmount * o.limitPrice + val net = gross * (BigDecimal.ONE - feeRate) + val profitPctText = if (avg != null && avg > BigDecimal.ZERO) { + val pct = (o.limitPrice - avg).divide(avg, 4, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)).setScale(1, RoundingMode.HALF_UP) + "${if (pct.signum() >= 0) "+" else ""}${pct.toPlainString()} %" + } else "-" + totalNet += net + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Text("${i + 1}", modifier = Modifier.weight(0.4f), style = MaterialTheme.typography.bodySmall) + Text(NumberFormatters.crypto(o.cryptoAmount), modifier = Modifier.weight(1.4f), style = MaterialTheme.typography.bodySmall) + Text(NumberFormatters.fiat(o.limitPrice), modifier = Modifier.weight(1.6f), style = MaterialTheme.typography.bodySmall) + Text(profitPctText, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodySmall) + Text(NumberFormatters.fiat(net.setScale(2, RoundingMode.HALF_UP)), modifier = Modifier.weight(1.6f), style = MaterialTheme.typography.bodySmall) + } + } + androidx.compose.material3.HorizontalDivider() + Text( + "${stringResource(R.string.sell_wizard_ladder_preview_total)}: ${NumberFormatters.fiat(totalNet.setScale(2, RoundingMode.HALF_UP))} $fiat", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 4.dp) + ) + } +} + +/** + * Locale-aware thousand-separator visual transformation. The user keeps typing raw digits + * (with '.' or ',' for decimal); only the visual presentation is grouped using the active + * locale's grouping separator (e.g. CS uses non-breaking space, EN uses ','). + */ +private val ThousandSeparator: VisualTransformation = VisualTransformation { text -> + val raw = text.text + if (raw.isEmpty()) return@VisualTransformation TransformedText(text, OffsetMapping.Identity) + val groupChar = java.text.DecimalFormatSymbols.getInstance(java.util.Locale.getDefault()).groupingSeparator + val transformed = formatThousands(raw, groupChar) + TransformedText( + AnnotatedString(transformed), + object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + if (offset <= 0) return 0 + val capped = offset.coerceAtMost(raw.length) + return formatThousands(raw.substring(0, capped), groupChar).length + } + + override fun transformedToOriginal(offset: Int): Int { + if (offset <= 0) return 0 + if (offset >= transformed.length) return raw.length + var seen = 0 + var orig = 0 + for (ch in transformed) { + if (seen >= offset) break + seen++ + if (ch != groupChar) orig++ + } + return orig.coerceAtMost(raw.length) + } + } + ) +} + +private fun formatThousands(s: String, groupChar: Char): String { + val dotIdx = s.indexOfAny(charArrayOf('.', ',')) + val intPart = if (dotIdx >= 0) s.substring(0, dotIdx) else s + val rest = if (dotIdx >= 0) s.substring(dotIdx) else "" + val grouped = if (intPart.length <= 3) intPart + else intPart.reversed().chunked(3).joinToString(groupChar.toString()).reversed() + return grouped + rest +} + +@Composable +internal fun LossBanner(text: String) { + Surface( + color = MaterialTheme.colorScheme.errorContainer, + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.Top) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } +} + +@Composable +internal fun WarningBanner(text: String) { + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer, + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.Top) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt new file mode 100644 index 0000000..87f8ab4 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt @@ -0,0 +1,699 @@ +package com.accbot.dca.presentation.screens.plans.sell + +import android.content.Context +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.accbot.dca.R +import com.accbot.dca.data.local.CredentialsStore +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.domain.model.RemainingInventory +import com.accbot.dca.domain.usecase.CalculatePlanCostBasisUseCase +import com.accbot.dca.domain.usecase.CalculatePlanPnLUseCase +import com.accbot.dca.domain.usecase.LadderOrder +import com.accbot.dca.domain.usecase.LadderResult +import com.accbot.dca.domain.usecase.PlaceLadderSellUseCase +import com.accbot.dca.domain.usecase.PlaceLimitSellUseCase +import com.accbot.dca.domain.usecase.SellValidation +import com.accbot.dca.domain.usecase.ValidateSellOrderUseCase +import com.accbot.dca.exchange.ExchangeApiFactory +import com.accbot.dca.exchange.MinOrderSizeRepository +import com.accbot.dca.presentation.utils.NumberFormatters +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import java.math.BigDecimal +import java.math.RoundingMode +import javax.inject.Inject + +/** Locale-free ladder validation error; UI resolves to a string resource. */ +sealed class LadderError { + object AvgRequired : LadderError() + object AmountMustBePositive : LadderError() + object CountMin2 : LadderError() + object ToMustExceedFrom : LadderError() + object Insufficient : LadderError() + data class BelowMin(val smallest: BigDecimal, val min: BigDecimal, val fiat: String) : LadderError() +} + +/** + * Derived single-order summary: gross proceeds, fees, net, optional profit vs avg buy + * and amount-as-pct-of-available. Computed in the VM so the UI stays dumb. + */ +data class SellSummary( + val proceeds: BigDecimal, + val feeAmount: BigDecimal, + val netProceeds: BigDecimal, + val costBasis: BigDecimal?, + val netProfit: BigDecimal?, + val netProfitPct: BigDecimal?, + val amountPct: BigDecimal?, + val totalProgress: BigDecimal?, + val targetProgressPct: Int? +) + +/** + * State + actions for the two-step limit-sell wizard (input -> confirm -> submit). + * + * Cost basis: the avg buy price prefilled in the UI is computed via timestamp-aware + * cheapest-first ([CalculatePlanCostBasisUseCase]). Manual override is supported via + * [setAvgBuyPrice]; [resetAvgBuyPrice] returns to auto. + * + * Three-field calculator: [setAmount], [setPrice], [setNetFiat] each record the field as + * "most recently edited"; [SellCalculatorMath.recompute] fills the third field when the + * other two are set. + */ +@HiltViewModel +class SellWizardViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val validateSellOrderUseCase: ValidateSellOrderUseCase, + private val placeLimitSellUseCase: PlaceLimitSellUseCase, + private val placeLadderSellUseCase: PlaceLadderSellUseCase, + private val calculatePlanPnLUseCase: CalculatePlanPnLUseCase, + private val calculatePlanCostBasisUseCase: CalculatePlanCostBasisUseCase, + private val database: DcaDatabase, + private val credentialsStore: CredentialsStore, + private val exchangeApiFactory: ExchangeApiFactory, + private val userPreferences: UserPreferences, + private val minOrderSizeRepository: MinOrderSizeRepository +) : ViewModel() { + + enum class Step { INPUT, CONFIRM } + enum class LadderRangeMode { PRICE, PROFIT_PCT } + + /** Snapshot of partial ladder result so the UI can present a dialog after submit. */ + data class LadderSubmitOutcome(val placed: Int, val total: Int, val reason: String?) + + data class UiState( + val planId: Long = 0, + val planName: String = "", + val exchangeName: String = "", + val crypto: String = "", + val fiat: String = "", + val held: BigDecimal = BigDecimal.ZERO, + val availableToSell: BigDecimal = BigDecimal.ZERO, + val spotPrice: BigDecimal? = null, + /** Auto-computed avg buy price from cheapest-first; null when no buys remain. */ + val avgBuyPriceAuto: BigDecimal? = null, + /** User-entered text for avg; empty string means "use auto". */ + val avgBuyPriceInput: String = "", + /** True when [avgBuyPriceInput] differs from auto - drives the reset chip. */ + val avgBuyPriceManual: Boolean = false, + /** Minimum order size **in fiat** (e.g. 50 CZK on Coinmate). */ + val minOrderFiat: BigDecimal = BigDecimal.ZERO, + val amountInput: String = "", + val priceInput: String = "", + val netInput: String = "", + val lastTwoEdited: List = emptyList(), + val feeRate: BigDecimal = BigDecimal.ZERO, + val targetProfitAmount: BigDecimal? = null, + val realizedPnLSoFar: BigDecimal = BigDecimal.ZERO, + val inventoryDeficit: BigDecimal = BigDecimal.ZERO, + val validations: List = emptyList(), + val ladderEnabled: Boolean = false, + val ladderRangeMode: LadderRangeMode = LadderRangeMode.PROFIT_PCT, + val ladderFromInput: String = "", + val ladderToInput: String = "", + val ladderCountInput: String = "5", + val ladderAmountMode: LadderGenerator.AmountMode = LadderGenerator.AmountMode.EQUAL_CRYPTO, + val ladderPreview: List = emptyList(), + val ladderHardError: LadderError? = null, + val ladderOutcome: LadderSubmitOutcome? = null, + val step: Step = Step.INPUT, + val initializing: Boolean = true, + val submitting: Boolean = false, + val submitError: String? = null, + val showTimeoutDialog: Boolean = false, + val dismissRequested: Boolean = false + ) { + /** Effective avg buy: parsed manual input takes precedence, else auto. */ + val avgBuyPrice: BigDecimal? + get() = if (avgBuyPriceManual) avgBuyPriceInput.toBigDecimalOrNull() else avgBuyPriceAuto + + val canProceed: Boolean + get() = if (ladderEnabled) { + ladderHardError == null && ladderPreview.size >= 2 && + amountInput.toBigDecimalOrNull() != null + } else { + validations.none { it is SellValidation.HardError } && + amountInput.isNotBlank() && priceInput.isNotBlank() && + amountInput.toBigDecimalOrNull() != null && + priceInput.toBigDecimalOrNull() != null + } + + /** + * Single-order summary derived from amount/price/avg/fee. null when amount or + * price is missing/zero - the UI hides the summary card in that case. + */ + val summary: SellSummary? + get() { + val a = amountInput.toBigDecimalOrNull() ?: return null + val p = priceInput.toBigDecimalOrNull() ?: return null + if (a <= BigDecimal.ZERO || p <= BigDecimal.ZERO) return null + val proceeds = (a * p).setScale(2, RoundingMode.HALF_UP) + val feeAmount = (proceeds * feeRate).setScale(2, RoundingMode.HALF_UP) + val netProceeds = (proceeds - feeAmount).setScale(2, RoundingMode.HALF_UP) + val avg = avgBuyPrice + val costBasis = avg?.let { a * it } + val netProfit = costBasis?.let { (netProceeds - it).setScale(2, RoundingMode.HALF_UP) } + val netProfitPct = if (costBasis != null && netProfit != null && costBasis > BigDecimal.ZERO) { + netProfit.divide(costBasis, 4, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)) + .setScale(1, RoundingMode.HALF_UP) + } else null + val amountPct = if (availableToSell > BigDecimal.ZERO) { + a.divide(availableToSell, 4, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)) + .setScale(1, RoundingMode.HALF_UP) + } else null + val totalProgress = netProfit?.let { realizedPnLSoFar + it } + val targetProgressPct = if (totalProgress != null && + targetProfitAmount != null && targetProfitAmount > BigDecimal.ZERO + ) { + val raw = totalProgress.divide(targetProfitAmount, 4, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)) + raw.toInt().coerceAtLeast(0) + } else null + return SellSummary( + proceeds, feeAmount, netProceeds, + costBasis, netProfit, netProfitPct, + amountPct, totalProgress, targetProgressPct + ) + } + } + + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var initialized = false + private var validationJob: Job? = null + + fun init(planId: Long) { + if (initialized) return + initialized = true + viewModelScope.launch { + val plan = database.dcaPlanDao().getPlanById(planId) + if (plan == null) { + _uiState.update { it.copy(initializing = false) } + return@launch + } + + val credentials = try { + credentialsStore.getCredentials(plan.connectionId, userPreferences.isSandboxMode()) + } catch (e: Exception) { + Log.w(TAG, "getCredentials failed: ${e.message}") + null + } + val api = credentials?.let { exchangeApiFactory.create(it) } + val spot = api?.let { + try { + withTimeoutOrNull(INIT_TIMEOUT_MS) { it.getCurrentPrice(plan.crypto, plan.fiat) } + } catch (e: Exception) { + Log.w(TAG, "getCurrentPrice failed: ${e.message}") + null + } + } + val feeRate = api?.estimatedTakerFeeRate ?: BigDecimal.ZERO + + val inventory: RemainingInventory = try { + calculatePlanCostBasisUseCase(planId) + } catch (e: Exception) { + Log.w(TAG, "cost basis calc failed: ${e.message}") + RemainingInventory(BigDecimal.ZERO, null, emptyList(), BigDecimal.ZERO) + } + + val pnl = try { + calculatePlanPnLUseCase(planId, spot) + } catch (e: Exception) { + Log.w(TAG, "PnL calc failed: ${e.message}") + null + } + val held = pnl?.currentCryptoHeld ?: inventory.available + val realizedSoFar = pnl?.realizedPnL ?: BigDecimal.ZERO + + val minOrderFiat = try { + minOrderSizeRepository.getMinOrderSize(plan.exchange, plan.crypto, plan.fiat) + } catch (e: Exception) { + Log.w(TAG, "min order fetch failed: ${e.message}") + BigDecimal.ZERO + } + + _uiState.update { + it.copy( + planId = planId, + planName = plan.name.ifBlank { "${plan.crypto}/${plan.fiat}" }, + exchangeName = plan.exchange.displayName, + crypto = plan.crypto, + fiat = plan.fiat, + held = held, + availableToSell = inventory.available, + spotPrice = spot, + avgBuyPriceAuto = inventory.weightedAvgPrice, + avgBuyPriceInput = inventory.weightedAvgPrice?.setScale(2, RoundingMode.HALF_UP)?.toPlainString() ?: "", + avgBuyPriceManual = false, + minOrderFiat = minOrderFiat, + feeRate = feeRate, + targetProfitAmount = plan.targetProfitAmount, + realizedPnLSoFar = realizedSoFar, + inventoryDeficit = inventory.deficit, + initializing = false + ) + } + revalidate() + } + } + + fun setAmount(value: String) { + updateCalculatorField(SellCalculatorMath.Field.AMOUNT, value) + } + + fun setAmountPct(pct: Int) { + val target = _uiState.value.availableToSell + .multiply(BigDecimal(pct)) + .divide(BigDecimal(100), 8, RoundingMode.HALF_UP) + setAmount(target.stripTrailingZeros().toPlainString()) + } + + fun setPrice(value: String) { + updateCalculatorField(SellCalculatorMath.Field.PRICE, value) + } + + fun setNetFiat(value: String) { + updateCalculatorField(SellCalculatorMath.Field.NET, value) + } + + fun setPriceSpot() { + _uiState.value.spotPrice?.let { + setPrice(it.stripTrailingZeros().toPlainString()) + } + } + + fun setPriceAvgPlus(pct: Int) { + _uiState.value.avgBuyPrice?.let { avg -> + val multiplier = BigDecimal.ONE + + BigDecimal(pct).divide(BigDecimal(100), 4, RoundingMode.HALF_UP) + setPrice((avg * multiplier).setScale(2, RoundingMode.HALF_UP).toPlainString()) + } + } + + /** Apply a profit-target preset to the net field: N = A * avg * (1 + profitPct). */ + fun applyNetProfitPreset(profitPct: Double) { + val st = _uiState.value + val a = st.amountInput.toBigDecimalOrNull() ?: return + val avg = st.avgBuyPrice ?: return + if (a <= BigDecimal.ZERO || avg <= BigDecimal.ZERO) return + val target = a * avg * (BigDecimal.ONE + BigDecimal(profitPct)) + setNetFiat(target.setScale(2, RoundingMode.HALF_UP).toPlainString()) + } + + fun setAvgBuyPrice(value: String) { + _uiState.update { st -> + val parsed = value.toBigDecimalOrNull() + // Compare against the same 2dp display rounding the user sees - avoids + // flagging "manual" on the very first prefill that came from auto. + val autoDisplay = st.avgBuyPriceAuto?.setScale(2, RoundingMode.HALF_UP) + val isManual = parsed != null && + (autoDisplay == null || parsed.compareTo(autoDisplay) != 0) + st.copy(avgBuyPriceInput = value, avgBuyPriceManual = isManual) + } + revalidate() + recomputeLadderPreview() + } + + fun resetAvgBuyPrice() { + _uiState.update { st -> + st.copy( + avgBuyPriceInput = st.avgBuyPriceAuto?.setScale(2, RoundingMode.HALF_UP)?.toPlainString() ?: "", + avgBuyPriceManual = false + ) + } + revalidate() + recomputeLadderPreview() + } + + private fun updateCalculatorField(field: SellCalculatorMath.Field, text: String) { + _uiState.update { st -> + val newLastTwo = (listOf(field) + st.lastTwoEdited.filter { it != field }).take(2) + val current = mapOf( + SellCalculatorMath.Field.AMOUNT to st.amountInput, + SellCalculatorMath.Field.PRICE to st.priceInput, + SellCalculatorMath.Field.NET to st.netInput + ).toMutableMap() + current[field] = text + val a = current[SellCalculatorMath.Field.AMOUNT]?.toBigDecimalOrNull() + val p = current[SellCalculatorMath.Field.PRICE]?.toBigDecimalOrNull() + val n = current[SellCalculatorMath.Field.NET]?.toBigDecimalOrNull() + val (newA, newP, newN) = SellCalculatorMath.recompute(a, p, n, st.feeRate, newLastTwo) + + // Only overwrite the field that wasn't directly edited; the user-typed text stays as-is + val nextAmount = if (field == SellCalculatorMath.Field.AMOUNT) text + else newA?.stripTrailingZeros()?.toPlainString() ?: st.amountInput + val nextPrice = if (field == SellCalculatorMath.Field.PRICE) text + else newP?.toPlainString() ?: st.priceInput + val nextNet = if (field == SellCalculatorMath.Field.NET) text + else newN?.toPlainString() ?: st.netInput + + st.copy( + amountInput = nextAmount, + priceInput = nextPrice, + netInput = nextNet, + lastTwoEdited = newLastTwo + ) + } + revalidate() + if (field == SellCalculatorMath.Field.AMOUNT) recomputeLadderPreview() + } + + // --- Ladder handlers --- + + fun setLadderEnabled(enabled: Boolean) { + _uiState.update { st -> + if (enabled) { + // Single -> Ladder: spread +-10 % around the single price so the middle + // order matches what the user had already set. + val price = st.priceInput.toBigDecimalOrNull() + val avg = st.avgBuyPrice + val (fromInput, toInput) = if (price != null && price > BigDecimal.ZERO) { + val fromPrice = price * BigDecimal("0.90") + val toPrice = price * BigDecimal("1.10") + when (st.ladderRangeMode) { + LadderRangeMode.PRICE -> + fromPrice.setScale(2, RoundingMode.HALF_UP).toPlainString() to + toPrice.setScale(2, RoundingMode.HALF_UP).toPlainString() + LadderRangeMode.PROFIT_PCT -> { + val fromPct = LadderGenerator.priceToProfitPct(fromPrice, avg) + val toPct = LadderGenerator.priceToProfitPct(toPrice, avg) + if (fromPct != null && toPct != null) { + fromPct.toPlainString() to toPct.toPlainString() + } else st.ladderFromInput to st.ladderToInput + } + } + } else st.ladderFromInput to st.ladderToInput + st.copy( + ladderEnabled = true, + ladderOutcome = null, + ladderFromInput = fromInput, + ladderToInput = toInput + ) + } else { + // Ladder -> Single: derive a single price that yields the same total net + // for the same crypto amount: net = amount * price * (1 - fee), so + // price = net / (amount * (1 - fee)). Net comes from current ladder total + // (already in netInput, kept in sync by recomputeLadderPreview). + val amount = st.amountInput.toBigDecimalOrNull() + val total = st.netInput.toBigDecimalOrNull() + val factor = BigDecimal.ONE - st.feeRate + val newPrice = if (amount != null && total != null && + amount > BigDecimal.ZERO && factor > BigDecimal.ZERO + ) { + total.divide(amount * factor, 2, RoundingMode.HALF_UP).toPlainString() + } else st.priceInput + st.copy( + ladderEnabled = false, + ladderOutcome = null, + priceInput = newPrice, + ladderPreview = emptyList(), + ladderHardError = null + ) + } + } + if (_uiState.value.ladderEnabled) { + recomputeLadderPreview() + } else { + revalidate() + } + } + + fun setLadderRangeMode(mode: LadderRangeMode) { + _uiState.update { st -> + if (st.ladderRangeMode == mode) return@update st + val avg = st.avgBuyPrice + val convert: (String) -> String = { input -> + val v = input.toBigDecimalOrNull() + val converted = v?.let { + when (mode) { + LadderRangeMode.PRICE -> LadderGenerator.profitPctToPrice(it, avg) + LadderRangeMode.PROFIT_PCT -> LadderGenerator.priceToProfitPct(it, avg) + } + } + converted?.toPlainString() ?: "" + } + st.copy( + ladderRangeMode = mode, + ladderFromInput = convert(st.ladderFromInput), + ladderToInput = convert(st.ladderToInput) + ) + } + recomputeLadderPreview() + } + + fun setLadderFrom(value: String) { + _uiState.update { it.copy(ladderFromInput = value) } + recomputeLadderPreview() + } + + fun setLadderTo(value: String) { + _uiState.update { it.copy(ladderToInput = value) } + recomputeLadderPreview() + } + + fun setLadderCount(value: String) { + _uiState.update { it.copy(ladderCountInput = value) } + recomputeLadderPreview() + } + + fun setLadderAmountMode(mode: LadderGenerator.AmountMode) { + _uiState.update { it.copy(ladderAmountMode = mode) } + recomputeLadderPreview() + } + + /** + * Ladder-mode third-field calculator: user enters target net total, we adjust the + * upper bound (`to`) to hit it given the current amount + from. Linear distribution + * with equal-crypto: `total_net = amount * (from+to)/2 * (1-feeRate)`, so + * `to = 2*net/(amount*(1-feeRate)) - from`. + */ + fun setLadderNetTarget(value: String) { + _uiState.update { it.copy(netInput = value) } + val targetNet = value.toBigDecimalOrNull() ?: return + val st = _uiState.value + val amount = st.amountInput.toBigDecimalOrNull() ?: return + if (amount <= BigDecimal.ZERO || targetNet <= BigDecimal.ZERO) return + val factor = BigDecimal.ONE - st.feeRate + if (factor <= BigDecimal.ZERO) return + val avg = st.avgBuyPrice + val fromRaw = st.ladderFromInput.toBigDecimalOrNull() ?: return + val fromPrice = when (st.ladderRangeMode) { + LadderRangeMode.PRICE -> fromRaw + LadderRangeMode.PROFIT_PCT -> LadderGenerator.profitPctToPrice(fromRaw, avg) ?: return + } + if (fromPrice <= BigDecimal.ZERO) return + val toPrice = (BigDecimal(2) * targetNet).divide(amount * factor, 8, RoundingMode.HALF_UP) - fromPrice + if (toPrice <= fromPrice) return + val toDisplay = when (st.ladderRangeMode) { + LadderRangeMode.PRICE -> toPrice.setScale(2, RoundingMode.HALF_UP).toPlainString() + LadderRangeMode.PROFIT_PCT -> + LadderGenerator.priceToProfitPct(toPrice, avg)?.toPlainString() ?: return + } + _uiState.update { it.copy(ladderToInput = toDisplay) } + recomputeLadderPreview(syncNetFromTotal = false) + } + + private fun recomputeLadderPreview(syncNetFromTotal: Boolean = true) { + val st = _uiState.value + if (!st.ladderEnabled) { + _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = null) } + return + } + val total = st.amountInput.toBigDecimalOrNull() + val count = st.ladderCountInput.toIntOrNull() + val avg = st.avgBuyPrice + val (from, to) = when (st.ladderRangeMode) { + LadderRangeMode.PRICE -> { + st.ladderFromInput.toBigDecimalOrNull() to st.ladderToInput.toBigDecimalOrNull() + } + LadderRangeMode.PROFIT_PCT -> { + if (avg == null) { + _uiState.update { + it.copy(ladderPreview = emptyList(), ladderHardError = LadderError.AvgRequired) + } + return + } + val fPct = st.ladderFromInput.toBigDecimalOrNull() + val tPct = st.ladderToInput.toBigDecimalOrNull() + val f = fPct?.let { LadderGenerator.profitPctToPrice(it, avg) } + val t = tPct?.let { LadderGenerator.profitPctToPrice(it, avg) } + f to t + } + } + + if (total == null || count == null || from == null || to == null) { + _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = null) } + return + } + if (total <= BigDecimal.ZERO) { + _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = LadderError.AmountMustBePositive) } + return + } + if (count < 2) { + _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = LadderError.CountMin2) } + return + } + if (to <= from || from <= BigDecimal.ZERO) { + _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = LadderError.ToMustExceedFrom) } + return + } + if (total > st.availableToSell) { + _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = LadderError.Insufficient) } + return + } + + val preview = LadderGenerator.generate(total, from, to, count, st.ladderAmountMode) + val minOrderFiatValue = preview.minOf { it.cryptoAmount * it.limitPrice } + val hardError: LadderError? = if (st.minOrderFiat > BigDecimal.ZERO && minOrderFiatValue < st.minOrderFiat) { + LadderError.BelowMin( + smallest = minOrderFiatValue.setScale(2, RoundingMode.HALF_UP), + min = st.minOrderFiat, + fiat = st.fiat + ) + } else null + + val totalNet = preview.fold(BigDecimal.ZERO) { acc, o -> + acc + o.cryptoAmount * o.limitPrice * (BigDecimal.ONE - st.feeRate) + }.setScale(2, RoundingMode.HALF_UP) + + _uiState.update { + it.copy( + ladderPreview = preview, + ladderHardError = hardError, + netInput = if (syncNetFromTotal) totalNet.toPlainString() else it.netInput + ) + } + } + + fun submitLadder() { + val st = _uiState.value + if (!st.ladderEnabled || st.ladderPreview.size < 2) return + viewModelScope.launch { + _uiState.update { it.copy(submitting = true, submitError = null, ladderOutcome = null) } + val result = withTimeoutOrNull(SUBMIT_LADDER_TIMEOUT_MS) { + placeLadderSellUseCase(st.planId, st.ladderPreview) + } + when (result) { + null -> _uiState.update { it.copy(submitting = false, showTimeoutDialog = true) } + is LadderResult.AllPlaced -> _uiState.update { + it.copy( + submitting = false, + ladderOutcome = LadderSubmitOutcome( + placed = result.placedTxIds.size, + total = result.placedTxIds.size, + reason = null + ) + ) + } + is LadderResult.PartialFailure -> _uiState.update { + it.copy( + submitting = false, + ladderOutcome = LadderSubmitOutcome( + placed = result.placedTxIds.size, + total = result.totalCount, + reason = result.reason + ) + ) + } + } + } + } + + fun consumeLadderOutcome() { + _uiState.update { it.copy(ladderOutcome = null, dismissRequested = true) } + } + + fun proceedToConfirm() { + _uiState.update { it.copy(step = Step.CONFIRM, submitError = null) } + } + + fun back() { + _uiState.update { it.copy(step = Step.INPUT, submitError = null) } + } + + fun submit() { + val state = _uiState.value + if (state.ladderEnabled) { + submitLadder() + return + } + val amount = state.amountInput.toBigDecimalOrNull() ?: return + val price = state.priceInput.toBigDecimalOrNull() ?: return + + viewModelScope.launch { + _uiState.update { it.copy(submitting = true, submitError = null) } + + val result = withTimeoutOrNull(SUBMIT_SINGLE_TIMEOUT_MS) { + placeLimitSellUseCase(state.planId, amount, price) + } + + when { + result == null -> + _uiState.update { it.copy(submitting = false, showTimeoutDialog = true) } + result.isSuccess -> + _uiState.update { it.copy(submitting = false, dismissRequested = true) } + else -> + _uiState.update { + it.copy( + submitting = false, + submitError = result.exceptionOrNull()?.message + ?: context.getString(R.string.sell_wizard_submit_error_unknown) + ) + } + } + } + } + + fun dismissTimeoutDialog() { + _uiState.update { it.copy(showTimeoutDialog = false) } + } + + fun consumeDismiss() { + _uiState.update { it.copy(dismissRequested = false) } + } + + private fun revalidate() { + val state = _uiState.value + val amount = state.amountInput.toBigDecimalOrNull() + val price = state.priceInput.toBigDecimalOrNull() + validationJob?.cancel() + if (amount == null || price == null) { + _uiState.update { it.copy(validations = emptyList()) } + return + } + // Cancel any in-flight validation so a slower earlier coroutine can't overwrite + // newer results - keystrokes fire revalidate() rapidly and we must keep the latest win. + validationJob = viewModelScope.launch { + val validations = try { + validateSellOrderUseCase( + state.planId, amount, price, state.minOrderFiat, state.spotPrice, + state.avgBuyPrice, state.feeRate + ) + } catch (e: Exception) { + Log.w(TAG, "validate failed: ${e.message}") + emptyList() + } + _uiState.update { it.copy(validations = validations) } + } + } + + companion object { + private const val TAG = "SellWizardViewModel" + private const val INIT_TIMEOUT_MS = 10_000L + private const val SUBMIT_SINGLE_TIMEOUT_MS = 15_000L + private const val SUBMIT_LADDER_TIMEOUT_MS = 30_000L + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt index 62a43d0..5326ea8 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -68,9 +69,17 @@ fun PortfolioScreen( viewModel: PortfolioViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val openSellLimitPrices by viewModel.openSellLimitPrices.collectAsStateWithLifecycle() + val openSells by viewModel.openSells.collectAsStateWithLifecycle() val configuration = LocalConfiguration.current val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + // Snackbar host for cancel-order failures + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(Unit) { + viewModel.snackbar.collect { msg -> snackbarHostState.showSnackbar(msg) } + } + // Refresh portfolio data when returning to screen (e.g. after transaction import) val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { @@ -83,6 +92,21 @@ fun PortfolioScreen( onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } + // Sell wizard bottom sheet + var sellWizardPlanId by rememberSaveable { mutableStateOf(null) } + sellWizardPlanId?.let { planId -> + com.accbot.dca.presentation.screens.plans.sell.SellWizardBottomSheet( + planId = planId, + onDismiss = { sellWizardPlanId = null } + ) + } + + val currentPage = uiState.pages.getOrNull(uiState.selectedPageIndex) + val currentPlanId = (currentPage as? PairPage.Plan)?.planId + val onCreateSellOrder: (() -> Unit)? = if (currentPlanId != null && uiState.currentPlanAllowsSells) { + { sellWizardPlanId = currentPlanId } + } else null + // Landscape: two-pane layout – chart left, controls right if (isLandscape) { val chartData = uiState.chartData @@ -146,6 +170,10 @@ fun PortfolioScreen( visibleCryptoGroupLines = uiState.visibleCryptoGroupLines, zoomLevel = uiState.zoomLevel, onScrub = { idx -> scrubbedIndex = idx ?: -1 }, + // Show open-sell limit lines only on per-plan pages with sells enabled + openSellLimitPrices = if (uiState.currentPlanAllowsSells && + uiState.denominationMode == DenominationMode.FIAT && + uiState.limitLinesVisible) openSellLimitPrices else emptyList(), modifier = Modifier .fillMaxWidth() .weight(1f) @@ -171,6 +199,22 @@ fun PortfolioScreen( onToggleAdvanced = { viewModel.toggleAdvancedLegendExpanded() } ) } + // Limit-sell legend entry (landscape) + if (uiState.currentPlanAllowsSells && + uiState.denominationMode == DenominationMode.FIAT && + openSellLimitPrices.isNotEmpty() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + LimitOrderLegendItem( + label = stringResource(R.string.chart_legend_limit_sell), + enabled = uiState.limitLinesVisible, + onClick = { viewModel.toggleLimitLinesVisibility() } + ) + } + } // Zoom header + drill-down chips Column( @@ -284,7 +328,8 @@ fun PortfolioScreen( } } ) - } + }, + snackbarHost = { SnackbarHost(snackbarHostState) } ) { paddingValues -> when { uiState.isLoading -> { @@ -313,6 +358,9 @@ fun PortfolioScreen( else -> { PortfolioContent( uiState = uiState, + openSellLimitPrices = openSellLimitPrices, + openSells = openSells, + onCancelSell = viewModel::cancelSell, onDrillDownYear = { viewModel.drillDownToYear(it) }, onDrillDownMonth = { year, month -> viewModel.drillDownToMonth(year, month) }, onZoomOut = { viewModel.zoomOut() }, @@ -323,6 +371,8 @@ fun PortfolioScreen( onTogglePlanLineVisibility = { id, type -> viewModel.togglePlanLineVisibility(id, type) }, onToggleCryptoGroupLineVisibility = { crypto, type -> viewModel.toggleCryptoGroupLineVisibility(crypto, type) }, onToggleAdvancedLegend = { viewModel.toggleAdvancedLegendExpanded() }, + onToggleLimitLinesVisibility = { viewModel.toggleLimitLinesVisibility() }, + onCreateSellOrder = onCreateSellOrder, onRefresh = { viewModel.syncPricesAndLoadChart() }, onChartTouching = onChartTouching, modifier = Modifier.padding(paddingValues) @@ -336,6 +386,9 @@ fun PortfolioScreen( @Composable internal fun PortfolioContent( uiState: PortfolioUiState, + openSellLimitPrices: List = emptyList(), + openSells: List = emptyList(), + onCancelSell: (Long) -> Unit = {}, onDrillDownYear: (Int) -> Unit, onDrillDownMonth: (Int, Int) -> Unit, onZoomOut: () -> Unit, @@ -346,6 +399,8 @@ internal fun PortfolioContent( onTogglePlanLineVisibility: (Long, PlanLineType) -> Unit, onToggleCryptoGroupLineVisibility: (String, CryptoGroupLineType) -> Unit, onToggleAdvancedLegend: () -> Unit, + onToggleLimitLinesVisibility: () -> Unit = {}, + onCreateSellOrder: (() -> Unit)? = null, onRefresh: () -> Unit, onChartTouching: (Boolean) -> Unit = {}, modifier: Modifier = Modifier @@ -583,6 +638,10 @@ internal fun PortfolioContent( visibleCryptoGroupLines = uiState.visibleCryptoGroupLines, zoomLevel = uiState.zoomLevel, onScrub = { idx -> scrubbedIndex = idx ?: -1 }, + // Show open-sell limit lines only on per-plan pages with sells enabled + openSellLimitPrices = if (uiState.currentPlanAllowsSells && + uiState.denominationMode == DenominationMode.FIAT && + uiState.limitLinesVisible) openSellLimitPrices else emptyList(), modifier = Modifier.fillMaxWidth() ) } else if (chartData.size == 1) { @@ -628,6 +687,52 @@ internal fun PortfolioContent( onToggleAdvanced = onToggleAdvancedLegend ) } + // Limit-sell legend entry: shown only when matching dashed lines render on the chart + if (uiState.currentPlanAllowsSells && + uiState.denominationMode == DenominationMode.FIAT && + openSellLimitPrices.isNotEmpty() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + LimitOrderLegendItem( + label = stringResource(R.string.chart_legend_limit_sell), + enabled = uiState.limitLinesVisible, + onClick = onToggleLimitLinesVisibility + ) + } + } + } + } + + // Open sell-orders section + new order button (only on per-plan pages with sells enabled) + if (uiState.currentPlanAllowsSells) { + if (openSells.isNotEmpty()) { + item(key = "portfolio-open-sells-section") { + com.accbot.dca.presentation.screens.portfolio.components.OpenSellsCollapsibleSection( + openSells = openSells, + onCancelClick = onCancelSell + ) + } + } + if (onCreateSellOrder != null) { + item(key = "portfolio-new-sell-order") { + OutlinedButton( + onClick = onCreateSellOrder, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.portfolio_new_sell_order)) + } + } } } @@ -1113,6 +1218,64 @@ internal fun KpiCardContent( } } } + + // Sell-extension trading metrics: realized + net P&L (only when trading enabled) + TradingMetricsRows(uiState = uiState, fiatSymbol = fiatSymbol) +} + +@Composable +private fun TradingMetricsRows( + uiState: PortfolioUiState, + fiatSymbol: String +) { + if (!uiState.showTradingMetrics) return + val realized = uiState.totalRealized ?: BigDecimal.ZERO + if (realized.signum() <= 0 && uiState.netPnL == null) return + + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + + if (realized.signum() > 0) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.portfolio_realized), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "${NumberFormatters.fiat(realized)} $fiatSymbol", + fontWeight = FontWeight.SemiBold, + color = successColor() + ) + } + } + + val net = uiState.netPnL + if (net != null) { + Spacer(modifier = Modifier.height(4.dp)) + val isPositive = net.signum() >= 0 + val pnlColor = if (isPositive) successColor() else MaterialTheme.colorScheme.error + val sign = if (isPositive) "+" else "" + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.portfolio_net_pnl), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "$sign${NumberFormatters.fiat(net)} $fiatSymbol", + fontWeight = FontWeight.SemiBold, + color = pnlColor + ) + } + } } @Composable @@ -1259,6 +1422,9 @@ private fun LandscapeKpiContent( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) + + // Sell-extension trading metrics: realized + net P&L (only when trading enabled) + TradingMetricsRows(uiState = uiState, fiatSymbol = fiatSymbol) } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt index 2a634c2..a81f412 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt @@ -11,6 +11,9 @@ import com.accbot.dca.data.local.TransactionEntity import com.accbot.dca.data.local.UserPreferences // TransactionStatus filtering now done in DAO query import com.accbot.dca.domain.usecase.CalculateChartDataUseCase +import com.accbot.dca.domain.usecase.CancelSellOrderUseCase +import com.accbot.dca.domain.model.Transaction +import com.accbot.dca.data.local.toDomain import com.accbot.dca.domain.usecase.ChartDataPoint import com.accbot.dca.domain.usecase.ChartZoomLevel import com.accbot.dca.domain.usecase.SyncDailyPricesUseCase @@ -22,6 +25,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.math.BigDecimal import java.time.LocalDate import java.time.ZoneId import javax.inject.Inject @@ -70,6 +74,7 @@ data class PortfolioUiState( val currentPairFiat: String? = null, val totalTransactions: Int = 0, val visibleSeries: Set = setOf(0, 1), + val limitLinesVisible: Boolean = false, val scrubbedIndex: Int? = null, val planLines: List = emptyList(), val visiblePlanLines: Set> = emptySet(), @@ -79,7 +84,31 @@ data class PortfolioUiState( val isLoading: Boolean = true, val isChartLoading: Boolean = false, val isPriceSyncing: Boolean = false, - val error: String? = null + val error: String? = null, + /** + * True when the global trading master switch is on. Gates display of the + * sell-extension summary rows (realized P&L, net P&L) so users without the + * feature enabled don't see empty/zero rows. + */ + val showTradingMetrics: Boolean = false, + /** + * Sum of fiat received from completed/partial SELL orders for the currently + * selected fiat. Null when not loaded yet, BigDecimal.ZERO when there are + * no realized sells. + */ + val totalRealized: BigDecimal? = null, + /** + * Net P&L = currentPortfolioValue + totalRealized - totalInvested. + * Null when current price is unavailable (matches the existing chart-loading + * pattern where ROI fields are also null until prices arrive). + */ + val netPnL: BigDecimal? = null, + /** + * True when the currently selected page is a [PairPage.Plan] AND the plan has + * `allowSells = true`. Drives visibility of the open-orders list and chart + * horizontal lines on the per-plan page. + */ + val currentPlanAllowsSells: Boolean = false, ) @HiltViewModel @@ -89,7 +118,8 @@ class PortfolioViewModel @Inject constructor( private val dcaPlanDao: DcaPlanDao, private val syncDailyPricesUseCase: SyncDailyPricesUseCase, private val calculateChartDataUseCase: CalculateChartDataUseCase, - private val userPreferences: UserPreferences + private val userPreferences: UserPreferences, + private val cancelSellOrderUseCase: CancelSellOrderUseCase ) : ViewModel() { // Consumed once on first loadPortfolio() and then nulled out so a process-death @@ -108,6 +138,28 @@ class PortfolioViewModel @Inject constructor( private val _uiState = MutableStateFlow(PortfolioUiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** + * Stream of open (PENDING / PARTIAL) sell-order transactions for the currently + * selected per-plan page. Empty when the page is Aggregate, the plan has + * allowSells=false, or no open sells exist. Drives both the chart's horizontal + * limit-price lines (Task B) and the collapsible open-orders list (Task C). + */ + private val _openSells = MutableStateFlow>(emptyList()) + val openSells: StateFlow> = _openSells.asStateFlow() + + /** + * Convenience derived flow exposing only the sorted limit prices for the + * chart's horizontal lines. + */ + val openSellLimitPrices: StateFlow> = openSells + .map { txs -> txs.mapNotNull { it.limitPrice } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + private val _snackbar = MutableSharedFlow(extraBufferCapacity = 4) + val snackbar: SharedFlow = _snackbar.asSharedFlow() + + private var openSellsJob: Job? = null + private var completedTransactions: List = emptyList() /** * Cached plan list used by [loadChartData] to build aggregate per-plan lines. @@ -145,7 +197,12 @@ class PortfolioViewModel @Inject constructor( private fun loadPortfolio() { portfolioJob?.cancel() portfolioJob = viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } + val tradingEnabled = userPreferences.isTradingEnabled() + _uiState.update { it.copy( + isLoading = true, + error = null, + showTradingMetrics = tradingEnabled + ) } try { // Use pre-filtered, sorted query (avoids loading failed/pending into memory) val completed = transactionDao.getCompletedTransactionsOrdered() @@ -203,6 +260,7 @@ class PortfolioViewModel @Inject constructor( } updateNavigationState() + refreshOpenSellsForCurrentPage() syncPricesAndLoadChart() lastLoadedAt = System.currentTimeMillis() } catch (e: CancellationException) { @@ -252,6 +310,7 @@ class PortfolioViewModel @Inject constructor( ) } updateNavigationState() + refreshOpenSellsForCurrentPage() lastTransactionsFetchedAt = System.currentTimeMillis() } catch (e: CancellationException) { throw e @@ -388,6 +447,50 @@ class PortfolioViewModel @Inject constructor( } updateNavigationState() loadChartData() + refreshOpenSellsForCurrentPage() + } + + /** + * (Re)subscribe to the open-sells Flow for the currently selected page. + * Cancels any prior subscription so we never have two collectors competing + * to push into [_openSells]. Aggregate pages and plans without `allowSells` + * yield an empty list. + */ + private fun refreshOpenSellsForCurrentPage() { + openSellsJob?.cancel() + val page = _uiState.value.pages.getOrNull(_uiState.value.selectedPageIndex) + if (page !is PairPage.Plan) { + _openSells.value = emptyList() + _uiState.update { it.copy(currentPlanAllowsSells = false) } + return + } + val planEntity = cachedDbPlans.firstOrNull { it.id == page.planId } + val allowSells = planEntity?.allowSells == true + _uiState.update { it.copy(currentPlanAllowsSells = allowSells) } + if (!allowSells) { + _openSells.value = emptyList() + return + } + openSellsJob = viewModelScope.launch { + transactionDao.observeOpenSellsForPlan(page.planId).collect { entities -> + _openSells.value = entities.map { it.toDomain() } + } + } + } + + /** + * Cancel an open limit-sell order for the currently visible plan. On failure + * a localized message is pushed to [snackbar] for the screen to display. + */ + fun cancelSell(txId: Long) { + viewModelScope.launch { + val result = cancelSellOrderUseCase(txId) + if (result.isFailure) { + _snackbar.emit( + "Zruseni orderu selhalo: ${result.exceptionOrNull()?.message ?: "neznama chyba"}" + ) + } + } } fun toggleDenomination() { @@ -412,6 +515,10 @@ class PortfolioViewModel @Inject constructor( } } + fun toggleLimitLinesVisibility() { + _uiState.update { state -> state.copy(limitLinesVisible = !state.limitLinesVisible) } + } + fun togglePlanLineVisibility(planId: Long, type: PlanLineType) { _uiState.update { state -> val key = planId to type @@ -536,7 +643,52 @@ class PortfolioViewModel @Inject constructor( cryptoGroupLines = chartResult.cryptoGroupLines, isChartLoading = false ) } + + // Sell-extension: compute realized P&L from SELL transactions and net P&L + // (currentValue + realized - invested). Gated by the global trading switch. + recomputeTradingMetrics(chartResult) + } + } + + /** + * Computes [PortfolioUiState.totalRealized] and [PortfolioUiState.netPnL] for + * the currently selected page. Reads SELL transactions from cache instead of + * re-querying because they're already in [completedTransactions] (the DAO + * pre-filter is BUY-only, so we look at the global `db` here via DAO). + */ + private suspend fun recomputeTradingMetrics(chartResult: ChartComputeResult) { + if (!_uiState.value.showTradingMetrics) { + _uiState.update { it.copy(totalRealized = null, netPnL = null) } + return } + val fiat = chartResult.fiat + if (fiat == null) { + _uiState.update { it.copy(totalRealized = null, netPnL = null) } + return + } + + val realized = try { + // Query SELL totals scoped to fiat. For per-plan pages we filter further + // via the page's planId; for aggregate pages we use everything in fiat. + val state = _uiState.value + val page = state.pages.getOrNull(state.selectedPageIndex) + val planId = (page as? PairPage.Plan)?.planId + + if (planId != null) { + BigDecimal(transactionDao.getRealizedFiatByPlan(planId)) + } else { + BigDecimal(transactionDao.getRealizedFiatByFiat(fiat)) + } + } catch (_: Exception) { + BigDecimal.ZERO + } + + val lastPoint = chartResult.data.lastOrNull() + val net = if (lastPoint != null) { + lastPoint.portfolioValue + realized - lastPoint.totalInvested + } else null + + _uiState.update { it.copy(totalRealized = realized, netPnL = net) } } private data class ChartComputeResult( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/components/OpenSellsCollapsibleSection.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/components/OpenSellsCollapsibleSection.kt new file mode 100644 index 0000000..fd052c4 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/components/OpenSellsCollapsibleSection.kt @@ -0,0 +1,101 @@ +package com.accbot.dca.presentation.screens.portfolio.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.accbot.dca.R +import com.accbot.dca.domain.model.Transaction +import com.accbot.dca.presentation.screens.plans.components.OpenSellRow + +/** + * Collapsible section showing open (PENDING / PARTIAL) sell orders for the + * currently selected plan on the Pozice (Portfolio) screen. Header is always + * visible; the order list is hidden by default and toggled by tapping the + * header. Hidden entirely when there are no open orders for the plan. + * + * Reuses [OpenSellRow] from the plan-detail screen so the cancel-confirmation + * dialog is shared between the two surfaces. + */ +@Composable +fun OpenSellsCollapsibleSection( + openSells: List, + onCancelClick: (Long) -> Unit, + modifier: Modifier = Modifier +) { + if (openSells.isEmpty()) return + + // Persist expand state across configuration changes (rotation) but reset on + // process death - matches the "transient UI" feel. + var expanded by rememberSaveable { mutableStateOf(false) } + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + // Tappable header + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource( + R.string.portfolio_open_sells_section_title, + openSells.size + ), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + AnimatedVisibility(visible = expanded) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + ) { + HorizontalDivider() + openSells.forEach { tx -> + OpenSellRow(tx = tx, onCancelClick = onCancelClick) + } + } + } + } + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/ui/theme/Theme.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/ui/theme/Theme.kt index fd2b45c..57e0f0e 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/ui/theme/Theme.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/ui/theme/Theme.kt @@ -41,8 +41,16 @@ val SandboxSuccess = Color(0xFFFFA726) private val DarkColorScheme = darkColorScheme( primary = Primary, onPrimary = OnPrimary, + primaryContainer = Color(0xFF276652), // Primary @ ~50% brightness, same mint hue + onPrimaryContainer = Color(0xFFB8E8D5), secondary = Secondary, onSecondary = OnSecondary, + secondaryContainer = Color(0xFF142B4D), // slightly darker variant of Secondary + onSecondaryContainer = Color(0xFFB0CCEE), + tertiary = Warning, + onTertiary = Color(0xFF1A1A2E), + tertiaryContainer = Color(0xFF553300), // dark amber for warning surfaces + onTertiaryContainer = Color(0xFFFFD194), background = Background, onBackground = OnBackground, surface = Surface, @@ -50,7 +58,9 @@ private val DarkColorScheme = darkColorScheme( surfaceVariant = SurfaceVariant, onSurfaceVariant = OnSurfaceVariant, error = Error, - onError = Color.White + onError = Color.White, + errorContainer = Color(0xFF601824), + onErrorContainer = Color(0xFFFFB4B4) ) private val LightColorScheme = lightColorScheme( @@ -84,8 +94,16 @@ private val LightColorScheme = lightColorScheme( private val SandboxDarkColorScheme = darkColorScheme( primary = SandboxPrimary, onPrimary = OnPrimary, + primaryContainer = Color(0xFF553300), // dark amber for sandbox primary + onPrimaryContainer = Color(0xFFFFD194), secondary = Secondary, onSecondary = OnSecondary, + secondaryContainer = Color(0xFF142B4D), + onSecondaryContainer = Color(0xFFB0CCEE), + tertiary = Warning, + onTertiary = Color(0xFF1A1A2E), + tertiaryContainer = Color(0xFF553300), + onTertiaryContainer = Color(0xFFFFD194), background = Background, onBackground = OnBackground, surface = Surface, @@ -93,7 +111,9 @@ private val SandboxDarkColorScheme = darkColorScheme( surfaceVariant = SurfaceVariant, onSurfaceVariant = OnSurfaceVariant, error = Error, - onError = Color.White + onError = Color.White, + errorContainer = Color(0xFF601824), + onErrorContainer = Color(0xFFFFB4B4) ) // Sandbox light color scheme (orange theme) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/service/NotificationService.kt b/accbot-android/app/src/main/java/com/accbot/dca/service/NotificationService.kt index dd66adb..34b1d4a 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/service/NotificationService.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/service/NotificationService.kt @@ -399,6 +399,56 @@ class NotificationService @Inject constructor( ) } + /** + * Show notification when a limit sell order is filled (PENDING/PARTIAL -> COMPLETED). + * Uses a unique ID per transaction so multiple ladder fills are all visible. + */ + suspend fun showSellFilledNotification( + crypto: String, + cryptoAmount: BigDecimal, + fiatAmount: BigDecimal, + fiat: String, + price: BigDecimal, + transactionId: Long = 0, + planId: Long = 0, + exchange: Exchange? = null, + connectionId: Long? = null + ) { + val args = NotificationTemplateArgs.SellFilled( + cryptoAmount = cryptoAmount.toPlainString(), + crypto = crypto, + fiatAmount = fiatAmount.toPlainString(), + fiat = fiat, + price = price.toPlainString() + ) + val (title, text) = NotificationRenderer.render(context, args) + val label = connectionLabel(connectionId, exchange) + val displayedTitle = if (!label.isNullOrBlank() && label != exchange?.displayName) { + "$label · $title" + } else title + + // Use transaction ID as the unique key so per-tier ladder fills don't collapse. + val keyForId = if (transactionId > 0) transactionId else planId + val sysNotifId = notificationIdForPlan(NOTIFICATION_ID_SELL_FILLED, keyForId) + persistAndShow( + sysNotifId = sysNotifId, + channel = CHANNEL_PURCHASE, + title = displayedTitle, + text = text, + entity = NotificationEntity( + type = NotificationType.SELL_FILLED, + title = displayedTitle, + message = text, + planId = planId.takeIf { it > 0 }, + crypto = crypto, + exchange = exchange, + connectionId = connectionId, + systemNotificationId = sysNotifId, + templateArgs = args.toJson() + ) + ) + } + /** * Cancel a specific system notification by its ID. */ @@ -466,6 +516,7 @@ class NotificationService @Inject constructor( private const val NOTIFICATION_ID_WITHDRAWAL_THRESHOLD = 40_000 private const val NOTIFICATION_ID_NETWORK_RETRY = 50_000 private const val NOTIFICATION_ID_MISSED_PURCHASES = 60_000 + private const val NOTIFICATION_ID_SELL_FILLED = 70_000 const val EXTRA_NOTIFICATION_ID = "extra_notification_id" diff --git a/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt b/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt index 93cb00a..0d5b1b2 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt @@ -12,9 +12,13 @@ import com.accbot.dca.data.local.TransactionEntity import com.accbot.dca.data.local.UserPreferences import com.accbot.dca.domain.model.DcaResult import com.accbot.dca.domain.model.DcaStrategy +import com.accbot.dca.domain.model.Transaction import com.accbot.dca.domain.model.TransactionStatus import com.accbot.dca.domain.util.CronUtils +import com.accbot.dca.domain.usecase.BuySafetyPolicy import com.accbot.dca.domain.usecase.CalculateStrategyMultiplierUseCase +import com.accbot.dca.domain.usecase.ReconcileRecentBuyUseCase +import com.accbot.dca.domain.usecase.ReconcileResult import com.accbot.dca.domain.usecase.ResolvePendingTransactionsUseCase import com.accbot.dca.exchange.ExchangeApi import com.accbot.dca.exchange.ExchangeApiFactory @@ -62,8 +66,19 @@ class DcaWorker @AssistedInject constructor( Log.w(TAG, "Failed to resolve pending transactions", e) } + // Catch-up progress is persisted in missedPurchaseCount so that a replayed worker + // (process death mid-loop -> WorkManager re-runs the whole request) resumes where + // it left off instead of re-buying already-covered slots from iteration 1. + val isCatchUp = forceRun && forcePlanId > 0 && repeatCount > 1 + val totalIterations = if (isCatchUp) { + val remaining = database.dcaPlanDao().getPlanById(forcePlanId)?.missedPurchaseCount ?: 0 + minOf(repeatCount, remaining) + } else { + repeatCount + } + try { - for (iteration in 1..repeatCount) { + for (iteration in 1..totalIterations) { if (iteration > 1) { Log.d(TAG, "Repeat iteration $iteration/$repeatCount") kotlinx.coroutines.delay(3_000L) // brief pause between missed purchases @@ -195,6 +210,32 @@ class DcaWorker @AssistedInject constructor( continue } + // Circuit breaker: if this plan has already bought far more than its schedule + // allows in the last 24h, something is wrong - auto-disable instead of continuing + // to spend. Counts reconciled buys too, so it reflects real exchange spend. + // Forced runs (run-now / catch-up) are user-initiated and may legitimately + // exceed the schedule, so they get a wider allowance (+repeatCount) instead + // of bypassing the breaker - a replayed or duplicated force run must still + // be bounded. + val buysLast24h = database.transactionDao() + .countCompletedBuysSinceSync(plan.id, now.minus(Duration.ofHours(24))) + val expectedPerDay = BuySafetyPolicy.expectedBuysPerDay(effectiveIntervalMinutes(plan)) + val allowedPerDay = if (forceRun) expectedPerDay + repeatCount else expectedPerDay + if (BuySafetyPolicy.isRunaway(buysLast24h, allowedPerDay)) { + Log.e(TAG, "Plan ${plan.id} runaway detected ($buysLast24h buys/24h, allowed ~$allowedPerDay) - auto-disabling") + database.dcaPlanDao().setEnabled(plan.id, false) + notificationService.showErrorNotification( + planId = plan.id, + exchange = plan.exchange, + connectionId = plan.connectionId, + templateArgs = NotificationTemplateArgs.Error( + crypto = plan.crypto, + errorMessage = context.getString(R.string.notification_runaway_disabled) + ) + ) + continue + } + // Atomically claim the plan to prevent double-purchase from concurrent workers. // claimPlanForExecutionSync advances nextExecutionAt only if it's still in the past // (or null), returning 0 if another worker already claimed it. @@ -208,17 +249,26 @@ class DcaWorker @AssistedInject constructor( Log.d(TAG, "Plan ${plan.id} claimed for execution, nextExecution advanced to $nextExec") } - // Execute DCA purchase with immediate retry + // Execute DCA purchase. A market buy is NOT idempotent: the order may be placed + // server-side even when the client sees a timeout/network error. So before EVER + // re-issuing a buy, reconcile against the exchange to see whether the order + // actually went through - this prevents the runaway duplicate-spend bug. val api = exchangeApiFactory.create(credentials) + val reconcileRecentBuy = ReconcileRecentBuyUseCase(database.transactionDao()) + val attemptStart = Instant.now() val maxAttempts = 3 val retryDelayMs = 2_000L val failedAttemptMessages = mutableListOf() var finalResult: DcaResult? = null + var reconcileUncertain = false - for (attempt in 1..maxAttempts) { - val attemptResult = withTimeoutOrNull(30_000L) { + attemptLoop@ for (attempt in 1..maxAttempts) { + // Kept strictly ABOVE OkHttp's callTimeout (30s) so this coroutine + // timeout only fires after OkHttp has already aborted the request - + // reconciliation must never run while the order POST is still in flight. + val attemptResult = withTimeoutOrNull(45_000L) { api.marketBuy(plan.crypto, plan.fiat, purchaseAmount) - } ?: DcaResult.Error("API call timed out after 30s", retryable = true) + } ?: DcaResult.Error("API call timed out after 45s", retryable = true) if (attemptResult is DcaResult.Success) { finalResult = attemptResult @@ -229,6 +279,46 @@ class DcaWorker @AssistedInject constructor( failedAttemptMessages.add("Attempt $attempt: ${error.message}") Log.w(TAG, "Plan ${plan.id} attempt $attempt/$maxAttempts failed: ${error.message}") + if (!error.retryable) { + // Business error (e.g. insufficient balance) - no order placed, don't retry. + finalResult = error + break + } + + // Ambiguous failure - did the order actually go through on the exchange? + when (val rec = reconcileRecentBuy(api, plan, attemptStart, purchaseAmount)) { + is ReconcileResult.Found -> { + Log.w(TAG, "Plan ${plan.id} buy timed out client-side but order ${rec.trade.orderId} exists on exchange - recording, not retrying") + finalResult = DcaResult.Success( + Transaction( + planId = plan.id, + exchange = plan.exchange, + crypto = plan.crypto, + fiat = plan.fiat, + fiatAmount = rec.trade.fiatAmount, + cryptoAmount = rec.trade.cryptoAmount, + price = rec.trade.price, + fee = rec.trade.fee, + feeAsset = rec.trade.feeAsset, + status = TransactionStatus.COMPLETED, + exchangeOrderId = rec.trade.orderId, + executedAt = rec.trade.timestamp + ) + ) + break@attemptLoop + } + ReconcileResult.Unknown -> { + // We don't know whether an order was placed - NEVER retry on uncertainty. + Log.w(TAG, "Plan ${plan.id} buy failed and reconciliation inconclusive - not retrying") + reconcileUncertain = true + finalResult = error + break@attemptLoop + } + ReconcileResult.NotFound -> { + // Confirmed: no order exists. Safe to retry. + } + } + if (attempt < maxAttempts) { kotlinx.coroutines.delay(retryDelayMs) } else { @@ -331,28 +421,51 @@ class DcaWorker @AssistedInject constructor( is DcaResult.Error -> { if (finalResult.retryable) { - // Network error – retry in 5 min and notify user. - // Override the claimed nextExecutionAt with an earlier retry time. + // We only reach here when reconciliation confirmed NO order exists + // (safe) or was inconclusive (uncertain). Bound how often we re-issue + // a market buy so a degraded network can never drain the account. try { - val retryTime = now.plus(Duration.ofMinutes(5)) - database.runInTransaction { - database.dcaPlanDao().updateExecutionTimeSync(plan.id, now, retryTime) - database.dcaPlanDao().incrementNetworkRetrySync(plan.id, retryTime, nextExecution ?: now) - } - Log.w(TAG, "Network error for plan ${plan.id}, will retry at $retryTime: ${finalResult.message}") - - // Only notify on first failure, not on subsequent retries - if (plan.networkRetryCount == 0) { - notificationService.showNetworkRetryNotification( - crypto = plan.crypto, - exchangeName = plan.exchange.displayName, - errorMessage = finalResult.message, - nextRetryAt = retryTime, - attemptCount = 1, + val capReached = !BuySafetyPolicy.shouldRetryAfterConfirmedFailure(plan.networkRetryCount) + if (reconcileUncertain || capReached) { + // Stop hammering: advance to the next normal slot and reset. + // A later run / trade-history import records the order if it + // actually went through. + database.runInTransaction { + database.dcaPlanDao().updateExecutionTimeSync(plan.id, now, calculateNextExecution(plan, now)) + database.dcaPlanDao().resetNetworkRetrySync(plan.id) + } + Log.w(TAG, "Plan ${plan.id} giving up this slot (uncertain=$reconcileUncertain, capReached=$capReached): ${finalResult.message}") + notificationService.showErrorNotification( planId = plan.id, exchange = plan.exchange, - connectionId = plan.connectionId + connectionId = plan.connectionId, + templateArgs = NotificationTemplateArgs.Error( + crypto = plan.crypto, + errorMessage = finalResult.message + ) ) + } else { + // Confirmed-failed network buy under the retry cap: retry in 5 min. + val retryTime = now.plus(Duration.ofMinutes(5)) + database.runInTransaction { + database.dcaPlanDao().updateExecutionTimeSync(plan.id, now, retryTime) + database.dcaPlanDao().incrementNetworkRetrySync(plan.id, retryTime, nextExecution ?: now) + } + Log.w(TAG, "Network error for plan ${plan.id}, will retry at $retryTime: ${finalResult.message}") + + // Only notify on first failure, not on subsequent retries + if (plan.networkRetryCount == 0) { + notificationService.showNetworkRetryNotification( + crypto = plan.crypto, + exchangeName = plan.exchange.displayName, + errorMessage = finalResult.message, + nextRetryAt = retryTime, + attemptCount = 1, + planId = plan.id, + exchange = plan.exchange, + connectionId = plan.connectionId + ) + } } } catch (e: Exception) { Log.e(TAG, "Failed to update retry time for plan ${plan.id}", e) @@ -408,18 +521,34 @@ class DcaWorker @AssistedInject constructor( } } } + + // Consume one persisted catch-up slot per finished iteration (see isCatchUp + // above) so a replayed worker continues instead of starting over. + if (isCatchUp) { + database.dcaPlanDao().decrementMissedPurchaseCount(forcePlanId) + } } // repeat loop // Re-arm alarm for next execution (self-perpetuating chain) DcaAlarmScheduler.scheduleNextAlarm(context) return Result.success() + } catch (ce: kotlinx.coroutines.CancellationException) { + // Worker was cancelled (e.g. system reclaimed it mid-run). This is not an error - + // don't show a scary "Job was cancelled" notification. Let WorkManager reschedule. + Log.d(TAG, "DcaWorker cancelled", ce) + try { DcaAlarmScheduler.scheduleNextAlarm(context) } catch (_: Exception) {} + throw ce } catch (e: Exception) { Log.e(TAG, "DcaWorker error", e) notificationService.showErrorNotification(context.getString(R.string.notification_dca_error), e.message ?: "Unknown error") // Still try to re-arm alarm even on error try { DcaAlarmScheduler.scheduleNextAlarm(context) } catch (_: Exception) {} - return Result.retry() + // Forced runs bypass the per-plan claim and due-time guards, so an automatic + // WorkManager retry could re-buy plans that already bought in this run. They + // are user-initiated - fail instead; the user sees the error notification + // and can trigger the run again. + return if (forceRun) Result.failure() else Result.retry() } } @@ -450,6 +579,14 @@ class DcaWorker @AssistedInject constructor( } } + /** Best-effort minutes between executions, used by the runaway circuit breaker. */ + private fun effectiveIntervalMinutes(plan: DcaPlanEntity): Long = + if (plan.cronExpression != null) { + CronUtils.getIntervalMinutesEstimate(plan.cronExpression) ?: 1440L + } else { + plan.frequency.intervalMinutes + } + private suspend fun checkWithdrawalThreshold(plan: DcaPlanEntity, api: ExchangeApi) { try { // Per-connection threshold lookup; the plan carries connectionId since migration v18→v19. @@ -500,6 +637,14 @@ class DcaWorker @AssistedInject constructor( private const val KEY_REPEAT_COUNT = "repeatCount" const val WORK_NAME = "dca_periodic_work" + /** + * Single unique-work queue for ALL user-initiated (forceRun) executions. + * Forced runs bypass the per-plan claim, so they must never run concurrently - + * APPEND_OR_REPLACE serializes them one after another (and replaces a failed + * chain instead of blocking future runs). + */ + private const val FORCE_WORK_NAME = "dca_force_work" + /** * Plan IDs we've already shown a "missing credentials" notification for in * this process lifetime. Prevents spamming the notification tray on every @@ -558,7 +703,7 @@ class DcaWorker @AssistedInject constructor( .build() WorkManager.getInstance(context) - .enqueue(oneTimeWorkRequest) + .enqueueUniqueWork(FORCE_WORK_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, oneTimeWorkRequest) Log.d(TAG, "DCA one-time work enqueued (forceRun=true)") } @@ -582,7 +727,7 @@ class DcaWorker @AssistedInject constructor( .build() WorkManager.getInstance(context) - .enqueue(oneTimeWorkRequest) + .enqueueUniqueWork(FORCE_WORK_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, oneTimeWorkRequest) Log.d(TAG, "DCA one-time work enqueued for plan $planId (forceRun=true)") } @@ -607,7 +752,7 @@ class DcaWorker @AssistedInject constructor( .build() WorkManager.getInstance(context) - .enqueue(oneTimeWorkRequest) + .enqueueUniqueWork(FORCE_WORK_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, oneTimeWorkRequest) Log.d(TAG, "Missed purchases enqueued for plan $planId (count=$count)") } @@ -635,7 +780,10 @@ class DcaWorker @AssistedInject constructor( .build() WorkManager.getInstance(context) - .enqueueUniqueWork(ALARM_WORK_NAME, ExistingWorkPolicy.REPLACE, oneTimeWorkRequest) + // KEEP, never REPLACE: a re-fired alarm must not cancel a worker that may + // be mid-buy - REPLACE could abort it after the order POST was sent, + // leaving a real order unrecorded (invisible to the runaway breaker). + .enqueueUniqueWork(ALARM_WORK_NAME, ExistingWorkPolicy.KEEP, oneTimeWorkRequest) Log.d(TAG, "DCA alarm-triggered work enqueued (unique=$ALARM_WORK_NAME)") } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/worker/SellPollingScheduler.kt b/accbot-android/app/src/main/java/com/accbot/dca/worker/SellPollingScheduler.kt new file mode 100644 index 0000000..c7a4ac8 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/worker/SellPollingScheduler.kt @@ -0,0 +1,85 @@ +package com.accbot.dca.worker + +import android.util.Log +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.domain.model.DcaFrequency +import com.accbot.dca.domain.util.CronUtils +import java.time.Duration +import java.time.Instant +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Schedules the self-perpetuating chain of [SellPollingWorker] runs. + * + * Each worker run calls [rescheduleIfEnabled] to enqueue the next one, which + * means the only way to stop polling is either [cancel] or toggling off the + * user preference. Using one-shot work with [ExistingWorkPolicy.REPLACE] gives + * us arbitrary cron-based intervals (and intervals shorter than WorkManager's + * 15-minute periodic minimum, e.g. EVERY_15_MIN still works, but anything + * tighter is also supported if a cron is provided). + */ +@Singleton +class SellPollingScheduler @Inject constructor( + private val workManager: WorkManager, + private val userPreferences: UserPreferences +) { + /** + * Enqueue the next poll if the user has polling enabled; otherwise cancel + * any outstanding chain. Safe to call from worker completion, app startup, + * and settings-change listeners. + */ + fun rescheduleIfEnabled() { + if (!userPreferences.isPeriodicSellPollingEnabled()) { + cancel() + return + } + + val frequency = userPreferences.getSellPollingFrequency() + val cron = userPreferences.getSellPollingCronExpression() + val now = Instant.now() + + val nextFire: Instant = when { + frequency == DcaFrequency.CUSTOM && cron != null -> { + CronUtils.getNextExecution(cron, now) + ?: now.plus(Duration.ofMinutes(60)) + } + else -> now.plus(Duration.ofMinutes(frequency.intervalMinutes)) + } + + val delayMs = (nextFire.toEpochMilli() - System.currentTimeMillis()).coerceAtLeast(0L) + + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(delayMs, TimeUnit.MILLISECONDS) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .addTag(SellPollingWorker.WORK_NAME) + .build() + + workManager.enqueueUniqueWork( + SellPollingWorker.WORK_NAME, + ExistingWorkPolicy.REPLACE, + request + ) + + Log.d(TAG, "SellPollingWorker scheduled for $nextFire (in ${delayMs}ms)") + } + + fun cancel() { + workManager.cancelUniqueWork(SellPollingWorker.WORK_NAME) + Log.d(TAG, "SellPollingWorker cancelled") + } + + companion object { + private const val TAG = "SellPollingScheduler" + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/worker/SellPollingWorker.kt b/accbot-android/app/src/main/java/com/accbot/dca/worker/SellPollingWorker.kt new file mode 100644 index 0000000..d7ce57a --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/worker/SellPollingWorker.kt @@ -0,0 +1,54 @@ +package com.accbot.dca.worker + +import android.content.Context +import android.util.Log +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.domain.usecase.ResolvePendingTransactionsUseCase +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +/** + * Periodic background worker that resolves PENDING sell orders by polling the + * exchange API. Scheduled/rescheduled by [SellPollingScheduler] using the + * user-configured frequency (preset or cron). + * + * The worker is a no-op (other than rescheduling) when there are no open sells, + * keeping battery impact minimal when trading is idle. + */ +@HiltWorker +class SellPollingWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted params: WorkerParameters, + private val database: DcaDatabase, + private val resolvePendingTransactionsUseCase: ResolvePendingTransactionsUseCase, + private val sellPollingScheduler: SellPollingScheduler +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + return try { + val openSells = database.transactionDao().countOpenSells() + if (openSells > 0) { + Log.d(TAG, "Resolving $openSells open sell(s)") + val resolved = resolvePendingTransactionsUseCase() + Log.d(TAG, "Resolved $resolved transaction(s)") + } else { + Log.d(TAG, "No open sells, skipping resolve") + } + sellPollingScheduler.rescheduleIfEnabled() + Result.success() + } catch (e: Exception) { + Log.w(TAG, "SellPollingWorker error", e) + // Always re-arm the chain so a transient failure doesn't stop polling forever. + try { sellPollingScheduler.rescheduleIfEnabled() } catch (_: Exception) {} + Result.retry() + } + } + + companion object { + private const val TAG = "SellPollingWorker" + const val WORK_NAME = "sell_polling" + } +} diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index 53d9ff2..6a43a26 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -37,7 +37,7 @@ Přehled - Portfolio + Pozice Oznámení Nastavení @@ -233,6 +233,7 @@ Vymazat vše Filtrovat transakce Kryptoměna + Plán Burza Stav %1$d transakcí @@ -256,6 +257,16 @@ Do Hledat transakce… Vybrat datum + Vše + Nákupy + Prodeje + Čekající + Nákup + Prodej + + + %1$s: %2$d otevřených prodejních příkazů + %1$d otevřených prodejních příkazů Vytvořit DCA plán @@ -313,6 +324,9 @@ Zadejte %1$d pro potvrzení Smazat všechny transakce %1$d transakcí smazáno + + Vytvořit prodejní příkaz + Nelze smazat plán + Tento plán má %1$d otevřených prodejních příkazů. Zruš je nejdřív, než plán smažeš. Upravit plán @@ -322,10 +336,14 @@ Uložit změny Zadejte adresu vaší %1$s peněženky - - Portfolio + + Pozice Transakce - Načítání portfolia… + Načítání pozic… + Realizováno + Čistý P&L + Otevřené prodejní příkazy (%1$d) + Nový prodejní příkaz Všechny páry @@ -360,6 +378,7 @@ Akum. %1$s Prům. nákupní cena Prozkoumat historii: + Limitní prodej Burzy @@ -428,9 +447,17 @@ Zkopírováno do schránky Dokončeno Selhalo + Zrušeno Čekající Částečně vyplněno v + Prodejní příkaz + Limitní cena + Vyplněno + Průměrná cena plnění + Zrušit příkaz + Zrušit příkaz? + Opravdu zrušit limitní prodej? Client ID @@ -585,6 +612,7 @@ DCA selhalo Nepodařilo se nakoupit %1$s: %2$s DCA chyba + Plán pozastaven: zjištěno neobvykle mnoho nákupů. Zkontroluj účet a po vyřešení plán znovu zapni. Nízký zůstatek na %1$s %1$s %2$s zbývá pro DCA < 1 den @@ -926,4 +954,107 @@ Opakovat + + + POKROČILÉ + Povolit prodeje + Umožní u vybraných plánů zadávat limitní prodejní příkazy a sledovat P&L. + Kontrolovat prodejní příkazy na pozadí + Periodická kontrola stavu příkazů. Zvyšuje spotřebu baterie. + Frekvence + Vlastní plán (CRON) + např. 0 *\/2 * * * + Časté kontroly zvyšují spotřebu baterie a počítají se do API limitů burzy. + + + Prodeje (volitelné) + Povolit prodeje pro tento plán + Cíl zisku (volitelné, v %1$s) + Detail plánu zobrazí progress bar k tomuto cíli. + Zadej platné číslo + Cíl musí být kladný + + + Vypnout prodeje? + Máš %1$d otevřených prodejních příkazů. Vypnutím prodejů se skryje sekce prodejů, ale příkazy na burze zůstávají. Musíš je zrušit ručně přes burzu, nebo zapnutím prodejů a zrušením příkazů. + Vypnout + + + Limitní prodej %1$s/%2$s + Aktuální cena: + Průměrný nákup: + K dispozici: + Množství k prodeji + Vše + Limitní cena + Tržní + Souhrn + Získáte: + Prodej proběhne okamžitě. Limitní cena je pod aktuální tržní (%1$s %2$s). Příkaz se vyplní ihned za nejvyšší nabídku na burze (obvykle blízko tržní ceny minus spread). Není to chyba. + Cena je vysoko nad trhem - prodej se nemusí vyplnit dlouho. + Pokračovat + Potvrdit prodej + Burza: + Plán: + Směr: + PRODEJ + Množství: + Limitní cena: + Získáte: + Tato akce odešle příkaz na %1$s a nelze ji vrátit. Příkaz lze poté zrušit, dokud není částečně nebo celý vyplněn. + Zpět + Odeslat + Nelze ověřit stav příkazu + Spojení s burzou selhalo nebo vypršelo. Příkaz mohl být odeslán, ale nelze to potvrdit. Zkontroluj otevřené příkazy na burze přes web a v případě potřeby zruš duplicitu. + Spočítáno z plánu + Zadáno ručně + Zatím žádné nákupy (nebo vše prodáno) - zadej ručně + Spočítat z plánu + Čistý výnos (po fee) + Zisk z této transakce: + Odhad fee + Čistý zisk (po fee) + Po tomto prodeji + Postup k cíli plánu + Prodáváš pod nákupní cenou: ztráta %1$s %2$s + Po fee jdeš do ztráty: %1$s %2$s + Inventář nesedí (chybí %1$s) - zadej průměrnou nákupní cenu ručně + Rozdělit na více příkazů + Od + Do + Počet příkazů + Cena + Zisk % + Stejné množství + Stejný výnos + Celkem při plném vyplnění + Vytvořeno všech %1$d příkazů + Vytvořeno %1$d z %2$d příkazů. Zastaveno: %3$s + Výsledek + Zisk + Výnos + Otevřené prodejní příkazy (%1$d) + Částečně: %1$s %% (%2$s / %3$s) + Čeká na vyplnění + Zrušit příkaz + Zrušit příkaz? + Opravdu zrušit limitní prodej %1$s %2$s @ %3$s %4$s? + Zpět + Zrušit vše + Zrušit všechny příkazy? + Opravdu zrušit všechny otevřené prodejní příkazy (%1$d)? Akci nelze vrátit. + Množství musí být větší než 0 + Limitní cena musí být větší než 0 + Minimální hodnota orderu je %1$s (zvyš množství nebo cenu) + Nemáš tolik k dispozici (k dispozici %1$s) + Pro profit %% musí být zadaná průměrná nákupní cena + Množství musí být větší než 0 + Počet orderů musí být alespoň 2 + Do musí být větší než Od (oba kladné) + Nemáš tolik k dispozici + Hodnota nejmenšího orderu (%1$s %2$s) je pod minimem %3$s %2$s + = %1$s %% z dostupných + Neznámá chyba + Prodej proveden ✓ + Prodáno %1$s %2$s za %3$s %4$s @ %5$s %4$s diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 3eb68f2..76400d8 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -39,7 +39,7 @@ Dashboard - Portfolio + Positions Notifications Settings @@ -232,6 +232,7 @@ Clear all Filter Transactions Cryptocurrency + Plan Exchange Status %1$d transaction(s) @@ -255,6 +256,16 @@ To Search transactions… Select date + All + Buys + Sells + Pending + Buy + Sell + + + %1$s: %2$d open sell order(s) + %1$d open sell order(s) Create DCA Plan @@ -312,6 +323,9 @@ Type %1$d to confirm Delete All Transactions %1$d transactions deleted + + Create sell order + Cannot delete plan + This plan has %1$d open sell order(s). Cancel them first before deleting the plan. Edit Plan @@ -321,10 +335,14 @@ Save Changes Enter your %1$s wallet address - - Portfolio + + Positions Transactions - Loading portfolio… + Loading positions… + Realized + Net P&L + Open sell orders (%1$d) + New sell order All Pairs @@ -359,6 +377,7 @@ Accum. %1$s Avg Buy Price Explore history: + Limit sell Exchanges @@ -427,9 +446,17 @@ Copied to clipboard Completed Failed + Cancelled Pending Partially Filled at + Sell order + Limit price + Filled + Avg fill price + Cancel order + Cancel order? + Are you sure you want to cancel this limit sell order? Client ID @@ -584,6 +611,7 @@ DCA Failed Failed to buy %1$s: %2$s DCA Error + Plan paused: an unusually high number of purchases was detected. Check your account and re-enable once resolved. Low balance on %1$s %1$s of %2$s remaining for DCA < 1 day @@ -920,4 +948,107 @@ Retry + + + ADVANCED + Enable sell orders + Allow selected plans to place limit sell orders and track P&L. + Check sell orders in background + Periodically check order status. Increases battery usage. + Frequency + Custom schedule (CRON) + e.g. 0 *\/2 * * * + Frequent checks increase battery usage and count against exchange API limits. + + + Sells (optional) + Enable sells for this plan + Profit target (optional, in %1$s) + Plan detail shows a progress bar toward this target. + Enter a valid number + Target must be positive + + + Turn off sells? + You have %1$d open sell order(s). Turning sells off will hide the sell section, but the orders remain on the exchange. You must cancel them manually on the exchange, or by turning sells back on and clicking Cancel. + Turn off + + + Limit sell %1$s/%2$s + Current price: + Avg buy price: + Available: + Amount to sell + All + Limit price + Market + Summary + Proceeds: + The sell will execute immediately. The limit price is below the current market price (%1$s %2$s). The order will fill at the highest bid on the exchange (usually close to market price minus spread). This is not an error. + The price is far above market - the order may take a long time to fill. + Continue + Confirm sell + Exchange: + Plan: + Side: + SELL + Amount: + Limit price: + Proceeds: + This action will submit an order to %1$s and cannot be undone. The order can be cancelled later, as long as it has not been partially or fully filled. + Back + Submit + Cannot verify order status + Connection to the exchange failed or timed out. The order may have been submitted, but cannot be confirmed. Check open orders on the exchange via web and cancel any duplicates if needed. + Auto-calculated from this plan + Manually entered + No buys yet (or all sold) - enter manually + Calc from plan + Net proceeds (after fee) + Profit on this transaction: + Estimated fee + Net profit (after fee) + After this sell + Plan target progress + Selling below buy price: %1$s %2$s loss + After fee, this is a loss: %1$s %2$s + Inventory mismatch (%1$s missing) - enter avg buy price manually + Split into multiple orders + From + To + Number of orders + Price + Profit % + Equal crypto + Equal fiat + Total at full fill + All %1$d orders placed + Placed %1$d of %2$d. Stopped: %3$s + Result + Profit + Net + Open sell orders (%1$d) + Partial: %1$s %% (%2$s / %3$s) + Waiting for fill + Cancel order + Cancel order? + Cancel limit sell %1$s %2$s @ %3$s %4$s? + Back + Cancel all + Cancel all orders? + Cancel all %1$d open sell orders? This cannot be undone. + Amount must be greater than 0 + Limit price must be greater than 0 + Minimum order value is %1$s (raise amount or price) + Not enough available (available %1$s) + Profit %% requires the average buy price to be set + Amount must be greater than 0 + Order count must be at least 2 + To must be greater than From (both positive) + Not enough available + Smallest order (%1$s %2$s) is below the minimum %3$s %2$s + = %1$s %% of available + Unknown error + Sell filled ✓ + Sold %1$s %2$s for %3$s %4$s @ %5$s %4$s diff --git a/accbot-android/app/src/test/java/com/accbot/dca/data/local/FilteredTransactionsByPlanTest.kt b/accbot-android/app/src/test/java/com/accbot/dca/data/local/FilteredTransactionsByPlanTest.kt new file mode 100644 index 0000000..75f6b54 --- /dev/null +++ b/accbot-android/app/src/test/java/com/accbot/dca/data/local/FilteredTransactionsByPlanTest.kt @@ -0,0 +1,46 @@ +package com.accbot.dca.data.local + +import com.accbot.dca.domain.model.Exchange +import com.accbot.dca.domain.model.TransactionStatus +import com.accbot.dca.testing.buildInMemoryDb +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.math.BigDecimal +import java.time.Instant + +@RunWith(RobolectricTestRunner::class) +class FilteredTransactionsByPlanTest { + + private lateinit var db: DcaDatabase + + @Before fun setUp() { db = buildInMemoryDb() } + @After fun tearDown() = db.close() + + private suspend fun tx(planId: Long) = db.transactionDao().insertTransaction( + TransactionEntity( + planId = planId, exchange = Exchange.COINMATE, connectionId = planId, + crypto = "BTC", fiat = "CZK", fiatAmount = BigDecimal("50"), + cryptoAmount = BigDecimal("0.00003"), price = BigDecimal("1500000"), + fee = BigDecimal("0.17"), status = TransactionStatus.COMPLETED, + exchangeOrderId = "o$planId-${System.nanoTime()}", executedAt = Instant.now() + ) + ) + + @Test + fun `filters transactions by planId, leaving other plans out`() = runTest { + tx(2); tx(2); tx(4) // two BTC/CZK plans share the same pair + + val all = db.transactionDao().getFilteredTransactions(null, null, null, null).first() + val onlyPlan2 = db.transactionDao().getFilteredTransactions(null, null, null, 2L).first() + + assertEquals(3, all.size) + assertEquals(2, onlyPlan2.size) + assertEquals(setOf(2L), onlyPlan2.map { it.planId }.toSet()) + } +} diff --git a/accbot-android/app/src/test/java/com/accbot/dca/data/local/Migration21To22Test.kt b/accbot-android/app/src/test/java/com/accbot/dca/data/local/Migration21To22Test.kt new file mode 100644 index 0000000..c7863ff --- /dev/null +++ b/accbot-android/app/src/test/java/com/accbot/dca/data/local/Migration21To22Test.kt @@ -0,0 +1,67 @@ +package com.accbot.dca.data.local + +import com.accbot.dca.domain.model.DcaFrequency +import com.accbot.dca.domain.model.Exchange +import com.accbot.dca.domain.model.TransactionStatus +import com.accbot.dca.testing.buildInMemoryDb +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.math.BigDecimal +import java.time.Instant + +/** + * Verifies the backfill SQL in MIGRATION_21_22: NULL connectionId rows inherit the plan's + * connection, but only when the plan actually has one (> 0). + */ +@RunWith(RobolectricTestRunner::class) +class Migration21To22Test { + + private lateinit var db: DcaDatabase + + @Before + fun setUp() { + db = buildInMemoryDb() + } + + @After + fun tearDown() = db.close() + + private suspend fun tx(planId: Long, orderId: String) = db.transactionDao().insertTransaction( + TransactionEntity( + planId = planId, exchange = Exchange.COINMATE, connectionId = null, + crypto = "BTC", fiat = "CZK", fiatAmount = BigDecimal("50"), + cryptoAmount = BigDecimal("0.00003"), price = BigDecimal("1500000"), + fee = BigDecimal("0.17"), status = TransactionStatus.COMPLETED, + exchangeOrderId = orderId, executedAt = Instant.now() + ) + ) + + @Test + fun `backfills connectionId from plan, leaves rows without a real connection NULL`() = runTest { + val planWithConn = db.dcaPlanDao().insertPlan( + DcaPlanEntity( + exchange = Exchange.COINMATE, connectionId = 6, crypto = "BTC", fiat = "CZK", + amount = BigDecimal("50"), frequency = DcaFrequency.EVERY_8_HOURS + ) + ) + val planNoConn = db.dcaPlanDao().insertPlan( + DcaPlanEntity( + exchange = Exchange.COINMATE, connectionId = 0, crypto = "BTC", fiat = "CZK", + amount = BigDecimal("50"), frequency = DcaFrequency.EVERY_8_HOURS + ) + ) + tx(planWithConn, "orphan-6") + tx(planNoConn, "orphan-0") + + DcaDatabase.MIGRATION_21_22.migrate(db.openHelper.writableDatabase) + + assertEquals(6L, db.transactionDao().getByExchangeOrderId("orphan-6")?.connectionId) + assertNull(db.transactionDao().getByExchangeOrderId("orphan-0")?.connectionId) + } +} diff --git a/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/BuySafetyPolicyTest.kt b/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/BuySafetyPolicyTest.kt new file mode 100644 index 0000000..4d383ec --- /dev/null +++ b/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/BuySafetyPolicyTest.kt @@ -0,0 +1,49 @@ +package com.accbot.dca.domain.usecase + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pure-logic guards that bound how often a single plan can fire. No Android deps. + */ +class BuySafetyPolicyTest { + + @Test + fun `retries soon while under the network-retry cap`() { + assertTrue(BuySafetyPolicy.shouldRetryAfterConfirmedFailure(0)) + assertTrue(BuySafetyPolicy.shouldRetryAfterConfirmedFailure(1)) + assertTrue(BuySafetyPolicy.shouldRetryAfterConfirmedFailure(2)) + } + + @Test + fun `stops the 5-minute retry loop once the cap is reached`() { + assertFalse(BuySafetyPolicy.shouldRetryAfterConfirmedFailure(BuySafetyPolicy.MAX_NETWORK_RETRIES)) + assertFalse(BuySafetyPolicy.shouldRetryAfterConfirmedFailure(99)) + } + + @Test + fun `circuit breaker flags a plan that bought far more than expected`() { + // incident: every-8h plan (3/day) executed 53 times in under 2h + assertTrue(BuySafetyPolicy.isRunaway(buysLast24h = 53, expectedBuysPerDay = 3)) + } + + @Test + fun `circuit breaker tolerates normal cadence and modest catch-up`() { + assertFalse(BuySafetyPolicy.isRunaway(buysLast24h = 3, expectedBuysPerDay = 3)) + assertFalse(BuySafetyPolicy.isRunaway(buysLast24h = 6, expectedBuysPerDay = 3)) + } + + @Test + fun `circuit breaker trips just above twice the expected daily count`() { + assertTrue(BuySafetyPolicy.isRunaway(buysLast24h = 7, expectedBuysPerDay = 3)) + } + + @Test + fun `expected buys per day derived from interval minutes`() { + assertEquals(3, BuySafetyPolicy.expectedBuysPerDay(480)) // every 8h + assertEquals(1, BuySafetyPolicy.expectedBuysPerDay(1440)) // daily + assertEquals(1, BuySafetyPolicy.expectedBuysPerDay(0)) // guard against div-by-zero + } +} diff --git a/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCaseTest.kt b/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCaseTest.kt new file mode 100644 index 0000000..b59a95e --- /dev/null +++ b/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCaseTest.kt @@ -0,0 +1,180 @@ +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.TransactionEntity +import com.accbot.dca.domain.model.Exchange +import com.accbot.dca.domain.model.TransactionSide +import com.accbot.dca.domain.model.TransactionStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.math.BigDecimal +import java.time.Instant + +class CalculatePlanCostBasisUseCaseTest { + + private val t0: Instant = Instant.parse("2026-01-01T00:00:00Z") + private fun ts(daysOffset: Long): Instant = t0.plusSeconds(daysOffset * 86_400) + + private fun buy( + id: Long, + crypto: BigDecimal, + price: BigDecimal, + executedAt: Instant, + status: TransactionStatus = TransactionStatus.COMPLETED + ): TransactionEntity = TransactionEntity( + id = id, + planId = 1, + connectionId = 1, + exchange = Exchange.COINMATE, + crypto = "BTC", + fiat = "CZK", + fiatAmount = crypto * price, + cryptoAmount = crypto, + price = price, + fee = BigDecimal.ZERO, + feeAsset = "CZK", + status = status, + exchangeOrderId = "buy-$id", + executedAt = executedAt, + side = TransactionSide.BUY + ) + + private fun sell( + id: Long, + crypto: BigDecimal, + price: BigDecimal, + executedAt: Instant, + status: TransactionStatus = TransactionStatus.COMPLETED, + requested: BigDecimal? = null + ): TransactionEntity = TransactionEntity( + id = id, + planId = 1, + connectionId = 1, + exchange = Exchange.COINMATE, + crypto = "BTC", + fiat = "CZK", + fiatAmount = crypto * price, + cryptoAmount = crypto, + price = price, + fee = BigDecimal.ZERO, + feeAsset = "CZK", + status = status, + exchangeOrderId = "sell-$id", + executedAt = executedAt, + side = TransactionSide.SELL, + requestedCryptoAmount = requested ?: crypto, + limitPrice = price + ) + + @Test + fun `prazdny plan vraci available nula a avg null`() { + val result = CalculatePlanCostBasisUseCase.computeCostBasis(emptyList()) + assertEquals(0, BigDecimal.ZERO.compareTo(result.available)) + assertNull(result.weightedAvgPrice) + assertTrue(result.perBuyDetail.isEmpty()) + assertEquals(0, BigDecimal.ZERO.compareTo(result.deficit)) + } + + @Test + fun `jeden buy bez sells - available a avg jsou z buyu`() { + val txs = listOf(buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0))) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + assertEquals(0, BigDecimal("1").compareTo(result.available)) + assertEquals(0, BigDecimal("1000000").compareTo(result.weightedAvgPrice!!)) + } + + @Test + fun `dva buys, sell konzumuje cheapest first`() { + val txs = listOf( + buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0)), + buy(2, BigDecimal("1"), BigDecimal("2000000"), ts(1)), + sell(3, BigDecimal("0.5"), BigDecimal("2500000"), ts(2)) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + // 0.5 BTC zkonzumovano z 1M buyu, zbyva 0.5 BTC @ 1M + 1 BTC @ 2M + assertEquals(0, BigDecimal("1.5").compareTo(result.available)) + // weighted avg = (0.5 * 1M + 1 * 2M) / 1.5 = ~1666666.67 + val expected = BigDecimal("1666666.66666667") + assertEquals(0, expected.compareTo(result.weightedAvgPrice!!)) + } + + @Test + fun `novy levny buy po sellu neovlivni avg pro driv prodane`() { + val txs = listOf( + buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0)), + sell(2, BigDecimal("0.5"), BigDecimal("2000000"), ts(1)), + buy(3, BigDecimal("0.5"), BigDecimal("800000"), ts(2)) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + // Sell @ts(1) sees only buy 1. Consumes 0.5 from 1M. + // Remaining: 0.5 @ 1M + 0.5 @ 800k = avg (500k + 400k) / 1.0 = 900k + assertEquals(0, BigDecimal("1.0").compareTo(result.available)) + assertEquals(0, BigDecimal("900000").compareTo(result.weightedAvgPrice!!)) + } + + @Test + fun `PENDING sell rezervuje cheapest`() { + val txs = listOf( + buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0)), + sell( + 2, BigDecimal.ZERO, BigDecimal("3000000"), ts(1), + status = TransactionStatus.PENDING, requested = BigDecimal("0.5") + ) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + assertEquals(0, BigDecimal("0.5").compareTo(result.available)) + assertEquals(0, BigDecimal("1000000").compareTo(result.weightedAvgPrice!!)) + } + + @Test + fun `PARTIAL sell pouziva requested ne filled`() { + val txs = listOf( + buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0)), + sell( + 2, BigDecimal("0.2"), BigDecimal("3000000"), ts(1), + status = TransactionStatus.PARTIAL, requested = BigDecimal("0.5") + ) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + assertEquals(0, BigDecimal("0.5").compareTo(result.available)) + } + + @Test + fun `FAILED sell se ignoruje`() { + val txs = listOf( + buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0)), + sell( + 2, BigDecimal("0.5"), BigDecimal("3000000"), ts(1), + status = TransactionStatus.FAILED + ) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + assertEquals(0, BigDecimal("1").compareTo(result.available)) + } + + @Test + fun `negative inventory - sells presahly buys, deficit non-zero`() { + val txs = listOf( + buy(1, BigDecimal("0.5"), BigDecimal("1000000"), ts(0)), + sell(2, BigDecimal("1"), BigDecimal("2000000"), ts(1)) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + assertEquals(0, BigDecimal.ZERO.compareTo(result.available)) + assertNull(result.weightedAvgPrice) + assertEquals(0, BigDecimal("0.5").compareTo(result.deficit)) + } + + @Test + fun `tie na cene - starsi executedAt napred`() { + val txs = listOf( + buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0)), + buy(2, BigDecimal("1"), BigDecimal("1000000"), ts(1)), + sell(3, BigDecimal("0.5"), BigDecimal("2000000"), ts(2)) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + assertEquals(0, BigDecimal("1.5").compareTo(result.available)) + val cheap1 = result.perBuyDetail.firstOrNull { it.transactionId == 1L } + assertEquals(0, BigDecimal("0.5").compareTo(cheap1?.remaining ?: BigDecimal.ZERO)) + } +} diff --git a/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ImportTradeHistoryConnectionIdTest.kt b/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ImportTradeHistoryConnectionIdTest.kt new file mode 100644 index 0000000..f363add --- /dev/null +++ b/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ImportTradeHistoryConnectionIdTest.kt @@ -0,0 +1,65 @@ +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.DcaPlanEntity +import com.accbot.dca.domain.model.DcaFrequency +import com.accbot.dca.domain.model.Exchange +import com.accbot.dca.domain.model.HistoricalTrade +import com.accbot.dca.domain.model.TradeHistoryPage +import com.accbot.dca.testing.FakeExchangeApi +import com.accbot.dca.testing.buildInMemoryDb +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.math.BigDecimal +import java.time.Instant + +@RunWith(RobolectricTestRunner::class) +class ImportTradeHistoryConnectionIdTest { + + private lateinit var db: DcaDatabase + private lateinit var useCase: ImportTradeHistoryUseCase + + @Before + fun setUp() { + db = buildInMemoryDb() + useCase = ImportTradeHistoryUseCase(db.transactionDao(), db.dcaPlanDao()) + } + + @After + fun tearDown() = db.close() + + @Test + fun `imported transactions inherit the plan's connectionId`() = runTest { + val planId = db.dcaPlanDao().insertPlan( + DcaPlanEntity( + exchange = Exchange.COINMATE, connectionId = 6, + crypto = "BTC", fiat = "CZK", + amount = BigDecimal("50"), frequency = DcaFrequency.EVERY_8_HOURS + ) + ) + val api = FakeExchangeApi(tradeHistoryHandler = { _, _, _, _ -> + TradeHistoryPage( + listOf( + HistoricalTrade( + orderId = "999", timestamp = Instant.parse("2026-05-28T19:31:00Z"), + crypto = "BTC", fiat = "CZK", cryptoAmount = BigDecimal("0.00003"), + fiatAmount = BigDecimal("50"), price = BigDecimal("1500000"), + fee = BigDecimal("0.17"), feeAsset = "CZK", side = "BUY" + ) + ), + hasMore = false + ) + }) + + useCase.importFromApi(api, planId, "BTC", "CZK", Exchange.COINMATE).toList() + + val tx = db.transactionDao().getByExchangeOrderIdAndConnection("999", 6) + assertEquals(6L, tx?.connectionId) + } +} diff --git a/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ReconcileRecentBuyUseCaseTest.kt b/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ReconcileRecentBuyUseCaseTest.kt new file mode 100644 index 0000000..1eaa005 --- /dev/null +++ b/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ReconcileRecentBuyUseCaseTest.kt @@ -0,0 +1,178 @@ +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.DcaPlanEntity +import com.accbot.dca.data.local.TransactionEntity +import com.accbot.dca.domain.model.DcaFrequency +import com.accbot.dca.domain.model.Exchange +import com.accbot.dca.domain.model.HistoricalTrade +import com.accbot.dca.domain.model.TradeHistoryPage +import com.accbot.dca.domain.model.TransactionStatus +import com.accbot.dca.testing.FakeExchangeApi +import com.accbot.dca.testing.buildInMemoryDb +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.math.BigDecimal +import java.time.Instant + +@RunWith(RobolectricTestRunner::class) +class ReconcileRecentBuyUseCaseTest { + + private lateinit var db: DcaDatabase + private lateinit var useCase: ReconcileRecentBuyUseCase + private val since: Instant = Instant.parse("2026-05-28T19:31:00Z") + + @Before + fun setUp() { + db = buildInMemoryDb() + useCase = ReconcileRecentBuyUseCase(db.transactionDao()) + } + + @After + fun tearDown() = db.close() + + private suspend fun plan(): DcaPlanEntity { + val id = db.dcaPlanDao().insertPlan( + DcaPlanEntity( + exchange = Exchange.COINMATE, connectionId = 6, + crypto = "BTC", fiat = "CZK", + amount = BigDecimal("50"), frequency = DcaFrequency.EVERY_8_HOURS + ) + ) + return db.dcaPlanDao().getPlanById(id)!! + } + + private fun buyTrade(orderId: String, at: Instant, fiat: BigDecimal = BigDecimal("50")) = + HistoricalTrade( + orderId = orderId, timestamp = at, crypto = "BTC", fiat = "CZK", + cryptoAmount = BigDecimal("0.00003"), fiatAmount = fiat, + price = BigDecimal("1500000"), fee = BigDecimal("0.17"), feeAsset = "CZK", side = "BUY" + ) + + @Test + fun `Found when a matching buy exists after attemptStart and not yet recorded`() = runTest { + val api = FakeExchangeApi(tradeHistoryHandler = { _, _, _, _ -> + TradeHistoryPage(listOf(buyTrade("111", since.plusSeconds(10))), hasMore = false) + }) + + val result = useCase(api, plan(), since, expectedFiat = BigDecimal("50")) + + assertTrue(result is ReconcileResult.Found) + assertEquals("111", (result as ReconcileResult.Found).trade.orderId) + } + + @Test + fun `NotFound when exchange reports no buys after attemptStart`() = runTest { + val api = FakeExchangeApi(tradeHistoryHandler = { _, _, _, _ -> + TradeHistoryPage(emptyList(), hasMore = false) + }) + + val result = useCase(api, plan(), since, expectedFiat = BigDecimal("50")) + + assertEquals(ReconcileResult.NotFound, result) + } + + @Test + fun `Unknown when the reconciliation query itself fails - caller must stay conservative`() = runTest { + val api = FakeExchangeApi(tradeHistoryHandler = { _, _, _, _ -> + throw java.io.IOException("history timed out") + }) + + val result = useCase(api, plan(), since, expectedFiat = BigDecimal("50")) + + assertEquals(ReconcileResult.Unknown, result) + } + + @Test + fun `excludes orders already recorded in the DB - no double counting`() = runTest { + val p = plan() + db.transactionDao().insertTransaction( + TransactionEntity( + planId = p.id, exchange = Exchange.COINMATE, connectionId = 6, + crypto = "BTC", fiat = "CZK", fiatAmount = BigDecimal("50"), + cryptoAmount = BigDecimal("0.00003"), price = BigDecimal("1500000"), + fee = BigDecimal("0.17"), status = TransactionStatus.COMPLETED, + exchangeOrderId = "111", executedAt = since.plusSeconds(10) + ) + ) + val api = FakeExchangeApi(tradeHistoryHandler = { _, _, _, _ -> + TradeHistoryPage(listOf(buyTrade("111", since.plusSeconds(10))), hasMore = false) + }) + + val result = useCase(api, p, since, expectedFiat = BigDecimal("50")) + + assertEquals(ReconcileResult.NotFound, result) + } + + @Test + fun `ignores buys that happened before attemptStart`() = runTest { + val api = FakeExchangeApi(tradeHistoryHandler = { _, _, _, _ -> + TradeHistoryPage(listOf(buyTrade("old", since.minusSeconds(120))), hasMore = false) + }) + + val result = useCase(api, plan(), since, expectedFiat = BigDecimal("50")) + + assertEquals(ReconcileResult.NotFound, result) + } + + @Test + fun `aggregates partial fills of one order before matching the amount`() = runTest { + // One market buy filled across two rows of 25 each = 50 total. + val api = FakeExchangeApi(tradeHistoryHandler = { _, _, _, _ -> + TradeHistoryPage( + listOf( + buyTrade("split", since.plusSeconds(5), fiat = BigDecimal("25")), + buyTrade("split", since.plusSeconds(7), fiat = BigDecimal("25")) + ), + hasMore = false + ) + }) + + val result = useCase(api, plan(), since, expectedFiat = BigDecimal("50")) + + assertTrue(result is ReconcileResult.Found) + val trade = (result as ReconcileResult.Found).trade + assertEquals("split", trade.orderId) + assertEquals(0, trade.fiatAmount.compareTo(BigDecimal("50"))) + } + + @Test + fun `tolerates exchange clock skew within the lookback buffer`() = runTest { + // Order stamped slightly before attemptStart (device clock ahead of exchange). + val api = FakeExchangeApi(tradeHistoryHandler = { _, _, _, _ -> + TradeHistoryPage(listOf(buyTrade("skew", since.minusSeconds(2))), hasMore = false) + }) + + val result = useCase(api, plan(), since, expectedFiat = BigDecimal("50")) + + assertTrue(result is ReconcileResult.Found) + } + + @Test + fun `excludes orders already recorded under another plan on the same connection`() = runTest { + val p = plan() // connectionId 6 + // Another plan on the SAME connection already recorded this order. + db.transactionDao().insertTransaction( + TransactionEntity( + planId = 99, exchange = Exchange.COINMATE, connectionId = 6, + crypto = "BTC", fiat = "CZK", fiatAmount = BigDecimal("50"), + cryptoAmount = BigDecimal("0.00003"), price = BigDecimal("1500000"), + fee = BigDecimal("0.17"), status = TransactionStatus.COMPLETED, + exchangeOrderId = "shared", executedAt = since.plusSeconds(10) + ) + ) + val api = FakeExchangeApi(tradeHistoryHandler = { _, _, _, _ -> + TradeHistoryPage(listOf(buyTrade("shared", since.plusSeconds(10))), hasMore = false) + }) + + val result = useCase(api, p, since, expectedFiat = BigDecimal("50")) + + assertEquals(ReconcileResult.NotFound, result) + } +} diff --git a/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCaseLossTest.kt b/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCaseLossTest.kt new file mode 100644 index 0000000..c7922e2 --- /dev/null +++ b/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCaseLossTest.kt @@ -0,0 +1,55 @@ +package com.accbot.dca.domain.usecase + +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import java.math.BigDecimal + +class ValidateSellOrderUseCaseLossTest { + + @Test + fun `pod nakupni cenou vraci LossWarning`() { + val w = ValidateSellOrderUseCase.checkLoss( + cryptoAmount = BigDecimal("1"), + limitPrice = BigDecimal("900000"), + avgBuyPrice = BigDecimal("1000000"), + feeRate = BigDecimal("0.0035") + ) + assertNotNull(w) + } + + @Test + fun `tesne nad nakupni cenou ale po fee ztrata vraci LossWarning`() { + // P=1003500, avg=1M, fee=0.0035 -> netFiat = 1003500 * 0.9965 ~= 999988.75 + // netProfit = 999988.75 - 1000000 = -11.25 < 0 + val w = ValidateSellOrderUseCase.checkLoss( + cryptoAmount = BigDecimal("1"), + limitPrice = BigDecimal("1003500"), + avgBuyPrice = BigDecimal("1000000"), + feeRate = BigDecimal("0.0035") + ) + assertNotNull(w) + } + + @Test + fun `dostatecne nad nakupni cenou vraci null`() { + val w = ValidateSellOrderUseCase.checkLoss( + cryptoAmount = BigDecimal("1"), + limitPrice = BigDecimal("1100000"), + avgBuyPrice = BigDecimal("1000000"), + feeRate = BigDecimal("0.0035") + ) + assertNull(w) + } + + @Test + fun `null avg vraci null`() { + val w = ValidateSellOrderUseCase.checkLoss( + cryptoAmount = BigDecimal("1"), + limitPrice = BigDecimal("900000"), + avgBuyPrice = null, + feeRate = BigDecimal("0.0035") + ) + assertNull(w) + } +} diff --git a/accbot-android/app/src/test/java/com/accbot/dca/presentation/screens/plans/sell/LadderGeneratorTest.kt b/accbot-android/app/src/test/java/com/accbot/dca/presentation/screens/plans/sell/LadderGeneratorTest.kt new file mode 100644 index 0000000..7a25356 --- /dev/null +++ b/accbot-android/app/src/test/java/com/accbot/dca/presentation/screens/plans/sell/LadderGeneratorTest.kt @@ -0,0 +1,50 @@ +package com.accbot.dca.presentation.screens.plans.sell + +import com.accbot.dca.presentation.screens.plans.sell.LadderGenerator.AmountMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.math.BigDecimal + +class LadderGeneratorTest { + + @Test + fun `equal crypto - 5 orderu po 0,2 BTC v rozsahu cen`() { + val orders = LadderGenerator.generate( + totalAmount = BigDecimal("1"), + from = BigDecimal("2000000"), + to = BigDecimal("2400000"), + count = 5, + mode = AmountMode.EQUAL_CRYPTO + ) + assertEquals(5, orders.size) + assertEquals(0, BigDecimal("2000000.00").compareTo(orders[0].limitPrice)) + assertEquals(0, BigDecimal("2400000.00").compareTo(orders[4].limitPrice)) + // sum amounts == totalAmount (with the last order absorbing rounding drobky) + val total = orders.fold(BigDecimal.ZERO) { acc, o -> acc + o.cryptoAmount } + assertEquals(0, BigDecimal("1").compareTo(total)) + } + + @Test + fun `equal fiat - levnejsi ordery prodavaji vic crypta`() { + val orders = LadderGenerator.generate( + totalAmount = BigDecimal("1"), + from = BigDecimal("1000000"), + to = BigDecimal("2000000"), + count = 4, + mode = AmountMode.EQUAL_FIAT + ) + assertEquals(4, orders.size) + assertTrue( + "cheapest order should sell more crypto than most expensive", + orders[0].cryptoAmount > orders[3].cryptoAmount + ) + } + + @Test(expected = IllegalArgumentException::class) + fun `count menez nez 2 hodi exception`() { + LadderGenerator.generate( + BigDecimal("1"), BigDecimal("1000"), BigDecimal("2000"), 1, AmountMode.EQUAL_CRYPTO + ) + } +} diff --git a/accbot-android/app/src/test/java/com/accbot/dca/presentation/screens/plans/sell/SellCalculatorMathTest.kt b/accbot-android/app/src/test/java/com/accbot/dca/presentation/screens/plans/sell/SellCalculatorMathTest.kt new file mode 100644 index 0000000..d3b5c8f --- /dev/null +++ b/accbot-android/app/src/test/java/com/accbot/dca/presentation/screens/plans/sell/SellCalculatorMathTest.kt @@ -0,0 +1,79 @@ +package com.accbot.dca.presentation.screens.plans.sell + +import com.accbot.dca.presentation.screens.plans.sell.SellCalculatorMath.Field +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.math.BigDecimal + +class SellCalculatorMathTest { + + private val fee = BigDecimal("0.0035") + + @Test + fun `A a P editovane - dopocita N`() { + val (a, p, n) = SellCalculatorMath.recompute( + a = BigDecimal("1"), + p = BigDecimal("1000000"), + n = null, + feeRate = fee, + lastTwoEdited = listOf(Field.PRICE, Field.AMOUNT) + ) + // 1 * 1000000 * 0.9965 = 996500 + assertEquals(0, BigDecimal("996500.00").compareTo(n!!)) + assertEquals(0, BigDecimal("1").compareTo(a!!)) + assertEquals(0, BigDecimal("1000000").compareTo(p!!)) + } + + @Test + fun `A a N editovane - dopocita P`() { + val (_, p, _) = SellCalculatorMath.recompute( + a = BigDecimal("1"), + p = null, + n = BigDecimal("996500"), + feeRate = fee, + lastTwoEdited = listOf(Field.NET, Field.AMOUNT) + ) + // 996500 / (1 * 0.9965) = 1000000 + assertEquals(0, BigDecimal("1000000.00").compareTo(p!!)) + } + + @Test + fun `P a N editovane - dopocita A`() { + val (a, _, _) = SellCalculatorMath.recompute( + a = null, + p = BigDecimal("1000000"), + n = BigDecimal("996500"), + feeRate = fee, + lastTwoEdited = listOf(Field.NET, Field.PRICE) + ) + // 996500 / (1000000 * 0.9965) = 1.0 + assertEquals(0, BigDecimal("1.00000000").compareTo(a!!)) + } + + @Test + fun `mene nez 2 editovana pole - nedopocitava`() { + val (_, p, n) = SellCalculatorMath.recompute( + a = BigDecimal("1"), + p = null, + n = null, + feeRate = fee, + lastTwoEdited = listOf(Field.AMOUNT) + ) + assertNull(p) + assertNull(n) + } + + @Test + fun `chybejici vstupy v computed dvojici - vrati null`() { + val (_, _, n) = SellCalculatorMath.recompute( + a = null, + p = BigDecimal("1000000"), + n = null, + feeRate = fee, + lastTwoEdited = listOf(Field.PRICE, Field.AMOUNT) + ) + // computed = NET, ale a je null -> n = null + assertNull(n) + } +} diff --git a/accbot-android/app/src/test/java/com/accbot/dca/testing/FakeExchangeApi.kt b/accbot-android/app/src/test/java/com/accbot/dca/testing/FakeExchangeApi.kt new file mode 100644 index 0000000..14c383c --- /dev/null +++ b/accbot-android/app/src/test/java/com/accbot/dca/testing/FakeExchangeApi.kt @@ -0,0 +1,67 @@ +package com.accbot.dca.testing + +import com.accbot.dca.domain.model.DcaResult +import com.accbot.dca.domain.model.Exchange +import com.accbot.dca.domain.model.TradeHistoryPage +import com.accbot.dca.exchange.ExchangeApi +import com.accbot.dca.exchange.OrderStatusResult +import java.math.BigDecimal +import java.time.Instant + +/** + * Configurable fake [ExchangeApi] for JVM unit tests. + * + * Each behaviour is a swappable lambda so tests can simulate timeouts (throw), + * successful buys, and trade-history reconciliation scenarios deterministically. + */ +class FakeExchangeApi( + override val exchange: Exchange = Exchange.COINMATE, + override val supportsLimitSell: Boolean = true, + var marketBuyHandler: suspend (crypto: String, fiat: String, fiatAmount: BigDecimal) -> DcaResult = + { _, _, _ -> DcaResult.Error("not configured", retryable = false) }, + var tradeHistoryHandler: suspend (crypto: String, fiat: String, since: Instant?, limit: Int) -> TradeHistoryPage = + { _, _, _, _ -> TradeHistoryPage(emptyList(), hasMore = false) }, + var limitSellHandler: suspend (crypto: String, fiat: String, cryptoAmount: BigDecimal, limitPrice: BigDecimal) -> DcaResult = + { _, _, _, _ -> DcaResult.Error("not configured", retryable = false) }, + var balance: BigDecimal? = BigDecimal("1000"), + var price: BigDecimal? = BigDecimal("1500000") +) : ExchangeApi { + + /** Number of times [marketBuy] was invoked - lets tests assert "no duplicate order". */ + var marketBuyCallCount: Int = 0 + private set + + /** Number of times [limitSell] was invoked. */ + var limitSellCallCount: Int = 0 + private set + + override suspend fun marketBuy(crypto: String, fiat: String, fiatAmount: BigDecimal): DcaResult { + marketBuyCallCount++ + return marketBuyHandler(crypto, fiat, fiatAmount) + } + + override suspend fun limitSell( + crypto: String, + fiat: String, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal + ): DcaResult { + limitSellCallCount++ + return limitSellHandler(crypto, fiat, cryptoAmount, limitPrice) + } + + override suspend fun getTradeHistory( + crypto: String, + fiat: String, + sinceTimestamp: Instant?, + limit: Int + ): TradeHistoryPage = tradeHistoryHandler(crypto, fiat, sinceTimestamp, limit) + + override suspend fun getBalance(currency: String): BigDecimal? = balance + override suspend fun getCurrentPrice(crypto: String, fiat: String): BigDecimal? = price + override suspend fun withdraw(crypto: String, amount: BigDecimal, address: String): Result = + Result.success("fake-withdrawal") + override suspend fun getWithdrawalFee(crypto: String): BigDecimal? = BigDecimal.ZERO + override suspend fun validateCredentials(): Boolean = true + override suspend fun getOrderStatus(orderId: String, crypto: String, fiat: String): OrderStatusResult? = null +} diff --git a/accbot-android/app/src/test/java/com/accbot/dca/testing/HarnessSanityTest.kt b/accbot-android/app/src/test/java/com/accbot/dca/testing/HarnessSanityTest.kt new file mode 100644 index 0000000..c771eaa --- /dev/null +++ b/accbot-android/app/src/test/java/com/accbot/dca/testing/HarnessSanityTest.kt @@ -0,0 +1,53 @@ +package com.accbot.dca.testing + +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.DcaPlanEntity +import com.accbot.dca.domain.model.DcaFrequency +import com.accbot.dca.domain.model.Exchange +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.math.BigDecimal + +/** + * Validates that the Robolectric + in-memory Room harness works before we build on it. + */ +@RunWith(RobolectricTestRunner::class) +class HarnessSanityTest { + + private lateinit var db: DcaDatabase + + @Before + fun setUp() { + db = buildInMemoryDb() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun `inserts and reads back a plan`() = runTest { + val id = db.dcaPlanDao().insertPlan( + DcaPlanEntity( + exchange = Exchange.COINMATE, + connectionId = 6, + crypto = "BTC", + fiat = "CZK", + amount = BigDecimal("50"), + frequency = DcaFrequency.EVERY_8_HOURS + ) + ) + + val plans = db.dcaPlanDao().getEnabledPlans() + + assertEquals(1, plans.size) + assertEquals(id, plans.first().id) + assertEquals(6L, plans.first().connectionId) + } +} diff --git a/accbot-android/app/src/test/java/com/accbot/dca/testing/InMemoryDb.kt b/accbot-android/app/src/test/java/com/accbot/dca/testing/InMemoryDb.kt new file mode 100644 index 0000000..85ebf9c --- /dev/null +++ b/accbot-android/app/src/test/java/com/accbot/dca/testing/InMemoryDb.kt @@ -0,0 +1,17 @@ +package com.accbot.dca.testing + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.accbot.dca.data.local.DcaDatabase + +/** + * Builds an in-memory [DcaDatabase] for JVM unit tests (Robolectric). + * Allows main-thread queries so tests can use the *Sync DAO methods directly. + */ +fun buildInMemoryDb(): DcaDatabase = + Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + DcaDatabase::class.java + ) + .allowMainThreadQueries() + .build() diff --git a/accbot-android/app/src/test/resources/robolectric.properties b/accbot-android/app/src/test/resources/robolectric.properties new file mode 100644 index 0000000..979b5ee --- /dev/null +++ b/accbot-android/app/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=34 diff --git a/docs/superpowers/plans/2026-04-23-dca-sell-extension.md b/docs/superpowers/plans/2026-04-23-dca-sell-extension.md new file mode 100644 index 0000000..8523ecc --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-dca-sell-extension.md @@ -0,0 +1,2930 @@ +# DCA Sell Extension - implementacni plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Cil:** Rozsirit DCA plany o opt-in limitni prodejni prikazy, P&L tracking a volitelny cil zisku. Globalni Settings toggle + per-plan `allowSells` flag. MVP podpora Coinmate + Binance. + +**Architektura:** Minimum invasive na existujici kod. 2 nova pole na `DcaPlan`, 3 pole na `TransactionEntity`, 3 metody v `ExchangeApi`. Sells jsou obycejne transakce s `side=SELL`. Existujici `ResolvePendingTransactionsUseCase` rozsirime na polling BUY i SELL side, polling triggery: app onResume + DcaWorker tick + po user akci + pull-to-refresh + opt-in periodic worker. + +**Tech Stack:** Kotlin, Jetpack Compose, Room, Hilt, WorkManager, AlarmManager, OkHttp + +**Referencni spec:** `docs/superpowers/specs/2026-04-23-dca-sell-extension-design.md` + +**Pozn. k testum:** Projekt nema unit test infrastructure (jen `androidTest` pro screenshoty/recording). Manualni verifikace po kazdem tasku: `./gradlew assembleDebug` ze `accbot-android/`. Funkcni testy: sandbox mode + realny run. + +--- + +## Faze 1: Datovy model + +### Task 1: Pridat TransactionSide enum a rozsirit TransactionEntity + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt` + +- [ ] **Krok 1: Pridat TransactionSide enum** + +V `Entities.kt` nad `TransactionEntity`: + +```kotlin +enum class TransactionSide { + BUY, + SELL +} +``` + +- [ ] **Krok 2: Pridat 3 nova pole do TransactionEntity** + +```kotlin +@Entity( + tableName = "transactions", + // ... existujici +) +data class TransactionEntity( + // ... vsechna existujici pole beze zmeny ... + val side: TransactionSide = TransactionSide.BUY, + val limitPrice: BigDecimal? = null, + val requestedCryptoAmount: BigDecimal? = null +) +``` + +- [ ] **Krok 3: Pridat TypeConverter pro TransactionSide** + +V `Entities.kt` do `Converters` tridy: + +```kotlin +@TypeConverter +fun fromTransactionSide(side: TransactionSide): String = side.name + +@TypeConverter +fun toTransactionSide(value: String): TransactionSide = + TransactionSide.valueOf(value) +``` + +- [ ] **Krok 4: Build check** + +```bash +cd accbot-android +./gradlew assembleDebug +``` + +Expected: SUCCESS + +- [ ] **Krok 5: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt +git commit -m "feat(sell): add TransactionSide enum and sell-specific fields to TransactionEntity" +``` + +--- + +### Task 2: Rozsirit DcaPlanEntity o allowSells + targetProfitAmount + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt` + +- [ ] **Krok 1: Pridat pole do DcaPlanEntity** + +```kotlin +@Entity( + tableName = "dca_plans", + // ... existujici +) +data class DcaPlanEntity( + // ... vsechna existujici pole beze zmeny ... + val allowSells: Boolean = false, + val targetProfitAmount: BigDecimal? = null +) +``` + +- [ ] **Krok 2: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 3: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt +git commit -m "feat(sell): add allowSells + targetProfitAmount to DcaPlanEntity" +``` + +--- + +### Task 3: Napsat Room migraci 20 -> 21 + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt` + +- [ ] **Krok 1: Pridat MIGRATION_20_21** + +Pod `MIGRATION_19_20` v companion objectu: + +```kotlin +private val MIGRATION_20_21 = object : Migration(20, 21) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE dca_plans ADD COLUMN allowSells INTEGER NOT NULL DEFAULT 0") + database.execSQL("ALTER TABLE dca_plans ADD COLUMN targetProfitAmount TEXT DEFAULT NULL") + database.execSQL("ALTER TABLE transactions ADD COLUMN side TEXT NOT NULL DEFAULT 'BUY'") + database.execSQL("ALTER TABLE transactions ADD COLUMN limitPrice TEXT DEFAULT NULL") + database.execSQL("ALTER TABLE transactions ADD COLUMN requestedCryptoAmount TEXT DEFAULT NULL") + database.execSQL("CREATE INDEX IF NOT EXISTS idx_tx_plan_side_status ON transactions(planId, side, status)") + } +} +``` + +- [ ] **Krok 2: Upravit @Database version** + +Zmenit `version = 20` na `version = 21`: + +```kotlin +@Database( + entities = [...], + version = 21, + exportSchema = true +) +``` + +- [ ] **Krok 3: Zaregistrovat migraci v addMigrations(...)** + +V `createDatabase` / builderu pridat na konec listu: + +```kotlin +.addMigrations( + MIGRATION_1_2, MIGRATION_2_3, /* ... */ MIGRATION_19_20, MIGRATION_20_21 +) +``` + +- [ ] **Krok 4: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +Expected: SUCCESS. Pokud failne s "Schema export... mismatch", spustit `./gradlew :app:kspDebugKotlin` a zkontrolovat `schemas/com.accbot.dca.data.local.DcaDatabase/21.json`. + +- [ ] **Krok 5: Manualni test migrace** + +Nainstaluj pres `./gradlew installDebug` na emulator ktery ma starou DB (version 20). App musi nastartovat bez crashe. Logcat: `adb logcat | grep -i "room\|migration"` nesmi hlasit `IllegalStateException`. + +- [ ] **Krok 6: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt +git commit -m "feat(sell): Room migration 20->21 for sell extension fields" +``` + +--- + +### Task 4: Rozsirit DcaPlan domain model + mapper + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt` (nebo kde je `DcaPlan` data class) +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/data/local/EntityMappers.kt` + +- [ ] **Krok 1: Najit DcaPlan domain data class** + +```bash +grep -rn 'data class DcaPlan[^E]' accbot-android/app/src/main/java/com/accbot/dca/ +``` + +- [ ] **Krok 2: Pridat pole do DcaPlan domain modelu** + +```kotlin +data class DcaPlan( + // ... existujici pole beze zmeny ... + val allowSells: Boolean = false, + val targetProfitAmount: BigDecimal? = null +) +``` + +- [ ] **Krok 3: Upravit mapper Entity -> Domain** + +V `EntityMappers.kt` najit `DcaPlanEntity.toDomain()` a pridat: + +```kotlin +fun DcaPlanEntity.toDomain(): DcaPlan = DcaPlan( + // ... existujici mapping ... + allowSells = allowSells, + targetProfitAmount = targetProfitAmount +) +``` + +- [ ] **Krok 4: Upravit mapper Domain -> Entity** + +```kotlin +fun DcaPlan.toEntity(): DcaPlanEntity = DcaPlanEntity( + // ... existujici mapping ... + allowSells = allowSells, + targetProfitAmount = targetProfitAmount +) +``` + +- [ ] **Krok 5: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 6: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/ +git commit -m "feat(sell): extend DcaPlan domain model + mappers" +``` + +--- + +### Task 5: Rozsirit Transaction domain model + mapper + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/domain/model/Transaction.kt` (nebo kde je `Transaction` data class) +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/data/local/EntityMappers.kt` + +- [ ] **Krok 1: Najit Transaction domain data class** + +```bash +grep -rn 'data class Transaction[^E]' accbot-android/app/src/main/java/com/accbot/dca/ +``` + +- [ ] **Krok 2: Pridat pole do Transaction** + +```kotlin +data class Transaction( + // ... existujici pole ... + val side: TransactionSide = TransactionSide.BUY, + val limitPrice: BigDecimal? = null, + val requestedCryptoAmount: BigDecimal? = null +) +``` + +- [ ] **Krok 3: Upravit mappery TransactionEntity <-> Transaction** + +V `EntityMappers.kt`: + +```kotlin +fun TransactionEntity.toDomain(): Transaction = Transaction( + // ... existujici mapping ... + side = side, + limitPrice = limitPrice, + requestedCryptoAmount = requestedCryptoAmount +) + +fun Transaction.toEntity(): TransactionEntity = TransactionEntity( + // ... existujici mapping ... + side = side, + limitPrice = limitPrice, + requestedCryptoAmount = requestedCryptoAmount +) +``` + +- [ ] **Krok 4: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 5: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/ +git commit -m "feat(sell): extend Transaction domain model + mappers" +``` + +--- + +### Task 6: Rozsirit TransactionDao o sell-aware queries + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt` + +- [ ] **Krok 1: Pridat query pro resolvable pending + partial transakce** + +```kotlin +@Query(""" + SELECT * FROM transactions + WHERE status IN ('PENDING', 'PARTIAL') + AND exchangeOrderId IS NOT NULL +""") +suspend fun getResolvablePendingTransactions(): List +``` + +- [ ] **Krok 2: Pridat query pro pocitani open sells** + +```kotlin +@Query(""" + SELECT COUNT(*) FROM transactions + WHERE side = 'SELL' + AND status IN ('PENDING', 'PARTIAL') +""") +suspend fun countOpenSells(): Int +``` + +- [ ] **Krok 3: Pridat query pro open sells konkretniho planu** + +```kotlin +@Query(""" + SELECT * FROM transactions + WHERE planId = :planId + AND side = 'SELL' + AND status IN ('PENDING', 'PARTIAL') + ORDER BY executedAt DESC +""") +fun observeOpenSellsForPlan(planId: Long): Flow> +``` + +- [ ] **Krok 4: Pridat query pro vsechny transakce planu (observe variant, pokud neexistuje)** + +Zkontroluj jestli existuje `fun observeTransactionsForPlan(planId: Long): Flow>`. Pokud neexistuje: + +```kotlin +@Query("SELECT * FROM transactions WHERE planId = :planId ORDER BY executedAt DESC") +fun observeTransactionsForPlan(planId: Long): Flow> +``` + +- [ ] **Krok 5: Concurrency-guarded update pro resolve** + +```kotlin +@Query(""" + UPDATE transactions + SET status = :newStatus, + cryptoAmount = :cryptoAmount, + fiatAmount = :fiatAmount, + price = :price, + fee = :fee + WHERE id = :id + AND status IN ('PENDING', 'PARTIAL') +""") +suspend fun updateResolvedTransaction( + id: Long, + newStatus: TransactionStatus, + cryptoAmount: BigDecimal, + fiatAmount: BigDecimal, + price: BigDecimal, + fee: BigDecimal? +): Int // returns affected row count +``` + +- [ ] **Krok 6: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 7: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt +git commit -m "feat(sell): add sell-aware DAO queries (open sells, resolvable, guarded update)" +``` + +--- + +### Task 7: Rozsirit BackupPlan + BackupTransaction modely + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/domain/model/BackupModels.kt` +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataCollector.kt` +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt` + +- [ ] **Krok 1: Rozsirit BackupPlan** + +V `BackupModels.kt`: + +```kotlin +data class BackupPlan( + // ... existujici pole ... + @SerializedName("allowSells") val allowSells: Boolean = false, + @SerializedName("targetProfitAmount") val targetProfitAmount: String? = null // BigDecimal jako String +) +``` + +- [ ] **Krok 2: Rozsirit BackupTransaction** + +```kotlin +data class BackupTransaction( + // ... existujici pole ... + @SerializedName("side") val side: String = "BUY", // TransactionSide.name + @SerializedName("limitPrice") val limitPrice: String? = null, + @SerializedName("requestedCryptoAmount") val requestedCryptoAmount: String? = null +) +``` + +- [ ] **Krok 3: Upravit BackupDataCollector - ukladat nova pole** + +Najit kde se vytvareji `BackupPlan` a `BackupTransaction` v `BackupDataCollector.kt`, pridat: + +```kotlin +BackupPlan( + // ... existujici ... + allowSells = plan.allowSells, + targetProfitAmount = plan.targetProfitAmount?.toPlainString() +) + +BackupTransaction( + // ... existujici ... + side = tx.side.name, + limitPrice = tx.limitPrice?.toPlainString(), + requestedCryptoAmount = tx.requestedCryptoAmount?.toPlainString() +) +``` + +- [ ] **Krok 4: Upravit BackupDataRestorer - nacitat nova pole** + +V `BackupDataRestorer.kt` pri konstrukci entit z backup modelu: + +```kotlin +DcaPlanEntity( + // ... existujici ... + allowSells = backupPlan.allowSells, + targetProfitAmount = backupPlan.targetProfitAmount?.let { BigDecimal(it) } +) + +TransactionEntity( + // ... existujici ... + side = TransactionSide.valueOf(backupTx.side), + limitPrice = backupTx.limitPrice?.let { BigDecimal(it) }, + requestedCryptoAmount = backupTx.requestedCryptoAmount?.let { BigDecimal(it) } +) +``` + +- [ ] **Krok 5: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 6: Verifikace ProGuard keep rules** + +Ve `proguard-rules.pro` (nebo kde jsou ProGuard rules) overit ze `com.accbot.dca.domain.model.**` je v keep rules. Nova pole maji `@SerializedName` anotaci - release build musi fungovat. Pokud package neni v keep, pridat: + +```proguard +-keep class com.accbot.dca.domain.model.** { *; } +``` + +- [ ] **Krok 7: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/ +git commit -m "feat(sell): extend backup/restore for sell fields" +``` + +--- + +## Faze 2: Exchange API + +### Task 8: Definovat OrderStatusResult a refactor ExchangeApi + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/exchange/ExchangeApi.kt` + +- [ ] **Krok 1: Pridat OrderStatusResult data class** + +Do `ExchangeApi.kt` nebo noveho souboru `OrderStatusResult.kt`: + +```kotlin +package com.accbot.dca.exchange + +import com.accbot.dca.domain.model.TransactionStatus +import java.math.BigDecimal + +data class OrderStatusResult( + val status: TransactionStatus, // PENDING/PARTIAL/COMPLETED/FAILED + val filledCryptoAmount: BigDecimal, + val filledFiatAmount: BigDecimal, + val avgFillPrice: BigDecimal?, + val fee: BigDecimal?, + val feeAsset: String? +) +``` + +- [ ] **Krok 2: Refactor getOrderStatus signature** + +V `ExchangeApi.kt` interface (definitivní signatura, pouzita ve vsech implementacich): + +```kotlin +suspend fun getOrderStatus(orderId: String, crypto: String, fiat: String): OrderStatusResult? = null +``` + +(Drive vracelo `Transaction?` a bralo jen `orderId` - breaking change.) + +- [ ] **Krok 3: Pridat limitSell metodu** + +```kotlin +/** + * Place a limit sell order. Order zustava otevreny na burze. + * @return DcaResult.Success s exchangeOrderId a status=PENDING. + * Failure pokud burza odmitne (insufficient balance, invalid price). + */ +suspend fun limitSell( + crypto: String, + fiat: String, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal +): DcaResult = throw UnsupportedOperationException( + "AccBot zatim nepodporuje limit sell pro ${exchange.displayName}" +) +``` + +- [ ] **Krok 4: Pridat cancelOrder metodu** + +**Signature poznamka:** Binance vyzaduje `symbol=${crypto}${fiat}` navic k `orderId`. Aby byla signature konzistentni pro vsechny burzy, pridavame `crypto + fiat` parametry (Coinmate/Coinbase/Kraken je ignoruji). + +```kotlin +/** + * Cancel an open order on the exchange. + * @param crypto, fiat - nektere burzy (Binance) vyzaduji symbol ke cancelu + * @return Result.success pokud order byl zrusen (nebo uz byl filled/canceled). + * Result.failure pokud burza odmitla / nedostupna. + */ +suspend fun cancelOrder(orderId: String, crypto: String, fiat: String): Result = + Result.failure(UnsupportedOperationException( + "AccBot zatim nepodporuje cancel order pro ${exchange.displayName}" + )) +``` + +Zaroven upravit getOrderStatus signaturu: + +```kotlin +suspend fun getOrderStatus(orderId: String, crypto: String, fiat: String): OrderStatusResult? = null +``` + +- [ ] **Krok 5: Pridat supportsLimitSell capability flag** + +```kotlin +val supportsLimitSell: Boolean get() = false +``` + +- [ ] **Krok 6: Build check - bude failovat kvuli breaking change** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +Expected: FAIL na callers `getOrderStatus` (CoinbaseApi, KrakenApi, ResolvePendingTransactionsUseCase). Fix prichazi v nasledujicich tascich. + +- [ ] **Krok 7: Commit (broken build, fix v dalsich tascich)** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/exchange/ +git commit -m "feat(sell): add limitSell/cancelOrder/supportsLimitSell + refactor getOrderStatus to OrderStatusResult" +``` + +--- + +### Task 9: Refactor existujicich getOrderStatus implementaci (Coinbase + Kraken) + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinbaseApi.kt` +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/exchange/OtherExchanges.kt` (KrakenApi) + +- [ ] **Krok 1: Precti aktualni CoinbaseApi.getOrderStatus** + +```bash +grep -n -A 30 'getOrderStatus' accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinbaseApi.kt +``` + +- [ ] **Krok 2: Refactor CoinbaseApi.getOrderStatus na OrderStatusResult** + +Stavajici kod vraci `Transaction?` - prepsat aby vracelo `OrderStatusResult?`. Nova signature: `(orderId, crypto, fiat)` (crypto+fiat se pro Coinbase ignoruji, ale interface je sjednoceny). + +```kotlin +override suspend fun getOrderStatus(orderId: String, crypto: String, fiat: String): OrderStatusResult? = withContext(Dispatchers.IO) { + // ... existujici API call ... + // Misto vraceni Transaction(...) vrat OrderStatusResult(...) + + val status = when (coinbaseStatusString) { + "OPEN" -> TransactionStatus.PENDING + "PARTIALLY_FILLED" -> TransactionStatus.PARTIAL + "FILLED" -> TransactionStatus.COMPLETED + "CANCELLED", "EXPIRED" -> if (filledSize > BigDecimal.ZERO) + TransactionStatus.PARTIAL else TransactionStatus.FAILED + else -> return@withContext null + } + + OrderStatusResult( + status = status, + filledCryptoAmount = filledSize, + filledFiatAmount = filledValue, + avgFillPrice = if (filledSize > BigDecimal.ZERO) + filledValue.divide(filledSize, 8, RoundingMode.HALF_UP) else null, + fee = fee, + feeAsset = feeAsset + ) +} +``` + +(Detail mapovani Coinbase statusu si dohledat v dokumentaci / existujicim kodu.) + +- [ ] **Krok 3: Refactor KrakenApi.getOrderStatus v OtherExchanges.kt** + +Obdobne - stejna nova signature `(orderId, crypto, fiat)` (crypto+fiat pro Kraken ignoruji). `status` Kraken pouziva `open/closed/canceled/expired`: + +```kotlin +override suspend fun getOrderStatus(orderId: String, crypto: String, fiat: String): OrderStatusResult? = withContext(Dispatchers.IO) { + // ... API call ... + val status = when (krakenStatus) { + "open" -> if (vol_exec > BigDecimal.ZERO) TransactionStatus.PARTIAL else TransactionStatus.PENDING + "closed" -> TransactionStatus.COMPLETED + "canceled", "expired" -> if (vol_exec > BigDecimal.ZERO) + TransactionStatus.PARTIAL else TransactionStatus.FAILED + else -> return@withContext null +} + +OrderStatusResult( + status = status, + filledCryptoAmount = vol_exec, + filledFiatAmount = cost, + avgFillPrice = if (vol_exec > BigDecimal.ZERO) + cost.divide(vol_exec, 8, RoundingMode.HALF_UP) else null, + fee = fee, + feeAsset = null +) +``` + +- [ ] **Krok 4: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +Expected: stale failne na `ResolvePendingTransactionsUseCase` (fix v Task 12). + +- [ ] **Krok 5: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/exchange/ +git commit -m "refactor(sell): adapt CoinbaseApi + KrakenApi to OrderStatusResult" +``` + +--- + +### Task 10: Implementovat CoinmateApi.limitSell + cancelOrder + getOrderStatus + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt` + +- [ ] **Krok 1: Override supportsLimitSell** + +V `CoinmateApi` classi: + +```kotlin +override val supportsLimitSell: Boolean = true +``` + +- [ ] **Krok 2: Implementovat limitSell** + +Coinmate API endpoint: `POST /api/sellLimit` s body: `amount, price, currencyPair, clientOrderId (optional)`. + +```kotlin +override suspend fun limitSell( + crypto: String, + fiat: String, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal +): DcaResult = withContext(Dispatchers.IO) { + val currencyPair = "${crypto}_${fiat}" + val params = buildSignedParams( + "amount" to cryptoAmount.stripTrailingZeros().toPlainString(), + "price" to limitPrice.stripTrailingZeros().toPlainString(), + "currencyPair" to currencyPair + ) + val response = executePostRequest("/api/sellLimit", params) + + if (response.error) { + return@withContext DcaResult.Failure( + exchange = Exchange.COINMATE, + reason = mapCoinmateErrorToReason(response.errorMessage), + message = response.errorMessage ?: "Coinmate sell limit failed" + ) + } + + val orderId = response.data?.toString() ?: return@withContext DcaResult.Failure(...) + + DcaResult.Success( + transaction = Transaction( + exchange = Exchange.COINMATE, + crypto = crypto, + fiat = fiat, + cryptoAmount = BigDecimal.ZERO, // not filled yet + fiatAmount = BigDecimal.ZERO, // not filled yet + price = limitPrice, + fee = null, + feeAsset = null, + status = TransactionStatus.PENDING, + exchangeOrderId = orderId, + side = TransactionSide.SELL, + limitPrice = limitPrice, + requestedCryptoAmount = cryptoAmount, + executedAt = Instant.now() + ) + ) +} +``` + +**Pozn.:** `buildSignedParams` / `executePostRequest` jsou existujici helpery v `CoinmateApi` - reuse. + +- [ ] **Krok 3: Implementovat cancelOrder** + +Coinmate: `POST /api/cancelOrder` s `orderId`. Parametry `crypto + fiat` se ignoruji (jsou tam kvuli interface konzistenci s Binance). + +```kotlin +override suspend fun cancelOrder(orderId: String, crypto: String, fiat: String): Result = withContext(Dispatchers.IO) { + try { + val params = buildSignedParams("orderId" to orderId) + val response = executePostRequest("/api/cancelOrder", params) + if (response.error) { + return@withContext Result.failure( + IOException("Coinmate cancel failed: ${response.errorMessage}") + ) + } + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } +} +``` + +- [ ] **Krok 4: Implementovat getOrderStatus** + +Coinmate: `POST /api/orderById` s `orderId`, vraci objekt s `status, remainingAmount, originalAmount, orderType, price, avgPrice, ...`. Parametry `crypto + fiat` se ignoruji. + +```kotlin +override suspend fun getOrderStatus(orderId: String, crypto: String, fiat: String): OrderStatusResult? = withContext(Dispatchers.IO) { + try { + val params = buildSignedParams("orderId" to orderId) + val response = executePostRequest("/api/orderById", params) + if (response.error) return@withContext null + + val order = response.data as? JSONObject ?: return@withContext null + val originalAmount = BigDecimal(order.getString("originalAmount")) + val remainingAmount = BigDecimal(order.getString("remainingAmount")) + val filledAmount = originalAmount - remainingAmount + val avgPrice = order.optString("avgPrice").takeIf { it.isNotBlank() }?.let { BigDecimal(it) } + val filledFiat = avgPrice?.let { filledAmount * it } ?: BigDecimal.ZERO + + val coinmateStatus = order.getString("status") + val txStatus = when (coinmateStatus) { + "OPEN" -> if (filledAmount > BigDecimal.ZERO) TransactionStatus.PARTIAL else TransactionStatus.PENDING + "FILLED" -> TransactionStatus.COMPLETED + "PARTIALLY_FILLED" -> TransactionStatus.PARTIAL + "CANCELLED", "EXPIRED" -> if (filledAmount > BigDecimal.ZERO) + TransactionStatus.PARTIAL else TransactionStatus.FAILED + else -> return@withContext null + } + + OrderStatusResult( + status = txStatus, + filledCryptoAmount = filledAmount, + filledFiatAmount = filledFiat, + avgFillPrice = avgPrice, + fee = null, // Coinmate order endpoint fee TBD - vracet null pokud endpoint nevraci + feeAsset = null + ) + } catch (e: Exception) { + Log.w("CoinmateApi", "getOrderStatus failed for $orderId", e) + null + } +} +``` + +- [ ] **Krok 5: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 6: Manualni sandbox test** + +Spustit app v sandbox mode (UserPreferences sandbox=true). Pres DEBUG UI (nebo adb shell) zavolat `limitSell` s malou castkou, pak `getOrderStatus`, pak `cancelOrder`. Verifikovat ze orderID prichazi, status se meni, cancel funguje. Logy pres `adb logcat | grep CoinmateApi`. + +- [ ] **Krok 7: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt +git commit -m "feat(sell): implement CoinmateApi limitSell + cancelOrder + getOrderStatus" +``` + +--- + +### Task 11: Implementovat BinanceApi.limitSell + cancelOrder + getOrderStatus + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/exchange/BinanceApi.kt` + +- [ ] **Krok 1: Override supportsLimitSell** + +```kotlin +override val supportsLimitSell: Boolean = true +``` + +- [ ] **Krok 2: Implementovat limitSell** + +Binance: `POST /api/v3/order?symbol=BTCEUR&side=SELL&type=LIMIT&timeInForce=GTC&quantity=0.01&price=1200000`. + +```kotlin +override suspend fun limitSell( + crypto: String, + fiat: String, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal +): DcaResult = withContext(Dispatchers.IO) { + val symbol = "${crypto}${fiat}" + val params = mapOf( + "symbol" to symbol, + "side" to "SELL", + "type" to "LIMIT", + "timeInForce" to "GTC", + "quantity" to cryptoAmount.stripTrailingZeros().toPlainString(), + "price" to limitPrice.stripTrailingZeros().toPlainString() + ) + + try { + val response = executeSignedRequest("POST", "/api/v3/order", params) + val orderId = response.getLong("orderId").toString() + + DcaResult.Success( + transaction = Transaction( + exchange = Exchange.BINANCE, + crypto = crypto, + fiat = fiat, + cryptoAmount = BigDecimal.ZERO, + fiatAmount = BigDecimal.ZERO, + price = limitPrice, + fee = null, + feeAsset = null, + status = TransactionStatus.PENDING, + exchangeOrderId = orderId, + side = TransactionSide.SELL, + limitPrice = limitPrice, + requestedCryptoAmount = cryptoAmount, + executedAt = Instant.now() + ) + ) + } catch (e: BinanceApiException) { + DcaResult.Failure( + exchange = Exchange.BINANCE, + reason = mapBinanceErrorToReason(e.code), + message = e.message ?: "Binance limit sell failed" + ) + } +} +``` + +- [ ] **Krok 3: Implementovat cancelOrder** + +Binance: `DELETE /api/v3/order?symbol=BTCEUR&orderId=XXX`. Signature uz je `(orderId, crypto, fiat)` definovane v Task 8. + +```kotlin +override suspend fun cancelOrder(orderId: String, crypto: String, fiat: String): Result = withContext(Dispatchers.IO) { + try { + val symbol = "${crypto}${fiat}" + val params = mapOf("symbol" to symbol, "orderId" to orderId) + executeSignedRequest("DELETE", "/api/v3/order", params) + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } +} +``` + +- [ ] **Krok 4: Implementovat getOrderStatus** + +Binance: `GET /api/v3/order?symbol=BTCEUR&orderId=XXX`. + +```kotlin +override suspend fun getOrderStatus(orderId: String, crypto: String, fiat: String): OrderStatusResult? = withContext(Dispatchers.IO) { + try { + val symbol = "${crypto}${fiat}" + val params = mapOf("symbol" to symbol, "orderId" to orderId) + val response = executeSignedRequest("GET", "/api/v3/order", params) + + val binanceStatus = response.getString("status") + val executedQty = BigDecimal(response.getString("executedQty")) + val cumQuoteQty = BigDecimal(response.getString("cummulativeQuoteQty")) + val avgPrice = if (executedQty > BigDecimal.ZERO) + cumQuoteQty.divide(executedQty, 8, RoundingMode.HALF_UP) else null + + val txStatus = when (binanceStatus) { + "NEW" -> TransactionStatus.PENDING + "PARTIALLY_FILLED" -> TransactionStatus.PARTIAL + "FILLED" -> TransactionStatus.COMPLETED + "CANCELED", "EXPIRED", "REJECTED" -> if (executedQty > BigDecimal.ZERO) + TransactionStatus.PARTIAL else TransactionStatus.FAILED + else -> return@withContext null + } + + OrderStatusResult( + status = txStatus, + filledCryptoAmount = executedQty, + filledFiatAmount = cumQuoteQty, + avgFillPrice = avgPrice, + fee = null, // fee je per-fill, zjisti se z /myTrades endpoint - MVP: skip + feeAsset = null + ) + } catch (e: Exception) { + Log.w("BinanceApi", "getOrderStatus failed for $orderId", e) + null + } +} +``` + +- [ ] **Krok 5: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 6: Manualni Binance testnet test** + +Prepnout app na sandbox mode (uses Binance testnet). Zalozit plan na BTC/EUR, pak pres DEBUG UI vyvolat limitSell s malou castkou. Verifikovat orderID, status check, cancel. Logy `adb logcat | grep BinanceApi`. + +- [ ] **Krok 7: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/exchange/ +git commit -m "feat(sell): implement BinanceApi limitSell + cancel + getOrderStatus" +``` + +--- + +## Faze 3: Use cases a business logic + +### Task 12: Rozsirit ResolvePendingTransactionsUseCase + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ResolvePendingTransactionsUseCase.kt` + +- [ ] **Krok 1: Zmenit query z getPendingTransactionsWithOrderId na getResolvablePendingTransactions** + +```kotlin +val pendingTransactions = database.transactionDao().getResolvablePendingTransactions() +``` + +- [ ] **Krok 2: Zmenit volani getOrderStatus na novy signaturu** + +```kotlin +val filledOrder = api.getOrderStatus(orderId, tx.crypto, tx.fiat) ?: continue +``` + +- [ ] **Krok 3: Zmenit update logiku - pouzit guarded update** + +```kotlin +val rowsUpdated = database.transactionDao().updateResolvedTransaction( + id = tx.id, + newStatus = filledOrder.status, + cryptoAmount = filledOrder.filledCryptoAmount, + fiatAmount = filledOrder.filledFiatAmount, + price = filledOrder.avgFillPrice ?: tx.price, + fee = filledOrder.fee +) +if (rowsUpdated > 0) resolvedCount++ +``` + +- [ ] **Krok 4: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 5: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ResolvePendingTransactionsUseCase.kt +git commit -m "feat(sell): extend ResolvePendingTransactionsUseCase for SELL-side + PARTIAL resolution" +``` + +--- + +### Task 13: PlaceLimitSellUseCase + +**Soubory:** +- Vytvorit: `accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLimitSellUseCase.kt` + +- [ ] **Krok 1: Vytvorit use case** + +```kotlin +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.CredentialsStore +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.EntityMappers.toEntity +import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.domain.model.DcaResult +import com.accbot.dca.exchange.ExchangeApiFactory +import java.math.BigDecimal +import javax.inject.Inject + +class PlaceLimitSellUseCase @Inject constructor( + private val database: DcaDatabase, + private val credentialsStore: CredentialsStore, + private val exchangeApiFactory: ExchangeApiFactory, + private val userPreferences: UserPreferences, + private val resolvePendingTransactionsUseCase: ResolvePendingTransactionsUseCase +) { + suspend operator fun invoke( + planId: Long, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal + ): Result { + val plan = database.dcaPlanDao().getPlanById(planId) + ?: return Result.failure(IllegalArgumentException("Plan $planId neexistuje")) + + val credentials = plan.connectionId?.let { + credentialsStore.getCredentials(it, userPreferences.isSandboxMode()) + } ?: return Result.failure(IllegalStateException("Chybi credentials pro plan")) + + val api = exchangeApiFactory.create(credentials) + val result = api.limitSell(plan.crypto, plan.fiat, cryptoAmount, limitPrice) + + return when (result) { + is DcaResult.Success -> { + val tx = result.transaction.copy( + planId = planId, + connectionId = plan.connectionId + ) + val txId = database.transactionDao().insertTransaction(tx.toEntity()) + resolvePendingTransactionsUseCase() // okamzity poll (edge: instant fill) + Result.success(txId) + } + is DcaResult.Failure -> Result.failure( + IllegalStateException("${result.reason}: ${result.message}") + ) + } + } +} +``` + +- [ ] **Krok 2: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 3: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLimitSellUseCase.kt +git commit -m "feat(sell): add PlaceLimitSellUseCase" +``` + +--- + +### Task 14: CancelSellOrderUseCase + +**Soubory:** +- Vytvorit: `accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CancelSellOrderUseCase.kt` + +- [ ] **Krok 1: Vytvorit use case** + +```kotlin +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.CredentialsStore +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.domain.model.TransactionStatus +import com.accbot.dca.exchange.ExchangeApiFactory +import java.math.BigDecimal +import javax.inject.Inject + +class CancelSellOrderUseCase @Inject constructor( + private val database: DcaDatabase, + private val credentialsStore: CredentialsStore, + private val exchangeApiFactory: ExchangeApiFactory, + private val userPreferences: UserPreferences, + private val resolvePendingTransactionsUseCase: ResolvePendingTransactionsUseCase +) { + suspend operator fun invoke(txId: Long): Result { + val tx = database.transactionDao().getTransactionById(txId) + ?: return Result.failure(IllegalArgumentException("Transakce $txId neexistuje")) + + val orderId = tx.exchangeOrderId + ?: return Result.failure(IllegalStateException("Transakce nema exchangeOrderId")) + + val credentials = tx.connectionId?.let { + credentialsStore.getCredentials(it, userPreferences.isSandboxMode()) + } ?: return Result.failure(IllegalStateException("Chybi credentials")) + + val api = exchangeApiFactory.create(credentials) + val cancelResult = api.cancelOrder(orderId, tx.crypto, tx.fiat) + + return if (cancelResult.isSuccess) { + val newStatus = if (tx.cryptoAmount > BigDecimal.ZERO) + TransactionStatus.PARTIAL else TransactionStatus.FAILED + database.transactionDao().updateResolvedTransaction( + id = txId, + newStatus = newStatus, + cryptoAmount = tx.cryptoAmount, + fiatAmount = tx.fiatAmount, + price = tx.price, + fee = tx.fee + ) + Result.success(Unit) + } else { + resolvePendingTransactionsUseCase() // mozna se mezitim zfilovalo + cancelResult + } + } +} +``` + +- [ ] **Krok 2: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 3: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CancelSellOrderUseCase.kt +git commit -m "feat(sell): add CancelSellOrderUseCase" +``` + +--- + +### Task 15: CalculatePlanPnLUseCase + +**Soubory:** +- Vytvorit: `accbot-android/app/src/main/java/com/accbot/dca/domain/model/PlanPnL.kt` +- Vytvorit: `accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculatePlanPnLUseCase.kt` + +- [ ] **Krok 1: Vytvorit PlanPnL data class** + +`PlanPnL.kt`: + +```kotlin +package com.accbot.dca.domain.model + +import java.math.BigDecimal + +data class PlanPnL( + val totalBoughtFiat: BigDecimal, + val totalBoughtCrypto: BigDecimal, + val totalSoldFiat: BigDecimal, + val totalSoldCrypto: BigDecimal, + val currentCryptoHeld: BigDecimal, + val avgBuyPrice: BigDecimal?, + val currentValueFiat: BigDecimal?, + val realizedPnL: BigDecimal?, + val unrealizedPnL: BigDecimal?, + val netPnL: BigDecimal?, + val targetProgressPct: Double? +) +``` + +- [ ] **Krok 2: Vytvorit CalculatePlanPnLUseCase** + +`CalculatePlanPnLUseCase.kt`: + +```kotlin +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.TransactionSide +import com.accbot.dca.domain.model.PlanPnL +import com.accbot.dca.domain.model.TransactionStatus +import java.math.BigDecimal +import java.math.RoundingMode +import javax.inject.Inject + +class CalculatePlanPnLUseCase @Inject constructor( + private val database: DcaDatabase +) { + suspend operator fun invoke( + planId: Long, + currentMarketPrice: BigDecimal? + ): PlanPnL { + val plan = database.dcaPlanDao().getPlanById(planId) + ?: error("Plan $planId neexistuje") + + val transactions = database.transactionDao() + .getTransactionsByPlanId(planId) + .filter { it.status == TransactionStatus.COMPLETED || it.status == TransactionStatus.PARTIAL } + + val buys = transactions.filter { it.side == TransactionSide.BUY } + val sells = transactions.filter { it.side == TransactionSide.SELL } + + val totalBoughtFiat = buys.sumOf { it.fiatAmount } + val totalBoughtCrypto = buys.sumOf { it.cryptoAmount } + val totalSoldFiat = sells.sumOf { it.fiatAmount } + val totalSoldCrypto = sells.sumOf { it.cryptoAmount } + val currentCryptoHeld = totalBoughtCrypto - totalSoldCrypto + + val avgBuyPrice = if (totalBoughtCrypto > BigDecimal.ZERO) + totalBoughtFiat.divide(totalBoughtCrypto, 8, RoundingMode.HALF_UP) + else null + + val currentValueFiat = currentMarketPrice?.let { currentCryptoHeld * it } + + val realizedPnL = avgBuyPrice?.let { + totalSoldFiat - (totalSoldCrypto * it) + } + + val unrealizedPnL = if (avgBuyPrice != null && currentValueFiat != null) + currentValueFiat - (currentCryptoHeld * avgBuyPrice) + else null + + val netPnL = if (realizedPnL != null && unrealizedPnL != null) + realizedPnL + unrealizedPnL + else null + + val targetProgressPct = if (netPnL != null && plan.targetProfitAmount != null && plan.targetProfitAmount > BigDecimal.ZERO) + netPnL.toDouble() / plan.targetProfitAmount.toDouble() + else null + + return PlanPnL( + totalBoughtFiat, totalBoughtCrypto, + totalSoldFiat, totalSoldCrypto, + currentCryptoHeld, avgBuyPrice, currentValueFiat, + realizedPnL, unrealizedPnL, netPnL, targetProgressPct + ) + } +} +``` + +- [ ] **Krok 3: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 4: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/ +git commit -m "feat(sell): add PlanPnL model + CalculatePlanPnLUseCase" +``` + +--- + +### Task 16: ValidateSellOrderUseCase (business validace wizardu) + +**Soubory:** +- Vytvorit: `accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt` + +- [ ] **Krok 1: Vytvorit use case** + +```kotlin +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.TransactionSide +import com.accbot.dca.domain.model.TransactionStatus +import java.math.BigDecimal +import javax.inject.Inject + +sealed class SellValidation { + object Ok : SellValidation() + data class HardError(val message: String) : SellValidation() + data class InstantFillInfo(val spot: BigDecimal) : SellValidation() + data class FarFromMarketWarning(val spot: BigDecimal) : SellValidation() +} + +class ValidateSellOrderUseCase @Inject constructor( + private val database: DcaDatabase +) { + suspend operator fun invoke( + planId: Long, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal, + minOrderSize: BigDecimal, + currentSpot: BigDecimal? + ): List { + val result = mutableListOf() + + if (cryptoAmount <= BigDecimal.ZERO) { + result += SellValidation.HardError("Mnozstvi musi byt vetsi nez 0") + return result + } + + if (limitPrice <= BigDecimal.ZERO) { + result += SellValidation.HardError("Limitni cena musi byt vetsi nez 0") + return result + } + + if (cryptoAmount < minOrderSize) { + result += SellValidation.HardError("Minimalni order je $minOrderSize") + } + + val tx = database.transactionDao().getTransactionsByPlanId(planId) + val completedOrPartial = tx.filter { it.status == TransactionStatus.COMPLETED || it.status == TransactionStatus.PARTIAL } + val heldBought = completedOrPartial.filter { it.side == TransactionSide.BUY }.sumOf { it.cryptoAmount } + val heldSold = completedOrPartial.filter { it.side == TransactionSide.SELL }.sumOf { it.cryptoAmount } + val held = heldBought - heldSold + + val openSellsRequested = tx + .filter { it.side == TransactionSide.SELL && it.status in setOf(TransactionStatus.PENDING, TransactionStatus.PARTIAL) } + .sumOf { (it.requestedCryptoAmount ?: BigDecimal.ZERO) - it.cryptoAmount } + + val available = held - openSellsRequested + if (cryptoAmount > available) { + result += SellValidation.HardError("Nemas tolik BTC k dispozici (k dispozici $available)") + } + + if (currentSpot != null) { + if (limitPrice <= currentSpot) { + result += SellValidation.InstantFillInfo(currentSpot) + } + if (limitPrice > currentSpot.multiply(BigDecimal("3"))) { + result += SellValidation.FarFromMarketWarning(currentSpot) + } + } + + if (result.isEmpty()) result += SellValidation.Ok + return result + } +} +``` + +- [ ] **Krok 2: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 3: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt +git commit -m "feat(sell): add ValidateSellOrderUseCase with instant-fill + far-from-market checks" +``` + +--- + +## Faze 4: UserPreferences + periodic polling + +### Task 17: Rozsirit UserPreferences o trading flagy + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/data/local/UserPreferences.kt` + +- [ ] **Krok 1: Pridat klice a gettery/settery** + +```kotlin +// Klice (companion object nebo top-level const): +private const val KEY_TRADING_ENABLED = "trading_enabled" +private const val KEY_SELL_POLLING_ENABLED = "sell_polling_enabled" +private const val KEY_SELL_POLLING_FREQUENCY = "sell_polling_frequency" +private const val KEY_SELL_POLLING_CRON = "sell_polling_cron" +private const val KEY_SELL_POLLING_SCHEDULE_CONFIG = "sell_polling_schedule_config" + +// Metody: +fun isTradingEnabled(): Boolean = prefs.getBoolean(KEY_TRADING_ENABLED, false) +fun setTradingEnabled(enabled: Boolean) { prefs.edit().putBoolean(KEY_TRADING_ENABLED, enabled).apply() } + +fun isPeriodicSellPollingEnabled(): Boolean = prefs.getBoolean(KEY_SELL_POLLING_ENABLED, false) +fun getSellPollingFrequency(): DcaFrequency = + prefs.getString(KEY_SELL_POLLING_FREQUENCY, null)?.let { DcaFrequency.valueOf(it) } ?: DcaFrequency.HOURLY +fun getSellPollingCronExpression(): String? = prefs.getString(KEY_SELL_POLLING_CRON, null) +fun getSellPollingScheduleConfig(): String? = prefs.getString(KEY_SELL_POLLING_SCHEDULE_CONFIG, null) + +fun setPeriodicSellPolling(enabled: Boolean, frequency: DcaFrequency, cron: String?, scheduleConfig: String?) { + prefs.edit() + .putBoolean(KEY_SELL_POLLING_ENABLED, enabled) + .putString(KEY_SELL_POLLING_FREQUENCY, frequency.name) + .putString(KEY_SELL_POLLING_CRON, cron) + .putString(KEY_SELL_POLLING_SCHEDULE_CONFIG, scheduleConfig) + .apply() +} +``` + +- [ ] **Krok 2: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 3: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/data/local/UserPreferences.kt +git commit -m "feat(sell): add trading + sell polling flags to UserPreferences" +``` + +--- + +### Task 18: SellPollingWorker + SellPollingScheduler + +**Soubory:** +- Vytvorit: `accbot-android/app/src/main/java/com/accbot/dca/worker/SellPollingWorker.kt` +- Vytvorit: `accbot-android/app/src/main/java/com/accbot/dca/worker/SellPollingScheduler.kt` + +- [ ] **Krok 1: Prozkoumat DcaWorker pattern** + +```bash +head -100 accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt +``` + +Najit jak se schedule vypocitava `nextExecutionAt` z `cronExpression` / `DcaFrequency`. Bude sdilena utility funkce. + +- [ ] **Krok 2: Vytvorit SellPollingWorker** + +```kotlin +package com.accbot.dca.worker + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.domain.usecase.ResolvePendingTransactionsUseCase +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +@HiltWorker +class SellPollingWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted params: WorkerParameters, + private val database: DcaDatabase, + private val resolvePendingTransactionsUseCase: ResolvePendingTransactionsUseCase +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + return try { + val openSells = database.transactionDao().countOpenSells() + if (openSells == 0) return Result.success() + + resolvePendingTransactionsUseCase() + Result.success() + } catch (e: Exception) { + Result.retry() + } + } + + companion object { + const val WORK_NAME = "sell_polling" + } +} +``` + +- [ ] **Krok 3: Vytvorit SellPollingScheduler** + +```kotlin +package com.accbot.dca.worker + +import android.content.Context +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.domain.model.DcaFrequency +import com.accbot.dca.domain.util.calculateNextFireTime +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SellPollingScheduler @Inject constructor( + private val workManager: WorkManager, + private val userPreferences: UserPreferences +) { + fun rescheduleIfEnabled() { + if (!userPreferences.isPeriodicSellPollingEnabled()) { + cancel() + return + } + + val frequency = userPreferences.getSellPollingFrequency() + val cronOrConfig = userPreferences.getSellPollingCronExpression() + ?: userPreferences.getSellPollingScheduleConfig() + + val nextFire = calculateNextFireTime(frequency, cronOrConfig, Instant.now()) + val delayMs = (nextFire.toEpochMilli() - System.currentTimeMillis()).coerceAtLeast(0L) + + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(delayMs, java.util.concurrent.TimeUnit.MILLISECONDS) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .build() + + workManager.enqueueUniqueWork( + SellPollingWorker.WORK_NAME, + ExistingWorkPolicy.REPLACE, + request + ) + } + + fun cancel() { + workManager.cancelUniqueWork(SellPollingWorker.WORK_NAME) + } +} +``` + +**Pozn.:** `calculateNextFireTime` existuje nekde v codebase (pouziva ji DcaWorker). Overit `grep -rn "calculateNextFireTime\|fun.*nextFire\|toCronExpression" accbot-android/app/src/main/java/com/accbot/dca/domain/`. Reuse. + +- [ ] **Krok 4: Zaretez rescheduling - po kazdem worker doWork() naplanovat dalsi** + +V `SellPollingWorker` injectovat `SellPollingScheduler`: + +```kotlin +@HiltWorker +class SellPollingWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted params: WorkerParameters, + private val database: DcaDatabase, + private val resolvePendingTransactionsUseCase: ResolvePendingTransactionsUseCase, + private val sellPollingScheduler: SellPollingScheduler +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + return try { + val openSells = database.transactionDao().countOpenSells() + if (openSells > 0) { + resolvePendingTransactionsUseCase() + } + sellPollingScheduler.rescheduleIfEnabled() // naplanovat dalsi spusteni + Result.success() + } catch (e: Exception) { + sellPollingScheduler.rescheduleIfEnabled() // naplanovat i pri failu + Result.retry() + } + } + + companion object { const val WORK_NAME = "sell_polling" } +} +``` + +- [ ] **Krok 5: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 6: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/worker/ +git commit -m "feat(sell): add SellPollingWorker + SellPollingScheduler" +``` + +--- + +### Task 19: ProcessLifecycle observer pro onResume polling + +**Soubory:** +- Vytvorit: `accbot-android/app/src/main/java/com/accbot/dca/AppLifecycleObserver.kt` (nebo do existujiciho Application tridy) + +- [ ] **Krok 1: Najit Application tridu** + +```bash +grep -rn "class.*Application\|@HiltAndroidApp" accbot-android/app/src/main/java/com/accbot/dca/ | head -5 +``` + +- [ ] **Krok 2: Vytvorit lifecycle observer** + +V `AccBotApplication.kt` (nebo jak se jmenuje): + +```kotlin +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import com.accbot.dca.domain.usecase.ResolvePendingTransactionsUseCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltAndroidApp +class AccBotApplication : Application() { + + @Inject lateinit var resolvePendingTransactionsUseCase: ResolvePendingTransactionsUseCase + + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + override fun onCreate() { + super.onCreate() + // ... existujici ... + + ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + appScope.launch { + try { + resolvePendingTransactionsUseCase() + } catch (e: Exception) { + android.util.Log.w("AppLifecycle", "Polling on app start failed", e) + } + } + } + }) + } +} +``` + +- [ ] **Krok 3: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 4: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/ +git commit -m "feat(sell): trigger pending tx resolution on app foreground" +``` + +--- + +## Faze 5: UI - Settings + +### Task 20: Rozsirit SettingsScreen o Pokrocile sekci + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsScreen.kt` +- Upravit: ViewModel pro SettingsScreen (grep pro `SettingsViewModel`) + +- [ ] **Krok 1: Najit SettingsViewModel** + +```bash +grep -rn "class SettingsViewModel" accbot-android/app/src/main/java/com/accbot/dca/ +``` + +- [ ] **Krok 2: Pridat state pro trading toggle a periodic polling** + +Do `SettingsUiState`: + +```kotlin +val tradingEnabled: Boolean = false, +val periodicSellPollingEnabled: Boolean = false, +val sellPollingFrequency: DcaFrequency = DcaFrequency.HOURLY, +val sellPollingScheduleState: ScheduleBuilderState = ScheduleBuilderState() +``` + +- [ ] **Krok 3: Pridat ViewModel akce** + +```kotlin +fun setTradingEnabled(enabled: Boolean) { + userPreferences.setTradingEnabled(enabled) + if (!enabled) { + userPreferences.setPeriodicSellPolling(false, DcaFrequency.HOURLY, null, null) + sellPollingScheduler.cancel() + } + refreshState() +} + +fun setPeriodicSellPolling( + enabled: Boolean, + frequency: DcaFrequency, + cron: String?, + scheduleConfig: String? +) { + userPreferences.setPeriodicSellPolling(enabled, frequency, cron, scheduleConfig) + if (enabled) sellPollingScheduler.rescheduleIfEnabled() + else sellPollingScheduler.cancel() + refreshState() +} +``` + +- [ ] **Krok 4: Pridat Compose sekci do SettingsScreen** + +Najit konec existujiciho settings formu a pridat: + +```kotlin +SettingsSection(title = "Pokrocile") { + SwitchRow( + title = "Povolit prodeje", + subtitle = "Umozni u vybranych planu zadavat limitni prodejni prikazy a sledovat P&L.", + checked = uiState.tradingEnabled, + onCheckedChange = viewModel::setTradingEnabled + ) + + if (uiState.tradingEnabled) { + Divider() + + SwitchRow( + title = "Kontrolovat sell ordery na pozadi", + subtitle = "Periodicka kontrola stavu orderu. Zvysuje spotrebu baterie.", + checked = uiState.periodicSellPollingEnabled, + onCheckedChange = { enabled -> + viewModel.setPeriodicSellPolling( + enabled = enabled, + frequency = uiState.sellPollingFrequency, + cron = uiState.sellPollingScheduleState.toCronExpression().takeIf { uiState.sellPollingFrequency == DcaFrequency.CUSTOM }, + scheduleConfig = null + ) + } + ) + + if (uiState.periodicSellPollingEnabled) { + // Reuse schedule builder z AddPlanScreen + FrequencyPickerAndScheduleBuilder( + frequency = uiState.sellPollingFrequency, + state = uiState.sellPollingScheduleState, + onFrequencyChange = { freq -> + viewModel.setPeriodicSellPolling( + enabled = true, + frequency = freq, + cron = null, + scheduleConfig = null + ) + }, + onStateChange = { state -> + // Uloz state pro CUSTOM/DAILY/WEEKLY + } + ) + + Text( + text = "Caste kontroly zvysuji spotrebu baterie a pocitaji se do API limitu burzy.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline + ) + } + } +} +``` + +- [ ] **Krok 5: Pokud neexistuje sdilena komponenta FrequencyPickerAndScheduleBuilder** + +Extrahovat existujici Compose logiku z `AddPlanScreen.kt` (kde je ScheduleBuilder) do sdilene komponenty `accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ScheduleBuilder.kt`. Pouzit stejny Compose kod v SettingsScreen. + +- [ ] **Krok 6: Build check + instalace** + +```bash +cd accbot-android && ./gradlew assembleDebug && ./gradlew installDebug +``` + +Otevrit app, jit do Settings, overit ze: +- Toggle "Povolit prodeje" funguje +- Pri ON se odkryje "Kontrolovat na pozadi" +- Pri zapnuti periodic se odkryje frequency picker + schedule builder + +- [ ] **Krok 7: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/ +git commit -m "feat(sell): add Advanced section to SettingsScreen with trading + polling toggles" +``` + +--- + +## Faze 6: UI - Plan creation/edit + +### Task 21: Rozsirit AddPlanScreen o sell sekci (gated by global trading) + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt` +- Upravit: AddPlan ViewModel + +- [ ] **Krok 1: Injektovat UserPreferences do AddPlanViewModel a expose trading flag** + +```kotlin +val tradingEnabled: Boolean = userPreferences.isTradingEnabled() +``` + +- [ ] **Krok 2: Pridat state pro allowSells + targetProfitAmount** + +```kotlin +val allowSells: Boolean = false, +val targetProfitAmount: String = "", // raw input +val targetProfitAmountError: String? = null +``` + +Validace `targetProfitAmount`: musi byt prazdny nebo kladne cislo parsovatelne BigDecimal. + +- [ ] **Krok 3: Update CreateDcaPlanUseCase (pokud je potreba)** + +Zkontrolovat `CreateDcaPlanUseCase`, pokud akceptuje `DcaPlan`, automaticky dostane nova pole. Pokud ma explicitni parametry, pridat: + +```kotlin +suspend operator fun invoke( + // ... existujici ... + allowSells: Boolean = false, + targetProfitAmount: BigDecimal? = null +): Result +``` + +- [ ] **Krok 4: Pridat Compose sekci v AddPlanScreen** + +Na konec formu, pred submit button, pridat: + +```kotlin +if (uiState.tradingEnabled) { + SectionHeader(text = "Prodeje (volitelne)") + + SwitchRow( + title = "Povolit prodeje pro tento plan", + subtitle = null, + checked = uiState.allowSells, + onCheckedChange = viewModel::setAllowSells + ) + + if (uiState.allowSells) { + OutlinedTextField( + value = uiState.targetProfitAmount, + onValueChange = viewModel::setTargetProfitAmount, + label = { Text("Cil zisku (volitelne, v ${uiState.fiat})") }, + supportingText = { + Text("Plan-detail zobrazi progress bar k tomuto cili.") + }, + isError = uiState.targetProfitAmountError != null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth() + ) + } +} +``` + +- [ ] **Krok 5: Wire submit - predat nova pole do CreateDcaPlanUseCase** + +```kotlin +createDcaPlanUseCase( + // ... existujici ... + allowSells = uiState.allowSells, + targetProfitAmount = uiState.targetProfitAmount.takeIf { it.isNotBlank() }?.let { BigDecimal(it) } +) +``` + +- [ ] **Krok 6: Build check + manualni test** + +```bash +cd accbot-android && ./gradlew installDebug +``` + +Settings -> zapnout Povolit prodeje. AddPlan screen -> overit ze se zobrazi Prodeje sekce. Vypnout trading -> sekce mizi. + +- [ ] **Krok 7: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt +git add accbot-android/app/src/main/java/com/accbot/dca/ +git commit -m "feat(sell): add Prodeje section to AddPlanScreen (gated by trading_enabled)" +``` + +--- + +### Task 22: Rozsirit EditPlanScreen o sell sekci + confirm dialog + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanScreen.kt` +- Upravit: EditPlan ViewModel + +- [ ] **Krok 1: Stejna logika jako AddPlanScreen - pridat state + sekci** + +Identicke kroky jako Task 21 Kroky 1-4, jen v EditPlanScreen kontextu. Nacita existujici hodnoty `plan.allowSells`, `plan.targetProfitAmount` do state. + +- [ ] **Krok 2: Pridat confirm dialog pri vypnuti allowSells pokud jsou open ordery** + +V onClick `Ulozit` (nebo onChange allowSells toggle off): + +```kotlin +fun onToggleAllowSells(newValue: Boolean) { + if (!newValue && uiState.currentAllowSells) { + viewModelScope.launch { + val openSells = database.transactionDao().observeOpenSellsForPlan(planId).first().size + if (openSells > 0) { + _uiState.update { it.copy(showDisableSellsDialog = openSells) } + } else { + _uiState.update { it.copy(allowSells = false) } + } + } + } else { + _uiState.update { it.copy(allowSells = newValue) } + } +} +``` + +Compose: + +```kotlin +uiState.showDisableSellsDialog?.let { count -> + AlertDialog( + onDismissRequest = { viewModel.dismissDisableSellsDialog() }, + title = { Text("Vypnout prodeje?") }, + text = { Text("Mas $count otevrenych sell orderu. Vypnutim prodeju se skryje sell sekce, ale ordery na burze zustavaji. Musis je zrusit rucne pres burzu, nebo zapnutim prodeju a kliknutim Cancel.") }, + confirmButton = { + TextButton(onClick = { viewModel.confirmDisableSells() }) { + Text("Vypnout") + } + }, + dismissButton = { + TextButton(onClick = { viewModel.dismissDisableSellsDialog() }) { + Text("Zrusit") + } + } + ) +} +``` + +- [ ] **Krok 3: Build check + manualni test** + +- [ ] **Krok 4: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanScreen.kt +git commit -m "feat(sell): add Prodeje section + disable confirm dialog to EditPlanScreen" +``` + +--- + +## Faze 7: UI - Plan detail + sell wizard + +### Task 23: Plan detail - P&L card + open orders list + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt` +- Upravit: PlanDetails ViewModel +- Vytvorit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/PnLCard.kt` +- Vytvorit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/OpenSellsList.kt` + +- [ ] **Krok 1: ViewModel expose PlanPnL + open sells** + +```kotlin +val planPnL: StateFlow = combine( + transactionFlow, + spotPriceFlow +) { txs, spot -> + calculatePlanPnLUseCase(planId, spot) +}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + +val openSells: StateFlow> = + transactionDao.observeOpenSellsForPlan(planId) + .map { list -> list.map { it.toDomain() } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) +``` + +- [ ] **Krok 2: Vytvorit PnLCard composable** + +```kotlin +@Composable +fun PnLCard( + pnl: PlanPnL, + fiat: String, + targetAmount: BigDecimal?, + modifier: Modifier = Modifier +) { + Card(modifier.fillMaxWidth().padding(16.dp)) { + Column(Modifier.padding(16.dp)) { + Text("P&L", style = MaterialTheme.typography.titleMedium) + PnLRow("Drzeno:", "${pnl.currentCryptoHeld.stripTrailingZeros().toPlainString()} BTC") + pnl.currentValueFiat?.let { + PnLRow(" = hodnota:", "${formatFiat(it, fiat)}") + } + pnl.avgBuyPrice?.let { + PnLRow("Prum. nakup:", "${formatFiat(it, fiat)}") + } + pnl.realizedPnL?.let { + PnLRow("Realizovany:", formatPnL(it, fiat), pnlColor(it)) + } ?: PnLRow("Realizovany:", "-") + + pnl.unrealizedPnL?.let { + PnLRow("Nerealizovany:", formatPnL(it, fiat), pnlColor(it)) + } ?: PnLRow("Nerealizovany:", "-") + + pnl.netPnL?.let { + PnLRow("Net:", formatPnL(it, fiat), pnlColor(it), bold = true) + } + + if (targetAmount != null && pnl.targetProgressPct != null) { + Spacer(Modifier.height(8.dp)) + LinearProgressIndicator( + progress = pnl.targetProgressPct.toFloat().coerceIn(0f, 1f), + modifier = Modifier.fillMaxWidth() + ) + Text( + "Cil: ${formatFiat(targetAmount, fiat)} (${(pnl.targetProgressPct * 100).toInt()}%)", + style = MaterialTheme.typography.bodySmall + ) + } + } + } +} + +@Composable +private fun PnLRow(label: String, value: String, color: Color? = null, bold: Boolean = false) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, style = MaterialTheme.typography.bodyMedium) + Text( + value, + style = MaterialTheme.typography.bodyMedium, + color = color ?: LocalContentColor.current, + fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal + ) + } +} + +private fun pnlColor(value: BigDecimal): Color = when { + value > BigDecimal.ZERO -> Color(0xFF2E7D32) // green + value < BigDecimal.ZERO -> Color(0xFFC62828) // red + else -> Color.Unspecified +} + +private fun formatPnL(value: BigDecimal, fiat: String): String = + (if (value >= BigDecimal.ZERO) "+" else "") + formatFiat(value, fiat) +``` + +- [ ] **Krok 3: Vytvorit OpenSellsList composable** + +```kotlin +@Composable +fun OpenSellsList( + openSells: List, + onCancelClick: (Long) -> Unit, + modifier: Modifier = Modifier +) { + if (openSells.isEmpty()) return + + Card(modifier.fillMaxWidth().padding(16.dp)) { + Column(Modifier.padding(16.dp)) { + Text("Otevrene sell ordery (${openSells.size})", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + openSells.forEach { tx -> + OpenSellRow(tx, onCancelClick) + } + } + } +} + +@Composable +private fun OpenSellRow(tx: Transaction, onCancelClick: (Long) -> Unit) { + val requested = tx.requestedCryptoAmount ?: BigDecimal.ZERO + val filled = tx.cryptoAmount + val progressPct = if (requested > BigDecimal.ZERO) + filled.divide(requested, 4, RoundingMode.HALF_UP).multiply(BigDecimal(100)).toInt() else 0 + + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("${requested.toPlainString()} ${tx.crypto} @ ${tx.limitPrice?.toPlainString() ?: "-"} ${tx.fiat}") + if (tx.status == TransactionStatus.PARTIAL) { + Text("Partial: $progressPct% (${filled.toPlainString()} / ${requested.toPlainString()})", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary) + } else { + Text("Pending", style = MaterialTheme.typography.bodySmall) + } + } + IconButton(onClick = { onCancelClick(tx.id) }) { + Icon(Icons.Default.Close, contentDescription = "Zrusit order") + } + } +} +``` + +- [ ] **Krok 4: Vlozit komponenty do PlanDetailsScreen** + +V mistem layoutu (mezi buy info a transaction history): + +```kotlin +val sellUiVisible = remember(plan, userPrefs, exchangeApi) { + plan.allowSells && userPrefs.isTradingEnabled() && exchangeApi.supportsLimitSell +} + +if (sellUiVisible) { + pnl?.let { PnLCard(it, plan.fiat, plan.targetProfitAmount) } + OpenSellsList(openSells, onCancelClick = { viewModel.cancelSell(it) }) + Button( + onClick = { viewModel.openSellWizard() }, + enabled = (pnl?.currentCryptoHeld ?: BigDecimal.ZERO) > BigDecimal.ZERO + ) { + Text("+ Vytvorit prodejni prikaz") + } +} +``` + +- [ ] **Krok 5: Wire cancelSell akci ve ViewModelu** + +```kotlin +fun cancelSell(txId: Long) = viewModelScope.launch { + val result = cancelSellOrderUseCase(txId) + if (result.isFailure) { + _snackbar.emit("Zruseni orderu selhalo: ${result.exceptionOrNull()?.message}") + } +} +``` + +- [ ] **Krok 6: Build check + manualni test** + +```bash +cd accbot-android && ./gradlew installDebug +``` + +Otevrit plan-detail planu s `allowSells=true`. Overit: +- P&L card se zobrazuje (i s null hodnotami jako "-") +- Open sells list je prazdny (zatim nejsou sell transakce) +- Button "Vytvorit prodejni prikaz" je disabled pokud held=0, inak enabled + +- [ ] **Krok 7: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/ +git commit -m "feat(sell): add P&L card + open sells list to PlanDetailsScreen" +``` + +--- + +### Task 24: Sell wizard Krok 1 - zadani objednavky + +**Soubory:** +- Vytvorit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt` +- Vytvorit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt` + +- [ ] **Krok 1: Vytvorit SellWizardViewModel** + +```kotlin +@HiltViewModel +class SellWizardViewModel @Inject constructor( + private val validateUseCase: ValidateSellOrderUseCase, + private val placeSellUseCase: PlaceLimitSellUseCase, + private val calculatePnLUseCase: CalculatePlanPnLUseCase, + private val database: DcaDatabase, + private val credentialsStore: CredentialsStore, + private val exchangeApiFactory: ExchangeApiFactory, + private val userPreferences: UserPreferences +) : ViewModel() { + + data class UiState( + val planId: Long = 0, + val crypto: String = "", + val fiat: String = "", + val held: BigDecimal = BigDecimal.ZERO, + val spotPrice: BigDecimal? = null, + val avgBuyPrice: BigDecimal? = null, + val amountInput: String = "", // raw, v crypto + val priceInput: String = "", // raw, ve fiatu + val minOrderSize: BigDecimal = BigDecimal("0.0001"), + val validations: List = emptyList(), + val step: WizardStep = WizardStep.INPUT, + val submitting: Boolean = false, + val submitError: String? = null, + val showTimeoutDialog: Boolean = false + ) { + val canProceed: Boolean + get() = validations.none { it is SellValidation.HardError } && + amountInput.isNotBlank() && priceInput.isNotBlank() + } + + enum class WizardStep { INPUT, CONFIRM } + + // setters: setAmount, setPrice, chipActions (25/50/75/all, spot/breakeven/+10/+25) + // validate() - re-runs validateUseCase on every input change + // proceedToConfirm() - step = CONFIRM + // submit() - calls placeSellUseCase + // back() - step = INPUT +} +``` + +- [ ] **Krok 2: Vytvorit bottom sheet UI (zadani)** + +```kotlin +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SellWizardBottomSheet( + planId: Long, + onDismiss: () -> Unit, + viewModel: SellWizardViewModel = hiltViewModel() +) { + LaunchedEffect(planId) { viewModel.init(planId) } + + val state by viewModel.uiState.collectAsStateWithLifecycle() + ModalBottomSheet( + onDismissRequest = onDismiss, + modifier = Modifier.fillMaxHeight(0.95f) + ) { + when (state.step) { + SellWizardViewModel.WizardStep.INPUT -> SellInputStep(state, viewModel, onDismiss) + SellWizardViewModel.WizardStep.CONFIRM -> SellConfirmStep(state, viewModel) + } + } +} + +@Composable +private fun SellInputStep( + state: SellWizardViewModel.UiState, + vm: SellWizardViewModel, + onDismiss: () -> Unit +) { + Column(Modifier.padding(16.dp).verticalScroll(rememberScrollState())) { + TopAppBar( + title = { Text("Limit sell ${state.crypto}/${state.fiat}") }, + navigationIcon = { IconButton(onDismiss) { Icon(Icons.Default.Close, null) } } + ) + + InfoRow("Aktualni cena:", state.spotPrice?.toPlainString() ?: "-") + InfoRow("Prum. nakup:", state.avgBuyPrice?.toPlainString() ?: "-") + InfoRow("K dispozici:", "${state.held.toPlainString()} ${state.crypto}") + + SectionHeader("Mnozstvi") + OutlinedTextField( + value = state.amountInput, + onValueChange = vm::setAmount, + trailingIcon = { Text(state.crypto) }, + modifier = Modifier.fillMaxWidth() + ) + Row { listOf(25, 50, 75, 100).forEach { pct -> + AssistChip(onClick = { vm.setAmountPct(pct) }, label = { Text(if (pct == 100) "Vse" else "$pct%") }) + } } + + SectionHeader("Limitni cena") + OutlinedTextField( + value = state.priceInput, + onValueChange = vm::setPrice, + trailingIcon = { Text(state.fiat) }, + modifier = Modifier.fillMaxWidth() + ) + Row { + AssistChip(onClick = vm::setPriceSpot, label = { Text("Trzni") }) + AssistChip(onClick = vm::setPriceBreakeven, label = { Text("Breakeven") }) + AssistChip(onClick = { vm.setPricePct(10) }, label = { Text("+10%") }) + AssistChip(onClick = { vm.setPricePct(25) }, label = { Text("+25%") }) + } + + Spacer(Modifier.height(16.dp)) + SectionHeader("Souhrn") + val amountBD = state.amountInput.toBigDecimalOrNull() ?: BigDecimal.ZERO + val priceBD = state.priceInput.toBigDecimalOrNull() ?: BigDecimal.ZERO + InfoRow("Ziskate:", "${(amountBD * priceBD).toPlainString()} ${state.fiat}") + state.avgBuyPrice?.let { avg -> + val profit = (priceBD - avg) * amountBD + InfoRow("Zisk vs prum:", formatPnL(profit, state.fiat), pnlColor(profit)) + } + + // validation messages + state.validations.forEach { v -> + when (v) { + is SellValidation.HardError -> + Text(v.message, color = MaterialTheme.colorScheme.error) + is SellValidation.InstantFillInfo -> + InfoBanner("Prodej probehne okamzite. Limitni cena je pod aktualni trzni (${v.spot.toPlainString()} ${state.fiat}). Prikaz se zfilluje ihned za nejvyssi nabidku na burze.") + is SellValidation.FarFromMarketWarning -> + WarningBanner("Cena vysoko nad trhem - prodej se nemusi zfillovat dlouho.") + is SellValidation.Ok -> { /* noop */ } + } + } + + Button( + onClick = vm::proceedToConfirm, + enabled = state.canProceed, + modifier = Modifier.fillMaxWidth() + ) { Text("Pokracovat") } + } +} +``` + +- [ ] **Krok 3: Implementovat chip actions ve ViewModelu** + +```kotlin +fun setAmountPct(pct: Int) { + val available = state.held - sumOpenSellRequested(state.planId) + val amount = available.multiply(BigDecimal(pct)).divide(BigDecimal(100), 8, RoundingMode.HALF_UP) + setAmount(amount.stripTrailingZeros().toPlainString()) +} + +fun setPriceSpot() = state.spotPrice?.let { setPrice(it.toPlainString()) } +fun setPriceBreakeven() = state.avgBuyPrice?.let { setPrice(it.toPlainString()) } +fun setPricePct(pct: Int) = state.avgBuyPrice?.let { avg -> + val price = avg.multiply(BigDecimal("1.${pct.toString().padStart(2, '0')}")) + setPrice(price.setScale(2, RoundingMode.HALF_UP).toPlainString()) +} +``` + +- [ ] **Krok 4: Build check + manualni test** + +```bash +cd accbot-android && ./gradlew installDebug +``` + +Overit ze bottom sheet otevre, inputy funguji, chipy pocitaji spravne, validace se zobrazuji (hlavne instant-fill banner kdyz limit < spot). + +- [ ] **Krok 5: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/ +git commit -m "feat(sell): add SellWizardBottomSheet Step 1 (input)" +``` + +--- + +### Task 25: Sell wizard Krok 2 - potvrzeni + submit + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt` +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt` + +- [ ] **Krok 1: Pridat SellConfirmStep composable** + +```kotlin +@Composable +private fun SellConfirmStep( + state: SellWizardViewModel.UiState, + vm: SellWizardViewModel +) { + val amountBD = state.amountInput.toBigDecimalOrNull() ?: BigDecimal.ZERO + val priceBD = state.priceInput.toBigDecimalOrNull() ?: BigDecimal.ZERO + + Column(Modifier.padding(16.dp)) { + TopAppBar( + title = { Text("Potvrdit prodej") }, + navigationIcon = { IconButton(vm::back) { Icon(Icons.Default.ArrowBack, null) } } + ) + + SummaryRow("Burza:", state.exchangeName) + SummaryRow("Plan:", state.planName) + SummaryRow("Side:", "PRODEJ") + SummaryRow("Mnozstvi:", "${amountBD.toPlainString()} ${state.crypto}") + SummaryRow("Limitni cena:", "${priceBD.toPlainString()} ${state.fiat}") + SummaryRow("Ziskate:", "${(amountBD * priceBD).toPlainString()} ${state.fiat}") + + WarningBanner("Tato akce odesle prikaz na ${state.exchangeName} a nelze ji vratit. Prikaz lze pote zrusit, dokud neni castecne/celkem zfillovan.") + + state.submitError?.let { err -> + Text(err, color = MaterialTheme.colorScheme.error) + } + + Row { + OutlinedButton(onClick = vm::back, enabled = !state.submitting) { Text("Zpet") } + Spacer(Modifier.width(8.dp)) + Button( + onClick = { vm.submit() }, + enabled = !state.submitting + ) { + if (state.submitting) CircularProgressIndicator() else Text("Odeslat") + } + } + } + + if (state.showTimeoutDialog) { + AlertDialog( + onDismissRequest = vm::dismissTimeoutDialog, + title = { Text("Nelze overit stav prikazu") }, + text = { Text("Spojeni s burzou selhalo. Zkontroluj otevrene ordery na burze pres web a v pripade potreby zrus duplicitu.") }, + confirmButton = { Button(onClick = vm::dismissTimeoutDialog) { Text("OK") } } + ) + } +} +``` + +- [ ] **Krok 2: Implementovat submit ve ViewModelu** + +```kotlin +fun submit() = viewModelScope.launch { + _uiState.update { it.copy(submitting = true, submitError = null) } + + try { + val amount = state.amountInput.toBigDecimal() + val price = state.priceInput.toBigDecimal() + + val result = withTimeoutOrNull(10_000L) { + placeSellUseCase(state.planId, amount, price) + } + + when { + result == null -> { + _uiState.update { it.copy(submitting = false, showTimeoutDialog = true) } + } + result.isSuccess -> { + _navEvents.emit(NavEvent.Dismiss) + _snackbar.emit("Prikaz vytvoren") + } + result.isFailure -> { + val msg = result.exceptionOrNull()?.message ?: "Neznama chyba" + _uiState.update { it.copy(submitting = false, submitError = msg) } + } + } + } catch (e: Exception) { + _uiState.update { it.copy(submitting = false, submitError = e.message ?: "Neznama chyba") } + } +} + +fun dismissTimeoutDialog() { + _uiState.update { it.copy(showTimeoutDialog = false) } +} +``` + +- [ ] **Krok 3: Wire bottom sheet ve Plan detail screen** + +V PlanDetailsScreen: + +```kotlin +var sellWizardOpen by rememberSaveable { mutableStateOf(false) } + +if (sellWizardOpen) { + SellWizardBottomSheet( + planId = planId, + onDismiss = { sellWizardOpen = false } + ) +} + +Button(onClick = { sellWizardOpen = true }) { Text("+ Vytvorit prodejni prikaz") } +``` + +- [ ] **Krok 4: Build check + manualni sandbox test** + +```bash +cd accbot-android && ./gradlew installDebug +``` + +Sandbox mode, plan s allowSells a koupene BTC. Otevrit wizard, zadat 0.0001 BTC @ 1 200 000. Pokracovat. Potvrdit. Overit ze: +- API call prosel (logy) +- Transakce v DB ma status=PENDING, side=SELL, requestedCryptoAmount=0.0001 +- Bottom sheet se zavrel +- Plan-detail ukazuje open order + +- [ ] **Krok 5: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/ +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt +git commit -m "feat(sell): add SellWizardBottomSheet Step 2 (confirm + submit) + wire into plan-detail" +``` + +--- + +## Faze 8: UI - Chart, History, Portfolio, Dashboard + +### Task 26: Chart sell markery + +**Soubory:** +- Upravit: existujici chart komponenta na plan-detail (grep `PlanDetailChart\|chart` v presentation/screens/plans/) + +- [ ] **Krok 1: Najit chart komponentu** + +```bash +grep -rn "Canvas\|drawLine\|Chart" accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/ +``` + +- [ ] **Krok 2: Rozsirit chart o BUY/SELL markery** + +Pro kazdou transakci s `status IN (COMPLETED, PARTIAL)`: + +```kotlin +// v Canvas drawScope: +transactions.filter { it.status in setOf(TransactionStatus.COMPLETED, TransactionStatus.PARTIAL) }.forEach { tx -> + val x = xForTime(tx.executedAt) + val y = yForValue(tx.price) // nebo y = size.height - 20.dp.toPx() pro fixed bottom axis + + val color = when (tx.side) { + TransactionSide.BUY -> Color(0xFF2E7D32) + TransactionSide.SELL -> Color(0xFFC62828) + } + val sizePx = 6.dp.toPx() + + val path = androidx.compose.ui.graphics.Path().apply { + if (tx.side == TransactionSide.BUY) { + // Trojuhelnik nahoru ^ + moveTo(x, y - sizePx) + lineTo(x - sizePx, y + sizePx) + lineTo(x + sizePx, y + sizePx) + } else { + // Trojuhelnik dolu v + moveTo(x, y + sizePx) + lineTo(x - sizePx, y - sizePx) + lineTo(x + sizePx, y - sizePx) + } + close() + } + drawPath(path, color) +} +``` + +- [ ] **Krok 3: Tooltip na tap** + +Rozsirit existujici tap handler aby detect klik na marker a zobrazit tooltip se detaily tx (mnozstvi, cena, status, side). + +- [ ] **Krok 4: Build check + manualni test** + +Test: plan s aspon 1 COMPLETED buy a 1 COMPLETED sell -> v chartu vidim zeleny trojuhelnik nahoru a cerveny dolu. + +- [ ] **Krok 5: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/ +git commit -m "feat(sell): add BUY/SELL markers to plan chart" +``` + +--- + +### Task 27: HistoryScreen - BUY/SELL icons + filter + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt` + +- [ ] **Krok 1: Pridat filter chip** + +```kotlin +enum class HistoryFilter { ALL, BUYS, SELLS, PENDING } + +var filter by rememberSaveable { mutableStateOf(HistoryFilter.ALL) } + +Row { + FilterChip(filter == HistoryFilter.ALL, { filter = HistoryFilter.ALL }, { Text("Vse") }) + FilterChip(filter == HistoryFilter.BUYS, { filter = HistoryFilter.BUYS }, { Text("Nakupy") }) + FilterChip(filter == HistoryFilter.SELLS, { filter = HistoryFilter.SELLS }, { Text("Prodeje") }) + FilterChip(filter == HistoryFilter.PENDING, { filter = HistoryFilter.PENDING }, { Text("Pending") }) +} + +val filtered = transactions.filter { tx -> + when (filter) { + HistoryFilter.ALL -> true + HistoryFilter.BUYS -> tx.side == TransactionSide.BUY + HistoryFilter.SELLS -> tx.side == TransactionSide.SELL + HistoryFilter.PENDING -> tx.status in setOf(TransactionStatus.PENDING, TransactionStatus.PARTIAL) + } +} +``` + +- [ ] **Krok 2: Upravit item rendering** + +```kotlin +@Composable +fun TransactionRow(tx: Transaction) { + val (icon, color, sign) = when (tx.side) { + TransactionSide.BUY -> Triple(Icons.Default.ArrowDownward, Color(0xFF2E7D32), "+") + TransactionSide.SELL -> Triple(Icons.Default.ArrowUpward, Color(0xFFC62828), "-") + } + Row { + Icon(icon, contentDescription = null, tint = color) + Column { + Text("${sign}${tx.cryptoAmount} ${tx.crypto}") + Text("${if (tx.side == TransactionSide.BUY) "-" else "+"}${tx.fiatAmount} ${tx.fiat}") + } + } +} +``` + +- [ ] **Krok 3: Build check + manualni test** + +- [ ] **Krok 4: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt +git commit -m "feat(sell): add BUY/SELL icons + filter chips to HistoryScreen" +``` + +--- + +### Task 28: TransactionDetailsScreen - sell-specific fields + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/history/TransactionDetailsScreen.kt` + +- [ ] **Krok 1: Pridat rendering pro SELL pole** + +```kotlin +if (tx.side == TransactionSide.SELL) { + DetailRow("Limitni cena:", tx.limitPrice?.toPlainString() ?: "-") + val requested = tx.requestedCryptoAmount ?: BigDecimal.ZERO + DetailRow("Vyplneno:", "${tx.cryptoAmount} / $requested ${tx.crypto} (${progressPct}%)") + tx.price?.let { DetailRow("Avg fill price:", it.toPlainString()) } + + if (tx.status in setOf(TransactionStatus.PENDING, TransactionStatus.PARTIAL)) { + Button(onClick = { viewModel.cancelOrder(tx.id) }) { + Text("Zrusit order") + } + } +} +``` + +- [ ] **Krok 2: Wire cancel ve ViewModelu** + +```kotlin +fun cancelOrder(txId: Long) = viewModelScope.launch { + cancelSellOrderUseCase(txId).onFailure { e -> + _snackbar.emit("Zruseni selhalo: ${e.message}") + } +} +``` + +- [ ] **Krok 3: Build check + manualni test** + +- [ ] **Krok 4: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/history/ +git commit -m "feat(sell): add sell-specific fields + cancel to TransactionDetailsScreen" +``` + +--- + +### Task 29: PortfolioScreen - realized + net P&L + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt` +- Upravit: PortfolioViewModel + CalculatePortfolioUseCase + +- [ ] **Krok 1: Pridat realized + net P&L do CalculatePortfolioUseCase** + +Najit kde se pocita portfolio summary (`CalculatePortfolioUseCase` nebo inline v ViewModelu). Pridat: + +```kotlin +val totalRealizedFiat = completedOrPartialTxs + .filter { it.side == TransactionSide.SELL } + .sumOf { it.fiatAmount } + +val currentHeldValueFiat = /* existing calc */ +val totalInvestedFiat = completedOrPartialTxs + .filter { it.side == TransactionSide.BUY } + .sumOf { it.fiatAmount } + +val netPnLFiat = currentHeldValueFiat + totalRealizedFiat - totalInvestedFiat +``` + +- [ ] **Krok 2: Expose v UiState (gated by isTradingEnabled)** + +```kotlin +val totalRealized: BigDecimal = BigDecimal.ZERO, +val netPnL: BigDecimal? = null, +val showTradingMetrics: Boolean = false // = userPreferences.isTradingEnabled() +``` + +- [ ] **Krok 3: Render v Compose** + +V PortfolioScreen: + +```kotlin +if (uiState.showTradingMetrics && uiState.totalRealized > BigDecimal.ZERO) { + SummaryRow("Celkem realizovano:", "${uiState.totalRealized} ${uiState.fiat}") +} +uiState.netPnL?.takeIf { uiState.showTradingMetrics }?.let { + SummaryRow("Net P&L:", formatPnL(it, uiState.fiat), pnlColor(it)) +} +``` + +- [ ] **Krok 4: Build check + manualni test** + +- [ ] **Krok 5: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/ +git commit -m "feat(sell): add realized + net P&L to PortfolioScreen" +``` + +--- + +### Task 30: DashboardScreen - open sells card + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt` +- Upravit: Dashboard ViewModel + +- [ ] **Krok 1: Expose open sells per plan ve ViewModelu** + +```kotlin +val openSellsByPlan: StateFlow>> = + transactionDao.observeOpenSells() + .map { list -> list.groupBy { it.planId } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyMap()) +``` + +(`observeOpenSells()` - pridat do DAO pokud neexistuje: `SELECT * FROM transactions WHERE side='SELL' AND status IN ('PENDING','PARTIAL')`) + +- [ ] **Krok 2: Render cards v DashboardScreen** + +Pro kazdy plan s openSells: + +```kotlin +uiState.openSellsByPlan.forEach { (planId, sells) -> + val plan = planLookup[planId] ?: return@forEach + Card(onClick = { nav.navigate("plan/$planId") }) { + Column(Modifier.padding(16.dp)) { + Text("${plan.name}: ${sells.size} open sell${if (sells.size > 1) "s" else ""}") + sells.firstOrNull()?.let { tx -> + Text("${tx.requestedCryptoAmount ?: tx.cryptoAmount} ${tx.crypto} @ ${tx.limitPrice} ${tx.fiat}") + } + uiState.spotPrices[plan.crypto]?.let { + Text("Aktualni trzni: $it ${plan.fiat}", + style = MaterialTheme.typography.bodySmall) + } + } + } +} +``` + +- [ ] **Krok 3: Build check + manualni test** + +- [ ] **Krok 4: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt +git commit -m "feat(sell): add open sells card to DashboardScreen" +``` + +--- + +## Faze 9: Edge cases, polish, testing + +### Task 31: Block plan delete pokud jsou open ordery + +**Soubory:** +- Upravit: `DeleteDcaPlanUseCase` (grep pro tento nebo obdobny) +- Upravit: UI kde se spousti delete (pravdepodobne PlanDetailsScreen nebo EditPlanScreen) + +- [ ] **Krok 1: Najit DeletePlanUseCase** + +```bash +grep -rn "DeleteDcaPlan\|deletePlan" accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ +``` + +- [ ] **Krok 2: Pridat check v use case** + +```kotlin +suspend operator fun invoke(planId: Long): Result { + val openSells = database.transactionDao().observeOpenSellsForPlan(planId).first() + if (openSells.isNotEmpty()) { + return Result.failure( + IllegalStateException("Plan ma ${openSells.size} open sell orderu. Zrus je nejdrive.") + ) + } + database.dcaPlanDao().deletePlan(planId) + return Result.success(Unit) +} +``` + +- [ ] **Krok 3: V UI zobrazit alert pri failure** + +V delete handler (pravdepodobne ve ViewModelu): + +```kotlin +fun deletePlan(planId: Long) = viewModelScope.launch { + val result = deletePlanUseCase(planId) + if (result.isFailure) { + _dialog.emit(Dialog.CannotDelete(result.exceptionOrNull()?.message)) + } +} +``` + +A Compose: + +```kotlin +uiState.dialog?.let { d -> + when (d) { + is Dialog.CannotDelete -> AlertDialog( + onDismissRequest = { vm.dismissDialog() }, + title = { Text("Nelze smazat plan") }, + text = { Text(d.message ?: "Plan ma otevrene ordery.") }, + confirmButton = { Button(vm::dismissDialog) { Text("OK") } } + ) + } +} +``` + +- [ ] **Krok 4: Build check + manualni test** + +Smaz plan s open sell order -> alert. Cancel order. Smaz znovu -> uspech. + +- [ ] **Krok 5: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/ +git commit -m "feat(sell): block plan delete when open sell orders exist" +``` + +--- + +### Task 32: Pull-to-refresh integration + +**Soubory:** +- Upravit: `PlanDetailsScreen.kt` + +- [ ] **Krok 1: Wire pull-to-refresh** + +Pokud existuje `PullToRefreshBox` nebo `SwipeRefresh` v projektu, reuse. Jinak: + +```kotlin +val refreshing by viewModel.refreshing.collectAsStateWithLifecycle() +val state = rememberPullToRefreshState() + +Box( + Modifier.pullToRefresh(state, refreshing, { viewModel.refresh() }) +) { + // existujici content + PullToRefreshContainer(state = state, modifier = Modifier.align(Alignment.TopCenter)) +} +``` + +- [ ] **Krok 2: V ViewModelu** + +```kotlin +private val _refreshing = MutableStateFlow(false) +val refreshing = _refreshing.asStateFlow() + +fun refresh() = viewModelScope.launch { + _refreshing.value = true + try { + resolvePendingTransactionsUseCase() + } finally { + _refreshing.value = false + } +} +``` + +- [ ] **Krok 3: Build check + manualni test** + +Pull-down -> spinner -> pokud byly pending/partial ordery, aktualizovat. + +- [ ] **Krok 4: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/ +git commit -m "feat(sell): add pull-to-refresh to plan-detail for order status polling" +``` + +--- + +### Task 33: Manualni sandbox E2E test - Coinmate + +**Zadne soubory ke zmene - ciste testovaci task.** + +- [ ] **Krok 1: Pripravit sandbox** + +Zapnout sandbox mode v Settings. Overit ze Coinmate credentials sandbox jsou platne. + +- [ ] **Krok 2: Setup** + +- Vytvorit plan BTC/CZK s `allowSells=true`, `targetProfitAmount=10000` +- Spustit 3 rucni buys po 100 CZK aby byl v planu nejaky BTC (pres "Run Now") +- Overit ze buy transakce jsou status=COMPLETED, held > 0 + +- [ ] **Krok 3: Scenare** + +**Scenar A - standardni limit sell nad trhem:** +- Otevrit wizard, 25% z held, price = spot × 1.1 +- Pokracovat -> Potvrdit -> Odeslat +- Overit: + - V plan-detail open orders: 1 order, status=PENDING + - Pred pull-to-refresh: status se nemeni + - Po chvili / pull-to-refresh: status stale PENDING (order se nefilluje nad trhem) + +**Scenar B - instant fill (limit pod trhem):** +- Wizard, 10% z held, price = spot × 0.5 +- Validace: instant-fill banner zobrazen +- Odeslat +- Po pull-to-refresh (1-2s): status = COMPLETED, cryptoAmount = requested + +**Scenar C - cancel:** +- Wizard, price = spot × 2 (nad trhem) +- Odeslat -> PENDING +- V plan-detail kliknout cancel ikonku +- Overit: + - Tlacitko cancel trigger + - Transakce: status = FAILED + - Na burze (pres web): order canceled + +**Scenar D - plan delete block:** +- Plan s open sell orderem +- Smazat plan -> alert "Nelze smazat" +- Cancel order +- Smazat plan -> uspech + +- [ ] **Krok 4: Verifikace migrace** + +Backup aktualni DB (export pres existujici backup flow). Pokud byly v backupu plany, zkusit restore na cistou instalaci -> plany + transakce projdou mapovanim, vcetne `allowSells`, `side`, atd. + +- [ ] **Krok 5: Poznamky k chybam pripadne opravy** + +Pokud scenare selzou, opravit konkretni bug (identifikovat task) a projit scenare znovu. + +- [ ] **Krok 6: Commit (pokud byly opravy)** + +```bash +git commit -m "fix(sell): ..." +``` + +--- + +### Task 34: Manualni sandbox E2E test - Binance + +**Zadne soubory ke zmene - ciste testovaci task.** + +- [ ] **Krok 1: Setup Binance testnet credentials** + +Dle dokumentace aplikace. Zapnout sandbox mode. + +- [ ] **Krok 2: Opakovat scenare A-D z Task 33, tentokrat na Binance** + +Plan BTC/USDT nebo BTC/EUR. Krome orderu pres Binance endpointu `/api/v3/order`, overit: +- `limitSell` zakonci s numerickym orderId (Binance vraci long) +- `cancelOrder` vyzaduje `symbol + orderId` - funguje +- `getOrderStatus` vraci `executedQty` a `cummulativeQuoteQty` ktere se mapuji spravne + +- [ ] **Krok 3: Verifikace - vydal jsi appku s oboustrannou podporou** + +Po obou E2E testech (Coinmate + Binance) je MVP ready. + +- [ ] **Krok 4: Final commit (pokud opravy)** + +--- + +## Summary + +**Celkem tasku:** 34 +**Predpokladany rozsah:** 3-5 dnu pro experienced Kotlin/Compose dev, vice pro nezkusene s Room / WorkManager / Hilt patterns. + +**Kriticke zavislosti v poradi:** +- Tasky 1-7 (datovy model) MUSI byt hotove pred 8+ (Exchange API) +- Task 8 je breaking change, opravy v 9-11 +- Task 12 zavisi na 6 (DAO queries) a 8-11 (API refactor) +- Tasky 20+ (UI) zavisi na 12-16 (use cases) +- Tasky 33-34 (E2E) zavisi na vsem + +**Vedlejsi dulezite:** +- ProGuard keep rules: Task 7 Krok 6 - overit ze Gson modely v `domain.model` package nejsou shrinknute +- DAO `observeOpenSells` (bez planId filter) pro Dashboard - Task 30 +- Reuse `ScheduleBuilderState` Compose komponenty - Task 20 Krok 5 (pripadne extract do shared) diff --git a/docs/superpowers/plans/2026-05-09-sell-cost-basis-and-ladder.md b/docs/superpowers/plans/2026-05-09-sell-cost-basis-and-ladder.md new file mode 100644 index 0000000..694ebbd --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-sell-cost-basis-and-ladder.md @@ -0,0 +1,2372 @@ +# Sell wizard - cost basis + ladder Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rozsirit sell wizard o cost basis kalkulacku (timestamp-aware cheapest-first), tripolovou kalkulacku (mnozstvi/cena/cisty vynos) s fee math, loss warning, profit summary, a volitelny ladder mod pro scale-out strategie. Anti-emocionalni decision support. + +**Architecture:** Cost basis algoritmus je cista funkce (TDD-friendly), volana z `SellWizardViewModel`. Tripolova kalkulacka je separatni pure helper. ViewModel drzi state machine pro single i ladder mod. UI v existujicim `SellWizardBottomSheet` (rozsireni, ne nova obrazovka). Zadna DB schema zmena, vse stateless. + +**Tech Stack:** Kotlin, Jetpack Compose, Hilt DI, Room DAO (read-only pro nas), kotlinx.coroutines, JUnit 4 + Kotlin coroutines test (nove pridavame). + +**Spec:** `docs/superpowers/specs/2026-05-09-sell-cost-basis-and-ladder-design.md` + +**Existing files of interest:** +- `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt` +- `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt` +- `accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt` +- `accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLimitSellUseCase.kt` +- `accbot-android/app/src/main/java/com/accbot/dca/exchange/ExchangeApi.kt` +- `accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt:218` - `getTransactionsByPlanSync` +- `accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt` - `TransactionEntity` + +**Branch:** zustavame na `feature/dca-sell-extension`. + +--- + +## Faze 1: Foundation - model a algoritmus + +### Task 1: Pridat RemainingInventory data class + +**Files:** +- Create: `accbot-android/app/src/main/java/com/accbot/dca/domain/model/RemainingInventory.kt` + +- [ ] **Krok 1: Vytvorit soubor** + +```kotlin +package com.accbot.dca.domain.model + +import java.math.BigDecimal + +/** + * Vystup CalculatePlanCostBasisUseCase - timestamp-aware cheapest-first + * remaining inventory po aplikaci historickych a pending sells. + */ +data class RemainingInventory( + /** Soucet zbyvajiciho crypta napric vsemi buys s nezkonzumovanou casti. */ + val available: BigDecimal, + + /** Volume-weighted prumerna nakupni cena z [perBuyDetail]. Null kdyz available == 0. */ + val weightedAvgPrice: BigDecimal?, + + /** Per-buy zbytky (jen buys s remaining > 0). Pro debug a future per-fill features. */ + val perBuyDetail: List, + + /** > 0 kdyz historicke sells presahly buys (data inconsistency, napr. po importu). */ + val deficit: BigDecimal +) + +data class RemainingBuy( + val transactionId: Long, + val price: BigDecimal, + val remaining: BigDecimal +) +``` + +- [ ] **Krok 2: Build check** + +Run: +```bash +export JAVA_HOME="/c/Program Files/Android/Android Studio/jbr" +cd accbot-android && ./gradlew :app:compileDebugKotlin +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Krok 3: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/domain/model/RemainingInventory.kt +git commit -m "feat(sell): add RemainingInventory model for cost basis algorithm" +``` + +--- + +### Task 2: Setup unit test infra + +Projekt zatim nema `src/test/` (jen androidTest + screenshotTest). Pridame JUnit 4 + Kotlin test deps a vytvorime test source set. + +**Files:** +- Modify: `accbot-android/app/build.gradle.kts` +- Create: `accbot-android/app/src/test/java/com/accbot/dca/.gitkeep` (placeholder pro git) + +- [ ] **Krok 1: Pridat test dependencies** + +V `accbot-android/app/build.gradle.kts` v sekci `dependencies { ... }` pridat (pokud uz tam nejsou): + +```kotlin +testImplementation("junit:junit:4.13.2") +testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") +testImplementation("org.jetbrains.kotlin:kotlin-test:1.9.22") +``` + +(Verze sladit s existujicimi - mrknout do `gradle/libs.versions.toml` pokud projekt pouziva version catalog. Pokud existuje `testImplementation(libs.junit)` apod., pouzit aliasy.) + +- [ ] **Krok 2: Vytvorit test source set folder** + +```bash +mkdir -p accbot-android/app/src/test/java/com/accbot/dca +touch accbot-android/app/src/test/java/com/accbot/dca/.gitkeep +``` + +- [ ] **Krok 3: Build check** + +Run: +```bash +cd accbot-android && ./gradlew :app:compileDebugUnitTestKotlin +``` + +Expected: BUILD SUCCESSFUL (zadne testy zatim, jen kompilace test source setu). + +- [ ] **Krok 4: Commit** + +```bash +git add accbot-android/app/build.gradle.kts accbot-android/app/src/test/ +git commit -m "chore: setup JUnit 4 + coroutines test infra" +``` + +--- + +### Task 3: CalculatePlanCostBasisUseCase + unit testy (TDD) + +**Approach:** Algoritmus extrahovat do pure funkce v companion objectu, aby se daly testovat bez DB / Hilt. + +**Files:** +- Create: `accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCase.kt` +- Create: `accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCaseTest.kt` + +- [ ] **Krok 1: Napsat selhavajici testy nejdrive (TDD)** + +Soubor `accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCaseTest.kt`: + +```kotlin +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.TransactionEntity +import com.accbot.dca.domain.model.Exchange +import com.accbot.dca.domain.model.TransactionSide +import com.accbot.dca.domain.model.TransactionStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.math.BigDecimal +import java.time.Instant + +class CalculatePlanCostBasisUseCaseTest { + + private val t0: Instant = Instant.parse("2026-01-01T00:00:00Z") + private fun ts(daysOffset: Long): Instant = t0.plusSeconds(daysOffset * 86_400) + + private fun buy( + id: Long, + crypto: BigDecimal, + price: BigDecimal, + executedAt: Instant, + status: TransactionStatus = TransactionStatus.COMPLETED + ): TransactionEntity = TransactionEntity( + id = id, + planId = 1, + connectionId = 1, + exchange = Exchange.COINMATE, + crypto = "BTC", + fiat = "CZK", + fiatAmount = crypto * price, + cryptoAmount = crypto, + price = price, + fee = BigDecimal.ZERO, + feeAsset = "CZK", + status = status, + exchangeOrderId = "buy-$id", + executedAt = executedAt, + side = TransactionSide.BUY + ) + + private fun sell( + id: Long, + crypto: BigDecimal, + price: BigDecimal, + executedAt: Instant, + status: TransactionStatus = TransactionStatus.COMPLETED, + requested: BigDecimal? = null + ): TransactionEntity = TransactionEntity( + id = id, + planId = 1, + connectionId = 1, + exchange = Exchange.COINMATE, + crypto = "BTC", + fiat = "CZK", + fiatAmount = crypto * price, + cryptoAmount = crypto, + price = price, + fee = BigDecimal.ZERO, + feeAsset = "CZK", + status = status, + exchangeOrderId = "sell-$id", + executedAt = executedAt, + side = TransactionSide.SELL, + requestedCryptoAmount = requested ?: crypto, + limitPrice = price + ) + + @Test + fun `prazdny plan vraci available=0 a avg=null`() { + val result = CalculatePlanCostBasisUseCase.computeCostBasis(emptyList()) + assertEquals(BigDecimal.ZERO, result.available) + assertNull(result.weightedAvgPrice) + assertTrue(result.perBuyDetail.isEmpty()) + assertEquals(BigDecimal.ZERO, result.deficit) + } + + @Test + fun `jeden buy bez sells - available a avg jsou z buyu`() { + val txs = listOf(buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0))) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + assertEquals(0, BigDecimal("1").compareTo(result.available)) + assertEquals(0, BigDecimal("1000000").compareTo(result.weightedAvgPrice!!)) + } + + @Test + fun `dva buys, sell konzumuje cheapest first`() { + val txs = listOf( + buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0)), // cheaper + buy(2, BigDecimal("1"), BigDecimal("2000000"), ts(1)), + sell(3, BigDecimal("0.5"), BigDecimal("2500000"), ts(2)) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + // 0.5 BTC zkonzumovano z 1M buyu, zbyva 0.5 BTC @ 1M + 1 BTC @ 2M + assertEquals(0, BigDecimal("1.5").compareTo(result.available)) + // weighted avg = (0.5 × 1M + 1 × 2M) / 1.5 = 5/3 M = 1666666.67 + val expected = BigDecimal("1666666.66666667") + assertEquals(0, expected.compareTo(result.weightedAvgPrice!!)) + } + + @Test + fun `novy levny buy po sellu neovlivni avg pro driv prodane`() { + // Buy 1M, sell 0.5 (consumes 0.5 z 1M), pak buy 800k cheaper. + // Cheapest-first by retroactivne mohl konzumovat 800k buy, ale timestamp filter to nedovoli. + val txs = listOf( + buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0)), + sell(2, BigDecimal("0.5"), BigDecimal("2000000"), ts(1)), + buy(3, BigDecimal("0.5"), BigDecimal("800000"), ts(2)) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + // Sell @ts(1) vidi jen buy 1 (ts(0)). Konzumuje 0.5 z 1M. + // Remaining: 0.5 @ 1M + 0.5 @ 800k = avg (500k + 400k) / 1.0 = 900k + assertEquals(0, BigDecimal("1.0").compareTo(result.available)) + assertEquals(0, BigDecimal("900000").compareTo(result.weightedAvgPrice!!)) + } + + @Test + fun `PENDING sell rezervuje cheapest`() { + val txs = listOf( + buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0)), + sell(2, BigDecimal("0"), BigDecimal("3000000"), ts(1), + status = TransactionStatus.PENDING, requested = BigDecimal("0.5")) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + // Pending rezervuje 0.5 z 1M buyu. Remaining 0.5 @ 1M. + assertEquals(0, BigDecimal("0.5").compareTo(result.available)) + assertEquals(0, BigDecimal("1000000").compareTo(result.weightedAvgPrice!!)) + } + + @Test + fun `PARTIAL sell pouziva requested ne filled`() { + // PARTIAL: requested 0.5, filled 0.2 -> efektivne konzumuje 0.5 (cele rezervuje) + val txs = listOf( + buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0)), + sell(2, BigDecimal("0.2"), BigDecimal("3000000"), ts(1), + status = TransactionStatus.PARTIAL, requested = BigDecimal("0.5")) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + assertEquals(0, BigDecimal("0.5").compareTo(result.available)) + } + + @Test + fun `FAILED sell se ignoruje`() { + val txs = listOf( + buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0)), + sell(2, BigDecimal("0.5"), BigDecimal("3000000"), ts(1), + status = TransactionStatus.FAILED) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + assertEquals(0, BigDecimal("1").compareTo(result.available)) + } + + @Test + fun `negative inventory - sells presahly buys, deficit non-zero`() { + val txs = listOf( + buy(1, BigDecimal("0.5"), BigDecimal("1000000"), ts(0)), + sell(2, BigDecimal("1"), BigDecimal("2000000"), ts(1)) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + assertEquals(0, BigDecimal.ZERO.compareTo(result.available)) + assertNull(result.weightedAvgPrice) + assertEquals(0, BigDecimal("0.5").compareTo(result.deficit)) + } + + @Test + fun `tie na cene rozhodne starsi executedAt napred`() { + val txs = listOf( + buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0)), + buy(2, BigDecimal("1"), BigDecimal("1000000"), ts(1)), + sell(3, BigDecimal("0.5"), BigDecimal("2000000"), ts(2)) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + // Sell konzumuje 0.5 ze starsiho buyu. Remaining: 0.5 z buy 1 + 1 z buy 2. + assertEquals(0, BigDecimal("1.5").compareTo(result.available)) + val cheap1 = result.perBuyDetail.firstOrNull { it.transactionId == 1L } + assertEquals(0, BigDecimal("0.5").compareTo(cheap1?.remaining ?: BigDecimal.ZERO)) + } +} +``` + +**Pozn.:** Pokud `TransactionEntity` ma jine pole / jine pojmenovani, sladit s realnym definici v `Entities.kt`. Vsechna pouzita pole tam jsou (`id`, `planId`, `connectionId`, `exchange`, `crypto`, `fiat`, `fiatAmount`, `cryptoAmount`, `price`, `fee`, `feeAsset`, `status`, `exchangeOrderId`, `executedAt`, `side`, `requestedCryptoAmount`, `limitPrice`). + +- [ ] **Krok 2: Spustit testy a overit ze selhavaji s "computeCostBasis is not defined"** + +Run: +```bash +cd accbot-android && ./gradlew :app:testDebugUnitTest --tests "com.accbot.dca.domain.usecase.CalculatePlanCostBasisUseCaseTest" +``` + +Expected: kompilacni chyba "unresolved reference: CalculatePlanCostBasisUseCase". + +- [ ] **Krok 3: Implementovat CalculatePlanCostBasisUseCase** + +Soubor `accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCase.kt`: + +```kotlin +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.TransactionEntity +import com.accbot.dca.domain.model.RemainingBuy +import com.accbot.dca.domain.model.RemainingInventory +import com.accbot.dca.domain.model.TransactionSide +import com.accbot.dca.domain.model.TransactionStatus +import java.math.BigDecimal +import java.math.RoundingMode +import javax.inject.Inject + +/** + * Spocita zbyvajici inventar (a vazenou prumernou nakupni cenu) pro plan + * pomoci timestamp-aware cheapest-first algoritmu. + * + * Pure logika v [computeCostBasis] companion funkci pro snadne unit testovani. + */ +class CalculatePlanCostBasisUseCase @Inject constructor( + private val database: DcaDatabase +) { + suspend operator fun invoke(planId: Long): RemainingInventory { + val transactions = database.transactionDao().getTransactionsByPlanSync(planId) + return computeCostBasis(transactions) + } + + companion object { + fun computeCostBasis(transactions: List): RemainingInventory { + val buys = transactions.filter { + it.side == TransactionSide.BUY && + (it.status == TransactionStatus.COMPLETED || it.status == TransactionStatus.PARTIAL) + } + + val sells = transactions.filter { + it.side == TransactionSide.SELL && + (it.status == TransactionStatus.COMPLETED || + it.status == TransactionStatus.PARTIAL || + it.status == TransactionStatus.PENDING) + }.sortedBy { it.executedAt } + + val consumed = HashMap(buys.size) + for (b in buys) consumed[b.id] = BigDecimal.ZERO + + var totalDeficit = BigDecimal.ZERO + + for (sell in sells) { + val toConsume = effectiveConsumption(sell) + if (toConsume <= BigDecimal.ZERO) continue + var remaining = toConsume + + val eligible = buys + .filter { it.executedAt.isBefore(sell.executedAt) } + .filter { + (it.cryptoAmount - (consumed[it.id] ?: BigDecimal.ZERO)) > BigDecimal.ZERO + } + .sortedWith(compareBy({ it.price }, { it.executedAt })) + + for (b in eligible) { + if (remaining <= BigDecimal.ZERO) break + val available = b.cryptoAmount - (consumed[b.id] ?: BigDecimal.ZERO) + val take = remaining.min(available) + consumed[b.id] = (consumed[b.id] ?: BigDecimal.ZERO) + take + remaining -= take + } + + if (remaining > BigDecimal.ZERO) totalDeficit = totalDeficit + remaining + } + + val perBuyDetail = buys.mapNotNull { b -> + val left = b.cryptoAmount - (consumed[b.id] ?: BigDecimal.ZERO) + if (left > BigDecimal.ZERO) RemainingBuy(b.id, b.price, left) else null + } + + val available = perBuyDetail.fold(BigDecimal.ZERO) { acc, rb -> acc + rb.remaining } + val weightedAvg = if (available > BigDecimal.ZERO) { + val sumCost = perBuyDetail.fold(BigDecimal.ZERO) { acc, rb -> + acc + rb.remaining * rb.price + } + sumCost.divide(available, 8, RoundingMode.HALF_UP) + } else null + + return RemainingInventory( + available = available, + weightedAvgPrice = weightedAvg, + perBuyDetail = perBuyDetail, + deficit = totalDeficit + ) + } + + /** + * Mnozstvi crypta, ktere ten sell rezervuje/zkonzumuje. Pro PENDING/PARTIAL = requested + * (cela rezervace, vc. unfilled cast). Pro COMPLETED = cryptoAmount (= requested). + */ + private fun effectiveConsumption(sell: TransactionEntity): BigDecimal { + val requested = sell.requestedCryptoAmount ?: BigDecimal.ZERO + return requested.max(sell.cryptoAmount) + } + } +} +``` + +- [ ] **Krok 4: Spustit testy a overit ze prochazeji** + +Run: +```bash +cd accbot-android && ./gradlew :app:testDebugUnitTest --tests "com.accbot.dca.domain.usecase.CalculatePlanCostBasisUseCaseTest" +``` + +Expected: 9 testu PASS. + +Pokud nejaky selhe, opravit implementaci, ne test (pokud test nepoukazuje na chybu zamerne). + +- [ ] **Krok 5: Build check** + +Run: +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Krok 6: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCase.kt +git add accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCaseTest.kt +git commit -m "feat(sell): add CalculatePlanCostBasisUseCase with timestamp-aware cheapest-first" +``` + +--- + +## Faze 2: Fee plumbing + +### Task 4: Pridat estimatedTakerFeeRate do ExchangeApi + +**Files:** +- Modify: `accbot-android/app/src/main/java/com/accbot/dca/exchange/ExchangeApi.kt` +- Modify: `accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt` +- Modify: `accbot-android/app/src/main/java/com/accbot/dca/exchange/BinanceApi.kt` +- Modify: `accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinbaseApi.kt` +- Modify: `accbot-android/app/src/main/java/com/accbot/dca/exchange/OtherExchanges.kt` (Kraken, KuCoin, Bitfinex, Huobi) + +- [ ] **Krok 1: Pridat property do ExchangeApi interface** + +V `ExchangeApi.kt` interfejsu (poblize existujiciho `supportsLimitSell: Boolean`): + +```kotlin +/** + * Odhadovany taker fee rate (e.g. 0.0035 = 0.35%) pro decision support v UI. + * Hodnota nemusi presne odpovidat realnemu fee uzivatele (lower tier, BNB discount, ...). + */ +val estimatedTakerFeeRate: BigDecimal +``` + +- [ ] **Krok 2: Implementovat v CoinmateApi** + +V `CoinmateApi.kt`, doplnit pod existujici `takerFeeRate`: + +```kotlin +override val estimatedTakerFeeRate: BigDecimal = BigDecimal("0.0035") +``` + +(Existujici `private val takerFeeRate` se pouziva interne pro fallback fee - nezasahovat, je to jiny use case.) + +- [ ] **Krok 3: Implementovat v BinanceApi** + +V `BinanceApi.kt`: + +```kotlin +override val estimatedTakerFeeRate: BigDecimal = BigDecimal("0.001") +``` + +- [ ] **Krok 4: Implementovat v CoinbaseApi** + +V `CoinbaseApi.kt`: + +```kotlin +override val estimatedTakerFeeRate: BigDecimal = BigDecimal("0.0040") +``` + +- [ ] **Krok 5: Implementovat v Kraken/KuCoin/Bitfinex/Huobi (OtherExchanges.kt)** + +V kazde z trid: + +```kotlin +// KrakenApi +override val estimatedTakerFeeRate: BigDecimal = BigDecimal("0.0026") + +// KuCoinApi +override val estimatedTakerFeeRate: BigDecimal = BigDecimal("0.001") + +// BitfinexApi +override val estimatedTakerFeeRate: BigDecimal = BigDecimal("0.002") + +// HuobiApi +override val estimatedTakerFeeRate: BigDecimal = BigDecimal("0.002") +``` + +- [ ] **Krok 6: Build check** + +Run: +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +``` + +Expected: BUILD SUCCESSFUL. Pokud failne s "class is not abstract and does not implement abstract member estimatedTakerFeeRate" - chybi implementace v nektere z trid. + +- [ ] **Krok 7: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/exchange/ +git commit -m "feat(sell): add estimatedTakerFeeRate to ExchangeApi for fee math in wizard" +``` + +--- + +## Faze 3: Validation logic + +### Task 5: Pridat LossWarning do ValidateSellOrderUseCase + +**Files:** +- Modify: `accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt` +- Create: `accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCaseLossTest.kt` + +- [ ] **Krok 1: Pridat LossWarning do sealed SellValidation** + +V `ValidateSellOrderUseCase.kt` v `sealed class SellValidation`: + +```kotlin +data class LossWarning(val lossFiat: BigDecimal, val lossPct: Double) : SellValidation() +``` + +- [ ] **Krok 2: Rozsirit invoke o avg buy price + fee rate vstupy** + +Modifikovat signature: + +```kotlin +suspend operator fun invoke( + planId: Long, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal, + minOrderSize: BigDecimal, + currentSpot: BigDecimal?, + avgBuyPrice: BigDecimal?, // NOVE + feeRate: BigDecimal // NOVE +): List +``` + +Pridat trigger logiku (umistit za existujici `instantFill` / `farFromMarket` checks, pred final `result.isEmpty()`): + +```kotlin +if (avgBuyPrice != null && avgBuyPrice > BigDecimal.ZERO) { + val grossFiat = cryptoAmount * limitPrice + val netFiat = grossFiat * (BigDecimal.ONE - feeRate) + val costBasis = cryptoAmount * avgBuyPrice + val netProfit = netFiat - costBasis + if (netProfit < BigDecimal.ZERO) { + val lossPct = if (costBasis > BigDecimal.ZERO) { + netProfit.toDouble() / costBasis.toDouble() + } else 0.0 + result += SellValidation.LossWarning(lossFiat = netProfit.negate(), lossPct = -lossPct) + } +} +``` + +- [ ] **Krok 3: Napsat unit testy pro novou logiku** + +`accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCaseLossTest.kt`: + +**Pozn.:** ValidateSellOrderUseCase pouziva `database` injekci. Pro unit test potrebujeme bud (a) refaktorovat loss-check do pure helperu, nebo (b) mockovat `DcaDatabase`. + +Refaktor: extrahovat loss-check do internal funkce nebo do companion objectu, testovat ji primo: + +V `ValidateSellOrderUseCase.kt` companion: + +```kotlin +companion object { + /** + * Pure helper pro loss-check, testovany unit testem. + * Vraci LossWarning kdyz `netProfit < 0`, jinak null. + */ + internal fun checkLoss( + cryptoAmount: BigDecimal, + limitPrice: BigDecimal, + avgBuyPrice: BigDecimal?, + feeRate: BigDecimal + ): SellValidation.LossWarning? { + if (avgBuyPrice == null || avgBuyPrice <= BigDecimal.ZERO) return null + val grossFiat = cryptoAmount * limitPrice + val netFiat = grossFiat * (BigDecimal.ONE - feeRate) + val costBasis = cryptoAmount * avgBuyPrice + val netProfit = netFiat - costBasis + if (netProfit >= BigDecimal.ZERO) return null + val lossPct = if (costBasis > BigDecimal.ZERO) { + netProfit.toDouble() / costBasis.toDouble() + } else 0.0 + return SellValidation.LossWarning(lossFiat = netProfit.negate(), lossPct = -lossPct) + } +} +``` + +A v `invoke()` zavolat `checkLoss(...)?.let { result += it }`. + +Test soubor: + +```kotlin +package com.accbot.dca.domain.usecase + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.math.BigDecimal + +class ValidateSellOrderUseCaseLossTest { + + @Test + fun `pod nakupni cenou vraci LossWarning`() { + val w = ValidateSellOrderUseCase.checkLoss( + cryptoAmount = BigDecimal("1"), + limitPrice = BigDecimal("900000"), + avgBuyPrice = BigDecimal("1000000"), + feeRate = BigDecimal("0.0035") + ) + assertEquals(true, w != null) + } + + @Test + fun `tesne nad nakupni cenou ale po fee ztrata vraci LossWarning`() { + // P=1003500, avg=1000000, fee=0.0035 + // grossFiat = 1003500, netFiat = 1003500 × 0.9965 = 999988.75 + // costBasis = 1000000 -> netProfit = -11.25 < 0 + val w = ValidateSellOrderUseCase.checkLoss( + cryptoAmount = BigDecimal("1"), + limitPrice = BigDecimal("1003500"), + avgBuyPrice = BigDecimal("1000000"), + feeRate = BigDecimal("0.0035") + ) + assertEquals(true, w != null) + } + + @Test + fun `dostatecne nad nakupni cenou vraci null`() { + val w = ValidateSellOrderUseCase.checkLoss( + cryptoAmount = BigDecimal("1"), + limitPrice = BigDecimal("1100000"), + avgBuyPrice = BigDecimal("1000000"), + feeRate = BigDecimal("0.0035") + ) + assertNull(w) + } + + @Test + fun `null avg vraci null`() { + val w = ValidateSellOrderUseCase.checkLoss( + cryptoAmount = BigDecimal("1"), + limitPrice = BigDecimal("900000"), + avgBuyPrice = null, + feeRate = BigDecimal("0.0035") + ) + assertNull(w) + } +} +``` + +- [ ] **Krok 4: Spustit testy** + +```bash +cd accbot-android && ./gradlew :app:testDebugUnitTest --tests "com.accbot.dca.domain.usecase.ValidateSellOrderUseCaseLossTest" +``` + +Expected: 4 testy PASS. + +- [ ] **Krok 5: Najit existujici call-sites ValidateSellOrderUseCase a pridat avg + feeRate parametry** + +Pravdepodobne jen `SellWizardViewModel.kt`. Hledat: + +```bash +grep -rn "validateSellOrderUseCase\|ValidateSellOrderUseCase" accbot-android/app/src/main +``` + +Doplnit volani s pravymi argumenty (avg z `CalculatePlanCostBasisUseCase`, feeRate z `api.estimatedTakerFeeRate`). Tohle se finalizuje az v Tasku 8, zatim staci aby kompilace prosla - lze docasne predat `null` a `BigDecimal.ZERO` a zustanou `LossWarning` skip vetve. + +- [ ] **Krok 6: Build check** + +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Krok 7: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt +git add accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCaseLossTest.kt +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt +git commit -m "feat(sell): LossWarning in ValidateSellOrderUseCase based on net-of-fee profit" +``` + +--- + +## Faze 4: Sell calculator helper a ViewModel + +### Task 6: SellCalculatorMath pure helper + testy + +**Files:** +- Create: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellCalculatorMath.kt` +- Create: `accbot-android/app/src/test/java/com/accbot/dca/presentation/screens/plans/sell/SellCalculatorMathTest.kt` + +- [ ] **Krok 1: Implementovat pure helper** + +```kotlin +package com.accbot.dca.presentation.screens.plans.sell + +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * Pure logika pro tripolovou kalkulacku (Amount, Price, Net). + * Vztah: N = A × P × (1 - feeRate) + * + * Pri editaci jednoho z poli ViewModel zavola [recompute] a zaznamena pole jako + * "naposledy editovane". Pole, ktere neni v `lastTwoEdited`, je dopocitano. + */ +object SellCalculatorMath { + + enum class Field { AMOUNT, PRICE, NET } + + /** + * @param a mnozstvi crypta + * @param p limit cena + * @param n cisty vynos + * @param feeRate burzy + * @param lastTwoEdited pole v poradi nejnovejsi -> druhe nejnovejsi (FIFO buffer) + * @return updated trojice (a, p, n) s dopocitanym 3. polem + */ + fun recompute( + a: BigDecimal?, + p: BigDecimal?, + n: BigDecimal?, + feeRate: BigDecimal, + lastTwoEdited: List + ): Triple { + if (lastTwoEdited.size < 2) return Triple(a, p, n) + val factor = BigDecimal.ONE - feeRate + val toCompute = Field.values().firstOrNull { it !in lastTwoEdited } ?: return Triple(a, p, n) + + return when (toCompute) { + Field.NET -> { + val newN = if (a != null && p != null) (a * p * factor).setScale(2, RoundingMode.HALF_UP) + else null + Triple(a, p, newN) + } + Field.PRICE -> { + val newP = if (a != null && n != null && a > BigDecimal.ZERO && factor > BigDecimal.ZERO) + n.divide(a * factor, 2, RoundingMode.HALF_UP) + else null + Triple(a, newP, n) + } + Field.AMOUNT -> { + val newA = if (p != null && n != null && p > BigDecimal.ZERO && factor > BigDecimal.ZERO) + n.divide(p * factor, 8, RoundingMode.HALF_UP) + else null + Triple(newA, p, n) + } + } + } +} +``` + +- [ ] **Krok 2: Napsat testy** + +```kotlin +package com.accbot.dca.presentation.screens.plans.sell + +import com.accbot.dca.presentation.screens.plans.sell.SellCalculatorMath.Field +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.math.BigDecimal + +class SellCalculatorMathTest { + + private val fee = BigDecimal("0.0035") // 0.35% + + @Test + fun `A a P editovane - dopocita N`() { + val (a, p, n) = SellCalculatorMath.recompute( + a = BigDecimal("1"), + p = BigDecimal("1000000"), + n = null, + feeRate = fee, + lastTwoEdited = listOf(Field.PRICE, Field.AMOUNT) + ) + // 1 × 1000000 × 0.9965 = 996500 + assertEquals(0, BigDecimal("996500.00").compareTo(n!!)) + assertEquals(BigDecimal("1"), a) + assertEquals(BigDecimal("1000000"), p) + } + + @Test + fun `A a N editovane - dopocita P`() { + val (a, p, n) = SellCalculatorMath.recompute( + a = BigDecimal("1"), + p = null, + n = BigDecimal("996500"), + feeRate = fee, + lastTwoEdited = listOf(Field.NET, Field.AMOUNT) + ) + // 996500 / (1 × 0.9965) = 1000000 + assertEquals(0, BigDecimal("1000000.00").compareTo(p!!)) + } + + @Test + fun `P a N editovane - dopocita A`() { + val (a, p, n) = SellCalculatorMath.recompute( + a = null, + p = BigDecimal("1000000"), + n = BigDecimal("996500"), + feeRate = fee, + lastTwoEdited = listOf(Field.NET, Field.PRICE) + ) + // 996500 / (1000000 × 0.9965) = 1.0 + assertEquals(0, BigDecimal("1.00000000").compareTo(a!!)) + } + + @Test + fun `mene nez 2 editovana pole - nedopocitava`() { + val (a, p, n) = SellCalculatorMath.recompute( + a = BigDecimal("1"), + p = null, + n = null, + feeRate = fee, + lastTwoEdited = listOf(Field.AMOUNT) + ) + assertNull(p) + assertNull(n) + } + + @Test + fun `chybejici vstupy v computed dvojici - vrati null`() { + val (a, p, n) = SellCalculatorMath.recompute( + a = null, + p = BigDecimal("1000000"), + n = null, + feeRate = fee, + lastTwoEdited = listOf(Field.PRICE, Field.AMOUNT) + ) + // computed = NET, ale a je null -> n = null + assertNull(n) + } +} +``` + +- [ ] **Krok 3: Spustit testy** + +```bash +cd accbot-android && ./gradlew :app:testDebugUnitTest --tests "com.accbot.dca.presentation.screens.plans.sell.SellCalculatorMathTest" +``` + +Expected: 5 testu PASS. + +- [ ] **Krok 4: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellCalculatorMath.kt +git add accbot-android/app/src/test/java/com/accbot/dca/presentation/screens/plans/sell/SellCalculatorMathTest.kt +git commit -m "feat(sell): SellCalculatorMath pure helper for amount/price/net field" +``` + +--- + +### Task 7: SellWizardViewModel - cost basis prefill + state machine + +**Files:** +- Modify: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt` + +- [ ] **Krok 1: Precist aktualni SellWizardViewModel** + +```bash +cat accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt | head -100 +``` + +Pochopit existujici state model. Identifikovat, kde se uchovava amount, limitPrice, kdy se vola `validateSellOrderUseCase` a `placeLimitSellUseCase`. + +- [ ] **Krok 2: Doplnit pole do state** + +V state data class (`SellWizardState` nebo podobne): + +```kotlin +data class SellWizardState( + // ...existujici pole... + val avgBuyPrice: String = "", // text input, prefill z cost basis + val avgBuyPriceManual: Boolean = false, // user prepsal default + val netFiat: String = "", // 3. pole kalkulacky + val computedRemainingInventory: RemainingInventory? = null, + val lastTwoEditedFields: List = emptyList(), + val feeRate: BigDecimal = BigDecimal.ZERO, // z api.estimatedTakerFeeRate + val lossWarning: SellValidation.LossWarning? = null +) +``` + +- [ ] **Krok 3: Pridat dependencies do constructoru** + +```kotlin +@HiltViewModel +class SellWizardViewModel @Inject constructor( + private val savedState: SavedStateHandle, // pokud uz neni + private val database: DcaDatabase, // existing + private val calculatePlanCostBasisUseCase: CalculatePlanCostBasisUseCase, // NEW + private val exchangeApiFactory: ExchangeApiFactory, // existing or new + private val credentialsStore: CredentialsStore, + private val userPreferences: UserPreferences, + private val validateSellOrderUseCase: ValidateSellOrderUseCase, + private val placeLimitSellUseCase: PlaceLimitSellUseCase, + private val minOrderSizeRepository: MinOrderSizeRepository +) : ViewModel() { ... } +``` + +- [ ] **Krok 4: Pri inicializaci viewmodelu (load planId) spustit cost basis a fee rate fetch** + +```kotlin +init { + val planId = savedState.get("planId") ?: return + viewModelScope.launch { + val plan = database.dcaPlanDao().getPlanById(planId) ?: return@launch + val credentials = credentialsStore.getCredentials( + plan.connectionId, userPreferences.isSandboxMode() + ) ?: return@launch + val api = exchangeApiFactory.create(credentials) + + val inventory = calculatePlanCostBasisUseCase(planId) + _state.update { + it.copy( + computedRemainingInventory = inventory, + avgBuyPrice = inventory.weightedAvgPrice?.toPlainString() ?: "", + feeRate = api.estimatedTakerFeeRate + ) + } + } +} +``` + +- [ ] **Krok 5: Reagovat na editaci poli (amount/price/net)** + +Pridat handlery: + +```kotlin +fun onAmountChange(text: String) { + _state.update { st -> + val a = text.toBigDecimalOrNull() + val p = st.limitPrice.toBigDecimalOrNull() + val n = st.netFiat.toBigDecimalOrNull() + val newLastTwo = listOf(SellCalculatorMath.Field.AMOUNT) + + st.lastTwoEditedFields.filter { it != SellCalculatorMath.Field.AMOUNT }.take(1) + val (newA, newP, newN) = SellCalculatorMath.recompute(a, p, n, st.feeRate, newLastTwo) + st.copy( + amount = text, + limitPrice = newP?.toPlainString() ?: st.limitPrice, + netFiat = newN?.toPlainString() ?: st.netFiat, + lastTwoEditedFields = newLastTwo + ) + } + revalidate() +} + +fun onLimitPriceChange(text: String) { + _state.update { st -> + val a = st.amount.toBigDecimalOrNull() + val p = text.toBigDecimalOrNull() + val n = st.netFiat.toBigDecimalOrNull() + val newLastTwo = listOf(SellCalculatorMath.Field.PRICE) + + st.lastTwoEditedFields.filter { it != SellCalculatorMath.Field.PRICE }.take(1) + val (newA, newP, newN) = SellCalculatorMath.recompute(a, p, n, st.feeRate, newLastTwo) + st.copy( + amount = newA?.toPlainString() ?: st.amount, + limitPrice = text, + netFiat = newN?.toPlainString() ?: st.netFiat, + lastTwoEditedFields = newLastTwo + ) + } + revalidate() +} + +fun onNetFiatChange(text: String) { + _state.update { st -> + val a = st.amount.toBigDecimalOrNull() + val p = st.limitPrice.toBigDecimalOrNull() + val n = text.toBigDecimalOrNull() + val newLastTwo = listOf(SellCalculatorMath.Field.NET) + + st.lastTwoEditedFields.filter { it != SellCalculatorMath.Field.NET }.take(1) + val (newA, newP, newN) = SellCalculatorMath.recompute(a, p, n, st.feeRate, newLastTwo) + st.copy( + amount = newA?.toPlainString() ?: st.amount, + limitPrice = newP?.toPlainString() ?: st.limitPrice, + netFiat = text, + lastTwoEditedFields = newLastTwo + ) + } + revalidate() +} +``` + +- [ ] **Krok 6: Manualni override avg buy** + +```kotlin +fun onAvgBuyPriceChange(text: String) { + _state.update { it.copy(avgBuyPrice = text, avgBuyPriceManual = text.isNotBlank()) } + revalidate() +} + +fun onResetAvgBuyPrice() { + _state.update { st -> + st.copy( + avgBuyPrice = st.computedRemainingInventory?.weightedAvgPrice?.toPlainString() ?: "", + avgBuyPriceManual = false + ) + } + revalidate() +} +``` + +- [ ] **Krok 7: Aktualizovat revalidate s avgBuy a feeRate** + +```kotlin +private fun revalidate() { + viewModelScope.launch { + val st = _state.value + val avg = st.avgBuyPrice.toBigDecimalOrNull() + val amount = st.amount.toBigDecimalOrNull() + val price = st.limitPrice.toBigDecimalOrNull() + if (amount == null || price == null) return@launch + + val results = validateSellOrderUseCase( + planId = st.planId, + cryptoAmount = amount, + limitPrice = price, + minOrderSize = st.minOrderSize ?: BigDecimal.ZERO, + currentSpot = st.currentSpot, + avgBuyPrice = avg, + feeRate = st.feeRate + ) + + val loss = results.filterIsInstance().firstOrNull() + _state.update { it.copy(validation = results, lossWarning = loss) } + } +} +``` + +- [ ] **Krok 8: Build check** + +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +``` + +Expected: BUILD SUCCESSFUL. Pokud je tam neco co konfliktuje s aktualni state, najit & opravit; struktura ViewModelu je popsana obecne, sladit s realnou. + +- [ ] **Krok 9: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt +git commit -m "feat(sell): wire cost basis + 3-field calculator into SellWizardViewModel" +``` + +--- + +## Faze 5: UI - single mod + +### Task 8: Avg buy price field + reset tlacitko + +**Files:** +- Modify: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt` +- Modify: `accbot-android/app/src/main/res/values/strings.xml` +- Modify: `accbot-android/app/src/main/res/values-cs/strings.xml` + +- [ ] **Krok 1: Pridat stringy** + +`values/strings.xml`: + +```xml +Average buy price +Auto-calculated from this plan +Manually entered +Calculate from plan +Enter manually (no buys yet or all sold) +``` + +`values-cs/strings.xml`: + +```xml +Prumerna nakupni cena +Spocitano z planu +Zadano rucne +Spocitat z planu +Zadej rucne (zadne buys nebo vse prodano) +``` + +- [ ] **Krok 2: Pridat OutlinedTextField pro avg buy price (uvnitr SellWizardBottomSheet, krok 1)** + +Najit zacatek wizardu (Composable function `SellWizardStep1` nebo podobne) a pridat na zacatek, pred existujici pole pro mnozstvi: + +```kotlin +OutlinedTextField( + value = state.avgBuyPrice, + onValueChange = viewModel::onAvgBuyPriceChange, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.sell_wizard_avg_buy_price)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + supportingText = { + val res = when { + state.computedRemainingInventory?.weightedAvgPrice == null -> R.string.sell_wizard_avg_buy_price_required + state.avgBuyPriceManual -> R.string.sell_wizard_avg_buy_price_helper_manual + else -> R.string.sell_wizard_avg_buy_price_helper_auto + } + Text(stringResource(res)) + }, + trailingIcon = { + if (state.avgBuyPriceManual && state.computedRemainingInventory?.weightedAvgPrice != null) { + TextButton(onClick = viewModel::onResetAvgBuyPrice) { + Text(stringResource(R.string.sell_wizard_avg_buy_price_reset)) + } + } + } +) +``` + +- [ ] **Krok 3: Build check** + +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +``` + +- [ ] **Krok 4: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt +git add accbot-android/app/src/main/res/values/strings.xml +git add accbot-android/app/src/main/res/values-cs/strings.xml +git commit -m "feat(sell): avg buy price field with auto-prefill + manual override" +``` + +--- + +### Task 9: Net fiat pole + presety + +**Files:** +- Modify: `SellWizardBottomSheet.kt` +- Modify: `strings.xml` (cs + en) + +- [ ] **Krok 1: Stringy** + +```xml + +Net proceeds (after fee) +Profit on this transaction ++10% ++20% ++50% ++100% + + +Cisty vynos (po fee) +Zisk na transakci + +``` + +- [ ] **Krok 2: Pridat OutlinedTextField + preset Row pod limit price field** + +```kotlin +OutlinedTextField( + value = state.netFiat, + onValueChange = viewModel::onNetFiatChange, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.sell_wizard_net_fiat)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal) +) + +Text( + stringResource(R.string.sell_wizard_net_preset_label), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant +) +Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + listOf(0.10 to R.string.sell_wizard_net_preset_10, + 0.20 to R.string.sell_wizard_net_preset_20, + 0.50 to R.string.sell_wizard_net_preset_50, + 1.00 to R.string.sell_wizard_net_preset_100).forEach { (factor, label) -> + FilterChip( + selected = false, + onClick = { viewModel.applyNetPreset(factor) }, + label = { Text(stringResource(label)) } + ) + } +} +``` + +- [ ] **Krok 3: ViewModel handler** + +V `SellWizardViewModel`: + +```kotlin +fun applyNetPreset(profitTarget: Double) { + val st = _state.value + val a = st.amount.toBigDecimalOrNull() ?: return + val avg = st.avgBuyPrice.toBigDecimalOrNull() ?: return + if (avg <= BigDecimal.ZERO || a <= BigDecimal.ZERO) return + val target = a * avg * (BigDecimal.ONE + BigDecimal(profitTarget)) + onNetFiatChange(target.setScale(2, RoundingMode.HALF_UP).toPlainString()) +} +``` + +- [ ] **Krok 4: Build a commit** + +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/ +git add accbot-android/app/src/main/res/values/strings.xml +git add accbot-android/app/src/main/res/values-cs/strings.xml +git commit -m "feat(sell): net proceeds field + profit-target presets" +``` + +--- + +### Task 10: Cenove presety dropdown (% z avg / % ze spotu) + +**Files:** +- Modify: `SellWizardBottomSheet.kt` +- Modify: `SellWizardViewModel.kt` +- Modify: `strings.xml` (cs + en) + +- [ ] **Krok 1: Stringy** + +```xml + +% above avg buy +% above spot + + +% z avg buy +% ze spotu +``` + +- [ ] **Krok 2: ViewModel state + handlery** + +V `SellWizardState`: + +```kotlin +val priceP resetMode: PricePresetMode = PricePresetMode.AVG_BUY, + +enum class PricePresetMode { AVG_BUY, SPOT } +``` + +Handlery: + +```kotlin +fun onPricePresetModeChange(mode: PricePresetMode) { + _state.update { it.copy(pricePresetMode = mode) } +} + +fun applyPricePreset(factor: Double) { + val st = _state.value + val basis = when (st.pricePresetMode) { + PricePresetMode.AVG_BUY -> st.avgBuyPrice.toBigDecimalOrNull() + PricePresetMode.SPOT -> st.currentSpot + } ?: return + val newPrice = basis * (BigDecimal.ONE + BigDecimal(factor)) + onLimitPriceChange(newPrice.setScale(2, RoundingMode.HALF_UP).toPlainString()) +} +``` + +- [ ] **Krok 3: UI - DropdownMenu nahore presetu** + +```kotlin +var modeMenuOpen by remember { mutableStateOf(false) } +Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(when (state.pricePresetMode) { + PricePresetMode.AVG_BUY -> R.string.sell_wizard_price_preset_mode_avg + PricePresetMode.SPOT -> R.string.sell_wizard_price_preset_mode_spot + }), + modifier = Modifier.clickable { modeMenuOpen = true } + ) + Icon(Icons.Filled.ArrowDropDown, contentDescription = null) + DropdownMenu(expanded = modeMenuOpen, onDismissRequest = { modeMenuOpen = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.sell_wizard_price_preset_mode_avg)) }, + onClick = { viewModel.onPricePresetModeChange(PricePresetMode.AVG_BUY); modeMenuOpen = false } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.sell_wizard_price_preset_mode_spot)) }, + onClick = { viewModel.onPricePresetModeChange(PricePresetMode.SPOT); modeMenuOpen = false } + ) + } +} + +Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + listOf(0.05, 0.10, 0.20, 0.50).forEach { factor -> + FilterChip( + selected = false, + onClick = { viewModel.applyPricePreset(factor) }, + label = { Text("+${(factor * 100).toInt()}%") } + ) + } +} +``` + +- [ ] **Krok 4: Build a commit** + +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/ +git add accbot-android/app/src/main/res/values/strings.xml +git add accbot-android/app/src/main/res/values-cs/strings.xml +git commit -m "feat(sell): price presets with avg-buy/spot mode toggle" +``` + +--- + +### Task 11: Summary - profit per coin, fee, cisty zisk, remaining avg, postup k cili + +**Files:** +- Modify: `SellWizardBottomSheet.kt` (summary sekce) +- Modify: `SellWizardViewModel.kt` (computed summary state) +- Modify: `strings.xml` + +- [ ] **Krok 1: Pridat computed summary do state** + +```kotlin +data class SellSummary( + val profitPerCoin: BigDecimal? = null, + val grossProfit: BigDecimal? = null, + val estimatedFee: BigDecimal? = null, + val netProfit: BigDecimal? = null, + val netProfitPct: Double? = null, + val remainingAfter: RemainingInventory? = null, + val targetProgressPct: Double? = null +) + +val summary: SellSummary = SellSummary() +``` + +- [ ] **Krok 2: Pridat computeSummary v ViewModelu po revalidate** + +```kotlin +private fun computeSummary() { + val st = _state.value + val a = st.amount.toBigDecimalOrNull() + val p = st.limitPrice.toBigDecimalOrNull() + val avg = st.avgBuyPrice.toBigDecimalOrNull() + if (a == null || p == null || avg == null || a <= BigDecimal.ZERO) { + _state.update { it.copy(summary = SellSummary()) } + return + } + val factor = BigDecimal.ONE - st.feeRate + val grossFiat = a * p + val estimatedFee = grossFiat * st.feeRate + val netFiat = grossFiat * factor + val costBasis = a * avg + val grossProfit = grossFiat - costBasis + val netProfit = netFiat - costBasis + val netProfitPct = if (costBasis > BigDecimal.ZERO) netProfit.toDouble() / costBasis.toDouble() else 0.0 + + // hypoteticky pridat ten sell mezi historicke a prepocitat remaining + val remaining = computeRemainingAfterHypotheticalSell(st.planId, a, p) + + // postup k cili + val plan = st.plan + val target = plan?.targetProfitAmount + val realizedSoFar = computeRealizedPnLSoFar(st.planId) + val progress = if (target != null && target > BigDecimal.ZERO) + (realizedSoFar + netProfit).toDouble() / target.toDouble() + else null + + _state.update { + it.copy(summary = SellSummary( + profitPerCoin = p - avg, + grossProfit = grossProfit, + estimatedFee = estimatedFee, + netProfit = netProfit, + netProfitPct = netProfitPct, + remainingAfter = remaining, + targetProgressPct = progress + )) + } +} +``` + +`computeRemainingAfterHypotheticalSell` - vlozit fake SELL transakci do listu transakci a zavolat `CalculatePlanCostBasisUseCase.computeCostBasis(...)`: + +```kotlin +private suspend fun computeRemainingAfterHypotheticalSell( + planId: Long, + cryptoAmount: BigDecimal, + price: BigDecimal +): RemainingInventory { + val txs = database.transactionDao().getTransactionsByPlanSync(planId) + val fakeSell = TransactionEntity( + id = -1, planId = planId, connectionId = 0, + exchange = Exchange.COINMATE, crypto = "?", fiat = "?", + fiatAmount = cryptoAmount * price, + cryptoAmount = cryptoAmount, + price = price, fee = BigDecimal.ZERO, feeAsset = "", + status = TransactionStatus.COMPLETED, + exchangeOrderId = "fake", + executedAt = Instant.now(), + side = TransactionSide.SELL, + requestedCryptoAmount = cryptoAmount, + limitPrice = price + ) + return CalculatePlanCostBasisUseCase.computeCostBasis(txs + fakeSell) +} +``` + +`computeRealizedPnLSoFar` - pouzit existujici `CalculatePlanPnLUseCase` (lifetime accounting), ktery uz mame: + +```kotlin +private suspend fun computeRealizedPnLSoFar(planId: Long): BigDecimal { + return calculatePlanPnLUseCase(planId, currentMarketPrice = null).realizedPnL + ?: BigDecimal.ZERO +} +``` + +(Pridat `CalculatePlanPnLUseCase` do constructor injection pokud tam neni.) + +- [ ] **Krok 3: UI - Summary sekce** + +```kotlin +@Composable +fun SellSummarySection( + summary: SellSummary, + lossWarning: SellValidation.LossWarning?, + target: BigDecimal?, + fiat: String +) { + Column(modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.sell_wizard_summary_title), style = MaterialTheme.typography.titleSmall) + + SummaryRow(stringResource(R.string.sell_wizard_summary_profit_per_coin), summary.profitPerCoin, fiat) + SummaryRow(stringResource(R.string.sell_wizard_summary_gross_profit), summary.grossProfit, fiat) + SummaryRow(stringResource(R.string.sell_wizard_summary_fee), summary.estimatedFee?.negate(), fiat) + SummaryRow( + label = stringResource(R.string.sell_wizard_summary_net_profit), + value = summary.netProfit, + fiat = fiat, + highlightLoss = (summary.netProfit?.signum() ?: 0) < 0, + extra = summary.netProfitPct?.let { " (${"%+.1f".format(it * 100)}%)" } + ) + summary.remainingAfter?.let { ri -> + val avgText = ri.weightedAvgPrice?.setScale(2, RoundingMode.HALF_UP)?.toPlainString() ?: "-" + Text( + stringResource(R.string.sell_wizard_summary_remaining_after) + + ": ${ri.available.setScale(8, RoundingMode.DOWN).stripTrailingZeros().toPlainString()} @ $avgText" + ) + } + if (target != null && summary.targetProgressPct != null) { + Text( + stringResource(R.string.sell_wizard_summary_target_progress) + + ": ${"%.0f".format(summary.targetProgressPct * 100)}%" + ) + LinearProgressIndicator( + progress = { summary.targetProgressPct.toFloat().coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Composable +private fun SummaryRow( + label: String, + value: BigDecimal?, + fiat: String, + highlightLoss: Boolean = false, + extra: String? = null +) { + if (value == null) return + val color = if (highlightLoss) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurface + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + "${value.setScale(2, RoundingMode.HALF_UP).toPlainString()} $fiat${extra ?: ""}", + color = color + ) + } +} +``` + +- [ ] **Krok 4: Stringy** + +```xml + +Summary +Profit per coin +Gross profit +Estimated fee +Net profit (after fee) +After this sell +Plan target progress + + +Souhrn +Zisk na coin +Hruby zisk +Odhad fee +Cisty zisk (po fee) +Po prodeji +Postup k cili planu +``` + +- [ ] **Krok 5: Build a commit** + +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +git add ... +git commit -m "feat(sell): rich summary with profit, fee, remaining inventory, target progress" +``` + +--- + +### Task 12: Loss warning banner + +**Files:** +- Modify: `SellWizardBottomSheet.kt` +- Modify: `strings.xml` + +- [ ] **Krok 1: Stringy** + +```xml + +You are selling below your buy price: %1$s +After fee, you are selling at a loss: %1$s + + +Prodavas pod nakupni cenou: %1$s +Po fee prodavas se ztratou: %1$s +``` + +- [ ] **Krok 2: Banner v UI mezi summary a tlacitkem Pokracovat** + +```kotlin +state.lossWarning?.let { warn -> + val limitPrice = state.limitPrice.toBigDecimalOrNull() + val avg = state.avgBuyPrice.toBigDecimalOrNull() + val isPriceBelowAvg = limitPrice != null && avg != null && limitPrice < avg + val resId = if (isPriceBelowAvg) R.string.sell_wizard_loss_below_buy + else R.string.sell_wizard_loss_after_fee + Surface( + color = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.fillMaxWidth().padding(8.dp) + ) { + Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Warning, contentDescription = null, + tint = MaterialTheme.colorScheme.error) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(resId, formatFiat(warn.lossFiat, state.fiat)), + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } +} +``` + +- [ ] **Krok 3: Build a commit** + +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +git add ... +git commit -m "feat(sell): loss warning banner triggered by net-profit < 0" +``` + +--- + +### Task 13: Manualni overeni single mod konci-konce + +Bez code zmen. Otevrit appku v emulatoru / fyzickem zarizeni: + +- [ ] **Krok 1: Build debug APK** + +```bash +cd accbot-android && ./gradlew :app:assembleDebug +``` + +- [ ] **Krok 2: Nainstalovat na zarizeni** + +```bash +adb install -r accbot-android/app/build/outputs/apk/debug/app-debug.apk +``` + +- [ ] **Krok 3: Scenare pro overeni single modu** + +- **Plan s buys**: otevrit sell wizard, avg pole prefilled. Zkusit zmenit, reset pres tlacitko. Vrati se k auto. +- **Prazdny plan (zadne buys)**: otevrit sell wizard, avg pole prazdne, helper text "Zadej rucne". Vyplnit, validace projde. +- **Editovat A a P**: N se dopocita. Editovat A a N: P se dopocita. Editovat P a N: A se dopocita. +- **Cenove presety**: AVG mode, +10% -> P = avg × 1.10. Prepnout na SPOT mode, +10% -> P = spot × 1.10. +- **Net presety**: +20% -> N = A × avg × 1.20. +- **Loss case**: zadat P = avg - 1, banner "Prodavas pod nakupni cenou". Zadat P tesne nad avg (~ 0.3% nad), banner "Po fee se ztratou". +- **Summary**: vsechny radky prochazeji, profit cervene pri ztrate, target progress jen pokud `targetProfitAmount` na planu nastaveno. + +- [ ] **Krok 4: Pripadne opravy** + +Pokud nektery scenar selhe, opravit konkretni bug. Maly commit `fix(sell): ...`. + +--- + +## Faze 6: Ladder mode + +### Task 14: PlaceLadderSellUseCase + +**Files:** +- Create: `accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLadderSellUseCase.kt` + +- [ ] **Krok 1: Implementovat use case** + +```kotlin +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.CredentialsStore +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.data.local.toEntity +import com.accbot.dca.domain.model.DcaResult +import com.accbot.dca.exchange.ExchangeApiFactory +import java.math.BigDecimal +import javax.inject.Inject + +data class LadderOrder(val cryptoAmount: BigDecimal, val limitPrice: BigDecimal) + +sealed class LadderResult { + data class AllPlaced(val placedTxIds: List) : LadderResult() + data class PartialFailure( + val placedTxIds: List, + val failedAtIndex: Int, + val totalCount: Int, + val reason: String + ) : LadderResult() +} + +class PlaceLadderSellUseCase @Inject constructor( + private val database: DcaDatabase, + private val credentialsStore: CredentialsStore, + private val exchangeApiFactory: ExchangeApiFactory, + private val userPreferences: UserPreferences, + private val resolvePendingTransactionsUseCase: ResolvePendingTransactionsUseCase +) { + suspend operator fun invoke( + planId: Long, + orders: List + ): LadderResult { + if (orders.size < 2) return LadderResult.PartialFailure( + emptyList(), 0, orders.size, "Ladder vyzaduje aspon 2 ordery" + ) + + val plan = database.dcaPlanDao().getPlanById(planId) + ?: return LadderResult.PartialFailure(emptyList(), 0, orders.size, "Plan neexistuje") + val credentials = credentialsStore.getCredentials( + plan.connectionId, userPreferences.isSandboxMode() + ) ?: return LadderResult.PartialFailure(emptyList(), 0, orders.size, "Chybi credentials") + + val api = exchangeApiFactory.create(credentials) + val placed = mutableListOf() + + orders.forEachIndexed { idx, order -> + val result = api.limitSell(plan.crypto, plan.fiat, order.cryptoAmount, order.limitPrice) + when (result) { + is DcaResult.Success -> { + val tx = result.transaction.copy(planId = planId, connectionId = plan.connectionId) + val id = database.transactionDao().insertTransaction(tx.toEntity()) + placed += id + } + is DcaResult.Error -> { + return LadderResult.PartialFailure(placed, idx, orders.size, result.message) + } + } + } + + try { resolvePendingTransactionsUseCase() } catch (_: Exception) {} + return LadderResult.AllPlaced(placed) + } +} +``` + +- [ ] **Krok 2: Build check** + +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +``` + +- [ ] **Krok 3: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLadderSellUseCase.kt +git commit -m "feat(sell): PlaceLadderSellUseCase with stop-and-report failure handling" +``` + +--- + +### Task 15: Ladder generator helper + testy + +**Files:** +- Create: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/LadderGenerator.kt` +- Create: `accbot-android/app/src/test/java/com/accbot/dca/presentation/screens/plans/sell/LadderGeneratorTest.kt` + +- [ ] **Krok 1: Implementovat generator** + +```kotlin +package com.accbot.dca.presentation.screens.plans.sell + +import com.accbot.dca.domain.usecase.LadderOrder +import java.math.BigDecimal +import java.math.RoundingMode + +object LadderGenerator { + + enum class AmountMode { EQUAL_CRYPTO, EQUAL_FIAT } + + /** + * Generuje N orderu s linearne rozprostrenymi cenami od `from` do `to`. + * @param totalAmount celkove crypto k prodeji + * @param mode rozdeleni mezi ordery + */ + fun generate( + totalAmount: BigDecimal, + from: BigDecimal, + to: BigDecimal, + count: Int, + mode: AmountMode + ): List { + require(count >= 2) { "count >= 2" } + require(totalAmount > BigDecimal.ZERO) { "totalAmount > 0" } + require(from > BigDecimal.ZERO && to > BigDecimal.ZERO) { "ceny > 0" } + + val prices = (0 until count).map { i -> + from + (to - from) * BigDecimal(i) / BigDecimal(count - 1) + } + + return when (mode) { + AmountMode.EQUAL_CRYPTO -> { + val per = totalAmount.divide(BigDecimal(count), 8, RoundingMode.DOWN) + // Korekce zaokrouhleni na poslednim orderu (zbyle drobky pridat) + val drobky = totalAmount - per * BigDecimal(count) + prices.mapIndexed { i, p -> + val a = if (i == count - 1) per + drobky else per + LadderOrder(a, p.setScale(2, RoundingMode.HALF_UP)) + } + } + AmountMode.EQUAL_FIAT -> { + val totalGross = (prices.sum()) * totalAmount / BigDecimal(count) // pro odhad equal fiat + // Equal fiat: kazdy order vygeneruje stejny gross fiat (totalGross / N) + // amount_i = (totalGross / N) / price_i + val perOrderFiat = totalGross.divide(BigDecimal(count), 8, RoundingMode.HALF_UP) + val amounts = prices.map { p -> + perOrderFiat.divide(p, 8, RoundingMode.DOWN) + } + val sumAmounts = amounts.fold(BigDecimal.ZERO) { acc, x -> acc + x } + // Skalovat aby suma sedela na totalAmount + val scale = if (sumAmounts > BigDecimal.ZERO) totalAmount.divide(sumAmounts, 8, RoundingMode.HALF_UP) + else BigDecimal.ONE + amounts.mapIndexed { i, a -> + val scaled = (a * scale).setScale(8, RoundingMode.DOWN) + LadderOrder(scaled, prices[i].setScale(2, RoundingMode.HALF_UP)) + } + } + } + } + + private fun List.sum(): BigDecimal = fold(BigDecimal.ZERO) { acc, x -> acc + x } +} +``` + +- [ ] **Krok 2: Testy** + +```kotlin +package com.accbot.dca.presentation.screens.plans.sell + +import com.accbot.dca.presentation.screens.plans.sell.LadderGenerator.AmountMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.math.BigDecimal + +class LadderGeneratorTest { + + @Test + fun `equal crypto - 5 orderu po 0,2 BTC v rozsahu cen`() { + val orders = LadderGenerator.generate( + totalAmount = BigDecimal("1"), + from = BigDecimal("2000000"), + to = BigDecimal("2400000"), + count = 5, + mode = AmountMode.EQUAL_CRYPTO + ) + assertEquals(5, orders.size) + // ceny linearne: 2.0M, 2.1M, 2.2M, 2.3M, 2.4M + assertEquals(0, BigDecimal("2000000.00").compareTo(orders[0].limitPrice)) + assertEquals(0, BigDecimal("2400000.00").compareTo(orders[4].limitPrice)) + // mnozstvi: kazdy 0.2 BTC, suma = 1 + val total = orders.fold(BigDecimal.ZERO) { acc, o -> acc + o.cryptoAmount } + assertEquals(0, BigDecimal("1.00000000").compareTo(total)) + } + + @Test + fun `equal fiat - levnejsi ordery prodavaji vic crypta`() { + val orders = LadderGenerator.generate( + totalAmount = BigDecimal("1"), + from = BigDecimal("1000000"), + to = BigDecimal("2000000"), + count = 4, + mode = AmountMode.EQUAL_FIAT + ) + assertEquals(4, orders.size) + // levnejsi (index 0) -> vetsi mnozstvi + assertTrue(orders[0].cryptoAmount > orders[3].cryptoAmount) + } + + @Test(expected = IllegalArgumentException::class) + fun `count menez nez 2 hodi exception`() { + LadderGenerator.generate(BigDecimal("1"), BigDecimal("1000"), BigDecimal("2000"), 1, AmountMode.EQUAL_CRYPTO) + } +} +``` + +- [ ] **Krok 3: Spustit testy** + +```bash +cd accbot-android && ./gradlew :app:testDebugUnitTest --tests "com.accbot.dca.presentation.screens.plans.sell.LadderGeneratorTest" +``` + +Expected: 3 testy PASS. + +- [ ] **Krok 4: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/LadderGenerator.kt +git add accbot-android/app/src/test/java/com/accbot/dca/presentation/screens/plans/sell/LadderGeneratorTest.kt +git commit -m "feat(sell): LadderGenerator pure helper for linear scale-out" +``` + +--- + +### Task 16: Ladder validation v ValidateSellOrderUseCase + +**Files:** +- Modify: `ValidateSellOrderUseCase.kt` + +- [ ] **Krok 1: Pridat ladder validate metodu** + +V tride pridat: + +```kotlin +suspend fun validateLadder( + planId: Long, + orders: List, + minOrderSize: BigDecimal, + avgBuyPrice: BigDecimal?, + feeRate: BigDecimal, + currentSpot: BigDecimal? +): List { + val total = orders.fold(BigDecimal.ZERO) { acc, o -> acc + o.cryptoAmount } + val baseValidation = invoke( + planId = planId, + cryptoAmount = total, + limitPrice = orders.firstOrNull()?.limitPrice ?: BigDecimal.ONE, // proxy pro available check + minOrderSize = orders.minOf { it.cryptoAmount }, + currentSpot = null, // skip instant-fill / far-from-market u ladderu + avgBuyPrice = avgBuyPrice, + feeRate = feeRate + ).filter { + // Vyhodit instant-fill / far-from-market warningy z proxy validace + it !is SellValidation.InstantFillInfo && it !is SellValidation.FarFromMarketWarning + }.toMutableList() + + // Aggregated loss warning + val totalLoss = orders.fold(BigDecimal.ZERO) { acc, o -> + val l = checkLoss(o.cryptoAmount, o.limitPrice, avgBuyPrice, feeRate)?.lossFiat ?: BigDecimal.ZERO + acc + l + } + if (totalLoss > BigDecimal.ZERO) { + baseValidation += SellValidation.LossWarning(totalLoss, 0.0) + } + + // Per-order minOrderSize check je uz v invoke pres `minOf` - OK + // Far-from-market: kdyz prvni cena (nejnizsi) > 3 × spot + if (currentSpot != null && orders.first().limitPrice > currentSpot * BigDecimal("3")) { + baseValidation += SellValidation.FarFromMarketWarning(currentSpot) + } + + return baseValidation.ifEmpty { listOf(SellValidation.Ok) } +} +``` + +- [ ] **Krok 2: Build a commit** + +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +git add accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt +git commit -m "feat(sell): ladder validation with aggregated loss + edge checks" +``` + +--- + +### Task 17: SellWizardViewModel - ladder state + +**Files:** +- Modify: `SellWizardViewModel.kt` + +- [ ] **Krok 1: Rozsirit state** + +```kotlin +data class SellWizardState( + // ... + val ladderEnabled: Boolean = false, + val ladderRangeMode: LadderRangeMode = LadderRangeMode.PRICE, + val ladderFrom: String = "", + val ladderTo: String = "", + val ladderCount: String = "5", + val ladderAmountMode: LadderGenerator.AmountMode = LadderGenerator.AmountMode.EQUAL_CRYPTO, + val ladderPreview: List = emptyList() +) + +enum class LadderRangeMode { PRICE, PROFIT_PCT } +``` + +- [ ] **Krok 2: Pridat ladder handlery** + +```kotlin +fun onLadderEnabledChange(enabled: Boolean) { + _state.update { it.copy(ladderEnabled = enabled) } + recomputeLadderPreview() +} + +fun onLadderRangeModeChange(mode: LadderRangeMode) { + _state.update { it.copy(ladderRangeMode = mode) } + recomputeLadderPreview() +} + +fun onLadderFromChange(text: String) { + _state.update { it.copy(ladderFrom = text) } + recomputeLadderPreview() +} + +fun onLadderToChange(text: String) { + _state.update { it.copy(ladderTo = text) } + recomputeLadderPreview() +} + +fun onLadderCountChange(text: String) { + _state.update { it.copy(ladderCount = text) } + recomputeLadderPreview() +} + +fun onLadderAmountModeChange(mode: LadderGenerator.AmountMode) { + _state.update { it.copy(ladderAmountMode = mode) } + recomputeLadderPreview() +} + +private fun recomputeLadderPreview() { + val st = _state.value + if (!st.ladderEnabled) { + _state.update { it.copy(ladderPreview = emptyList()) } + return + } + val total = st.amount.toBigDecimalOrNull() ?: return + val count = st.ladderCount.toIntOrNull() ?: return + if (count < 2) return + + val avg = st.avgBuyPrice.toBigDecimalOrNull() + val (from, to) = when (st.ladderRangeMode) { + LadderRangeMode.PRICE -> { + val f = st.ladderFrom.toBigDecimalOrNull() ?: return + val t = st.ladderTo.toBigDecimalOrNull() ?: return + f to t + } + LadderRangeMode.PROFIT_PCT -> { + if (avg == null) return + val fPct = st.ladderFrom.toBigDecimalOrNull() ?: return + val tPct = st.ladderTo.toBigDecimalOrNull() ?: return + (avg * (BigDecimal.ONE + fPct / BigDecimal("100"))) to + (avg * (BigDecimal.ONE + tPct / BigDecimal("100"))) + } + } + if (to <= from) return + + val orders = LadderGenerator.generate(total, from, to, count, st.ladderAmountMode) + _state.update { it.copy(ladderPreview = orders) } +} +``` + +- [ ] **Krok 3: Build a commit** + +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +git add ... +git commit -m "feat(sell): ladder state machine in SellWizardViewModel" +``` + +--- + +### Task 18: Ladder UI - checkbox, range, count, toggles, preview tabulka + +**Files:** +- Modify: `SellWizardBottomSheet.kt` +- Modify: `strings.xml` + +- [ ] **Krok 1: Stringy** + +```xml + +Create multiple sell orders +From +To +Number of orders +Price +Profit % +Equal crypto +Equal fiat +Total at full fill + + +Vytvorit vice sell orderu +Od +Do +Pocet orderu +Cena +Profit % +Stejne crypto +Stejny fiat +Celkem pri plnem fillu +``` + +- [ ] **Krok 2: UI - checkbox + ladder block (visible jen kdyz enabled)** + +```kotlin +Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = state.ladderEnabled, + onCheckedChange = viewModel::onLadderEnabledChange + ) + Text(stringResource(R.string.sell_wizard_ladder_enable)) +} + +if (state.ladderEnabled) { + // Range mode toggle (Cena / Profit %) + SegmentedButton( + options = listOf( + LadderRangeMode.PRICE to stringResource(R.string.sell_wizard_ladder_range_price), + LadderRangeMode.PROFIT_PCT to stringResource(R.string.sell_wizard_ladder_range_profit) + ), + selected = state.ladderRangeMode, + onSelect = viewModel::onLadderRangeModeChange + ) + + Row { + OutlinedTextField( + value = state.ladderFrom, + onValueChange = viewModel::onLadderFromChange, + label = { Text(stringResource(R.string.sell_wizard_ladder_from)) }, + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = state.ladderTo, + onValueChange = viewModel::onLadderToChange, + label = { Text(stringResource(R.string.sell_wizard_ladder_to)) }, + modifier = Modifier.weight(1f) + ) + } + + OutlinedTextField( + value = state.ladderCount, + onValueChange = viewModel::onLadderCountChange, + label = { Text(stringResource(R.string.sell_wizard_ladder_count)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + + SegmentedButton( + options = listOf( + LadderGenerator.AmountMode.EQUAL_CRYPTO to stringResource(R.string.sell_wizard_ladder_amount_equal_crypto), + LadderGenerator.AmountMode.EQUAL_FIAT to stringResource(R.string.sell_wizard_ladder_amount_equal_fiat) + ), + selected = state.ladderAmountMode, + onSelect = viewModel::onLadderAmountModeChange + ) + + LadderPreviewTable( + orders = state.ladderPreview, + avg = state.avgBuyPrice.toBigDecimalOrNull(), + feeRate = state.feeRate + ) +} +``` + +(Pokud `SegmentedButton` nemate, pouzit FilterChip Row alternativu.) + +- [ ] **Krok 3: Composable LadderPreviewTable** + +```kotlin +@Composable +fun LadderPreviewTable(orders: List, avg: BigDecimal?, feeRate: BigDecimal) { + if (orders.isEmpty()) return + Column(modifier = Modifier.fillMaxWidth()) { + // Header + Row(modifier = Modifier.fillMaxWidth()) { + Text("#", modifier = Modifier.weight(0.5f)) + Text(stringResource(R.string.sell_wizard_summary_amount), modifier = Modifier.weight(1.5f)) + Text(stringResource(R.string.sell_wizard_limit_price), modifier = Modifier.weight(2f)) + Text("Profit %", modifier = Modifier.weight(1.5f)) + Text(stringResource(R.string.sell_wizard_summary_net_profit), modifier = Modifier.weight(2f)) + } + Divider() + + var totalNet = BigDecimal.ZERO + orders.forEachIndexed { i, o -> + val gross = o.cryptoAmount * o.limitPrice + val net = gross * (BigDecimal.ONE - feeRate) + val profitPct = if (avg != null && avg > BigDecimal.ZERO) + (o.limitPrice - avg).divide(avg, 4, RoundingMode.HALF_UP).movePointRight(2) + else null + totalNet = totalNet + (net - o.cryptoAmount * (avg ?: BigDecimal.ZERO)) + + Row(modifier = Modifier.fillMaxWidth()) { + Text("${i + 1}", modifier = Modifier.weight(0.5f)) + Text(o.cryptoAmount.setScale(8, RoundingMode.DOWN).stripTrailingZeros().toPlainString(), + modifier = Modifier.weight(1.5f)) + Text(o.limitPrice.setScale(2, RoundingMode.HALF_UP).toPlainString(), + modifier = Modifier.weight(2f)) + Text(profitPct?.toPlainString()?.let { "$it%" } ?: "-", + modifier = Modifier.weight(1.5f)) + Text(net.setScale(2, RoundingMode.HALF_UP).toPlainString(), + modifier = Modifier.weight(2f)) + } + } + Divider() + Text( + "${stringResource(R.string.sell_wizard_ladder_preview_total)}: " + + totalNet.setScale(2, RoundingMode.HALF_UP).toPlainString(), + style = MaterialTheme.typography.titleSmall + ) + } +} +``` + +- [ ] **Krok 4: V ladder modu skryt singl-mode pole `Cisty vynos` a presety** + +V krocich kde se rendruje single mode UI: obalit do `if (!state.ladderEnabled) { ... }`. + +Limit price pole zustava (single nazev), v ladder modu se schova a zobrazi se `Od/Do`. + +- [ ] **Krok 5: Build a commit** + +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +git add ... +git commit -m "feat(sell): ladder UI with range, count, amount-mode toggle, preview table" +``` + +--- + +### Task 19: Ladder submit flow + post-submit dialog + +**Files:** +- Modify: `SellWizardViewModel.kt` +- Modify: `SellWizardBottomSheet.kt` +- Modify: `strings.xml` + +- [ ] **Krok 1: Stringy** + +```xml + +Placed %1$d of %2$d orders. Remaining did not proceed: %3$s +All %1$d orders placed + + +Vytvoreno %1$d z %2$d orderu. Zbyvajici nepokracovaly: %3$s +Vsech %1$d orderu vytvoreno +``` + +- [ ] **Krok 2: ViewModel - submit ladder** + +```kotlin +@Inject lateinit var placeLadderSellUseCase: PlaceLadderSellUseCase + +suspend fun submitLadder(): LadderResult? { + val st = _state.value + if (!st.ladderEnabled || st.ladderPreview.isEmpty()) return null + return placeLadderSellUseCase(st.planId, st.ladderPreview) +} +``` + +- [ ] **Krok 3: Krok 2 wizardu - rozliseni single vs ladder pri submitu** + +V kroku 2 (potvrzeni): + +```kotlin +val coroutineScope = rememberCoroutineScope() +Button(onClick = { + coroutineScope.launch { + if (state.ladderEnabled) { + val result = viewModel.submitLadder() + // ukazat dialog dle vysledku + when (result) { + is LadderResult.AllPlaced -> showDialog(R.string.sell_wizard_ladder_all_placed, result.placedTxIds.size) + is LadderResult.PartialFailure -> showDialog( + R.string.sell_wizard_ladder_partial_success, + result.placedTxIds.size, result.totalCount, result.reason + ) + null -> {} + } + onDismiss() + } else { + viewModel.submitSingle() + onDismiss() + } + } +}) { + Text(stringResource(R.string.sell_wizard_submit)) +} +``` + +- [ ] **Krok 4: Build a commit** + +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +git add ... +git commit -m "feat(sell): ladder submit flow + partial-success dialog" +``` + +--- + +### Task 20: Manualni overeni ladder modu + +Bez code zmen. + +- [ ] **Krok 1: Build & install debug APK** + +```bash +cd accbot-android && ./gradlew :app:assembleDebug && \ +adb install -r accbot-android/app/build/outputs/apk/debug/app-debug.apk +``` + +- [ ] **Krok 2: Scenare** + +- **Aktivace ladder**: zaskrtnout, single pole se schova, ladder pole se ukaze, preview prazdne. +- **Zadat range cena**: from=2000000, to=2400000, count=5, total amount=1 BTC. Preview ukaze 5 orderu po 0.2 BTC s rovnomerne rozprostrenymi cenami. +- **Toggle profit %**: prepnout, pole `Od/Do` jsou ted procenta nad avg. Zadat 10/30, preview vyrenderuje ceny avg×1.10 az avg×1.30. +- **Toggle equal-fiat**: preview se prerovna - levnejsi ordery vetsi mnozstvi. +- **Submit**: kliknout Pokracovat, Krok 2 zobrazi preview + agregat. Submit -> burza dostane N orderu. +- **Stop & report**: jak otestovat? Coinmate sandbox neexistuje, fakov failure jde: + - Zadat ladder s prilis malymi mnozstvimi (under minOrderSize) - validace by mela zachytit pred submitem. + - Pokud chces realny stop&report test: nastavit `to` velmi vysoko (mimo limity burzy), 1-2 ordery proveddu, zbytek fail. +- **Plan delete**: po vytvoreni ladderu zkusit smazat plan. Mel by byt blokovany (existujici Task 31 logiky). + +- [ ] **Krok 3: Pripadne opravy** + +Maly commit `fix(sell): ...` per opravu. + +--- + +## Faze 7: E2E zacleneni + +### Task 21: Aktualizace E2E checklistu pro Task 33 / Task 34 z puvodniho planu + +**Files:** +- Modify: `docs/superpowers/plans/2026-04-23-dca-sell-extension.md` + +- [ ] **Krok 1: V Task 33 (Coinmate manualni sandbox test) doplnit nove scenare:** + +```markdown +**Scenar E - cost basis prefill:** +- Otevrit sell wizard na planu s 3 buys ruznych cen +- Overit ze avg buy price prefilled, hodnota matematicky odpovida cheapest-first vypoctu +- Manualni override -> zmeni se hodnota, "✏️ Zadano rucne" indikator +- Reset -> vrati auto + +**Scenar F - 3-pole kalkulacka:** +- Zadat A a P, N se dopocita +- Zadat A a N, P se dopocita +- Cenovy preset +20% z avg -> P = avg × 1.20 +- Net preset +20% -> N = A × avg × 1.20 + +**Scenar G - loss warning:** +- Zadat P pod avg buy -> banner "Prodavas pod nakupni cenou", cervene profit +- Zadat P tesne nad avg (~0.3%) -> banner "Po fee se ztratou" + +**Scenar H - ladder mode:** +- Zaskrtnout checkbox, zadat from/to/count, total amount +- Preview tabulka renderuje N orderu +- Toggle equal-fiat -> uneven crypto amounts +- Submit -> N PENDING SELL transakci na burze, vsechny v plan-detail open orders + +**Scenar I - ladder partial failure:** +- Zadat ladder mimo limity Coinmate (extremne vysoky `to`) +- Submit -> dialog "Vytvoreno X z N orderu, zbyvajici: " +- Plan-detail ukazuje X PENDING orderu +``` + +- [ ] **Krok 2: V Task 34 (Binance) totez (analogicke scenare E-I)** + +- [ ] **Krok 3: Commit** + +```bash +git add docs/superpowers/plans/2026-04-23-dca-sell-extension.md +git commit -m "docs(sell): extend Task 33/34 E2E with cost-basis + ladder scenarios" +``` + +--- + +## Summary + +**Celkem tasku:** 21 +**Predpokladany rozsah:** 2-3 dny pro experienced Kotlin/Compose dev. Vetsina komplexity je v UI state machine a v korektnim provazani s existujicimi flowy. + +**Kriticke zavislosti v poradi:** +- Task 1-3 (cost basis foundation) MUSI byt hotove pred 7+ (ViewModel) +- Task 4 (fee plumbing) potreba pred 5 (loss check) a 7 (calculator) +- Task 14-16 (ladder use case + generator + validation) pred 17-19 (UI) +- Tasky 13 a 20 (manualni testy) konci jednotlivych mod +- Task 21 (E2E zacleneni) jen po vsem + +**TDD pokryti:** +- `CalculatePlanCostBasisUseCase.computeCostBasis` - 9 testu +- `ValidateSellOrderUseCase.checkLoss` - 4 testy +- `SellCalculatorMath.recompute` - 5 testu +- `LadderGenerator.generate` - 3 testy +- UI a ViewModel state machine - manualni testy v Task 13 / Task 20 + +**Co se NEmeni:** +- DB schema, migrace +- Backup/restore +- `CalculatePlanPnLUseCase` +- `SellPollingWorker`, `ResolvePendingTransactionsUseCase` +- `PlaceLimitSellUseCase` (ladder = vlastni cesta) + +**Out of scope (viz spec):** cache, snapshot avg na sell, hard block na loss, geometric distribuce, atomic batch, persistovane preset preferences. diff --git a/docs/superpowers/specs/2026-04-23-dca-sell-extension-design.md b/docs/superpowers/specs/2026-04-23-dca-sell-extension-design.md new file mode 100644 index 0000000..cc0a543 --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-dca-sell-extension-design.md @@ -0,0 +1,488 @@ +# DCA Sell Extension - Design Spec + +**Datum:** 2026-04-23 +**Status:** Draft - waiting user review +**Scope:** Android app (`accbot-android/`) + +## 1. Cíl a kontext + +Rozšířit existující DCA plány o opt-in **trading mode** - schopnost zadávat limitní prodejní příkazy na burzu, sledovat jejich stav, zobrazovat sell transakce v historii a grafu, a počítat realizovaný i nerealizovaný P&L vůči volitelnému cíli zisku. + +Funkce je primárně určena pro pokročilé uživatele, kteří kromě akumulace občas realizují část pozice. Skrývá se za globální Settings toggle a per-plán opt-in - běžný DCA uživatel nezažije žádnou změnu. + +### Mimo scope MVP + +- Auto-sell triggery (plán prodá sám při dosažení ceny) +- Online price watcher +- Stop-loss +- Ladder sells (více limit orderů najednou) +- Sell wizard s doporučenou cenou / profit preview kalkulátorem nad rámec quick-select chipů +- Limit BUY (DCA zůstává market buy) +- Loan tracking (Firefish nebo jiné půjčky) +- Push notifikace o filled orderech + +## 2. Architektura + +``` +┌──────────────────────────────────────────────────────────┐ +│ Plan Detail Screen │ +│ ┌──────────────────┐ ┌─────────────────────────────┐ │ +│ │ Buy side │ │ Sell side (opt-in) │ │ +│ │ - DCA schedule │ │ - "Place limit sell" button │ │ +│ │ - Next buy │ │ - Open orders list │ │ +│ │ - Buy history │ │ - P&L card + target progress│ │ +│ └──────────────────┘ └─────────────────────────────┘ │ +└──────────────────────────────────────────────────────────┘ + │ │ + ▼ ▼ + ┌─────────────┐ ┌──────────────────┐ + │ DcaWorker │ │ ExchangeApi │ + │ (buy exec) │ │ - marketBuy() │ + └─────────────┘ │ - limitSell() │ ← nový + │ │ - getOrderStatus│ ← refactor + │ │ - cancelOrder() │ ← nový + ▼ └──────────────────┘ + ┌──────────────────────────────────────┐ + │ ResolvePendingTransactionsUseCase │ ← rozšíří se + │ (řeší PENDING + PARTIAL na BUY+SELL)│ + └──────────────────────────────────────┘ + ▲ + │ triggers + ├── App onResume + ├── DcaWorker tick (buy exec) + ├── Po placement / cancel + ├── Pull-to-refresh + └── SellPollingWorker (opt-in) +``` + +### Klíčové principy + +1. **Minimum invasive na existující kód.** Buy-side logika beze změn. Přidají se 2 pole na `DcaPlan`, 3 pole na `TransactionEntity`, 3 nové metody v `ExchangeApi`. +2. **Znovuužití existujícího pending-tx flow.** Limit sell = transakce s `side=SELL`, `status=PENDING`. Existující `ResolvePendingTransactionsUseCase` řeší fill resolution pro buy i sell. +3. **Dvouvrstvý opt-in.** Globální Settings toggle (default OFF) → per-plán `allowSells` flag. Bez globálního toggle žádné nové UI nikde. +4. **Polling, ne websocket.** Žádné real-time updaty. Triggery: app open, DCA worker tick, po user akci, pull-to-refresh, opt-in periodic worker. + +## 3. Datový model + +### UserPreferences (nové flagy) + +```kotlin +fun isTradingEnabled(): Boolean // default false (master gate) +fun setTradingEnabled(enabled: Boolean) + +fun isPeriodicSellPollingEnabled(): Boolean // default false +fun getSellPollingFrequency(): DcaFrequency // default HOURLY +fun getSellPollingCronExpression(): String? // jen pro CUSTOM +fun getSellPollingScheduleConfig(): String? // serialized ScheduleBuilderState +fun setPeriodicSellPolling(enabled: Boolean, frequency: DcaFrequency, ...) +``` + +SharedPreferences keys: `trading_enabled`, `sell_polling_enabled`, `sell_polling_frequency`, `sell_polling_cron`, `sell_polling_schedule_config`. Per-device, ne v backupu (advanced opt-in se přijatelně re-enabluje po restore). + +### DcaPlan (rozšíření) + +```kotlin +data class DcaPlan( + // existující pole beze změn + val allowSells: Boolean = false, + val targetProfitAmount: BigDecimal? = null // jednotka = plan.fiat +) +``` + +### TransactionEntity (rozšíření) + +```kotlin +data class TransactionEntity( + // existující pole beze změn + val side: TransactionSide = TransactionSide.BUY, + val limitPrice: BigDecimal? = null, + val requestedCryptoAmount: BigDecimal? = null +) + +enum class TransactionSide { BUY, SELL } +``` + +**Sémantika polí pro různé stavy:** + +| Pole | BUY market | SELL limit | +|---|---|---| +| `cryptoAmount` | filled (final) | filled so far (0 → requested) | +| `fiatAmount` | spent (final) | received so far (0 → requested × avg fill) | +| `requestedCryptoAmount` | `null` | `0.01` (zadáno při založení, fixed) | +| `limitPrice` | `null` | `1 200 000` (fixed) | + +Progress fill v UI = `cryptoAmount / requestedCryptoAmount`. + +### Lifecycle limit sell orderu + +| Fáze | status | cryptoAmount | fiatAmount | +|---|---|---|---| +| Order zadán | PENDING | 0 | 0 | +| Partially filled | PARTIAL | 0.005 | 6 000 | +| Fully filled | COMPLETED | 0.01 (= requested) | 12 000 | +| Canceled bez fillu | FAILED | 0 | 0 | +| Canceled po partial fillu | PARTIAL | filled-so-far | filled-so-far | +| Expired bez fillu | FAILED | 0 | 0 | +| Expired po partial fillu | PARTIAL | filled-so-far | filled-so-far | + +`requestedCryptoAmount` zůstává fixní napříč všemi stavy. + +### Room migrace v20 → v21 + +```sql +ALTER TABLE dca_plans ADD COLUMN allowSells INTEGER NOT NULL DEFAULT 0; +ALTER TABLE dca_plans ADD COLUMN targetProfitAmount TEXT DEFAULT NULL; + +ALTER TABLE transactions ADD COLUMN side TEXT NOT NULL DEFAULT 'BUY'; +ALTER TABLE transactions ADD COLUMN limitPrice TEXT DEFAULT NULL; +ALTER TABLE transactions ADD COLUMN requestedCryptoAmount TEXT DEFAULT NULL; + +CREATE INDEX IF NOT EXISTS idx_tx_plan_side_status + ON transactions(planId, side, status); +``` + +Vše backward-kompatibilní. + +### Backup / Restore + +`BackupPlan` rozšířen o `allowSells` + `targetProfitAmount`. `BackupTransaction` rozšířen o `side` + `limitPrice` + `requestedCryptoAmount`. Všechna nová pole nepovinná s defaulty (BUY, null) pro starší verze. + +### P&L (derivovaný, neperzistuje se) + +```kotlin +data class PlanPnL( + val totalBoughtFiat: BigDecimal, + val totalBoughtCrypto: BigDecimal, + val totalSoldFiat: BigDecimal, + val totalSoldCrypto: BigDecimal, + val currentCryptoHeld: BigDecimal, // bought - sold + val avgBuyPrice: BigDecimal?, // null pokud nic nenakoupeno + val currentValueFiat: BigDecimal?, // null pokud spot není dostupný + val realizedPnL: BigDecimal?, // soldFiat - (soldCrypto * avgBuyPrice) + val unrealizedPnL: BigDecimal?, // currentValueFiat - (held * avgBuyPrice) + val netPnL: BigDecimal?, // realized + unrealized + val targetProgressPct: Double? // netPnL / targetProfitAmount +) +``` + +Počítá se on-the-fly v ViewModelu. Žádná perzistence. + +## 4. Exchange API rozšíření + +### Nové metody na `ExchangeApi` + +```kotlin +interface ExchangeApi { + // existující metody beze změn + + suspend fun limitSell( + crypto: String, + fiat: String, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal + ): DcaResult = throw UnsupportedOperationException( + "AccBot zatím nepodporuje limit sell pro ${exchange.displayName}" + ) + + suspend fun cancelOrder(orderId: String): Result = + Result.failure(UnsupportedOperationException( + "AccBot zatím nepodporuje cancel order pro ${exchange.displayName}" + )) + + val supportsLimitSell: Boolean get() = false +} +``` + +### Refactor `getOrderStatus` + +Stávající signature `Transaction?` nepokrývá partial fill. Refactor na: + +```kotlin +data class OrderStatusResult( + val status: TransactionStatus, // PENDING/PARTIAL/COMPLETED/FAILED + val filledCryptoAmount: BigDecimal, + val filledFiatAmount: BigDecimal, + val avgFillPrice: BigDecimal?, + val fee: BigDecimal?, + val feeAsset: String? +) + +suspend fun getOrderStatus(orderId: String): OrderStatusResult? = null +``` + +**Breaking change** pro existující callery - migrace kódu: +- `CoinbaseApi.getOrderStatus` - přemapovat z `Transaction?` na `OrderStatusResult?` +- `OtherExchanges.kt` (KrakenApi má getOrderStatus) - dtto +- `ResolvePendingTransactionsUseCase` - update mapování + +### MVP support matrix + +| Burza | `limitSell` | `cancelOrder` | `getOrderStatus` | +|---|---|---|---| +| Coinmate | ANO | ANO | ANO (nový/refactor) | +| Binance | ANO | ANO | ANO (nový) | +| Coinbase | NE (default) | NE | refactor existujícího | +| Kraken | NE | NE | refactor existujícího | +| KuCoin / Bitfinex / Huobi | NE | NE | NE | + +Coinmate i Binance přepíšou `supportsLimitSell = true` v override; ostatní burzy ho nechávají na default `false`. + +UI gating přes `supportsLimitSell` - tlačítka pro nepodporované burzy nejsou viditelná, případné existující plány s `allowSells=true` na nepodporované burze zobrazí warning místo sell sekce. + +### REST endpointy + +**Coinmate:** +- `POST /api/sellLimit` - založení +- `POST /api/cancelOrder` - cancel +- `POST /api/orderById` - status (`status: OPEN/FILLED/PARTIALLY_FILLED/CANCELLED`, `remainingAmount`, `originalAmount`) + +**Binance:** +- `POST /api/v3/order` (`type=LIMIT, side=SELL, timeInForce=GTC`) +- `DELETE /api/v3/order` +- `GET /api/v3/order` (`status`, `executedQty`, `cummulativeQuoteQty`) + +Sandbox: Coinmate sandbox + Binance testnet podporují limit ordery, fungují identicky s produkcí. + +## 5. Sell flow (UX) + +### Wizard - 2-step bottom-sheet + +**Krok 1: Zadání objednávky** + +Inputy: +- **Množství** (crypto, defaultně focused). Quick-select chipy `25% / 50% / 75% / Vše` z `currentCryptoHeld - sum(open sell requested)`. Toggle na vstup ve fiatu (přepočet podle limitní ceny). +- **Limitní cena** (fiat). Quick-select chipy: + - `Tržní` = aktuální spot price + - `Breakeven` = `avgBuyPrice` plánu + - `+10%`, `+25%` = relativně k `avgBuyPrice` + +Live souhrn: +- Získáte: `množství × limitní cena` +- Zisk vs prům: `(limitní - avgBuy) × množství` (zelená/červená, fiat + %) +- Cílová cena: `(limitní - spot) / spot` (informativní) + +**Validace inline:** +- Množství > `currentCryptoHeld - sum(open sell requested)` → red error "Nemáte tolik BTC k dispozici (k dispozici X)" +- Množství < min order size burzy → red error +- Limit price <= spot → ⚡ info banner "Prodej proběhne okamžitě - příkaz se zfilluje ihned za nejvyšší nabídku na burze (obvykle blízko tržní ceny minus spread). Není to chyba." (ne-blokující) +- Limit price > spot × 3 → ⚠ warning "Cena vysoko nad trhem - prodej se nemusí zfillovat dlouho" +- Limit price <= 0 → red error + +**Krok 2: Potvrzení** + +Souhrn (burza, plán, side, množství, limit, získáte) + warning text "Akce odešle příkaz na {burzu} a nelze ji vrátit. Příkaz lze poté zrušit, dokud není zfillován." + +`Odeslat`: +1. Disable wizard, show spinner +2. `exchangeApi.limitSell(...)` +3. Success → zápis `TransactionEntity(side=SELL, status=PENDING, exchangeOrderId, limitPrice, requestedCryptoAmount=množství, cryptoAmount=0, fiatAmount=0)` → toast "Příkaz vytvořen" → close wizard → trigger immediate poll +4. Failure → inline error v Krok 2 + button Zpět + Zkusit znovu (žádný DB zápis) +5. Network timeout → dialog "Nelze ověřit stav. Zkontroluj na burze." (žádný DB zápis) + +### Sell sekce na plan-detailu + +Mezi existující buy info a transaction history: + +``` +[Buy info] +───────── +P&L card (drženo, prům. nákup, realizovaný, nerealizovaný, net, cíl progress) +Otevřené sell ordery (list s cancel ikonkou, partial fill progress) +[ + Vytvořit prodejní příkaz ] +───────── +[Transaction history] +``` + +Cancel ikonka u open orderu → confirm dialog → `cancelOrder()` → DB update na `status=FAILED` (nebo `PARTIAL` pokud filled > 0). + +## 6. Order tracking + polling + +### Polling triggery + +1. **App onResume** - `ProcessLifecycle` observer volá `ResolvePendingTransactionsUseCase` jednou +2. **DcaWorker tick** - piggyback (už dnes) +3. **Po placement / cancel sell orderu** - okamžitý poll (free, ověří propsání) +4. **Pull-to-refresh na plan-detail** - explicit user action s loading spinnerem +5. **Periodic worker (opt-in)** - `SellPollingWorker` reuse pattern z `DcaWorker` (AlarmManager + cron next-fire) + +### UC query rozšíření + +```kotlin +@Query(""" + SELECT * FROM transactions + WHERE status IN ('PENDING', 'PARTIAL') + AND exchangeOrderId IS NOT NULL +""") +suspend fun getResolvablePendingTransactions(): List +``` + +PARTIAL stav se taky pollluje (může se postupně doplnit do COMPLETED). + +### UC update logika + +Pro každou transakci: +- Načti credentials (existující path s connectionId) +- `api.getOrderStatus(orderId)` → `OrderStatusResult?` +- Mapování: + - `OPEN` → no change + - `PARTIALLY_FILLED` → update `cryptoAmount=filled`, `fiatAmount=filled*avg`, `status=PARTIAL` + - `FILLED` → update na final, `status=COMPLETED` + - `CANCELED` / `EXPIRED` → `status=FAILED` (nebo `PARTIAL` pokud filled > 0) + - `null` (order neznámý burze) → log warning, no change +- UPDATE query musí mít `WHERE status IN ('PENDING', 'PARTIAL')` jako concurrency guard (cancel mezitím nemění) + +### Reaktivní propagace + +Plan-detail ViewModel čte transakce přes Room Flow (existující `observeTransactionsForPlan(planId)` - ověřit; pokud chybí, přidáme `observe` variantu standardním Room patternem). UC update → Flow emit → UI re-render. + +### Periodic SellPollingWorker + +- Reuse `DcaFrequency` enum (`EVERY_15_MIN`, `HOURLY`, `EVERY_4_HOURS`, `EVERY_8_HOURS`, `DAILY`, `WEEKLY`, `CUSTOM`) +- Reuse `ScheduleBuilderState` Compose komponenta pro DAILY/WEEKLY/CUSTOM (extrahovat do shared composable pokud ještě není) +- AlarmManager pattern stejný jako `DcaWorker` +- Auto-skip když `transactionDao().countOpenSells() == 0` +- Constraints: `NetworkType.CONNECTED` +- Cancel při vypnutí toggle: `WorkManager.cancelUniqueWork("sell_polling")` +- Default frequency: `HOURLY` + +## 7. UI placement + +### 7.1 SettingsScreen + +Nová sekce "Pokročilé": +- `[ ]` Povolit prodeje (master gate, default OFF) +- Dimmed dokud master OFF: + - `[ ]` Kontrolovat sell ordery na pozadí + - Frekvence dropdown (`DcaFrequency` options) + visual schedule builder pro DAILY/WEEKLY/CUSTOM + - Warning text o spotřebě baterie / API limitech + +Při vypnutí master toggle: periodic sell polling auto-disable, worker cancel. Plány s `allowSells=true` zachovány v DB, jen UI sell sekce se skryje. + +### 7.2 AddPlanScreen / EditPlanScreen + +Když `isTradingEnabled = false` → žádné nové UI (skryté). + +Když `true`, nová sekce "Prodeje (volitelné)" na konci formuláře: +- `[ ]` Povolit prodeje pro tento plán +- Cíl zisku (volitelné, pouze pokud allowSells ON) - input v `plan.fiat` + +V edit-mode pokud user vypne `allowSells` a má open sell ordery → confirm dialog s informací o orderech na burze. Allow continue. + +### 7.3 PlanDetailsScreen + +Sell sekce zobrazená pouze když `plan.allowSells && global.isTradingEnabled && exchangeApi.supportsLimitSell`: + +- P&L card (Drženo, Prům. nákup, Realizovaný, Nerealizovaný, Net, Cíl progress bar pokud `targetProfitAmount` set) +- Open orders list s cancel button (a partial fill progress bar pokud filled > 0) +- "Vytvořit prodejní příkaz" button (disabled pokud held = 0 nebo pod min order size) + +### 7.4 Chart sell markery + +Existující chart na plan-detailu: +- BUY = malý zelený trojúhelník nahoru ▲ +- SELL = malý červený trojúhelník dolů ▼ +- Klik/long-press → tooltip (množství, cena, status) + +Sells **nemění historickou křivku invested** (= sum buy fiat). **Mění** křivku held value (klesne v čase sellu). + +### 7.5 HistoryScreen + TransactionDetailsScreen + +HistoryScreen: +- Item dostane směrovou ikonu/badge (BUY ↓ zelená, SELL ↑ červená) +- Filter chip: `Vše | Nákupy | Prodeje | Pending` +- Amounty s znaménkem (`-0.01 BTC / +12 500 CZK` pro SELL) + +TransactionDetailsScreen pro SELL navíc: +- Limitní cena +- Vyplněno: X / Y BTC (Z%) +- Avg fill price +- Cancel button (status v PENDING/PARTIAL) + +### 7.6 PortfolioScreen + +- **Sumární BTC drženo** = `sum(buy crypto) - sum(sell crypto)` +- **Celkem investováno** = `sum(buy fiat)` (beze změny) +- **Celkem realizováno** (nové, jen pokud > 0) = `sum(sell fiat)` +- **Net P&L portfolia** (nové, jen pokud trading enabled) = `currentValue + realized - invested` +- Agregátní křivka: sells "ujídají" z held value křivky, invested zůstává monotónně rostoucí + +### 7.7 DashboardScreen + +Nová karta (jen pro plány s `allowSells=true` a aspoň jedním open sell orderem): +``` +📤 BTC stack: 1 open sell +0.01 BTC @ 1 250 000 CZK +Aktuální tržní: 1 180 245 +``` +Klik → plan-detail. + +## 8. Edge cases & error handling + +### 8.1 Insufficient balance + +Server-side fail (Coinmate `ERROR_INSUFFICIENT_FUNDS`, Binance `-2010`) → `DcaResult.Failure(INSUFFICIENT_BALANCE)` → wizard inline error → no DB write. + +### 8.2 Partial fill + cancel + +User cancel po partial fill: `status=PARTIAL`, filled hodnoty zachovány. P&L bere `cryptoAmount` (filled), ne requested. + +### 8.3 Out-of-band cancel (web) + +Polling detekuje `CANCELED` → status=FAILED nebo PARTIAL. Žádné notifikace. + +### 8.4 Placement timeout + +Dialog "Nelze ověřit stav. Zkontroluj na burze." Žádný DB write. Lepší false negative než duplicitní order. + +### 8.5 Multiple open sells + +Validace amount = `held - sum(open sell requested)`. Server-side error zachytí race. + +### 8.6 Sandbox mode + +Existující `isSandboxMode()` orthogonal. Limit sells jdou na Coinmate sandbox / Binance testnet. Manuální sandbox sell loop před release. + +### 8.7 Concurrency: polling vs cancel + +UPDATE query s `WHERE status IN ('PENDING', 'PARTIAL')` slouží jako optimistic lock. Cancel mění na FAILED → následující polling update nic neudělá. + +### 8.8 Plán delete s open ordery + +**Block delete** s alertem "Plán má X open sell ordery. Zruš je nejdřív." User musí explicitně cancelovat. + +### 8.9 Target overshoot + +Žádný side effect. Progress bar capped vizuálně na 100% s textem (např. "130%" nebo "Cíl dosažen"). User pokračuje normálně. + +### 8.10 P&L NaN edge cases + +- Bez buy tx → `avgBuyPrice = null` → realized/unrealized = null → UI "—" +- Bez spot price → `unrealizedPnL = null` → UI "—" +- `realized` se počítá jen pokud `totalBoughtCrypto > 0` (zero-div guard) + +## 9. Testing + +### Unit +- `PlanPnL` kalkulace pro různé scénáře (no buys, no sells, partial fills, missing spot) +- `ResolvePendingTransactionsUseCase` mapování `OrderStatusResult` → DB update pro každý status +- Validace v sell wizardu (amount, price thresholds) +- Multi-open-sell validace (`held - sum(open sell requested)`) + +### Integration +- Coinmate sandbox: place limit sell pod tržní (instant fill), nad tržní (open), partial fill simulation, cancel +- Binance testnet: stejný matrix +- Migration v20 → v21 idempotence +- Backup roundtrip s/bez nových polí + +### Manual (před release) +- Full E2E na Coinmate sandbox: vytvoření trading plánu, buy několik tx, založit sell, sledovat polling, cancel +- Network timeout simulace při placement (DB ne-zápis verifikace) +- Plán delete s open ordery (block alert) +- Settings master toggle off → sell UI mizí, plány nedotčené + +## 10. Rollout + +- Feature gated globálním Settings toggle (default OFF) - safe to ship +- Coinmate + Binance support na release; ostatní burzy zobrazí "AccBot zatím nepodporuje" hlášku +- Periodic sell polling default OFF - user musí explicit opt-in +- Před release: manuální sandbox sell loop na Coinmate i Binance diff --git a/docs/superpowers/specs/2026-05-09-sell-cost-basis-and-ladder-design.md b/docs/superpowers/specs/2026-05-09-sell-cost-basis-and-ladder-design.md new file mode 100644 index 0000000..d1a91e3 --- /dev/null +++ b/docs/superpowers/specs/2026-05-09-sell-cost-basis-and-ladder-design.md @@ -0,0 +1,338 @@ +# Sell wizard - cost basis kalkulacka a ladder mode + +**Datum:** 2026-05-09 +**Branch:** feature/dca-sell-extension +**Status:** Navrh ke schvaleni + +## Motivace + +Rucni nastaveni sellu otevira prostor emocionalnimu rozhodovani (panic sell, FOMO, anchoring). Soucasny sell wizard pracuje jen s "kolik a za kolik", bez kontextu kolik koin si uzivatel poridil za jakou cenu. Uzivatel tak nevidi, jestli dnesni cena je nad/pod jeho cost basis a jak by sell ovlivnil zbyvajici prumernou nakupni cenu. + +Tato funkce pridava: + +1. **Vypocteny remaining cost basis** v sell wizardu (timestamp-aware cheapest-first), s moznosti manualniho prepsani. +2. **Tripolovou kalkulacku** (mnozstvi / limit cena / cisty vynos) s automatickym doplnovanim treti hodnoty z dvou zadanych. +3. **Profit preview** v summary vcetne "remaining avg po prodeji". +4. **Loss warning** banner pro prodeje pod cost basis. +5. **Volitelny ladder mod** (checkbox "Vytvorit vice sell orderu") pro pre-commitovane scale-out strategie. + +Cil: uzivatel vidi v okamziku zadani vsechna relevantni cisla a rozhoduje se na zaklade faktu, ne emoci. + +## Pozadi v kodu + +- Sell wizard: `accbot-android/.../presentation/screens/plans/sell/SellWizardBottomSheet.kt` + `SellWizardViewModel.kt` +- Validace: `domain/usecase/ValidateSellOrderUseCase.kt` (vraci sealed `SellValidation`, vicenasobne vysledky v listu) +- Provedeni: `domain/usecase/PlaceLimitSellUseCase.kt` (dnes 1 order, vlozi PENDING SELL transakci) +- PnL: `domain/usecase/CalculatePlanPnLUseCase.kt` (lifetime accounting - **zustava beze zmeny**) +- Polling: `worker/SellPollingWorker.kt` (synchronizace z burzy - **zustava beze zmeny**) +- Schema: `TransactionEntity` ma `side`, `cryptoAmount`, `requestedCryptoAmount`, `executedAt`, `status` - vse uz existuje. **Zadna migrace.** + +## Navrh + +### 1. Cost basis algoritmus (timestamp-aware cheapest-first) + +Novy use case `CalculatePlanCostBasisUseCase`, cista funkce. + +**Vstup:** `planId: Long` + +**Postup:** + +1. Nacist vsechny transakce planu (BUY + SELL). +2. Vyfiltrovat relevantni stavy: + - BUYs: `COMPLETED` nebo `PARTIAL` + - SELLs: `COMPLETED`, `PARTIAL`, `PENDING` (pending blokuji inventar) +3. Inicializovat `consumed[buyId] = 0` pro kazdy buy. +4. Pro kazdy sell v poradi podle `executedAt ASC`: + - Filtr buyu, ktere maji `buy.executedAt < sell.executedAt` AND `buy.cryptoAmount - consumed[buy.id] > 0` (zbyva nezkonzumovana cast). + - Seradit ASC podle `buy.price`. Tie-break: starsi `executedAt` napred. + - Cilova konzumace: + - COMPLETED/PARTIAL sell: `sell.cryptoAmount` + - PENDING/PARTIAL sell: `sell.requestedCryptoAmount - sell.cryptoAmount` (= unfilled reservation) + - Konzumovat sekvencne: pro kazdy buy v poradi `take = min(remaining_in_buy, remaining_to_consume)`, zvysit `consumed[buy.id] += take`, snizit `remaining_to_consume -= take`. Pokud po vycerpani vsech eligible buyu zbyva `remaining_to_consume > 0` -> spadne pod edge case "negative inventory" (vraci se ve vystupu). +5. Po projiti vsech sells: + - `remainingPerBuy[buy] = buy.cryptoAmount - consumed[buy.id]` (ulozit jen kde > 0) + - `available = sum(remainingPerBuy)` + - `weightedAvgPrice = available > 0 ? sum(remaining × buy.price) / available : null` + +**Vystup:** + +```kotlin +data class RemainingInventory( + val available: BigDecimal, // sum zbyvajicich crypto + val weightedAvgPrice: BigDecimal?, // null pokud available == 0 + val perBuyDetail: List, // pro debug / future features + val deficit: BigDecimal // > 0 pokud sells presahly buys +) +``` + +**Vlastnosti:** + +- Plne stateless. Zadna DB schema zmena, zadna persistence, zadny backup tweak. +- Stabilni vuci novym buyum: novy buy s `executedAt > existing_sells` ma `consumed = 0`, plne se zapocita do remaining. +- Reservace z PENDING/PARTIAL nezdvoji prodej cheap inventory. +- Performance: `O(sells × buys × log(buys))` na sort. Pro 2000 buys + 50 sells ~ 1M ops, jednotky ms. **Cache je YAGNI pro v1.** + +### 2. Tripolova kalkulacka + +**Pole** v sell wizardu Krok 1: + +| Pole | Symbol | Vyznam | +|---|---|---| +| Avg buy price | `avg` | cost basis (prefill z algoritmu, editovatelny) | +| Mnozstvi crypto | `A` | kolik prodat | +| Limit cena | `P` | fiat / crypto | +| Cisty vynos | `N` | fiat na ucet po fee | + +**Vztah:** `N = A × P × (1 - feeRate)` + +**Logika kalkulacky:** + +ViewModel si pamatuje `lastTwoEdited: Pair` (FIFO poradi mezi A/P/N). Kdyz uzivatel napise hodnotu do pole `X`: + +1. Pridat `X` na konec `lastTwoEdited`, vyhodit nejstarsi. +2. Pokud jsou vsechna 3 pole vyplnena: dopocitat to, ktere NENI v `lastTwoEdited`. +3. Pokud jen 2 jsou vyplnena: dopocitat 3. +4. Pokud jen 1: nedelat nic. + +**Rovnice:** + +- `(A, P) -> N = A × P × (1 - feeRate)` +- `(A, N) -> P = N / (A × (1 - feeRate))` +- `(P, N) -> A = N / (P × (1 - feeRate))` + +**Avg pole je separatni vstup**, neni soucasti 3-pole kalkulacky (nemeni A/P/N primo, jen ovlivnuje profit ve summary). Tlacitko "Spocitat z planu" resetuje na auto-prefill z `CalculatePlanCostBasisUseCase`. + +### 3. Fee plumbing + +Rozsirit `ExchangeApi` interface o: + +```kotlin +val estimatedTakerFeeRate: BigDecimal +``` + +Hodnoty: + +| Burza | feeRate | Zdroj | +|---|---|---| +| Coinmate | 0.0035 | dnes hardcoded v `CoinmateApi.kt:32` | +| Binance | 0.001 | default taker, ignoruje BNB/VIP discounty | +| KuCoin | 0.001 | default taker | +| Coinbase | 0.0040 | advanced trade base tier | +| Kraken | 0.0026 | base tier | +| Bitfinex / Huobi | 0.002 | placeholder, validace stejne dnes vraci `false` | + +V summary radek `Odhadovany fee: X CZK (0.35%)` pro transparentnost. Pokud uzivatel ma nizsi fee tier nebo BNB discount, dostane mirne vic - to je akceptovatelne pro decision support. + +### 4. Cenove a vynosove presety + +**Pod polem `Limit cena`** dropdown menu s rezimem: + +- **% z avg buy** (default): `P = avg × (1 + preset)`. Hodnoty: +5%, +10%, +20%, +50%. +- **% ze spotu**: `P = spot × (1 + preset)`. Stejne hodnoty. + +Toggle se ulozi pro session (transient, neperzistovat). + +**Pod polem `Mnozstvi`:** zachovat existujici 25% / 50% / 75% / 100% z `available`. + +**Pod polem `Cisty vynos`:** presety relativni k cost basis. Cil = "kolik chci na transakci vydelat". + +`N = A × avg × (1 + profitTarget)` (cislo, ktere by mi prislo na ucet, kdyby fee byl 0; system pak dopocita `P` zpetne pres `P = N / (A × (1 - feeRate))`, fee je implicitne zahrnut). + +Hodnoty: +10%, +20%, +50%, +100% + +### 5. Loss warning + +`ValidateSellOrderUseCase` doplnit o: + +```kotlin +data class LossWarning(val lossFiat: BigDecimal, val lossPct: Double) : SellValidation() +``` + +**Trigger: `netProfit < 0`**, kde `netProfit = N - A × avg = A × P × (1 - feeRate) - A × avg`. + +Pozor: trigger neni jen `P < avg`. Pri P tesne nad avg muze fee uz dostat transakci do realne ztraty. Banner reflektuje skutecnou ekonomickou realitu (anti-emocionalni cil = videt pravdu). + +Banner formulace: +- `P < avg`: "Prodavas pod nakupni cenou: -X CZK" +- `P >= avg`, `netProfit < 0`: "Po fee prodavas se ztratou: -X CZK" + +Cervene formatovani zisku, wizard normalne projde. **Zadny hard block, zadna dvojita konfirmace.** Pokud uzivatel po nasazeni zjisti, ze potrebuje silnejsi friction, lze pridat pozdeji. + +### 6. Summary rozsireni + +Sell wizard summary (Krok 1 i Krok 2) zobrazi: + +``` +--- Souhrn --- +Avg nakupni cena: 1 870 000 CZK [auto / ✏️ rucne] +Profit per coin: +230 000 CZK +Hruby zisk: +5 750 CZK +Odhad fee: -184 CZK (0.35%) +Cisty zisk: +5 566 CZK (+12.3%) +Po prodeji: 0.18 BTC, avg 1 920 000 CZK +Postup k cili: 18 666 / 25 000 CZK (75%) +``` + +**Vypocty:** + +- "Hruby zisk" = `A × (P - avg)` +- "Odhad fee" = `A × P × feeRate` +- "Cisty zisk" = `N - A × avg` (kde `N = A × P × (1 - feeRate)`) +- "Cisty zisk %" = `cistyZisk / (A × avg)` +- "Po prodeji - avg" = stejny algoritmus z #1, ale s timto hypotetickym sellem zahrnutym mezi historicke (smaze cheapest-first ze zbytku po existujicich pending+real sells) +- "Postup k cili" = `(realizedPnL + cistyZisk_thisTx) / plan.targetProfitAmount`. Jen pokud `targetProfitAmount != null`. + +**Loss case** (`P < avg`): "Cisty zisk" se zobrazi cervene jako "**-X CZK (-Y%)**", plus banner. + +### 7. Ladder mode (volitelny) + +**Aktivace:** checkbox "Vytvorit vice sell orderu" v Kroku 1. + +**UI po zaskrtnuti:** + +- Pole `Limit cena` se nahradi dvojici `Od` / `Do`. +- Toggle uvnitr "Cena | Profit %" prepina mezi absolutnimi cenami a % nad cost basis. +- Pole `Cisty vynos` se skryje (nedava smysl pro ladder, derivuje se v preview). +- Nove pole `Pocet orderu` (cele cislo, default 5, min 2, max 10). +- Toggle "Equal crypto | Equal fiat": + - **Equal crypto**: kazdy order ma `A_i = total / N` BTC. + - **Equal fiat**: kazdy order ma `A_i = (totalFiatGross / N) / P_i` BTC, takze kazdy vygeneruje stejny gross fiat. +- Distribuce cen: linear, `P_i = from + (to - from) × i / (N - 1)` pro `i = 0..N-1`. + +**Preview tabulka** vzdy viditelna pod inputs, re-renders na kazdou zmenu: + +``` +# Mnozstvi Cena Profit % Cisty vynos +1 0.05 BTC 2 000 000 +12.3% 99 650 +2 0.05 BTC 2 100 000 +17.6% 104 632 +3 0.05 BTC 2 200 000 +22.9% 109 615 +4 0.05 BTC 2 300 000 +28.1% 114 597 +5 0.05 BTC 2 400 000 +33.4% 119 580 + -------- + Celkem: 548 074 CZK +``` + +Souhrn pod tabulkou: total profit (sum of profits), avg po prodeji vsech orderu (kdyby vsechny fillnuly), postup k cili. + +### 8. Provedeni v ladder modu + +Novy use case `PlaceLadderSellUseCase`. Vstup: `planId, List`. + +**Failure handling: stop & report.** + +1. Iterovat ordery sekvencne. +2. Pro kazdy: zavolat `api.limitSell(...)`, vlozit PENDING SELL transakci. +3. Pri prvnim selhani zastavit, vratit `Result(placedTxIds: List, failedAtIndex: Int, reason: String)`. +4. UI zobrazi `"Vytvoreno X z N orderu. Zbyvajici nepokracovaly: . Muzes zkusit znovu pro zbyvajici."` +5. Zadny auto-rollback. Pokud uzivatel chce zrusit uz vytvorene, pouzije existujici cancel ikonu na plan-detail. + +### 9. Validace v ladder modu + +`ValidateSellOrderUseCase` rozsirit o ladder validation: + +- Total amount ≤ available (cost-basis-aware, viz #1) +- Per-order amount ≥ minOrderSize: `(total / N) ≥ minOrderSize` (equal-crypto), nebo `min(A_i) ≥ minOrderSize` (equal-fiat) +- `from > 0`, `to > from`, `N >= 2` +- Pokud profit % mod: `from`, `to` mohou byt i zaporne, vrati LossWarning +- Pokud absolutni ceny: `from > 3 × spot` -> `FarFromMarketWarning` +- LossWarning agreguje: `sum(amount_i × max(0, avg - P_i))` napric ordery + +## UI rozlozeni + +``` ++- Sell wizard - Krok 1 ----------+ +| Avg nakupni cena ⓘ | +| [ 1 870 000 CZK ] (auto) | +| [ Spocitat z planu ] | +| | +| ☐ Vytvorit vice sell orderu | +| | +| Mnozstvi | +| [ 0.025 BTC ] | +| [25%][50%][75%][100%] | +| | +| Limit cena [▼ % z avg] | +| [ 2 100 000 CZK ] | +| [+5%][+10%][+20%][+50%] | +| | +| Cisty vynos | +| [ 52 316 CZK ] | +| [+10%][+20%][+50%][+100%] | +| | +| ⚠️ Prodavas se ztratou (red) | +| | +| --- Souhrn --- | +| Avg buy: 1 870 000 ✏️ | +| Profit per coin: +230 000 | +| Hruby zisk: +5 750 | +| Odhad fee: -184 (0.35%) | +| Cisty zisk: +5 566 (+12.3%) | +| Po prodeji: 0.18 BTC @ 1 920 000| +| Cil: 18 666 / 25 000 (75%) | +| | +| [ Pokracovat ] | ++---------------------------------+ +``` + +**Po zaskrtnuti ladder checkboxu:** + +- Limit cena → dvojice Od/Do + toggle "Cena | %" +- Cisty vynos pole skryto +- Pribude "Pocet orderu" + toggle "Equal crypto | Equal fiat" +- Summary se nahradi preview tabulkou + agregatem + +## Implementacni surface + +**Nove soubory:** + +- `domain/model/RemainingInventory.kt` - data class +- `domain/usecase/CalculatePlanCostBasisUseCase.kt` - algoritmus +- `domain/usecase/PlaceLadderSellUseCase.kt` - multi-order place + +**Modifikace:** + +- `exchange/ExchangeApi.kt` - pridat `estimatedTakerFeeRate` +- `exchange/CoinmateApi.kt`, `BinanceApi.kt`, `CoinbaseApi.kt`, `OtherExchanges.kt` - implementovat field +- `domain/usecase/ValidateSellOrderUseCase.kt` - `LossWarning`, ladder validation +- `presentation/screens/plans/sell/SellWizardViewModel.kt` - state machine pro 3-pole + ladder +- `presentation/screens/plans/sell/SellWizardBottomSheet.kt` - UI pole, presety, summary, ladder +- `res/values-cs/strings.xml`, `res/values/strings.xml` - nove stringy + +**Nemeni se:** + +- DB schema, migrace +- Backup/restore (`BackupDataCollector`, `BackupDataRestorer`) +- `CalculatePlanPnLUseCase` (zustava lifetime accounting) +- `SellPollingWorker`, `ResolvePendingTransactionsUseCase` +- `PlaceLimitSellUseCase` (single mod zustava nedotcen, ladder = nova cesta) + +**Odhad rozsahu:** 8-10 souboru. Zadna schema migrace. Testovat lze postupne (single mod nejdriv, pak ladder). + +## Edge cases + +- **Zadne buys / vse prodano**: `available = 0`, `avg = null`, prefill prazdny, vyzaduje manualni vstup. Wizard projde, validace se opira jen o manualni avg. +- **Negative inventory** (`deficit > 0`, sells > buys): banner "Inventar nesedi, zadej avg manualne". Wizard projde s manualnim avg. +- **PARTIAL buy**: pouzit `cryptoAmount` (skutecne koupene), ne `requestedCryptoAmount`. (DCA buys jsou typicky atomic, ale obecne OK.) +- **Multi-connection v planu**: nestane se. Plan ma jednu `connectionId`, sells jdou pres ni. +- **Plan target = null**: skryt radek "Postup k cili". +- **PENDING ladder rozsahem prekracujici available**: validate pred place, hard error. +- **Ladder s 1 orderem**: nedovolit. `N >= 2` (jinak pouzij single mod). +- **Manualni override avg na nesmyslnou hodnotu** (zaporna, 0): hard error v ValidateSellOrderUseCase. + +## Out of scope + +- **Cache cost basis vypoctu**: YAGNI v1. Vypocet je rychly pro realisticka data. Pokud by se ukazal jako problem, in-memory cache invalidovana z `TransactionDao` flow. +- **Snapshot avg na SELL transakci**: nepotrebujeme, timestamp-aware cheapest-first resi stabilitu. +- **Hard block na loss**: jen warning + visual cue, uzivatel rozhoduje. +- **Geometric distribuce v ladderu**: linear postacuje. +- **Atomic batch place** / rollback pri selhani mid-batch: stop & report staci. +- **Perzistovane preset preference** (% z avg vs % ze spotu): transient session-level. +- **Zmena PnL vypoctu na cheapest-first**: zustava lifetime accounting v `CalculatePlanPnLUseCase`. Mozna pridat druhy radek "remaining cost basis" do PnL card jako future enhancement. +- **Per-buy detail v summary** ("z toho 0.05 BTC z buy z 1.1.2026, 0.10 BTC z buy z 5.3.2026..."): k debugu/future, neni MVP. + +## Otevrene otazky pro planovaci fazi + +- **Poradi tasku v planu**: cost basis use case (s testy) → fee plumbing → wizard ViewModel rewrite → UI single mod → ladder mode. +- **TDD**: cost basis algoritmus ma dost edge cases, vyplati se napsat unit testy. UI a presety testovat manualne. +- **Lokalizace**: nove stringy v cs + en soucasne, v jednom kroku. +- **Manual E2E test**: zacleneni do existujiciho Task 33 (Coinmate manual sandbox) a Task 34 (Binance) z `2026-04-23-dca-sell-extension.md`.