This spec documents every gap between the mobile app and the backend API, with exact file paths, line numbers, API contracts, and implementation instructions. It is designed so that any developer or coding agent can pick up a work item and implement it without further context.
User ID convention: All API calls use "user_mvp_1" as the hardcoded user ID for the hackathon demo.
API client: apps/mobile/src/services/api/index.ts — singleton api instance. All methods already exist (onboarding, chat, intake, generateReport, panelChat, importPortfolio, getPortfolio, getProfile). Types are in apps/mobile/src/services/api/types.ts.
Base URL: apps/mobile/src/config/config.dev.ts — currently http://192.168.37.85:8000. Update to deployed Railway URL in config.prod.ts after deploy.
- WS-1: Voice Onboarding (Conversational Intake)
- WS-2: Board Thread → Panel Chat
- WS-3: New Thread Creation (Report Generation)
- WS-4: Heartbeat Alerts UI + Custom Scenarios
- WS-5: Voice Playback on Agent Responses
- WS-6: Portfolio from Backend
- WS-7: Home Screen Live Agent Data
- WS-8: Live Stock Charts
Goal: Replace the current form-based onboarding (steps 1-4) with a real-time voice conversation where the user talks to a voice agent who extracts their risk profile, goals, and investment preferences through natural conversation.
- Onboarding is a 4-step form flow:
apps/mobile/app/onboarding/step-1.tsx→ThemeShowcaseScreen(name, risk, horizon, knowledge chips)apps/mobile/app/onboarding/step-2.tsx→OnboardingPortfolioTypeScreen(crypto/stock)apps/mobile/app/onboarding/step-3/→ wallet or stock importapps/mobile/app/onboarding/step-4.tsx→OnboardingCompleteScreen(callsapi.onboarding()+api.importPortfolio())
- Screen implementations live in
apps/mobile/src/screens/ OnboardingCompleteScreenis the only step that calls the backend
Intake endpoint (conversational goal extraction):
POST /api/intake
Request: { user_id: string, message: string }
Response: {
user_id: string,
message: string, // agent's reply text
is_complete: boolean, // true when goals extracted
follow_up_count: number, // max 2 follow-ups
goals_brief: string | null,
audio_b64: string | null // base64 MP3 if ElevenLabs configured
}
Onboarding endpoint (creates user profile):
POST /api/onboarding
Request: { user_id: string, risk_score: number, horizon: "short"|"medium"|"long", knowledge: number }
Response: { profile: UserProfile, welcome_message: string, audio_b64: string | null }
Intake reset:
POST /api/intake/reset?user_id=user_mvp_1
Replace onboarding with a single voice conversation screen:
New file: apps/mobile/src/screens/VoiceOnboardingScreen.tsx
UX flow:
- User lands on a voice chat screen with a mic button and text input fallback
- User taps mic → record audio → send as text via speech-to-text (or type)
- App calls
POST /api/intakewith the user's message - Backend responds with agent text +
audio_b64 - App plays the audio via
playBase64Audio()fromsrc/utils/playAudio.ts - Conversation continues until
is_complete === true(max 2 follow-ups) - When complete, app calls
POST /api/onboardingwith extracted profile data - App calls
POST /api/portfolio/importwithmode: "mock" - Navigate to home tab
Speech-to-text options (pick one):
expo-speech-recognition— wraps native iOS speech recognition, works on-device@react-native-voice/voice— community package, good iOS support- Manual: record audio with
expo-av, send to a transcription API (Deepgram, Whisper)
State to track:
type VoiceOnboardingState = {
messages: { role: "user" | "agent", text: string, audio_b64?: string }[]
isListening: boolean
isProcessing: boolean
isComplete: boolean
}Mapping intake results to onboarding profile:
The intake goals_brief is free text. After intake completes, you need to either:
- Use the form chips as a fallback step ("Confirm your profile") after the conversation
- Or parse the goals_brief on the backend to auto-derive risk_score/horizon/knowledge
Recommended for demo: Keep it simple. After voice conversation completes, show a brief confirmation screen with the profile chips pre-filled, then call api.onboarding().
| File | Change |
|---|---|
apps/mobile/src/screens/VoiceOnboardingScreen.tsx |
Create — voice chat UI with mic button, message list, auto-play TTS |
apps/mobile/app/onboarding/step-1.tsx |
Change to render VoiceOnboardingScreen instead of ThemeShowcaseScreen |
apps/mobile/src/stores/onboardingStore.ts |
No changes needed — still stores the final profile values |
apps/mobile/src/services/api/index.ts |
No changes — api.intake() already exists (line 111) |
apps/mobile/src/utils/playAudio.ts |
No changes — playBase64Audio() already works |
- API client intake method:
apps/mobile/src/services/api/index.ts:111-121 - API client onboarding method:
apps/mobile/src/services/api/index.ts:49-61 - Audio playback utility:
apps/mobile/src/utils/playAudio.ts:17-44 - Onboarding store:
apps/mobile/src/stores/onboardingStore.ts - Backend intake handler:
apps/agents/src/finly_agents/server.py— search forPOST /api/intake - Backend intake logic:
apps/agents/src/finly_agents/intake.py— MAX_FOLLOW_UPS = 2
Goal: Wire the thread detail chat so that when the user sends a message, it calls the backend panel chat API and displays real agent responses.
- Board tab (
apps/mobile/app/(tabs)/board.tsx): ShowsboardThreadsfrommockAppData.ts— 4 hardcoded threads (NVDA, TSLA, MSFT, AAPL) - Thread detail (
apps/mobile/app/thread/[id].tsx):- Displays mock messages from the thread
- Text input works but
handleSend()(line 61) only appends to localmessagesstate - No API calls — user messages go nowhere
- No agent response generation
Panel chat (talk to the agent board about an existing report):
POST /api/report/chat
Request: { user_id: string, message: string, report_id?: string }
Response: {
user_id: string,
question: string,
agent_responses: [
{ agent_role: string, agent_name: string, response: string },
... // 4 agents: Analyst, Researcher, Trader, Advisor
],
memory_updates: string[]
}
Requirement: A report must exist before panel chat works. The backend checks get_latest_report(user_id) and returns "No report has been generated yet" if none exists.
File to modify: apps/mobile/app/thread/[id].tsx
Changes to handleSend() (currently line 61-76):
const handleSend = async () => {
const nextMessage = draft.trim()
if (!nextMessage) return
// 1. Append user message immediately (optimistic)
setMessages((current) => [
...current,
{
id: String(current.length + 1),
author: "You",
role: "user",
avatar: "YU",
message: nextMessage,
time: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
},
])
setDraft("")
setIsLoading(true)
// 2. Call panel chat API
const result = await api.panelChat({
user_id: "user_mvp_1",
message: nextMessage,
})
if (result.kind === "ok") {
// 3. Append each agent response as a separate message
const agentMessages = result.data.agent_responses.map((resp, i) => ({
id: String(messages.length + 2 + i),
author: resp.agent_name,
role: mapAgentRole(resp.agent_role), // map "analyst" -> "market-analyst" etc.
avatar: resp.agent_name.slice(0, 2).toUpperCase(),
message: resp.response,
time: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
}))
setMessages((current) => [...current, ...agentMessages])
} else {
// 4. Show error state (optional: toast or inline error message)
}
setIsLoading(false)
}Agent role mapping (backend returns simplified roles):
function mapAgentRole(role: string): BoardMessage["role"] {
const map: Record<string, BoardMessage["role"]> = {
analyst: "market-analyst",
researcher: "researcher",
trader: "portfolio-manager",
advisor: "portfolio-manager",
}
return map[role] ?? "researcher"
}Add imports:
import { api } from "@/services/api"Add loading state: Show a typing indicator or spinner while waiting for agent responses (panel chat takes up to 60s).
| File | Change |
|---|---|
apps/mobile/app/thread/[id].tsx |
Wire handleSend to api.panelChat(), add loading state, map agent responses to BoardMessage format |
This will only return real agent responses if a report has been generated first (see WS-3). Without a report, the backend returns a canned "generate a report first" message from all 4 agents.
Goal: Allow the user to create a new board thread by asking a question about a ticker, which triggers the full agent pipeline and creates a report.
- Board tab shows 4 hardcoded threads from
boardThreadsinmockAppData.ts - No way to create a new thread
- No report generation from the mobile app (the
DashboardScreenhas it but is not in the navigation)
Report generation (runs full multi-agent pipeline):
POST /api/report/generate
Request: { user_id: string, ticker?: string, portfolio?: Record[] }
Response: {
report_id: string,
user_id: string,
ticker: string,
decision: string, // "BUY" | "HOLD" | "SELL"
summary: string,
full_report: string,
agent_reasoning: Record,
specialist_insights: [
{ role: string, summary: string, full_analysis: string },
...
],
additional_tickers: [{ ticker: string, reason: string }, ...],
intake_brief: string
}
Timeout: 120 seconds — the agent pipeline is slow (30-60s typical).
Option A: "New Thread" button on Board tab
Add a floating action button or header button on the Board tab that opens a modal/sheet:
- User taps "+" or "New Thread"
- Modal shows a text input: "What would you like the board to analyze?"
- User types: "Should I buy VCB?" or "Analyze FPT for me"
- App calls
POST /api/report/generatewith{ user_id: "user_mvp_1", message }- Note: the backend auto-extracts the ticker from the message, or uses
FINLY_DEFAULT_TICKER(FPT)
- Note: the backend auto-extracts the ticker from the message, or uses
- Show a full-screen loading state (this takes 30-60s) — important for demo UX
- On success, create a new
BoardThreadobject from the report response and prepend to the thread list - Navigate to the new thread detail view
Mapping ReportResponse → BoardThread:
function reportToThread(report: ReportResponse): BoardThread {
return {
id: report.report_id,
title: `${report.ticker} ${report.decision.toLowerCase()} analysis`,
ticker: report.ticker,
decision: mapDecision(report.decision), // "BUY" -> "Buy", "SELL" -> "Sell", "HOLD" -> "Position"
intake: report.intake_brief || report.summary,
summary: report.summary,
updatedAt: "Just now",
unreadCount: report.specialist_insights.length,
participantAgentIds: ["portfolio-manager", "market-analyst", "risk-assessor", "researcher"],
messages: report.specialist_insights.map((insight, i) => ({
id: String(i + 1),
author: roleToAgentName(insight.role),
role: roleToAgentRole(insight.role),
avatar: roleToAgentName(insight.role).slice(0, 2).toUpperCase(),
message: insight.summary,
time: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
})),
}
}State management: The thread list should combine mock threads + any real threads from reports. Options:
- Simple:
useStatein Board tab, prepend new threads - Better: Zustand store (
boardStore.ts) that holds threads and persists across tab switches
| File | Change |
|---|---|
apps/mobile/app/(tabs)/board.tsx |
Add "New Thread" button, loading state, call api.generateReport(), prepend result to thread list |
apps/mobile/src/stores/boardStore.ts |
Create (optional) — Zustand store for threads if you want persistence across tab switches |
apps/mobile/src/services/api/index.ts |
No changes — api.generateReport() already exists (line 137) |
Goal: Add heartbeat alert UI to the mobile app with a demo trigger button, and support custom scenarios on the backend (e.g., "Strait of Hormuz closed").
Mobile: HeartbeatAlert type is defined in types.ts:168-177 but completely unused. No UI, no polling, no trigger button.
Backend:
GET /api/heartbeat/alerts?user_id=broadcast— returns and clears pending alertsPOST /api/heartbeat/trigger?scenario=fpt_earnings_beat&user_id=broadcast— triggers one of 8 pre-built scenarios- Only 8 hardcoded scenarios in
heartbeat.py:14-78— cannot create custom events like "Strait of Hormuz closed"
File: apps/agents/src/finly_agents/heartbeat.py
Add a function to register ad-hoc scenarios:
def trigger_custom_alert(
ticker: str,
headline: str,
body: str,
severity: str = "warning",
attributed_to: str = "Market Analyst",
user_id: str = "broadcast",
) -> HeartbeatAlert:
"""Create an ad-hoc alert not tied to a pre-built scenario."""
alert = HeartbeatAlert(
alert_id=uuid.uuid4().hex[:12],
timestamp=datetime.now().isoformat(),
ticker=ticker,
alert_type="custom",
headline=headline,
body=body,
attributed_to=attributed_to,
severity=severity,
)
_alert_queues.setdefault(user_id, []).append(alert)
if user_id != "broadcast":
_alert_queues.setdefault("broadcast", []).append(alert)
return alertFile: apps/agents/src/finly_agents/server.py
Add a new endpoint after the existing heartbeat endpoints (~line 846):
class CustomAlertRequest(BaseModel):
ticker: str = "HOSE"
headline: str
body: str
severity: str = "warning"
attributed_to: str = "Market Analyst"
@app.post("/api/heartbeat/custom")
async def heartbeat_custom(req: CustomAlertRequest, user_id: str = Query(default="broadcast")):
from finly_agents.heartbeat import trigger_custom_alert
alert = trigger_custom_alert(
ticker=req.ticker,
headline=req.headline,
body=req.body,
severity=req.severity,
attributed_to=req.attributed_to,
user_id=user_id,
)
return alert.model_dump()Pre-built demo scenarios to add to SCENARIOS dict (for the demo trigger button):
"hormuz_closure": {
"ticker": "HOSE",
"alert_type": "geopolitical",
"headline": "Strait of Hormuz closed — oil prices spike 12%",
"body": "Breaking: Iran has closed the Strait of Hormuz to commercial shipping. Crude oil surged 12% in Asian trading. The Risk Assessor flags elevated exposure for portfolios with energy-sensitive names. Review your holdings for supply chain risk.",
"attributed_to": "Risk Assessor",
"severity": "critical",
},
"rsi_threshold": {
"ticker": "VCB",
"alert_type": "technical",
"headline": "VCB RSI crosses above 70 — overbought signal",
"body": "VCB's 14-day RSI has crossed 70, entering overbought territory. Historically this has preceded 3-5% pullbacks within 2 weeks. The Market Analyst recommends tightening stop-losses or trimming on further strength.",
"attributed_to": "Market Analyst",
"severity": "warning",
},API client addition (apps/mobile/src/services/api/index.ts):
async getHeartbeatAlerts(
userId: string = "broadcast",
): Promise<{ kind: "ok"; alerts: HeartbeatAlert[] } | GeneralApiProblem> {
const response: ApiResponse<HeartbeatAlert[]> = await this.apisauce.get(
`/api/heartbeat/alerts?user_id=${userId}`,
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
return { kind: "ok", alerts: response.data ?? [] }
}
async triggerHeartbeat(
scenario: string,
userId: string = "broadcast",
): Promise<{ kind: "ok"; alert: HeartbeatAlert } | GeneralApiProblem> {
const response: ApiResponse<HeartbeatAlert> = await this.apisauce.post(
`/api/heartbeat/trigger?scenario=${scenario}&user_id=${userId}`,
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
return { kind: "ok", alert: response.data! }
}Heartbeat UI options (pick one):
Option 1: Alert banner on Home tab (recommended for demo)
- Add a dismissable alert card at the top of the Home tab scroll view
- Poll
GET /api/heartbeat/alertsevery 10 seconds - Show severity-colored banner: critical = red, warning = amber, info = blue
- Display: headline, body, attributed_to agent name + avatar
Option 2: Dedicated alerts tab
- Replace Settings tab with an Alerts tab
- Full list of alerts with timestamps
Demo trigger button:
- Add a hidden dev button (long-press on the "Your Team" header, or a button in Settings)
- Shows a picker with scenario options:
hormuz_closure,rsi_threshold,fpt_earnings_beat, etc. - Calls
api.triggerHeartbeat(scenario) - The next poll cycle picks up the alert and displays it
Polling hook (apps/mobile/src/services/useHeartbeatAlerts.ts):
export function useHeartbeatAlerts(intervalMs = 10000) {
const [alerts, setAlerts] = useState<HeartbeatAlert[]>([])
useEffect(() => {
let active = true
const poll = async () => {
const result = await api.getHeartbeatAlerts("user_mvp_1")
if (active && result.kind === "ok" && result.alerts.length > 0) {
setAlerts((prev) => [...result.alerts, ...prev])
}
}
const timer = setInterval(poll, intervalMs)
poll() // initial fetch
return () => { active = false; clearInterval(timer) }
}, [intervalMs])
return { alerts, clearAlerts: () => setAlerts([]) }
}Note: GET /api/heartbeat/alerts returns and clears — alerts are consumed on poll. The mobile app should accumulate them locally.
| File | Change |
|---|---|
apps/agents/src/finly_agents/heartbeat.py |
Add trigger_custom_alert(), add hormuz_closure and rsi_threshold to SCENARIOS |
apps/agents/src/finly_agents/server.py |
Add POST /api/heartbeat/custom endpoint |
apps/mobile/src/services/api/index.ts |
Add getHeartbeatAlerts() and triggerHeartbeat() methods |
apps/mobile/src/services/api/types.ts |
No changes — HeartbeatAlert already defined |
apps/mobile/src/services/useHeartbeatAlerts.ts |
Create — polling hook |
apps/mobile/app/(tabs)/home.tsx |
Add alert banner component at top of scroll view, use useHeartbeatAlerts() |
apps/mobile/app/(tabs)/settings.tsx |
Add demo trigger button (or long-press on home screen) |
Goal: Play ElevenLabs TTS audio when agent responses arrive in chat.
playBase64Audio()exists inapps/mobile/src/utils/playAudio.tsand works- Backend
/api/intakereturnsaudio_b64in responses - Backend
/api/chat/voicereturns audio as streaming response with metadata headers - Backend
/api/report/chat(panel chat) does NOT return audio — text only
For intake/onboarding voice (WS-1): Already handled — IntakeResponse.audio_b64 exists.
For panel chat voice: Two options:
Option A (Simple): After receiving panel chat text responses, make a separate TTS call:
- Add a new backend endpoint or use the existing voice.py to generate audio for a text string
- Or call ElevenLabs directly from the mobile app (simpler but requires API key in mobile)
Option B (Recommended for demo): Add audio_b64 to PanelChatResponse:
- Modify
apps/agents/src/finly_agents/server.pyinreport_chat()endpoint - After getting agent responses, pick the "advisor" response (synthesis) and run
text_to_speech()on it - Return the audio alongside the text responses
Backend change (server.py, in report_chat handler):
# After collecting agent_responses, add TTS for the advisor's response
advisor_text = next(
(r["response"] for r in agent_responses if r["agent_role"] == "advisor"),
None
)
audio_b64 = None
if advisor_text:
from finly_agents.voice import text_to_speech
import base64
audio_bytes = text_to_speech(advisor_text)
if audio_bytes:
audio_b64 = base64.b64encode(audio_bytes).decode()Mobile change: After receiving panel chat response, auto-play the advisor's audio:
if (result.data.audio_b64) {
playBase64Audio(result.data.audio_b64)
}| File | Change |
|---|---|
apps/agents/src/finly_agents/server.py |
Add audio_b64 to panel chat response |
apps/agents/src/finly_agents/models.py |
Add `audio_b64: str |
apps/mobile/src/services/api/types.ts |
Add audio_b64?: string | null to PanelChatResponse |
apps/mobile/app/thread/[id].tsx |
Call playBase64Audio() after receiving panel response |
Goal: Display the user's actual portfolio from the backend instead of hardcoded NVDA/TSLA/MSFT/AAPL.
- Home (
home.tsx:22) and Portfolio (portfolio.tsx) importholdingsfrommockAppData.ts— always 4 US stocks OnboardingCompleteScreencallsapi.importPortfolio({ user_id, mode: "mock" })which creates a backend portfolio- Mock portfolio profiles are in
apps/agents/src/finly_agents/portfolio.py— conservative (VCB/VNM), moderate (VCB/FPT/VNM), aggressive (FPT/TPB/VCB) - API client has
api.getPortfolio(userId)(line 94) but it's never called from a screen
For hackathon demo: Keep mock holdings for now but fetch portfolio from backend to replace them. The backend mock portfolio uses VN tickers (VCB, FPT, VNM, TPB) which aligns with the demo narrative.
Hook (apps/mobile/src/services/usePortfolio.ts):
export function usePortfolio(userId: string) {
const [items, setItems] = useState<PortfolioResponse["items"]>([])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
api.getPortfolio(userId).then((result) => {
if (result.kind === "ok") setItems(result.portfolio.items)
setIsLoading(false)
})
}, [userId])
return { items, isLoading }
}Map backend portfolio items to Holding type:
function portfolioItemToHolding(item: Record<string, unknown>): Holding {
return {
ticker: String(item.ticker),
name: String(item.ticker), // backend doesn't store company name
shares: Number(item.quantity),
valueUsd: Number(item.quantity) * Number(item.avg_cost),
changePercent: 0, // filled by useMarketData
allocationPercent: 0, // calculated from total
}
}Decision for demo: This is lower priority. The mock holdings work fine visually. Only wire this if you want the VN tickers to show on the home screen.
| File | Change |
|---|---|
apps/mobile/src/services/usePortfolio.ts |
Create — hook to fetch portfolio |
apps/mobile/app/(tabs)/home.tsx |
Replace holdings import with usePortfolio() hook |
apps/mobile/app/(tabs)/portfolio.tsx |
Same |
Goal: Show real agent activity on the home screen instead of mock messages.
home.tsx:22importsboardMessagesfrommockAppData.tslatestAgentMessages(line 64-71) extracts the most recent message per agent from mock data- Agent cards show these static messages
For hackathon demo: This is lowest priority. The mock messages look good. Only wire if there's time.
If wiring: After a report is generated (WS-3), store the specialist insights and use those as the "latest messages" per agent:
// After report generation succeeds:
const agentMessages = report.specialist_insights.reduce((acc, insight) => {
acc[roleToAgentName(insight.role)] = insight.summary
return acc
}, {} as Record<string, string>)
// Store in a Zustand store or context, read from Home tab| File | Change |
|---|---|
apps/mobile/app/(tabs)/home.tsx |
Replace boardMessages usage with data from report store |
apps/mobile/src/stores/boardStore.ts |
Store latest agent messages from reports |
Goal: Show interactive price charts for each holding, using real historical data from yfinance (US tickers) and mock OHLCV for VN tickers.
- Backend: yfinance is already a dependency (
pyproject.toml).y_finance.pyhasget_YFin_data_online()that fetches OHLCV history.mock_data.pyhas_generate_ohlcv()that generates 30-day mock data for VN tickers. - Current
/api/market-data(server.py:801): Only returns a single current price + change_pct per ticker. No historical data. - Mobile:
PortfolioGrowthCharton home.tsx uses hardcodedPORTFOLIO_GROWTH_POINTS(line 28) — 12 static numbers rendered as View-based line segments. No chart library installed.
File: apps/agents/src/finly_agents/server.py
Add after the existing /api/market-data endpoint (~line 827):
@app.get("/api/market-data/history")
async def market_data_history(
ticker: str = Query(...),
period: str = Query(default="1mo"), # 1d, 5d, 1mo, 3mo, 6mo, 1y, 5y
interval: str = Query(default="1d"), # 1m, 5m, 15m, 1h, 1d, 1wk
):
"""Return OHLCV history for a ticker. Uses yfinance for US stocks, mock for VN."""
import yfinance as yf
from finly_agents.mock_data import is_vn_ticker, _generate_ohlcv, _clean
clean_ticker = _clean(ticker)
if is_vn_ticker(clean_ticker):
# Map period to days
period_days = {"1d": 1, "5d": 5, "1mo": 30, "3mo": 90, "6mo": 180, "1y": 365, "5y": 1825}
days = period_days.get(period, 30)
rows = _generate_ohlcv(clean_ticker, days=days)
return {
"ticker": clean_ticker,
"currency": "VND",
"period": period,
"interval": interval,
"data": rows, # [{date, open, high, low, close, volume}, ...]
}
else:
# Real yfinance data for US tickers
try:
yf_ticker = yf.Ticker(clean_ticker)
hist = yf_ticker.history(period=period, interval=interval)
if hist.empty:
raise HTTPException(status_code=404, detail=f"No data for {clean_ticker}")
if hist.index.tz is not None:
hist.index = hist.index.tz_localize(None)
data = []
for idx, row in hist.iterrows():
data.append({
"date": idx.strftime("%Y-%m-%d") if interval in ("1d", "1wk") else idx.isoformat(),
"open": round(row["Open"], 2),
"high": round(row["High"], 2),
"low": round(row["Low"], 2),
"close": round(row["Close"], 2),
"volume": int(row["Volume"]),
})
return {
"ticker": clean_ticker,
"currency": "USD",
"period": period,
"interval": interval,
"data": data,
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))Response shape:
{
"ticker": "NVDA",
"currency": "USD",
"period": "1mo",
"interval": "1d",
"data": [
{ "date": "2026-02-21", "open": 380.5, "high": 390.2, "low": 378.1, "close": 388.9, "volume": 42000000 },
{ "date": "2026-02-22", "open": 389.0, "high": 395.0, "low": 385.3, "close": 392.1, "volume": 38000000 },
...
]
}Note on _generate_ohlcv: The existing function in mock_data.py:32-56 returns dicts with keys date, open, high, low, close, volume — already the right shape. Just make sure the function is importable (add to __init__.py or import directly).
Check that _generate_ohlcv returns date as a string (ISO format). Currently it uses datetime objects — may need:
# In _generate_ohlcv, ensure date is stringified:
rows.append({
"date": cur.strftime("%Y-%m-%d"),
"open": o, "high": h, "low": l, "close": c, "volume": vol,
})Recommended: react-native-wagmi-charts — lightweight, Expo-compatible, designed for financial data. No native module linking needed.
cd apps/mobile && pnpm add react-native-wagmi-charts react-native-reanimated react-native-gesture-handlerreact-native-reanimated and react-native-gesture-handler are likely already installed (check package.json).
Alternative: victory-native — more full-featured but heavier. Or react-native-svg + manual path drawing (no extra dep, but more work).
File: apps/mobile/src/services/api/types.ts
// Add after HeartbeatAlert interface
export interface OHLCVPoint {
date: string
open: number
high: number
low: number
close: number
volume: number
}
export interface MarketHistoryResponse {
ticker: string
currency: string
period: string
interval: string
data: OHLCVPoint[]
}File: apps/mobile/src/services/api/index.ts
async getMarketHistory(
ticker: string,
period: string = "1mo",
interval: string = "1d",
): Promise<{ kind: "ok"; history: MarketHistoryResponse } | GeneralApiProblem> {
const response: ApiResponse<MarketHistoryResponse> = await this.apisauce.get(
`/api/market-data/history`,
{ ticker, period, interval },
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
return { kind: "ok", history: response.data! }
}File: apps/mobile/src/services/useStockChart.ts (create)
import { useEffect, useState } from "react"
import { api } from "@/services/api"
import type { OHLCVPoint } from "@/services/api/types"
export function useStockChart(ticker: string, period = "1mo", interval = "1d") {
const [data, setData] = useState<OHLCVPoint[]>([])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
let active = true
setIsLoading(true)
api.getMarketHistory(ticker, period, interval).then((result) => {
if (!active) return
if (result.kind === "ok") setData(result.history.data)
setIsLoading(false)
})
return () => { active = false }
}, [ticker, period, interval])
return { data, isLoading }
}Option A: Holding detail screen (recommended — already has per-ticker context)
File: apps/mobile/app/holding/[ticker].tsx
Add a chart card between the header and the "BOARD INTAKE" section:
import { useStockChart } from "@/services/useStockChart"
import { LineChart } from "react-native-wagmi-charts" // or your chosen library
// Inside HoldingDetailRoute:
const { data: chartData, isLoading: chartLoading } = useStockChart(ticker!, "1mo")
// Render:
<View className="mt-5 rounded-[24px] bg-[#F7F9FC] p-4">
<View className="flex-row items-center justify-between mb-3">
<Text className="font-sans text-[17px] font-semibold text-[#0F1728]">
Price · 1M
</Text>
<View className="flex-row gap-2">
{["1d", "1mo", "3mo", "1y"].map((p) => (
<Pressable key={p} onPress={() => setPeriod(p)}>
<Text className={`font-sans text-[13px] ${period === p ? "text-[#2453FF] font-semibold" : "text-[#7A8699]"}`}>
{p.toUpperCase()}
</Text>
</Pressable>
))}
</View>
</View>
{chartLoading ? (
<View className="h-[160px] items-center justify-center">
<ActivityIndicator color="#2453FF" />
</View>
) : (
<LineChart.Provider data={chartData.map(d => ({ timestamp: new Date(d.date).getTime(), value: d.close }))}>
<LineChart height={160}>
<LineChart.Path color="#2453FF" width={2} />
<LineChart.CursorCrosshair color="#2453FF" />
</LineChart>
<LineChart.PriceText style={{ fontSize: 14, color: "#0F1728" }} />
<LineChart.DatetimeText style={{ fontSize: 12, color: "#7A8699" }} />
</LineChart.Provider>
)}
</View>Option B: Also replace the static PortfolioGrowthChart on home
Replace PORTFOLIO_GROWTH_POINTS (home.tsx:28) with real aggregate portfolio data. Fetch chart data for all held tickers, weight by shares, compute portfolio value per day. More complex — do only if time allows.
Allow switching between time ranges: 1D, 1W, 1M, 3M, 1Y
Map to yfinance parameters:
| Button | period |
interval |
Notes |
|---|---|---|---|
| 1D | 1d |
5m |
Intraday, 5-min candles |
| 1W | 5d |
15m |
5 days of 15-min candles |
| 1M | 1mo |
1d |
Daily close (default) |
| 3M | 3mo |
1d |
Daily close |
| 1Y | 1y |
1wk |
Weekly close |
yfinance does NOT support Vietnamese tickers natively. The mock data generator (_generate_ohlcv) handles VN tickers with deterministic random data. This is fine for the demo — the chart will show realistic-looking price action. For post-hackathon, integrate SSI or VNDirect API for real HOSE data.
| File | Change |
|---|---|
apps/agents/src/finly_agents/server.py |
Add GET /api/market-data/history endpoint |
apps/agents/src/finly_agents/mock_data.py |
Ensure _generate_ohlcv returns string dates; make it importable |
apps/mobile/src/services/api/types.ts |
Add OHLCVPoint and MarketHistoryResponse types |
apps/mobile/src/services/api/index.ts |
Add getMarketHistory() method |
apps/mobile/src/services/useStockChart.ts |
Create — hook to fetch chart data |
apps/mobile/app/holding/[ticker].tsx |
Add chart card with period selector |
apps/mobile/package.json |
Add react-native-wagmi-charts (or chosen chart lib) |
| Priority | Work Item | Effort | Demo Impact |
|---|---|---|---|
| P0 | WS-2: Wire thread chat → panel API | Small (1 file) | Core demo flow — agents respond to user questions |
| P0 | WS-3: New thread (report generation) | Medium (1-2 files) | Core demo flow — user triggers agent analysis |
| P0 | WS-4: Heartbeat alerts + trigger | Medium (4-5 files) | Demo moment — "Hormuz closes" → alerts appear |
| P1 | WS-1: Voice onboarding | Large (new screen + STT) | Impressive but complex — voice conversation |
| P1 | WS-5: Voice playback | Small (3-4 files) | Polish — agents speak their responses |
| P1 | WS-8: Live stock charts | Medium (3-4 files) | Visual wow — real price charts on holdings |
| P2 | WS-6: Portfolio from backend | Small (2-3 files) | Nice-to-have — VN tickers on home screen |
| P2 | WS-7: Live agent data on home | Small (1-2 files) | Nice-to-have — dynamic agent messages |
// User ID for all API calls during hackathon demo
const USER_ID = "user_mvp_1"
// Agent role mapping (backend → mobile)
const AGENT_ROLE_MAP = {
analyst: "market-analyst",
researcher: "researcher",
trader: "portfolio-manager",
advisor: "portfolio-manager",
} as const
// Agent name mapping (backend → mobile)
const AGENT_NAME_MAP = {
analyst: "Kai",
researcher: "Milo",
trader: "Avery",
advisor: "Avery",
} as const
// Decision mapping (backend → mobile)
const DECISION_MAP = {
BUY: "Buy",
SELL: "Sell",
HOLD: "Position",
} as const