First Commit

This commit is contained in:
Theo Tappe
2026-05-13 14:19:41 +02:00
commit 1eaa7d0896
89 changed files with 6206 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
FROM eclipse-temurin:23-jre-alpine
WORKDIR /app
RUN mkdir -p /app/data
EXPOSE 8080
COPY build/libs/papertraderServer.jar /app/paptertraderServer.jar
ENTRYPOINT ["java", "-jar", "/app/paptertraderServer.jar"]
+105
View File
@@ -0,0 +1,105 @@
import org.gradle.kotlin.dsl.support.serviceOf
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.ktor)
alias(libs.plugins.sqldelight)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.jib)
application
}
group = "win.tap_tap.papertrader"
application {
mainClass.set("win.tap_tap.papertrader.ApplicationKt")
val isDevelopment: Boolean = project.ext.has("development")
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
}
dependencies {
implementation(projects.shared)
implementation(libs.logback)
implementation(libs.ktor.serverCore)
implementation(libs.ktor.serverNetty)
implementation(libs.bundles.ktor)
implementation(libs.bundles.ktorServer)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.server.cors)
implementation(libs.sqlite.driver)
testImplementation(libs.ktor.serverTestHost)
testImplementation(libs.kotlin.testJunit)
}
allprojects {
version = "1.0.1"
}
tasks.register<Exec>("serverDockerBuild") {
group = "deployment"
dependsOn("buildFatJar")
workingDir = projectDir
commandLine("docker", "build", "-t", "papertrader-server", ".")
}
tasks.register("serverDockerPush") {
group = "deployment"
val version = project.version
val execOps = project.serviceOf<ExecOperations>()
doLast {
fun execute(args: List<String>) {
execOps.exec {
commandLine(args)
}
}
execute(listOf("docker", "tag", "papertrader-server", "taptap1/papertrader-server:$version"))
execute(listOf("docker", "tag", "papertrader-server", "taptap1/papertrader-server:latest"))
execute(listOf("docker", "push", "taptap1/papertrader-server:$version"))
execute(listOf("docker", "push", "taptap1/papertrader-server:latest"))
println("Pushed: papertrader-server:$version and papertrader-server:latest")
}
}
tasks.register("serverDockerDeploy") {
group = "deployment"
dependsOn("serverDockerBuild")
dependsOn("serverDockerPush")
}
tasks.named("serverDockerPush") {
mustRunAfter("serverDockerBuild")
}
//jib {
// from {
// image = "eclipse-temurin:23-jre-alpine"
// }
//
// to {
// image = "taptap1/papertrader-server"
// tags = setOf("latest", project.version.toString())
// }
//
// container {
// entrypoint = listOf("java", "-jar", "/app/paptertraderServer.jar")
// ports = listOf("8080")
// }
//}
ktor {
fatJar {
archiveFileName.set("papertraderServer.jar")
}
}
sqldelight {
databases {
register("Data") {
packageName.set("win.tap_tap.papertrader")
}
}
}
@@ -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"
+12
View File
@@ -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())
}
}