Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions frontend/src/components/FilterComponents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { useScannerStore } from '../store/scannerStore';
import type { StockUniverse } from '../services/stockApi';

function UniverseSelect() {
const { selectedUniverse, availableUniverses, setSelectedUniverse } = useScannerStore();

return (
<div>
<label className="block text-xs font-mono mb-1" style={{ color: 'var(--text-muted)' }}>
Universe
</label>
<select
value={selectedUniverse}
onChange={(e) => setSelectedUniverse(e.target.value)}
className="w-full px-3 py-2 rounded text-sm font-mono border focus:outline-none focus:ring-1"
style={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border-color)', color: 'var(--text-primary)' }}
>
{availableUniverses.map((universe: StockUniverse) => (
<option key={universe.id} value={universe.name}>
{universe.name} ({universe.symbols.length})
</option>
))}
</select>
</div>
);
}

function FilterInput({ label, value, onChange, min = 0, step = 0.5, max }: {
label: string;
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
}) {
return (
<div>
<label className="block text-xs font-mono mb-1" style={{ color: 'var(--text-muted)' }}>
{label}
</label>
<input
type="number"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full px-3 py-2 rounded text-sm font-mono border focus:outline-none focus:ring-1"
style={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border-color)', color: 'var(--text-primary)' }}
min={min}
max={max}
step={step}
/>
</div>
);
}

function FilterInputs() {
const { filters, updateFilters } = useScannerStore();

return (
<>
<FilterInput
label="Min Price ($)"
value={filters.minPrice}
onChange={(v) => updateFilters({ minPrice: v })}
step={0.5}
/>
<FilterInput
label="Min Volume ($M)"
value={filters.minVolumeDollars / 1000000}
onChange={(v) => updateFilters({ minVolumeDollars: v * 1000000 })}
min={0}
step={1}
/>
<FilterInput
label="Min ADR (%)"
value={filters.minADR}
onChange={(v) => updateFilters({ minADR: v })}
step={0.5}
/>
<FilterInput
label="Min Score (0-20)"
value={filters.minScore}
onChange={(v) => updateFilters({ minScore: v })}
min={0}
max={20}
step={1}
/>
</>
);
}

export { UniverseSelect, FilterInputs, FilterInput };
12 changes: 10 additions & 2 deletions frontend/src/components/ResultsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export default function ResultsTable() {
/>
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-2 animate-pulse">
<div className="h-8 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }} />
<div className="h-8 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }} />
<div className="h-8 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }} />
</div>
</div>
);
}
Expand All @@ -40,8 +45,11 @@ export default function ResultsTable() {
<div className="rounded-lg border p-6" style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border-color)' }}>
<div className="text-center py-8">
<div className="font-mono text-4xl mb-4 opacity-20">[]</div>
<p className="font-mono text-sm" style={{ color: 'var(--text-muted)' }}>
Select a universe and run scan to find momentum stocks
<p className="font-mono text-sm mb-2" style={{ color: 'var(--text-muted)' }}>
No signals found
</p>
<p className="text-xs font-mono" style={{ color: 'var(--text-muted)', opacity: 0.6 }}>
Try lowering the min score or expanding your universe
</p>
</div>
</div>
Expand Down
93 changes: 10 additions & 83 deletions frontend/src/components/ScannerControls.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import { useEffect, useRef, useCallback } from 'react';
import { useScannerStore } from '../store/scannerStore';
import { STOCK_UNIVERSES } from '../services/stockApi';
import { calculateMetrics, filterAndSortResults } from '../utils/calculations';
import { fetchBatchHistoricalData } from '../services/stockApi';
import { fetchBatchHistoricalData, STOCK_UNIVERSES, type StockUniverse } from '../services/stockApi';
import { UniverseSelect, FilterInputs } from './FilterComponents';

export default function ScannerControls() {
const {
selectedUniverse,
setSelectedUniverse,
filters,
updateFilters,
setIsLoading,
setError,
setProgress,
Expand All @@ -18,6 +16,7 @@ export default function ScannerControls() {
error,
results,
resetScan,
loadUniverses,
} = useScannerStore();

const hasAutoScanned = useRef(false);
Expand All @@ -27,7 +26,7 @@ export default function ScannerControls() {
setIsLoading(true);

try {
const universe = STOCK_UNIVERSES.find(u => u.name === selectedUniverse);
const universe = STOCK_UNIVERSES.find((u: StockUniverse) => u.name === selectedUniverse);
if (!universe) {
throw new Error('Please select a valid stock universe');
}
Expand Down Expand Up @@ -60,6 +59,10 @@ export default function ScannerControls() {
}
}, [selectedUniverse, filters, resetScan, setIsLoading, setError, setProgress, setResults]);

useEffect(() => {
loadUniverses();
}, [loadUniverses]);

useEffect(() => {
if (!hasAutoScanned.current && results.length === 0 && !isLoading) {
hasAutoScanned.current = true;
Expand All @@ -82,84 +85,8 @@ export default function ScannerControls() {
</div>

<div className="space-y-4">
<div>
<label className="block text-xs font-mono mb-1" style={{ color: 'var(--text-muted)' }}>
Universe
</label>
<select
value={selectedUniverse}
onChange={(e) => setSelectedUniverse(e.target.value)}
className="w-full px-3 py-2 rounded text-sm font-mono border focus:outline-none focus:ring-1"
style={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border-color)', color: 'var(--text-primary)' }}
>
{STOCK_UNIVERSES.map((universe) => (
<option key={universe.name} value={universe.name}>
{universe.name} ({universe.symbols.length})
</option>
))}
</select>
</div>

<div>
<label className="block text-xs font-mono mb-1" style={{ color: 'var(--text-muted)' }}>
Min Price ($)
</label>
<input
type="number"
value={filters.minPrice}
onChange={(e) => updateFilters({ minPrice: Number(e.target.value) })}
className="w-full px-3 py-2 rounded text-sm font-mono border focus:outline-none focus:ring-1"
style={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border-color)', color: 'var(--text-primary)' }}
min="0"
step="0.5"
/>
</div>

<div>
<label className="block text-xs font-mono mb-1" style={{ color: 'var(--text-muted)' }}>
Min Volume ($M)
</label>
<input
type="number"
value={filters.minVolumeDollars / 1000000}
onChange={(e) => updateFilters({ minVolumeDollars: Number(e.target.value) * 1000000 })}
className="w-full px-3 py-2 rounded text-sm font-mono border focus:outline-none focus:ring-1"
style={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border-color)', color: 'var(--text-primary)' }}
min="0"
step="1"
/>
</div>

<div>
<label className="block text-xs font-mono mb-1" style={{ color: 'var(--text-muted)' }}>
Min ADR (%)
</label>
<input
type="number"
value={filters.minADR}
onChange={(e) => updateFilters({ minADR: Number(e.target.value) })}
className="w-full px-3 py-2 rounded text-sm font-mono border focus:outline-none focus:ring-1"
style={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border-color)', color: 'var(--text-primary)' }}
min="0"
step="0.5"
/>
</div>

<div>
<label className="block text-xs font-mono mb-1" style={{ color: 'var(--text-muted)' }}>
Min Score (0-20)
</label>
<input
type="number"
value={filters.minScore}
onChange={(e) => updateFilters({ minScore: Number(e.target.value) })}
className="w-full px-3 py-2 rounded text-sm font-mono border focus:outline-none focus:ring-1"
style={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border-color)', color: 'var(--text-primary)' }}
min="0"
max="20"
step="1"
/>
</div>
<UniverseSelect />
<FilterInputs />

<button
onClick={handleRunScan}
Expand Down
49 changes: 47 additions & 2 deletions frontend/src/services/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ export interface CachedStockData {
expiresAt: number;
}

export interface CachedUniverse {
id: string;
name: string;
symbols: string[];
isDefault: boolean;
createdAt: number;
updatedAt: number;
}

export interface CacheStats {
hits: number;
misses: number;
Expand All @@ -25,13 +34,15 @@ export interface CacheStats {

class StockCacheDB extends Dexie {
stockData!: Table<CachedStockData, string>;
universes!: Table<CachedUniverse, string>;
private stats: CacheStats = { hits: 0, misses: 0, totalCached: 0 };

constructor() {
super('QullamaggieScannerCache');

this.version(1).stores({
stockData: 'symbol, timestamp, expiresAt'
this.version(2).stores({
stockData: 'symbol, timestamp, expiresAt',
universes: 'id, name'
});
}

Expand Down Expand Up @@ -124,6 +135,40 @@ class StockCacheDB extends Dexie {
resetStats(): void {
this.stats = { hits: 0, misses: 0, totalCached: this.stats.totalCached };
}

async getSavedUniverses(): Promise<CachedUniverse[]> {
try {
return await this.universes.toArray();
} catch (error) {
console.error('[Cache] Error getting universes:', error);
return [];
}
}

async saveUniverse(universe: Omit<CachedUniverse, 'createdAt' | 'updatedAt'>): Promise<void> {
try {
const now = Date.now();
const existing = await this.universes.get(universe.id);

await this.universes.put({
...universe,
createdAt: existing?.createdAt || now,
updatedAt: now
});
console.log(`[Cache] SAVED universe: ${universe.name}`);
} catch (error) {
console.error('[Cache] Error saving universe:', error);
}
}

async deleteUniverse(id: string): Promise<void> {
try {
await this.universes.delete(id);
console.log(`[Cache] DELETED universe: ${id}`);
} catch (error) {
console.error('[Cache] Error deleting universe:', error);
}
}
}

// Singleton instance
Expand Down
Loading
Loading