From f657d1f63bb5889db17ef65dbb5d73395e933f81 Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:49:35 +0200 Subject: [PATCH] ENH - add slow VaultX market cycles Drive server-authoritative prices through alternating global and per-market trend phases with eased transitions, reduced noise, momentum, mean reversion, and rare bounded shocks. Mirror the cycle model in browser mocks and slow development preview ticks so charts show readable highs and downs. --- .../src/views/apps/CryptoApp.contract.test.ts | 15 ++ frontend/src/views/apps/CryptoApp.vue | 2 +- frontend/testserver/index.cjs | 62 ++++++- sky_phone/config/config.lua | 33 ++-- sky_phone/source/server/crypto.lua | 156 ++++++++++++++---- 5 files changed, 223 insertions(+), 45 deletions(-) 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)