Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77ed2d8423 | |||
| 2e3550a4dc |
@@ -0,0 +1,17 @@
|
|||||||
|
.git
|
||||||
|
.github
|
||||||
|
.gitea
|
||||||
|
.idea
|
||||||
|
.claude
|
||||||
|
.npm-cache
|
||||||
|
node_modules
|
||||||
|
build
|
||||||
|
.svelte-kit
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.zip
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Production identity
|
||||||
|
PUBLIC_SITE_URL=https://trade.flamy.studio
|
||||||
|
PUBLIC_SITE_NAME=Flamy Trade
|
||||||
|
PUBLIC_SITE_DESCRIPTION=Публичная витрина ML-прогнозов для крипторынка.
|
||||||
|
PUBLIC_DASHBOARD_DATA_MODE=research-static
|
||||||
|
|
||||||
|
# SvelteKit adapter-node runtime
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=3000
|
||||||
|
ORIGIN=https://trade.flamy.studio
|
||||||
|
BODY_SIZE_LIMIT=1M
|
||||||
|
SHUTDOWN_TIMEOUT=15
|
||||||
|
APP_STOP_GRACE_PERIOD=20s
|
||||||
|
|
||||||
|
# Docker image provenance
|
||||||
|
APP_IMAGE_REPOSITORY=registry.example.com/flamy-trade
|
||||||
|
APP_IMAGE_TAG=local
|
||||||
|
BUILD_DATE=
|
||||||
|
VCS_REF=
|
||||||
|
|
||||||
|
# Reverse proxy publishing
|
||||||
|
APP_HOST_IP=10.20.0.20
|
||||||
|
APP_PUBLISHED_PORT=18082
|
||||||
|
NGINX_IMAGE=nginxinc/nginx-unprivileged:1.30.4-alpine-perl@sha256:36fa38fb8f34faba45e7e1857644d6561f2faf45e7ce6b15e97d1a2a1682a0cd
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
*.svelte text eol=lf
|
||||||
|
*.ts text eol=lf
|
||||||
|
*.js text eol=lf
|
||||||
|
*.json text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.md text eol=lf
|
||||||
|
*.css text eol=lf
|
||||||
+5
-1
@@ -1,4 +1,7 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
.npm-cache
|
||||||
|
.corepack
|
||||||
|
.pnpm-store
|
||||||
|
|
||||||
# Output
|
# Output
|
||||||
.output
|
.output
|
||||||
@@ -24,4 +27,5 @@ vite.config.ts.timestamp-*
|
|||||||
|
|
||||||
#IDE & AI
|
#IDE & AI
|
||||||
.claude
|
.claude
|
||||||
.idea
|
.idea
|
||||||
|
.vscode
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
ARG NODE_IMAGE=node:24.18.0-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd
|
||||||
|
|
||||||
|
FROM ${NODE_IMAGE} AS base
|
||||||
|
WORKDIR /app
|
||||||
|
ENV PNPM_HOME=/pnpm
|
||||||
|
ENV PATH=${PNPM_HOME}:${PATH}
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
FROM deps AS quality
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm format:check
|
||||||
|
RUN pnpm lint
|
||||||
|
RUN pnpm check
|
||||||
|
|
||||||
|
FROM quality AS build
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM base AS prod-deps
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||||
|
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
|
||||||
|
|
||||||
|
FROM ${NODE_IMAGE} AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ARG BUILD_DATE
|
||||||
|
ARG VCS_REF
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.title="Flamy Trade"
|
||||||
|
LABEL org.opencontainers.image.description="SvelteKit frontend for trade.flamy.studio"
|
||||||
|
LABEL org.opencontainers.image.created="${BUILD_DATE}"
|
||||||
|
LABEL org.opencontainers.image.revision="${VCS_REF}"
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV ORIGIN=https://trade.flamy.studio
|
||||||
|
ENV BODY_SIZE_LIMIT=1M
|
||||||
|
ENV SHUTDOWN_TIMEOUT=15
|
||||||
|
|
||||||
|
COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules
|
||||||
|
COPY --from=build --chown=node:node /app/build ./build
|
||||||
|
COPY --from=build --chown=node:node /app/package.json ./package.json
|
||||||
|
|
||||||
|
USER node
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "build/index.js"]
|
||||||
@@ -1,316 +1,114 @@
|
|||||||
# Flamy Trade — Frontend
|
# Flamy Trade
|
||||||
|
|
||||||
Публичный лендинг и дашборд ML-прогнозов для крипторынка. Построен на **SvelteKit 5** (Runes API), **Tailwind v4**, **TypeScript** и **lightweight-charts v5**.
|
Публичный frontend для `trade.flamy.studio`: лендинг, блог и исследовательский дашборд ML-прогнозов по крипторынку.
|
||||||
|
|
||||||
---
|
Проект построен на SvelteKit, Svelte 5 Runes, Tailwind CSS v4, TypeScript и `lightweight-charts`.
|
||||||
|
|
||||||
## Стек
|
## Требования
|
||||||
|
|
||||||
| Слой | Технология |
|
```text
|
||||||
|---|---|
|
Node.js: >=24.0.0 <25
|
||||||
| Фреймворк | SvelteKit 5 (runes, SSR) |
|
pnpm: >=11.15.1 <12
|
||||||
| Язык | TypeScript (strict) |
|
```
|
||||||
| Стили | Tailwind CSS v4 (Vite plugin, `@theme`) |
|
|
||||||
| Графики | lightweight-charts v5 (TradingView) |
|
|
||||||
| Сервер | `@sveltejs/adapter-node` (Node.js) |
|
|
||||||
| Пакетный менеджер | pnpm |
|
|
||||||
|
|
||||||
---
|
В Windows PowerShell запускайте pnpm через `pnpm.cmd`, если выполнение `.ps1`-скриптов отключено.
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
**Требования:** Node.js ≥ 20, pnpm ≥ 9
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Установить зависимости
|
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|
||||||
# 2. Запустить dev-сервер
|
|
||||||
pnpm dev
|
pnpm dev
|
||||||
|
|
||||||
# 3. Открыть в браузере
|
|
||||||
# http://localhost:5173
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Остальные команды:
|
Локальный адрес по умолчанию:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
## Команды
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm build # Сборка для продакшена (папка build/)
|
pnpm format # автоформатирование
|
||||||
pnpm preview # Превью продакшен-сборки
|
pnpm format:check # проверка форматирования
|
||||||
pnpm check # Svelte + TypeScript проверка типов
|
pnpm lint # ESLint
|
||||||
pnpm lint # ESLint + Prettier проверка
|
pnpm check # Svelte + TypeScript
|
||||||
pnpm format # Автоформатирование
|
pnpm build # production build
|
||||||
|
pnpm preview # preview production build
|
||||||
|
pnpm audit # аудит зависимостей
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
`prepare` намеренно выполняет `svelte-kit sync` без подавления ошибок. Если синхронизация SvelteKit падает, установка зависимостей тоже должна завершаться ошибкой.
|
||||||
|
|
||||||
## Структура проекта
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── lib/
|
|
||||||
│ ├── components/
|
|
||||||
│ │ ├── Chart.svelte # lightweight-charts обёртка
|
|
||||||
│ │ ├── home/
|
|
||||||
│ │ │ ├── DashboardMockup.svelte # 3D-макет на главной
|
|
||||||
│ │ │ └── DashboardStatus.svelte
|
|
||||||
│ │ ├── layout/
|
|
||||||
│ │ │ ├── Header.svelte # Фиксированная шапка + мобильное меню
|
|
||||||
│ │ │ └── Footer.svelte
|
|
||||||
│ │ └── ui/
|
|
||||||
│ │ ├── Button.svelte # Универсальная кнопка/ссылка
|
|
||||||
│ │ └── Logo.svelte
|
|
||||||
│ ├── blog/
|
|
||||||
│ │ └── posts.ts # Статьи блога (статические данные)
|
|
||||||
│ ├── stores/
|
|
||||||
│ │ └── chartStore.ts # Типы + генератор фейковых данных
|
|
||||||
│ ├── utils.ts # cn() — утилита для className
|
|
||||||
│ └── index.ts
|
|
||||||
│
|
|
||||||
├── routes/
|
|
||||||
│ ├── +layout.svelte # Корневой layout: Header, Footer, шрифты
|
|
||||||
│ ├── +page.svelte # Главная страница (секции)
|
|
||||||
│ ├── +error.svelte # Страница ошибки
|
|
||||||
│ ├── (sections)/ # Секции главной страницы
|
|
||||||
│ │ ├── Hero.svelte
|
|
||||||
│ │ ├── Ticker.svelte
|
|
||||||
│ │ ├── About.svelte
|
|
||||||
│ │ ├── Features.svelte
|
|
||||||
│ │ ├── Stats.svelte
|
|
||||||
│ │ ├── BlogPreview.svelte
|
|
||||||
│ │ └── Cta.svelte
|
|
||||||
│ ├── about/
|
|
||||||
│ │ ├── +page.svelte
|
|
||||||
│ │ └── _data.ts
|
|
||||||
│ ├── blog/
|
|
||||||
│ │ ├── +page.svelte # Список статей
|
|
||||||
│ │ └── [slug]/
|
|
||||||
│ │ ├── +page.ts # Загрузка статьи по slug
|
|
||||||
│ │ └── +page.svelte # Статья
|
|
||||||
│ └── dashboard/
|
|
||||||
│ └── +page.svelte # Интерактивный дашборд с графиком
|
|
||||||
│
|
|
||||||
└── routes/layout.css # Tailwind @theme + глобальные стили
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Дизайн-система
|
|
||||||
|
|
||||||
### Цвета (`layout.css`)
|
|
||||||
|
|
||||||
```css
|
|
||||||
--color-primary: #fe4b07 /* Акцент — оранжевый */
|
|
||||||
--color-primary-h:#c83e06 /* Hover-состояние */
|
|
||||||
--color-bg: #09080a /* Фон страницы */
|
|
||||||
--color-bg-e: #0f0e10 /* Elevated (шапка, карточки) */
|
|
||||||
--color-bg-c: #141318 /* Card background */
|
|
||||||
--color-bg-h: #1a191e /* Hover / бордеры */
|
|
||||||
--color-title: #f0ede6 /* Основной текст */
|
|
||||||
--color-desc: #8a887f /* Вторичный текст */
|
|
||||||
```
|
|
||||||
|
|
||||||
В Tailwind используются как `bg-primary`, `text-desc`, `border-bg-h` и т.д.
|
|
||||||
|
|
||||||
### Шрифты
|
|
||||||
|
|
||||||
| Переменная | Семейство | Применение |
|
|
||||||
|---|---|---|
|
|
||||||
| `font-display` | Unbounded | Заголовки, кнопки, тикеры |
|
|
||||||
| `font-sans` | DM Sans | Основной текст |
|
|
||||||
| `font-mono` | JetBrains Mono | Цены, метки, метаданные |
|
|
||||||
|
|
||||||
Подключаются через Google Fonts в `+layout.svelte`.
|
|
||||||
|
|
||||||
### Компонент `Button.svelte`
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<!-- Первичная (ссылка) -->
|
|
||||||
<Button href="/dashboard">Открыть дашборд</Button>
|
|
||||||
|
|
||||||
<!-- Вторичная (кнопка) -->
|
|
||||||
<Button variant="secondary" onclick={handler}>Действие</Button>
|
|
||||||
```
|
|
||||||
|
|
||||||
Props: `href?`, `target?`, `variant?: 'primary' | 'secondary'`, `class?`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Дашборд и Charts
|
|
||||||
|
|
||||||
### Архитектура данных
|
|
||||||
|
|
||||||
```
|
|
||||||
chartStore.ts → generateChartData(symbolId, timeframeId)
|
|
||||||
└── возвращает ChartData
|
|
||||||
├── candles: CandleBar[] // 160 свечей
|
|
||||||
├── prediction: PredPoint[] // 19 прогнозных точек
|
|
||||||
├── signal: Signal // direction, confidence, entry/tp/sl
|
|
||||||
├── currentPrice: number
|
|
||||||
└── change24h(Pct): number
|
|
||||||
```
|
|
||||||
|
|
||||||
`generateChartData` — **заглушка с детерминированными фейковыми данными**. При подключении реального бекенда её нужно заменить на API-запрос, сохранив возвращаемые типы:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// chartStore.ts — заменить эту функцию на реальный запрос:
|
|
||||||
export async function fetchChartData(symbolId: string, tfId: TimeframeId): Promise<ChartData> {
|
|
||||||
const res = await fetch(`/api/chart/${symbolId}?tf=${tfId}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Компонент `Chart.svelte`
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<Chart data={chartData} decimals={2} height={520} />
|
|
||||||
```
|
|
||||||
|
|
||||||
| Prop | Тип | Описание |
|
|
||||||
|---|---|---|
|
|
||||||
| `data` | `ChartData` | Свечи + прогноз + сигнал |
|
|
||||||
| `decimals` | `number` | Кол-во знаков после запятой для цены |
|
|
||||||
| `height` | `number` | Высота canvas в пикселях (default: 520) |
|
|
||||||
|
|
||||||
Что рендерится:
|
|
||||||
- **CandlestickSeries** — исторические зелёные/красные свечи
|
|
||||||
- **LineSeries** — пунктирная оранжевая линия ML-прогноза (19 свечей вперёд)
|
|
||||||
- **PriceLine** × 3 — горизонтальные линии Entry / TP / SL с подписями на шкале
|
|
||||||
- **OHLC тултип** — кастомный HTML-оверлей при наведении (показывает O/H/L/C и ML-значение)
|
|
||||||
- **ResizeObserver** — автоматически подстраивает ширину и высоту при изменении окна
|
|
||||||
|
|
||||||
### Таймфреймы и символы
|
|
||||||
|
|
||||||
Определены в `chartStore.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
SYMBOLS // 8 монет: BTC ETH SOL BNB XRP ADA DOGE LINK
|
|
||||||
TIMEFRAMES // 6 таймфреймов: 1m 5m 15m 1h 4h 1d
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Контракт API (для бекенда)
|
|
||||||
|
|
||||||
Дашборд ожидает от бекенда следующую форму ответа:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type ChartData = {
|
|
||||||
candles: Array<{
|
|
||||||
time: number; // Unix timestamp (UTC seconds)
|
|
||||||
open: number;
|
|
||||||
high: number;
|
|
||||||
low: number;
|
|
||||||
close: number;
|
|
||||||
}>;
|
|
||||||
prediction: Array<{
|
|
||||||
time: number; // Unix timestamp будущих свечей
|
|
||||||
value: number; // Прогнозная цена (mid/close)
|
|
||||||
}>;
|
|
||||||
signal: {
|
|
||||||
direction: 'long' | 'short';
|
|
||||||
confidence: number; // 0–100
|
|
||||||
entry: number;
|
|
||||||
tp: number;
|
|
||||||
sl: number;
|
|
||||||
};
|
|
||||||
currentPrice: number;
|
|
||||||
change24h: number;
|
|
||||||
change24hPct: number;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Эндпоинт (предполагаемый):
|
|
||||||
```
|
|
||||||
GET /api/chart/:symbol?tf=5m
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Блог
|
|
||||||
|
|
||||||
Статьи хранятся в `src/lib/blog/posts.ts` как статический массив `Post[]`.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type Post = {
|
|
||||||
id: number;
|
|
||||||
slug: string; // URL: /blog/:slug
|
|
||||||
date: string; // ISO 8601
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
tags: string[];
|
|
||||||
content: string; // HTML-строка
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Для подключения CMS — заменить `posts` на API-запрос в `+page.ts` / `+page.server.ts`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Конфигурация
|
## Конфигурация
|
||||||
|
|
||||||
### SvelteKit (`svelte.config.js`)
|
`.env.example` описывает production-настройки. Для сервера скопируйте его в `.env` и заполните значения без локальных заглушек.
|
||||||
|
|
||||||
- Адаптер: `adapter-node` (деплой как Node.js-сервер)
|
Основные переменные:
|
||||||
- Runes: принудительно включены для всех файлов вне `node_modules`
|
|
||||||
|
|
||||||
### Tailwind v4 (`layout.css`)
|
|
||||||
|
|
||||||
Tailwind подключается через Vite-плагин (`@tailwindcss/vite`), конфиг CSS-первый — все кастомные токены в блоке `@theme` в `src/routes/layout.css`.
|
|
||||||
|
|
||||||
**Добавить новый токен:**
|
|
||||||
```css
|
|
||||||
/* layout.css */
|
|
||||||
@theme {
|
|
||||||
--color-accent: #your-color;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
После этого доступен как `bg-accent`, `text-accent` и т.д.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Деплой
|
|
||||||
|
|
||||||
Сборка производит Node.js-сервер:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm build
|
PUBLIC_SITE_URL=https://trade.flamy.studio
|
||||||
node build/index.js
|
PUBLIC_SITE_NAME=Flamy Trade
|
||||||
|
PUBLIC_SITE_DESCRIPTION=Публичная витрина ML-прогнозов для крипторынка.
|
||||||
|
PUBLIC_DASHBOARD_DATA_MODE=research-static
|
||||||
|
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=3000
|
||||||
|
ORIGIN=https://trade.flamy.studio
|
||||||
|
BODY_SIZE_LIMIT=1M
|
||||||
|
SHUTDOWN_TIMEOUT=15
|
||||||
|
APP_STOP_GRACE_PERIOD=20s
|
||||||
|
|
||||||
|
APP_IMAGE_REPOSITORY=registry.example.com/flamy-trade
|
||||||
|
APP_IMAGE_TAG=<git-sha>
|
||||||
|
BUILD_DATE=<utc-iso-date>
|
||||||
|
VCS_REF=<git-sha>
|
||||||
|
|
||||||
|
APP_HOST_IP=10.20.0.20
|
||||||
|
APP_PUBLISHED_PORT=18082
|
||||||
|
NGINX_IMAGE=nginxinc/nginx-unprivileged:1.30.4-alpine-perl@sha256:36fa38fb8f34faba45e7e1857644d6561f2faf45e7ce6b15e97d1a2a1682a0cd
|
||||||
```
|
```
|
||||||
|
|
||||||
Переменные окружения:
|
`PUBLIC_DASHBOARD_DATA_MODE=research-static` означает, что дашборд показывает исследовательские синтетические данные. Это production-поведение текущего публичного этапа, а не интеграция с реальным торговым API.
|
||||||
```
|
|
||||||
PORT=3000 # порт (default: 3000)
|
## Структура
|
||||||
HOST=0.0.0.0 # хост
|
|
||||||
ORIGIN=https://... # обязательно при deploy за проксей
|
```text
|
||||||
|
src/lib/blog/posts.ts # типизированные статьи блога
|
||||||
|
src/lib/config/site.ts # единая конфигурация сайта, canonical и metadata
|
||||||
|
src/lib/data/dashboard.ts # provider данных дашборда
|
||||||
|
src/lib/stores/chartStore.ts # типы графика и research-static генератор
|
||||||
|
src/lib/components/Chart.svelte # компонент lightweight-charts
|
||||||
|
src/routes/healthz/+server.ts # health endpoint
|
||||||
|
src/routes/robots.txt/+server.ts # runtime robots.txt
|
||||||
|
src/routes/sitemap.xml/+server.ts # runtime sitemap
|
||||||
|
docker/nginx/default.conf # nginx server config
|
||||||
|
docker/nginx/includes # общие nginx include-файлы
|
||||||
|
docs/deployment.md # runbook развёртывания
|
||||||
```
|
```
|
||||||
|
|
||||||
Docker (минимальный `Dockerfile`):
|
## Production
|
||||||
```dockerfile
|
|
||||||
FROM node:22-alpine
|
Production-схема:
|
||||||
WORKDIR /app
|
|
||||||
COPY build/ ./build/
|
```text
|
||||||
COPY package.json .
|
Internet -> Caddy -> 10.20.0.20:18082 -> nginx -> app:3000
|
||||||
RUN npm install --omit=dev --ignore-scripts
|
|
||||||
CMD ["node", "build/index.js"]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Публикуется только nginx. Node-приложение доступно только во внутренней Docker-сети. Nginx запускается через unprivileged image с `cap_drop: ALL`, `read_only` и `no-new-privileges`.
|
||||||
|
|
||||||
## Соглашения по коду
|
Подробный порядок развёртывания, Caddy block, smoke-check и rollback описаны в [docs/deployment.md](docs/deployment.md).
|
||||||
|
|
||||||
- **Svelte 5 Runes** везде: `$state`, `$derived`, `$effect`, `$props` — никакого legacy API
|
## Зависимости
|
||||||
- **Нет `export let`** — только деструктуризация `$props()`
|
|
||||||
- **Tailwind-first** — inline-стили только там, где Tailwind не справляется (динамические значения, chart dimensions)
|
|
||||||
- **cn()** из `$lib/utils` для условного объединения классов
|
|
||||||
- **Нет комментариев** если смысл очевиден из имён; комментарий — только для неочевидного инварианта
|
|
||||||
- **SSR-safe** — всё что требует `window`/`document` — только в `onMount`
|
|
||||||
- **Cleanup** — каждый `onMount` возвращает функцию очистки (removeEventListener, disconnect, remove)
|
|
||||||
|
|
||||||
---
|
Renovate закрепляет версии и digest Docker-образов, а также ждёт 3 дня перед обновлением свежих релизов. TypeScript 6.x является осознанным compatibility hold до обновления peer constraints в SvelteKit/typescript-eslint.
|
||||||
|
|
||||||
## Известные ограничения
|
## Что не входит в текущий этап
|
||||||
|
|
||||||
- Данные в дашборде — **фейковые** (детерминированный генератор). Требует подключения реального API бекенда через замену `generateChartData` в `chartStore.ts`
|
- ML-runtime;
|
||||||
- Прогнозная линия — условная визуализация; реальная модель возвращает `prediction[]` из бекенда
|
- реальный торговый API;
|
||||||
- Блог — статические данные в коде; для production рекомендуется CMS или headless API
|
- S3;
|
||||||
|
- база данных;
|
||||||
|
- Redis;
|
||||||
|
- Telegram egress;
|
||||||
|
- публикация ML API через Caddy.
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
name: flamy_trade
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_DATE: ${BUILD_DATE:-}
|
||||||
|
VCS_REF: ${VCS_REF:-}
|
||||||
|
image: ${APP_IMAGE_REPOSITORY:-flamy-trade}:${APP_IMAGE_TAG:-local}
|
||||||
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
read_only: true
|
||||||
|
stop_signal: SIGTERM
|
||||||
|
stop_grace_period: ${APP_STOP_GRACE_PERIOD:-20s}
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
PORT: 3000
|
||||||
|
ORIGIN: ${ORIGIN:-https://trade.flamy.studio}
|
||||||
|
BODY_SIZE_LIMIT: ${BODY_SIZE_LIMIT:-1M}
|
||||||
|
SHUTDOWN_TIMEOUT: ${SHUTDOWN_TIMEOUT:-15}
|
||||||
|
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-https://trade.flamy.studio}
|
||||||
|
PUBLIC_SITE_NAME: ${PUBLIC_SITE_NAME:-Flamy Trade}
|
||||||
|
PUBLIC_SITE_DESCRIPTION: ${PUBLIC_SITE_DESCRIPTION:-Публичная витрина ML-прогнозов для крипторынка.}
|
||||||
|
expose:
|
||||||
|
- '3000'
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
cpus: 0.50
|
||||||
|
mem_limit: 384m
|
||||||
|
mem_reservation: 128m
|
||||||
|
memswap_limit: 384m
|
||||||
|
pids_limit: 128
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=32m,noexec,nosuid,nodev
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
'CMD-SHELL',
|
||||||
|
'node -e "fetch(''http://127.0.0.1:3000/healthz'').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"'
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: 10m
|
||||||
|
max-file: '3'
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: ${NGINX_IMAGE:-nginxinc/nginx-unprivileged:1.30.4-alpine-perl@sha256:36fa38fb8f34faba45e7e1857644d6561f2faf45e7ce6b15e97d1a2a1682a0cd}
|
||||||
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
read_only: true
|
||||||
|
depends_on:
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
target: 8080
|
||||||
|
published: '${APP_PUBLISHED_PORT:-18082}'
|
||||||
|
host_ip: '${APP_HOST_IP:-10.20.0.20}'
|
||||||
|
protocol: tcp
|
||||||
|
app_protocol: http
|
||||||
|
volumes:
|
||||||
|
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
- ./docker/nginx/includes:/etc/nginx/includes:ro
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
cpus: 0.10
|
||||||
|
mem_limit: 64m
|
||||||
|
mem_reservation: 16m
|
||||||
|
memswap_limit: 64m
|
||||||
|
pids_limit: 64
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
tmpfs:
|
||||||
|
- /var/cache/nginx:size=32m,nosuid,nodev
|
||||||
|
- /var/run:size=8m,nosuid,nodev
|
||||||
|
- /tmp:size=16m,noexec,nosuid,nodev
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1']
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: 10m
|
||||||
|
max-file: '3'
|
||||||
|
|
||||||
|
networks:
|
||||||
|
backend:
|
||||||
|
internal: true
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
map $http_x_forwarded_proto $forwarded_proto {
|
||||||
|
default $http_x_forwarded_proto;
|
||||||
|
"" $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
client_max_body_size 1m;
|
||||||
|
add_header_inherit merge;
|
||||||
|
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
||||||
|
|
||||||
|
location = /healthz {
|
||||||
|
access_log off;
|
||||||
|
proxy_pass http://app:3000;
|
||||||
|
include /etc/nginx/includes/proxy-headers.conf;
|
||||||
|
add_header Cache-Control "no-cache" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /_app/immutable/ {
|
||||||
|
proxy_pass http://app:3000;
|
||||||
|
include /etc/nginx/includes/proxy-headers.conf;
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(?:css|js|mjs|svg|png|jpg|jpeg|gif|webp|ico|ttf|woff2?)$ {
|
||||||
|
proxy_pass http://app:3000;
|
||||||
|
include /etc/nginx/includes/proxy-headers.conf;
|
||||||
|
add_header Cache-Control "public, max-age=86400" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://app:3000;
|
||||||
|
include /etc/nginx/includes/proxy-headers.conf;
|
||||||
|
add_header Cache-Control "no-cache" always;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||||
|
proxy_set_header Connection "";
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# Развёртывание Flamy Trade
|
||||||
|
|
||||||
|
Документ описывает production-развёртывание сайта `trade.flamy.studio` без ML-runtime и будущего API. Эти части подключаются отдельным этапом.
|
||||||
|
|
||||||
|
## Схема
|
||||||
|
|
||||||
|
```text
|
||||||
|
Internet
|
||||||
|
-> Router :80/:443
|
||||||
|
-> edge-proxy VM / Caddy
|
||||||
|
-> 10.20.0.20:18082
|
||||||
|
-> flamy_trade-nginx-1
|
||||||
|
-> flamy_trade-app-1:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose project name зафиксирован как `flamy_trade`. `container_name` не задаётся, чтобы не ломать стандартное управление Compose.
|
||||||
|
|
||||||
|
## Переменные
|
||||||
|
|
||||||
|
Скопируйте `.env.example` в `.env` на сервере и проверьте значения:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PUBLIC_SITE_URL=https://trade.flamy.studio
|
||||||
|
PUBLIC_SITE_NAME=Flamy Trade
|
||||||
|
PUBLIC_SITE_DESCRIPTION=Публичная витрина ML-прогнозов для крипторынка.
|
||||||
|
PUBLIC_DASHBOARD_DATA_MODE=research-static
|
||||||
|
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=3000
|
||||||
|
ORIGIN=https://trade.flamy.studio
|
||||||
|
BODY_SIZE_LIMIT=1M
|
||||||
|
SHUTDOWN_TIMEOUT=15
|
||||||
|
APP_STOP_GRACE_PERIOD=20s
|
||||||
|
|
||||||
|
APP_IMAGE_REPOSITORY=registry.example.com/flamy-trade
|
||||||
|
APP_IMAGE_TAG=<git-sha>
|
||||||
|
BUILD_DATE=<utc-iso-date>
|
||||||
|
VCS_REF=<git-sha>
|
||||||
|
|
||||||
|
APP_HOST_IP=10.20.0.20
|
||||||
|
APP_PUBLISHED_PORT=18082
|
||||||
|
NGINX_IMAGE=nginxinc/nginx-unprivileged:1.30.4-alpine-perl@sha256:36fa38fb8f34faba45e7e1857644d6561f2faf45e7ce6b15e97d1a2a1682a0cd
|
||||||
|
```
|
||||||
|
|
||||||
|
Перед фиксацией порта проверьте, что `18082` свободен:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ss -lntup | grep ':18082'
|
||||||
|
docker ps --format 'table {{.Names}}\t{{.Ports}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Проверки перед сборкой
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm format:check
|
||||||
|
pnpm lint
|
||||||
|
pnpm check
|
||||||
|
pnpm build
|
||||||
|
pnpm audit
|
||||||
|
docker compose config
|
||||||
|
```
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
Перед сборкой image задайте provenance:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
export VCS_REF="$(git rev-parse HEAD)"
|
||||||
|
export APP_IMAGE_TAG="$VCS_REF"
|
||||||
|
```
|
||||||
|
|
||||||
|
Сборка и запуск:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build --pull
|
||||||
|
docker compose up -d --wait
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs --tail=200
|
||||||
|
```
|
||||||
|
|
||||||
|
## Caddy
|
||||||
|
|
||||||
|
Блок для edge-proxy:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
trade.flamy.studio {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
reverse_proxy 10.20.0.20:18082 {
|
||||||
|
header_up Host {host}
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Перед reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp -a /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak.$(date +%F_%H%M%S)
|
||||||
|
sudo caddy validate --config /etc/caddy/Caddyfile
|
||||||
|
sudo systemctl reload caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Smoke-check
|
||||||
|
|
||||||
|
С Docker-runtime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS http://10.20.0.20:18082/healthz
|
||||||
|
curl -fsSI http://10.20.0.20:18082/
|
||||||
|
curl -fsSI http://10.20.0.20:18082/robots.txt
|
||||||
|
curl -fsSI http://10.20.0.20:18082/sitemap.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
Снаружи:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSI https://trade.flamy.studio/
|
||||||
|
curl -fsS https://trade.flamy.studio/healthz
|
||||||
|
curl -fsS https://trade.flamy.studio/robots.txt
|
||||||
|
curl -fsS https://trade.flamy.studio/sitemap.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
Маршруты:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/ 200
|
||||||
|
/about 200
|
||||||
|
/blog 200
|
||||||
|
/blog/osnovy-upravleniya-riskami 200
|
||||||
|
/dashboard 200
|
||||||
|
/healthz 200
|
||||||
|
/favicon.svg 200
|
||||||
|
/robots.txt 200
|
||||||
|
/sitemap.xml 200
|
||||||
|
/nesuschestvuyuschiy-url 404
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверка headers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSI https://trade.flamy.studio/
|
||||||
|
curl -fsSI https://trade.flamy.studio/favicon.svg
|
||||||
|
curl -fsSI https://trade.flamy.studio/_app/immutable/<real-file>.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидаемо:
|
||||||
|
|
||||||
|
```text
|
||||||
|
X-Content-Type-Options: nosniff
|
||||||
|
Referrer-Policy: strict-origin-when-cross-origin
|
||||||
|
X-Frame-Options: SAMEORIGIN
|
||||||
|
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
|
||||||
|
```
|
||||||
|
|
||||||
|
`Content-Security-Policy` задаётся SvelteKit и ожидается на HTML-ответах приложения.
|
||||||
|
|
||||||
|
Для `/_app/immutable/*` ожидается:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
|
```
|
||||||
|
|
||||||
|
Для HTML:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cache-Control: no-cache
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Надёжный rollback должен опираться на immutable image tag:
|
||||||
|
|
||||||
|
```text
|
||||||
|
${APP_IMAGE_REPOSITORY}:<git-sha>
|
||||||
|
```
|
||||||
|
|
||||||
|
На production-сервере меняется только `APP_IMAGE_TAG`, затем выполняется:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull app
|
||||||
|
docker compose up -d --no-build --wait
|
||||||
|
```
|
||||||
|
|
||||||
|
Если проблема в маршрутизации, восстановите backup Caddyfile и выполните:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl reload caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
Предыдущий image не удаляется до успешного smoke-check нового релиза.
|
||||||
+32
-26
@@ -3,42 +3,48 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@11.15.1",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.0.0 <25",
|
||||||
|
"pnpm": ">=11.15.1 <12"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"lint": "prettier --check . && eslint .",
|
"format:check": "prettier --check .",
|
||||||
|
"lint": "eslint .",
|
||||||
"format": "prettier --write ."
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^2.0.4",
|
"@eslint/compat": "2.1.0",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@sveltejs/adapter-node": "^5.5.4",
|
"@sveltejs/adapter-node": "5.5.7",
|
||||||
"@sveltejs/kit": "^2.57.0",
|
"@sveltejs/vite-plugin-svelte": "7.2.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@tailwindcss/vite": "4.3.3",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@types/node": "24.12.4",
|
||||||
"@types/node": "^24",
|
"eslint": "10.7.0",
|
||||||
"eslint": "^10.2.0",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-plugin-svelte": "3.20.0",
|
||||||
"eslint-plugin-svelte": "^3.17.0",
|
"globals": "17.7.0",
|
||||||
"globals": "^17.4.0",
|
"prettier": "3.9.5",
|
||||||
"prettier": "^3.8.1",
|
"prettier-plugin-svelte": "4.1.1",
|
||||||
"prettier-plugin-svelte": "^3.5.1",
|
"prettier-plugin-tailwindcss": "0.8.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"svelte": "5.56.6",
|
||||||
"svelte": "^5.55.2",
|
"svelte-check": "4.7.3",
|
||||||
"svelte-check": "^4.4.6",
|
"tailwindcss": "4.3.3",
|
||||||
"tailwindcss": "^4.2.2",
|
"typescript": "6.0.3",
|
||||||
"typescript": "^6.0.2",
|
"typescript-eslint": "8.64.0",
|
||||||
"typescript-eslint": "^8.58.1",
|
"vite": "8.1.5"
|
||||||
"vite": "^8.0.7"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"clsx": "^2.1.1",
|
"@sveltejs/kit": "2.69.3",
|
||||||
"gsap": "^3.15.0",
|
"clsx": "2.1.1",
|
||||||
"lightweight-charts": "^5.2.0",
|
"gsap": "3.15.0",
|
||||||
"tailwind-merge": "^3.6.0"
|
"lightweight-charts": "5.2.0",
|
||||||
|
"tailwind-merge": "3.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+615
-564
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,9 @@
|
|||||||
|
packages:
|
||||||
|
- .
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
cookie: 0.7.2
|
||||||
|
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- '@tailwindcss/oxide'
|
- '@tailwindcss/oxide'
|
||||||
- esbuild
|
- esbuild
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": ["config:recommended"],
|
||||||
|
"dependencyDashboard": true,
|
||||||
|
"labels": ["dependencies"],
|
||||||
|
"minimumReleaseAge": "3 days",
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"matchManagers": ["npm"],
|
||||||
|
"rangeStrategy": "pin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchDatasources": ["docker"],
|
||||||
|
"pinDigests": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+1
-2
@@ -1,9 +1,8 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="text-scale" content="scale" />
|
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
+87
-38
@@ -1,11 +1,25 @@
|
|||||||
export type Post = {
|
export type PostContentBlock =
|
||||||
|
| {
|
||||||
|
type: 'paragraph';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'heading';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'list';
|
||||||
|
items: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Post = {
|
||||||
id: number;
|
id: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
date: string;
|
date: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
content: string;
|
content: PostContentBlock[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const posts: Post[] = [
|
export const posts: Post[] = [
|
||||||
@@ -17,50 +31,85 @@ export const posts: Post[] = [
|
|||||||
description:
|
description:
|
||||||
'Как использовать уровни Stop Loss и Take Profit для грамотного управления капиталом при работе с прогнозами.',
|
'Как использовать уровни Stop Loss и Take Profit для грамотного управления капиталом при работе с прогнозами.',
|
||||||
tags: ['RISK', 'TRADING', 'EDUCATION'],
|
tags: ['RISK', 'TRADING', 'EDUCATION'],
|
||||||
content: `
|
content: [
|
||||||
<p>Управление рисками — фундамент любой торговой стратегии. Без него даже точный ML-прогноз не поможет сохранить капитал.</p>
|
{
|
||||||
<h2>Stop Loss: где ваша позиция неправа</h2>
|
type: 'paragraph',
|
||||||
<p>Stop Loss — уровень, при достижении которого позиция закрывается автоматически. Это не признание ошибки, а часть стратегии. На графиках Flamy AI уровень SL рассчитывается моделью на основе исторической волатильности инструмента.</p>
|
text: 'Управление рисками — фундамент любой торговой стратегии. Без него даже точный ML-прогноз не поможет сохранить капитал.'
|
||||||
<h2>Take Profit: когда забирать прибыль</h2>
|
},
|
||||||
<p>Take Profit — целевой уровень закрытия позиции с прибылью. TP рассчитывается пропорционально прогнозному движению с учётом Risk:Reward ratio.</p>
|
{ type: 'heading', text: 'Stop Loss: где ваша позиция неправа' },
|
||||||
<h2>Risk:Reward Ratio</h2>
|
{
|
||||||
<p>Оптимальное соотношение риска к доходности — не менее <strong>1:2</strong>. Это означает, что потенциальная прибыль должна минимум вдвое превышать риск.</p>
|
type: 'paragraph',
|
||||||
<ul>
|
text: 'Stop Loss — уровень, при достижении которого позиция закрывается автоматически. Это не признание ошибки, а часть стратегии. На графиках Flamy Trade уровень SL рассчитывается моделью на основе исторической волатильности инструмента.'
|
||||||
<li>SL: не более 1% от капитала на сделку</li>
|
},
|
||||||
<li>TP: 2% и выше от точки входа</li>
|
{ type: 'heading', text: 'Take Profit: когда забирать прибыль' },
|
||||||
<li>Одна сделка: не более 2% депозита</li>
|
{
|
||||||
</ul>
|
type: 'paragraph',
|
||||||
<h2>Почему это важно при работе с ML-прогнозами</h2>
|
text: 'Take Profit — целевой уровень закрытия позиции с прибылью. TP рассчитывается пропорционально прогнозному движению с учётом Risk:Reward ratio.'
|
||||||
<p>ML-модель не даёт гарантий. Уверенность 80% означает, что 20% прогнозов будут неточными. Управление рисками — страховка на эти 20%.</p>
|
},
|
||||||
`
|
{ type: 'heading', text: 'Risk:Reward Ratio' },
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
text: 'Оптимальное соотношение риска к доходности — не менее 1:2. Это означает, что потенциальная прибыль должна минимум вдвое превышать риск.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'list',
|
||||||
|
items: [
|
||||||
|
'SL: не более 1% от капитала на сделку',
|
||||||
|
'TP: 2% и выше от точки входа',
|
||||||
|
'Одна сделка: не более 2% депозита'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ type: 'heading', text: 'Почему это важно при работе с ML-прогнозами' },
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
text: 'ML-модель не даёт гарантий. Уверенность 80% означает, что 20% прогнозов будут неточными. Управление рисками — страховка на эти 20%.'
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
slug: 'kak-chitat-grafiki-flamy-ai',
|
slug: 'kak-chitat-grafiki-flamy-ai',
|
||||||
date: '2026-05-17',
|
date: '2026-05-17',
|
||||||
title: 'Как читать графики Flamy AI',
|
title: 'Как читать графики Flamy Trade',
|
||||||
description:
|
description: 'Краткое объяснение свечей, прогнозной зоны, TP и SL на графиках Flamy Trade.',
|
||||||
'Краткое объяснение свечей, прогнозной зоны, TP и SL на графиках Flamy AI.',
|
|
||||||
tags: ['ML', 'FORECAST', 'CHARTS'],
|
tags: ['ML', 'FORECAST', 'CHARTS'],
|
||||||
content: `
|
content: [
|
||||||
<p>Графики Flamy AI содержат три слоя: исторические свечи, прогнозную зону и уровни торгового плана. Разберём каждый из них.</p>
|
{
|
||||||
<h2>Исторические свечи</h2>
|
type: 'paragraph',
|
||||||
<p>Левая часть графика — реальные OHLC-свечи. Зелёные — бычьи (close > open), красные — медвежьи. Это основа для анализа модели.</p>
|
text: 'Графики Flamy Trade содержат три слоя: исторические свечи, прогнозную зону и уровни торгового плана. Разберём каждый из них.'
|
||||||
<h2>Прогнозная зона</h2>
|
},
|
||||||
<p>Правая часть — прогнозные свечи на горизонт <strong>19 периодов</strong> вперёд. Это не точные значения, а вероятностный коридор движения цены на ближайшие 95 минут.</p>
|
{ type: 'heading', text: 'Исторические свечи' },
|
||||||
<h2>Уровни Entry, TP и SL</h2>
|
{
|
||||||
<p>Если модель публикует режим TRADE_PLAN, на графике появляются три горизонтальные линии:</p>
|
type: 'paragraph',
|
||||||
<ul>
|
text: 'Левая часть графика — реальные OHLC-свечи. Зелёные — бычьи, красные — медвежьи. Это основа для анализа модели.'
|
||||||
<li><strong>Entry</strong> — рекомендуемый уровень входа в позицию</li>
|
},
|
||||||
<li><strong>TP</strong> — цель Take Profit</li>
|
{ type: 'heading', text: 'Прогнозная зона' },
|
||||||
<li><strong>SL</strong> — Stop Loss для ограничения убытков</li>
|
{
|
||||||
</ul>
|
type: 'paragraph',
|
||||||
<h2>Таймфрейм и горизонт</h2>
|
text: 'Правая часть — прогнозные свечи на горизонт 19 периодов вперёд. Это не точные значения, а вероятностный коридор движения цены на ближайшие 95 минут.'
|
||||||
<p>Все графики работают на таймфрейме <strong>5 минут</strong>. Горизонт прогноза — 19 свечей = 95 минут вперёд. Прогнозы обновляются каждый час автоматически.</p>
|
},
|
||||||
`
|
{ type: 'heading', text: 'Уровни Entry, TP и SL' },
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
text: 'Если модель публикует режим TRADE_PLAN, на графике появляются три горизонтальные линии.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'list',
|
||||||
|
items: [
|
||||||
|
'Entry — рекомендуемый уровень входа в позицию',
|
||||||
|
'TP — цель Take Profit',
|
||||||
|
'SL — Stop Loss для ограничения убытков'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ type: 'heading', text: 'Таймфрейм и горизонт' },
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
text: 'Все графики работают на таймфрейме 5 минут. Горизонт прогноза — 19 свечей = 95 минут вперёд. Прогнозы обновляются каждый час автоматически.'
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getPostBySlug(slug: string): Post | undefined {
|
export function getPostBySlug(slug: string): Post | undefined {
|
||||||
return posts.find((p) => p.slug === slug);
|
return posts.find((p) => p.slug === slug);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,10 @@
|
|||||||
CrosshairMode,
|
CrosshairMode,
|
||||||
LineStyle,
|
LineStyle,
|
||||||
type IChartApi,
|
type IChartApi,
|
||||||
|
type IPriceLine,
|
||||||
type ISeriesApi,
|
type ISeriesApi,
|
||||||
type MouseEventParams,
|
type MouseEventParams,
|
||||||
type UTCTimestamp,
|
type UTCTimestamp
|
||||||
} from 'lightweight-charts';
|
} from 'lightweight-charts';
|
||||||
import type { ChartData, CandleBar } from '$lib/stores/chartStore';
|
import type { ChartData, CandleBar } from '$lib/stores/chartStore';
|
||||||
|
|
||||||
@@ -25,8 +26,7 @@
|
|||||||
let chart: IChartApi | undefined;
|
let chart: IChartApi | undefined;
|
||||||
let candleSeries: ISeriesApi<'Candlestick'> | undefined;
|
let candleSeries: ISeriesApi<'Candlestick'> | undefined;
|
||||||
let predSeries: ISeriesApi<'Line'> | undefined;
|
let predSeries: ISeriesApi<'Line'> | undefined;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
let priceLines: IPriceLine[] = [];
|
||||||
let priceLines: any[] = [];
|
|
||||||
|
|
||||||
type Tooltip = {
|
type Tooltip = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
high: 0,
|
high: 0,
|
||||||
low: 0,
|
low: 0,
|
||||||
close: 0,
|
close: 0,
|
||||||
isUp: true,
|
isUp: true
|
||||||
});
|
});
|
||||||
|
|
||||||
function fmt(n: number): string {
|
function fmt(n: number): string {
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
lineWidth: 1,
|
lineWidth: 1,
|
||||||
lineStyle: LineStyle.Solid,
|
lineStyle: LineStyle.Solid,
|
||||||
axisLabelVisible: true,
|
axisLabelVisible: true,
|
||||||
title: 'Entry',
|
title: 'Entry'
|
||||||
}),
|
}),
|
||||||
candleSeries.createPriceLine({
|
candleSeries.createPriceLine({
|
||||||
price: d.signal.tp,
|
price: d.signal.tp,
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
lineWidth: 1,
|
lineWidth: 1,
|
||||||
lineStyle: LineStyle.Dashed,
|
lineStyle: LineStyle.Dashed,
|
||||||
axisLabelVisible: true,
|
axisLabelVisible: true,
|
||||||
title: 'TP',
|
title: 'TP'
|
||||||
}),
|
}),
|
||||||
candleSeries.createPriceLine({
|
candleSeries.createPriceLine({
|
||||||
price: d.signal.sl,
|
price: d.signal.sl,
|
||||||
@@ -91,8 +91,8 @@
|
|||||||
lineWidth: 1,
|
lineWidth: 1,
|
||||||
lineStyle: LineStyle.Dashed,
|
lineStyle: LineStyle.Dashed,
|
||||||
axisLabelVisible: true,
|
axisLabelVisible: true,
|
||||||
title: 'SL',
|
title: 'SL'
|
||||||
}),
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
chart.timeScale().fitContent();
|
chart.timeScale().fitContent();
|
||||||
@@ -107,11 +107,11 @@
|
|||||||
textColor: '#8a887f',
|
textColor: '#8a887f',
|
||||||
fontFamily: "'JetBrains Mono', monospace",
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
attributionLogo: false,
|
attributionLogo: false
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
vertLines: { color: '#1a191e' },
|
vertLines: { color: '#1a191e' },
|
||||||
horzLines: { color: '#1a191e' },
|
horzLines: { color: '#1a191e' }
|
||||||
},
|
},
|
||||||
crosshair: {
|
crosshair: {
|
||||||
mode: CrosshairMode.Normal,
|
mode: CrosshairMode.Normal,
|
||||||
@@ -119,25 +119,25 @@
|
|||||||
color: '#3d3b42',
|
color: '#3d3b42',
|
||||||
labelBackgroundColor: '#1a191e',
|
labelBackgroundColor: '#1a191e',
|
||||||
style: LineStyle.Dashed,
|
style: LineStyle.Dashed,
|
||||||
width: 1,
|
width: 1
|
||||||
},
|
},
|
||||||
horzLine: {
|
horzLine: {
|
||||||
color: '#3d3b42',
|
color: '#3d3b42',
|
||||||
labelBackgroundColor: '#1a191e',
|
labelBackgroundColor: '#1a191e',
|
||||||
style: LineStyle.Dashed,
|
style: LineStyle.Dashed,
|
||||||
width: 1,
|
width: 1
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
rightPriceScale: {
|
rightPriceScale: {
|
||||||
borderColor: '#1a191e',
|
borderColor: '#1a191e',
|
||||||
scaleMargins: { top: 0.06, bottom: 0.04 },
|
scaleMargins: { top: 0.06, bottom: 0.04 }
|
||||||
},
|
},
|
||||||
timeScale: {
|
timeScale: {
|
||||||
borderColor: '#1a191e',
|
borderColor: '#1a191e',
|
||||||
timeVisible: true,
|
timeVisible: true,
|
||||||
secondsVisible: false,
|
secondsVisible: false,
|
||||||
rightOffset: 24,
|
rightOffset: 24
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
candleSeries = chart.addSeries(CandlestickSeries, {
|
candleSeries = chart.addSeries(CandlestickSeries, {
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
borderUpColor: '#26a69a',
|
borderUpColor: '#26a69a',
|
||||||
borderDownColor: '#ef5350',
|
borderDownColor: '#ef5350',
|
||||||
wickUpColor: '#26a69a',
|
wickUpColor: '#26a69a',
|
||||||
wickDownColor: '#ef5350',
|
wickDownColor: '#ef5350'
|
||||||
});
|
});
|
||||||
|
|
||||||
predSeries = chart.addSeries(LineSeries, {
|
predSeries = chart.addSeries(LineSeries, {
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
crosshairMarkerBorderColor: '#fe4b07',
|
crosshairMarkerBorderColor: '#fe4b07',
|
||||||
crosshairMarkerBackgroundColor: '#09080a',
|
crosshairMarkerBackgroundColor: '#09080a',
|
||||||
priceLineVisible: false,
|
priceLineVisible: false,
|
||||||
lastValueVisible: true,
|
lastValueVisible: true
|
||||||
});
|
});
|
||||||
|
|
||||||
applyData(data);
|
applyData(data);
|
||||||
@@ -169,17 +169,22 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const c = param.seriesData.get(candleSeries) as CandleBar;
|
const c = param.seriesData.get(candleSeries) as CandleBar;
|
||||||
if (!c) { tip = { ...tip, visible: false }; return; }
|
if (!c) {
|
||||||
|
tip = { ...tip, visible: false };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const pred = predSeries && param.seriesData.has(predSeries)
|
const pred =
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
predSeries && param.seriesData.has(predSeries)
|
||||||
? (param.seriesData.get(predSeries) as any)?.value
|
? (param.seriesData.get(predSeries) as { value?: number })?.value
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const ts = param.time as number;
|
const ts = param.time as number;
|
||||||
const d = new Date(ts * 1000);
|
const d = new Date(ts * 1000);
|
||||||
const timeStr = d.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' })
|
const timeStr =
|
||||||
+ ' ' + d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
d.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' }) +
|
||||||
|
' ' +
|
||||||
|
d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
tip = {
|
tip = {
|
||||||
visible: true,
|
visible: true,
|
||||||
@@ -191,14 +196,14 @@
|
|||||||
low: c.low,
|
low: c.low,
|
||||||
close: c.close,
|
close: c.close,
|
||||||
isUp: c.close >= c.open,
|
isUp: c.close >= c.open,
|
||||||
pred,
|
pred
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const ro = new ResizeObserver(() => {
|
const ro = new ResizeObserver(() => {
|
||||||
chart?.applyOptions({
|
chart?.applyOptions({
|
||||||
width: container.clientWidth,
|
width: container.clientWidth,
|
||||||
height: container.clientHeight,
|
height: container.clientHeight
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
ro.observe(container);
|
ro.observe(container);
|
||||||
@@ -229,7 +234,7 @@
|
|||||||
style="left: {left}px; top: {top}px"
|
style="left: {left}px; top: {top}px"
|
||||||
>
|
>
|
||||||
<p class="mb-2 text-[0.5625rem] tracking-widest text-desc uppercase">{tip.time}</p>
|
<p class="mb-2 text-[0.5625rem] tracking-widest text-desc uppercase">{tip.time}</p>
|
||||||
{#each [['O', tip.open], ['H', tip.high], ['L', tip.low], ['C', tip.close]] as [lbl, val]}
|
{#each [['O', tip.open], ['H', tip.high], ['L', tip.low], ['C', tip.close]] as [lbl, val] (lbl)}
|
||||||
<div class="flex justify-between gap-5">
|
<div class="flex justify-between gap-5">
|
||||||
<span class="text-desc">{lbl}</span>
|
<span class="text-desc">{lbl}</span>
|
||||||
<span class={tip.isUp ? 'text-emerald-400' : 'text-red-400'}>{fmt(val as number)}</span>
|
<span class={tip.isUp ? 'text-emerald-400' : 'text-red-400'}>{fmt(val as number)}</span>
|
||||||
@@ -237,7 +242,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{#if tip.pred !== undefined}
|
{#if tip.pred !== undefined}
|
||||||
<div class="mt-1.5 flex justify-between gap-5 border-t border-white/8 pt-1.5">
|
<div class="mt-1.5 flex justify-between gap-5 border-t border-white/8 pt-1.5">
|
||||||
<span class="text-primary text-[0.5625rem] tracking-widest uppercase">ML</span>
|
<span class="text-[0.5625rem] tracking-widest text-primary uppercase">ML</span>
|
||||||
<span class="text-primary">{fmt(tip.pred)}</span>
|
<span class="text-primary">{fmt(tip.pred)}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full max-w-170 z-20 mx-auto xl:mx-0" style="perspective: 1000px;">
|
<div class="z-20 mx-auto w-full max-w-170 xl:mx-0" style="perspective: 1000px;">
|
||||||
<div
|
<div
|
||||||
class="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950 shadow-[0_40px_100px_rgba(0,0,0,0.7),0_0_0_1px_rgba(255,255,255,0.04)] will-change-transform"
|
class="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950 shadow-[0_40px_100px_rgba(0,0,0,0.7),0_0_0_1px_rgba(255,255,255,0.04)] will-change-transform"
|
||||||
style="transform: perspective(1000px) rotateY(-6deg) rotateX(3deg); transform-style: preserve-3d;"
|
style="transform: perspective(1000px) rotateY(-6deg) rotateX(3deg); transform-style: preserve-3d;"
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="hidden h-full w-full max-w-140 items-center justify-end lg:flex z-20">
|
<div class="z-20 hidden h-full w-full max-w-140 items-center justify-end lg:flex">
|
||||||
<div
|
<div
|
||||||
class="w-full overflow-hidden rounded-2xl border border-white/8 bg-zinc-950 shadow-[0_0_0_1px_rgba(255,255,255,0.03),0_40px_80px_rgba(0,0,0,0.5),0_0_80px_rgba(255,69,0,0.05)]"
|
class="w-full overflow-hidden rounded-2xl border border-white/8 bg-zinc-950 shadow-[0_0_0_1px_rgba(255,255,255,0.03),0_40px_80px_rgba(0,0,0,0.5),0_0_80px_rgba(255,69,0,0.05)]"
|
||||||
>
|
>
|
||||||
@@ -81,9 +81,7 @@
|
|||||||
class="grid grid-cols-[1fr_56px_76px] gap-2.5 border-b border-white/6 bg-white/1 px-4 py-3.5"
|
class="grid grid-cols-[1fr_56px_76px] gap-2.5 border-b border-white/6 bg-white/1 px-4 py-3.5"
|
||||||
>
|
>
|
||||||
<span class="font-mono text-[0.55rem] tracking-widest text-zinc-600">ИНСТРУМЕНТ</span>
|
<span class="font-mono text-[0.55rem] tracking-widest text-zinc-600">ИНСТРУМЕНТ</span>
|
||||||
<span class="text-right font-mono text-[0.55rem] tracking-widest text-zinc-600"
|
<span class="text-right font-mono text-[0.55rem] tracking-widest text-zinc-600">КОНФИД.</span>
|
||||||
>КОНФИД.</span
|
|
||||||
>
|
|
||||||
<span class="pl-2 font-mono text-[0.55rem] tracking-widest text-zinc-600">ПРОГНОЗ</span>
|
<span class="pl-2 font-mono text-[0.55rem] tracking-widest text-zinc-600">ПРОГНОЗ</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
|
|
||||||
<footer class="mt-auto border-t border-white/6 bg-bg-e">
|
<footer class="mt-auto border-t border-white/6 bg-bg-e">
|
||||||
<div class="mx-auto max-w-400 px-5 py-20 sm:px-10">
|
<div class="mx-auto max-w-400 px-5 py-20 sm:px-10">
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-10 sm:grid-cols-4 sm:justify-between">
|
<div class="grid grid-cols-2 gap-10 sm:grid-cols-4 sm:justify-between">
|
||||||
<div class="col-span-2 flex flex-col gap-4 lg:col-span-1">
|
<div class="col-span-2 flex flex-col gap-4 lg:col-span-1">
|
||||||
<Logo />
|
<Logo />
|
||||||
@@ -23,7 +22,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<span class="font-mono text-base tracking-widest text-zinc-600 uppercase mb-1">Навигация</span>
|
<span class="mb-1 font-mono text-base tracking-widest text-zinc-600 uppercase"
|
||||||
|
>Навигация</span
|
||||||
|
>
|
||||||
{#each navLinks as link (link.id)}
|
{#each navLinks as link (link.id)}
|
||||||
<a
|
<a
|
||||||
href={link.href}
|
href={link.href}
|
||||||
@@ -59,6 +60,5 @@
|
|||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { tick } from 'svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { gsap } from 'gsap';
|
import { gsap } from 'gsap';
|
||||||
import { cn } from '$lib/utils';
|
import { cn } from '$lib/utils';
|
||||||
@@ -13,6 +15,9 @@
|
|||||||
|
|
||||||
let mobileOpen = $state(false);
|
let mobileOpen = $state(false);
|
||||||
let header = $state<HTMLElement>(null!);
|
let header = $state<HTMLElement>(null!);
|
||||||
|
let menuButton = $state<HTMLButtonElement>(null!);
|
||||||
|
|
||||||
|
const mobileMenuId = 'mobile-navigation';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||||
@@ -21,12 +26,37 @@
|
|||||||
opacity: 0,
|
opacity: 0,
|
||||||
duration: 0.5,
|
duration: 0.5,
|
||||||
ease: 'power2.out',
|
ease: 'power2.out',
|
||||||
clearProps: 'transform,opacity',
|
clearProps: 'transform,opacity'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function isActive(href: string) {
|
||||||
|
const pathname = page.url.pathname;
|
||||||
|
return href === '/' ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleMobileMenu() {
|
||||||
|
mobileOpen = !mobileOpen;
|
||||||
|
if (mobileOpen) {
|
||||||
|
await tick();
|
||||||
|
header.querySelector<HTMLAnchorElement>(`#${mobileMenuId} a`)?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMobileMenu({ restoreFocus = false } = {}) {
|
||||||
|
mobileOpen = false;
|
||||||
|
if (restoreFocus) menuButton?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== 'Escape' || !mobileOpen) return;
|
||||||
|
closeMobileMenu({ restoreFocus: true });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header bind:this={header} class="fixed right-0 left-0 border-b border-b-bg-h bg-bg-e z-9999">
|
<svelte:window onkeydown={handleWindowKeydown} />
|
||||||
|
|
||||||
|
<header bind:this={header} class="fixed right-0 left-0 z-9999 border-b border-b-bg-h bg-bg-e">
|
||||||
<div class="mx-auto flex max-w-400 items-center justify-between px-5 py-5 sm:px-10 lg:px-5">
|
<div class="mx-auto flex max-w-400 items-center justify-between px-5 py-5 sm:px-10 lg:px-5">
|
||||||
<Logo />
|
<Logo />
|
||||||
|
|
||||||
@@ -37,6 +67,7 @@
|
|||||||
<a
|
<a
|
||||||
href={link.href}
|
href={link.href}
|
||||||
class={cn('transition-colors duration-300', link.isButton ? 'nav-btn' : 'nav-link')}
|
class={cn('transition-colors duration-300', link.isButton ? 'nav-btn' : 'nav-link')}
|
||||||
|
aria-current={isActive(link.href) ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
{link.text}
|
{link.text}
|
||||||
</a>
|
</a>
|
||||||
@@ -46,10 +77,13 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
bind:this={menuButton}
|
||||||
|
type="button"
|
||||||
class="flex flex-col justify-center gap-1.5 p-1 lg:hidden"
|
class="flex flex-col justify-center gap-1.5 p-1 lg:hidden"
|
||||||
onclick={() => (mobileOpen = !mobileOpen)}
|
onclick={toggleMobileMenu}
|
||||||
aria-label={mobileOpen ? 'Закрыть меню' : 'Открыть меню'}
|
aria-label={mobileOpen ? 'Закрыть меню' : 'Открыть меню'}
|
||||||
aria-expanded={mobileOpen}
|
aria-expanded={mobileOpen}
|
||||||
|
aria-controls={mobileMenuId}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class={cn(
|
class={cn(
|
||||||
@@ -60,7 +94,7 @@
|
|||||||
<span
|
<span
|
||||||
class={cn(
|
class={cn(
|
||||||
'block h-0.5 w-6 bg-title transition-all duration-200',
|
'block h-0.5 w-6 bg-title transition-all duration-200',
|
||||||
mobileOpen && 'opacity-0 scale-x-0'
|
mobileOpen && 'scale-x-0 opacity-0'
|
||||||
)}
|
)}
|
||||||
></span>
|
></span>
|
||||||
<span
|
<span
|
||||||
@@ -74,7 +108,7 @@
|
|||||||
|
|
||||||
<!-- Мобильное выпадающее меню -->
|
<!-- Мобильное выпадающее меню -->
|
||||||
{#if mobileOpen}
|
{#if mobileOpen}
|
||||||
<nav class="border-t border-white/6 px-5 pb-4 sm:px-10 lg:hidden">
|
<nav id={mobileMenuId} class="border-t border-white/6 px-5 pb-4 sm:px-10 lg:hidden">
|
||||||
<ul class="flex flex-col">
|
<ul class="flex flex-col">
|
||||||
{#each menuLinks as link (link.id)}
|
{#each menuLinks as link (link.id)}
|
||||||
<li>
|
<li>
|
||||||
@@ -82,7 +116,8 @@
|
|||||||
<a
|
<a
|
||||||
href={link.href}
|
href={link.href}
|
||||||
class="nav-btn mt-3 block text-center"
|
class="nav-btn mt-3 block text-center"
|
||||||
onclick={() => (mobileOpen = false)}
|
onclick={() => closeMobileMenu()}
|
||||||
|
aria-current={isActive(link.href) ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
{link.text}
|
{link.text}
|
||||||
</a>
|
</a>
|
||||||
@@ -90,7 +125,8 @@
|
|||||||
<a
|
<a
|
||||||
href={link.href}
|
href={link.href}
|
||||||
class="nav-link block border-b border-white/6 py-3.5 last:border-0"
|
class="nav-link block border-b border-white/6 py-3.5 last:border-0"
|
||||||
onclick={() => (mobileOpen = false)}
|
onclick={() => closeMobileMenu()}
|
||||||
|
aria-current={isActive(link.href) ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
{link.text}
|
{link.text}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -5,29 +5,45 @@
|
|||||||
type Props = {
|
type Props = {
|
||||||
href?: string;
|
href?: string;
|
||||||
target?: string;
|
target?: string;
|
||||||
|
type?: 'button' | 'submit' | 'reset';
|
||||||
variant?: 'primary' | 'secondary';
|
variant?: 'primary' | 'secondary';
|
||||||
class?: string;
|
class?: string;
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { href, target = '_self', variant = 'primary', class: className, children }: Props = $props();
|
let {
|
||||||
|
href,
|
||||||
|
target = '_self',
|
||||||
|
type = 'button',
|
||||||
|
variant = 'primary',
|
||||||
|
class: className,
|
||||||
|
children
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const classes = $derived(
|
||||||
|
cn(
|
||||||
|
'flex w-fit items-center justify-center gap-2 rounded-xl px-6 py-3 font-display text-lg font-medium transition-colors duration-300',
|
||||||
|
variant === 'primary' && 'bg-primary hover:bg-primary-h',
|
||||||
|
variant === 'secondary' && 'border bg-bg hover:text-desc',
|
||||||
|
className
|
||||||
|
)
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:element
|
{#if href}
|
||||||
this={href ? 'a' : 'button'}
|
<a {href} {target} rel={target === '_blank' ? 'noopener noreferrer' : undefined} class={classes}>
|
||||||
{href}
|
{@render children()}
|
||||||
{target}
|
|
||||||
rel={target === '_blank' ? 'noopener noreferrer' : undefined}
|
|
||||||
class={cn(
|
|
||||||
'flex items-center justify-center gap-2 w-fit rounded-xl px-6 py-3 font-display text-lg font-medium transition-colors duration-300',
|
|
||||||
variant === 'primary' && 'bg-primary hover:bg-primary-h',
|
|
||||||
variant === 'secondary' && 'bg-bg border hover:text-desc',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{@render children()}
|
|
||||||
|
|
||||||
{#if variant === 'primary'}
|
{#if variant === 'primary'}
|
||||||
<img src="images/icons/arrow-right.svg" alt="Перейти" class="h-5 w-5" />
|
<img src="/images/icons/arrow-right.svg" alt="" aria-hidden="true" class="h-5 w-5" />
|
||||||
{/if}
|
{/if}
|
||||||
</svelte:element>
|
</a>
|
||||||
|
{:else}
|
||||||
|
<button {type} class={classes}>
|
||||||
|
{@render children()}
|
||||||
|
|
||||||
|
{#if variant === 'primary'}
|
||||||
|
<img src="/images/icons/arrow-right.svg" alt="" aria-hidden="true" class="h-5 w-5" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a href="/" class="flex items-center gap-2.5 no-underline max-w-fit">
|
<a href="/" class="flex max-w-fit items-center gap-2.5 no-underline">
|
||||||
<img src="images/icons/logo.svg" alt="Логотип" class="w-8 h-8" />
|
<img src="/images/icons/logo.svg" alt="" aria-hidden="true" class="h-8 w-8" />
|
||||||
<span class="font-display text-xl font-black tracking-tighter">
|
<span class="font-display text-xl font-black tracking-tighter">
|
||||||
FLAMY<span class="text-primary">TRADE</span>
|
FLAMY<span class="text-primary">TRADE</span>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { env } from '$env/dynamic/public';
|
||||||
|
|
||||||
|
export const site = {
|
||||||
|
name: env.PUBLIC_SITE_NAME || 'Flamy Trade',
|
||||||
|
url: (env.PUBLIC_SITE_URL || 'https://trade.flamy.studio').replace(/\/$/, ''),
|
||||||
|
description: env.PUBLIC_SITE_DESCRIPTION || 'Публичная витрина ML-прогнозов для крипторынка.'
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageMeta = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function pageTitle(title?: string): string {
|
||||||
|
return title ? `${title} | ${site.name}` : site.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonical(path = '/'): string {
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
return `${site.url}${normalizedPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageMeta({
|
||||||
|
title,
|
||||||
|
description = site.description,
|
||||||
|
path = '/'
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
path?: string;
|
||||||
|
} = {}): PageMeta {
|
||||||
|
return {
|
||||||
|
title: pageTitle(title),
|
||||||
|
description,
|
||||||
|
url: canonical(path)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { env } from '$env/dynamic/public';
|
||||||
|
import {
|
||||||
|
generateChartData,
|
||||||
|
SYMBOLS,
|
||||||
|
TIMEFRAMES,
|
||||||
|
type ChartData,
|
||||||
|
type TimeframeId
|
||||||
|
} from '$lib/stores/chartStore';
|
||||||
|
|
||||||
|
const supportedModes = ['research-static'] as const;
|
||||||
|
|
||||||
|
export type DashboardDataMode = (typeof supportedModes)[number];
|
||||||
|
|
||||||
|
export const dashboardDataMode: DashboardDataMode = supportedModes.includes(
|
||||||
|
env.PUBLIC_DASHBOARD_DATA_MODE as DashboardDataMode
|
||||||
|
)
|
||||||
|
? (env.PUBLIC_DASHBOARD_DATA_MODE as DashboardDataMode)
|
||||||
|
: 'research-static';
|
||||||
|
|
||||||
|
export const dashboardDataNotice =
|
||||||
|
dashboardDataMode === 'research-static'
|
||||||
|
? 'Исследовательские синтетические данные'
|
||||||
|
: 'Исследовательские данные';
|
||||||
|
|
||||||
|
export function getDashboardChartData(symbolId: string, timeframeId: TimeframeId): ChartData {
|
||||||
|
return generateChartData(symbolId, timeframeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SYMBOLS, TIMEFRAMES };
|
||||||
@@ -67,7 +67,7 @@ export function scrollFadeUp(el: HTMLElement, params: FadeUpParams = {}) {
|
|||||||
delay,
|
delay,
|
||||||
ease: 'power3.out',
|
ease: 'power3.out',
|
||||||
clearProps: 'transform,opacity',
|
clearProps: 'transform,opacity',
|
||||||
scrollTrigger: { trigger: el, start, once: true },
|
scrollTrigger: { trigger: el, start, once: true }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ export function scrollRevealLeft(el: HTMLElement, params: RevealLeftParams = {})
|
|||||||
delay,
|
delay,
|
||||||
ease: 'power3.out',
|
ease: 'power3.out',
|
||||||
clearProps: 'transform,opacity',
|
clearProps: 'transform,opacity',
|
||||||
scrollTrigger: { trigger: el, start, once: true },
|
scrollTrigger: { trigger: el, start, once: true }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ export function scrollStagger(el: HTMLElement, params: StaggerParams = {}) {
|
|||||||
duration = 0.65,
|
duration = 0.65,
|
||||||
stagger = 0.1,
|
stagger = 0.1,
|
||||||
start = 'top 80%',
|
start = 'top 80%',
|
||||||
selector = ':scope > *',
|
selector = ':scope > *'
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const targets = el.querySelectorAll<HTMLElement>(selector);
|
const targets = el.querySelectorAll<HTMLElement>(selector);
|
||||||
@@ -133,7 +133,7 @@ export function scrollStagger(el: HTMLElement, params: StaggerParams = {}) {
|
|||||||
ease: 'power3.out',
|
ease: 'power3.out',
|
||||||
stagger: { each: stagger, ease: 'power1.inOut' },
|
stagger: { each: stagger, ease: 'power1.inOut' },
|
||||||
clearProps: 'transform,opacity',
|
clearProps: 'transform,opacity',
|
||||||
scrollTrigger: { trigger: el, start, once: true },
|
scrollTrigger: { trigger: el, start, once: true }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ export function scrollScaleIn(el: HTMLElement, params: { delay?: number; start?:
|
|||||||
delay,
|
delay,
|
||||||
ease: 'power3.out',
|
ease: 'power3.out',
|
||||||
clearProps: 'transform,opacity',
|
clearProps: 'transform,opacity',
|
||||||
scrollTrigger: { trigger: el, start, once: true },
|
scrollTrigger: { trigger: el, start, once: true }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const SYMBOLS = [
|
|||||||
{ id: 'XRPUSDT', label: 'XRP/USDT', short: 'XRP', base: 0.55, dec: 5 },
|
{ id: 'XRPUSDT', label: 'XRP/USDT', short: 'XRP', base: 0.55, dec: 5 },
|
||||||
{ id: 'ADAUSDT', label: 'ADA/USDT', short: 'ADA', base: 0.43, dec: 5 },
|
{ id: 'ADAUSDT', label: 'ADA/USDT', short: 'ADA', base: 0.43, dec: 5 },
|
||||||
{ id: 'DOGEUSDT', label: 'DOGE/USDT', short: 'DOGE', base: 0.131, dec: 5 },
|
{ id: 'DOGEUSDT', label: 'DOGE/USDT', short: 'DOGE', base: 0.131, dec: 5 },
|
||||||
{ id: 'LINKUSDT', label: 'LINK/USDT', short: 'LINK', base: 18.5, dec: 3 },
|
{ id: 'LINKUSDT', label: 'LINK/USDT', short: 'LINK', base: 18.5, dec: 3 }
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const TIMEFRAMES = [
|
export const TIMEFRAMES = [
|
||||||
@@ -48,7 +48,7 @@ export const TIMEFRAMES = [
|
|||||||
{ id: '15m' as TimeframeId, label: '15м', sec: 900 },
|
{ id: '15m' as TimeframeId, label: '15м', sec: 900 },
|
||||||
{ id: '1h' as TimeframeId, label: '1ч', sec: 3600 },
|
{ id: '1h' as TimeframeId, label: '1ч', sec: 3600 },
|
||||||
{ id: '4h' as TimeframeId, label: '4ч', sec: 14400 },
|
{ id: '4h' as TimeframeId, label: '4ч', sec: 14400 },
|
||||||
{ id: '1d' as TimeframeId, label: '1д', sec: 86400 },
|
{ id: '1d' as TimeframeId, label: '1д', sec: 86400 }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Simple LCG PRNG — deterministic per seed so switching back gives the same chart
|
// Simple LCG PRNG — deterministic per seed so switching back gives the same chart
|
||||||
@@ -91,7 +91,13 @@ export function generateChartData(symbolId: string, tfId: TimeframeId): ChartDat
|
|||||||
const low = Math.min(open, close) - bodyH * wk * 0.7 - rand() * vol * 0.2;
|
const low = Math.min(open, close) - bodyH * wk * 0.7 - rand() * vol * 0.2;
|
||||||
|
|
||||||
const round = (n: number) => parseFloat(n.toFixed(Math.min(sym.dec + 2, 8)));
|
const round = (n: number) => parseFloat(n.toFixed(Math.min(sym.dec + 2, 8)));
|
||||||
candles.push({ time: startTime + i * tf.sec, open: round(open), high: round(high), low: round(low), close: round(close) });
|
candles.push({
|
||||||
|
time: startTime + i * tf.sec,
|
||||||
|
open: round(open),
|
||||||
|
high: round(high),
|
||||||
|
low: round(low),
|
||||||
|
close: round(close)
|
||||||
|
});
|
||||||
price = close;
|
price = close;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,15 +113,12 @@ export function generateChartData(symbolId: string, tfId: TimeframeId): ChartDat
|
|||||||
const predDir = rand() > 0.42 ? 1 : -1;
|
const predDir = rand() > 0.42 ? 1 : -1;
|
||||||
const predStrength = (0.006 + rand() * 0.018) * sym.base;
|
const predStrength = (0.006 + rand() * 0.018) * sym.base;
|
||||||
|
|
||||||
const prediction: PredPoint[] = [
|
const prediction: PredPoint[] = [{ time: candles[HISTORY - 1].time, value: currentPrice }];
|
||||||
{ time: candles[HISTORY - 1].time, value: currentPrice },
|
|
||||||
];
|
|
||||||
|
|
||||||
let pPrice = currentPrice;
|
|
||||||
for (let i = 1; i <= HORIZON; i++) {
|
for (let i = 1; i <= HORIZON; i++) {
|
||||||
const t = i / HORIZON;
|
const t = i / HORIZON;
|
||||||
const noise = (rand() - 0.5) * vol * 0.6;
|
const noise = (rand() - 0.5) * vol * 0.6;
|
||||||
pPrice = currentPrice + predDir * predStrength * t + noise;
|
const pPrice = currentPrice + predDir * predStrength * t + noise;
|
||||||
const round = (n: number) => parseFloat(n.toFixed(Math.min(sym.dec + 2, 8)));
|
const round = (n: number) => parseFloat(n.toFixed(Math.min(sym.dec + 2, 8)));
|
||||||
prediction.push({ time: now + i * tf.sec, value: round(Math.max(pPrice, currentPrice * 0.1)) });
|
prediction.push({ time: now + i * tf.sec, value: round(Math.max(pPrice, currentPrice * 0.1)) });
|
||||||
}
|
}
|
||||||
@@ -127,8 +130,12 @@ export function generateChartData(symbolId: string, tfId: TimeframeId): ChartDat
|
|||||||
|
|
||||||
const round = (n: number) => parseFloat(n.toFixed(Math.min(sym.dec + 2, 8)));
|
const round = (n: number) => parseFloat(n.toFixed(Math.min(sym.dec + 2, 8)));
|
||||||
const entry = round(currentPrice);
|
const entry = round(currentPrice);
|
||||||
const tp = round(direction === 'long' ? currentPrice + predRange * 1.3 : currentPrice - predRange * 1.3);
|
const tp = round(
|
||||||
const sl = round(direction === 'long' ? currentPrice - predRange * 0.65 : currentPrice + predRange * 0.65);
|
direction === 'long' ? currentPrice + predRange * 1.3 : currentPrice - predRange * 1.3
|
||||||
|
);
|
||||||
|
const sl = round(
|
||||||
|
direction === 'long' ? currentPrice - predRange * 0.65 : currentPrice + predRange * 0.65
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
candles,
|
candles,
|
||||||
@@ -136,6 +143,6 @@ export function generateChartData(symbolId: string, tfId: TimeframeId): ChartDat
|
|||||||
signal: { direction, confidence, entry, tp, sl },
|
signal: { direction, confidence, entry, tp, sl },
|
||||||
currentPrice,
|
currentPrice,
|
||||||
change24h,
|
change24h,
|
||||||
change24hPct,
|
change24hPct
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,9 @@
|
|||||||
lg:h-[70vh] lg:flex-row lg:items-center lg:justify-center lg:gap-40 lg:py-0"
|
lg:h-[70vh] lg:flex-row lg:items-center lg:justify-center lg:gap-40 lg:py-0"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h2
|
<h2 use:scrollRevealLeft class="font-display text-4xl font-bold sm:text-5xl lg:text-7xl">
|
||||||
use:scrollRevealLeft
|
Дашборд
|
||||||
class="font-display text-4xl font-bold sm:text-5xl lg:text-7xl"
|
</h2>
|
||||||
>Дашборд</h2>
|
|
||||||
|
|
||||||
<p
|
<p
|
||||||
use:scrollFadeUp={{ delay: 0.1 }}
|
use:scrollFadeUp={{ delay: 0.1 }}
|
||||||
|
|||||||
@@ -27,10 +27,7 @@
|
|||||||
{#if posts.length > 0}
|
{#if posts.length > 0}
|
||||||
<div class="border-b border-white/6">
|
<div class="border-b border-white/6">
|
||||||
<section class="px-5 py-24 sm:px-10 sm:py-32">
|
<section class="px-5 py-24 sm:px-10 sm:py-32">
|
||||||
<div
|
<div use:scrollFadeUp={{ y: 20 }} class="mb-12 flex items-end justify-between">
|
||||||
use:scrollFadeUp={{ y: 20 }}
|
|
||||||
class="mb-12 flex items-end justify-between"
|
|
||||||
>
|
|
||||||
<h2 class="font-display text-4xl font-black tracking-tighter uppercase">БЛОГ</h2>
|
<h2 class="font-display text-4xl font-black tracking-tighter uppercase">БЛОГ</h2>
|
||||||
<a
|
<a
|
||||||
href="/blog"
|
href="/blog"
|
||||||
@@ -44,7 +41,7 @@
|
|||||||
use:scrollStagger={{ selector: 'a', stagger: 0.1, y: 32 }}
|
use:scrollStagger={{ selector: 'a', stagger: 0.1, y: 32 }}
|
||||||
class="grid grid-cols-1 overflow-hidden rounded-xl border border-white/6 sm:grid-cols-[repeat(auto-fit,minmax(20rem,1fr))]"
|
class="grid grid-cols-1 overflow-hidden rounded-xl border border-white/6 sm:grid-cols-[repeat(auto-fit,minmax(20rem,1fr))]"
|
||||||
>
|
>
|
||||||
{#each posts as post, i}
|
{#each posts as post, i (post.slug)}
|
||||||
<a
|
<a
|
||||||
href="/blog/{post.slug}"
|
href="/blog/{post.slug}"
|
||||||
class="flex flex-col gap-5 p-10 no-underline transition-colors duration-150 hover:bg-white/2
|
class="flex flex-col gap-5 p-10 no-underline transition-colors duration-150 hover:bg-white/2
|
||||||
|
|||||||
@@ -12,35 +12,42 @@
|
|||||||
gsap.registerPlugin(ScrollTrigger);
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
const ctx = gsap.context(() => {
|
const ctx = gsap.context(() => {
|
||||||
const heading = section.querySelector<HTMLElement>('[data-cta-heading]');
|
const heading = section.querySelector<HTMLElement>('[data-cta-heading]');
|
||||||
const btnWrap = section.querySelector<HTMLElement>('[data-cta-btn]');
|
const btnWrap = section.querySelector<HTMLElement>('[data-cta-btn]');
|
||||||
const disclaimer = section.querySelector<HTMLElement>('[data-cta-note]');
|
const disclaimer = section.querySelector<HTMLElement>('[data-cta-note]');
|
||||||
|
|
||||||
if (heading) {
|
if (heading) {
|
||||||
gsap.set(heading, { opacity: 0, scale: 0.88, y: 32 });
|
gsap.set(heading, { opacity: 0, scale: 0.88, y: 32 });
|
||||||
gsap.to(heading, {
|
gsap.to(heading, {
|
||||||
opacity: 1, scale: 1, y: 0,
|
opacity: 1,
|
||||||
duration: 0.8, ease: 'power3.out',
|
scale: 1,
|
||||||
|
y: 0,
|
||||||
|
duration: 0.8,
|
||||||
|
ease: 'power3.out',
|
||||||
clearProps: 'transform,opacity',
|
clearProps: 'transform,opacity',
|
||||||
scrollTrigger: { trigger: section, start: 'top 75%', once: true },
|
scrollTrigger: { trigger: section, start: 'top 75%', once: true }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (btnWrap) {
|
if (btnWrap) {
|
||||||
gsap.set(btnWrap, { opacity: 0, y: 20 });
|
gsap.set(btnWrap, { opacity: 0, y: 20 });
|
||||||
gsap.to(btnWrap, {
|
gsap.to(btnWrap, {
|
||||||
opacity: 1, y: 0,
|
opacity: 1,
|
||||||
duration: 0.6, delay: 0.2, ease: 'power3.out',
|
y: 0,
|
||||||
|
duration: 0.6,
|
||||||
|
delay: 0.2,
|
||||||
|
ease: 'power3.out',
|
||||||
clearProps: 'transform,opacity',
|
clearProps: 'transform,opacity',
|
||||||
scrollTrigger: { trigger: section, start: 'top 75%', once: true },
|
scrollTrigger: { trigger: section, start: 'top 75%', once: true }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (disclaimer) {
|
if (disclaimer) {
|
||||||
gsap.set(disclaimer, { opacity: 0 });
|
gsap.set(disclaimer, { opacity: 0 });
|
||||||
gsap.to(disclaimer, {
|
gsap.to(disclaimer, {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
duration: 0.6, delay: 0.4,
|
duration: 0.6,
|
||||||
|
delay: 0.4,
|
||||||
clearProps: 'opacity',
|
clearProps: 'opacity',
|
||||||
scrollTrigger: { trigger: section, start: 'top 75%', once: true },
|
scrollTrigger: { trigger: section, start: 'top 75%', once: true }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, section);
|
}, section);
|
||||||
|
|||||||
@@ -42,7 +42,9 @@
|
|||||||
<h2
|
<h2
|
||||||
use:scrollRevealLeft
|
use:scrollRevealLeft
|
||||||
class="mb-16 font-display text-4xl font-black tracking-tighter uppercase"
|
class="mb-16 font-display text-4xl font-black tracking-tighter uppercase"
|
||||||
>ВОЗМОЖНОСТИ</h2>
|
>
|
||||||
|
ВОЗМОЖНОСТИ
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
use:scrollStagger={{ selector: '[data-feature-row]', stagger: 0.07, y: 28 }}
|
use:scrollStagger={{ selector: '[data-feature-row]', stagger: 0.07, y: 28 }}
|
||||||
|
|||||||
@@ -11,26 +11,26 @@
|
|||||||
|
|
||||||
const ctx = gsap.context(() => {
|
const ctx = gsap.context(() => {
|
||||||
const words = section.querySelectorAll<HTMLElement>('[data-hero-word]');
|
const words = section.querySelectorAll<HTMLElement>('[data-hero-word]');
|
||||||
const desc = section.querySelector<HTMLElement>('[data-hero-desc]');
|
const desc = section.querySelector<HTMLElement>('[data-hero-desc]');
|
||||||
const cta = section.querySelector<HTMLElement>('[data-hero-cta]');
|
const cta = section.querySelector<HTMLElement>('[data-hero-cta]');
|
||||||
const mock = section.querySelector<HTMLElement>('[data-hero-mockup]');
|
const mock = section.querySelector<HTMLElement>('[data-hero-mockup]');
|
||||||
|
|
||||||
gsap.set(words, { opacity: 0, y: 44, skewX: -6 });
|
gsap.set(words, { opacity: 0, y: 44, skewX: -6 });
|
||||||
gsap.set(desc, { opacity: 0, y: 24 });
|
gsap.set(desc, { opacity: 0, y: 24 });
|
||||||
gsap.set(cta, { opacity: 0, y: 16 });
|
gsap.set(cta, { opacity: 0, y: 16 });
|
||||||
gsap.set(mock, { opacity: 0, x: 48 });
|
gsap.set(mock, { opacity: 0, x: 48 });
|
||||||
|
|
||||||
const tl = gsap.timeline({
|
const tl = gsap.timeline({
|
||||||
defaults: { ease: 'power3.out' },
|
defaults: { ease: 'power3.out' },
|
||||||
onComplete: () => gsap.set([words, desc, cta, mock], { clearProps: 'all' }),
|
onComplete: () => gsap.set([words, desc, cta, mock], { clearProps: 'all' })
|
||||||
});
|
});
|
||||||
|
|
||||||
words.forEach((w, i) =>
|
words.forEach((w, i) =>
|
||||||
tl.to(w, { opacity: 1, y: 0, skewX: 0, duration: 0.75 }, 0.05 + i * 0.1)
|
tl.to(w, { opacity: 1, y: 0, skewX: 0, duration: 0.75 }, 0.05 + i * 0.1)
|
||||||
);
|
);
|
||||||
tl.to(desc, { opacity: 1, y: 0, duration: 0.65, ease: 'power2.out' }, 0.42);
|
tl.to(desc, { opacity: 1, y: 0, duration: 0.65, ease: 'power2.out' }, 0.42);
|
||||||
tl.to(cta, { opacity: 1, y: 0, duration: 0.55, ease: 'expo.out' }, 0.58);
|
tl.to(cta, { opacity: 1, y: 0, duration: 0.55, ease: 'expo.out' }, 0.58);
|
||||||
tl.to(mock, { opacity: 1, x: 0, duration: 0.9, ease: 'power2.out' }, 0.18);
|
tl.to(mock, { opacity: 1, x: 0, duration: 0.9, ease: 'power2.out' }, 0.18);
|
||||||
}, section);
|
}, section);
|
||||||
|
|
||||||
return () => ctx.revert();
|
return () => ctx.revert();
|
||||||
@@ -47,12 +47,14 @@
|
|||||||
<h1 class="flex flex-col font-display leading-[0.95] font-black uppercase">
|
<h1 class="flex flex-col font-display leading-[0.95] font-black uppercase">
|
||||||
<span data-hero-word class="text-[clamp(2.5rem,10vw,6.25rem)]">Рыночный</span>
|
<span data-hero-word class="text-[clamp(2.5rem,10vw,6.25rem)]">Рыночный</span>
|
||||||
<span data-hero-word class="text-[clamp(3rem,13vw,8rem)]">прогноз</span>
|
<span data-hero-word class="text-[clamp(3rem,13vw,8rem)]">прогноз</span>
|
||||||
<span data-hero-word class="text-[clamp(1.5rem,6.5vw,4rem)] leading-none text-primary">от ML-модели</span>
|
<span data-hero-word class="text-[clamp(1.5rem,6.5vw,4rem)] leading-none text-primary"
|
||||||
|
>от ML-модели</span
|
||||||
|
>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
data-hero-desc
|
data-hero-desc
|
||||||
class="mt-6 max-w-full text-base leading-[1.3] text-desc sm:mt-8 sm:text-xl lg:mt-10 xl:max-w-180 lg:text-2xl lg:leading-[1.1]"
|
class="mt-6 max-w-full text-base leading-[1.3] text-desc sm:mt-8 sm:text-xl lg:mt-10 lg:text-2xl lg:leading-[1.1] xl:max-w-180"
|
||||||
>
|
>
|
||||||
Исторические свечи, прогнозная зона, уровни входа и выхода — всё на одном интерактивном
|
Исторические свечи, прогнозная зона, уровни входа и выхода — всё на одном интерактивном
|
||||||
графике. Горизонт 19 свечей, таймфрейм 5M.
|
графике. Горизонт 19 свечей, таймфрейм 5M.
|
||||||
@@ -64,14 +66,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-hero-mockup class="w-full mx-auto xl:mx-0 z-20">
|
<div data-hero-mockup class="z-20 mx-auto w-full xl:mx-0">
|
||||||
<DashboardMockup />
|
<DashboardMockup />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Визуальные элементы -->
|
|
||||||
<span
|
|
||||||
class="absolute -top-60 left-0 z-0 h-80 w-full rotate-3 rounded-full bg-primary opacity-30 blur-[200px]"
|
|
||||||
></span>
|
|
||||||
<span class="absolute bottom-35 left-0 z-0 hidden h-px w-full bg-bg-h xl:block"></span>
|
<span class="absolute bottom-35 left-0 z-0 hidden h-px w-full bg-bg-h xl:block"></span>
|
||||||
<span class="absolute top-55 left-0 z-0 hidden h-px w-full bg-bg-h xl:block"></span>
|
<span class="absolute top-55 left-0 z-0 hidden h-px w-full bg-bg-h xl:block"></span>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,12 +1,43 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import { page } from '$app/state';
|
||||||
|
import { pageTitle } from '$lib/config/site';
|
||||||
|
|
||||||
|
const title = $derived(
|
||||||
|
page.status === 404
|
||||||
|
? 'Страница не найдена'
|
||||||
|
: page.status >= 500
|
||||||
|
? 'Внутренняя ошибка'
|
||||||
|
: 'Произошла ошибка'
|
||||||
|
);
|
||||||
|
|
||||||
|
const description = $derived(
|
||||||
|
page.status === 404
|
||||||
|
? 'Проверьте адрес или вернитесь к публичному дашборду Flamy Trade.'
|
||||||
|
: 'Запрос не удалось обработать. Попробуйте обновить страницу позже.'
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="flex h-screen items-center justify-center pt-16">
|
<svelte:head>
|
||||||
<h1
|
<title>{pageTitle(title)}</title>
|
||||||
class="flex flex-col text-center font-display text-[110px] leading-[1.1] font-black uppercase"
|
<meta name="robots" content="noindex" />
|
||||||
>
|
</svelte:head>
|
||||||
Страница не найдена
|
|
||||||
<span class="text-[64px] leading-none text-primary"> Возможно стр </span>
|
<section class="flex min-h-screen items-center justify-center px-5 pt-16 text-center">
|
||||||
</h1>
|
<div class="max-w-2xl">
|
||||||
|
<p class="mb-4 font-mono text-xs tracking-widest text-primary uppercase">
|
||||||
|
Ошибка {page.status}
|
||||||
|
</p>
|
||||||
|
<h1 class="font-display text-[clamp(2.75rem,9vw,6.5rem)] leading-none font-black uppercase">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p class="mx-auto mt-5 max-w-lg text-base leading-relaxed text-desc sm:text-lg">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="mt-8 inline-flex rounded-xl bg-primary px-5 py-3 font-display text-sm transition-colors hover:bg-primary-h"
|
||||||
|
>
|
||||||
|
На главную
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,19 +2,15 @@
|
|||||||
import './layout.css';
|
import './layout.css';
|
||||||
import Header from '$lib/components/layout/Header.svelte';
|
import Header from '$lib/components/layout/Header.svelte';
|
||||||
import Footer from '$lib/components/layout/Footer.svelte';
|
import Footer from '$lib/components/layout/Footer.svelte';
|
||||||
|
import { site } from '$lib/config/site';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Unbounded:wght@200..900&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<meta property="og:site_name" content={site.name} />
|
||||||
|
<meta name="twitter:card" content="summary" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="noise"></div>
|
<div class="noise"></div>
|
||||||
|
|||||||
+23
-8
@@ -1,20 +1,35 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Hero from './(sections)/Hero.svelte';
|
||||||
|
import Ticker from './(sections)/Ticker.svelte';
|
||||||
|
import About from './(sections)/About.svelte';
|
||||||
|
import Features from './(sections)/Features.svelte';
|
||||||
|
import Stats from './(sections)/Stats.svelte';
|
||||||
|
import BlogPreview from './(sections)/BlogPreview.svelte';
|
||||||
|
import Cta from './(sections)/Cta.svelte';
|
||||||
|
import { posts } from '$lib/blog/posts';
|
||||||
|
import { pageMeta } from '$lib/config/site';
|
||||||
|
|
||||||
import Hero from './(sections)/Hero.svelte';
|
const meta = pageMeta();
|
||||||
import Ticker from './(sections)/Ticker.svelte';
|
|
||||||
import About from './(sections)/About.svelte';
|
|
||||||
import Features from './(sections)/Features.svelte';
|
|
||||||
import Stats from './(sections)/Stats.svelte';
|
|
||||||
import BlogPreview from './(sections)/BlogPreview.svelte';
|
|
||||||
import Cta from './(sections)/Cta.svelte';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{meta.title}</title>
|
||||||
|
<meta name="description" content={meta.description} />
|
||||||
|
<link rel="canonical" href={meta.url} />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content={meta.title} />
|
||||||
|
<meta property="og:description" content={meta.description} />
|
||||||
|
<meta property="og:url" content={meta.url} />
|
||||||
|
<meta name="twitter:title" content={meta.title} />
|
||||||
|
<meta name="twitter:description" content={meta.description} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<Hero />
|
<Hero />
|
||||||
<Ticker />
|
<Ticker />
|
||||||
<About />
|
<About />
|
||||||
<Features />
|
<Features />
|
||||||
<Stats />
|
<Stats />
|
||||||
<BlogPreview posts={[]} />
|
<BlogPreview {posts} />
|
||||||
<Cta />
|
<Cta />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,9 +1,29 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
import { about, params} from './_data';
|
import { pageMeta } from '$lib/config/site';
|
||||||
import { scrollFadeUp, scrollRevealLeft, scrollStagger } from '$lib/gsap/actions';
|
import { scrollFadeUp, scrollRevealLeft } from '$lib/gsap/actions';
|
||||||
|
import { about, params } from './_data';
|
||||||
|
|
||||||
|
const meta = pageMeta({
|
||||||
|
title: 'О проекте',
|
||||||
|
description:
|
||||||
|
'Flamy Trade показывает исследовательские результаты ML-модели для анализа крипторынка.',
|
||||||
|
path: '/about'
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{meta.title}</title>
|
||||||
|
<meta name="description" content={meta.description} />
|
||||||
|
<link rel="canonical" href={meta.url} />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content={meta.title} />
|
||||||
|
<meta property="og:description" content={meta.description} />
|
||||||
|
<meta property="og:url" content={meta.url} />
|
||||||
|
<meta name="twitter:title" content={meta.title} />
|
||||||
|
<meta name="twitter:description" content={meta.description} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div class="relative border-b border-b-bg-h bg-bg-e">
|
<div class="relative border-b border-b-bg-h bg-bg-e">
|
||||||
<section
|
<section
|
||||||
@@ -23,10 +43,6 @@
|
|||||||
Публичная витрина результатов ML-модели, обученной на исторических рыночных данных.
|
Публичная витрина результатов ML-модели, обученной на исторических рыночных данных.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<span
|
|
||||||
class="absolute -top-20 -right-20 z-0 h-80 w-full rotate-3 rounded-full bg-primary opacity-30 blur-[200px]"
|
|
||||||
></span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="grid max-h-full! grid-cols-1 lg:grid-cols-[1fr_420px]">
|
<section class="grid max-h-full! grid-cols-1 lg:grid-cols-[1fr_420px]">
|
||||||
@@ -58,7 +74,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- padding на родителе — не margin/w-full на самом блоке -->
|
|
||||||
<div class="border-b border-b-bg-h px-5 py-12 sm:px-10 sm:py-16">
|
<div class="border-b border-b-bg-h px-5 py-12 sm:px-10 sm:py-16">
|
||||||
<div
|
<div
|
||||||
class="flex h-[50vh] w-full items-center justify-center rounded-2xl
|
class="flex h-[50vh] w-full items-center justify-center rounded-2xl
|
||||||
@@ -92,7 +107,7 @@
|
|||||||
<p
|
<p
|
||||||
class="mb-2 flex items-center gap-2 font-display text-xs font-bold tracking-widest text-primary uppercase"
|
class="mb-2 flex items-center gap-2 font-display text-xs font-bold tracking-widest text-primary uppercase"
|
||||||
>
|
>
|
||||||
⚠ Дисклеймер
|
Дисклеймер
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm leading-normal text-desc">
|
<p class="text-sm leading-normal text-desc">
|
||||||
Материалы являются результатом работы исследовательской ML-модели и используются в
|
Материалы являются результатом работы исследовательской ML-модели и используются в
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const about: About[] = [
|
|||||||
desc: 'Назначение',
|
desc: 'Назначение',
|
||||||
title: 'Исследовательская платформа',
|
title: 'Исследовательская платформа',
|
||||||
text: [
|
text: [
|
||||||
'Flamy AI — публичная витрина результатов ML-модели, обученной на исторических рыночных данных. Показывает прогнозы по крипто-инструментам: направление, уровни входа, Take Profit и Stop Loss.',
|
'Flamy Trade — публичная витрина результатов ML-модели, обученной на исторических рыночных данных. Показывает прогнозы по крипто-инструментам: направление, уровни входа, Take Profit и Stop Loss.',
|
||||||
'Платформа создана для изучения поведения ML-модели в рыночных условиях — не для торговли реальными средствами.'
|
'Платформа создана для изучения поведения ML-модели в рыночных условиях — не для торговли реальными средствами.'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { posts } from '$lib/blog/posts';
|
import { posts } from '$lib/blog/posts';
|
||||||
import { scrollRevealLeft, scrollFadeUp, scrollStagger } from '$lib/gsap/actions';
|
import { pageMeta } from '$lib/config/site';
|
||||||
|
import { scrollFadeUp, scrollRevealLeft, scrollStagger } from '$lib/gsap/actions';
|
||||||
|
|
||||||
|
const meta = pageMeta({
|
||||||
|
title: 'Блог',
|
||||||
|
description:
|
||||||
|
'Гайды Flamy Trade по чтению графиков, управлению рисками и работе с ML-прогнозами.',
|
||||||
|
path: '/blog'
|
||||||
|
});
|
||||||
|
|
||||||
function fmtDate(d: string) {
|
function fmtDate(d: string) {
|
||||||
try {
|
try {
|
||||||
@@ -15,12 +23,24 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{meta.title}</title>
|
||||||
|
<meta name="description" content={meta.description} />
|
||||||
|
<link rel="canonical" href={meta.url} />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content={meta.title} />
|
||||||
|
<meta property="og:description" content={meta.description} />
|
||||||
|
<meta property="og:url" content={meta.url} />
|
||||||
|
<meta name="twitter:title" content={meta.title} />
|
||||||
|
<meta name="twitter:description" content={meta.description} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div class="relative overflow-hidden border-b border-bg-h bg-bg-e">
|
<div class="relative overflow-hidden border-b border-bg-h bg-bg-e">
|
||||||
<section class="z-10 flex flex-col items-start justify-end px-5 py-16 sm:px-10 sm:py-20">
|
<section class="z-10 flex flex-col items-start justify-end px-5 py-16 sm:px-10 sm:py-20">
|
||||||
<h1
|
<h1
|
||||||
use:scrollRevealLeft={{ start: 'top 95%' }}
|
use:scrollRevealLeft={{ start: 'top 95%' }}
|
||||||
class="font-display text-[clamp(2.5rem,10vw,8rem)] font-black uppercase leading-none"
|
class="font-display text-[clamp(2.5rem,10vw,8rem)] leading-none font-black uppercase"
|
||||||
>
|
>
|
||||||
Блог
|
Блог
|
||||||
</h1>
|
</h1>
|
||||||
@@ -31,15 +51,11 @@
|
|||||||
Гайды по чтению графиков, управлению рисками и работе с ML-прогнозами.
|
Гайды по чтению графиков, управлению рисками и работе с ML-прогнозами.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<span
|
|
||||||
class="absolute -right-20 -bottom-40 z-0 h-80 w-full -rotate-3 rounded-full bg-primary opacity-30 blur-[200px]"
|
|
||||||
></span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
use:scrollStagger={{ selector: 'a', stagger: 0.08, y: 24, start: 'top 88%' }}
|
use:scrollStagger={{ selector: 'a', stagger: 0.08, y: 24, start: 'top 88%' }}
|
||||||
class="mx-auto max-w-400 px-5 sm:px-10 lg:px-0 mb-32"
|
class="mx-auto mb-32 max-w-400 px-5 sm:px-10 lg:px-0"
|
||||||
>
|
>
|
||||||
{#if posts.length === 0}
|
{#if posts.length === 0}
|
||||||
<div class="flex flex-col items-center gap-3 py-20 text-center">
|
<div class="flex flex-col items-center gap-3 py-20 text-center">
|
||||||
@@ -64,12 +80,11 @@
|
|||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-linear-to-br from-primary/10 via-transparent to-transparent"
|
class="absolute inset-0 bg-linear-to-br from-primary/10 via-transparent to-transparent"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
||||||
<h2
|
<h2
|
||||||
class="font-display text-[clamp(1.125rem,2.5vw,1.625rem)] font-black leading-[1.15] tracking-tight text-title transition-colors duration-150 group-hover:text-primary"
|
class="font-display text-[clamp(1.125rem,2.5vw,1.625rem)] leading-[1.15] font-black tracking-tight text-title transition-colors duration-150 group-hover:text-primary"
|
||||||
>
|
>
|
||||||
{post.title}
|
{post.title}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -80,7 +95,7 @@
|
|||||||
<div class="mt-1 flex flex-wrap gap-1.5">
|
<div class="mt-1 flex flex-wrap gap-1.5">
|
||||||
{#each post.tags.slice(0, 3) as tag, i (i)}
|
{#each post.tags.slice(0, 3) as tag, i (i)}
|
||||||
<span
|
<span
|
||||||
class="rounded-sm border border-white/6 bg-white/3 px-1.75 py-0.5 font-mono text-xs uppercase tracking-widest text-desc/60"
|
class="rounded-sm border border-white/6 bg-white/3 px-1.75 py-0.5 font-mono text-xs tracking-widest text-desc/60 uppercase"
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
@@ -96,9 +111,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="shrink-0 pt-1 text-desc/30 transition-[color,translate] duration-700 group-hover:translate-x-2 group-hover:text-primary mr-6"
|
class="mr-6 shrink-0 pt-1 text-desc/30 transition-[color,translate] duration-700 group-hover:translate-x-2 group-hover:text-primary"
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
d="M4 10 H16 M12 6 L16 10 L12 14"
|
d="M4 10 H16 M12 6 L16 10 L12 14"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -112,4 +127,4 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
import { pageMeta } from '$lib/config/site';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
const { post } = $derived(data);
|
const { post } = $derived(data);
|
||||||
|
const meta = $derived(
|
||||||
|
pageMeta({
|
||||||
|
title: post.title,
|
||||||
|
description: post.description,
|
||||||
|
path: `/blog/${post.slug}`
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
function fmtDate(d: string) {
|
function fmtDate(d: string) {
|
||||||
try {
|
try {
|
||||||
@@ -17,13 +25,26 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{meta.title}</title>
|
||||||
|
<meta name="description" content={meta.description} />
|
||||||
|
<link rel="canonical" href={meta.url} />
|
||||||
|
<meta property="og:type" content="article" />
|
||||||
|
<meta property="og:title" content={meta.title} />
|
||||||
|
<meta property="og:description" content={meta.description} />
|
||||||
|
<meta property="og:url" content={meta.url} />
|
||||||
|
<meta property="article:published_time" content={post.date} />
|
||||||
|
<meta name="twitter:title" content={meta.title} />
|
||||||
|
<meta name="twitter:description" content={meta.description} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div class="mx-auto max-w-3xl px-5 pb-24 pt-16 sm:px-10 sm:pb-32 sm:pt-20 lg:px-0 mt-10">
|
<div class="mx-auto mt-10 max-w-3xl px-5 pt-16 pb-24 sm:px-10 sm:pt-20 sm:pb-32 lg:px-0">
|
||||||
<a
|
<a
|
||||||
href="/blog"
|
href="/blog"
|
||||||
class="back-link mb-6 inline-flex items-center gap-1.5 font-mono text-[0.625rem] uppercase tracking-[0.08em] transition-colors"
|
class="back-link mb-6 inline-flex items-center gap-1.5 font-mono text-[0.625rem] tracking-[0.08em] uppercase transition-colors"
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
d="M11 7 H3 M6 4 L3 7 L6 10"
|
d="M11 7 H3 M6 4 L3 7 L6 10"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -40,7 +61,7 @@
|
|||||||
<div class="mb-5 flex flex-wrap gap-1.5">
|
<div class="mb-5 flex flex-wrap gap-1.5">
|
||||||
{#each post.tags as tag (tag)}
|
{#each post.tags as tag (tag)}
|
||||||
<span
|
<span
|
||||||
class="rounded-sm border border-primary/25 bg-primary/10 px-1.75 py-0.5 font-mono text-xs uppercase tracking-widest text-primary"
|
class="rounded-sm border border-primary/25 bg-primary/10 px-1.75 py-0.5 font-mono text-xs tracking-widest text-primary uppercase"
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
@@ -49,7 +70,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<h1
|
<h1
|
||||||
class="mb-5 font-display text-[clamp(1.875rem,5vw,2.75rem)] font-black leading-[1.08] tracking-[-0.035em] text-title"
|
class="mb-5 font-display text-[clamp(1.875rem,5vw,2.75rem)] leading-[1.08] font-black tracking-tight text-title"
|
||||||
>
|
>
|
||||||
{post.title}
|
{post.title}
|
||||||
</h1>
|
</h1>
|
||||||
@@ -58,21 +79,36 @@
|
|||||||
{post.description}
|
{post.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<time datetime={post.date} class="font-mono text-[0.625rem] uppercase tracking-[0.08em] text-desc/50">
|
<time
|
||||||
|
datetime={post.date}
|
||||||
|
class="font-mono text-[0.625rem] tracking-[0.08em] text-desc/50 uppercase"
|
||||||
|
>
|
||||||
{fmtDate(post.date)}
|
{fmtDate(post.date)}
|
||||||
</time>
|
</time>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<article class="prose-article">
|
<article class="prose-article">
|
||||||
{@html post.content}
|
{#each post.content as block, i (`${block.type}-${i}`)}
|
||||||
|
{#if block.type === 'heading'}
|
||||||
|
<h2>{block.text}</h2>
|
||||||
|
{:else if block.type === 'paragraph'}
|
||||||
|
<p>{block.text}</p>
|
||||||
|
{:else}
|
||||||
|
<ul>
|
||||||
|
{#each block.items as item (item)}
|
||||||
|
<li>{item}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<div class="mt-16 flex items-center justify-between gap-3 border-t border-bg-h pt-7">
|
<div class="mt-16 flex items-center justify-between gap-3 border-t border-bg-h pt-7">
|
||||||
<a
|
<a
|
||||||
href="/blog"
|
href="/blog"
|
||||||
class="back-link inline-flex items-center gap-1.5 font-mono text-[0.625rem] uppercase tracking-[0.08em] transition-colors"
|
class="back-link inline-flex items-center gap-1.5 font-mono text-[0.625rem] tracking-[0.08em] uppercase transition-colors"
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
d="M11 7 H3 M6 4 L3 7 L6 10"
|
d="M11 7 H3 M6 4 L3 7 L6 10"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -89,7 +125,7 @@
|
|||||||
class="inline-flex items-center gap-2 rounded-xl bg-primary px-5 py-3 font-display text-sm font-normal transition-colors hover:bg-primary-h"
|
class="inline-flex items-center gap-2 rounded-xl bg-primary px-5 py-3 font-display text-sm font-normal transition-colors hover:bg-primary-h"
|
||||||
>
|
>
|
||||||
Открыть дашборд
|
Открыть дашборд
|
||||||
<img src="/images/icons/arrow-right.svg" alt="" class="h-4 w-4" />
|
<img src="/images/icons/arrow-right.svg" alt="" aria-hidden="true" class="h-4 w-4" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,11 +142,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prose-article :global(h2) {
|
.prose-article :global(h2) {
|
||||||
font-family: var(--font-display),sans-serif;
|
font-family: var(--font-display), sans-serif;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--color-title);
|
color: var(--color-title);
|
||||||
letter-spacing: -0.025em;
|
letter-spacing: 0;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
margin-top: 2.25em;
|
margin-top: 2.25em;
|
||||||
margin-bottom: 0.75em;
|
margin-bottom: 0.75em;
|
||||||
@@ -120,7 +156,7 @@
|
|||||||
font-size: 1.0625rem;
|
font-size: 1.0625rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-title);
|
color: var(--color-title);
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: 0;
|
||||||
margin-top: 1.75em;
|
margin-top: 1.75em;
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 0.5em;
|
||||||
}
|
}
|
||||||
@@ -158,7 +194,7 @@
|
|||||||
color: var(--color-title);
|
color: var(--color-title);
|
||||||
}
|
}
|
||||||
.prose-article :global(code) {
|
.prose-article :global(code) {
|
||||||
font-family: var(--font-mono),sans-serif;
|
font-family: var(--font-mono), sans-serif;
|
||||||
font-size: 0.875em;
|
font-size: 0.875em;
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||||
@@ -197,4 +233,4 @@
|
|||||||
border: 1px solid var(--color-bg-h);
|
border: 1px solid var(--color-bg-h);
|
||||||
margin: 1.75em 0;
|
margin: 1.75em 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { getPostBySlug } from '$lib/blog/posts';
|
import { getPostBySlug } from '$lib/blog/posts';
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
@@ -6,4 +6,4 @@ export const load: PageLoad = ({ params }) => {
|
|||||||
const post = getPostBySlug(params.slug);
|
const post = getPostBySlug(params.slug);
|
||||||
if (!post) throw error(404, 'Статья не найдена');
|
if (!post) throw error(404, 'Статья не найдена');
|
||||||
return { post };
|
return { post };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,30 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { gsap } from 'gsap';
|
import { gsap } from 'gsap';
|
||||||
import { generateChartData, SYMBOLS, TIMEFRAMES, type TimeframeId } from '$lib/stores/chartStore';
|
|
||||||
import Chart from '$lib/components/Chart.svelte';
|
import Chart from '$lib/components/Chart.svelte';
|
||||||
|
import {
|
||||||
|
dashboardDataNotice,
|
||||||
|
getDashboardChartData,
|
||||||
|
SYMBOLS,
|
||||||
|
TIMEFRAMES
|
||||||
|
} from '$lib/data/dashboard';
|
||||||
|
import type { TimeframeId } from '$lib/stores/chartStore';
|
||||||
|
import { pageMeta } from '$lib/config/site';
|
||||||
import { cn } from '$lib/utils';
|
import { cn } from '$lib/utils';
|
||||||
|
|
||||||
|
const meta = pageMeta({
|
||||||
|
title: 'Дашборд',
|
||||||
|
description:
|
||||||
|
'Публичный исследовательский дашборд Flamy Trade с графиком, ML-прогнозом и уровнями Entry, TP и SL.',
|
||||||
|
path: '/dashboard'
|
||||||
|
});
|
||||||
|
|
||||||
let activeSymbol = $state('BTCUSDT');
|
let activeSymbol = $state('BTCUSDT');
|
||||||
let activeTimeframe = $state<TimeframeId>('5m');
|
let activeTimeframe = $state<TimeframeId>('5m');
|
||||||
let chartHeight = $state(520);
|
let chartHeight = $state(520);
|
||||||
|
|
||||||
const sym = $derived(SYMBOLS.find((s) => s.id === activeSymbol) ?? SYMBOLS[0]);
|
const sym = $derived(SYMBOLS.find((s) => s.id === activeSymbol) ?? SYMBOLS[0]);
|
||||||
const data = $derived(generateChartData(activeSymbol, activeTimeframe));
|
const data = $derived(getDashboardChartData(activeSymbol, activeTimeframe));
|
||||||
|
|
||||||
let titleEl = $state<HTMLElement>(null!);
|
let titleEl = $state<HTMLElement>(null!);
|
||||||
let panelEl = $state<HTMLElement>(null!);
|
let panelEl = $state<HTMLElement>(null!);
|
||||||
@@ -24,19 +38,38 @@
|
|||||||
|
|
||||||
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
const ctx = gsap.context(() => {
|
const ctx = gsap.context(() => {
|
||||||
gsap.from(titleEl, { opacity: 0, y: 20, duration: 0.6, ease: 'power3.out', clearProps: 'all' });
|
gsap.from(titleEl, {
|
||||||
gsap.from(panelEl, { opacity: 0, y: 32, duration: 0.7, delay: 0.12, ease: 'power3.out', clearProps: 'all' });
|
opacity: 0,
|
||||||
|
y: 20,
|
||||||
|
duration: 0.6,
|
||||||
|
ease: 'power3.out',
|
||||||
|
clearProps: 'all'
|
||||||
|
});
|
||||||
|
gsap.from(panelEl, {
|
||||||
|
opacity: 0,
|
||||||
|
y: 32,
|
||||||
|
duration: 0.7,
|
||||||
|
delay: 0.12,
|
||||||
|
ease: 'power3.out',
|
||||||
|
clearProps: 'all'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return () => { ctx.revert(); window.removeEventListener('resize', update); };
|
return () => {
|
||||||
|
ctx.revert();
|
||||||
|
window.removeEventListener('resize', update);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => window.removeEventListener('resize', update);
|
return () => window.removeEventListener('resize', update);
|
||||||
});
|
});
|
||||||
|
|
||||||
function fmtPrice(n: number): string {
|
function fmtPrice(n: number): string {
|
||||||
if (n >= 10000) return n.toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
if (n >= 10000)
|
||||||
if (n >= 100) return n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
return n.toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
||||||
if (n >= 1) return n.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 4 });
|
if (n >= 100)
|
||||||
|
return n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
if (n >= 1)
|
||||||
|
return n.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 4 });
|
||||||
return n.toLocaleString('en-US', { minimumFractionDigits: 6, maximumFractionDigits: 6 });
|
return n.toLocaleString('en-US', { minimumFractionDigits: 6, maximumFractionDigits: 6 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,21 +78,41 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{meta.title}</title>
|
||||||
|
<meta name="description" content={meta.description} />
|
||||||
|
<link rel="canonical" href={meta.url} />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content={meta.title} />
|
||||||
|
<meta property="og:description" content={meta.description} />
|
||||||
|
<meta property="og:url" content={meta.url} />
|
||||||
|
<meta name="twitter:title" content={meta.title} />
|
||||||
|
<meta name="twitter:description" content={meta.description} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<main class="min-h-screen bg-bg">
|
<main class="min-h-screen bg-bg">
|
||||||
<div class="mx-auto max-w-400 px-5 pt-22 pb-16 sm:px-10 sm:pt-26 lg:pt-24">
|
<div class="mx-auto max-w-400 px-5 pt-22 pb-16 sm:px-10 sm:pt-26 lg:pt-24">
|
||||||
<div bind:this={titleEl} class="mb-5 mx-auto w-fit">
|
<div bind:this={titleEl} class="mx-auto mb-5 w-fit">
|
||||||
<h1 class="font-display text-center text-2xl font-black uppercase leading-none tracking-tight mb-2 sm:text-4xl ">
|
<h1
|
||||||
|
class="mb-2 text-center font-display text-2xl leading-none font-black tracking-tight uppercase sm:text-4xl"
|
||||||
|
>
|
||||||
Дашборд
|
Дашборд
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-1.5 font-mono text-[0.625rem] tracking-widest text-desc uppercase sm:text-xs">
|
<p class="mt-1.5 font-mono text-[0.625rem] tracking-widest text-desc uppercase sm:text-xs">
|
||||||
ML-прогноз · Горизонт 19 свечей · Таймфрейм 5M
|
{dashboardDataNotice} · Горизонт 19 свечей · Таймфрейм {activeTimeframe.toUpperCase()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div bind:this={panelEl} class="overflow-hidden rounded-2xl border border-bg-h bg-bg-c shadow-[0_40px_80px_rgba(0,0,0,0.6)]">
|
<div
|
||||||
<div class="flex flex-col border-b border-bg-h sm:flex-row sm:items-stretch sm:justify-between">
|
bind:this={panelEl}
|
||||||
|
class="overflow-hidden rounded-2xl border border-bg-h bg-bg-c shadow-[0_40px_80px_rgba(0,0,0,0.6)]"
|
||||||
<div class="flex overflow-hidden border-b border-bg-h sm:border-b-0 [&::-webkit-scrollbar]:hidden">
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-col border-b border-bg-h sm:flex-row sm:items-stretch sm:justify-between"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex overflow-hidden border-b border-bg-h sm:border-b-0 [&::-webkit-scrollbar]:hidden"
|
||||||
|
>
|
||||||
{#each SYMBOLS as s (s.id)}
|
{#each SYMBOLS as s (s.id)}
|
||||||
<button
|
<button
|
||||||
class={cn(
|
class={cn(
|
||||||
@@ -69,6 +122,7 @@
|
|||||||
: 'border-transparent text-desc hover:text-title'
|
: 'border-transparent text-desc hover:text-title'
|
||||||
)}
|
)}
|
||||||
onclick={() => (activeSymbol = s.id)}
|
onclick={() => (activeSymbol = s.id)}
|
||||||
|
aria-pressed={activeSymbol === s.id}
|
||||||
>
|
>
|
||||||
{s.short}
|
{s.short}
|
||||||
</button>
|
</button>
|
||||||
@@ -83,6 +137,7 @@
|
|||||||
activeTimeframe === tf.id ? 'bg-bg-h text-title' : 'text-desc hover:text-title'
|
activeTimeframe === tf.id ? 'bg-bg-h text-title' : 'text-desc hover:text-title'
|
||||||
)}
|
)}
|
||||||
onclick={() => (activeTimeframe = tf.id)}
|
onclick={() => (activeTimeframe = tf.id)}
|
||||||
|
aria-pressed={activeTimeframe === tf.id}
|
||||||
>
|
>
|
||||||
{tf.label}
|
{tf.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -90,44 +145,66 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-x-5 gap-y-3 border-b border-bg-h px-4 py-3 sm:px-5">
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-x-5 gap-y-3 border-b border-bg-h px-4 py-3 sm:px-5"
|
||||||
|
>
|
||||||
<div class="flex items-baseline gap-2.5">
|
<div class="flex items-baseline gap-2.5">
|
||||||
<span class="font-display text-[0.625rem] font-black tracking-tight text-desc uppercase hidden sm:inline">
|
<span
|
||||||
|
class="hidden font-display text-[0.625rem] font-black tracking-tight text-desc uppercase sm:inline"
|
||||||
|
>
|
||||||
{sym.label}
|
{sym.label}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-mono text-lg font-medium text-title sm:text-2xl">
|
<span class="font-mono text-lg font-medium text-title sm:text-2xl">
|
||||||
{fmtPrice(data.currentPrice)}
|
{fmtPrice(data.currentPrice)}
|
||||||
</span>
|
</span>
|
||||||
<span class={cn(
|
<span
|
||||||
'font-mono text-xs font-medium sm:text-sm',
|
class={cn(
|
||||||
data.change24hPct >= 0 ? 'text-emerald-400' : 'text-red-400'
|
'font-mono text-xs font-medium sm:text-sm',
|
||||||
)}>
|
data.change24hPct >= 0 ? 'text-emerald-400' : 'text-red-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
{fmtPct(data.change24hPct)}
|
{fmtPct(data.change24hPct)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class={cn(
|
<div
|
||||||
'flex items-center gap-1.5 rounded-lg px-2.5 py-1.5',
|
class={cn(
|
||||||
data.signal.direction === 'long' ? 'bg-emerald-400/10' : 'bg-red-400/10'
|
'flex items-center gap-1.5 rounded-lg px-2.5 py-1.5',
|
||||||
)}>
|
data.signal.direction === 'long' ? 'bg-emerald-400/10' : 'bg-red-400/10'
|
||||||
<span class={cn(
|
)}
|
||||||
'font-display text-[0.6875rem] font-black uppercase tracking-tight',
|
>
|
||||||
data.signal.direction === 'long' ? 'text-emerald-400' : 'text-red-400'
|
<span
|
||||||
)}>
|
class={cn(
|
||||||
|
'font-display text-[0.6875rem] font-black tracking-tight uppercase',
|
||||||
|
data.signal.direction === 'long' ? 'text-emerald-400' : 'text-red-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
{data.signal.direction === 'long' ? '▲ LONG' : '▼ SHORT'}
|
{data.signal.direction === 'long' ? '▲ LONG' : '▼ SHORT'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-mono text-[0.5625rem] tracking-widest text-desc uppercase hidden sm:inline">
|
<span
|
||||||
|
class="hidden font-mono text-[0.5625rem] tracking-widest text-desc uppercase sm:inline"
|
||||||
|
>
|
||||||
Уверенность
|
Уверенность
|
||||||
</span>
|
</span>
|
||||||
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-bg-h sm:w-20">
|
<div
|
||||||
|
class="h-1.5 w-16 overflow-hidden rounded-full bg-bg-h sm:w-20"
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="Уверенность прогноза"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
aria-valuenow={data.signal.confidence}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
'h-full rounded-full transition-all duration-700',
|
'h-full rounded-full transition-all duration-700',
|
||||||
data.signal.confidence >= 70 ? 'bg-emerald-400' : data.signal.confidence >= 55 ? 'bg-amber-400' : 'bg-red-400'
|
data.signal.confidence >= 70
|
||||||
|
? 'bg-emerald-400'
|
||||||
|
: data.signal.confidence >= 55
|
||||||
|
? 'bg-amber-400'
|
||||||
|
: 'bg-red-400'
|
||||||
)}
|
)}
|
||||||
style="width: {data.signal.confidence}%"
|
style="width: {data.signal.confidence}%"
|
||||||
></div>
|
></div>
|
||||||
@@ -136,22 +213,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ml-auto flex gap-4 sm:gap-6">
|
<div class="ml-auto flex gap-4 sm:gap-6">
|
||||||
{#each [
|
{#each [{ label: 'Entry', val: data.signal.entry, cls: 'text-primary' }, { label: 'TP', val: data.signal.tp, cls: 'text-emerald-400' }, { label: 'SL', val: data.signal.sl, cls: 'text-red-400' }] as lvl (lvl.label)}
|
||||||
{ label: 'Entry', val: data.signal.entry, cls: 'text-primary' },
|
|
||||||
{ label: 'TP', val: data.signal.tp, cls: 'text-emerald-400' },
|
|
||||||
{ label: 'SL', val: data.signal.sl, cls: 'text-red-400' },
|
|
||||||
] as lvl (lvl.label)}
|
|
||||||
<div class="flex flex-col gap-0.5 text-right">
|
<div class="flex flex-col gap-0.5 text-right">
|
||||||
<span class="font-mono text-[0.5rem] tracking-widest text-desc uppercase">{lvl.label}</span>
|
<span class="font-mono text-[0.5rem] tracking-widest text-desc uppercase"
|
||||||
|
>{lvl.label}</span
|
||||||
|
>
|
||||||
<span class="font-mono text-xs {lvl.cls}">{fmtPrice(lvl.val)}</span>
|
<span class="font-mono text-xs {lvl.cls}">{fmtPrice(lvl.val)}</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Chart data={data} decimals={sym.dec} height={chartHeight} />
|
<Chart {data} decimals={sym.dec} height={chartHeight} />
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-bg-h px-4 py-3 sm:px-5">
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-bg-h px-4 py-3 sm:px-5"
|
||||||
|
>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<div class="flex gap-0.5">
|
<div class="flex gap-0.5">
|
||||||
<span class="h-3.5 w-1.5 rounded-[2px] bg-emerald-400/80"></span>
|
<span class="h-3.5 w-1.5 rounded-[2px] bg-emerald-400/80"></span>
|
||||||
@@ -162,9 +239,19 @@
|
|||||||
|
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<svg width="22" height="4" aria-hidden="true">
|
<svg width="22" height="4" aria-hidden="true">
|
||||||
<line x1="0" y1="2" x2="22" y2="2" stroke="#fe4b07" stroke-width="2" stroke-dasharray="4 3" />
|
<line
|
||||||
|
x1="0"
|
||||||
|
y1="2"
|
||||||
|
x2="22"
|
||||||
|
y2="2"
|
||||||
|
stroke="#fe4b07"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-dasharray="4 3"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="font-mono text-[0.5625rem] tracking-widest text-desc uppercase">ML-прогноз</span>
|
<span class="font-mono text-[0.5625rem] tracking-widest text-desc uppercase"
|
||||||
|
>ML-прогноз</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
@@ -183,14 +270,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="ml-auto font-mono text-[0.5rem] tracking-widest text-desc/60 uppercase">
|
<span class="ml-auto font-mono text-[0.5rem] tracking-widest text-desc/60 uppercase">
|
||||||
не является инвестиционной рекомендацией
|
исследовательские данные · не является инвестиционной рекомендацией
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span
|
|
||||||
class="pointer-events-none fixed top-0 left-1/2 -translate-x-1/2 h-80 w-full max-w-3xl rounded-full bg-primary opacity-[0.07] blur-[160px]"
|
|
||||||
aria-hidden="true"
|
|
||||||
></span>
|
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const GET = () => {
|
||||||
|
return json({
|
||||||
|
status: 'ok',
|
||||||
|
service: 'flamy-trade'
|
||||||
|
});
|
||||||
|
};
|
||||||
+25
-1
@@ -1,5 +1,29 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'DM Sans';
|
||||||
|
src: url('/fonts/DMSans.ttf') format('truetype');
|
||||||
|
font-weight: 100 1000;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
src: url('/fonts/JetBrainsMono.ttf') format('truetype');
|
||||||
|
font-weight: 100 800;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Unbounded';
|
||||||
|
src: url('/fonts/Unbounded.ttf') format('truetype');
|
||||||
|
font-weight: 200 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-display: 'Unbounded', sans-serif;
|
--font-display: 'Unbounded', sans-serif;
|
||||||
--font-sans: 'DM Sans', sans-serif;
|
--font-sans: 'DM Sans', sans-serif;
|
||||||
@@ -42,7 +66,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
section {
|
section {
|
||||||
@apply relative mx-auto max-w-400 lg:min-h-175 lg:max-h-300 px-10;
|
@apply relative mx-auto max-w-400 px-10 lg:max-h-300 lg:min-h-175;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { canonical } from '$lib/config/site';
|
||||||
|
|
||||||
|
export const GET = () => {
|
||||||
|
const body = `User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: ${canonical('/sitemap.xml')}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return new Response(body, {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'text/plain; charset=utf-8',
|
||||||
|
'cache-control': 'public, max-age=3600'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { posts } from '$lib/blog/posts';
|
||||||
|
import { canonical } from '$lib/config/site';
|
||||||
|
|
||||||
|
const staticPages = ['/', '/about', '/blog', '/dashboard'];
|
||||||
|
|
||||||
|
export const GET = () => {
|
||||||
|
const urls = [
|
||||||
|
...staticPages.map((path) => ({ loc: canonical(path), lastmod: undefined })),
|
||||||
|
...posts.map((post) => ({ loc: canonical(`/blog/${post.slug}`), lastmod: post.date }))
|
||||||
|
];
|
||||||
|
|
||||||
|
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
${urls
|
||||||
|
.map(
|
||||||
|
(url) => ` <url>
|
||||||
|
<loc>${url.loc}</loc>${url.lastmod ? `\n <lastmod>${url.lastmod}</lastmod>` : ''}
|
||||||
|
</url>`
|
||||||
|
)
|
||||||
|
.join('\n')}
|
||||||
|
</urlset>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return new Response(body, {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/xml; charset=utf-8',
|
||||||
|
'cache-control': 'public, max-age=3600'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-label="Flamy Trade">
|
||||||
|
<rect width="64" height="64" rx="12" fill="#09080a" />
|
||||||
|
<path d="M18 43V17h28v8H27v7h15v8H27v3h19v8H18z" fill="#f0ede6" />
|
||||||
|
<path d="M38 13h9v9h-9zM47 22h-9v9h9z" fill="#fe4b07" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 309 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
|
|||||||
# allow crawling everything by default
|
|
||||||
User-agent: *
|
|
||||||
Disallow:
|
|
||||||
+18
-1
@@ -6,7 +6,24 @@ const config = {
|
|||||||
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
|
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
|
||||||
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
|
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
|
||||||
},
|
},
|
||||||
kit: { adapter: adapter() }
|
kit: {
|
||||||
|
adapter: adapter(),
|
||||||
|
csp: {
|
||||||
|
mode: 'auto',
|
||||||
|
directives: {
|
||||||
|
'default-src': ['self'],
|
||||||
|
'script-src': ['self'],
|
||||||
|
'style-src': ['self', 'unsafe-inline'],
|
||||||
|
'img-src': ['self', 'data:'],
|
||||||
|
'font-src': ['self'],
|
||||||
|
'connect-src': ['self'],
|
||||||
|
'object-src': ['none'],
|
||||||
|
'base-uri': ['self'],
|
||||||
|
'frame-ancestors': ['self'],
|
||||||
|
'form-action': ['self']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
Reference in New Issue
Block a user