diff --git a/frontend/src/views/apps/CryptoApp.contract.test.ts b/frontend/src/views/apps/CryptoApp.contract.test.ts index 415e294..14eb709 100644 --- a/frontend/src/views/apps/CryptoApp.contract.test.ts +++ b/frontend/src/views/apps/CryptoApp.contract.test.ts @@ -22,6 +22,10 @@ const config = readFileSync( new URL('../../../../sky_phone/config/config.lua', import.meta.url), 'utf8', ) +const testServer = readFileSync( + new URL('../../../testserver/index.cjs', import.meta.url), + 'utf8', +) describe('VaultX crypto app contracts', () => { it('uses Sky UI without introducing Konsta components', () => { @@ -247,13 +251,24 @@ describe('VaultX crypto app contracts', () => { expect(config).toContain('PriceTickMinimumSeconds') expect(config).toContain('MarketsPerTickMaximum') expect(config).toContain('TickMovementDivisor') + expect(config).toContain('CycleDurationMinimumTicks') + expect(config).toContain('CycleStrengthMaximumBasisPoints') + expect(config).toContain('GlobalCycleMinimumTicks') expect(config).toContain('MarketShockChanceBasisPoints') expect(server).toContain('local market_dynamics = {}') expect(server).toContain('global_market_trend') + expect(server).toContain('local function advance_market_cycle') + expect(server).toContain( + 'dynamics.cycle_direction = -dynamics.cycle_direction', + ) + expect(server).toContain('local function advance_global_market_cycle') expect(server).toContain('Config.Crypto.MeanReversionBasisPoints') expect(server).toContain('TriggerClientEvent("sky_phone:crypto:changed"') expect(server).toContain('priceHistory = price_history') expect(source).toContain('selected.priceHistory') + expect(source).toContain('4500 + Math.random() * 2500') + expect(testServer).toContain('const cryptoMarketDynamics = new Map()') + expect(testServer).toContain('function advanceCryptoCycle(') }) it('stores cash in price-scale minor units throughout the ledger', () => { diff --git a/frontend/src/views/apps/CryptoApp.vue b/frontend/src/views/apps/CryptoApp.vue index 2bfb1b0..8856ecf 100644 --- a/frontend/src/views/apps/CryptoApp.vue +++ b/frontend/src/views/apps/CryptoApp.vue @@ -677,7 +677,7 @@ function scheduleDevelopmentMarketTick() { await crypto.previewMarketTick() scheduleDevelopmentMarketTick() }, - 1600 + Math.random() * 1800, + 4500 + Math.random() * 2500, ) } onMounted(async () => { diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index 2991f77..5b0118b 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -496,8 +496,43 @@ const cryptoMarkets = [ supply: 1200000000, }), ] +const cryptoMarketDynamics = new Map() +const cryptoGlobalCycle = { + bias: 0, + direction: 0, + remainingTicks: 0, + target: 0, +} +function advanceCryptoCycle( + cycle, + minimumTicks, + maximumTicks, + minimumStrength, + maximumStrength, +) { + if (cycle.remainingTicks <= 0) { + cycle.direction = + cycle.direction === 0 ? (Math.random() < 0.5 ? -1 : 1) : -cycle.direction + cycle.remainingTicks = Math.floor( + minimumTicks + Math.random() * (maximumTicks - minimumTicks + 1), + ) + cycle.target = + cycle.direction * + (minimumStrength + Math.random() * (maximumStrength - minimumStrength)) + } + cycle.bias += (cycle.target - cycle.bias) * 0.18 + cycle.remainingTicks -= 1 + return cycle.bias +} function advanceCryptoMarkets() { const updatedAt = Date.now() + const globalBias = advanceCryptoCycle( + cryptoGlobalCycle, + 24, + 54, + 0.00008, + 0.00024, + ) for (const market of cryptoMarkets) { const currentPrice = Number(market.price) const fractionDigits = currentPrice < 1 ? 4 : 2 @@ -510,8 +545,31 @@ function advanceCryptoMarkets() { : currentPrice >= 1 ? 0.0012 : 0.004 - const direction = Math.random() < 0.49 ? -1 : 1 - const movement = direction * volatility * (0.25 + Math.random() * 0.75) + const dynamics = cryptoMarketDynamics.get(market.id) ?? { + bias: 0, + direction: 0, + momentum: 0, + remainingTicks: 0, + target: 0, + } + const cycleBias = advanceCryptoCycle( + dynamics, + 14, + 30, + volatility * 0.28, + volatility * 0.52, + ) + const impulse = (Math.random() * 2 - 1) * volatility * 0.2 + dynamics.momentum = dynamics.momentum * 0.8 + impulse * 0.2 + cryptoMarketDynamics.set(market.id, dynamics) + const movement = Math.max( + -volatility * 0.9, + Math.min( + volatility * 0.9, + cycleBias + globalBias + dynamics.momentum + impulse, + ), + ) + const direction = movement < 0 ? -1 : 1 let nextPrice = Number( Math.max(minimumStep, currentPrice * (1 + movement)).toFixed( fractionDigits, diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index 5ce67ee..cba9bae 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -655,19 +655,28 @@ Config.Crypto = { AssetScale = 1000000, PriceScale = 100, QuoteLifetimeSeconds = 8, - PriceTickMinimumSeconds = 4, - PriceTickMaximumSeconds = 8, - MarketsPerTickMinimum = 6, - MarketsPerTickMaximum = 10, - TickMovementDivisor = 8, - MomentumDecayBasisPoints = 6500, - MomentumImpulseBasisPoints = 3500, - MeanReversionBasisPoints = 80, - GlobalTrendMaximumBasisPoints = 28, - MarketShockChanceBasisPoints = 300, + PriceTickMinimumSeconds = 7, + PriceTickMaximumSeconds = 12, + MarketsPerTickMinimum = 8, + MarketsPerTickMaximum = 12, + TickMovementDivisor = 10, + RandomImpulseDivisor = 3, + MomentumDecayBasisPoints = 8000, + MomentumImpulseBasisPoints = 2000, + MeanReversionBasisPoints = 55, + CycleDurationMinimumTicks = 14, + CycleDurationMaximumTicks = 30, + CycleStrengthMinimumBasisPoints = 2800, + CycleStrengthMaximumBasisPoints = 5200, + CycleTransitionBasisPoints = 1800, + GlobalCycleMinimumTicks = 24, + GlobalCycleMaximumTicks = 54, + GlobalTrendMinimumBasisPoints = 8, + GlobalTrendMaximumBasisPoints = 24, + MarketShockChanceBasisPoints = 100, MarketShockMinimumMultiplier = 2, - MarketShockMaximumMultiplier = 4, - MaximumMovementMultiplier = 5, + MarketShockMaximumMultiplier = 3, + MaximumMovementMultiplier = 3, HistoryRetentionTicks = 4096, SparklinePoints = 48, SessionSeconds = 30 * 60, diff --git a/sky_phone/source/server/crypto.lua b/sky_phone/source/server/crypto.lua index c1aaf0f..adfac86 100644 --- a/sky_phone/source/server/crypto.lua +++ b/sky_phone/source/server/crypto.lua @@ -8,6 +8,11 @@ local market_order = {} local market_dynamics = {} local market_cursor = 1 local global_market_trend = 0 +local global_market_cycle = { + direction = 0, + remaining_ticks = 0, + target = 0, +} local function ensure_schema() local statements = { @@ -1400,6 +1405,88 @@ end reconcile_settlements(true) +local function crypto_random_int(minimum, maximum, failure_message) + local value = exports[GetCurrentResourceName()]:CryptoRandomInt(minimum, maximum) + if type(value) ~= "number" then + error(failure_message) + end + return value +end + +local function truncate_integer(value) + if value > 0 then + return math.floor(value) + elseif value < 0 then + return math.ceil(value) + end + return 0 +end + +local function advance_global_market_cycle() + if global_market_cycle.remaining_ticks <= 0 then + if global_market_cycle.direction == 0 then + global_market_cycle.direction = crypto_random_int( + 0, + 2, + "[sky_phone] Crypto entropy provider did not return a global market direction." + ) == 0 and -1 or 1 + else + global_market_cycle.direction = -global_market_cycle.direction + end + global_market_cycle.remaining_ticks = crypto_random_int( + Config.Crypto.GlobalCycleMinimumTicks, + Config.Crypto.GlobalCycleMaximumTicks + 1, + "[sky_phone] Crypto entropy provider did not return a global market cycle duration." + ) + global_market_cycle.target = global_market_cycle.direction * crypto_random_int( + Config.Crypto.GlobalTrendMinimumBasisPoints, + Config.Crypto.GlobalTrendMaximumBasisPoints + 1, + "[sky_phone] Crypto entropy provider did not return a global market trend." + ) + end + + global_market_trend = truncate_integer(( + global_market_trend * (10000 - Config.Crypto.CycleTransitionBasisPoints) + + global_market_cycle.target * Config.Crypto.CycleTransitionBasisPoints + ) / 10000) + global_market_cycle.remaining_ticks = global_market_cycle.remaining_ticks - 1 +end + +local function advance_market_cycle(config, dynamics) + if dynamics.cycle_remaining_ticks <= 0 then + if dynamics.cycle_direction == 0 then + dynamics.cycle_direction = crypto_random_int( + 0, + 2, + "[sky_phone] Crypto entropy provider did not return a market cycle direction." + ) == 0 and -1 or 1 + else + dynamics.cycle_direction = -dynamics.cycle_direction + end + dynamics.cycle_remaining_ticks = crypto_random_int( + Config.Crypto.CycleDurationMinimumTicks, + Config.Crypto.CycleDurationMaximumTicks + 1, + "[sky_phone] Crypto entropy provider did not return a market cycle duration." + ) + local strength = crypto_random_int( + Config.Crypto.CycleStrengthMinimumBasisPoints, + Config.Crypto.CycleStrengthMaximumBasisPoints + 1, + "[sky_phone] Crypto entropy provider did not return a market cycle strength." + ) + dynamics.cycle_target = dynamics.cycle_direction * math.max( + 1, + math.floor(config.VolatilityBasisPoints * strength / 10000) + ) + end + + dynamics.cycle_bias = truncate_integer(( + dynamics.cycle_bias * (10000 - Config.Crypto.CycleTransitionBasisPoints) + + dynamics.cycle_target * Config.Crypto.CycleTransitionBasisPoints + ) / 10000) + dynamics.cycle_remaining_ticks = dynamics.cycle_remaining_ticks - 1 + return dynamics.cycle_bias +end + CreateThread(function() while true do Wait(5 * 60 * 1000) @@ -1409,27 +1496,19 @@ end) CreateThread(function() while true do - local tick_seconds = exports[GetCurrentResourceName()]:CryptoRandomInt( + local tick_seconds = crypto_random_int( Config.Crypto.PriceTickMinimumSeconds, - Config.Crypto.PriceTickMaximumSeconds + 1 + Config.Crypto.PriceTickMaximumSeconds + 1, + "[sky_phone] Crypto entropy provider did not return a market tick interval." ) - if type(tick_seconds) ~= "number" then - error("[sky_phone] Crypto entropy provider did not return a market tick interval.") - end Wait(tick_seconds * 1000) with_exchange_lock(function() - local market_count = exports[GetCurrentResourceName()]:CryptoRandomInt( + local market_count = crypto_random_int( Config.Crypto.MarketsPerTickMinimum, - Config.Crypto.MarketsPerTickMaximum + 1 + Config.Crypto.MarketsPerTickMaximum + 1, + "[sky_phone] Crypto entropy provider did not return a market count." ) - local trend_impulse = exports[GetCurrentResourceName()]:CryptoRandomInt( - -Config.Crypto.GlobalTrendMaximumBasisPoints, - Config.Crypto.GlobalTrendMaximumBasisPoints + 1 - ) - if type(market_count) ~= "number" or type(trend_impulse) ~= "number" then - error("[sky_phone] Crypto entropy provider did not return valid market dynamics.") - end - global_market_trend = math.floor((global_market_trend * 7800 + trend_impulse * 2200) / 10000) + advance_global_market_cycle() local changed_markets = {} market_count = math.min(market_count, #market_order) @@ -1443,44 +1522,61 @@ CreateThread(function() )[1] if row and row.status == "active" then local price = tonumber(row.price) or config.InitialPrice - local impulse = exports[GetCurrentResourceName()]:CryptoRandomInt( + local impulse = crypto_random_int( -config.VolatilityBasisPoints, - config.VolatilityBasisPoints + 1 + config.VolatilityBasisPoints + 1, + "[sky_phone] Crypto entropy provider did not return a market movement." ) - local shock_roll = exports[GetCurrentResourceName()]:CryptoRandomInt(0, 10000) - if type(impulse) ~= "number" or type(shock_roll) ~= "number" then - error("[sky_phone] Crypto entropy provider did not return a market movement.") + if impulse > 0 then + impulse = math.floor(impulse / Config.Crypto.RandomImpulseDivisor) + elseif impulse < 0 then + impulse = math.ceil(impulse / Config.Crypto.RandomImpulseDivisor) end - local dynamics = market_dynamics[market_id] or { momentum = 0 } - dynamics.momentum = math.floor(( + local shock_roll = crypto_random_int( + 0, + 10000, + "[sky_phone] Crypto entropy provider did not return a market shock roll." + ) + local dynamics = market_dynamics[market_id] or { + momentum = 0, + cycle_bias = 0, + cycle_direction = 0, + cycle_remaining_ticks = 0, + cycle_target = 0, + } + dynamics.momentum = truncate_integer(( dynamics.momentum * Config.Crypto.MomentumDecayBasisPoints + impulse * Config.Crypto.MomentumImpulseBasisPoints ) / 10000) + local cycle_bias = advance_market_cycle(config, dynamics) market_dynamics[market_id] = dynamics local deviation = math.floor( (config.InitialPrice - price) * 10000 / config.InitialPrice ) - local reversion = math.floor( + local reversion = truncate_integer( deviation * Config.Crypto.MeanReversionBasisPoints / 10000 ) local shock = 0 if shock_roll < Config.Crypto.MarketShockChanceBasisPoints then - local multiplier = exports[GetCurrentResourceName()]:CryptoRandomInt( + local multiplier = crypto_random_int( Config.Crypto.MarketShockMinimumMultiplier, - Config.Crypto.MarketShockMaximumMultiplier + 1 + Config.Crypto.MarketShockMaximumMultiplier + 1, + "[sky_phone] Crypto entropy provider did not return a market shock multiplier." + ) + local direction_roll = crypto_random_int( + 0, + 2, + "[sky_phone] Crypto entropy provider did not return a market shock direction." ) - local direction_roll = exports[GetCurrentResourceName()]:CryptoRandomInt(0, 2) - if type(multiplier) ~= "number" or type(direction_roll) ~= "number" then - error("[sky_phone] Crypto entropy provider did not return valid shock dynamics.") - end local direction = direction_roll == 0 and -1 or 1 shock = direction * config.VolatilityBasisPoints * multiplier end local maximum_movement = config.VolatilityBasisPoints * Config.Crypto.MaximumMovementMultiplier - local movement = impulse + dynamics.momentum + global_market_trend + reversion + shock + local movement = impulse + dynamics.momentum + cycle_bias + + global_market_trend + reversion + shock movement = math.max(-maximum_movement, math.min(maximum_movement, movement)) if movement > 0 then movement = math.floor(movement / Config.Crypto.TickMovementDivisor)