From 703d30b6ab772180160d0ad30d7e81a068c3f7ca Mon Sep 17 00:00:00 2001 From: MuslemRahimi Date: Mon, 9 Dec 2024 13:05:59 +0100 Subject: [PATCH] update screener of options flow page --- src/routes/options-flow/+page.svelte | 343 ++++++++++++++---- .../options-flow/workers/filterWorker.ts | 232 ++++++++---- src/routes/stock-screener/+page.svelte | 2 +- 3 files changed, 436 insertions(+), 141 deletions(-) diff --git a/src/routes/options-flow/+page.svelte b/src/routes/options-flow/+page.svelte index cbd8501e..26102ffa 100644 --- a/src/routes/options-flow/+page.svelte +++ b/src/routes/options-flow/+page.svelte @@ -44,7 +44,7 @@ const allRules = { volume: { label: "Volume", - step: ["100K", "10K", "1K"], + step: ["100K", "50K", "20K", "10K", "5K", "2K", "1K", "100", "0"], defaultCondition: "over", defaultValue: "any", }, @@ -133,7 +133,13 @@ Object.keys(allRules).forEach((ruleName) => { ruleCondition[ruleName] = allRules[ruleName].defaultCondition; - valueMappings[ruleName] = allRules[ruleName].defaultValue; + + // Check if the default condition is "between" + if (allRules[ruleName].defaultCondition === "between") { + valueMappings[ruleName] = allRules[ruleName].defaultValue || [null, null]; + } else { + valueMappings[ruleName] = allRules[ruleName].defaultValue; + } }); // Update ruleCondition and valueMappings based on existing rules @@ -277,9 +283,17 @@ //console.log(displayedData) }; - function changeRuleCondition(name: string, state: string) { + async function changeRuleCondition(name: string, state: string) { ruleName = name; - ruleCondition[ruleName] = state; + if ( + ruleCondition[ruleName] === "between" && + ["over", "under"]?.includes(state?.toLowerCase()) + ) { + valueMappings[ruleName] = ""; + } + ruleCondition[ruleName] = state?.toLowerCase(); + + await handleChangeValue(valueMappings[ruleName]); } let checkedItems = new Set(ruleOfList.flatMap((rule) => rule.value)); @@ -288,12 +302,43 @@ return checkedItems.has(item); } + // Utility function to convert values to comparable numbers + // Utility function to convert values to comparable numbers + function parseValue(val) { + if (typeof val === "string") { + // Handle percentage values + if (val.endsWith("%")) { + return parseFloat(val); + } + // Handle values with suffixes like K (thousand), M (million), B (billion) + const suffixMap = { + K: 1e3, + M: 1e6, + B: 1e9, + }; + const suffix = val.slice(-1).toUpperCase(); + const numberPart = parseFloat(val); + if (suffix in suffixMap) { + return numberPart * suffixMap[suffix]; + } + } + return parseFloat(val); + } + + // Custom sorting function + function customSort(a, b) { + return parseValue(a) - parseValue(b); + } + async function handleChangeValue(value) { + // Toggle checkedItems logic if (checkedItems.has(value)) { checkedItems.delete(value); } else { checkedItems.add(value); } + + // Specific rule handling for options-related rules if ( [ "put_call", @@ -306,29 +351,37 @@ ) { // Ensure valueMappings[ruleName] is initialized as an array if (!Array.isArray(valueMappings[ruleName])) { - valueMappings[ruleName] = []; // Initialize as an empty array if not already + valueMappings[ruleName] = []; } + // Similar logic to the original function for adding/removing values const index = valueMappings[ruleName].indexOf(value); if (index === -1) { - // Add the country if it's not already selected valueMappings[ruleName].push(value); + // Sort the array when a new value is added + valueMappings[ruleName] = valueMappings[ruleName].sort(customSort); } else { - // Remove the country if it's already selected (deselect) valueMappings[ruleName].splice(index, 1); } - // If no countries are selected, set value to "any" + // Set to "any" if no values are selected if (valueMappings[ruleName].length === 0) { valueMappings[ruleName] = "any"; } } else if (ruleName in valueMappings) { - // Handle non-country rules as single values - valueMappings[ruleName] = value; + // For rules that require sorting (like range or numeric values) + if (ruleCondition[ruleName] === "between" && Array.isArray(value)) { + // Sort the array for between conditions + valueMappings[ruleName] = value.sort(customSort); + } else { + // Handle non-specific rules as single values + valueMappings[ruleName] = value; + } } else { console.warn(`Unhandled rule: ${ruleName}`); } + // Update ruleOfList (if applicable) const ruleToUpdate = ruleOfList?.find((rule) => rule.name === ruleName); if (ruleToUpdate) { ruleToUpdate.value = valueMappings[ruleToUpdate.name]; @@ -336,10 +389,40 @@ ruleOfList = [...ruleOfList]; } + // Trigger worker load and save cookie shouldLoadWorker.set(true); await saveCookieRuleOfList(); } + async function stepSizeValue(value, condition) { + const match = value.toString().match(/^(-?[\d.]+)([KMB%]?)$/); + if (!match) return value; + + let [_, number, suffix] = match; + number = parseFloat(number); + + let step = 1; + + number += condition === "add" ? step : -step; + + // Round to 2 decimal places for consistency + number = parseFloat(number?.toFixed(2)); + const newValue = suffix ? `${number}${suffix}` : Math?.round(number); + await handleChangeValue(newValue); + } + + async function handleValueInput(event, ruleName, index = null) { + const newValue = event.target.value; + + if (ruleCondition[ruleName] === "between") { + const currentValues = valueMappings[ruleName] || ["", ""]; + currentValues[index] = newValue; + await handleChangeValue(currentValues); + } else { + await handleChangeValue(newValue); + } + } + const nyseDate = new Date( data?.getOptionsFlowFeed?.at(0)?.date ?? null, )?.toLocaleString("en-US", { @@ -1272,10 +1355,14 @@ function sendMessage(message) { {#if valueMappings[row?.rule] === "any"} Any + {:else if ruleCondition[row?.rule] === "between"} + {Array.isArray(valueMappings[row?.rule]) + ? `${valueMappings[row?.rule][0]}-${valueMappings[row?.rule][1] ?? "Any"}` + : "Any"} {:else} - {ruleCondition[row?.rule] !== undefined - ? ruleCondition[row?.rule] - : ""} + {ruleCondition[row?.rule] + ?.replace("under", "Under") + ?.replace("over", "Over") ?? ""} {valueMappings[row?.rule]} {/if} @@ -1295,7 +1382,7 @@ function sendMessage(message) { {#if !["put_call", "sentiment", "execution_estimate", "option_activity_type", "date_expiration", "underlying_type"]?.includes(row?.rule)} +
- - + + + + + + {#each ["Over", "Under", "Between"] as item} + + changeRuleCondition( + row?.rule, + item, + )} + class="cursor-pointer text-[1rem] font-normal" + >{item} + {/each} + + +
+ + {#if ruleCondition[row?.rule] === "between"} +
+ + handleValueInput(e, row?.rule, 0)} + class="ios-zoom-fix block max-w-[3.5rem] rounded-sm placeholder:text-gray-200 font-normal p-1 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-secondary" + /> + + & + + + handleValueInput(e, row?.rule, 1)} + class="ios-zoom-fix block max-w-[3.5rem] rounded-sm placeholder:text-gray-200 font-normal p-1 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-secondary" + /> +
+ {:else} + + handleValueInput(e, row?.rule)} + class="ios-zoom-fix block max-w-[4.8rem] rounded-sm placeholder:text-gray-200 font-normal p-1 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-secondary" + /> + {/if} + + {#if ["over", "under"]?.includes(ruleCondition[ruleName]?.toLowerCase())} +
+ + +
+ {/if} +
{:else} @@ -1352,22 +1545,48 @@ function sendMessage(message) { {/if} {#if !["put_call", "sentiment", "execution_estimate", "option_activity_type", "date_expiration", "underlying_type"]?.includes(row?.rule)} - {#each row?.step as newValue} - - + + {/if} + {:else} + - {ruleCondition[row?.rule] !== undefined - ? ruleCondition[row?.rule] - : ""} - {newValue} - - + + + {/if} {/each} {:else if ["put_call", "sentiment", "execution_estimate", "option_activity_type", "date_expiration", "underlying_type"]?.includes(row?.rule)} {#each row?.step as item} diff --git a/src/routes/options-flow/workers/filterWorker.ts b/src/routes/options-flow/workers/filterWorker.ts index cdb2954d..af5231fc 100644 --- a/src/routes/options-flow/workers/filterWorker.ts +++ b/src/routes/options-flow/workers/filterWorker.ts @@ -136,97 +136,173 @@ function isDateWithinRange(dateString: string, range: string): boolean { } async function filterRawData(rawData, ruleOfList, filterQuery) { - // Split filterQuery into an array of tickers if it's a comma-separated string + // Early return for empty inputs + if (!rawData?.length || !ruleOfList?.length) { + return rawData || []; + } + + // Preprocess filter tickers const filterTickers = filterQuery ? filterQuery.split(",").map((ticker) => ticker.trim().toUpperCase()) : []; - return rawData?.filter((item) => { - // Check if the item's ticker matches any of the tickers in filterTickers - if ( - filterTickers.length > 0 && - !filterTickers.includes(item.ticker.toUpperCase()) - ) { - return false; // Exclude if the ticker doesn't match any in filterTickers + // Precompile rules for more efficient filtering + const compiledRules = ruleOfList.map(rule => { + const ruleName = rule.name.toLowerCase(); + const ruleValue = convertUnitToValue(rule.value); + + return { + ...rule, + compiledCheck: createRuleCheck(rule, ruleName, ruleValue) + }; + }); + + // Optimized filtering with precompiled rules + return rawData.filter(item => { + // Early ticker filtering + if (filterTickers.length > 0 && + !filterTickers.includes(item.ticker.toUpperCase())) { + return false; } - return ruleOfList.every((rule) => { - const ruleName = rule.name.toLowerCase(); - const ruleValue = convertUnitToValue(rule.value); - - // Handle volumeOIRatio - if (ruleName === "volumeoiratio") { - const volume = parseFloat(item.volume); - const openInterest = parseFloat(item.open_interest); - - if (isNaN(volume) || isNaN(openInterest) || openInterest === 0) { - return false; // Invalid data, exclude this item - } - - const ratio = (volume / openInterest) * 100; - - if (rule.condition === "over" && ratio <= ruleValue) return false; - if (rule.condition === "under" && ratio >= ruleValue) return false; - return true; - } - - const itemValue = item[rule.name]; - - // Handle date_expiration - if (ruleName === "date_expiration") { - if (isAny(ruleValue)) return true; - if (Array.isArray(ruleValue)) { - return ruleValue.some((range) => isDateWithinRange(itemValue, range)); - } - return isDateWithinRange(itemValue, ruleValue as string); - } - - // Handle categorical data - else if ( - [ - "put_call", - "sentiment", - "execution_estimate", - "option_activity_type", - "underlying_type", - ].includes(ruleName) - ) { - if (isAny(ruleValue)) return true; - if (itemValue === null || itemValue === undefined) return false; - - const lowerItemValue = itemValue.toString().toLowerCase(); - - if (Array.isArray(ruleValue)) { - return ruleValue.some( - (value) => lowerItemValue === value.toString().toLowerCase(), - ); - } - - return lowerItemValue === ruleValue.toString().toLowerCase(); - } - - // Default numeric or string comparison - if (typeof ruleValue === "string") return true; - if (itemValue === null || itemValue === undefined) return false; - const numericItemValue = parseFloat(itemValue); - if (isNaN(numericItemValue)) return false; - - if (rule.condition === "over" && numericItemValue <= ruleValue) - return false; - if (rule.condition === "under" && numericItemValue >= ruleValue) - return false; - return true; - }); + // Apply all precompiled rules + return compiledRules.every(rule => rule.compiledCheck(item)); }); } +// Centralized rule checking logic +function createRuleCheck(rule, ruleName, ruleValue) { + // Handle volumeOIRatio + if (ruleName === 'volumeoiratio') { + return (item) => { + const volume = parseFloat(item.volume); + const openInterest = parseFloat(item.open_interest); + + if (isNaN(volume) || isNaN(openInterest) || openInterest === 0) { + return false; + } + + const ratio = (volume / openInterest) * 100; + + if (rule.condition === 'over' && ratio <= ruleValue) return false; + if (rule.condition === 'under' && ratio >= ruleValue) return false; + return true; + }; + } + + // Handle "between" condition + if (rule.condition === 'between') { + return (item) => { + const itemValue = parseFloat(item[rule.name]); + + // Handle array of ruleValue for between condition + if (!Array.isArray(ruleValue)) return true; + + // Convert rule values, ensuring they are valid + const [min, max] = ruleValue.map(convertUnitToValue); + + // Handle the case where one or both values are missing (empty string or undefined) + if ((min === '' || min === undefined || min === null) && + (max === '' || max === undefined || max === null)) { + return true; // If both values are empty or undefined, consider the condition as met (open-ended) + } + + // If only one of min or max is missing, handle it as open-ended + if (min === '' || min === undefined || min === null) { + return itemValue <= max; // If min is missing, only check against max + } + + if (max === '' || max === undefined || max === null) { + return itemValue >= min; // If max is missing, only check against min + } + + // If both min and max are defined, proceed with the normal comparison + return itemValue > min && itemValue < max; + }; + } + + // Handle date_expiration + if (ruleName === 'date_expiration') { + // If 'any' is specified or no specific range is set + if (isAny(ruleValue)) return () => true; + + return (item) => { + if (Array.isArray(ruleValue)) { + return ruleValue.some(range => isDateWithinRange(item[rule.name], range)); + } + return isDateWithinRange(item[rule.name], ruleValue); + }; + } + + // Categorical data handling + const categoricalFields = [ + 'put_call', + 'sentiment', + 'execution_estimate', + 'option_activity_type', + 'underlying_type' + ]; + + if (categoricalFields.includes(ruleName)) { + // If 'any' is specified + if (isAny(ruleValue)) return () => true; + + return (item) => { + const itemValue = item[rule.name]; + + // Handle null or undefined + if (itemValue === null || itemValue === undefined) return false; + + const lowerItemValue = itemValue.toString().toLowerCase(); + + // Handle array of values + if (Array.isArray(ruleValue)) { + return ruleValue.some( + value => lowerItemValue === value.toString().toLowerCase() + ); + } + + // Single value comparison + return lowerItemValue === ruleValue.toString().toLowerCase(); + }; + } + + // Default numeric comparison + return (item) => { + const itemValue = item[rule.name]; + + // Skip string rule values + if (typeof ruleValue === 'string') return true; + + // Handle null or undefined + if (itemValue === null || itemValue === undefined) return false; + + const numericItemValue = parseFloat(itemValue); + + // Invalid numeric conversion + if (isNaN(numericItemValue)) return false; + + // Comparison conditions + if (rule.condition === 'over' && numericItemValue <= ruleValue) return false; + if (rule.condition === 'under' && numericItemValue >= ruleValue) return false; + + return true; + }; +} + +// Web Worker message handler onmessage = async (event: MessageEvent) => { const { rawData, ruleOfList, filterQuery } = event.data || {}; + + // Filter the data let filteredData = await filterRawData(rawData, ruleOfList, filterQuery); - filteredData = Array?.from( - new Map(filteredData?.map((item) => [item?.id, item]))?.values(), + + // Remove duplicates based on id + filteredData = Array.from( + new Map(filteredData?.map((item) => [item?.id, item]))?.values() ); + postMessage({ message: "success", filteredData }); }; -export {}; +export {}; \ No newline at end of file diff --git a/src/routes/stock-screener/+page.svelte b/src/routes/stock-screener/+page.svelte index 42166e36..06215767 100644 --- a/src/routes/stock-screener/+page.svelte +++ b/src/routes/stock-screener/+page.svelte @@ -2814,7 +2814,7 @@ const handleKeyDown = (event) => { {#if !["sma20", "sma50", "sma100", "sma200", "ema20", "ema50", "ema100", "ema200", "grahamNumber", "analystRating", "halalStocks", "score", "sector", "industry", "country"]?.includes(row?.rule)}