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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ One-contract cash-secured put:

The Contracts field is a whole number of 100-share lots. Covered-call stock cost, premium, net outlay, and called P&L are multiplied by that count. CSP premium, collateral, and net collateral are multiplied. Bid, ask, volume, APRs, drops/cushions, effective cost, called P&L / sh, breakeven, Greeks, and low comparisons stay one-contract. Leave Contracts empty to keep the one-contract view. `0`, negatives, decimals, values too large for exact integer calculations, or other invalid text show validation and fall back to one contract.

Filters start closed. Optional minimums for Called P&L (calls) or Premium (puts), **APR net**, and **drop/cushion to breakeven**, plus an inclusive Min DTE and Max DTE range, combine with AND. Values compare directly with the API's scaled integers. Enter percent points (`40` means 40%). Invalid tokens show inline validation and are otherwise ignored; null metrics fail only their active filter. An inverted DTE range matches nothing. Active filters appear as individually removable chips, and Clear all restores the full chain and does not clear Contracts.
Filters start closed. Optional minimums for Called P&L (calls) or Premium (puts), **APR net**, and **drop/cushion to breakeven**, plus an inclusive Min DTE and Max DTE range, combine with AND. Values compare directly with the API's scaled integers. Enter percent points (`40` means 40%). Invalid tokens show inline validation and are otherwise ignored; null metrics fail only their active filter. An inverted DTE range matches nothing and asks you to fix the range. Valid filters count as applied; invalid entries show a separate **Fix** count. Each entry has its own clearable chip, and Clear all restores the full chain and does not clear Contracts.

Calls or puts with side open interest below 5, or with missing OI, are omitted.

Expand Down
4 changes: 4 additions & 0 deletions frontend/src/App.routes.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ it("keeps the live chain query on the option chain link", () => {
expect(href).toContain("t=CIFR")
expect(href).toContain("side=put")
expect(href).toContain("m=otm")
expect(document.title).toBe("Option chain · HyperOptions")
})

it("restores that chain query after opening the watchlist", async () => {
Expand All @@ -33,10 +34,12 @@ it("restores that chain query after opening the watchlist", async () => {
render(<App />)
fireEvent.click(screen.getByRole("link", { name: "Watchlist" }))
expect(await screen.findByRole("heading", { name: "Watchlist" })).toBeTruthy()
expect(document.title).toBe("Watchlist · HyperOptions")
fireEvent.click(screen.getByRole("link", { name: "Option chain" }))
await waitFor(() => expect(window.location.search).toContain("t=CIFR"))
expect(window.location.search).toContain("side=put")
expect(window.location.search).toContain("m=otm")
expect(document.title).toBe("Option chain · HyperOptions")
})

it.each([
Expand Down Expand Up @@ -68,4 +71,5 @@ it("offers navigation when a route does not exist", () => {
expect(main.textContent).toContain("Page not found")
expect(main.querySelector('a[href="/"]')?.textContent).toContain("option chain")
expect(main.querySelector('a[href="/watchlist"]')?.textContent).toContain("watchlist")
expect(document.title).toBe("Page not found · HyperOptions")
})
6 changes: 5 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { lazy, Suspense, useState } from "react"
import { lazy, Suspense, useEffect, useState } from "react"
import { BrowserRouter, Link, MemoryRouter, Navigate, Route, Routes, useLocation } from "react-router-dom"

import ItmChain from "./ItmChain"
Expand All @@ -10,6 +10,10 @@ function Workspace() {
const location = useLocation()
const [lastChainUrl, setLastChainUrl] = useState("/")
const watchlist = location.pathname === "/watchlist"
useEffect(() => {
const page = location.pathname === "/" ? "Option chain" : watchlist ? "Watchlist" : "Page not found"
document.title = `${page} · HyperOptions`
}, [location.pathname, watchlist])
const liveChainUrl = location.pathname === "/" ? `${location.pathname}${location.search}` : null
if (liveChainUrl !== null && liveChainUrl !== lastChainUrl) setLastChainUrl(liveChainUrl)
const chainUrl = liveChainUrl ?? lastChainUrl
Expand Down
12 changes: 8 additions & 4 deletions frontend/src/FilterControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,12 @@ export default function FilterControls({
key: spec.chipKey,
label: spec.chipLabel,
value: texts[spec.id],
valid: parsedById[spec.id] !== null && !(spec.rowKey === "dte" && invertedDte),
clear: () => onTextChange(spec.id, ""),
}))
.filter((chip) => chip.value.trim() !== "")
const activeCount = chips.filter((chip) => chip.valid).length
const invalidCount = chips.length - activeCount

return (
<Collapsible defaultOpen={false} className="results-controls">
Expand All @@ -115,7 +118,8 @@ export default function FilterControls({
<CollapsibleTrigger className="secondary-button toolbar-button">
<Filter aria-hidden="true" />
Filters
{chips.length > 0 ? <Badge variant="secondary">{chips.length}</Badge> : null}
{activeCount > 0 ? <Badge variant="secondary">{activeCount}</Badge> : null}
{invalidCount > 0 ? <Badge variant="destructive">Fix {invalidCount}</Badge> : null}
<ChevronDown className="chevron" aria-hidden="true" />
</CollapsibleTrigger>
<ColumnPicker side={side} selected={selectedColumns} onChange={onChangeColumns} />
Expand All @@ -141,10 +145,10 @@ export default function FilterControls({
</div>

{chips.length > 0 ? (
<div className="filter-chips" aria-label="Active filters">
<div className="filter-chips" aria-label="Filter entries">
{chips.map((chip) => (
<button key={chip.key} type="button" className="filter-chip" onClick={chip.clear} aria-label={`Remove filter ${chip.label} ${chip.value}`}>
<span>{chip.label} {chip.value}</span>
<button key={chip.key} type="button" className="filter-chip" aria-invalid={!chip.valid || undefined} onClick={chip.clear} aria-label={`${chip.valid ? "Remove filter" : "Clear invalid input"} ${chip.label} ${chip.value}`}>
<span>{chip.valid ? "" : "Invalid: "}{chip.label} {chip.value}</span>
<X aria-hidden="true" />
</button>
))}
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/ItmChain.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -726,11 +726,16 @@ describe("chain interactions", () => {
expect(screen.getAllByText("$50.00")[0]).toBeTruthy()
expect(screen.getByText("$40.50")).toBeTruthy()
expect(screen.getByLabelText("Min APR net (%)").getAttribute("aria-invalid")).toBe("true")
expect(screen.getByRole("button", { name: "Clear invalid input APR net (%) abc" })).toBeTruthy()
expect(screen.queryByRole("button", { name: "Remove filter APR net (%) abc" })).toBeNull()
expect(screen.getByRole("button", { name: /Filters/ }).textContent).toContain("Fix 1")
expect(screen.getByRole("button", { name: "Clear all" })).toBeTruthy()

fireEvent.change(screen.getByLabelText("Min Called P&L ($)"), { target: { value: "0" } })
expect(screen.getAllByText("$50.00")[0]).toBeTruthy()
expect(screen.queryByText("$40.50")).toBeNull()
expect(screen.getByRole("button", { name: "Remove filter Called P&L ($) 0" })).toBeTruthy()
expect(screen.getByRole("button", { name: /Filters/ }).textContent).toContain("Fix 1")
})

it("shows removable active-filter chips and clears filters independently", async () => {
Expand Down Expand Up @@ -850,9 +855,12 @@ describe("chain interactions", () => {
fireEvent.click(screen.getByRole("button", { name: "Expand all" }))
fireEvent.change(screen.getByLabelText("Min DTE"), { target: { value: "40" } })
fireEvent.change(screen.getByLabelText("Max DTE"), { target: { value: "10" } })
expect(screen.getByText("No rows match the current filters.")).toBeTruthy()
expect(screen.getByText("Minimum DTE must not exceed maximum DTE. Fix the range to see results.")).toBeTruthy()
expect(screen.queryByText("No rows match the current filters.")).toBeNull()
expect(screen.getByLabelText("Min DTE").getAttribute("aria-invalid")).toBe("true")
expect(screen.getByLabelText("Max DTE").getAttribute("aria-invalid")).toBe("true")
expect(screen.getByRole("button", { name: "Clear invalid input DTE ≥ 40" })).toBeTruthy()
expect(screen.getByRole("button", { name: "Clear invalid input DTE ≤ 10" })).toBeTruthy()
})

it("copies displayed row values with headers and context, including contract scaling", async () => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/ItmChain.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ export default function ItmChain() {
) : null}
{!loading && view.filterMiss ? (
<div className="empty-state" role="status">
<p>No rows match the current filters.</p>
<p>{view.invertedDte ? "Minimum DTE must not exceed maximum DTE. Fix the range to see results." : "No rows match the current filters."}</p>
</div>
) : null}

Expand Down
1 change: 1 addition & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,7 @@
font-size: .72rem;
}
.filter-chip svg { width: 13px; height: 13px; }
.filter-chip[aria-invalid="true"] { border-color: var(--destructive); color: var(--destructive); }
.filter-panel { padding: 4px 0 12px; }
.filter-cluster {
display: grid;
Expand Down
Loading