Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/api/routes/analyze-ticker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ export async function handleAnalyzeTicker(request: Request): Promise<Response> {
analysis: analysisResult,
};

console.log("Ticker analysis response:", response);
return jsonResponse(successResponse(response), 200, origin);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
Expand Down
12 changes: 10 additions & 2 deletions src/api/routes/refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,16 @@ export async function handleRefresh(request: Request): Promise<Response> {
return jsonResponse(successResponse(response), 200, origin);
}

// Determine schedule based on time
const hour = new Date().getHours();
// Determine schedule based on WIB time (UTC+7)
const options = { timeZone: "Asia/Jakarta", hour12: false };
const parts = new Intl.DateTimeFormat('en-US', {
...options,
hour: 'numeric',
}).formatToParts(new Date());

let hour = parseInt(parts.find(p => p.type === 'hour')?.value || "0");
if (hour === 24) hour = 0; // Intl format sometimes returns 24 for 00:00

const schedule: JobSchedule = hour < 12 ? "morning" : "evening";
const today = new Date().toISOString().slice(0, 10);

Expand Down
1 change: 1 addition & 0 deletions src/lib/analyzer/llm-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export async function generateContent<T>(
// Fallback to regex parsing if strict JSON parsing fails
parsedJson = parseJsonResponse(text);
if (!parsedJson) {
console.error("RAW LLM OUTPUT:", text);
throw new Error("Failed to parse JSON from response text");
}
}
Expand Down
120 changes: 84 additions & 36 deletions src/lib/analyzer/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,48 +5,96 @@ import { z } from "zod";
*/
export const tickerExtractionSchema = z.object({
tickers: z.array(
z.object({
code: z.string().toUpperCase().min(1),
sentiment: z.number().min(-1).max(1),
relevance: z.number().min(0).max(1),
reason: z.string(),
})
z.preprocess(
(val) => {
if (typeof val === "string") {
return { code: val, sentiment: 0, relevance: 0.5, reason: "Extracted from text" };
}
if (val && typeof val === "object") {
const v = val as any;
return {
...v,
code: typeof v.code === "string" ? v.code : String(v.code || v.ticker || v.name || "UNKNOWN"),
sentiment: typeof v.sentiment === "number" ? v.sentiment : 0,
relevance: typeof v.relevance === "number" ? v.relevance : 0.5,
reason: typeof v.reason === "string" ? v.reason : "Extracted from text",
};
}
return val;
},
z.object({
code: z.string().toUpperCase().min(1),
sentiment: z.number().min(-1).max(1),
relevance: z.number().min(0).max(1),
reason: z.string(),
})
)
),
});

/**
* Stock Analysis Schema
*/
export const stockAnalysisSchema = z.object({
action: z.enum(["BUY", "HOLD", "AVOID"]),
confidence: z.number().min(1).max(10),
entryPrice: z.number().nonnegative(),
stopLoss: z.number().nonnegative(),
targetPrice: z.number().nonnegative(),
maxHoldDays: z.number().int().positive(),
orderType: z.enum(["LIMIT", "MARKET"]).default("LIMIT"),
scores: z.object({
sentiment: z.number().min(0).max(100),
fundamental: z.number().min(0).max(100),
technical: z.number().min(0).max(100),
overall: z.number().min(0).max(100),
}),
reasoning: z.object({
news: z.string(),
fundamental: z.string(),
technical: z.string(),
summary: z.string(),
}),
previousPredictionUpdates: z.array(
z.object({
ticker: z.string().toUpperCase(),
action: z.enum(["HOLD", "EXIT", "TAKE_PROFIT", "ADD"]),
reason: z.string(),
newStopLoss: z.number().nonnegative().optional(),
newTarget: z.number().nonnegative().optional(),
})
).optional().default([]),
});
export const stockAnalysisSchema = z.preprocess(
(val) => {
if (typeof val === "string") {
try { return JSON.parse(val); } catch { return val; }
}
return val;
},
z.object({
action: z.enum(["BUY", "HOLD", "AVOID"]),
confidence: z.number().min(1).max(10),
entryPrice: z.number().nonnegative(),
stopLoss: z.number().nonnegative(),
targetPrice: z.number().nonnegative(),
maxHoldDays: z.number().int().positive(),
orderType: z.enum(["LIMIT", "MARKET"]).default("LIMIT"),
scores: z.preprocess((val) => {
if (typeof val === "string") {
try { return JSON.parse(val); } catch { return { sentiment: 0, fundamental: 0, technical: 0, overall: 0 }; }
}
return val;
}, z.object({
sentiment: z.number().min(0).max(100),
fundamental: z.number().min(0).max(100),
technical: z.number().min(0).max(100),
overall: z.number().min(0).max(100),
})),
reasoning: z.preprocess((val) => {
if (typeof val === "string") {
try {
return JSON.parse(val);
} catch {
return { summary: val, news: "", fundamental: "", technical: "" };
}
}
return val;
}, z.object({
news: z.string().optional().default(""),
fundamental: z.string().optional().default(""),
technical: z.string().optional().default(""),
summary: z.string().optional().default(""),
})),
previousPredictionUpdates: z.array(
z.preprocess(
(val) => {
if (typeof val === "string") {
try { return JSON.parse(val); } catch { return val; }
}
return val;
},
z.object({
ticker: z.string().toUpperCase(),
action: z.enum(["HOLD", "EXIT", "TAKE_PROFIT", "ADD"]),
reason: z.string(),
newStopLoss: z.number().nonnegative().optional(),
newTarget: z.number().nonnegative().optional(),
})
)
).optional().default([]),
})
);

export type TickerExtractionResponse = z.infer<typeof tickerExtractionSchema>;
export type StockAnalysisResponse = z.infer<typeof stockAnalysisSchema>;
16 changes: 9 additions & 7 deletions src/lib/prediction-tracker/status-checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,21 @@ export function checkStatusChange(

// Pending - check if entry price is hit
if (status === "pending") {
// For LIMIT orders: do NOT auto-transition to entry_hit.
// Entry should only happen after market close (manual or scheduled).
// For MARKET orders: auto-transition when price is hit.
const orderType = prediction.orderType ?? "LIMIT";

if (orderType === "MARKET" && isPriceHit(currentPrice, entryPrice)) {
// For ANY order type (LIMIT or MARKET): auto-transition when price touches entry
// A MARKET order buys right at the open price, but we just check if it touches the entry.
// We treat both similarly for real-time tracking: if the price hits or drops below the entryPrice, we enter.
const tolerance = 0.005; // 0.5% tolerance
const upperBound = entryPrice * (1 + tolerance);

// We consider the entry hit if current price is <= upperBound of entry price
if (currentPrice <= upperBound) {
return {
id,
ticker,
previousStatus: status,
newStatus: "entry_hit",
price: currentPrice,
reason: `MARKET order: Entry price ${entryPrice} reached at ${currentPrice}`,
reason: `Entry price ${entryPrice} reached at ${currentPrice}`,
timestamp: new Date(),
};
}
Expand Down
72 changes: 55 additions & 17 deletions src/services/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,7 @@ async function runCheck() {
});
}

for (const update of result.statusUpdates) {
await notifyStatusChange(update);
}
await notifyBatchedStatusChanges(result.statusUpdates);
}

// 3. Anomaly Detection
Expand Down Expand Up @@ -171,33 +169,73 @@ async function runCheck() {
}
}

async function notifyStatusChange(update: StatusUpdate) {
const { ticker, previousStatus, newStatus, price, reason } = update;
const emoji = getStatusEmoji(newStatus);
async function notifyBatchedStatusChanges(updates: StatusUpdate[]) {
if (updates.length === 0) return;

// Single update: keep the detailed format
if (updates.length === 1) {
const update = updates[0];
if (!update) return;
const emoji = getStatusEmoji(update.newStatus);
const time = update.timestamp.toLocaleTimeString("id-ID", { hour: "2-digit", minute: "2-digit" });

const message = `
${emoji} *${update.ticker} Status Update*

*Previous:* ${formatStatus(update.previousStatus)}
*New:* ${formatStatus(update.newStatus)}

const message = `
${emoji} *${ticker} Update*
💰 *Price:* ${update.price}
⏱ *Time:* ${time}

Status: ${formatStatus(previousStatus)} ➡️ ${formatStatus(newStatus)}
Price: ${price}
Reason: ${reason}
Time: ${update.timestamp.toLocaleTimeString("id-ID")}
`.trim();
📝 *Details:* ${update.reason}

await sendTelegramNotification(message);
_⚠️ Disclaimer: Prices may be delayed by up to 10 mins. Not financial advice. DYOR._
`.trim();

await sendTelegramNotification(message);
return;
}

// Multiple updates: Group by newStatus
const grouped = new Map<PredictionStatus, StatusUpdate[]>();
for (const u of updates) {
const arr = grouped.get(u.newStatus) || [];
arr.push(u);
grouped.set(u.newStatus, arr);
}

let message = `📊 *Sentimeter Batched Updates*\n`;
message += `_(${updates.length} updates at ${new Date().toLocaleTimeString("id-ID", { hour: "2-digit", minute: "2-digit" })})_\n\n`;

for (const [status, groupUpdates] of grouped.entries()) {
const emoji = getStatusEmoji(status);
message += `${emoji} *${formatStatus(status)}*\n`;
for (const u of groupUpdates) {
// Create concise bullet points
message += `• ${u.ticker}: ${u.reason}\n`;
}
message += `\n`;
}

message += `_⚠️ Disclaimer: Prices may be delayed by up to 10 mins. Not financial advice. DYOR._`;

await sendTelegramNotification(message.trim());
}

async function notifyAnomaly(anomaly: AnomalyDetected, analysis: string) {
const emoji = anomaly.type === "PRICE" ? "🚀" : "🔊";
const message = `
${emoji} *${anomaly.ticker} Anomaly Detected*

Type: ${anomaly.type}
Value: ${anomaly.type === "PRICE" ? anomaly.value.toFixed(2) + "%" : anomaly.value}
Message: ${anomaly.message}
*Type:* ${anomaly.type}
*Value:* ${anomaly.type === "PRICE" ? anomaly.value.toFixed(2) + "%" : anomaly.value}
*Details:* ${anomaly.message}

🤖 *AI Analysis:*
${analysis}

_⚠️ Disclaimer: Prices may be delayed by up to 10 mins. Not financial advice. DYOR._
`.trim();

await sendTelegramNotification(message);
Expand Down
35 changes: 31 additions & 4 deletions src/services/telegram-poller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ async function handleUpdate(update: TelegramUpdate) {
const user = message.from;

if (text === "/start") {
console.log(`👤 New Telegram user: ${user.first_name} (${chatId})`);
console.log(`👤 New Telegram user: ${user.first_name} ${user.last_name} (${user.username})`);

upsertTelegramUser({
chatId,
Expand All @@ -122,16 +122,43 @@ async function handleUpdate(update: TelegramUpdate) {
isActive: true,
});

await sendDirectMessage(process.env.TELEGRAM_BOT_TOKEN!, chatId, "✅ You are now subscribed to Sentimeter alerts!");
const welcomeMsg = `
🎉 *Welcome to Sentimeter!*

Hello ${user.first_name}! You are now successfully subscribed to receive AI-driven stock alerts. 📈

I will notify you here whenever:
🟢 A new position is entered
🎯 A target price is hit
🛑 A stop loss is triggered
⏰ A trade expires
🚀 Unusual volume or price anomalies are detected

_⚠️ Disclaimer: Prices may be delayed by up to 10 mins. Not financial advice. Always DYOR before trading._

🐙 *Open Source:* [GitHub Repository](https://github.com/snowfluke/sentimeter)

Type /stop at any time if you wish to unsubscribe.
`.trim();

await sendDirectMessage(process.env.TELEGRAM_BOT_TOKEN!, chatId, welcomeMsg);
} else if (text === "/stop") {
console.log(`👤 User unsubscribed: ${user.first_name} (${chatId})`);
console.log(`👤 User unsubscribed: ${user.first_name} ${user.last_name} (${user.username})`);

upsertTelegramUser({
chatId,
isActive: false,
});

await sendDirectMessage(process.env.TELEGRAM_BOT_TOKEN!, chatId, "🔕 You have unsubscribed from alerts.");
const goodbyeMsg = `
🔕 *Alerts Disabled*

You have successfully unsubscribed from Sentimeter alerts.

If you ever want to come back and start receiving notifications again, just type /start! 👋
`.trim();

await sendDirectMessage(process.env.TELEGRAM_BOT_TOKEN!, chatId, goodbyeMsg);
}
}

Expand Down
4 changes: 2 additions & 2 deletions web/src/components/ActivePositionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function ActivePositionCard({ position }: ActivePositionCardProps) {

return (
<Card className="hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3 sm:gap-0 mb-3">
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-bold text-gray-900">{position.ticker}</h3>
Expand All @@ -31,7 +31,7 @@ export function ActivePositionCard({ position }: ActivePositionCardProps) {
</div>
<p className="text-sm text-gray-500">{position.companyName}</p>
</div>
<div className="text-right">
<div className="text-left sm:text-right">
<p className={`text-xl font-bold ${pnlColor}`}>
{formatPercent(position.unrealizedPnlPct)}
</p>
Expand Down
Loading