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
8 changes: 8 additions & 0 deletions .changelog/unreleased/715-live-candle-reconciliation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
type: fixed
area: trade
pr: 715
breaking: false
---

Live charts now reconcile streaming candles with historical data across interval boundaries, reconnects, and late updates without losing the user's viewport.
8 changes: 8 additions & 0 deletions .changelog/unreleased/727-source-driven-leverage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
type: fixed
area: trade
pr: 727
breaking: false
---

Perpetual order leverage and margin estimates now use fresh market risk parameters, with unavailable liquidation estimates clearly marked instead of inferred.
8 changes: 8 additions & 0 deletions .changelog/unreleased/728-source-consistent-fee-preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
type: fixed
area: trade
pr: 728
breaking: false
---

Order previews now distinguish network and execution fees, use explicit depth and slippage status, and avoid presenting unknown liquidity or fee estimates as zero.
8 changes: 8 additions & 0 deletions .changelog/unreleased/729-execution-support-controls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
type: fixed
area: trade
pr: 729
breaking: false
---

Unsupported reduce-only and attached take-profit or stop-loss controls are now gated before signing instead of appearing to create orders.
3 changes: 3 additions & 0 deletions apps/s03-indexer/src/mappings/mappingHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,9 @@ async function handleMarketCreated(event: DecodedEvent): Promise<void> {
marketId: market.id,
key: market.key,
version: parseOptionalNumber(fieldString(event, ["version"], 4)),
maxLeverage: serializeOptional(
event.values.named.max_leverage ?? event.values.named.maxLeverage,
),
rawConfig: JSON.stringify(event.values.named),
ledger: event.ledger,
timestamp: event.timestamp,
Expand Down
3 changes: 3 additions & 0 deletions apps/s03-indexer/tests/mappingHandlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ describe("SO4 event dispatch", () => {
market: marketToken,
creator: account,
name: "TETH/TUSDC",
max_leverage: "25",
});

await dispatchEvent(event);
Expand All @@ -256,6 +257,8 @@ describe("SO4 event dispatch", () => {
expect(market.marketTokenId).toBe(marketToken);
expect(market.status).toBe("ACTIVE");
expect(market.createdBy).toBeDefined();
const [snapshot] = records("MarketConfigSnapshot");
expect(snapshot.maxLeverage).toBe("25");
});

test("indexes market creation with deterministic entity IDs", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
import { cn } from "@workspace/ui/lib/utils"
import { useOracleCandles } from "../../hooks/useOracleCandles"
import { useLiveBar } from "../../hooks/useLiveBar"
import { canIncrementallyApply, mergeBars } from "../../lib/bar-reconciliation"
import type { LiveBarUpdate } from "../../lib/bar-reconciliation"
import { usePositions } from "../../hooks/usePositions"
import {
buildCandleOptions,
Expand Down Expand Up @@ -105,10 +107,25 @@ export function TVChartContainer({ symbol, period }: Props) {
const chartRef = useRef<IChartApi | null>(null)
const seriesRef = useRef<ISeriesApi<"Candlestick"> | null>(null)
const priceLineRefs = useRef<Map<string, IPriceLine>>(new Map())
const displayedBarsRef = useRef<Array<OhlcBar>>([])
const hasRenderedHistoryRef = useRef(false)
// True only after setData() has been called for the current symbol+period.
// Prevents series.update() from firing against an empty or stale series.
const hasDataRef = useRef(false)

const { data: candles = [], isLoading, isError } = useOracleCandles(symbol, period)
const liveValue = useLiveBar(symbol, period) as
| OhlcBar
| LiveBarUpdate
| null
// Keep the consumer tolerant of the old single-bar shape while callers
// migrate to the richer stream/backfill update contract.
const liveUpdate = liveValue && "current" in liveValue
? liveValue
: liveValue
? { current: liveValue, bars: [liveValue], source: "stream" as const }
: null
const liveBar = liveUpdate?.current ?? null
const queryClient = useQueryClient()

const {
Expand Down Expand Up @@ -201,6 +218,8 @@ export function TVChartContainer({ symbol, period }: Props) {
chartRef.current = null
seriesRef.current = null
priceLineRefs.current.clear()
displayedBarsRef.current = []
hasRenderedHistoryRef.current = false
}
}, []) // mount once — symbol/period changes handled by separate effects below

Expand All @@ -211,6 +230,14 @@ export function TVChartContainer({ symbol, period }: Props) {
currentPeriodRef.current = period

if (!seriesRef.current) return
seriesRef.current.setData([])
hasDataRef.current = false
displayedBarsRef.current = []
hasRenderedHistoryRef.current = false
// Remove all price lines — they belong to the previous symbol
priceLineRefs.current.forEach((pl) => seriesRef.current!.removePriceLine(pl))
priceLineRefs.current.clear()
}, [symbol, period])

// On market switch, clear price lines immediately since they belong to the previous market
if (symbolChanged) {
Expand All @@ -230,6 +257,22 @@ export function TVChartContainer({ symbol, period }: Props) {
// ── Load historical candles ────────────────────────────────────────────────
const fittedRef = useRef(false)
useEffect(() => {
if (!seriesRef.current || candles.length === 0) return
const timeScale = chartRef.current?.timeScale()
const visibleRange = hasRenderedHistoryRef.current && timeScale &&
typeof timeScale.getVisibleLogicalRange === "function"
? timeScale.getVisibleLogicalRange()
: null

displayedBarsRef.current = mergeBars([], candles)
seriesRef.current.setData(displayedBarsRef.current.map(toChartBar))
hasDataRef.current = true
if (visibleRange && timeScale && typeof timeScale.setVisibleLogicalRange === "function") {
timeScale.setVisibleLogicalRange(visibleRange)
} else {
chartRef.current?.timeScale().fitContent()
}
hasRenderedHistoryRef.current = true
if (!seriesRef.current) return
if (candles.length === 0) {
seriesRef.current.setData([])
Expand All @@ -250,13 +293,41 @@ export function TVChartContainer({ symbol, period }: Props) {
// Only allowed after historical data is loaded (hasDataRef guards the race
// where a live bar arrives before the first setData call completes).
useEffect(() => {
if (!seriesRef.current || !liveUpdate || !hasDataRef.current) return

const previous = displayedBarsRef.current
const updates = [...liveUpdate.bars].sort((a, b) => a.time - b.time)

// lightweight-charts only accepts update() for the current or next point.
// Older arrivals are late corrections, and gaps indicate reconnect loss.
const canIncrementallyUpdate = canIncrementallyApply(previous, updates, period)
const merged = mergeBars(previous, updates)

if (canIncrementallyUpdate) {
try {
for (const bar of updates) seriesRef.current.update(toChartBar(bar))
displayedBarsRef.current = merged
return
} catch {
// Fall through to a preserving rehydrate if the chart rejected an edge case.
}
}

const timeScale = chartRef.current?.timeScale()
const visibleRange = timeScale && typeof timeScale.getVisibleLogicalRange === "function"
? timeScale.getVisibleLogicalRange()
: null
seriesRef.current.setData(merged.map(toChartBar))
displayedBarsRef.current = merged
if (visibleRange && timeScale && typeof timeScale.setVisibleLogicalRange === "function") {
timeScale.setVisibleLogicalRange(visibleRange)
if (!seriesRef.current || !liveBar || !hasDataRef.current.current) return
try {
seriesRef.current.update(toChartBar(liveBar))
} catch {
// Live bar occasionally arrives out-of-order during rapid switching — safe to ignore
}
}, [liveBar])
}, [liveUpdate, period])

// ── Draw position entry + liquidation price lines ─────────────────────────
useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ vi.mock("@/features/trade/hooks/useTokenPrices", () => ({
}),
}))

vi.mock("../../hooks/useMarketRiskParams", () => ({
useMarketRiskParams: () => ({
params: { maxLeverage: 50, maintenanceMarginRateBps: null, updatedAt: Date.now() },
state: "available",
isLoading: false,
isError: false,
}),
}))

const mockBalances: { data: Record<string, number> | undefined } = { data: undefined }

vi.mock("@/features/wallet/hooks/useTokenBalances", () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { Button } from "@workspace/ui/components/button"
import { Input } from "@workspace/ui/components/input"
import { useTokenPrices } from "../../hooks/useTokenPrices"
import { useMarketRiskParams } from "../../hooks/useMarketRiskParams"
import { createDecreaseOrder, createIncreaseOrder } from "../../lib/stellar"
import { activeQueryNetwork, queryKeys } from "../../lib/query-keys"
import type { Position } from "../../hooks/usePositions"
Expand All @@ -34,6 +35,7 @@ export function CollateralDialog({ position, mode, open, onClose, onSubmit }: Pr
const account = useWalletStore((state) => state.address)
const { data: balances } = useTokenBalances()
const { getMidPrice } = useTokenPrices()
const marketRisk = useMarketRiskParams(position?.marketAddress ?? "")
const queryClient = useQueryClient()

// OB-089: Check whether the OrderVault is a real deployed contract.
Expand Down Expand Up @@ -87,11 +89,18 @@ export function CollateralDialog({ position, mode, open, onClose, onSubmit }: Pr
if (amountNum >= position.collateralAmount) {
isValid = false
validationError = "Cannot remove all collateral (Close position instead)"
} else if (newLeverage > 50) {
} else if (marketRisk.state !== "available") {
isValid = false
validationError = "New leverage exceeds maximum allowed (50x)"
validationError = "Risk parameters unavailable; try again when the market risk data is fresh"
} else if (newLeverage > (marketRisk.params?.maxLeverage ?? 0)) {
isValid = false
validationError = `New leverage exceeds maximum allowed (${marketRisk.params?.maxLeverage}x)`
}
}
if (isValid && marketRisk.state !== "available") {
isValid = false
validationError = "Risk parameters unavailable; try again when the market risk data is fresh"
}
}

async function handleConfirm() {
Expand Down Expand Up @@ -247,6 +256,11 @@ export function CollateralDialog({ position, mode, open, onClose, onSubmit }: Pr
)}
</div>
</div>
{marketRisk.state !== "available" && (
<p role="status" className="text-xs text-muted-foreground">
Source risk parameters are unavailable or stale.
</p>
)}
</div>

{/* Validation & Error Messages */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ vi.mock("../../hooks/useTradeFees", () => ({
}),
}))

vi.mock("../../hooks/useMarketRiskParams", () => ({
useMarketRiskParams: () => ({
params: { maxLeverage: 50, maintenanceMarginRateBps: null, updatedAt: Date.now() },
state: "available",
isLoading: false,
isError: false,
}),
}))

// ── Transaction layer: mocked so no real transaction is ever submitted ───────
vi.mock("../../lib/stellar", () => ({ createSwapOrder, sendBatchOrderTxn }))

Expand Down
Loading
Loading