/
CountZero
/
quik-cscalp-lua
Обзор
Документация
Войти
/
CountZero
/
quik-cscalp-lua
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
qsconfig.lua
465 строк
15 KB
CountZero
Update
05 авг 2026, 20:59
05 авг 2026, 20:59
500bdaf
Код
Авторство
О чём код?
-- Compact, fail-closed configuration for the CScalp <-> QUIK bridge. -- This module has no QLua side effects and can be tested outside QUIK. local json = require("dkjson") local M = {} local ROOT_FIELDS = { visible_account = true, accounts = true, clear_managed_all_trades_filters_on_startup = true, } local ROUTE_FIELDS = { trade_account = true, client_code = true, order_client_code = true, } local function trim(value) if type(value) ~= "string" then return value end return (value:gsub("^%s+", ""):gsub("%s+$", "")) end local function non_empty_string(value) return type(value) == "string" and trim(value) ~= "" end local function has_outer_whitespace(value) return type(value) == "string" and value ~= trim(value) end local function sorted_keys(value) local result = {} for key, _ in pairs(value or {}) do result[#result + 1] = key end table.sort(result) return result end local function skip_json_whitespace(text, position) local length = #text while position <= length and text:sub(position, position):match("%s") do position = position + 1 end return position end -- dkjson correctly validates JSON, but (like ordinary Lua tables) cannot -- retain duplicate object keys. Scan the already validated input so a typo -- such as two TQBR entries cannot silently select the last one. local function detect_duplicate_object_keys(text) local position = 1 local length = #text local parse_value local function parse_string() local start_position = position if text:sub(position, position) ~= '"' then return nil, "expected a JSON string at byte " .. position end position = position + 1 while position <= length do local character = text:sub(position, position) if character == '"' then position = position + 1 local token = text:sub(start_position, position - 1) local value, _, decode_error = json.decode(token, 1, json.null) if decode_error then return nil, decode_error end return value elseif character == "\\" then position = position + 2 else position = position + 1 end end return nil, "unterminated JSON string at byte " .. start_position end local function parse_array(depth) position = position + 1 position = skip_json_whitespace(text, position) if text:sub(position, position) == "]" then position = position + 1 return true end while position <= length do local ok, parse_error = parse_value(depth + 1) if not ok then return nil, parse_error end position = skip_json_whitespace(text, position) local delimiter = text:sub(position, position) if delimiter == "]" then position = position + 1 return true elseif delimiter ~= "," then return nil, "expected ',' or ']' at byte " .. position end position = skip_json_whitespace(text, position + 1) end return nil, "unterminated JSON array" end local function parse_object(depth) position = position + 1 position = skip_json_whitespace(text, position) if text:sub(position, position) == "}" then position = position + 1 return true end local keys = {} while position <= length do local key, key_error = parse_string() if key == nil then return nil, key_error end if keys[key] then return nil, "duplicate JSON object key '" .. tostring(key) .. "'" end keys[key] = true position = skip_json_whitespace(text, position) if text:sub(position, position) ~= ":" then return nil, "expected ':' at byte " .. position end position = skip_json_whitespace(text, position + 1) local ok, parse_error = parse_value(depth + 1) if not ok then return nil, parse_error end position = skip_json_whitespace(text, position) local delimiter = text:sub(position, position) if delimiter == "}" then position = position + 1 return true elseif delimiter ~= "," then return nil, "expected ',' or '}' at byte " .. position end position = skip_json_whitespace(text, position + 1) end return nil, "unterminated JSON object" end parse_value = function(depth) if depth > 64 then return nil, "JSON nesting exceeds 64 levels" end position = skip_json_whitespace(text, position) local character = text:sub(position, position) if character == "{" then return parse_object(depth) elseif character == "[" then return parse_array(depth) elseif character == '"' then local _, string_error = parse_string() return string_error == nil, string_error elseif character == "" then return nil, "unexpected end of JSON input" end local start_position = position while position <= length do character = text:sub(position, position) if character == "," or character == "]" or character == "}" or character:match("%s") then break end position = position + 1 end if position == start_position then return nil, "expected a JSON value at byte " .. position end return true end local ok, parse_error = parse_value(0) if not ok then return nil, parse_error end position = skip_json_whitespace(text, position) if position <= length then return nil, "unexpected JSON content at byte " .. position end return true end local function unknown_field(value, allowed, prefix) for key, _ in pairs(value or {}) do if type(key) ~= "string" or not allowed[key] then return (prefix or "") .. tostring(key) end end return nil end local function strip_trailing_slashes(value) return (tostring(value or ""):gsub("/+$", "")) end local function register_alias(config, alias, route) if not non_empty_string(alias) then return true end local key = trim(alias) local existing = config.routes_by_alias[key] if existing and existing ~= route then return nil, "ambiguous real identifier '" .. key .. "' is used by classes " .. existing.class_code .. " and " .. route.class_code end config.routes_by_alias[key] = route return true end local function register_route_aliases(config, route) local candidates = { route.trade_account, route.client_code, route.order_client_code, } for i = 1, #candidates do local candidate = candidates[i] local base = strip_trailing_slashes(candidate) local ok, err = register_alias(config, candidate, route) if not ok then return nil, err end ok, err = register_alias(config, base, route) if not ok then return nil, err end ok, err = register_alias(config, base .. "/", route) if not ok then return nil, err end end return true end function M.validate(raw, source_path) if type(raw) ~= "table" then return nil, "configuration root must be a JSON object" end local field = unknown_field(raw, ROOT_FIELDS, "") if field then return nil, "unknown configuration field: " .. field end if not non_empty_string(raw.visible_account) then return nil, "visible_account must be a non-empty string" end if has_outer_whitespace(raw.visible_account) then return nil, "visible_account must not contain leading or trailing " .. "whitespace" end if type(raw.accounts) ~= "table" then return nil, "accounts must be a JSON object" end if raw.clear_managed_all_trades_filters_on_startup ~= nil and type(raw.clear_managed_all_trades_filters_on_startup) ~= "boolean" then return nil, "clear_managed_all_trades_filters_on_startup must be boolean" end local config = { visible_account = trim(raw.visible_account), virtual_brokerref = trim(raw.visible_account) .. "/", clear_managed_all_trades_filters_on_startup = raw.clear_managed_all_trades_filters_on_startup == true, routes_by_class = {}, routes_by_alias = {}, class_set = {}, classes = {}, source_path = source_path, } local normalized_seen = {} for original_class, raw_route in pairs(raw.accounts) do if type(original_class) ~= "string" or trim(original_class) == "" then return nil, "accounts contains an empty or non-string class code" end local class_code = string.upper(trim(original_class)) if normalized_seen[class_code] then return nil, "duplicate class after normalization: " .. class_code end normalized_seen[class_code] = true if type(raw_route) ~= "table" then return nil, "accounts." .. original_class .. " must be a JSON object" end field = unknown_field(raw_route, ROUTE_FIELDS, "accounts." .. original_class .. ".") if field then return nil, "unknown configuration field: " .. field end if not non_empty_string(raw_route.trade_account) then return nil, "accounts." .. original_class .. ".trade_account must be a non-empty string" end if has_outer_whitespace(raw_route.trade_account) then return nil, "accounts." .. original_class .. ".trade_account must not contain leading or trailing " .. "whitespace" end if not non_empty_string(raw_route.client_code) then return nil, "accounts." .. original_class .. ".client_code must be a non-empty string" end if has_outer_whitespace(raw_route.client_code) then return nil, "accounts." .. original_class .. ".client_code must not contain leading or trailing " .. "whitespace" end if raw_route.order_client_code ~= nil and not non_empty_string(raw_route.order_client_code) then return nil, "accounts." .. original_class .. ".order_client_code must be a non-empty string when specified" end if raw_route.order_client_code ~= nil and has_outer_whitespace(raw_route.order_client_code) then return nil, "accounts." .. original_class .. ".order_client_code must not contain leading or trailing " .. "whitespace" end local route = { class_code = class_code, trade_account = trim(raw_route.trade_account), client_code = trim(raw_route.client_code), order_client_code = raw_route.order_client_code ~= nil and trim(raw_route.order_client_code) or trim(raw_route.client_code), } config.routes_by_class[class_code] = route config.class_set[class_code] = true config.classes[#config.classes + 1] = class_code end if #config.classes == 0 then return nil, "accounts must contain at least one route" end table.sort(config.classes) for i = 1, #config.classes do local route = config.routes_by_class[config.classes[i]] local ok, err = register_route_aliases(config, route) if not ok then return nil, err end end for i = 1, #config.classes do local route = config.routes_by_class[config.classes[i]] if route.trade_account == config.visible_account then config.cash_class = route.class_code break end end config.classes_csv = table.concat(config.classes, ",") config.classes_pipe = "|" .. table.concat(config.classes, "|") .. "|" local fingerprint_parts = { config.visible_account } for i = 1, #config.classes do local route = config.routes_by_class[config.classes[i]] fingerprint_parts[#fingerprint_parts + 1] = route.class_code fingerprint_parts[#fingerprint_parts + 1] = route.trade_account fingerprint_parts[#fingerprint_parts + 1] = route.client_code fingerprint_parts[#fingerprint_parts + 1] = route.order_client_code end config.fingerprint = table.concat(fingerprint_parts, "\31") return config end function M.decode(text, source_path) if type(text) ~= "string" then return nil, "configuration content must be a string" end if text:sub(1, 3) == "\239\187\191" then text = text:sub(4) end local ok, value, _, decode_error = pcall(json.decode, text, 1, json.null) if not ok then return nil, "JSON decoder failed: " .. tostring(value) end if decode_error then return nil, "invalid JSON: " .. tostring(decode_error) end local unique, duplicate_error = detect_duplicate_object_keys(text) if not unique then return nil, "invalid JSON: " .. tostring(duplicate_error) end return M.validate(value, source_path) end local function join_path(base, name) local text = tostring(base or "") if text == "" then return name end local last = text:sub(-1) if last == "\\" or last == "/" then return text .. name end return text .. "\\" .. name end function M.canonical_path() if type(_G.getWorkingFolder) ~= "function" then return nil, "getWorkingFolder is unavailable; canonical QUIK " .. "configuration path cannot be determined" end local ok, value = pcall(_G.getWorkingFolder) if not ok then return nil, "getWorkingFolder failed: " .. tostring(value) end if not non_empty_string(value) then return nil, "getWorkingFolder returned an empty path" end return join_path(join_path(trim(value), "cscalp"), "qsaccount_adapter_config.json") end function M.load(path) local source_path = path if not source_path then local path_error source_path, path_error = M.canonical_path() if not source_path then return nil, path_error end end local file, open_error = io.open(source_path, "rb") if not file then return nil, "cannot open configuration '" .. tostring(source_path) .. "': " .. tostring(open_error) end local ok, text = pcall(file.read, file, "*a") pcall(file.close, file) if not ok then return nil, "cannot read configuration '" .. tostring(source_path) .. "': " .. tostring(text) end local config, validation_error = M.decode(text, source_path) if not config then return nil, "configuration '" .. tostring(source_path) .. "' is invalid: " .. tostring(validation_error) end return config end function M.sorted_keys(value) return sorted_keys(value) end return M