Commit 6132f5c0 authored by xeron56's avatar xeron56

Add CUDA device compatibility checks to TimesFM backend

- Implemented a function to check if the current CUDA device is supported by the installed PyTorch version.
- Added warnings for unsupported CUDA devices, informing users that the CPU will be used instead.
- Updated the TimesFMBackend class to utilize the new compatibility checks and handle GPU names appropriately.
- Introduced unit tests to validate the behavior of the CUDA compatibility function with various simulated CUDA environments.
parent 02b92e82
......@@ -86,7 +86,7 @@ DSE_PASSWORD=
#TRADINGAGENTS_SOCIAL_MEDIA_ENABLED=false
#TRADINGAGENTS_MACRO_DATA_ENABLED=false
#TRADINGAGENTS_PREDICTION_MARKETS_ENABLED=false
#TRADINGAGENTS_DSE_GATEWAY_URL=https://gateway.dohasecurities.com.bd
# TRADINGAGENTS_DSE_GATEWAY_URL=https://gateway.dohasecurities.com.bd
#TRADINGAGENTS_DSE_REQUEST_TIMEOUT=30
#TRADINGAGENTS_DSE_VERIFY_SSL=true
#TRADINGAGENTS_DSE_BENCHMARK_TICKER=DSEX
......
This diff is collapsed.
......@@ -79,24 +79,88 @@
<section class="chart-card">
<header>
<div>
<p class="eyebrow">HISTORICAL BACKTEST + FORWARD PATH</p>
<h2>Actual candles against {{ report.model.name }}</h2>
<p class="eyebrow">{{ chartMode() === 'future' ? 'FORWARD FORECAST' : 'HISTORICAL BACKTEST' }}</p>
<h2>
{{ chartMode() === 'future'
? 'Recent actual candles + ' + report.model.name + ' future'
: 'Hidden actuals against ' + report.model.name + ' backtest' }}
</h2>
</div>
<div class="legend" aria-label="Chart legend">
<span><i class="candle"></i>Actual OHLC</span>
<span><i class="backtest"></i>Held-out prediction</span>
<span><i class="future"></i>Future forecast</span>
<span><i class="live"></i>Live actual</span>
<div class="chart-controls">
<div class="chart-mode-switch" role="group" aria-label="Forecast chart view">
<button
type="button"
[attr.aria-pressed]="chartMode() === 'future'"
[class.active]="chartMode() === 'future'"
(click)="selectChartMode('future')"
>Future forecast</button>
<button
type="button"
[attr.aria-pressed]="chartMode() === 'backtest'"
[class.active]="chartMode() === 'backtest'"
(click)="selectChartMode('backtest')"
>Backtest</button>
</div>
<div class="legend" aria-label="Chart legend">
@if (chartMode() === 'future') {
<span><i class="candle"></i>Actual OHLC</span>
<span><i class="future"></i>Future forecast</span>
@if (showFutureInterval()) {
<span><i class="interval future-interval"></i>Q10–Q90</span>
}
<span><i class="live"></i>Live actual</span>
} @else {
<span><i class="actual-line"></i>Actual close</span>
<span><i class="backtest"></i>Backtest prediction</span>
@if (showBacktestInterval()) {
<span><i class="interval"></i>Q10–Q90</span>
}
}
</div>
</div>
</header>
<div #predictionChart class="prediction-chart" role="img" [attr.aria-label]="'DSE actual and ' + report.model.name + ' forecast chart'"></div>
<div class="chart-context-note">
<p>
@if (chartMode() === 'future') {
Uses the latest real prices. The separate historical backtest is excluded from this scale.
} @else {
The model uses the first {{ report.data.context_points | number }} bars as context, then predicts all
{{ report.data.holdout_points | number }} hidden bars. Compare predicted and actual closing prices directly.
}
</p>
<button
type="button"
class="interval-toggle"
[class.active]="intervalVisible()"
[attr.aria-pressed]="intervalVisible()"
(click)="toggleInterval()"
>{{ intervalVisible() ? 'Hide' : 'Show' }} 80% uncertainty</button>
</div>
<div
#predictionChart
id="prediction-chart"
class="prediction-chart"
role="img"
[attr.aria-label]="chartMode() === 'future'
? 'Recent DSE actual prices and ' + report.model.name + ' future forecast chart'
: 'DSE hidden actual prices and ' + report.model.name + ' historical backtest chart'"
></div>
<footer>
<span>Context</span>
<strong>{{ report.data.first_timestamp | date: 'mediumDate' }} → {{ report.history[report.data.context_points - 1].time | date: 'mediumDate' }}</strong>
<span>Hidden test</span>
<strong>{{ report.backtest[0].time | date: 'mediumDate' }} → {{ report.data.last_timestamp | date: 'mediumDate' }}</strong>
<span>Next forecast</span>
<strong>{{ report.future.length }} bars</strong>
@if (chartMode() === 'future') {
<span>Recent actual</span>
<strong>{{ futureHistoryStart(report) | date: 'mediumDate' }} → {{ report.data.last_timestamp | date: 'mediumDate' }}</strong>
<span>Forecast window</span>
<strong>{{ report.future[0].time | date: 'mediumDate' }} → {{ report.future[report.future.length - 1].time | date: 'mediumDate' }}</strong>
<span>Horizon</span>
<strong>{{ report.future.length }} bars</strong>
} @else {
<span>Model context</span>
<strong>{{ report.data.first_timestamp | date: 'mediumDate' }} → {{ report.history[report.data.context_points - 1].time | date: 'mediumDate' }}</strong>
<span>Line comparison</span>
<strong>{{ report.backtest[0].time | date: 'mediumDate' }} → {{ report.data.last_timestamp | date: 'mediumDate' }}</strong>
<span>Scored bars</span>
<strong>{{ report.backtest.length }}</strong>
}
</footer>
</section>
......
......@@ -179,8 +179,73 @@
.legend span { display: inline-flex; align-items: center; gap: 0.35rem; }
.legend i { width: 1.25rem; height: 0.16rem; border-radius: 1rem; background: #34d399; }
.legend .backtest { background: var(--amber); }
.legend .actual-line { background: #34d399; }
.legend .future { background: var(--violet); }
.legend .live { background: var(--cyan); }
.legend .interval {
height: 0;
border-top: 2px dashed rgb(245 158 11 / 65%);
border-radius: 0;
background: transparent;
}
.legend .future-interval { border-color: rgb(167 139 250 / 70%); }
.chart-controls { display: grid; justify-items: end; gap: 0.7rem; }
.chart-mode-switch {
display: inline-grid;
grid-template-columns: repeat(2, 1fr);
padding: 0.2rem;
border: 1px solid #334155;
border-radius: 0.7rem;
background: #0b1220;
}
.chart-mode-switch button,
.interval-toggle {
border: 0;
color: var(--muted);
background: transparent;
font: inherit;
font-size: 0.76rem;
font-weight: 700;
cursor: pointer;
}
.chart-mode-switch button {
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
}
.chart-mode-switch button.active {
color: #f8fafc;
background: #253146;
box-shadow: 0 0 0 1px rgb(148 163 184 / 12%);
}
.chart-mode-switch button:focus-visible,
.interval-toggle:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: 2px;
}
.chart-context-note {
display: flex;
min-height: 3rem;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.65rem 1.25rem;
border-bottom: 1px solid var(--line);
color: var(--muted);
background: #0d1420;
font-size: 0.76rem;
}
.chart-context-note p { margin: 0; line-height: 1.5; }
.interval-toggle {
flex: 0 0 auto;
padding: 0.45rem 0.7rem;
border: 1px solid #475569;
border-radius: 0.55rem;
}
.interval-toggle.active {
border-color: #7c3aed;
color: #ddd6fe;
background: rgb(124 58 237 / 14%);
}
.prediction-chart { width: 100%; min-height: 520px; }
.chart-card > footer {
display: grid;
......@@ -239,7 +304,9 @@ td { color: #dbe5f3; font-size: 0.82rem; }
.accuracy-card { flex-basis: auto; }
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.chart-card > header, .table-card > header, .prediction-footer { align-items: flex-start; flex-direction: column; }
.chart-controls { justify-items: start; }
.legend { justify-content: flex-start; }
.chart-context-note { align-items: flex-start; flex-direction: column; }
.prediction-chart { min-height: 420px; }
.live-comparison { grid-template-columns: 1fr; }
.table-card > header > span { text-align: left; }
......
......@@ -32,6 +32,10 @@ import {
} from './timesfm-prediction.model';
import { TimesFmPredictionService } from './timesfm-prediction.service';
type PredictionChartMode = 'future' | 'backtest';
const FUTURE_HISTORY_BARS = 30;
@Component({
selector: 'app-timesfm-prediction',
standalone: true,
......@@ -51,13 +55,15 @@ export class TimesFmPredictionComponent
);
readonly livePrice = signal<number | null>(null);
readonly liveMatches = signal<LiveForecastMatch[]>([]);
readonly chartMode = signal<PredictionChartMode>('future');
readonly showFutureInterval = signal(false);
readonly showBacktestInterval = signal(false);
readonly latestLiveMatch = computed(() => this.liveMatches().at(-1) ?? null);
readonly recentBacktest = computed(() => this.result()?.backtest.slice(-12) ?? []);
readonly subscriptions: Subscription[] = [];
private chart: IChartApi | null = null;
private liveSeries: ISeriesApi<'Line'> | null = null;
private resizeObserver: ResizeObserver | null = null;
private stompClient: Client | null = null;
private chartReady = false;
......@@ -107,7 +113,6 @@ export class TimesFmPredictionComponent
ngOnDestroy(): void {
this.subscriptions.forEach((subscription) => subscription.unsubscribe());
void this.stompClient?.deactivate();
this.resizeObserver?.disconnect();
this.chart?.remove();
}
......@@ -133,13 +138,38 @@ export class TimesFmPredictionComponent
return point.time;
}
selectChartMode(mode: PredictionChartMode): void {
if (this.chartMode() === mode) return;
this.chartMode.set(mode);
this.renderChart();
}
toggleInterval(): void {
if (this.chartMode() === 'future') {
this.showFutureInterval.update((visible) => !visible);
} else {
this.showBacktestInterval.update((visible) => !visible);
}
this.renderChart();
}
intervalVisible(): boolean {
return this.chartMode() === 'future'
? this.showFutureInterval()
: this.showBacktestInterval();
}
futureHistoryStart(result: TimesFmPredictionResult): string {
return result.history.at(-FUTURE_HISTORY_BARS)?.time ?? result.data.first_timestamp;
}
private renderChart(): void {
const result = this.result();
const container = this.chartContainer?.nativeElement;
if (!result || !container || !this.chartReady) return;
this.resizeObserver?.disconnect();
this.chart?.remove();
this.liveSeries = null;
this.chart = createChart(container, {
autoSize: true,
height: 520,
......@@ -164,15 +194,24 @@ export class TimesFmPredictionComponent
},
});
const candles = this.chart.addCandlestickSeries({
if (this.chartMode() === 'future') {
this.renderFutureSeries(result);
} else {
this.renderBacktestSeries(result);
}
this.chart.timeScale().fitContent();
}
private renderFutureSeries(result: TimesFmPredictionResult): void {
const candles = this.chart?.addCandlestickSeries({
upColor: '#34d399',
downColor: '#f87171',
borderVisible: false,
wickUpColor: '#34d399',
wickDownColor: '#f87171',
});
candles.setData(
result.history.map((point) => ({
candles?.setData(
result.history.slice(-FUTURE_HISTORY_BARS).map((point) => ({
time: this.chartTime(point.time),
open: point.open,
high: point.high,
......@@ -181,49 +220,7 @@ export class TimesFmPredictionComponent
})),
);
const predicted = this.chart.addLineSeries({
color: '#f59e0b',
lineWidth: 2,
title: 'Held-out prediction',
priceLineVisible: false,
});
predicted.setData(
result.backtest.map((point) => ({
time: this.chartTime(point.time),
value: point.predicted,
})),
);
const lower = this.chart.addLineSeries({
color: 'rgba(245, 158, 11, 0.48)',
lineWidth: 1,
lineStyle: LineStyle.Dashed,
title: 'Q10',
priceLineVisible: false,
lastValueVisible: false,
});
const upper = this.chart.addLineSeries({
color: 'rgba(245, 158, 11, 0.48)',
lineWidth: 1,
lineStyle: LineStyle.Dashed,
title: 'Q90',
priceLineVisible: false,
lastValueVisible: false,
});
lower.setData(
result.backtest.map((point) => ({
time: this.chartTime(point.time),
value: point.q10,
})),
);
upper.setData(
result.backtest.map((point) => ({
time: this.chartTime(point.time),
value: point.q90,
})),
);
const future = this.chart.addLineSeries({
const future = this.chart?.addLineSeries({
color: '#a78bfa',
lineWidth: 3,
lineStyle: LineStyle.Dashed,
......@@ -231,7 +228,7 @@ export class TimesFmPredictionComponent
priceLineVisible: false,
});
const futureStart = result.history.at(-1);
future.setData([
future?.setData([
...(futureStart
? [
{
......@@ -246,16 +243,91 @@ export class TimesFmPredictionComponent
})),
]);
this.liveSeries = this.chart.addLineSeries({
color: '#22d3ee',
lineWidth: 2,
title: 'Live actual',
priceLineVisible: true,
lastValueVisible: true,
if (this.showFutureInterval()) {
this.addIntervalSeries(result.future, '#a78bfa');
}
this.liveSeries =
this.chart?.addLineSeries({
color: '#22d3ee',
lineWidth: 2,
title: 'Live actual',
priceLineVisible: true,
lastValueVisible: true,
}) ?? null;
const latestMatch = this.latestLiveMatch();
if (latestMatch) {
this.liveSeries?.update({
time: this.chartTime(latestMatch.targetTime),
value: latestMatch.actual,
});
}
}
private renderBacktestSeries(result: TimesFmPredictionResult): void {
const contextEnd = result.history[result.data.context_points - 1];
const anchor = contextEnd
? [{ time: this.chartTime(contextEnd.time), value: contextEnd.close }]
: [];
const actual = this.chart?.addLineSeries({
color: '#34d399',
lineWidth: 3,
title: 'Actual close',
priceLineVisible: false,
});
actual?.setData([
...anchor,
...result.backtest.map((point) => ({
time: this.chartTime(point.time),
value: point.actual,
})),
]);
const predicted = this.chart?.addLineSeries({
color: '#f59e0b',
lineWidth: 3,
lineStyle: LineStyle.Dashed,
title: 'Backtest prediction',
priceLineVisible: false,
});
this.chart.timeScale().fitContent();
this.resizeObserver = new ResizeObserver(() => this.chart?.timeScale().fitContent());
this.resizeObserver.observe(container);
predicted?.setData([
...anchor,
...result.backtest.map((point) => ({
time: this.chartTime(point.time),
value: point.predicted,
})),
]);
if (this.showBacktestInterval()) {
this.addIntervalSeries(result.backtest, '#f59e0b');
}
}
private addIntervalSeries(
points: Array<{ time: string; q10: number; q90: number }>,
color: string,
): void {
const intervalOptions = {
color,
lineWidth: 1 as const,
lineStyle: LineStyle.Dashed,
priceLineVisible: false,
lastValueVisible: false,
};
const lower = this.chart?.addLineSeries({ ...intervalOptions, title: 'Q10' });
const upper = this.chart?.addLineSeries({ ...intervalOptions, title: 'Q90' });
lower?.setData(
points.map((point) => ({
time: this.chartTime(point.time),
value: point.q10,
})),
);
upper?.setData(
points.map((point) => ({
time: this.chartTime(point.time),
value: point.q90,
})),
);
}
private connectLiveFeed(result: TimesFmPredictionResult): void {
......
This diff is collapsed.
This diff is collapsed.
......@@ -8,6 +8,7 @@ import io
import math
import os
import sys
import warnings
from pathlib import Path
from types import ModuleType
from typing import Any
......@@ -82,6 +83,44 @@ def _load_archived_timesfm() -> ModuleType:
return module
def _cuda_device_is_supported(torch: ModuleType, device_index: int = 0) -> bool:
"""Return whether this PyTorch build contains code usable by the CUDA device."""
if not torch.cuda.is_available():
return False
compiled_arches = torch.cuda.get_arch_list()
if not compiled_arches:
# ROCm builds and some custom CUDA builds do not expose an architecture
# list. In that case availability is the best signal PyTorch provides.
return True
device_major, device_minor = torch.cuda.get_device_capability(device_index)
device_arch = device_major * 10 + device_minor
sm_arches: list[int] = []
ptx_arches: list[int] = []
for arch in compiled_arches:
kind, separator, version = arch.partition("_")
if not separator:
continue
numeric_version = "".join(character for character in version if character.isdigit())
if not numeric_version:
continue
if kind == "sm":
sm_arches.append(int(numeric_version))
elif kind == "compute":
ptx_arches.append(int(numeric_version))
# CUDA cubins are compatible with later minor revisions in the same major
# architecture. PTX can be JIT-compiled for newer CUDA architectures.
if any(
compiled // 10 == device_arch // 10 and compiled <= device_arch
for compiled in sm_arches
):
return True
return any(compiled <= device_arch for compiled in ptx_arches)
class TimesFMBackend:
"""One TimesFM 2.0 500M model instance configured for one prediction run."""
......@@ -111,7 +150,24 @@ class TimesFMBackend:
) from exc
timesfm = _load_archived_timesfm()
use_cuda = torch.cuda.is_available()
gpu_name = None
with warnings.catch_warnings():
# PyTorch emits several long compatibility warnings while querying
# an unsupported device. Replace them with one actionable warning.
warnings.simplefilter("ignore", UserWarning)
cuda_available = torch.cuda.is_available()
if cuda_available:
gpu_name = torch.cuda.get_device_name(0)
use_cuda = _cuda_device_is_supported(torch)
capability = torch.cuda.get_device_capability(0) if cuda_available else None
if cuda_available and not use_cuda:
device_arch = f"sm_{capability[0]}{capability[1]}" if capability else "unknown"
warnings.warn(
f"{gpu_name or 'CUDA device'} ({device_arch}) is not supported by this "
"PyTorch build; TimesFM is using the CPU instead.",
RuntimeWarning,
stacklevel=2,
)
torch.set_float32_matmul_precision("high")
desired_context = max(INPUT_PATCH, min(context_points, MODEL_CONTEXT_LIMIT))
......@@ -138,7 +194,7 @@ class TimesFMBackend:
)
if use_cuda:
self.device = "cuda:0"
self.gpu_name = torch.cuda.get_device_name(0)
self.gpu_name = gpu_name
else:
self.device = "cpu"
self.gpu_name = None
......
......@@ -6,6 +6,7 @@ import numpy as np
import pytest
from dohasecuritiesstockai.timesfm_forecasting.cli import _lookback_start
from dohasecuritiesstockai.timesfm_forecasting.forecaster import _cuda_device_is_supported
from dohasecuritiesstockai.timesfm_forecasting.market_data import (
MarketCandle,
fetch_dse_candles,
......@@ -48,6 +49,27 @@ class LinearForecastBackend:
return point, quantiles, 1
class FakeCuda:
def __init__(self, *, available: bool, capability=(0, 0), arches=()):
self.available = available
self.capability = capability
self.arches = list(arches)
def is_available(self):
return self.available
def get_device_capability(self, _device_index):
return self.capability
def get_arch_list(self):
return self.arches
class FakeTorch:
def __init__(self, cuda):
self.cuda = cuda
def _linear_candles(count: int) -> list[MarketCandle]:
start = datetime(2024, 1, 1, tzinfo=timezone.utc)
return [
......@@ -91,6 +113,36 @@ def test_lookback_is_separate_from_candle_resolution():
assert _lookback_start(datetime(2026, 8, 10).date(), "max") is None
def test_timesfm_rejects_cuda_device_older_than_compiled_pytorch_arches():
torch = FakeTorch(
FakeCuda(
available=True,
capability=(6, 0),
arches=("sm_70", "sm_75", "sm_80", "sm_86", "sm_90"),
)
)
assert not _cuda_device_is_supported(torch)
def test_timesfm_accepts_compatible_cuda_cubin_or_forward_compatible_ptx():
same_major = FakeTorch(
FakeCuda(available=True, capability=(8, 9), arches=("sm_80", "sm_86"))
)
forward_ptx = FakeTorch(
FakeCuda(available=True, capability=(9, 0), arches=("sm_86", "compute_86"))
)
assert _cuda_device_is_supported(same_major)
assert _cuda_device_is_supported(forward_ptx)
def test_timesfm_treats_cuda_as_unavailable_when_pytorch_does():
torch = FakeTorch(FakeCuda(available=False))
assert not _cuda_device_is_supported(torch)
def test_fetch_dse_candles_normalizes_envelope_and_ohlcv_arrays():
first = int(datetime(2025, 1, 1, tzinfo=timezone.utc).timestamp())
client = FakeDSEClient(
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment