Refactor project structure and enhance stock watchlist functionality
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:capstone_project/utils/agrigator.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
|
||||
const _baseUrl = 'http://duriin.imbenji.net';
|
||||
|
||||
|
||||
// pair of (article, semantic distance from the query / seed). distance is
|
||||
// null when the underlying endpoint doesnt return one.
|
||||
class SimilarHit {
|
||||
final FeedItem item;
|
||||
final double? distance;
|
||||
|
||||
SimilarHit({required this.item, this.distance});
|
||||
}
|
||||
|
||||
|
||||
class DuriinService {
|
||||
|
||||
Future<List<FeedItem>> search({
|
||||
required String ticker,
|
||||
required String companyName,
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
int limit = 100,
|
||||
}) async {
|
||||
final fromStr = from.toUtc().toIso8601String();
|
||||
final toStr = to.toUtc().toIso8601String();
|
||||
|
||||
|
||||
// build the keyword set: the ticker itself plus the company brand name
|
||||
// (first token of the registered name — "NVIDIA Corporation" -> "NVIDIA",
|
||||
// "Apple Inc" -> "Apple"). the full legal name rarely appears verbatim in
|
||||
// articles so matching on it would miss most hits.
|
||||
final keywords = <String>{};
|
||||
final t = ticker.trim();
|
||||
if (t.isNotEmpty) keywords.add(t);
|
||||
|
||||
final brand = companyName.trim().split(RegExp(r"\s+")).firstOrNull ?? "";
|
||||
if (brand.isNotEmpty) keywords.add(brand);
|
||||
|
||||
final hits = await _fetchHits(queryParams: {
|
||||
'keyword': keywords.toList(),
|
||||
'keyword_mode': 'or',
|
||||
'from': fromStr,
|
||||
'to': toStr,
|
||||
'limit': '$limit',
|
||||
'order': 'newest',
|
||||
});
|
||||
|
||||
final items = hits.map((h) => h.item).toList();
|
||||
|
||||
print('Duriin: ${items.length} articles for $ticker / $companyName (kw=${keywords.join(",")})');
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
// neighbours of a given article via the vector index. used by the event
|
||||
// clusterer to build real clusters instead of arbitrary time slices.
|
||||
Future<List<SimilarHit>> findSimilar(int articleId, {int limit = 25}) async {
|
||||
return _fetchHits(queryParams: {
|
||||
'similar_to_article': '$articleId',
|
||||
'limit': '$limit',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Future<List<SimilarHit>> _fetchHits({required Map<String, dynamic> queryParams}) async {
|
||||
final uri = Uri.parse(_baseUrl).replace(
|
||||
path: '/articles',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
try {
|
||||
final response = await http.get(uri);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
print('Duriin: HTTP ${response.statusCode} for $uri');
|
||||
return [];
|
||||
}
|
||||
|
||||
final body = response.body.trim();
|
||||
if (body.isEmpty) return [];
|
||||
|
||||
final list = jsonDecode(body) as List<dynamic>;
|
||||
|
||||
return list.map((raw) {
|
||||
final m = raw as Map<String, dynamic>;
|
||||
|
||||
final id = m['id'] is int
|
||||
? m['id'] as int
|
||||
: int.tryParse('${m['id']}');
|
||||
|
||||
final item = FeedItem(
|
||||
id: id,
|
||||
title: (m['title'] ?? '').toString(),
|
||||
description: (m['description'] ?? '').toString(),
|
||||
content: (m['content'] ?? '').toString(),
|
||||
link: (m['url'] ?? '').toString(),
|
||||
source: (m['source'] ?? '').toString().isNotEmpty ? m['source'].toString() : null,
|
||||
pubDate: m['pub_date'] != null ? DateTime.tryParse(m['pub_date'].toString()) : null,
|
||||
);
|
||||
|
||||
final distRaw = m['distance'];
|
||||
final distance = distRaw is num ? distRaw.toDouble() : null;
|
||||
|
||||
return SimilarHit(item: item, distance: distance);
|
||||
}).where((h) => h.item.title.isNotEmpty && h.item.link.isNotEmpty).toList();
|
||||
|
||||
} catch (e) {
|
||||
print('Duriin fetch error: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:capstone_project/services/duriin_service.dart';
|
||||
import 'package:capstone_project/utils/agrigator.dart';
|
||||
|
||||
|
||||
class NewsSearchService {
|
||||
final _duriin = DuriinService();
|
||||
|
||||
Future<List<FeedItem>> search({
|
||||
required String ticker,
|
||||
required String companyName,
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
}) => _duriin.search(
|
||||
ticker: ticker,
|
||||
companyName: companyName,
|
||||
from: from,
|
||||
to: to,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class StockPricePoint {
|
||||
final DateTime date;
|
||||
final double close;
|
||||
|
||||
const StockPricePoint({required this.date, required this.close});
|
||||
}
|
||||
|
||||
class StockPriceService {
|
||||
Future<List<StockPricePoint>> fetchPriceHistory(
|
||||
String ticker, {
|
||||
String range = '1mo',
|
||||
String interval = '1d',
|
||||
}) async {
|
||||
final normalizedTicker = ticker.trim().toUpperCase();
|
||||
final uri = Uri.parse(
|
||||
'https://query1.finance.yahoo.com/v8/finance/chart/$normalizedTicker?interval=$interval&range=$range',
|
||||
);
|
||||
|
||||
final response = await http.get(uri, headers: {
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
'Accept': 'application/json',
|
||||
});
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to fetch price history for $normalizedTicker: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final chart = json['chart'] as Map<String, dynamic>?;
|
||||
final results = chart?['result'] as List?;
|
||||
|
||||
if (results == null || results.isEmpty) {
|
||||
throw Exception('No price history returned for $normalizedTicker');
|
||||
}
|
||||
|
||||
final result = results.first as Map<String, dynamic>;
|
||||
final timestamps = (result['timestamp'] as List?) ?? const [];
|
||||
final indicators = result['indicators'] as Map<String, dynamic>?;
|
||||
final adjCloseSeries = ((indicators?['adjclose'] as List?) ?? const [])
|
||||
.cast<Map<String, dynamic>?>();
|
||||
final quoteSeries = ((indicators?['quote'] as List?) ?? const [])
|
||||
.cast<Map<String, dynamic>?>();
|
||||
|
||||
final adjCloseEntry = adjCloseSeries.isNotEmpty ? adjCloseSeries.first : null;
|
||||
final quoteEntry = quoteSeries.isNotEmpty ? quoteSeries.first : null;
|
||||
final adjCloseValues = adjCloseEntry == null ? null : adjCloseEntry['adjclose'];
|
||||
final quoteCloseValues = quoteEntry == null ? null : quoteEntry['close'];
|
||||
final closes = (adjCloseValues as List?) ?? (quoteCloseValues as List?) ?? const [];
|
||||
|
||||
final points = <StockPricePoint>[];
|
||||
final itemCount = timestamps.length < closes.length ? timestamps.length : closes.length;
|
||||
|
||||
for (int i = 0; i < itemCount; i++) {
|
||||
final timestamp = timestamps[i];
|
||||
final close = closes[i];
|
||||
|
||||
if (timestamp is! num || close is! num) {
|
||||
continue;
|
||||
}
|
||||
|
||||
points.add(
|
||||
StockPricePoint(
|
||||
date: DateTime.fromMillisecondsSinceEpoch(
|
||||
timestamp.toInt() * 1000,
|
||||
isUtc: true,
|
||||
).toLocal(),
|
||||
close: close.toDouble(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (points.isEmpty) {
|
||||
throw Exception('No valid price points returned for $normalizedTicker');
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user