-
Notifications
You must be signed in to change notification settings - Fork 395
feat: add SMC bias agent #264
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| LAMATIC_API_KEY=lt-a37857b83dd6f993e9d327b9a9f68bba | ||
| LAMATIC_GRAPHQL_URL=https://ayushsorganization263-ayushsproject211.lamatic.dev/graphql | ||
| LAMATIC_PROJECT_ID=3bffc6da-08d8-4f1a-af0c-f654bca169e6 | ||
| LAMATIC_WORKFLOW_ID=8ba7821c-251d-4d58-827b-bb5d627a3d57 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| node_modules | ||
| .next | ||
| .env.local | ||
| *.tsbuildinfo |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| # SMC Bias Agent | ||
|
|
||
| Detects Smart Money Concepts (SMC/ICT) structure on a crypto pair — swing highs/lows, order blocks, fair value gaps, break of structure / change of character — and returns a plain-English directional bias with the key levels to watch. Automates the manual chart marking an SMC/ICT trader does by hand. | ||
|
|
||
| ## How it works | ||
|
|
||
| 1. **Data** — pulls recent OHLCV candles from Binance's public REST API (no key required). | ||
| 2. **Detection** (`lib/smc.ts`) — deterministic, rule-based structure analysis: | ||
| - Swing highs/lows via a 2-candle fractal | ||
| - Order blocks: the last opposite-colored candle before a confirmed structure break | ||
| - Fair value gaps: 3-candle imbalance where the outer candles don't overlap | ||
| - Break of structure (trend continuation) vs. change of character (trend reversal) | ||
| - A bias + confidence score from the most recent structure event and any still-unmitigated order block or unfilled FVG in that direction | ||
| 3. **Narrative** (`lib/lamatic-client.ts`) — the structured detection output is handed to an LLM to explain in plain English. If `LAMATIC_API_KEY` / `LAMATIC_FLOW_ENDPOINT` aren't set, a local deterministic template produces the same kind of explanation instead, so the kit is fully runnable with zero configuration. | ||
| 4. **UI** (`app/page.tsx`, `components/`) — a symbol/timeframe form, an annotated candlestick chart with the detected zones overlaid, and a bias card with reasoning and key levels. | ||
|
|
||
| ## Setup | ||
|
|
||
| ```bash | ||
| cd kits/smc-bias-agent | ||
| npm install | ||
| cp .env.example .env.local # optional, see below | ||
| npm run dev | ||
| ``` | ||
|
|
||
| Open `http://localhost:3000`, enter a symbol (e.g. `BTCUSDT`), pick a timeframe, and scan. | ||
|
|
||
| ## Wiring up your Lamatic flow (optional but recommended for submission) | ||
|
|
||
| The kit works with zero setup using the local fallback narrative. To use an actual Lamatic flow instead: | ||
|
|
||
| 1. Create a project in [Lamatic Studio](https://studio.lamatic.ai) and build a simple flow: one input (a prompt string) → one LLM node. | ||
| 2. The exact prompt to paste into the LLM node is generated by `buildBiasPrompt()` in `lib/lamatic-client.ts` — log it once locally and copy it in, or point the node at the same structure. | ||
| 3. Copy the flow's execution endpoint and an API key into `.env.local`: | ||
| ``` | ||
| LAMATIC_API_KEY=... | ||
| LAMATIC_FLOW_ENDPOINT=... | ||
| ``` | ||
| 4. Restart `npm run dev` — the bias card footer will confirm whether it's using the Lamatic flow or the local fallback. | ||
|
|
||
| > The request/response shape in `lib/lamatic-client.ts` is a reasonable placeholder — swap in the exact call snippet Studio generates for your flow if the field names differ. | ||
|
|
||
| ## Testing the detection logic | ||
|
|
||
| ```bash | ||
| npm run test:smc | ||
| ``` | ||
|
|
||
| Runs `scripts/test-smc.ts` against a hand-built synthetic candle series with a known downtrend, order block, FVG, and reversal, and asserts the detector finds all of them. | ||
|
|
||
| ## Notes and limitations | ||
|
|
||
| - Swing/OB/FVG detection uses simplified, commonly-used definitions (2-candle fractals, body-based OB zones) rather than a specific proprietary indicator's exact rules — reasonable for demonstrating the concept, not tuned for live trading. | ||
| - This is an analysis and explanation tool, not trading advice — it never suggests position sizing, entries, or execution. | ||
| - Only a small set of intervals are enabled (`15m`, `1h`, `4h`, `1d`); extend `ALLOWED_INTERVALS` in `lib/binance.ts` if you need others. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,32 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||
| import { NextRequest, NextResponse } from "next/server"; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import { fetchCandles } from "@/lib/binance"; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import { analyzeSMC } from "@/lib/smc"; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import { getBiasNarrative } from "@/lib/lamatic-client"; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| export async function POST(req: NextRequest) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| let body: { symbol?: string; interval?: string }; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||
| body = await req.json(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: "Request body must be JSON." }, { status: 400 }); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const symbol = (body.symbol || "").trim().toUpperCase(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const interval = (body.interval || "4h").trim(); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if (!symbol) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: "Provide a symbol, e.g. BTCUSDT." }, { status: 400 }); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||
| const candles = await fetchCandles(symbol, interval); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const analysis = analyzeSMC(symbol, interval, candles); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const narrative = await getBiasNarrative(analysis); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ analysis, narrative }); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| const message = err instanceof Error ? err.message : "Unexpected error while analyzing the symbol."; | ||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: message }, { status: 502 }); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+22
to
+32
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win One-size-fits-all disguise won't fool every checkpoint. Every thrown error — including client-input problems like an unsupported 🎯 Proposed fix: distinguish client vs upstream errors } catch (err) {
const message = err instanceof Error ? err.message : "Unexpected error while analyzing the symbol.";
- return NextResponse.json({ error: message }, { status: 502 });
+ const isClientError = message.includes("Unsupported interval") || message.includes("Not enough candles");
+ return NextResponse.json({ error: message }, { status: isClientError ? 400 : 502 });
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| @tailwind base; | ||
| @tailwind components; | ||
| @tailwind utilities; | ||
|
|
||
| :root { | ||
| color-scheme: dark; | ||
| } | ||
|
|
||
| * { | ||
| box-sizing: border-box; | ||
| } | ||
|
|
||
| html, | ||
| body { | ||
| padding: 0; | ||
| margin: 0; | ||
| background: #12141a; | ||
| background-image: | ||
| radial-gradient(circle at 15% 0%, rgba(216, 166, 87, 0.06), transparent 45%), | ||
| radial-gradient(circle at 85% 100%, rgba(79, 174, 138, 0.05), transparent 45%); | ||
| background-attachment: fixed; | ||
| color: #e4e6eb; | ||
| } | ||
|
|
||
| ::selection { | ||
| background: rgba(216, 166, 87, 0.3); | ||
| } | ||
|
|
||
| :focus-visible { | ||
| outline: 2px solid #d8a657; | ||
| outline-offset: 2px; | ||
| border-radius: 4px; | ||
| } | ||
|
|
||
| .tabular { | ||
| font-variant-numeric: tabular-nums; | ||
| } | ||
|
|
||
| @media (prefers-reduced-motion: reduce) { | ||
| *, | ||
| *::before, | ||
| *::after { | ||
| animation-duration: 0.001ms !important; | ||
| animation-iteration-count: 1 !important; | ||
| transition-duration: 0.001ms !important; | ||
| } | ||
| } | ||
|
|
||
| @keyframes fade-up { | ||
| from { | ||
| opacity: 0; | ||
| transform: translateY(6px); | ||
| } | ||
| to { | ||
| opacity: 1; | ||
| transform: translateY(0); | ||
| } | ||
| } | ||
|
|
||
| .animate-fade-up { | ||
| animation: fade-up 0.35s ease-out both; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import type { Metadata } from "next"; | ||
| import { Space_Grotesk, Inter, IBM_Plex_Mono } from "next/font/google"; | ||
| import "./globals.css"; | ||
|
|
||
| const display = Space_Grotesk({ | ||
| subsets: ["latin"], | ||
| weight: ["500", "700"], | ||
| variable: "--font-display", | ||
| }); | ||
|
|
||
| const body = Inter({ | ||
| subsets: ["latin"], | ||
| weight: ["400", "500", "600"], | ||
| variable: "--font-body", | ||
| }); | ||
|
|
||
| const mono = IBM_Plex_Mono({ | ||
| subsets: ["latin"], | ||
| weight: ["400", "500"], | ||
| variable: "--font-mono", | ||
| }); | ||
|
|
||
| export const metadata: Metadata = { | ||
| title: "SMC Bias Agent", | ||
| description: "Smart Money Concepts structure detection and plain-English bias for any crypto pair.", | ||
| }; | ||
|
|
||
| export default function RootLayout({ children }: { children: React.ReactNode }) { | ||
| return ( | ||
| <html lang="en" className={`${display.variable} ${body.variable} ${mono.variable}`}> | ||
| <body className="font-body min-h-screen">{children}</body> | ||
| </html> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| "use client"; | ||
|
|
||
| import { useState } from "react"; | ||
| import PriceChart from "@/components/PriceChart"; | ||
| import BiasCard from "@/components/BiasCard"; | ||
| import { SMCAnalysis } from "@/lib/types"; | ||
|
|
||
| const INTERVALS = ["15m", "1h", "4h", "1d"]; | ||
|
|
||
| interface ApiResponse { | ||
| analysis: SMCAnalysis; | ||
| narrative: { reasoning: string; source: "lamatic" | "fallback" }; | ||
| } | ||
|
|
||
| export default function Home() { | ||
| const [symbol, setSymbol] = useState("BTCUSDT"); | ||
| const [interval, setIntervalValue] = useState("4h"); | ||
| const [data, setData] = useState<ApiResponse | null>(null); | ||
| const [loading, setLoading] = useState(false); | ||
| const [error, setError] = useState<string | null>(null); | ||
|
|
||
| async function runScan(e?: React.FormEvent) { | ||
| e?.preventDefault(); | ||
| setLoading(true); | ||
| setError(null); | ||
|
|
||
| try { | ||
| const res = await fetch("/api/analyze", { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ symbol, interval }), | ||
| }); | ||
| const json = await res.json(); | ||
|
|
||
| if (!res.ok) throw new Error(json.error || "Something went wrong."); | ||
| setData(json); | ||
| } catch (err) { | ||
| setError(err instanceof Error ? err.message : "Something went wrong."); | ||
| setData(null); | ||
| } finally { | ||
| setLoading(false); | ||
| } | ||
| } | ||
|
|
||
| return ( | ||
| <main className="mx-auto max-w-5xl px-4 py-8 md:py-12 flex flex-col gap-8"> | ||
| <header className="flex flex-col gap-1"> | ||
| <span className="font-mono text-xs uppercase tracking-[0.2em] text-signal">Smart Money Concepts</span> | ||
| <h1 className="font-display text-2xl md:text-3xl font-medium text-ink">SMC Bias Agent</h1> | ||
| <p className="text-sm text-muted max-w-xl"> | ||
| Reads recent price action for a pair, marks up order blocks, fair value gaps, and structure breaks the way | ||
| an ICT/SMC trader would by hand, then explains the bias in plain English. | ||
| </p> | ||
| </header> | ||
|
|
||
| <form onSubmit={runScan} className="flex flex-wrap items-end gap-3 rounded-lg border border-border bg-panel p-4"> | ||
| <label className="flex flex-col gap-1"> | ||
| <span className="text-xs uppercase tracking-wide text-muted">Symbol</span> | ||
| <input | ||
| value={symbol} | ||
| onChange={(e) => setSymbol(e.target.value.toUpperCase())} | ||
| placeholder="BTCUSDT" | ||
| className="w-40 rounded-md border border-border bg-bg px-3 py-2 font-mono text-sm text-ink outline-none focus:border-signal" | ||
| /> | ||
| </label> | ||
|
|
||
| <label className="flex flex-col gap-1"> | ||
| <span className="text-xs uppercase tracking-wide text-muted">Timeframe</span> | ||
| <select | ||
| value={interval} | ||
| onChange={(e) => setIntervalValue(e.target.value)} | ||
| className="rounded-md border border-border bg-bg px-3 py-2 font-mono text-sm text-ink outline-none focus:border-signal" | ||
| > | ||
| {INTERVALS.map((i) => ( | ||
| <option key={i} value={i}> | ||
| {i} | ||
| </option> | ||
| ))} | ||
| </select> | ||
| </label> | ||
|
|
||
| <button | ||
| type="submit" | ||
| disabled={loading} | ||
| className="ml-auto rounded-md bg-signal px-5 py-2 font-display text-sm font-medium text-bg transition hover:opacity-90 disabled:opacity-50" | ||
| > | ||
| {loading ? "Scanning…" : "Scan"} | ||
| </button> | ||
| </form> | ||
|
|
||
| {error && ( | ||
| <div className="rounded-md border border-bear/40 bg-bear/10 px-4 py-3 text-sm text-ink"> | ||
| Couldn't analyze that symbol: {error} | ||
| </div> | ||
| )} | ||
|
|
||
| {data && ( | ||
| <div className="grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-6 items-start"> | ||
| <div className="animate-fade-up rounded-lg border border-border bg-panel p-4"> | ||
| <PriceChart analysis={data.analysis} /> | ||
| <div className="flex gap-4 pt-3 text-xs text-muted"> | ||
| <span className="flex items-center gap-1.5"> | ||
| <span className="h-2 w-2 rounded-sm bg-signal/60" /> order block | ||
| </span> | ||
| <span className="flex items-center gap-1.5"> | ||
| <span className="h-2 w-2 rounded-sm bg-bull/40" /> fair value gap | ||
| </span> | ||
| <span className="flex items-center gap-1.5"> | ||
| <span className="h-2 w-0.5 border-t border-dashed border-signal" /> structure break | ||
| </span> | ||
| </div> | ||
| </div> | ||
|
|
||
| <BiasCard analysis={data.analysis} reasoning={data.narrative.reasoning} source={data.narrative.source} /> | ||
| </div> | ||
| )} | ||
|
|
||
| {!data && !error && ( | ||
| <div className="rounded-lg border border-dashed border-border p-10 text-center text-sm text-muted"> | ||
| Run a scan to see the detected structure. | ||
| </div> | ||
| )} | ||
| </main> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import { SMCAnalysis } from "@/lib/types"; | ||
|
|
||
| const BIAS_STYLES: Record<string, { text: string; bg: string; dot: string }> = { | ||
| bullish: { text: "text-bull", bg: "bg-bull/10", dot: "bg-bull" }, | ||
| bearish: { text: "text-bear", bg: "bg-bear/10", dot: "bg-bear" }, | ||
| neutral: { text: "text-muted", bg: "bg-muted/10", dot: "bg-muted" }, | ||
| }; | ||
|
|
||
| export default function BiasCard({ | ||
| analysis, | ||
| reasoning, | ||
| source, | ||
| }: { | ||
| analysis: SMCAnalysis; | ||
| reasoning: string; | ||
| source: "lamatic" | "fallback"; | ||
| }) { | ||
| const style = BIAS_STYLES[analysis.bias]; | ||
|
|
||
| return ( | ||
| <div className="animate-fade-up rounded-lg border border-border bg-panel p-5 flex flex-col gap-5"> | ||
| <div className="flex items-center justify-between"> | ||
| <div className={`inline-flex items-center gap-2 rounded-full px-3 py-1 ${style.bg}`}> | ||
| <span className={`h-2 w-2 rounded-full ${style.dot}`} /> | ||
| <span className={`font-display font-medium uppercase tracking-wide text-sm ${style.text}`}> | ||
| {analysis.bias} | ||
| </span> | ||
| </div> | ||
| <span className="font-mono tabular text-sm text-muted">{analysis.confidence}% confidence</span> | ||
| </div> | ||
|
|
||
| <p className="text-sm leading-relaxed text-ink/90">{reasoning}</p> | ||
|
|
||
| <div className="flex flex-col gap-2"> | ||
| <span className="text-xs uppercase tracking-wide text-muted">Key levels</span> | ||
| {analysis.keyLevels.length === 0 && <span className="text-sm text-muted">No active zones detected.</span>} | ||
| {analysis.keyLevels.map((level, i) => ( | ||
| <div key={i} className="flex items-center justify-between border-t border-border pt-2 text-sm"> | ||
| <div className="flex flex-col"> | ||
| <span className="text-ink/90">{level.label}</span> | ||
| <span className="text-xs text-muted">{level.detail}</span> | ||
| </div> | ||
| <span className="font-mono tabular text-ink/90"> | ||
| {level.low === level.high | ||
| ? level.low.toFixed(level.low < 10 ? 4 : 1) | ||
| : `${level.low.toFixed(level.low < 10 ? 4 : 1)}–${level.high.toFixed(level.high < 10 ? 4 : 1)}`} | ||
| </span> | ||
| </div> | ||
| ))} | ||
| </div> | ||
|
|
||
| <span className="text-xs text-muted"> | ||
| {source === "lamatic" ? "Narrative generated by your Lamatic flow." : "Narrative generated locally (no LAMATIC_API_KEY configured)."} | ||
| </span> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win
Abort, abort — this tape is self-destructing for a reason, and these credentials should too.
These values look like real, issued credentials (API key, project ID, workflow ID, and a personal Lamatic subdomain in the GraphQL URL), not placeholders. Static analysis flags a generic API key here. Committing real secrets into a
.env.exampletemplate exposes them to anyone who clones the repo.Rotate this key/workflow immediately and replace all four values with clearly fake placeholders (e.g.
LAMATIC_API_KEY=your-lamatic-api-key).🧰 Tools
🪛 Betterleaks (1.6.1)
[high] 1-1: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.
(generic-api-key)
🪛 dotenv-linter (4.0.0)
[warning] 4-4: [EndingBlankLine] No blank line at the end of the file
(EndingBlankLine)
🤖 Prompt for AI Agents
Source: Linters/SAST tools