Законченный MVP
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
export type Post = {
|
||||
id: number;
|
||||
slug: string;
|
||||
date: string;
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
content: string;
|
||||
};
|
||||
|
||||
export const posts: Post[] = [
|
||||
{
|
||||
id: 1,
|
||||
slug: 'osnovy-upravleniya-riskami',
|
||||
date: '2026-05-18',
|
||||
title: 'Основы управления рисками',
|
||||
description:
|
||||
'Как использовать уровни Stop Loss и Take Profit для грамотного управления капиталом при работе с прогнозами.',
|
||||
tags: ['RISK', 'TRADING', 'EDUCATION'],
|
||||
content: `
|
||||
<p>Управление рисками — фундамент любой торговой стратегии. Без него даже точный ML-прогноз не поможет сохранить капитал.</p>
|
||||
<h2>Stop Loss: где ваша позиция неправа</h2>
|
||||
<p>Stop Loss — уровень, при достижении которого позиция закрывается автоматически. Это не признание ошибки, а часть стратегии. На графиках Flamy AI уровень SL рассчитывается моделью на основе исторической волатильности инструмента.</p>
|
||||
<h2>Take Profit: когда забирать прибыль</h2>
|
||||
<p>Take Profit — целевой уровень закрытия позиции с прибылью. TP рассчитывается пропорционально прогнозному движению с учётом Risk:Reward ratio.</p>
|
||||
<h2>Risk:Reward Ratio</h2>
|
||||
<p>Оптимальное соотношение риска к доходности — не менее <strong>1:2</strong>. Это означает, что потенциальная прибыль должна минимум вдвое превышать риск.</p>
|
||||
<ul>
|
||||
<li>SL: не более 1% от капитала на сделку</li>
|
||||
<li>TP: 2% и выше от точки входа</li>
|
||||
<li>Одна сделка: не более 2% депозита</li>
|
||||
</ul>
|
||||
<h2>Почему это важно при работе с ML-прогнозами</h2>
|
||||
<p>ML-модель не даёт гарантий. Уверенность 80% означает, что 20% прогнозов будут неточными. Управление рисками — страховка на эти 20%.</p>
|
||||
`
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
slug: 'kak-chitat-grafiki-flamy-ai',
|
||||
date: '2026-05-17',
|
||||
title: 'Как читать графики Flamy AI',
|
||||
description:
|
||||
'Краткое объяснение свечей, прогнозной зоны, TP и SL на графиках Flamy AI.',
|
||||
tags: ['ML', 'FORECAST', 'CHARTS'],
|
||||
content: `
|
||||
<p>Графики Flamy AI содержат три слоя: исторические свечи, прогнозную зону и уровни торгового плана. Разберём каждый из них.</p>
|
||||
<h2>Исторические свечи</h2>
|
||||
<p>Левая часть графика — реальные OHLC-свечи. Зелёные — бычьи (close > open), красные — медвежьи. Это основа для анализа модели.</p>
|
||||
<h2>Прогнозная зона</h2>
|
||||
<p>Правая часть — прогнозные свечи на горизонт <strong>19 периодов</strong> вперёд. Это не точные значения, а вероятностный коридор движения цены на ближайшие 95 минут.</p>
|
||||
<h2>Уровни Entry, TP и SL</h2>
|
||||
<p>Если модель публикует режим TRADE_PLAN, на графике появляются три горизонтальные линии:</p>
|
||||
<ul>
|
||||
<li><strong>Entry</strong> — рекомендуемый уровень входа в позицию</li>
|
||||
<li><strong>TP</strong> — цель Take Profit</li>
|
||||
<li><strong>SL</strong> — Stop Loss для ограничения убытков</li>
|
||||
</ul>
|
||||
<h2>Таймфрейм и горизонт</h2>
|
||||
<p>Все графики работают на таймфрейме <strong>5 минут</strong>. Горизонт прогноза — 19 свечей = 95 минут вперёд. Прогнозы обновляются каждый час автоматически.</p>
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
export function getPostBySlug(slug: string): Post | undefined {
|
||||
return posts.find((p) => p.slug === slug);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
createChart,
|
||||
CandlestickSeries,
|
||||
LineSeries,
|
||||
CrosshairMode,
|
||||
LineStyle,
|
||||
type IChartApi,
|
||||
type ISeriesApi,
|
||||
type MouseEventParams,
|
||||
type UTCTimestamp,
|
||||
} from 'lightweight-charts';
|
||||
import type { ChartData, CandleBar } from '$lib/stores/chartStore';
|
||||
|
||||
type Props = {
|
||||
data: ChartData;
|
||||
decimals: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
let { data, decimals, height = 520 }: Props = $props();
|
||||
|
||||
let container = $state<HTMLDivElement>(null!);
|
||||
let chart: IChartApi | undefined;
|
||||
let candleSeries: ISeriesApi<'Candlestick'> | undefined;
|
||||
let predSeries: ISeriesApi<'Line'> | undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let priceLines: any[] = [];
|
||||
|
||||
type Tooltip = {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
time: string;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
isUp: boolean;
|
||||
pred?: number;
|
||||
};
|
||||
|
||||
let tip = $state<Tooltip>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
time: '',
|
||||
open: 0,
|
||||
high: 0,
|
||||
low: 0,
|
||||
close: 0,
|
||||
isUp: true,
|
||||
});
|
||||
|
||||
function fmt(n: number): string {
|
||||
if (n >= 10000) return n.toLocaleString('en-US', { maximumFractionDigits: 1 });
|
||||
if (n >= 100) return n.toLocaleString('en-US', { maximumFractionDigits: 2 });
|
||||
if (n >= 1) return n.toLocaleString('en-US', { maximumFractionDigits: 4 });
|
||||
return n.toLocaleString('en-US', { maximumFractionDigits: 6 });
|
||||
}
|
||||
|
||||
function applyData(d: ChartData) {
|
||||
if (!chart || !candleSeries || !predSeries) return;
|
||||
|
||||
candleSeries.setData(d.candles.map((c) => ({ ...c, time: c.time as UTCTimestamp })));
|
||||
predSeries.setData(d.prediction.map((p) => ({ ...p, time: p.time as UTCTimestamp })));
|
||||
|
||||
// Remove old price lines then re-add
|
||||
priceLines.forEach((pl) => candleSeries!.removePriceLine(pl));
|
||||
priceLines = [
|
||||
candleSeries.createPriceLine({
|
||||
price: d.signal.entry,
|
||||
color: '#fe4b07',
|
||||
lineWidth: 1,
|
||||
lineStyle: LineStyle.Solid,
|
||||
axisLabelVisible: true,
|
||||
title: 'Entry',
|
||||
}),
|
||||
candleSeries.createPriceLine({
|
||||
price: d.signal.tp,
|
||||
color: '#26a69a',
|
||||
lineWidth: 1,
|
||||
lineStyle: LineStyle.Dashed,
|
||||
axisLabelVisible: true,
|
||||
title: 'TP',
|
||||
}),
|
||||
candleSeries.createPriceLine({
|
||||
price: d.signal.sl,
|
||||
color: '#ef5350',
|
||||
lineWidth: 1,
|
||||
lineStyle: LineStyle.Dashed,
|
||||
axisLabelVisible: true,
|
||||
title: 'SL',
|
||||
}),
|
||||
];
|
||||
|
||||
chart.timeScale().fitContent();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
chart = createChart(container, {
|
||||
width: container.clientWidth,
|
||||
height,
|
||||
layout: {
|
||||
background: { color: '#09080a' },
|
||||
textColor: '#8a887f',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: 11,
|
||||
attributionLogo: false,
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: '#1a191e' },
|
||||
horzLines: { color: '#1a191e' },
|
||||
},
|
||||
crosshair: {
|
||||
mode: CrosshairMode.Normal,
|
||||
vertLine: {
|
||||
color: '#3d3b42',
|
||||
labelBackgroundColor: '#1a191e',
|
||||
style: LineStyle.Dashed,
|
||||
width: 1,
|
||||
},
|
||||
horzLine: {
|
||||
color: '#3d3b42',
|
||||
labelBackgroundColor: '#1a191e',
|
||||
style: LineStyle.Dashed,
|
||||
width: 1,
|
||||
},
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: '#1a191e',
|
||||
scaleMargins: { top: 0.06, bottom: 0.04 },
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: '#1a191e',
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
rightOffset: 24,
|
||||
},
|
||||
});
|
||||
|
||||
candleSeries = chart.addSeries(CandlestickSeries, {
|
||||
upColor: '#26a69a',
|
||||
downColor: '#ef5350',
|
||||
borderUpColor: '#26a69a',
|
||||
borderDownColor: '#ef5350',
|
||||
wickUpColor: '#26a69a',
|
||||
wickDownColor: '#ef5350',
|
||||
});
|
||||
|
||||
predSeries = chart.addSeries(LineSeries, {
|
||||
color: '#fe4b07',
|
||||
lineWidth: 2,
|
||||
lineStyle: LineStyle.Dashed,
|
||||
crosshairMarkerVisible: true,
|
||||
crosshairMarkerRadius: 4,
|
||||
crosshairMarkerBorderColor: '#fe4b07',
|
||||
crosshairMarkerBackgroundColor: '#09080a',
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: true,
|
||||
});
|
||||
|
||||
applyData(data);
|
||||
|
||||
chart.subscribeCrosshairMove((param: MouseEventParams) => {
|
||||
if (!param.point || !param.time || !candleSeries || !param.seriesData.has(candleSeries)) {
|
||||
tip = { ...tip, visible: false };
|
||||
return;
|
||||
}
|
||||
const c = param.seriesData.get(candleSeries) as CandleBar;
|
||||
if (!c) { tip = { ...tip, visible: false }; return; }
|
||||
|
||||
const pred = predSeries && param.seriesData.has(predSeries)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
? (param.seriesData.get(predSeries) as any)?.value
|
||||
: undefined;
|
||||
|
||||
const ts = param.time as number;
|
||||
const d = new Date(ts * 1000);
|
||||
const timeStr = d.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' })
|
||||
+ ' ' + d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
tip = {
|
||||
visible: true,
|
||||
x: param.point.x,
|
||||
y: param.point.y,
|
||||
time: timeStr,
|
||||
open: c.open,
|
||||
high: c.high,
|
||||
low: c.low,
|
||||
close: c.close,
|
||||
isUp: c.close >= c.open,
|
||||
pred,
|
||||
};
|
||||
});
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
chart?.applyOptions({
|
||||
width: container.clientWidth,
|
||||
height: container.clientHeight,
|
||||
});
|
||||
});
|
||||
ro.observe(container);
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
chart?.remove();
|
||||
};
|
||||
});
|
||||
|
||||
// React to data prop changes after mount
|
||||
$effect(() => {
|
||||
const d = data;
|
||||
void decimals; // track but unused — parent re-generates data on change
|
||||
if (!chart || !candleSeries || !predSeries) return;
|
||||
applyData(d);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative w-full" bind:this={container} style="height: {height}px">
|
||||
{#if tip.visible}
|
||||
{@const maxX = container?.clientWidth ?? 500}
|
||||
{@const left = tip.x + 160 > maxX ? tip.x - 168 : tip.x + 14}
|
||||
{@const top = Math.max(tip.y - 90, 8)}
|
||||
<div
|
||||
class="pointer-events-none absolute z-20 min-w-36 rounded-xl border border-white/8
|
||||
bg-bg-c/95 px-3 py-2.5 font-mono text-[0.6875rem] shadow-2xl backdrop-blur-sm"
|
||||
style="left: {left}px; top: {top}px"
|
||||
>
|
||||
<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]}
|
||||
<div class="flex justify-between gap-5">
|
||||
<span class="text-desc">{lbl}</span>
|
||||
<span class={tip.isUp ? 'text-emerald-400' : 'text-red-400'}>{fmt(val as number)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{#if tip.pred !== undefined}
|
||||
<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-primary">{fmt(tip.pred)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
+1
-1
@@ -62,7 +62,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full max-w-170 z-20 mx-auto lg:mx-0" style="perspective: 1000px;">
|
||||
<div class="w-full max-w-170 z-20 mx-auto xl:mx-0" style="perspective: 1000px;">
|
||||
<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"
|
||||
style="transform: perspective(1000px) rotateY(-6deg) rotateX(3deg); transform-style: preserve-3d;"
|
||||
@@ -14,7 +14,7 @@
|
||||
<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="col-span-2 flex flex-col gap-4 sm:col-span-1">
|
||||
<div class="col-span-2 flex flex-col gap-4 lg:col-span-1">
|
||||
<Logo />
|
||||
|
||||
<p class="max-w-55 text-base leading-[1.2] text-zinc-600">
|
||||
@@ -34,7 +34,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 flex flex-col gap-1.5 sm:col-span-2 ml-auto">
|
||||
<div class="col-span-2 flex flex-col gap-1.5 sm:col-span-2 lg:ml-auto">
|
||||
<span class="font-mono text-base tracking-widest text-zinc-600 uppercase">Дисклеймер</span>
|
||||
<p class="max-w-85 text-sm leading-relaxed text-zinc-600">
|
||||
Материалы являются результатом работы исследовательской ML-модели и используются в
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { gsap } from 'gsap';
|
||||
import { cn } from '$lib/utils';
|
||||
import Logo from '$lib/components/ui/Logo.svelte';
|
||||
|
||||
const menuLinks = [
|
||||
{ id: 0, href: 'reg', text: 'Главная' },
|
||||
{ id: 1, href: 'reg', text: 'О проекте' },
|
||||
{ id: 2, href: 'reg', text: 'Блог' },
|
||||
{ id: 3, href: 'reg', text: 'Открыть дашборд', isButton: true }
|
||||
{ id: 0, href: '/', text: 'Главная' },
|
||||
{ id: 1, href: '/about', text: 'О проекте' },
|
||||
{ id: 2, href: '/blog', text: 'Блог' },
|
||||
{ id: 3, href: '/dashboard', text: 'Открыть дашборд', isButton: true }
|
||||
];
|
||||
|
||||
let mobileOpen = $state(false);
|
||||
let header = $state<HTMLElement>(null!);
|
||||
|
||||
onMount(() => {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
gsap.from(header, {
|
||||
y: -10,
|
||||
opacity: 0,
|
||||
duration: 0.5,
|
||||
ease: 'power2.out',
|
||||
clearProps: 'transform,opacity',
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<header class="fixed right-0 left-0 border-b border-b-bg-h bg-bg-e z-9999">
|
||||
<div class="mx-auto flex max-w-400 items-center justify-between px-5 py-5 sm:px-10 lg:px-0">
|
||||
<header bind:this={header} class="fixed right-0 left-0 border-b border-b-bg-h bg-bg-e z-9999">
|
||||
<div class="mx-auto flex max-w-400 items-center justify-between px-5 py-5 sm:px-10 lg:px-5">
|
||||
<Logo />
|
||||
|
||||
<!-- Десктопная навигация -->
|
||||
<nav class="hidden lg:block">
|
||||
<ul class="flex items-center justify-center gap-5.5 transition-colors duration-300">
|
||||
{#each menuLinks as link (link.id)}
|
||||
@@ -32,7 +45,6 @@
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Мобильная кнопка гамбургера -->
|
||||
<button
|
||||
class="flex flex-col justify-center gap-1.5 p-1 lg:hidden"
|
||||
onclick={() => (mobileOpen = !mobileOpen)}
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
href?: string;
|
||||
target?: string;
|
||||
variant?: 'primary' | 'secondary';
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
};
|
||||
|
||||
let { href, target = '_self', variant = 'primary', children }: Props = $props();
|
||||
let { href, target = '_self', variant = 'primary', class: className, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:element
|
||||
@@ -20,7 +21,8 @@
|
||||
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'
|
||||
variant === 'secondary' && 'bg-bg border hover:text-desc',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{@render children()}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Svelte actions wrapping GSAP + ScrollTrigger.
|
||||
*
|
||||
* Each action returns { destroy } for automatic cleanup when the element
|
||||
* leaves the DOM. gsap.context() scopes all tweens so they're properly
|
||||
* killed in destroy, preventing memory leaks on navigation.
|
||||
*
|
||||
* ScrollTrigger is registered once on first import (browser-only).
|
||||
*/
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
}
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return typeof window !== 'undefined'
|
||||
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
: false;
|
||||
}
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type FadeUpParams = {
|
||||
y?: number;
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
start?: string;
|
||||
};
|
||||
|
||||
export type RevealLeftParams = {
|
||||
x?: number;
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
start?: string;
|
||||
};
|
||||
|
||||
export type StaggerParams = {
|
||||
y?: number;
|
||||
x?: number;
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
stagger?: number;
|
||||
start?: string;
|
||||
selector?: string;
|
||||
};
|
||||
|
||||
// ─── Actions ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fade up a single element when it scrolls into view.
|
||||
* use:scrollFadeUp or use:scrollFadeUp={{ y: 24, delay: 0.15 }}
|
||||
*/
|
||||
export function scrollFadeUp(el: HTMLElement, params: FadeUpParams = {}) {
|
||||
if (prefersReducedMotion()) return;
|
||||
|
||||
const { y = 36, delay = 0, duration = 0.65, start = 'top 84%' } = params;
|
||||
|
||||
gsap.set(el, { opacity: 0, y });
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.to(el, {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration,
|
||||
delay,
|
||||
ease: 'power3.out',
|
||||
clearProps: 'transform,opacity',
|
||||
scrollTrigger: { trigger: el, start, once: true },
|
||||
});
|
||||
});
|
||||
|
||||
return { destroy: () => ctx.revert() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Slide in from the left when element scrolls into view — good for headings.
|
||||
* use:scrollRevealLeft
|
||||
*/
|
||||
export function scrollRevealLeft(el: HTMLElement, params: RevealLeftParams = {}) {
|
||||
if (prefersReducedMotion()) return;
|
||||
|
||||
const { x = -48, delay = 0, duration = 0.7, start = 'top 82%' } = params;
|
||||
|
||||
gsap.set(el, { opacity: 0, x });
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.to(el, {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
duration,
|
||||
delay,
|
||||
ease: 'power3.out',
|
||||
clearProps: 'transform,opacity',
|
||||
scrollTrigger: { trigger: el, start, once: true },
|
||||
});
|
||||
});
|
||||
|
||||
return { destroy: () => ctx.revert() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stagger-animate direct children (or custom selector) of a container
|
||||
* when it scrolls into view.
|
||||
* use:scrollStagger or use:scrollStagger={{ stagger: 0.08, selector: 'li' }}
|
||||
*/
|
||||
export function scrollStagger(el: HTMLElement, params: StaggerParams = {}) {
|
||||
if (prefersReducedMotion()) return;
|
||||
|
||||
const {
|
||||
y = 32,
|
||||
x = 0,
|
||||
delay = 0,
|
||||
duration = 0.65,
|
||||
stagger = 0.1,
|
||||
start = 'top 80%',
|
||||
selector = ':scope > *',
|
||||
} = params;
|
||||
|
||||
const targets = el.querySelectorAll<HTMLElement>(selector);
|
||||
if (!targets.length) return;
|
||||
|
||||
gsap.set(targets, { opacity: 0, y, x });
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.to(targets, {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
x: 0,
|
||||
duration,
|
||||
delay,
|
||||
ease: 'power3.out',
|
||||
stagger: { each: stagger, ease: 'power1.inOut' },
|
||||
clearProps: 'transform,opacity',
|
||||
scrollTrigger: { trigger: el, start, once: true },
|
||||
});
|
||||
});
|
||||
|
||||
return { destroy: () => ctx.revert() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale + fade in a single element — good for cards and panels.
|
||||
* use:scrollScaleIn
|
||||
*/
|
||||
export function scrollScaleIn(el: HTMLElement, params: { delay?: number; start?: string } = {}) {
|
||||
if (prefersReducedMotion()) return;
|
||||
|
||||
const { delay = 0, start = 'top 82%' } = params;
|
||||
|
||||
gsap.set(el, { opacity: 0, scale: 0.94, y: 20 });
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.to(el, {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
duration: 0.7,
|
||||
delay,
|
||||
ease: 'power3.out',
|
||||
clearProps: 'transform,opacity',
|
||||
scrollTrigger: { trigger: el, start, once: true },
|
||||
});
|
||||
});
|
||||
|
||||
return { destroy: () => ctx.revert() };
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
export type TimeframeId = '1m' | '5m' | '15m' | '1h' | '4h' | '1d';
|
||||
export type Direction = 'long' | 'short';
|
||||
|
||||
export type CandleBar = {
|
||||
time: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
};
|
||||
|
||||
export type PredPoint = {
|
||||
time: number;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type Signal = {
|
||||
direction: Direction;
|
||||
confidence: number;
|
||||
entry: number;
|
||||
tp: number;
|
||||
sl: number;
|
||||
};
|
||||
|
||||
export type ChartData = {
|
||||
candles: CandleBar[];
|
||||
prediction: PredPoint[];
|
||||
signal: Signal;
|
||||
currentPrice: number;
|
||||
change24h: number;
|
||||
change24hPct: number;
|
||||
};
|
||||
|
||||
export const SYMBOLS = [
|
||||
{ id: 'BTCUSDT', label: 'BTC/USDT', short: 'BTC', base: 103500, dec: 1 },
|
||||
{ id: 'ETHUSDT', label: 'ETH/USDT', short: 'ETH', base: 3800, dec: 2 },
|
||||
{ id: 'SOLUSDT', label: 'SOL/USDT', short: 'SOL', base: 176, dec: 3 },
|
||||
{ id: 'BNBUSDT', label: 'BNB/USDT', short: 'BNB', base: 585, dec: 2 },
|
||||
{ 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: 'DOGEUSDT', label: 'DOGE/USDT', short: 'DOGE', base: 0.131, dec: 5 },
|
||||
{ id: 'LINKUSDT', label: 'LINK/USDT', short: 'LINK', base: 18.5, dec: 3 },
|
||||
] as const;
|
||||
|
||||
export const TIMEFRAMES = [
|
||||
{ id: '1m' as TimeframeId, label: '1м', sec: 60 },
|
||||
{ id: '5m' as TimeframeId, label: '5м', sec: 300 },
|
||||
{ id: '15m' as TimeframeId, label: '15м', sec: 900 },
|
||||
{ id: '1h' as TimeframeId, label: '1ч', sec: 3600 },
|
||||
{ id: '4h' as TimeframeId, label: '4ч', sec: 14400 },
|
||||
{ id: '1d' as TimeframeId, label: '1д', sec: 86400 },
|
||||
];
|
||||
|
||||
// Simple LCG PRNG — deterministic per seed so switching back gives the same chart
|
||||
function rng(seed: number) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
||||
return s / 0x100000000;
|
||||
};
|
||||
}
|
||||
|
||||
const HISTORY = 160;
|
||||
const HORIZON = 19;
|
||||
|
||||
export function generateChartData(symbolId: string, tfId: TimeframeId): ChartData {
|
||||
const sym = SYMBOLS.find((s) => s.id === symbolId) ?? SYMBOLS[0];
|
||||
const tf = TIMEFRAMES.find((t) => t.id === tfId) ?? TIMEFRAMES[1];
|
||||
|
||||
const seed =
|
||||
[...symbolId].reduce((a, c, i) => a + c.charCodeAt(0) * (i + 1), 0) +
|
||||
[...tfId].reduce((a, c, i) => a + c.charCodeAt(0) * (i + 1), 0);
|
||||
const rand = rng(seed);
|
||||
|
||||
const now = Math.floor(Date.now() / tf.sec) * tf.sec;
|
||||
const startTime = now - HISTORY * tf.sec;
|
||||
|
||||
const vol = sym.base * 0.004;
|
||||
let price = sym.base * (0.88 + rand() * 0.24);
|
||||
const trendBias = (rand() - 0.5) * 0.001;
|
||||
|
||||
const candles: CandleBar[] = [];
|
||||
|
||||
for (let i = 0; i < HISTORY; i++) {
|
||||
const open = price;
|
||||
const move = (rand() - 0.5 + trendBias) * vol * 2;
|
||||
const close = Math.max(open + move, open * 0.001);
|
||||
const bodyH = Math.abs(close - open);
|
||||
const wk = 0.3 + rand() * 0.8;
|
||||
const high = Math.max(open, close) + bodyH * wk + 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)));
|
||||
candles.push({ time: startTime + i * tf.sec, open: round(open), high: round(high), low: round(low), close: round(close) });
|
||||
price = close;
|
||||
}
|
||||
|
||||
const currentPrice = candles[candles.length - 1].close;
|
||||
|
||||
// 24h change — look back ~288 candles for 5m, scaled to timeframe
|
||||
const lookback = Math.min(Math.round(86400 / tf.sec), HISTORY - 1);
|
||||
const oldPrice = candles[HISTORY - 1 - lookback].close;
|
||||
const change24h = currentPrice - oldPrice;
|
||||
const change24hPct = (change24h / oldPrice) * 100;
|
||||
|
||||
// ML prediction — starts at last candle and extends HORIZON candles forward
|
||||
const predDir = rand() > 0.42 ? 1 : -1;
|
||||
const predStrength = (0.006 + rand() * 0.018) * sym.base;
|
||||
|
||||
const prediction: PredPoint[] = [
|
||||
{ time: candles[HISTORY - 1].time, value: currentPrice },
|
||||
];
|
||||
|
||||
let pPrice = currentPrice;
|
||||
for (let i = 1; i <= HORIZON; i++) {
|
||||
const t = i / HORIZON;
|
||||
const noise = (rand() - 0.5) * vol * 0.6;
|
||||
pPrice = currentPrice + predDir * predStrength * t + noise;
|
||||
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)) });
|
||||
}
|
||||
|
||||
const finalPred = prediction[prediction.length - 1].value;
|
||||
const predRange = Math.abs(finalPred - currentPrice);
|
||||
const direction: Direction = predDir > 0 ? 'long' : 'short';
|
||||
const confidence = Math.round(52 + rand() * 33);
|
||||
|
||||
const round = (n: number) => parseFloat(n.toFixed(Math.min(sym.dec + 2, 8)));
|
||||
const entry = round(currentPrice);
|
||||
const tp = round(direction === 'long' ? currentPrice + predRange * 1.3 : currentPrice - predRange * 1.3);
|
||||
const sl = round(direction === 'long' ? currentPrice - predRange * 0.65 : currentPrice + predRange * 0.65);
|
||||
|
||||
return {
|
||||
candles,
|
||||
prediction,
|
||||
signal: { direction, confidence, entry, tp, sl },
|
||||
currentPrice,
|
||||
change24h,
|
||||
change24hPct,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user