First Commit
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
package win.tap_tap.papertrader
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.cio.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.plugins.logging.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class AlpacaAssetBar(
|
||||
val c: Float
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AlpacaBars(
|
||||
val bars: Map<String, AlpacaAssetBar>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AlpacaAsset(
|
||||
val name: String,
|
||||
val symbol: String,
|
||||
val status: String,
|
||||
val tradable: Boolean,
|
||||
val fractionable: Boolean
|
||||
) {
|
||||
fun toAsset(assetId: Long): Asset {
|
||||
return Asset(id = assetId, symbol = symbol, name = name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AlpacaAPI(val apiKey: String, val apiSecret: String) {
|
||||
val client = HttpClient(CIO) {
|
||||
install(Logging) {
|
||||
logger = Logger.DEFAULT
|
||||
level = LogLevel.ALL
|
||||
}
|
||||
install(ContentNegotiation) {
|
||||
json(
|
||||
json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
install(DefaultRequest) {
|
||||
contentType(ContentType.Application.Json)
|
||||
headers.append("APCA-API-KEY-ID", apiKey)
|
||||
headers.append("APCA-API-SECRET-KEY", apiSecret)
|
||||
}
|
||||
}
|
||||
|
||||
suspend inline fun <reified T> safeApiCall(url: String, parameters: Map<String, String>): Result<T> {
|
||||
return try {
|
||||
val response = client.get(url) {
|
||||
for ((key, value) in parameters.entries) {
|
||||
parameter(key, value)
|
||||
}
|
||||
}
|
||||
if (response.status == HttpStatusCode.OK) {
|
||||
Result.success(response.body<T>())
|
||||
} else {
|
||||
Result.failure(Exception(response.body<String>()))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getTradableAssets(): Result<List<AlpacaAsset>> {
|
||||
val result =
|
||||
safeApiCall<List<AlpacaAsset>>(
|
||||
"https://paper-api.alpaca.markets/v2/assets?status=active&asset_class=us_equity",
|
||||
emptyMap()
|
||||
)
|
||||
return result.map { assets ->
|
||||
assets.filter { it.tradable && it.fractionable && it.status == "active" }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getPrices(assets: List<Asset>): Result<Map<Asset, Cash>> {
|
||||
if (assets.isEmpty()) {
|
||||
return Result.success(emptyMap())
|
||||
}
|
||||
val result = safeApiCall<AlpacaBars>(
|
||||
"https://data.alpaca.markets/v2/stocks/bars/latest",
|
||||
mapOf(
|
||||
"symbols" to assets.joinToString(",") { it.symbol },
|
||||
"feed" to "delayed_sip"
|
||||
)
|
||||
)
|
||||
return result.map { bars ->
|
||||
bars.bars.mapNotNull { (key, value) ->
|
||||
val asset = assets.find { it.symbol == key } ?: return@mapNotNull null
|
||||
asset to Cash.fromFloat(value.c)
|
||||
}.toMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package win.tap_tap.papertrader
|
||||
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.engine.*
|
||||
import io.ktor.server.netty.*
|
||||
import io.ktor.server.plugins.calllogging.*
|
||||
import io.ktor.server.plugins.contentnegotiation.*
|
||||
import io.ktor.server.plugins.cors.routing.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.slf4j.event.Level
|
||||
import win.tap_tap.papertrader.handler.*
|
||||
import kotlin.system.exitProcess
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
fun getEnv(key: String): String {
|
||||
val value: String? = System.getenv(key)
|
||||
if (value == null) {
|
||||
println("Environment Variable: $key isn't set! Shutting down!")
|
||||
exitProcess(1)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
val API_KEY: String = getEnv("ALPACA_KEY")
|
||||
val API_SECRET: String = getEnv("ALPACA_SECRET")
|
||||
|
||||
val alpacaAPI = AlpacaAPI(API_KEY, API_SECRET)
|
||||
val database = Database()
|
||||
val data = database.data
|
||||
|
||||
fun main() {
|
||||
println("+$API_KEY+$API_SECRET+")
|
||||
embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module)
|
||||
.start(wait = true)
|
||||
}
|
||||
|
||||
suspend fun handleCall(
|
||||
call: RoutingCall,
|
||||
callHandler: suspend (RoutingCall, Long, Database, AlpacaAPI) -> Unit,
|
||||
) {
|
||||
val userIdString = call.principal<UserIdPrincipal>()
|
||||
if (userIdString == null) {
|
||||
call.respond(HttpStatusCode.Unauthorized)
|
||||
} else {
|
||||
val userId = userIdString.name.toLongOrNull()
|
||||
if (userId == null) {
|
||||
call.respond(HttpStatusCode.InternalServerError, "Could not convert user id!")
|
||||
} else {
|
||||
callHandler(call, userId, database, alpacaAPI)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Application.module() {
|
||||
install(ContentNegotiation) {
|
||||
json(
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
allowStructuredMapKeys = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
install(CORS) {
|
||||
allowHeader(HttpHeaders.AccessControlAllowOrigin)
|
||||
allowHeader(HttpHeaders.Authorization)
|
||||
allowHeader(HttpHeaders.ContentType)
|
||||
allowMethod(HttpMethod.Post)
|
||||
allowMethod(HttpMethod.Get)
|
||||
allowHost("papertrader.tap-tap.win")
|
||||
allowHost("localhost:8081")
|
||||
allowHost("localhost:8080")
|
||||
allowHost("127.0.0.1:8081")
|
||||
allowHost("127.0.0.1:8080")
|
||||
}
|
||||
install(CallLogging) { level = Level.WARN }
|
||||
install(Authentication) {
|
||||
bearer("bearer-user") {
|
||||
realm = "Access to full App API"
|
||||
authenticate { tokenCredential ->
|
||||
val token =
|
||||
database.token.findByAccessToken(tokenCredential.token)
|
||||
?: return@authenticate null
|
||||
if (token.accessTokenExpired > Clock.System.now().toEpochMilliseconds()) {
|
||||
UserIdPrincipal(token.userId.toString())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
launch {
|
||||
while (true) {
|
||||
updateAssets(database = database, api = alpacaAPI)
|
||||
delay(1.days)
|
||||
}
|
||||
}
|
||||
routing {
|
||||
authenticate("bearer-user") {
|
||||
get(Endpoints.GET_CHALLENGES.endpoint()) { handleCall(call, ::handleGetChallengesCall) }
|
||||
get(Endpoints.GET_STOCKS.endpoint()) { handleCall(call, ::handleGetStocksCall) }
|
||||
post(Endpoints.CREATE_CHALLENGE.endpoint()) {
|
||||
handleCall(call, ::handleCreateChallengeCall)
|
||||
}
|
||||
// post(Endpoints.GET_PARTICIPANTS.endpoint()) { handleCall(call,
|
||||
// ::handleGetChallengeCall) }
|
||||
post(Endpoints.GET_MISSING_PARTICIPANTS.endpoint()) {
|
||||
handleCall(call, ::handleGetMissingParticipantsCall)
|
||||
}
|
||||
post(Endpoints.ADD_PARTICIPANT.endpoint()) {
|
||||
handleCall(call, ::handleAddParticipantCall)
|
||||
}
|
||||
post(Endpoints.ASSET_BUY.endpoint()) { handleCall(call, ::handleAssetBuyCall) }
|
||||
post(Endpoints.ASSET_SELL.endpoint()) { handleCall(call, ::handleStockSellCall) }
|
||||
post(Endpoints.GET_CHALLENGE_DATA.endpoint()) {
|
||||
handleCall(call, ::handleChallengeDataCall)
|
||||
}
|
||||
post(Endpoints.CHALLENGE_LEAVE.endpoint()) {
|
||||
handleCall(call, ::handleChallengeLeaveCall)
|
||||
}
|
||||
post(Endpoints.CHALLENGE_KICK.endpoint()) {
|
||||
handleCall(call, ::handleChallengeKickCall)
|
||||
}
|
||||
post(Endpoints.USER_DELETE.endpoint()) {
|
||||
handleCall(call, ::handleUserDeleteCall)
|
||||
}
|
||||
}
|
||||
post(Endpoints.REFRESH_TOKEN.endpoint()) { handleTokenRefreshCall(call, database) }
|
||||
post(Endpoints.LOGOUT.endpoint()) { handleLogoutCall(call, database) }
|
||||
post(Endpoints.REGISTER.endpoint()) { handleRegisterCall(call, database) }
|
||||
post(Endpoints.LOGIN.endpoint()) { handleLoginCall(call, database) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package win.tap_tap.papertrader
|
||||
|
||||
suspend fun updateAssets(database: Database, api: AlpacaAPI) {
|
||||
val result = api.getTradableAssets()
|
||||
result.onFailure { error ->
|
||||
println("ERROR: Could not get the tradable assets from alpaca: ${error.message}")
|
||||
}
|
||||
result.onSuccess { assets ->
|
||||
assets.forEach { asset ->
|
||||
database.asset.add(symbol = asset.symbol, name = asset.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package win.tap_tap.papertrader
|
||||
|
||||
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
|
||||
import java.util.*
|
||||
import kotlin.time.Instant
|
||||
|
||||
class UserActions(val data: Data) {
|
||||
val queries = data.userQueries
|
||||
fun get(name: String): UserDB? {
|
||||
return queries.getUserByName(name).executeAsOneOrNull()
|
||||
}
|
||||
|
||||
fun get(id: Long): UserDB? {
|
||||
return queries.getUser(id).executeAsOneOrNull()
|
||||
}
|
||||
|
||||
fun add(name: String, password: String) {
|
||||
queries.insertUser(name, password)
|
||||
}
|
||||
|
||||
fun delete(id: Long) {
|
||||
queries.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
class AssetActions(val data: Data) {
|
||||
val queries = data.stockQueries
|
||||
|
||||
fun get(stockId: Long): Asset? {
|
||||
return queries.get(stockId) { id, symbol, name -> Asset(id = id, symbol = symbol, name = name) }
|
||||
.executeAsOneOrNull()
|
||||
}
|
||||
|
||||
fun add(symbol: String, name: String) {
|
||||
if (getBySymbol(symbol) == null) {
|
||||
queries.add(symbol = symbol, name = name)
|
||||
}
|
||||
}
|
||||
|
||||
fun getBySymbol(symbol: String): Asset? {
|
||||
return queries.getBySymbol(symbol) { id, symbol, name -> Asset(id = id, symbol = symbol, name = name) }
|
||||
.executeAsOneOrNull()
|
||||
}
|
||||
|
||||
fun getAll(): List<Asset> {
|
||||
return queries.getAll { id, symbol, name -> Asset(id = id, symbol = symbol, name = name) }.executeAsList()
|
||||
}
|
||||
}
|
||||
|
||||
class TokenActions(val data: Data) {
|
||||
val queries = data.tokenQueries
|
||||
fun add(userId: Long, token: String, tokenExpired: Long, refreshToken: String, refreshTokenExpired: Long) {
|
||||
queries.insertToken(
|
||||
userId,
|
||||
token,
|
||||
tokenExpired,
|
||||
refreshToken,
|
||||
refreshTokenExpired,
|
||||
)
|
||||
}
|
||||
|
||||
fun findByAccessToken(accessToken: String): TokenDB? {
|
||||
return queries.findToken(accessToken).executeAsOneOrNull()
|
||||
}
|
||||
|
||||
fun findByRefreshToken(refreshToken: String): TokenDB? {
|
||||
return queries.findRefreshToken(refreshToken).executeAsOneOrNull()
|
||||
}
|
||||
|
||||
fun updateAccessToken(tokenId: Long, accessToken: String, expiresIn: Long): TokenDB? {
|
||||
var token: TokenDB? = null
|
||||
data.transaction {
|
||||
queries.updateAccessToken(accessToken, expiresIn, tokenId)
|
||||
token = queries.getToken(tokenId).executeAsOneOrNull()
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
fun delete(accessToken: String, refreshToken: String) {
|
||||
queries.deleteTokenByAccessToken(accessToken)
|
||||
queries.deleteTokenByRefreshToken(refreshToken)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class PositionActions(val data: Data) {
|
||||
val queries = data.positionQueries
|
||||
|
||||
fun getByUser(challengeId: Long, userId: Long): List<PositionDB> {
|
||||
return queries.getPositionByUser(challengeId, userId).executeAsList()
|
||||
}
|
||||
|
||||
fun add(stockId: Long, challengeId: Long, userId: Long, amount: Double, date: Instant, price: Cash) {
|
||||
queries.addPosition(
|
||||
stockId = stockId,
|
||||
challengeId = challengeId,
|
||||
userId = userId,
|
||||
amount = amount,
|
||||
date = date.toEpochMilliseconds(),
|
||||
price = price.asCents()
|
||||
)
|
||||
}
|
||||
|
||||
fun get(positionId: Long): PositionDB? {
|
||||
return queries.get(positionId).executeAsOneOrNull()
|
||||
}
|
||||
|
||||
fun delete(positionId: Long) {
|
||||
queries.delete(positionId)
|
||||
}
|
||||
}
|
||||
|
||||
class ChallengeActions(val data: Data, val database: Database) {
|
||||
val queries = data.challengeQueries
|
||||
|
||||
fun get(challengeId: Long): ChallengeDB? {
|
||||
return queries.get(challengeId).executeAsOneOrNull()
|
||||
}
|
||||
|
||||
fun leave(challengeId: Long, userId: Long) {
|
||||
queries.leave(challengeId = challengeId, userId = userId)
|
||||
}
|
||||
|
||||
fun addParticipant(userId: Long, challengeId: Long) {
|
||||
val challenge = get(challengeId)
|
||||
if (challenge != null) {
|
||||
data.transaction {
|
||||
val participant = queries.getParticipant(userId, challengeId).executeAsOneOrNull()
|
||||
if (participant == null) {
|
||||
queries.insertParticipant(userId, challenge.id, challenge.cash)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun insertChallenge(name: String, cashInPennies: Long, creatorId: Long) {
|
||||
data.transaction {
|
||||
queries.createChallenge(name, cashInPennies, creatorId)
|
||||
val challengeId = queries.lastInsertId().executeAsOneOrNull()
|
||||
if (challengeId != null) {
|
||||
addParticipant(creatorId, challengeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getByUserId(userId: Long): List<Challenge> {
|
||||
val dbChallenges = queries.getChallengesForUser(userId).executeAsList()
|
||||
val challenges = mutableListOf<Challenge>()
|
||||
for (challenge in dbChallenges) {
|
||||
challenges.add(Challenge(challenge.id, challenge.name, Cash.fromCents(challenge.cash)))
|
||||
}
|
||||
return challenges
|
||||
}
|
||||
|
||||
fun getParticipants(challengeId: Long): List<Participant> {
|
||||
val participants = queries.getParticipants(challengeId).executeAsList()
|
||||
return participants.map { participant ->
|
||||
Participant(
|
||||
User(participant.id, participant.name),
|
||||
Cash.fromCents(participant.cash)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateParticipantCash(cash: Cash, challengeId: Long, userId: Long) {
|
||||
queries.updateParticipantsCash(cash = cash.asCents(), challengeId = challengeId, userId = userId)
|
||||
}
|
||||
|
||||
// fun getParticipantResponse(challengeId: Long, userId: Long): ResponseGetParticipants? {
|
||||
// val rows = queries.getFullData(challengeId).executeAsList()
|
||||
// if (rows.isEmpty()) return null
|
||||
//
|
||||
// val participants = rows.groupBy { it.userId }.map { (_, row) ->
|
||||
// val firstRow = row.first()
|
||||
// Participant(
|
||||
// User(firstRow.userId, firstRow.username),
|
||||
// Cash.fromCents(firstRow.cash),
|
||||
// row.map { row ->
|
||||
// Position(Asset(row.positionName), Cash.fromCents(row.buyPrice), row.shareAmount)
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
// println("Before user")
|
||||
// val user = participants.find { it.user.id == userId } ?: return null
|
||||
// println("before challenge")
|
||||
// val challenge = queries.get(challengeId).executeAsOneOrNull() ?: return null
|
||||
// println("after challenge")
|
||||
// return ResponseGetParticipants(
|
||||
// challenge.creatorId == userId,
|
||||
// user,
|
||||
// participants.filter { it.user.id != userId })
|
||||
// }
|
||||
|
||||
fun getParticipant(userId: Long, challengeId: Long): ParticipantsDB? {
|
||||
return queries.getParticipant(userId, challengeId).executeAsOneOrNull()
|
||||
}
|
||||
|
||||
fun getMissingUsers(challengeId: Long): List<User> {
|
||||
val users = queries.getMissingUsers(challengeId).executeAsList()
|
||||
return users.map { user -> User(user.id, user.name) }
|
||||
}
|
||||
}
|
||||
|
||||
class Database() {
|
||||
val driver = JdbcSqliteDriver("jdbc:sqlite:./data/data.db", Properties(), Data.Schema)
|
||||
val data = Data(driver)
|
||||
val user = UserActions(data)
|
||||
val token = TokenActions(data)
|
||||
val challenge = ChallengeActions(data, this)
|
||||
val positions = PositionActions(data)
|
||||
val asset = AssetActions(data)
|
||||
|
||||
init {
|
||||
Data.Schema.create(driver)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package win.tap_tap.papertrader.handler
|
||||
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.util.*
|
||||
import win.tap_tap.papertrader.*
|
||||
import kotlin.time.Clock
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
suspend fun sha256(password: String): String {
|
||||
val digest = Digest("SHA-256")
|
||||
digest += password.encodeToByteArray()
|
||||
return hex(digest.build())
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
fun generateToken(databaseFinder: (String) -> TokenDB?): String {
|
||||
var token = Uuid.random().toString()
|
||||
while (databaseFinder(token) != null) {
|
||||
token = Uuid.random().toString()
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
fun generateRefreshToken(database: Database): Pair<String, Long> {
|
||||
return Pair(
|
||||
generateToken { database.token.findByRefreshToken(it) },
|
||||
Clock.System.now().toEpochMilliseconds() + (30L * 24 * 60 * 60 * 1000)
|
||||
)
|
||||
}
|
||||
|
||||
fun generateAccessToken(database: Database): Pair<String, Long> {
|
||||
return Pair(
|
||||
generateToken { database.token.findByAccessToken(it) },
|
||||
Clock.System.now().toEpochMilliseconds() + (60 * 60 * 1000)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun handleRegisterCall(call: RoutingCall, database: Database) {
|
||||
val request = call.receive<RequestRegisterUser>()
|
||||
val username = request.username.trim()
|
||||
val password = request.password.trim()
|
||||
val passwordConfirm = request.passwordConfirm.trim()
|
||||
if (username.length < 3) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Username has to be at least 3 characters.")
|
||||
} else if (password.length < 8) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Password has to be at least 8 characters.")
|
||||
} else if (password != passwordConfirm) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Passwords are not the same.")
|
||||
} else if (database.user.get(username) != null) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Username is already taken.")
|
||||
} else {
|
||||
database.user.add(username, sha256(password))
|
||||
call.respond(HttpStatusCode.OK, "User created.")
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
suspend fun handleLoginCall(call: RoutingCall, database: Database) {
|
||||
val request = call.receive<RequestLoginUser>()
|
||||
val username = request.username.trim()
|
||||
val password = request.password.trim()
|
||||
if (username.length < 3) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Your username is at least 3 characters long.")
|
||||
} else if (password.length < 8) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Your password is at least 8 characters long.")
|
||||
} else {
|
||||
val user = database.user.get(username)
|
||||
if (user == null) {
|
||||
call.respond(HttpStatusCode.BadRequest, "The username doesn't exist.")
|
||||
} else if (user.password != sha256(password)) {
|
||||
call.respond(HttpStatusCode.BadRequest, "The password is wrong.")
|
||||
} else {
|
||||
val accessToken = generateAccessToken(database)
|
||||
val refreshToken = generateRefreshToken(database)
|
||||
database.token.add(user.id, accessToken.first, accessToken.second, refreshToken.first, refreshToken.second)
|
||||
call.respond(HttpStatusCode.OK, ResponseLogin(accessToken.first, refreshToken.first, accessToken.second))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleLogoutCall(call: RoutingCall, database: Database) {
|
||||
val request = call.receive<RequestLogoutUser>()
|
||||
database.token.delete(request.accessToken, request.refreshToken)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
|
||||
suspend fun handleGetChallengesCall(call: RoutingCall, userId: Long, database: Database, api: AlpacaAPI) {
|
||||
val challenges = database.challenge.getByUserId(userId)
|
||||
call.respond(HttpStatusCode.OK, ResponseGetChallenges(challenges))
|
||||
}
|
||||
|
||||
suspend fun handleCreateChallengeCall(call: RoutingCall, userId: Long, database: Database, api: AlpacaAPI) {
|
||||
val request = call.receive<RequestCreateChallenge>()
|
||||
database.challenge.insertChallenge(request.name, request.euros * 100 + request.cents, userId)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
|
||||
suspend fun handleTokenRefreshCall(call: RoutingCall, database: Database) {
|
||||
val request = call.receive<RequestTokenRefresh>()
|
||||
val token = database.token.findByRefreshToken(request.refreshToken)
|
||||
if (token == null) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Refresh Token is invalid")
|
||||
} else {
|
||||
val accessToken = generateAccessToken(database)
|
||||
val token = database.token.updateAccessToken(token.id, accessToken.first, accessToken.second)
|
||||
if (token == null) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Internal Error")
|
||||
} else {
|
||||
call.respond(HttpStatusCode.OK, ResponseTokenRefresh(accessToken.first, accessToken.second))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
suspend fun handleGetMissingParticipantsCall(call: RoutingCall, userId: Long, database: Database, api: AlpacaAPI) {
|
||||
val request = call.receive<RequestGetMissingParticipants>()
|
||||
val users = database.challenge.getMissingUsers(request.challengeId)
|
||||
call.respond(HttpStatusCode.OK, ResponseGetMissingParticipants(users))
|
||||
}
|
||||
|
||||
suspend fun handleAddParticipantCall(call: RoutingCall, userId: Long, database: Database, api: AlpacaAPI) {
|
||||
val request = call.receive<RequestAddParticipant>()
|
||||
val challenge =
|
||||
database.challenge.get(request.challengeId) ?: return call.respond(HttpStatusCode.InternalServerError)
|
||||
if (challenge.creatorId != userId) {
|
||||
call.respond("You are not the creator of the Challenge!")
|
||||
} else {
|
||||
database.challenge.addParticipant(request.participantId, request.challengeId)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleChallengeLeaveCall(call: RoutingCall, userId: Long, database: Database, api: AlpacaAPI) {
|
||||
val request = call.receive<RequestLeaveChallenge>()
|
||||
if (database.challenge.getParticipant(userId = userId, challengeId = request.challengeId) == null) {
|
||||
call.respond("You cannot leave a challenge that you are not participating in")
|
||||
return
|
||||
}
|
||||
database.challenge.leave(challengeId = request.challengeId, userId = userId)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
|
||||
suspend fun handleChallengeKickCall(call: RoutingCall, userId: Long, database: Database, api: AlpacaAPI) {
|
||||
val request = call.receive<RequestChallengeKick>()
|
||||
val challenge = database.challenge.get(request.challengeId)
|
||||
if (challenge == null) {
|
||||
call.respond("Cannot find the challenge you are looking for.")
|
||||
return
|
||||
}
|
||||
if (challenge.creatorId != userId) {
|
||||
call.respond("You can not kick someone if you are not the creator of the challenge.")
|
||||
return
|
||||
}
|
||||
if (database.challenge.getParticipant(challengeId = request.challengeId, userId = request.userId) == null) {
|
||||
call.respond("You can not kick someone how is not participating in the challenge.")
|
||||
return
|
||||
}
|
||||
database.challenge.leave(challengeId = request.challengeId, userId = request.userId)
|
||||
}
|
||||
|
||||
suspend fun handleUserDeleteCall(call: RoutingCall, userId: Long, database: Database, api: AlpacaAPI) {
|
||||
database.user.delete(userId)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package win.tap_tap.papertrader.handler
|
||||
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import win.tap_tap.papertrader.*
|
||||
import kotlin.time.Clock
|
||||
|
||||
suspend fun handleAssetBuyCall(call: RoutingCall, userId: Long, database: Database, api: AlpacaAPI) {
|
||||
val request = call.receive<RequestAssetBuy>()
|
||||
val participant = database.challenge.getParticipant(userId, request.challengeId)
|
||||
if (participant == null) {
|
||||
call.respond(HttpStatusCode.BadRequest, "You are not participating in the challenge.")
|
||||
return
|
||||
}
|
||||
val asset = database.asset.get(request.assetId)
|
||||
if (asset == null) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Could not get the asset you want to buy from the database")
|
||||
return
|
||||
}
|
||||
if (participant.cash < request.cash.asCents()) {
|
||||
call.respond(HttpStatusCode.BadRequest, "You don't have enough cash to place that order.")
|
||||
return
|
||||
}
|
||||
val result = api.getPrices(listOf(asset))
|
||||
if (participant.cash < request.cash.asCents()) {
|
||||
call.respond(HttpStatusCode.BadRequest, "You don't have enough cash to place that order.")
|
||||
return
|
||||
}
|
||||
result.onFailure { error ->
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
"Could not get current price data of asset: ${error.message}"
|
||||
)
|
||||
}
|
||||
result.onSuccess { assetPrices ->
|
||||
val price = assetPrices[asset]
|
||||
if (price == null) {
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
"Could not get the price of the selected asset."
|
||||
)
|
||||
} else {
|
||||
if (participant.cash < request.cash.asCents()) {
|
||||
call.respond(HttpStatusCode.BadRequest, "You don't have enough cash to place that order.")
|
||||
return
|
||||
}
|
||||
database.positions.add(
|
||||
stockId = asset.id,
|
||||
challengeId = request.challengeId,
|
||||
userId = userId,
|
||||
amount = request.cash.asCents().toDouble() / price.asCents().toDouble(),
|
||||
date = Clock.System.now(),
|
||||
price = price
|
||||
)
|
||||
database.challenge.updateParticipantCash(
|
||||
Cash.fromCents(participant.cash - request.cash.asCents()),
|
||||
request.challengeId,
|
||||
userId
|
||||
)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleStockSellCall(call: RoutingCall, userId: Long, database: Database, api: AlpacaAPI) {
|
||||
val request = call.receive<RequestAssetSell>()
|
||||
val position = database.positions.get(request.positionId)
|
||||
val participant = database.challenge.getParticipant(userId, position?.challengeId ?: -1)
|
||||
if (position == null || participant == null) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
"You don't own the asset you are trying to sell"
|
||||
)
|
||||
return
|
||||
}
|
||||
val asset = database.asset.get(position.stockId)
|
||||
if (asset == null) {
|
||||
call.respond(HttpStatusCode.InternalServerError, "Could not find the corresponding Stock")
|
||||
return
|
||||
}
|
||||
val result = api.getPrices(listOf(asset))
|
||||
result.onFailure { error ->
|
||||
call.respond(HttpStatusCode.InternalServerError, "Could not get the current asset price: ${error.message}.")
|
||||
return@onFailure
|
||||
}
|
||||
result.onSuccess { assetPrice ->
|
||||
val price = assetPrice[asset]
|
||||
if (price == null) {
|
||||
call.respond(HttpStatusCode.InternalServerError, "Unexpected error while viewing the current asset price.")
|
||||
return@onSuccess
|
||||
}
|
||||
database.challenge.updateParticipantCash(
|
||||
Cash.fromCents(participant.cash) + price * position.amount,
|
||||
position.challengeId,
|
||||
userId
|
||||
)
|
||||
database.positions.delete(position.id)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleChallengeDataCall(call: RoutingCall, userId: Long, database: Database, api: AlpacaAPI) {
|
||||
val request = call.receive<RequestChallengeData>()
|
||||
val challenge = database.challenge.get(request.challengeId)
|
||||
val curParticipant = database.challenge.getParticipant(userId, request.challengeId)
|
||||
if (curParticipant == null || challenge == null
|
||||
) {
|
||||
call.respond(HttpStatusCode.BadRequest, "You are not participating in the requested challenge.")
|
||||
return
|
||||
}
|
||||
val participants = database.challenge.getParticipants(request.challengeId)
|
||||
if (participants.isEmpty()) {
|
||||
call.respond(HttpStatusCode.InternalServerError, "Could not find any participants of the challenge")
|
||||
return
|
||||
}
|
||||
val positionsDbByUser = mutableMapOf<Participant, MutableMap<PositionDB, Asset>>()
|
||||
val assets = mutableListOf<Asset>()
|
||||
for (participant in participants) {
|
||||
positionsDbByUser[participant] = mutableMapOf()
|
||||
val positions = database.positions.getByUser(request.challengeId, participant.user.id)
|
||||
for (position in positions) {
|
||||
val asset = database.asset.get(position.stockId)
|
||||
if (asset == null) {
|
||||
call.respond(HttpStatusCode.InternalServerError, "Could not get stock from database.")
|
||||
return
|
||||
}
|
||||
positionsDbByUser[participant]?.set(position, asset)
|
||||
assets.add(asset)
|
||||
}
|
||||
}
|
||||
val result = api.getPrices(assets.distinct())
|
||||
result.onFailure { error ->
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
"Could not get the price data of the asset: ${error.message}"
|
||||
)
|
||||
}
|
||||
result.onSuccess { prices ->
|
||||
val positionsByUser = mutableMapOf<Participant, MutableList<Position>>()
|
||||
for ((participant, assetByPosition) in positionsDbByUser) {
|
||||
positionsByUser[participant] = mutableListOf()
|
||||
for ((positionDb, asset) in assetByPosition) {
|
||||
val price = prices[asset]
|
||||
if (price == null) {
|
||||
call.respond(HttpStatusCode.InternalServerError, "Could not get the price data of an asset.")
|
||||
return
|
||||
}
|
||||
positionsByUser[participant]?.add(
|
||||
Position(
|
||||
id = positionDb.id,
|
||||
asset = asset,
|
||||
buyPrice = Cash.fromCents(positionDb.price),
|
||||
currentPrice = price,
|
||||
amount = positionDb.amount
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
val user = database.user.get(userId)
|
||||
if (user == null) {
|
||||
call.respond(HttpStatusCode.InternalServerError, "Could not get your data.")
|
||||
return
|
||||
}
|
||||
call.respond(
|
||||
HttpStatusCode.OK,
|
||||
ResponseChallengeData(
|
||||
challenge.creatorId == userId,
|
||||
Participant(User(user.id, user.name), Cash.fromCents(curParticipant.cash)),
|
||||
positionsByUser
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleGetStocksCall(call: RoutingCall, userId: Long, database: Database, api: AlpacaAPI) {
|
||||
call.respond(HttpStatusCode.OK, ResponseGetStocks(database.asset.getAll()))
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
ktor:
|
||||
auth:
|
||||
api_key: "XXX"
|
||||
api_secret: "XXX"
|
||||
@@ -0,0 +1,12 @@
|
||||
<configuration>
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
<root level="trace">
|
||||
<appender-ref ref="STDOUT"/>
|
||||
</root>
|
||||
<logger name="org.eclipse.jetty" level="INFO"/>
|
||||
<logger name="io.netty" level="INFO"/>
|
||||
</configuration>
|
||||
@@ -0,0 +1,60 @@
|
||||
createChallenge:
|
||||
INSERT INTO ChallengeDB(name, cash, creatorId)
|
||||
VALUES (?, ?, ?);
|
||||
|
||||
get:
|
||||
SELECT * FROM ChallengeDB
|
||||
WHERE ChallengeDB.id == ?;
|
||||
|
||||
leave:
|
||||
DELETE FROM ParticipantsDB
|
||||
WHERE challengeId = ? AND userId = ?;
|
||||
|
||||
lastInsertId:
|
||||
SELECT last_insert_rowid();
|
||||
|
||||
getChallengesForUser:
|
||||
SELECT c.* FROM ChallengeDB c
|
||||
JOIN ParticipantsDB p ON c.id = p.challengeId
|
||||
WHERE p.userId = :userId;
|
||||
|
||||
insertParticipant:
|
||||
INSERT INTO ParticipantsDB (userId, challengeId, cash)
|
||||
VALUES (?, ?, ?);
|
||||
|
||||
getParticipant:
|
||||
SELECT * FROM ParticipantsDB
|
||||
WHERE ParticipantsDB.userId = ? AND ParticipantsDB.challengeId = ?;
|
||||
|
||||
getParticipants:
|
||||
SELECT user.id, user.name, participant.cash FROM UserDB user
|
||||
JOIN ParticipantsDB participant ON participant.userId = user.id
|
||||
WHERE participant.challengeId = ?;
|
||||
|
||||
getMissingUsers:
|
||||
SELECT user.* FROM UserDB user
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ParticipantsDB participants
|
||||
WHERE participants.challengeId = ?
|
||||
AND user.id = participants.userId
|
||||
);
|
||||
|
||||
updateParticipantsCash:
|
||||
UPDATE ParticipantsDB
|
||||
SET cash = ?
|
||||
WHERE userId = ? AND challengeId = ?;
|
||||
|
||||
getFullData:
|
||||
SELECT
|
||||
user.id AS userId,
|
||||
user.name AS username,
|
||||
participant.cash AS cash,
|
||||
stock.name AS positionName,
|
||||
position.price AS buyPrice,
|
||||
position.amount AS shareAmount
|
||||
FROM PositionDB position
|
||||
JOIN ParticipantsDB participant ON position.userId = participant.userId
|
||||
JOIN AssetDB stock ON position.stockId = stock.id
|
||||
JOIN UserDB user ON position.userId = user.id
|
||||
WHERE position.challengeId = ?
|
||||
ORDER BY user.id ASC, stock.name ASC;
|
||||
@@ -0,0 +1,15 @@
|
||||
getPositionByUser:
|
||||
SELECT * FROM PositionDB
|
||||
WHERE challengeId = ? AND userId = ?;
|
||||
|
||||
addPosition:
|
||||
INSERT INTO PositionDB(stockId, challengeId, userId, amount, date, price)
|
||||
VALUES (?, ?, ?, ?, ?, ?);
|
||||
|
||||
get:
|
||||
SELECT * FROM PositionDB
|
||||
WHERE id = ?;
|
||||
|
||||
delete:
|
||||
DELETE FROM PositionDB
|
||||
WHERE id = ?;
|
||||
@@ -0,0 +1,14 @@
|
||||
get:
|
||||
SELECT * FROM AssetDB
|
||||
WHERE AssetDB.id = ?;
|
||||
|
||||
add:
|
||||
INSERT INTO AssetDB(symbol, name)
|
||||
VALUES (?, ?);
|
||||
|
||||
getBySymbol:
|
||||
SELECT * FROM AssetDB
|
||||
WHERE AssetDB.symbol = ?;
|
||||
|
||||
getAll:
|
||||
SELECT * FROM AssetDB;
|
||||
@@ -0,0 +1,52 @@
|
||||
CREATE TABLE IF NOT EXISTS UserDB (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS TokenDB (
|
||||
id Integer PRIMARY KEY AUTOINCREMENT,
|
||||
userId INTEGER NOT NULL,
|
||||
accessToken TEXT NOT NULL UNIQUE,
|
||||
accessTokenExpired INTEGER NOT NULL,
|
||||
refreshToken TEXT NOT NULL UNIQUE,
|
||||
refreshtokenExpired INTEGER NOT NULL,
|
||||
FOREIGN KEY (userId) REFERENCES UserDB(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ChallengeDB (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
cash INTEGER NOT NULL,
|
||||
creatorId INTEGER NOT NULL,
|
||||
FOREIGN KEY (creatorId) REFERENCES UserDB(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ParticipantsDB (
|
||||
userId INTEGER NOT NULL,
|
||||
challengeId INTEGER NOT NULL,
|
||||
cash INTEGER NOT NULL,
|
||||
PRIMARY KEY (userId, challengeId),
|
||||
FOREIGN KEY (userId) REFERENCES UserDB(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (challengeId) REFERENCES ChallengeDB(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS AssetDB (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS PositionDB (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
stockId INTEGER NOT NULL,
|
||||
challengeId INTEGER NOT NULL,
|
||||
userId INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
date INTEGER NOT NULL,
|
||||
price INTEGER NOT NULL,
|
||||
FOREIGN KEY (stockId) REFERENCES AssetDB(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (challengeId) REFERENCES ChallengeDB(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (userId) REFERENCES UserDB(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
insertToken:
|
||||
INSERT INTO TokenDB(userId, accessToken, accessTokenExpired, refreshToken, refreshtokenExpired)
|
||||
VALUES (?, ?, ?, ?, ?);
|
||||
|
||||
getToken:
|
||||
SELECT * FROM TokenDB
|
||||
WHERE id = ?
|
||||
LIMIT 1;
|
||||
|
||||
findToken:
|
||||
SELECT * FROM TokenDB
|
||||
WHERE accessToken = ?
|
||||
LIMIT 1;
|
||||
|
||||
findRefreshToken:
|
||||
SELECT * FROM TokenDB
|
||||
WHERE refreshToken = ?
|
||||
LIMIT 1;
|
||||
|
||||
updateAccessToken:
|
||||
UPDATE TokenDB
|
||||
SET accessToken = ?, accessTokenExpired = ?
|
||||
WHERE id = ?;
|
||||
|
||||
deleteTokenByAccessToken:
|
||||
DELETE FROM TokenDB
|
||||
WHERE accessToken = ?;
|
||||
|
||||
deleteTokenByRefreshToken:
|
||||
DELETE FROM TokenDB
|
||||
WHERE refreshToken = ?;
|
||||
@@ -0,0 +1,12 @@
|
||||
insertUser:
|
||||
INSERT INTO UserDB(name, password)
|
||||
VALUES (?, ?);
|
||||
|
||||
getUser:
|
||||
SELECT * FROM UserDB WHERE id = ?;
|
||||
|
||||
getUserByName:
|
||||
SELECT * FROM UserDB WHERE name = ? COLLATE NOCASE;
|
||||
|
||||
delete:
|
||||
DELETE FROM UserDB WHERE id = ?;
|
||||
@@ -0,0 +1,20 @@
|
||||
package win.tap_tap.papertrader
|
||||
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.testing.*
|
||||
import kotlin.test.*
|
||||
|
||||
class ApplicationTest {
|
||||
|
||||
@Test
|
||||
fun testRoot() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
val response = client.get("/")
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
assertEquals("Ktor: ${Greeting().greet()}", response.bodyAsText())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user