1use rusqlite::{Connection, Result as SqlResult, Row, params, types::Type};
7use std::path::{Path, PathBuf};
8use std::sync::{Mutex, MutexGuard};
9
10mod schema;
11#[cfg(test)]
12pub(crate) use schema::{CURRENT_SCHEMA_VERSION, write_user_version};
13pub(crate) use schema::{migrate_schema, read_user_version};
14
15fn db_path() -> PathBuf {
17 let home = std::env::var("HOME")
18 .or_else(|_| std::env::var("USERPROFILE"))
19 .unwrap_or_else(|_| ".".to_string());
20 let mut path = PathBuf::from(home);
21 path.push(".futu-opend-rs");
22 path.push("stock_data.db");
23 path
24}
25
26#[derive(Debug, Clone)]
28pub struct DbStockItem {
29 pub stock_id: u64,
30 pub sequence: u64,
32 pub code: String,
33 pub name_sc: String,
34 pub name_tc: String,
36 pub name_en: String,
38 pub name_jp: String,
40 pub market_code: u32,
41 pub instrument_type: u32,
42 pub spread_table_code: u32,
47 pub sub_instrument_type_v2: u32,
52 pub lot_size: u32,
53 pub delisting: bool,
54 pub warrnt_stock_owner: u64,
55 pub warrant_type: u32,
58 pub no_search: bool,
59 pub listing_date: u32,
60 pub future_origin_id: u64,
63 pub zhuli_id: u64,
66 pub cc_origin: String,
68 pub cc_destination: String,
70 pub exchange: String,
72 pub listed_exchange: String,
74}
75
76pub struct StockDb {
78 conn: Mutex<Connection>,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct StockListChecksumV2 {
83 pub id_low: u64,
84 pub id_high: u64,
85 pub seq_low: u64,
86 pub seq_high: u64,
87 pub stock_count: u64,
88}
89
90unsafe impl Send for StockDb {}
118unsafe impl Sync for StockDb {}
119
120impl StockDb {
121 #[cfg(test)]
122 pub(crate) fn open_in_memory_for_test() -> SqlResult<Self> {
123 let conn = Connection::open_in_memory()?;
124 conn.execute_batch(
125 "CREATE TABLE IF NOT EXISTS config (
126 key TEXT PRIMARY KEY,
127 value TEXT NOT NULL
128 );
129 CREATE TABLE IF NOT EXISTS securities (
130 stock_id INTEGER PRIMARY KEY,
131 sequence INTEGER NOT NULL DEFAULT 0,
132 code TEXT NOT NULL,
133 name_sc TEXT NOT NULL DEFAULT '',
134 name_tc TEXT NOT NULL DEFAULT '',
135 name_en TEXT NOT NULL DEFAULT '',
136 name_jp TEXT NOT NULL DEFAULT '',
137 market_code INTEGER NOT NULL DEFAULT 0,
138 instrument_type INTEGER NOT NULL DEFAULT 0,
139 spread_table_code INTEGER NOT NULL DEFAULT 0,
140 sub_instrument_type_v2 INTEGER NOT NULL DEFAULT 0,
141 lot_size INTEGER NOT NULL DEFAULT 0,
142 delisting INTEGER NOT NULL DEFAULT 0,
143 warrnt_stock_owner INTEGER NOT NULL DEFAULT 0,
144 warrant_type INTEGER NOT NULL DEFAULT 0,
145 no_search INTEGER NOT NULL DEFAULT 0,
146 listing_date INTEGER NOT NULL DEFAULT 0,
147 future_origin_id INTEGER NOT NULL DEFAULT 0,
148 zhuli_id INTEGER NOT NULL DEFAULT 0,
149 cc_origin TEXT NOT NULL DEFAULT '',
150 cc_destination TEXT NOT NULL DEFAULT '',
151 exchange TEXT NOT NULL DEFAULT '',
152 listed_exchange TEXT NOT NULL DEFAULT ''
153 );",
154 )?;
155 migrate_schema(&conn)?;
156 Ok(Self {
157 conn: Mutex::new(conn),
158 })
159 }
160
161 fn lock_conn(&self) -> SqlResult<MutexGuard<'_, Connection>> {
162 self.conn.lock().map_err(|_| rusqlite::Error::InvalidQuery)
163 }
164
165 fn ensure_parent_dir(parent: &std::path::Path) {
166 if let Err(err) = std::fs::create_dir_all(parent) {
167 tracing::warn!(
168 path = %parent.display(),
169 error = %err,
170 "stock db directory creation failed; sqlite open may fail"
171 );
172 }
173 }
174
175 fn apply_legacy_column(conn: &Connection, column: &'static str, sql: &'static str) {
176 match conn.execute_batch(sql) {
177 Ok(()) => {}
178 Err(err) if is_duplicate_column_error(&err) => {
179 tracing::debug!(column, "legacy stock db column already exists");
180 }
181 Err(err) => {
182 tracing::warn!(
183 column,
184 error = %err,
185 "legacy stock db schema migration failed"
186 );
187 }
188 }
189 }
190
191 pub fn open() -> SqlResult<Self> {
193 Self::open_at(db_path())
194 }
195
196 pub fn open_at(path: impl AsRef<Path>) -> SqlResult<Self> {
201 let path = path.as_ref();
202 if let Some(parent) = path.parent() {
203 Self::ensure_parent_dir(parent);
204 }
205 let conn = Connection::open(path)?;
206 conn.execute_batch(
207 "PRAGMA journal_mode=WAL;
208 PRAGMA synchronous=NORMAL;
209 PRAGMA cache_size=-8000;",
210 )?;
211 conn.execute_batch(
212 "CREATE TABLE IF NOT EXISTS config (
213 key TEXT PRIMARY KEY,
214 value TEXT NOT NULL
215 );
216 CREATE TABLE IF NOT EXISTS securities (
217 stock_id INTEGER PRIMARY KEY,
218 sequence INTEGER NOT NULL DEFAULT 0,
219 code TEXT NOT NULL,
220 name_sc TEXT NOT NULL DEFAULT '',
221 name_tc TEXT NOT NULL DEFAULT '',
222 name_en TEXT NOT NULL DEFAULT '',
223 name_jp TEXT NOT NULL DEFAULT '',
224 market_code INTEGER NOT NULL DEFAULT 0,
225 instrument_type INTEGER NOT NULL DEFAULT 0,
226 spread_table_code INTEGER NOT NULL DEFAULT 0,
227 sub_instrument_type_v2 INTEGER NOT NULL DEFAULT 0,
228 lot_size INTEGER NOT NULL DEFAULT 0,
229 delisting INTEGER NOT NULL DEFAULT 0,
230 warrnt_stock_owner INTEGER NOT NULL DEFAULT 0,
231 warrant_type INTEGER NOT NULL DEFAULT 0,
232 no_search INTEGER NOT NULL DEFAULT 0,
233 listing_date INTEGER NOT NULL DEFAULT 0,
234 future_origin_id INTEGER NOT NULL DEFAULT 0,
235 zhuli_id INTEGER NOT NULL DEFAULT 0,
236 cc_origin TEXT NOT NULL DEFAULT '',
237 cc_destination TEXT NOT NULL DEFAULT '',
238 exchange TEXT NOT NULL DEFAULT '',
239 listed_exchange TEXT NOT NULL DEFAULT ''
240 );",
241 )?;
242 Self::apply_legacy_column(
244 &conn,
245 "listing_date",
246 "ALTER TABLE securities ADD COLUMN listing_date INTEGER NOT NULL DEFAULT 0;",
247 );
248 Self::apply_legacy_column(
251 &conn,
252 "name_tc",
253 "ALTER TABLE securities ADD COLUMN name_tc TEXT NOT NULL DEFAULT '';",
254 );
255 Self::apply_legacy_column(
256 &conn,
257 "name_en",
258 "ALTER TABLE securities ADD COLUMN name_en TEXT NOT NULL DEFAULT '';",
259 );
260 Self::apply_legacy_column(
264 &conn,
265 "name_jp",
266 "ALTER TABLE securities ADD COLUMN name_jp TEXT NOT NULL DEFAULT '';",
267 );
268 Self::apply_legacy_column(
271 &conn,
272 "future_origin_id",
273 "ALTER TABLE securities ADD COLUMN future_origin_id INTEGER NOT NULL DEFAULT 0;",
274 );
275 Self::apply_legacy_column(
276 &conn,
277 "zhuli_id",
278 "ALTER TABLE securities ADD COLUMN zhuli_id INTEGER NOT NULL DEFAULT 0;",
279 );
280 Self::apply_legacy_column(
281 &conn,
282 "cc_origin",
283 "ALTER TABLE securities ADD COLUMN cc_origin TEXT NOT NULL DEFAULT '';",
284 );
285 Self::apply_legacy_column(
286 &conn,
287 "cc_destination",
288 "ALTER TABLE securities ADD COLUMN cc_destination TEXT NOT NULL DEFAULT '';",
289 );
290 Self::apply_legacy_column(
293 &conn,
294 "exchange",
295 "ALTER TABLE securities ADD COLUMN exchange TEXT NOT NULL DEFAULT '';",
296 );
297 Self::apply_legacy_column(
298 &conn,
299 "listed_exchange",
300 "ALTER TABLE securities ADD COLUMN listed_exchange TEXT NOT NULL DEFAULT '';",
301 );
302 Self::apply_legacy_column(
303 &conn,
304 "sub_instrument_type_v2",
305 "ALTER TABLE securities ADD COLUMN sub_instrument_type_v2 INTEGER NOT NULL DEFAULT 0;",
306 );
307 Self::apply_legacy_column(
308 &conn,
309 "spread_table_code",
310 "ALTER TABLE securities ADD COLUMN spread_table_code INTEGER NOT NULL DEFAULT 0;",
311 );
312 Self::apply_legacy_column(
313 &conn,
314 "sequence",
315 "ALTER TABLE securities ADD COLUMN sequence INTEGER NOT NULL DEFAULT 0;",
316 );
317 Self::apply_legacy_column(
318 &conn,
319 "warrant_type",
320 "ALTER TABLE securities ADD COLUMN warrant_type INTEGER NOT NULL DEFAULT 0;",
321 );
322
323 migrate_schema(&conn)?;
327
328 Ok(Self {
329 conn: Mutex::new(conn),
330 })
331 }
332
333 pub fn get_version(&self) -> u64 {
335 let Ok(conn) = self.lock_conn() else {
336 tracing::warn!(
337 "stock db stock_list_version read failed because connection mutex is poisoned; \
338 falling back to full stock-list sync"
339 );
340 return 0;
341 };
342 let raw = match conn.query_row(
343 "SELECT value FROM config WHERE key = 'stock_list_version'",
344 [],
345 |row| row.get::<_, String>(0),
346 ) {
347 Ok(raw) => raw,
348 Err(rusqlite::Error::QueryReturnedNoRows) => return 0,
349 Err(err) => {
350 tracing::warn!(
351 error = %err,
352 "stock db stock_list_version read failed; falling back to full stock-list sync"
353 );
354 return 0;
355 }
356 };
357 match raw.parse::<u64>() {
358 Ok(version) => version,
359 Err(err) => {
360 tracing::warn!(
361 value = %raw,
362 error = %err,
363 "stock db stock_list_version parse failed; falling back to full stock-list sync"
364 );
365 0
366 }
367 }
368 }
369
370 pub fn set_version(&self, version: u64) -> SqlResult<()> {
372 let conn = self.lock_conn()?;
373 conn.execute(
374 "INSERT OR REPLACE INTO config (key, value) VALUES ('stock_list_version', ?1)",
375 params![version.to_string()],
376 )?;
377 Ok(())
378 }
379
380 pub fn load_all(&self) -> SqlResult<Vec<DbStockItem>> {
382 let conn = self.lock_conn()?;
383 let mut stmt = conn.prepare(
384 "SELECT stock_id, code, name_sc, name_tc, name_en, name_jp, market_code, instrument_type,
385 spread_table_code, sub_instrument_type_v2, lot_size,
386 delisting, warrnt_stock_owner, warrant_type, no_search, listing_date,
387 future_origin_id, zhuli_id, cc_origin, cc_destination,
388 exchange, listed_exchange, sequence
389 FROM securities",
390 )?;
391 let rows = stmt.query_map([], db_stock_item_from_row)?;
392
393 let mut items = Vec::new();
394 let mut skipped_rows = 0usize;
395 for (row_index, row) in rows.enumerate() {
396 match row {
397 Ok(item) => items.push(item),
398 Err(err) => {
399 skipped_rows += 1;
400 if skipped_rows <= 5 {
401 tracing::warn!(
402 row_index,
403 error = %err,
404 "stock db load_all skipped malformed cached row"
405 );
406 }
407 }
408 }
409 }
410 if skipped_rows > 0 {
411 tracing::warn!(
412 skipped_rows,
413 loaded_rows = items.len(),
414 "stock db load_all completed with skipped malformed rows"
415 );
416 }
417 Ok(items)
418 }
419
420 pub fn load_by_code(&self, code: &str) -> SqlResult<Vec<DbStockItem>> {
425 let conn = self.lock_conn()?;
426 let mut stmt = conn.prepare_cached(
427 "SELECT stock_id, code, name_sc, name_tc, name_en, name_jp, market_code, instrument_type,
428 spread_table_code, sub_instrument_type_v2, lot_size,
429 delisting, warrnt_stock_owner, warrant_type, no_search, listing_date,
430 future_origin_id, zhuli_id, cc_origin, cc_destination,
431 exchange, listed_exchange, sequence
432 FROM securities
433 WHERE code = ?1",
434 )?;
435 let rows = stmt.query_map(params![code], db_stock_item_from_row)?;
436 let mut items = Vec::new();
437 let mut skipped_rows = 0usize;
438 for (row_index, row) in rows.enumerate() {
439 match row {
440 Ok(item) => items.push(item),
441 Err(err) => {
442 skipped_rows += 1;
443 if skipped_rows <= 5 {
444 tracing::warn!(
445 row_index,
446 code,
447 error = %err,
448 "stock db load_by_code skipped malformed cached row"
449 );
450 }
451 }
452 }
453 }
454 if skipped_rows > 0 {
455 tracing::warn!(
456 skipped_rows,
457 loaded_rows = items.len(),
458 code,
459 "stock db load_by_code completed with skipped malformed rows"
460 );
461 }
462 Ok(items)
463 }
464
465 pub fn load_warrants_by_owner(&self, owner_stock_id: u64) -> SqlResult<Vec<DbStockItem>> {
474 let conn = self.lock_conn()?;
475 let mut stmt = conn.prepare_cached(
476 "SELECT stock_id, code, name_sc, name_tc, name_en, name_jp, market_code, instrument_type,
477 spread_table_code, sub_instrument_type_v2, lot_size,
478 delisting, warrnt_stock_owner, warrant_type, no_search, listing_date,
479 future_origin_id, zhuli_id, cc_origin, cc_destination,
480 exchange, listed_exchange, sequence
481 FROM securities
482 WHERE warrnt_stock_owner = ?1",
483 )?;
484 let rows = stmt.query_map(params![owner_stock_id as i64], db_stock_item_from_row)?;
485 let mut items = Vec::new();
486 let mut skipped_rows = 0usize;
487 for (row_index, row) in rows.enumerate() {
488 match row {
489 Ok(item) => items.push(item),
490 Err(err) => {
491 skipped_rows += 1;
492 if skipped_rows <= 5 {
493 tracing::warn!(
494 row_index,
495 owner_stock_id,
496 error = %err,
497 "stock db load_warrants_by_owner skipped malformed cached row"
498 );
499 }
500 }
501 }
502 }
503 if skipped_rows > 0 {
504 tracing::warn!(
505 skipped_rows,
506 loaded_rows = items.len(),
507 owner_stock_id,
508 "stock db load_warrants_by_owner completed with skipped malformed rows"
509 );
510 }
511 Ok(items)
512 }
513
514 pub fn begin_batch(&self) -> SqlResult<()> {
516 let conn = self.lock_conn()?;
517 conn.execute_batch("BEGIN TRANSACTION")?;
518 Ok(())
519 }
520
521 pub fn commit_batch(&self) -> SqlResult<()> {
523 let conn = self.lock_conn()?;
524 conn.execute_batch("COMMIT")?;
525 Ok(())
526 }
527
528 pub fn rollback_batch_if_open(&self) -> SqlResult<()> {
534 let conn = self.lock_conn()?;
535 conn.execute_batch("ROLLBACK")?;
536 Ok(())
537 }
538
539 pub fn upsert(&self, item: &DbStockItem) -> SqlResult<()> {
541 let conn = self.lock_conn()?;
542 let mut stmt = conn.prepare_cached(
543 "INSERT OR REPLACE INTO securities
544 (stock_id, sequence, code, name_sc, name_tc, name_en, name_jp, market_code, instrument_type,
545 spread_table_code, lot_size,
546 delisting, warrnt_stock_owner, warrant_type, no_search, listing_date,
547 future_origin_id, zhuli_id, cc_origin, cc_destination,
548 exchange, listed_exchange, sub_instrument_type_v2)
549 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23)",
550 )?;
551 stmt.execute(params![
552 item.stock_id as i64,
553 item.sequence as i64,
554 &item.code,
555 &item.name_sc,
556 &item.name_tc,
557 &item.name_en,
558 &item.name_jp,
559 item.market_code as i32,
560 item.instrument_type as i32,
561 item.spread_table_code as i32,
562 item.lot_size as i32,
563 item.delisting as i32,
564 item.warrnt_stock_owner as i64,
565 item.warrant_type as i32,
566 item.no_search as i32,
567 item.listing_date as i32,
568 item.future_origin_id as i64,
570 item.zhuli_id as i64,
571 &item.cc_origin,
572 &item.cc_destination,
573 &item.exchange,
575 &item.listed_exchange,
576 item.sub_instrument_type_v2 as i32,
578 ])?;
579 Ok(())
580 }
581
582 pub fn delete(&self, stock_id: u64) -> SqlResult<()> {
584 let conn = self.lock_conn()?;
585 let mut stmt = conn.prepare_cached("DELETE FROM securities WHERE stock_id = ?1")?;
586 stmt.execute(params![stock_id as i64])?;
587 Ok(())
588 }
589
590 pub fn clear_all_securities(&self) -> SqlResult<()> {
595 let conn = self.lock_conn()?;
596 conn.execute("DELETE FROM securities", [])?;
597 Ok(())
598 }
599
600 pub fn load_stock_ids(&self) -> SqlResult<Vec<u64>> {
605 let conn = self.lock_conn()?;
606 let mut stmt = conn.prepare("SELECT stock_id FROM securities")?;
607 let rows = stmt.query_map([], |row| row_u64(row, 0, "stock_id"))?;
608 let mut ids = Vec::new();
609 for row in rows {
610 ids.push(row?);
611 }
612 Ok(ids)
613 }
614
615 pub fn count(&self) -> u64 {
617 let Ok(conn) = self.lock_conn() else {
618 tracing::warn!(
619 "stock db count read failed because connection mutex is poisoned; returning 0"
620 );
621 return 0;
622 };
623 match conn.query_row("SELECT COUNT(*) FROM securities", [], |row| {
624 row.get::<_, i64>(0)
625 }) {
626 Ok(count) if count >= 0 => count as u64,
627 Ok(count) => {
628 tracing::warn!(
629 count,
630 "stock db count read returned negative value; returning 0"
631 );
632 0
633 }
634 Err(err) => {
635 tracing::warn!(error = %err, "stock db count read failed; returning 0");
636 0
637 }
638 }
639 }
640
641 pub fn calc_checksum_v2(&self) -> SqlResult<StockListChecksumV2> {
644 let conn = self.lock_conn()?;
645 let mut stmt = conn.prepare("SELECT stock_id, sequence FROM securities")?;
646 let rows = stmt.query_map([], |row| {
647 Ok((row_u64(row, 0, "stock_id")?, row_u64(row, 1, "sequence")?))
648 })?;
649 let mut checksum = StockListChecksumV2 {
650 id_low: 0,
651 id_high: 0,
652 seq_low: 0,
653 seq_high: 0,
654 stock_count: 0,
655 };
656 for row in rows {
657 let (stock_id, sequence) = row?;
658 checksum.id_low = checksum.id_low.wrapping_add(stock_id & 0xFFFF_FFFF);
659 checksum.id_high = checksum.id_high.wrapping_add(stock_id >> 32);
660 checksum.seq_low = checksum.seq_low.wrapping_add(sequence & 0xFFFF_FFFF);
661 checksum.seq_high = checksum.seq_high.wrapping_add(sequence >> 32);
662 checksum.stock_count = checksum.stock_count.wrapping_add(1);
663 }
664 Ok(checksum)
665 }
666
667 pub fn schema_version(&self) -> u32 {
669 let Ok(conn) = self.lock_conn() else {
670 tracing::warn!(
671 "stock db schema version read failed because connection mutex is poisoned; \
672 returning 0"
673 );
674 return 0;
675 };
676 match read_user_version(&conn) {
677 Ok(version) => version,
678 Err(err) => {
679 tracing::warn!(error = %err, "stock db schema version read failed; returning 0");
680 0
681 }
682 }
683 }
684}
685
686fn db_stock_item_from_row(row: &Row<'_>) -> SqlResult<DbStockItem> {
687 Ok(DbStockItem {
688 stock_id: row_u64(row, 0, "stock_id")?,
689 sequence: row_u64(row, 22, "sequence")?,
690 code: row.get(1)?,
691 name_sc: row.get(2)?,
692 name_tc: row.get(3)?,
693 name_en: row.get(4)?,
694 name_jp: row.get(5)?,
695 market_code: row_u32(row, 6, "market_code")?,
696 instrument_type: row_u32(row, 7, "instrument_type")?,
697 spread_table_code: row_u32(row, 8, "spread_table_code")?,
698 sub_instrument_type_v2: row_u32(row, 9, "sub_instrument_type_v2")?,
699 lot_size: row_u32(row, 10, "lot_size")?,
700 delisting: row.get::<_, i32>(11)? != 0,
701 warrnt_stock_owner: row_u64(row, 12, "warrnt_stock_owner")?,
702 warrant_type: row_u32(row, 13, "warrant_type")?,
703 no_search: row.get::<_, i32>(14)? != 0,
704 listing_date: row_u32(row, 15, "listing_date")?,
705 future_origin_id: row_u64(row, 16, "future_origin_id")?,
707 zhuli_id: row_u64(row, 17, "zhuli_id")?,
708 cc_origin: row.get(18)?,
709 cc_destination: row.get(19)?,
710 exchange: row.get(20)?,
712 listed_exchange: row.get(21)?,
713 })
714}
715
716fn row_u64(row: &Row<'_>, idx: usize, field: &'static str) -> SqlResult<u64> {
717 let value = row.get::<_, i64>(idx)?;
718 u64::try_from(value).map_err(|err| {
719 rusqlite::Error::FromSqlConversionFailure(
720 idx,
721 Type::Integer,
722 Box::new(std::io::Error::new(
723 std::io::ErrorKind::InvalidData,
724 format!("stock db field {field} must be non-negative: {err}"),
725 )),
726 )
727 })
728}
729
730fn row_u32(row: &Row<'_>, idx: usize, field: &'static str) -> SqlResult<u32> {
731 let value = row.get::<_, i32>(idx)?;
732 u32::try_from(value).map_err(|err| {
733 rusqlite::Error::FromSqlConversionFailure(
734 idx,
735 Type::Integer,
736 Box::new(std::io::Error::new(
737 std::io::ErrorKind::InvalidData,
738 format!("stock db field {field} must be non-negative: {err}"),
739 )),
740 )
741 })
742}
743
744fn is_duplicate_column_error(err: &rusqlite::Error) -> bool {
745 err.to_string().contains("duplicate column name")
746}
747
748#[cfg(test)]
749mod tests;