This commit is contained in:
2026-04-17 17:15:33 -04:00
parent c8fa99c6d2
commit 6f118107d9
16 changed files with 128 additions and 18 deletions

View File

@@ -137,13 +137,13 @@ echo -e "${GREEN}User ID: $USER_ID${NC}"
# Build license JSON # Build license JSON
case "$LICENSE_TYPE" in case "$LICENSE_TYPE" in
enterprise) enterprise)
LICENSE_JSON='{"licenseType":"enterprise","features":{"maxIndicators":200,"maxStrategies":100,"maxBacktestDays":1825,"realtimeData":true,"customExecutors":true,"apiAccess":true},"resourceLimits":{"maxConcurrentSessions":20,"maxMessagesPerDay":10000,"maxTokensPerMessage":32768,"rateLimitPerMinute":300},"k8sResources":{"memoryRequest":"1Gi","memoryLimit":"4Gi","cpuRequest":"500m","cpuLimit":"4000m","storage":"50Gi","tmpSizeLimit":"1Gi","enableIdleShutdown":true,"idleTimeoutMinutes":120},"preferredModel":{"provider":"anthropic","model":"claude-opus-4-6","temperature":0.7}}' LICENSE_JSON='{"licenseType":"enterprise","features":{"maxIndicators":999,"maxStrategies":999,"maxBacktestDays":3650,"realtimeData":true,"customExecutors":true,"apiAccess":true},"resourceLimits":{"maxConcurrentSessions":20,"maxMessagesPerDay":10000,"maxTokensPerMessage":32768,"rateLimitPerMinute":300},"k8sResources":{"memoryRequest":"1Gi","memoryLimit":"8Gi","cpuRequest":"500m","cpuLimit":"4000m","storage":"50Gi","tmpSizeLimit":"512Mi","enableIdleShutdown":false,"idleTimeoutMinutes":0}}'
;; ;;
free) free)
LICENSE_JSON='{"licenseType":"free","features":{"maxIndicators":10,"maxStrategies":3,"maxBacktestDays":30,"realtimeData":false,"customExecutors":false,"apiAccess":false},"resourceLimits":{"maxConcurrentSessions":1,"maxMessagesPerDay":100,"maxTokensPerMessage":4096,"rateLimitPerMinute":20},"k8sResources":{"memoryRequest":"256Mi","memoryLimit":"512Mi","cpuRequest":"100m","cpuLimit":"500m","storage":"2Gi","tmpSizeLimit":"128Mi","enableIdleShutdown":true,"idleTimeoutMinutes":30},"preferredModel":{"provider":"anthropic","model":"claude-haiku-4-5-20251001","temperature":0.7}}' LICENSE_JSON='{"licenseType":"free","features":{"maxIndicators":5,"maxStrategies":3,"maxBacktestDays":30,"realtimeData":false,"customExecutors":false,"apiAccess":false},"resourceLimits":{"maxConcurrentSessions":1,"maxMessagesPerDay":100,"maxTokensPerMessage":4096,"rateLimitPerMinute":10},"k8sResources":{"memoryRequest":"256Mi","memoryLimit":"8Gi","cpuRequest":"100m","cpuLimit":"500m","storage":"1Gi","tmpSizeLimit":"128Mi","enableIdleShutdown":true,"idleTimeoutMinutes":15}}'
;; ;;
pro|*) pro|*)
LICENSE_JSON='{"licenseType":"pro","features":{"maxIndicators":50,"maxStrategies":20,"maxBacktestDays":365,"realtimeData":true,"customExecutors":true,"apiAccess":true},"resourceLimits":{"maxConcurrentSessions":5,"maxMessagesPerDay":1000,"maxTokensPerMessage":8192,"rateLimitPerMinute":60},"k8sResources":{"memoryRequest":"512Mi","memoryLimit":"2Gi","cpuRequest":"250m","cpuLimit":"2000m","storage":"10Gi","tmpSizeLimit":"256Mi","enableIdleShutdown":true,"idleTimeoutMinutes":60},"preferredModel":{"provider":"anthropic","model":"claude-sonnet-4-6","temperature":0.7}}' LICENSE_JSON='{"licenseType":"pro","features":{"maxIndicators":50,"maxStrategies":20,"maxBacktestDays":365,"realtimeData":true,"customExecutors":true,"apiAccess":true},"resourceLimits":{"maxConcurrentSessions":5,"maxMessagesPerDay":1000,"maxTokensPerMessage":8192,"rateLimitPerMinute":60},"k8sResources":{"memoryRequest":"512Mi","memoryLimit":"8Gi","cpuRequest":"250m","cpuLimit":"2000m","storage":"10Gi","tmpSizeLimit":"256Mi","enableIdleShutdown":false,"idleTimeoutMinutes":0}}'
;; ;;
esac esac

View File

@@ -2,7 +2,7 @@
# ZeroMQ bind address and ports # ZeroMQ bind address and ports
zmq_bind_address: "tcp://*" zmq_bind_address: "tcp://*"
zmq_ingestor_work_queue_port: 5555 zmq_ingestor_work_queue_port: 5567
zmq_market_data_pub_port: 5558 zmq_market_data_pub_port: 5558
# Notification endpoints # Notification endpoints

View File

@@ -2,7 +2,7 @@
# ZeroMQ bind address and ports # ZeroMQ bind address and ports
zmq_bind_address: "tcp://*" zmq_bind_address: "tcp://*"
zmq_ingestor_work_queue_port: 5555 zmq_ingestor_work_queue_port: 5567
zmq_market_data_pub_port: 5558 zmq_market_data_pub_port: 5558
# Notification endpoints (internal Flink task manager → job manager path) # Notification endpoints (internal Flink task manager → job manager path)

View File

@@ -21,6 +21,30 @@ data:
model_provider: deepinfra model_provider: deepinfra
model: zai-org/GLM-5 model: zai-org/GLM-5
# License tier model configuration
license_models:
# Free tier models
free:
default: zai-org/GLM-5
cost_optimized: zai-org/GLM-5
complex: zai-org/GLM-5
allowed_models:
- zai-org/GLM-5
# Pro tier models
pro:
default: zai-org/GLM-5
cost_optimized: zai-org/GLM-5
complex: zai-org/GLM-5
blocked_models:
- Qwen/Qwen3-235B-A22B-Instruct-2507
# Enterprise tier models
enterprise:
default: zai-org/GLM-5
cost_optimized: zai-org/GLM-5
complex: Qwen/Qwen3-235B-A22B-Instruct-2507
# Kubernetes configuration # Kubernetes configuration
kubernetes: kubernetes:
namespace: sandbox namespace: sandbox
@@ -40,10 +64,17 @@ data:
url: http://qdrant:6333 url: http://qdrant:6333
collection: gateway_memory collection: gateway_memory
# Agent configuration
agent:
# Number of prior conversation turns loaded as LLM context and flushed to Iceberg at session end
conversation_history_limit: 20
# Iceberg (for durable storage via REST catalog) # Iceberg (for durable storage via REST catalog)
iceberg: iceberg:
catalog_uri: http://iceberg-catalog:8181 catalog_uri: http://iceberg-catalog:8181
namespace: gateway namespace: gateway
ohlc_catalog_uri: http://iceberg-catalog:8181
ohlc_namespace: trading
s3_endpoint: http://minio:9000 s3_endpoint: http://minio:9000
conversations_bucket: warehouse conversations_bucket: warehouse

View File

@@ -10,12 +10,25 @@ supported_exchanges:
- COINBASE - COINBASE
- KRAKEN - KRAKEN
# Per-exchange work slot capacity.
# Each slot is one concurrent job. historical_slots limits parallel OHLC fetches;
# realtime_slots limits concurrent tick subscriptions.
exchange_capacity:
BINANCE:
historical_slots: 1
realtime_slots: 5
COINBASE:
historical_slots: 1
realtime_slots: 4
KRAKEN:
historical_slots: 1
realtime_slots: 3
# Kafka configuration # Kafka configuration
kafka_brokers: kafka_brokers:
- kafka:9092 - kafka:9092
# Worker configuration # Worker configuration
max_concurrent: 10
poll_interval_ms: 10000 poll_interval_ms: 10000
# Logging # Logging

View File

@@ -15,9 +15,8 @@ resources:
- infrastructure.yaml - infrastructure.yaml
# Sandbox namespace resources (go to sandbox namespace, not ai) # Sandbox namespace resources (go to sandbox namespace, not ai)
- sandbox-config.yaml - sandbox-config.yaml
# gateway-config ConfigMap is intentionally excluded from kustomize. # gateway-config ConfigMap (database URL is in secrets, not here)
# It contains an op:// reference for the DB password. Apply via: - configs/gateway-config.yaml
# bin/config-update prod gateway-config
patches: patches:
- path: patch-gateway-rbac-subject.yaml - path: patch-gateway-rbac-subject.yaml

View File

@@ -1,5 +1,6 @@
# Development Plan # Development Plan
* Single conversation in gateway
* Realtime data * Realtime data
* Triggers * Triggers
* Strategy UI * Strategy UI
@@ -8,3 +9,8 @@
* User secrets * User secrets
* Live Execution * Live Execution
* Sandbox <=> Dexorder auth * Sandbox <=> Dexorder auth
* Chat channels
* MCP channel (with or without images)
* TradingView indicator import tool
* Trader preferences tool
*

View File

@@ -30,6 +30,16 @@ This script (hardcoded to `--context=prod`) performs:
> **Secrets are NOT updated by this script.** Run `bin/secret-update prod` separately if secrets have changed. > **Secrets are NOT updated by this script.** Run `bin/secret-update prod` separately if secrets have changed.
### Post-deploy: refresh user licenses
After any deploy that changes license tier templates (`gateway/src/types/user.ts`), run:
```bash
bin/create-all-users prod
```
This upserts all alpha users and re-applies the current tier template to their `user_licenses` row. Safe to run on an existing database — it will not delete users or lose data. New sandbox deployments will pick up the updated resource limits on next login.
--- ---
## Full Deploy with Iceberg Schema Wipe ## Full Deploy with Iceberg Schema Wipe
@@ -137,3 +147,20 @@ kubectl --context prod -n ai logs deployment/gateway --tail=100
### Gateway shows `42P01` errors but pod is running ### Gateway shows `42P01` errors but pod is running
The gateway does not auto-migrate on startup. The schema file must be applied manually after any database recreation. A gateway restart alone will not fix this. The gateway does not auto-migrate on startup. The schema file must be applied manually after any database recreation. A gateway restart alone will not fix this.
### Gateway CrashLoopBackOff — `ECONNREFUSED postgresql://localhost/dexorder`
**Symptom:** New gateway pod crashes immediately with `Database connection failed` and logs show `databaseUrl: "postgresql://localhost/dexorder"`.
**Cause:** The gateway reads `database.url` from `config.yaml` (via `configData`). If that key is absent, it falls back to the localhost default — even if `secrets.yaml` has `database.url`. The code checks `configData.database?.url || secretsData.database?.url || ...` (as of `c8fa99c`), so both sources work, but both files must be present and correctly mounted.
**What to check:**
1. Does the `gateway-config` ConfigMap have a `database:` section? (It should not — credentials belong in secrets as of the nautilus branch.)
2. Does `gateway-secrets` have `database.url`? Verify: `kubectl --context prod -n ai get secret gateway-secrets -o jsonpath='{.data.secrets\.yaml}' | base64 -d`
3. If the secret is missing the database section, run `bin/secret-update prod` (requires 1Password desktop to be unlocked — must run interactively, not via pipe).
### `bin/secret-update prod` fails with "authorization prompt dismissed"
1Password's `op inject` requires interactive desktop authentication. Running it via `echo "yes" | bin/secret-update prod` or any background/piped invocation will fail silently (the script prints `✓` even though `kubectl apply` received empty input).
**Fix:** Run `bin/secret-update prod` in an interactive terminal with 1Password unlocked.

View File

@@ -129,7 +129,7 @@ export class Authenticator {
'Container is ready' 'Container is ready'
); );
const sessionId = `tg_${telegramUserId}_${Date.now()}`; const sessionId = `tg_${telegramUserId}`;
return { return {
userId, userId,

View File

@@ -248,7 +248,7 @@ export class TelegramHandler {
* Clean up sessions that have been idle longer than maxAgeMs. * Clean up sessions that have been idle longer than maxAgeMs.
* Triggers Iceberg flush for each expired session via harness.cleanup(). * Triggers Iceberg flush for each expired session via harness.cleanup().
*/ */
async cleanupSessions(maxAgeMs = 30 * 60 * 1000): Promise<void> { async cleanupSessions(maxAgeMs = 2 * 60 * 60 * 1000): Promise<void> {
const now = Date.now(); const now = Date.now();
const expired: string[] = []; const expired: string[] = [];

View File

@@ -511,13 +511,18 @@ export class WebSocketHandler {
hasSymbolIndexService: !!symbolIndexService hasSymbolIndexService: !!symbolIndexService
}, 'Service availability'); }, 'Service availability');
const requestId = payload.request_id || randomUUID();
if (!ohlcService && !symbolIndexService) { if (!ohlcService && !symbolIndexService) {
logger.warn('No datafeed services available'); logger.warn({ requestId }, 'No datafeed services available yet');
socket.send(JSON.stringify({
type: 'error',
request_id: requestId,
error_message: 'Services initializing, please retry',
}));
return; return;
} }
const requestId = payload.request_id || randomUUID();
try { try {
switch (payload.type) { switch (payload.type) {
case 'get_config': { case 'get_config': {

View File

@@ -14,7 +14,7 @@ import type { FastifyBaseLogger } from 'fastify';
* Iceberg for durable storage with time-travel capabilities. * Iceberg for durable storage with time-travel capabilities.
*/ */
export class TieredCheckpointSaver extends BaseCheckpointSaver<number> { export class TieredCheckpointSaver extends BaseCheckpointSaver<number> {
private readonly HOT_TTL_SECONDS = 3600; // 1 hour private readonly HOT_TTL_SECONDS = 86400; // 24 hours
private readonly KEY_PREFIX = 'ckpt:'; private readonly KEY_PREFIX = 'ckpt:';
constructor( constructor(

View File

@@ -30,7 +30,7 @@ export interface StoredMessage {
*/ */
export class ConversationStore { export class ConversationStore {
private readonly HOT_MESSAGE_LIMIT = 50; // Redis buffer ceiling private readonly HOT_MESSAGE_LIMIT = 50; // Redis buffer ceiling
private readonly HOT_TTL_SECONDS = 3600; // 1 hour private readonly HOT_TTL_SECONDS = 86400; // 24 hours
constructor( constructor(
private redis: Redis, private redis: Redis,

View File

@@ -372,7 +372,10 @@ class IngestorWorker {
if (candles.length > 0) { if (candles.length > 0) {
const metadata = { request_id: requestId, client_id, ticker, period_seconds, start_time, end_time }; const metadata = { request_id: requestId, client_id, ticker, period_seconds, start_time, end_time };
const PAGE_SIZE = 1000; // 8000 rows/page: each OHLC row is ~77 bytes typical (9 populated fields as
// protobuf varints + ticker string). Worst-case is ~124 bytes, so 8000 rows
// stays safely under Kafka's 1MB message limit in all realistic scenarios.
const PAGE_SIZE = 8000;
for (let i = 0; i < candles.length; i += PAGE_SIZE) { for (let i = 0; i < candles.length; i += PAGE_SIZE) {
const page = candles.slice(i, i + PAGE_SIZE); const page = candles.slice(i, i + PAGE_SIZE);
const isLastPage = (i + PAGE_SIZE) >= candles.length; const isLastPage = (i + PAGE_SIZE) >= candles.length;

View File

@@ -249,7 +249,6 @@ export class SymbolMetadataGenerator {
if (!this.publishedSymbols.has(key)) { if (!this.publishedSymbols.has(key)) {
uniqueMetadata.push(metadata); uniqueMetadata.push(metadata);
this.publishedSymbols.add(key);
} else { } else {
duplicateCount++; duplicateCount++;
} }
@@ -275,6 +274,12 @@ export class SymbolMetadataGenerator {
await this.kafkaProducer.writeMarketMetadata(topic, messages); await this.kafkaProducer.writeMarketMetadata(topic, messages);
// Mark as published only after successful Kafka write
for (const metadata of uniqueMetadata) {
const key = `${metadata.marketId}.${metadata.exchangeId}`;
this.publishedSymbols.add(key);
}
this.logger.info( this.logger.info(
{ count: messages.length, duplicateCount, topic }, { count: messages.length, duplicateCount, topic },
'Wrote symbol metadata to Kafka' 'Wrote symbol metadata to Kafka'

View File

@@ -45,6 +45,9 @@ export class WebSocketDatafeed implements IBasicDataFeed {
private configuration: DatafeedConfiguration | null = null private configuration: DatafeedConfiguration | null = null
private messageHandler: MessageHandler private messageHandler: MessageHandler
private symbolDenominators: Map<string, SymbolDenominators> = new Map() // Track denominators per symbol private symbolDenominators: Map<string, SymbolDenominators> = new Map() // Track denominators per symbol
// Tracks the last bar time (ms) returned by getBars per "symbolKey_periodSeconds".
// bar_updates with time < this watermark are stale and already covered by history.
private lastBarTimes: Map<string, number> = new Map()
constructor() { constructor() {
// Use the shared WebSocket connection (managed by App.vue authentication) // Use the shared WebSocket connection (managed by App.vue authentication)
@@ -118,6 +121,14 @@ export class WebSocketDatafeed implements IBasicDataFeed {
const symbolKey = subscription.symbolInfo.ticker || subscription.symbolInfo.name const symbolKey = subscription.symbolInfo.ticker || subscription.symbolInfo.name
const denoms = this.symbolDenominators.get(symbolKey) || { tick: 1, base: 1 } const denoms = this.symbolDenominators.get(symbolKey) || { tick: 1, base: 1 }
// Drop bars already covered by getBars history to prevent time-order violations
const barTimeMs = message.bar.time * 1000
const barKey = `${symbolKey}_${message.period_seconds}`
const watermark = this.lastBarTimes.get(barKey) ?? 0
if (barTimeMs < watermark) {
return
}
const bar: Bar = { const bar: Bar = {
time: message.bar.time * 1000, // Convert to milliseconds time: message.bar.time * 1000, // Convert to milliseconds
open: parseFloat(message.bar.open) / denoms.tick, open: parseFloat(message.bar.open) / denoms.tick,
@@ -284,6 +295,16 @@ export class WebSocketDatafeed implements IBasicDataFeed {
bars.sort((a, b) => a.time - b.time) bars.sort((a, b) => a.time - b.time)
// Update last-bar watermark so bar_update handler can drop stale replays
if (bars.length > 0) {
const barKey = `${symbolKey}_${intervalToSeconds(resolution)}`
const newLast = bars[bars.length - 1].time
const prevLast = this.lastBarTimes.get(barKey) ?? 0
if (newLast > prevLast) {
this.lastBarTimes.set(barKey, newLast)
}
}
console.log('[TradingView Datafeed] Scaled bar sample:', bars[0]) console.log('[TradingView Datafeed] Scaled bar sample:', bars[0])
const meta: HistoryMetadata = { const meta: HistoryMetadata = {